1. 相关背景
用户画像建模其实就是对用户“打标签”,标签一般分为三类:1.统计类标签,如性别、年龄、近7日活跃时长等可以从此用户注册数据、用户访问、消费数据中统计得出;2.规则类标签,该类标签基于用户行为及确定的规则产生,如“消费活跃”用户定义为“近30天交易次数≥2”;3.机器学习挖掘类标签,对用户的某些属性或行为进行预测判断。
用户画像方案整体:
注意:
第三阶段——需求场景讨论与明确:输出产品用户画像需求文档,在该文档中明确画像应用场景、最终开发出的标签内容与应用方式,并就该文档与需求方反复沟通并确认无误。
第四阶段——应用场景与数据口径确认:数据运营方需要输出产品用户画像开发文档,该文档需要明确应用场景、标签开发的模型、涉及的数据库与表以及应用实施流程。该文档不需要与运营方讨论,只需面向数据团队内部就开发实施流程达成一致。
2. 案例
基于spark计算引擎,涉及HiveQL、Python、Scala、Shell。
2.1 相关元数据
- 业务类数据:平台上下单、购买、收藏、配送等;
- 用户行为数据:搜索、访问、点击、提交表单等通过操作行为产生(在解析日志的埋点表中)的数据。
涉及表:
- 用户信息表(字段、字段类型、字段定义、备注)
- 商品订单表
- 埋点日志表
- 访问日志表
- 商品评论表
- 搜索日志表
- 用户收藏表
- 购物车信息表
2.2 画像表结构设计
两种设计思路:一是每日全量数据的表结构;二是每日增量数据的表结构。
日增量数据可视为ODS层的用户行为画像,在应用时还需基于增量数据做进一步的建模加工。
- 日全量数据
在每天对应的日期分区中插入截止到当天为止的全量数据。
CREATE TABLE 'dw.userprofile_attritube_all' (
'userid' STRING COMMENT 'userid',
'labelweight' STRING COMMENT '标签权重',)
COMMENT 'userid 用户画像数据'
PARTITIONED BY ('data_date' STRING COMMENT '数据日期', 'theme' STRING COMMENT '二级主题', 'labelid' STRING COMMENT '标签id')
//设置三个分区字段便于开发和查询数据
//标签权重仅考虑统计类型标签的权重
//表名加'_all'的规范化命名形式说明为日全量表
//对于主题类型为“会员”的标签,插入“20190101”日的全量数据
insert overwrite table dw.userprofile_userlabel_all partition(
data_date = '20190101',
theme = 'member',
labelid = 'ATTRITUBE_U_05_001')
//查询截止到“20190101”日的被打上会员标签的用户量
select count(distinct userid) from dw.userprofile_userlabel_all where data_date='20190101'
- 日增量数据
在每天的日期分区中插入当天业务运行产生的数据,用户进行查询时通过限制查询的日期范围,就可以找出特定时间范围内被打上特定标签的用户。
CREATE TABLE dw.userprofile_act_feature_append(//日增量表
labelid STRING COMMENT '标签ID',
cookieid STRING COMMENT '用户ID',
act_cnt int COMMENT '行为次数',
tag_type_id int COMMENT '标签类型编码',//母婴、数码
act_type_id int COMMENT '行为类型编码')//浏览、搜索、收藏、下单
COMMENT '用户画像-用户行为标签表'
PARTITIONED BY (data_date STRING COMMENT '数据日期')
//对于某用户ID为001,查询其在“20180701”到“20180707”日被打上的标签
select * from dw.userprofile_act_feature_append where userid='001' and data_date>='20180701' and data_date<='20180707'
关于宽表的设计
用户属性宽表设计,主要记录用户基本属性信息。
用户日活跃宽表设计,主要记录用户每天访问的信息。
2.3 定性类画像
多见于用户研究等运营类岗位,通过电话调研、网络调研问卷、当面深入访谈、网上第三方权威数据等方式收集用户信息。
2.4 数据指标体系
结合企业的业务情况设定相关的指标。互联网相关企业在建立用户画像时一般除了基于用户维度(userid)建立一套用户标签体系外,还会基于用户使用设备维度(cookieid)建立的相应的标签体系。
2.4.1 用户属性维度
对于相同的一级标签类型,需要判断多个标签之间的关系为互斥关系还是非互斥关系。
2.4.2 用户行为维度
2.4.3 用户消费维度
对于用户消费维度指标体系的建设,可从用户浏览、加购、下单、收藏、搜索商品对应的品类入手,品类越细越精确,给用户推荐或营销商品的准确性越高。
2.4.4 风险控制维度
为有效监控平台的不良用户,如薅羊毛、恶意刷单、借贷欺诈等行为的用户,建立风险控制维度。可从账号风险、设备风险、借贷风险等维度入手。
2.4.5 社交属性维度
2.4.6 总结
标签命名:
标签主题_刻画维度_标签类型_一级归类
ATTRITUBE_U_ol_001
标签主题 | 用户维度 | 标签类型 | 一级归类 |
---|---|---|---|
ATTRITUBE:人口属性;ACTION:行为属性;CONSUME:用户消费;RISKMANAGE:风险控制 | C:cookieid;U:userid | 统计型;规则型;算法型; | 自然性别;购物性别;年龄;地域 |
2.5 标签数据存储
2.5.1 Hive数据仓库
数据仓库建模:
- 事实表
- 事务事实表
- 单事务事实表
- 多事务事实表:当不同业务过程有相似性时考虑将多业务过程放到一张表中,新增字段判断属于哪个业务过程。
- 周期快照事实表:在一个确定的时间间隔内对业务状态进行度量。例如查看一个用户近一年付款金额。
- 累计快照事实表:用于查看不同事件之间的时间间隔。例如分析用户从购买到支付的时长,一般适用于有明确时间周期的业务过程。
- 事务事实表
- 维度表
- 缓慢变化维
- 重写维度值
- 保留多条记录
- 开发日期分区表,每日分区数据记录当日维度的属性
- 开发拉链表按时间变化进行全量存储
- 缓慢变化维
分区存储:
解决ETL花费时间较长:
- 将数据分区存储,分别执行作业;
- 标签脚本性能调优;
- 基于一些标签共同的数据来源开发中间表。
标签汇聚:
由于用户的全部标签存储在不同的分区下面,需要将用户身上的标签做聚合处理。
CREATE TABLE 'dw.userprofile_userlabel_map_all'(
'userid' STRING COMMENT 'userid',
'userlabels' map<STRING,STRING> COMMENT 'tagsmap',)
COMMENT 'userid用户标签汇聚'
PARTITIONED BY ('data_date' STRING COMMENT '数据日期')
//
insert overwrite table dw.userprofile_userlabel_map_all partion(data_date="data_date")
select userid,cast_to_json(concat_ws(",",collect_set(concat(labelid,":",labelweight)))) as userlabels from "用户各维度的标签表"
where data_date="data_date"
group by userid
ID-MAP:
ID-Mapping,即把用户不同来源的身份标识通过数据手段识别为同一个主体,消除数据孤岛,打通userid和cookieid的对应关系,可以在用户登录、未登录时都能捕获其行为轨迹。(用户和设备间的多对多关系)
拉链表是针对缓慢变化维表的一种设计方式,记录一个事物从开始到当前状态的全部状态变化信息。
- 首先,从埋点表和访问日志表里获取到cookieid和userid同时出现的访问记录;
INSERT OVERWRITE TABLE ods.cookie_user_signin PARTITION (data_date='${data_date}')
SELECT t.* FROM (
SELECT userid,cookieid,from_unixtime(eventtime,'yyyyMMdd') as signdate
FROM ods.page_event_log //埋点表
WHERE data_date = '${data_date}'
UNION ALL
SELECT userid,cookieid,from_unixtime(viewtime,'yyyyMMdd') as signdate
FROM ods.page_view_log //访问日志表
WHERE data_date = '${data_date}'
) t
- 创建ID-Map的拉链表,将每天新增到ods.cookie_user_signin表中的数据与拉链表历史数据做比较,如果有变化或新增数据则进行更新。
CREATE TABLE 'dw.cookie_user_zippertable' (
'userid' STRING COMMENT '账号id',
'cookieid' STRING COMMENT '设备id',
'start_date' STRING COMMENT 'start_date',
'end_date' STRING COMMENT 'end_date')
COMMENT 'id-map拉链表'
ROW FORMAT DELIMITED FIELDS TERMINATED BY '\t'
- 每天ETL调度将数据更新到ID-Mapping拉链表中
INSERT OVERWRITE TABLE ods.cookie_user_zippertable
SELECT t.* FROM (
SELECT t1.user_num, t1.mobile, t1.reg_date, t1.start_date,
CASE WHEN t1.end_date='99991231' AND t2.userid IS NOT NULL THEN '${data_date}'
ELSE t1.end_date
END AS end_date
FROM dw.cookie_user_zippertable t1
LEFT JOIN(
SELECT * FROM ods.cookie_user_signin
WHERE data_date='${data_date}'
) t2
ON t1.userid = t2.userid
UNION
SELECT userid,cookieid,'${data_date}' AS start_date,'99991231' AS end_date
FROM ods.cookie_user_signin
WHERE data_date='${data_date}'
) t
- 实践中,还需将用户在不同平台间(如web端和APP端)行为打通的应用场景。
2.5.2 MySQL存储
元数据管理
Hive适合于大数据量的批处理作业,对于量级较小的数据,MySQL具有更快的读写速度。
监控预警数据
需要监控的环节:每天标签的产出量、服务层数据同步情况的监控。
调度流每跑完相应的模块,就将该模块的监控数据插入MySQL中,当校验任务判断达到触发告警阈值时,发送告警邮件,同时中断后续的调度任务。
结果集存储
存储多维透视分析用的标签、圈人服务用的用户标签、当日记录各标签数量,用于校验标签数据是否出现异常。
sqoop是一个用来将Hadoop和关系型数据库中(MySQL、Oracle、postgreSQL)的数据相互迁移的工具。
- 首先,在Hive中建立一张记录用户身份相关信息的表,设置日期分区以满足按日期选取当前人群的需要。
CREATE TABLE 'dw.userprofile_userservice_all' (
'user_id' STRING COMMENT 'userid',
'user_sex' STRING COMMENT 'user_sex',
'city' STRING COMMENT 'city',
'payid_money' STRING COMMENT 'payid_money',
'payid_num' STRING COMMENT 'payid_num',
'latest_product' STRING COMMENT 'latest_product',
'date' STRING COMMENT 'date',
'data_status' STRING COMMENT 'data_status',)
COMMENT 'userid用户客服数据'
PARTITIONED BY ('data_date' STRING COMMENT '数据日期')
- 在MySQL中建立一张用于接收同步数据的表。
CREATE TABLE 'userservice_data' (
'user_id' varchar(128) COMMENT '用户id',
'user_sex' varchar(128) COMMENT '用户性别',
'city' varchar(128) COMMENT '城市',
'payid_money' varchar(128) COMMENT '消费金额',
'payid_num' varchar(128) COMMENT '消费次数',
'latest_product' varchar(128) COMMENT '最近购买产品',
'date' varchar(64) COMMENT '传输日期',
'data_status' varchar(64) COMMENT '0:未传输,1:传输中,2:成功,3:失败',
PRIMARY KEY ('user_id'),
) ENGINE=InnoDB AUTO_INCREMENT=2261628 DEFAULT CHARSET=utf8 COMMENT '用户客服数据表'
- 通过Python脚本调用Shell命令,将Hive中数据同步到MySQL中。执行如下脚本:
# -*- coding: utf-8 -*-
import os
import MySQLdb
import sys
def export_data(hive_tab, data_date):
sqoop_command = "sqoop export --connect jdbc:mysql://10.xxx.xxx.xxx:3306/mysql_database --username username --password password --table mysql_table --export-dir hdfs://nameservicel/user/hive/warehouse/dw.db/" + hive_tab + "/data_date=" + data_date + " --input-fields-terminated-by '\001'"//,分隔符
os.system(sqoop_command)
print(sqoop_command)
if __name__ == '__main__':
export_data("dw.userprofile_userservice_all", '20181201')
2.5.3 HBase存储
HBase是一个高性能、列存储、可伸缩、实时读写的分布式存储系统,与Hive不同,HBase能够在数据库上实时运行,而不是跑MapReduce任务,适合进行大数据的实时查询。画像系统每天在Hive里跑出的结果集数据可同步到HBase数据库,用于线上实时应用的场景。
访问HBase中的行只有三种方式:
- 通过单个row key访问;
- 通过row key的正则访问;
- 全表扫描。
rowkey设计的三大原则:
- 唯一性原则:rowkey需要保证唯一性,画像中一般使用用户id作为rowkey;
- 长度原则:rowkey的长度一般为10-100bytes;
- 散列原则:rowkey的散列分布有利于数据均衡分布在每个RegionServer,可实现负载均衡。
ColumnFamily指列簇,HBase中的每个列都归属于某个列簇,列簇是表的schema的一部分,在使用表前定义。划分列簇的原则包括:是否具有相似的数据格式;是否具有相似的访问类型。
//创建表,指定表名和列簇名
create '<table name>','<column family>'
//扫描表中数据,显示10条
scan '<table name>',{LIMIT=>10}
//get命令读取数据
get '<table name>','row1'
//插入数据
put '<table name>','row1','<colfamily:colname>','<value>'
//更新数据
put '<table name>','row','<colfamily:colname>','new value'
//删除表之前先禁用
disable '<table name>'
drop '<table name>'
案例:
HBase的服务器体系结构遵循主从服务器架构,同一时刻只有一个HMaster处于活跃状态,当活跃的Master挂掉后,Backup HMaster自动接管整个HBase集群。在同步数据前,先判断当前活跃节点是哪台机器。
global activenode
for node in ("10.xxx.xx.xxx","10.xxx.xx.xxx"):
command = "curl http://" + str(node) + ":9870/jmx?qry=Hadoop:service=NameNode,name=NameNodeStatus"
status = os.popen(command).read()
print("HBase Master Status:".format(status))
if ("active" in status)://不活跃standby
activenode = node
为避免数据都写入一个region,造成数据倾问题,在活跃节点上创建预分区表:
create ‘userprofile_labels’,{NAME=>"f",BLOCKCACHE=>"true",BLOOMFILTER=>"ROWCOL",COMPRESSION=>"snappy",IN_MEMORY=>'true'},{NUMREGIONS=>10,SPLITALGO=>'HexStringSplit'}
将待同步的数据写入HFile,以key-value键值对方式存储,然后将HFile数据使用BulkLoad批量写入HBase集群中。
//Scala
import org.apache.hadoop.fs.{FileSystem, Path}
import org.apache.hadoop.HBase.client.ConnectionFactory
import org.apache.hadoop.HBase.{HBaseConfiguration, KeyValue, TableName}
import org.apache.hadoop.HBase.io.ImmutableBytesWritable
import org.apache.hadoop.HBase.mapreduce.{HFileOutputFormat2, LoadIncrementalHFiles}
import org.apache.hadoop.HBase.util.Bytes
import org.apache.hadoop.mapreduce.Job
import org.apache.spark.sql.SparkSession
object Hive2HBase {
def main(args: Array[String]): Unit = {
// 传入日期参数 和 当前活跃的master节点
val data_date = args(0)
val node = args(1) //当前活跃的节点ip
val spark = SparkSession
.builder()
.appName("Hive2HBase")
.config("spark.serializer","org.apache.spark.serializer.KryoSerializer")
.config("spark.storage.memoryFraction", "0.1")
.config("spark.shuffle.memoryFraction", "0.7")
.config("spark.memory.useLegacyMode", "true")
.enableHiveSupport()
.getOrCreate()
//创建HBase的配置
val conf = HBaseConfiguration.create()
conf.set("HBase.zookeeper.quorum", "10.xxx.xxx.xxx,10.xxx.xxx.xxx")
conf.set("HBase.zookeeper.property.clientPort", "8020")
//为了预防hfile文件数过多无法进行导入,设置参数值
conf.setInt("HBase.hregion.max.filesize", 10737418240)
conf.setInt("HBase.mapreduce.bulkload.max.hfiles.perRegion.perFamily", 3200)
val Data = spark.sql(s"select userid,userlabels from dw.userprofile_usergroup_labels_all where data_date='${data_date}'")
val dataRdd = Data.rdd.flatMap(row => {
val rowkey = row.getAs[String]("userid".toLowerCase)
val tagsmap = row.getAs[Map[String, Object]]("userlabels".toLowerCase)
val sbkey = new StringBuffer() // 对MAP结构转化 a->b 'a':'b'
val sbvalue = new StringBuffer()
for ((key, value) <- tagsmap){
sbkey.append(key + ":")
val labelght = if (value == ""){
"-999999"
} else {
value
}
sbvalue.append(labelght + ":")
}
val item = sbkey.substring(0,sbkey.length -1)
val score = sbvalue.substring(0,sbvalue.length -1)
Array(
(rowkey,("f","i",item)),
(rowkey,("f","s",score))
)
})
// 将rdd转换成HFile需要的格式
val rdds = dataRdd.filter(x=>x._1 != null).sortBy(x=>(x._1,x._2._1, x._2._2)).map(x => {
//KeyValue的实例为value
val rowKey = Bytes.toBytes(x._1)
val family = Bytes.toBytes(x._2._1)
val colum = Bytes.toBytes(x._2._2)
val value = Bytes.toBytes(x._2._3.toString)
(new ImmutableBytesWritable(rowKey), new KeyValue(rowKey, family, colum, value))
})
//文件保存在hdfs的位置
val locatedir = "hdfs://" + node.toString + ":8020/user/bulkload/hfile/usergroup_HBase_" + data_date
//在locatedir生成的Hfile文件
rdds.saveAsNewAPIHadoopFile(locatedir,
classOf[ImmutableBytesWritable],
classOf[KeyValue],
classOf[HFileOutputFormat2],
conf)
//HFile导入到HBase
val load = new LoadIncrementalHFiles(conf)
//HBase的表名
val tableName = "userprofile_labels"
//创建HBase的链接,利用默认的配置文件,读取HBase的master地址
val conn = ConnectionFactory.createConnection(conf)
//根据表名获取表
val table = conn.getTable(TableName.valueOf(tableName))
try {
//获取HBase表的region分布
val regionLocation = conn.getregionLocation(TableName.valueOf(tableName))
//创建一个hadoop的mapreduce的job
val job = Job.getInstance(conf)
//设置job名称,任意命名
job.setJobName("Hive2HBase")
//输出文件的内容KeyValue
job.setMapOutputValueClass(classOf[KeyValue])
//设置文件输出key, outkey要用ImmutableBytesWritable
job.setMapOutputKeyClass(classOf[ImmutableBytesWritable])
//配置HFileOutputFormat2的信息
HFileOutputFormat2.configureIncrementalLoad(job, table, regionLocation)
//开始导入
load.doBulkLoad(new Path(locatedir), conn.getAdmin, table, regionLocation)
} finally {
table.close()
conn.close()
}
spark.close()
}
}
用Elasticsearch存储HBase索引数据。
# 查询Hive中数据
def check_Hive_data(data_date):
r = os.popen("Hive -S -e\"select count(1) from dw.userprofile_usergroup_labels_all where data_date='"+data_date+"'\"")
Hive_userid_count = r.read()
r.close()
Hive_count = str(int(Hive_userid_count)
print "Hive_result: " + str(Hive_count)
print "Hive select finished!"
# 查询HBase中数据
def check_HBase_data(data_date):
r = os.popen("HBase org.apache.hadoop.HBase.mapreduce.RowCounter 'userprofile_labels'\" 2>&1 |grep ROWS")
HBase_count = r.read().strip()[5:]
r.close()
print "HBase result: " + str(HBase_count)
print "HBase select finished!"
# 连接 DB,将查询结果插入表
db = MySQLdb.connect(host="xx.xx.xx.xx",port=3306,user="username", passwd="password", db="xxx", charset="utf8")
cursor = db.cursor()
cursor.execute("INSERT INTO service_monitor(date, service_type, Hive_count, HBase_count) VALUES('"+datestr_+"', 'advertisement', "+str(Hive_userid_count)+","+str(HBase_count)+")")
db.commit()
2.5.4 Elasticsearch存储
Elasticsearch是面向文档型数据库,一条数据就是一个文档,用json作为文档格式。
将dw,userprofile_userlabel_map_all数据写入Elasticsearch中,Scala代码如下:
object HiveDataToEs {
def main(args: Array[String]): Unit = {
val spark = SparkSession.builder()
.AppName("EsData")
.config("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
.config("spark.dynamicAllocation.enabled", "false")
.config("es.index.auto.create", "true")
.config("es.nodes", "10.xx.xx.xx")
.config("es.batch.write.retry.count", "3") // 默认重试3次
.config("es.batch.write.retry.wait", "5") // 每次重试等待时间为5秒
.config("thread_pool.write.queue_size", "1000")
.config("thread_pool.write.size", "50")
.config("thread_pool.write.type", "fixed")
.config("es.batch.size.bytes", "20mb")
.config("es.batch.size.entries", "2000")
.config("es.http.timeout","100m")
.enableHiveSupport()
.getOrCreate()
val data_date = args(0).toString
import spark.sql
val hiveDF = sql(
s"""
| SELECT userid, tagsmap FROM dw.userprofile_userlabel_map_all where data_date = '${data_date}'
""".stripMargin) // dw.userprofile_userlabel_map_all在3.1.3节中讲过,是聚合用户标签的表
val rdd = hiveDF.rdd.map {
row => {
val userid = row.getAs[String]("userid")
val userlabels = row.getAs[Map[String, Object]]("userlabels")
Map("userid" -> userid, "userlabels" -> userlabels)
}
}
EsSpark.saveToEs(rdd , "userprofile/tags", Map[String,String]("es.mApping.id"->"userid")
spark.stop()
}
}
//工程依赖
<dependency>
<groupId>org.elasticsearch</groupId>
<artifactId>elasticsearch-hadoop</artifactId>
<version>6.4.2</version>
</dependency>
//将该工程打包之后提交任务,传入日期分区参数“20190101”执行
spark-submit--class com.example.HiveDataToEs--master yarn--deploy-mode client--executor-mory 2g--num-executors 50--driver-memory 3g--executor-cores 2 spark-hive-to-es.jar 20190101
//python脚本查看数据校验逻辑,校验Elasticsearch和hive中的数据量是否一致
# 查询Hive中的数据
def monitor_hive_data(data_date):
hive_user = " select count(1) from dw.userprofile_userlabel_map_all where data_date='{}' ".format(data_date)
user_count = os.popen("hive -S -e \"" + hive_user + "\"").read().strip()
return user_count
# 查询es中的数据
def monitor_es_data(data_date):
userid_search = "curl http://10.xxx.xxx.xxx:9200/_cat/count/" + data_date + "_userid/"
userid_num = str(os.popen(userid_search).read()).split(' ')[-1].strip()
return userid_num
# 比较Hive和es中的数据,如通过校验,更新MySQL状态位
def update_es_data(data_date):
'''
data_date: 查询数据日期
'''
esdata = monitor_es_data(data_date) # 查询es中的数据
hivedata = monitor_hive_data(data_date) # 查询Hive中的数据
print("esdata ======>{}".format(esdata))
print("hivedata ======>{}".format(hivedata))
# 更新MySQL状态位
if (esdata[0] == hivedata[0] ):
db = MySQLdb.connect(host="10.xx.xx.xx", port=3306, user="username", passwd="password",
db="userprofile", charset="utf8")
cursor = db.cursor()
try:
select_command = "INSERT INTO `elasticsearch_state` VALUES ('"+ str(data_date) +"', 'elasticsearch', '0', '2');"
cursor.execute(select_command)
db.commit()
except Exception as e:
db.rollback()
exit(1)