1. 写在前面
这里是有关于一个头条推荐项目的学习笔记,主要是整理工业上的推荐系统用到的一些常用技术, 这是第七篇, 上一篇介绍了离线召回与定时更新技术, 这里说的就是根据用户的历史点击行为,基于模型或者是文章内容,从海量的文章中为每个用户在每个频道召回几百篇文章,并存储到HBase,供后面的精排模型所使用。 而今天这篇文章介绍的就是离线排序模型训练与实时计算用到的相关技术。这篇文章使用的数据就是前面召回回来的候选样本集。 主要内容如下:
- 离线排序模型训练基础(CTR预估作用,常见点击率预测种类模型,CTR中特征处理方式,spark LR模型完成训练预测评估)
- 离线CTR特征中心更新(特征服务中心的作用)
- 实时计算业务介绍(业务需求与作用) — 解决用户冷启动,实时反馈跟踪用户兴趣
- 实时日志分析与实时内容召回(Spark Streaming完成实时召回集创建)
- 热门与新文章召回
- 小总
Ok, let’s go!
2. 离线排序模型训练基础
2.1 离线排序模型 - CTR预估
CTR(Click-Through Rate)预估:给定一个Item,预测该Item会被点击的概率
- 离线的模型训练: 排序的各种模型训练评估
- 特征服务平台:为了提高模型在排序时候的特征读取处理速率,直接将处理好的特征写入HBASE
模型这块的逻辑是离线把模型训练好,然后实时的部分读取模型去进行服务。总体流程如下:
对于排序模型, spark本身支持的太少了,也就是有个LR还能用,而现在是深度学习的时代,一般这块都用一些比较优秀的深度学习模型,所以会基于TensorFlow框架去搭建深度学习模型去用于排序, 并且TensorFlow会提供serving服务, 为了TensorFlow能够快速的读取数据, 一般会把训练样本转成TFRecords文件。 所以整体的流程就是上面这个图中表示的: 首先从前面保存的用户画像和文章画像中抽取出排序部分所用的特征,存入到HBase中。这些特征后面会提供给一些实施排序的模型,包括spark集成的LR模型,当然这个对于目前推荐来说不够强大,所以一般会用TensorFlow的深度学习模型,而这些模型实时排序读取起来不方便,所以一般会进行线上部署从而去服务, 而为了让TF模型快速的读取数据,一般还会把完整特征转一份TFRecords文件保存。 这就是这块的整体逻辑了。 后面所介绍的,就是这上面图里面的各个模块的具体细节以及优化了。
2.2 排序模型
这里和我之前总结的王喆老师深度学习推荐系统上的模型对应起来了, 这里又给出了一个总结,最基础的模型目前都是基于LR的点击率预估策略,目前在工业使用模型做预估的有这么几种类型
- 宽模型 + 特征工程
- LR/MLR + 非ID类特征(人工离散/GBDT/FM)
- spark中可以直接使用
- 深模型
- DNN + 特征embedding
- 使用TensorFlow进行训练
- 宽模型 + 深模型
- wide&deep, DeepFM等
- 使用TensorFlow进行训练
下面是先用一个LR模型做一些简单的预测, 因为这个模型spark里面已经集成了,直接可以拿过来使用。 等下下篇文章的时候,再去整理深度学习模型究竟是如何进行排序且线上服务的。 这里的重点是弄明白整个排序部分的逻辑,这一块究竟怎么玩才是最重要的,而排序模型,仅仅是模型而已,不是实战课程的重点内容哈哈。再说个特征处理原则就开始干了。
2.3 特征处理原则
这个其实我在第五篇文章的最后,参考王喆老师的书整理的比较详细,这里简单的复习一下吧
- 离散数据: one-hot编码,然后还可以embedding
- 连续数据: 归一化,离散化,非线性化
- 图片/文本: 文章标签/关键词提取, embedding等,对于图片特征,在文章推荐场景用的不多,一般是用于视频推荐,商品推荐啥的。
这里还有个图:
2.4 spark LR的训练与评估
逻辑回归优化训练方式: Batch SGD优化,并加入正则防止过拟合
下面介绍如何使用spark LR进行CTR预估,主要步骤如下:
- 需要通过spark读取Hive外部表, 需要新的spark session配置
增加HBase配置 - 读取用户行为表,与用户画像,文章画像,构造训练样本
- LR模型进行训练
- LR模型进行预测与结果评估
下面一一来展开。
2.4.1 创建环境
这里就得再整理点新的东西了,我们的用户画像和文章画像是存在了HBase里面,而我们是通过Hive进行读取分析, 注意此时是Hive读取分析HBase里面的数据,之前都是Hive去读取分析HDFS上的数据,这不是一回事哈。所以在初始化配置中需要加入HBase的master IP以及master zookeeper监控, 默认是2181端口, 但是为了避免与后面kafka那边的端口冲突,这里改成了22181.
下面在full_cal新建一个ctr_lr.ipynb文件,在这里面写代码,创建一个sparkSession,注意这里的创建,不用之前的那个_create_spark_session()
了,而是新配置了一个_create_spark_hbase()
# 前面加环境变量这些参考前面的吧,因为我这边环境和它这里不一样,我就不贴了。
# 导包
from pyspark.ml.feature import OneHotEncoder
from pyspark.ml.feature import StringIndexer
from pyspark.ml import Pipeline
from pyspark.sql.types import *
from pyspark.ml.feature import VectorAssembler
from pyspark.ml.classification import LogisticRegression
from pyspark.ml.classification import LogisticRegressionModel
from offline import SparkSessionBase
class CtrLogisticRegression(SparkSessionBase):
SPARK_APP_NAME = "ctrLogisticRegression"
ENABLE_HIVE_SUPPORT = True
def __init__(self):
self.spark = self._create_spark_hbase()
ctr = CtrLogisticRegression()
这里关键是这个_create_spark_hbase()
, 我们后面需要通过spark读取HIVE外部表,需要新的配置, 这个基础的函数,可以加在__init__
那个SparkSessionBase中, 这样直接继承过来就OK。
def _create_spark_hbase(self):
conf = SparkConf() # 创建spark config对象
config = (
("spark.app.name", self.SPARK_APP_NAME), # 设置启动的spark的app名称,没有提供,将随机产生一个名称
("spark.executor.memory", self.SPARK_EXECUTOR_MEMORY), # 设置该app启动时占用的内存用量,默认2g
("spark.master", self.SPARK_URL), # spark master的地址
("spark.executor.cores", self.SPARK_EXECUTOR_CORES), # 设置spark executor使用的CPU核心数,默认是1核心
("spark.executor.instances", self.SPARK_EXECUTOR_INSTANCES),
("hbase.zookeeper.quorum", "192.168.56.101"), # 新加了与HBase连接的两个配置
("hbase.zookeeper.property.clientPort", "22181") # 这里的端口变了
)
conf.setAll(config)
# 利用config对象,创建spark session
if self.ENABLE_HIVE_SUPPORT:
return SparkSession.builder.config(conf=conf).enableHiveSupport().getOrCreate()
else:
return SparkSession.builder.config(conf=conf).getOrCreate()
2.4.2 读取用户点击行为表,与用户画像和文章画像,构造训练样本
这一步是核心,样本中的主要构成就是特征与标签, 而这两块,主要是来自于用户行为表,用户画像和文章画像。 在用户行为表中记录着用户对于某篇文章的点击行为,而这个就是我们的label。所以这里又用到了我们的用户行为表, 也就是user_action_basic, 这个表确实很关键。 还记得数据长啥样吗?再看下:
在这个表中,我们需要user_id, article_id和clicked这三列数据,其中clicked就是我们的label。而特征值呢?
- 用户画像的关键词权重: 权重值排序TOPK,这里取10个
- 文章频道号: channel_id, ID类型通常one-hot, 编程25维度的(25个频道), 这里由于只选了一个频道,所以这里不进行转换
- 文章向量: article_vector,这个代表文章的内容
所以最后我们构建的样本数据长下面这个样子:
当然这里还可以再拼接一些用户的基本信息啥的, 不过这里用了这几个,就先用这几个吧, 其他的可以作为优化空间。 但是处理逻辑都是一模一样的。下面看看如何得到上面的这个数据表。
首先,读取行为日志数据,这个在profile数据库里面的user_article_basic
表里面选择出我们需要的四列特征。 这里又三个数据库, toutiao数据库主要存业务相关的表,存的原始数据, article数据库存的是文章画像结果相关的表, 而profile数据库存的是用户历史行为相关的数据记录。这几个数据库是干啥的得了解。 而日志行为,当然是用profile数据库啦:
ctr.spark.sql("use profile")
# +-------------------+----------+----------+-------+
# | user_id|article_id|channel_id|clicked|
# +-------------------+----------+----------+-------+
# |1105045287866466304| 14225| 0| false|
user_article_basic = ctr.spark.sql("select * from user_article_basic").select(
['user_id', 'article_id', 'channel_id', 'clicked'])
用户画像读取处理与日志数据合并,这里用的是profile里面的user_profile_basic数据表,这个是关联的HBase上的那个表, 记载的是用户的相关信息。具体可以看第五篇文章里面的整理。具体代码如下:
user_profile_hbase = ctr.spark.sql(
"select user_id, information.birthday, information.gender, article_partial, env from user_profile_hbase")
user_profile_hbase = user_profile_hbase.drop('env')
# +--------------------+--------+------+--------------------+
# | user_id|birthday|gender| article_partial|
# +--------------------+--------+------+--------------------+
# | user:1| 0.0| null|Map(18:Animal -> ...|
接下来,读取用户画像的Hive外部表,构造样本:
# 这里是为了只拿到用户id,而把前面的那个user:去掉,看上面表里面的user_id
def get_user_id(row):
return int(row.user_id.split(":")[1]), row.birthday, row.gender, row.article_partial
user_profile_hbase = user_profile_hbase.rdd.map(get_user_id).toDF(["user_id", "gender", "birthday", "article_id"])
这时候,一运行,会报错:some of types cannot be detemined by the first 100 rows, please train again with sampling
, 这个的意思是说对于某些有大量空值的列, 无法确定到底应该是这么类型,在后面就会发现, 这个user_profile_hbase里面的gender和birthday列有大量的空值,超过了50%以上,所以这也是我们后面要删除的原因, 但是这个地方没法没法这样建表了, 那怎么办呢? 所以这里的一个知识点: 如果DF存在连续大量的空缺值, 这时候toDF这种方式无法得到这列给什么类型,就会报上面的错误。
解决方案: 手动指定类型, 方法就是用createDataFrame, 然后指定一个schema参数传入每一列的类型即可。
_schema = StructType([
StructField("user_id", LongType()),
StructField("birthday", DoubleType()),
StructField("gender", BooleanType()), # 这里空缺值我们手动指定类型
StructField("weights", MapType(StringType(), DoubleType()))
])
user_profile_hbase_temp = user_profile_hbase.rdd.map(get_user_id)
user_profile_hbase_schema = ctr.spark.createDataFrame(user_profile_hbase_temp, schema=_schema)
train = user_article_basic.join(user_profile_hbase_schema, on=['user_id'], how='left').drop('channel_id')
得到的结果如下(这里就会发现了空缺值)
这里不是没有考虑用户的基本信息,而是因为用户基本信息这块缺失值太多了,所以删除掉了。
接下来, 文章频道与向量读取合并,并删除掉上面缺失的那两个特征。文章向量在article数据库中的article_vector表。
ctr.spark.sql("use article")
article_vector = ctr.spark.sql("select * from article_vector")
# 在spark程序中,这个变量最好还是以同样的变量名处理接收,这也是一种优化方式,这种不会浪费内存。
train = train.join(article_vector, on=['article_id'], how='left').drop('birthday').drop('gender')
这样,就把文章向量合并了上来:
合并文章画像的权重特征, 这里用的依然是article数据库, 文章画像在article_profile中。这里的逻辑是这样,因为这个里面是每个关键词以及权重信息,如果关键词太多的话,选择最关键的N个,这里选择了前10个关键词对应的权重。
ctr.spark.sql("use article")
article_profile = ctr.spark.sql("select * from article_profile")
def article_profile_to_feature(row):
try:
weights = sorted(row.keywords.values())[:10] # 这里排序选择前10个关键词
except Exception as e: # 如果关键词不够10个,这里会报异常, 这里给个默认值0, 之所以给0,是对线性回归不起作用,不影响
weights = [0.0] * 10
return row.article_id, weights
article_profile = article_profile.rdd.map(article_profile_to_feature).toDF(['article_id', 'article_weights'])
# 下面前面的表连接上来
train = train.join(article_profile, on=['article_id'], how='left')
这个结果如下:
这里最后还要处理下这个weights特征,也就是用户的标签画像特征。
进行用户的权重特征筛选处理,类型处理
- 获取用户对应文章频道号的关键词权重
- 若无:生成默认值
train = train.dropna()
columns = ['article_id', 'user_id', 'channel_id', 'articlevector', 'user_weights', 'article_weights', 'clicked']
def feature_preprocess(row):
from pyspark.ml.linalg import Vectors
try:
# 获取用户对应文章频道号的关键词权重
weights = sorted([row.weights[key] for key in row.weights.keys() if key[:2] == str(row.channel_id)])[:10]
except Exception: # 如果不够10个,就会抛出异常, 这时候给默认值
weights = [0.0] * 10
# 这里的那些向量字段要转成向量才能放到逻辑回归模型用,现在是数组类型的
# 所以往Hive中存的时候,这些向量数据要转成数组类型,而导入过来用的时候,数组类型要转成向量类型
return row.article_id, row.user_id, row.channel_id, Vectors.dense(row.articlevector), Vectors.dense(weights), Vectors.dense(row.article_weights), int(row.clicked)
train = train.rdd.map(feature_preprocess).toDF(columns)
结果如下:
这时候就得到了我们最终的数据了。但是这个数据呢? 现在还不能给LR模型训练,因为LR模型要求的输入是有格式的,所以还得需要特征格式的指定。
2.4.3 训练LR模型
LR模型训练的时候,需要指定特征参数和label参数, 所以我们还得先把特征进行拼接起来,合成一个特征列表, 这里用VectorAssembler()
收集
cols = ['article_id', 'user_id', 'channel_id', 'articlevector', 'weights', 'article_weights', 'clicked']
# 这里收集第2个到第5个特征`在这里插入代码片`
train_version_two = VectorAssembler().setInputCols(cols[2:6]).setOutputCol("features").transform(train)
这里拿我之前学习的一个项目例子看下效果:
就是把前面所有列的特征拼接到一块去了。下面就能训练LR模型了,我发现,大量的工作都在数据的处理和样本的制作上, 到模型这块, 仅仅两三句代码就能搞定,就是调个包,然后构建模型,然后fit。
lr = LogisticRegression()
model = lr.setLabelCol("clicked").setFeaturesCol("features").fit(train_version_two)
# model.save("hdfs://hadoop-master:9000/headlines/models/lr.obj")
2.4.4 点击率预测结果
使用model模型加载预估
online_model = LogisticRegressionModel.load("hdfs://hadoop-master:9000/headlines/models/CtrLogistic.obj")
res_transfrom = online_model.transform(train_version_two)
res_transfrom.select(["clicked", "probability", "prediction"]).show()
逻辑回归模型预测的时候,会自动生成一个probability列,表示的预测结果, 这里也是拿之前项目的一个结果来看下:
这里主要说一下这个probability这里的格式,这个是个list, [不点击的概率, 点击的概率]
, 通常我们会拿点击的这个概率作为最终的预测结果。所以最后再进行一步处理保存结果,然后计算auc
def vector_to_double(row):
return float(row.clicked), float(row.probability[1])
score_label = res_transfrom.select(["clicked", "probability"]).rdd.map(vector_to_double)
有了这个score_label,下面我们就可以进行评估了。
2.4.5 模型评估 - accuracy 与AUC
这里可以画出auc曲线图竟然,看操作:
import matplotlib.pyplot as plt
plt.figure(figsize=(5,5))
plt.plot([0, 1], [0, 1], 'r--')
plt.plot(model.summary.roc.select('FPR').collect(), # collect()把rdd转成numpy数组
model.summary.roc.select('TPR').collect())
plt.xlabel('FPR')
plt.ylabel('TPR')
plt.show()
如果要计算ROC或者AUC, 这里建议用sklearn的包:
from sklearn.metrics import roc_auc_score, accuracy_score
import numpy as np
arr = np.array(score_label.collect()) # 这里需要转成numpy的array形式
评估AUC与准确率
accuracy_score(arr[:, 0], arr[:, 1].round())
0.9051438053097345
roc_auc_score(arr[:, 0], arr[:, 1])
0.719274521004087
这样就用逻辑回归模型走完了排序的整个流程, 从制作样本 -> 模型训练 -> 模型预测 -> 模型评估
3. 离线CTR特征中心更新
这里是定时更新离线的CTR特征,特征服务中心可以为离线计算提供用户与文章的高级特征,充当着重要的角色。可以为程序提供快速的特征处理与特征结果,而且不仅仅提供给离线使用。还可以作为实时的特征供其他场景读取进行
原则是:用户,文章能用到的特征都进行处理进行存储,便于实时推荐进行读取。
目的是:模型训练,模型实时预测的时候提供遍历,快速构建样本预测,只需要用户_id, 文章_id,就能直接读取到HBase中存储的模型中要使用的特征,直接拿出来做预测即可,对于后面做推荐非常方便。
存储形式:
- 存储到数据库HBASE中, 因为能实时快速供我们排序读取
- 构造好样本,存储到TFRecords文件给TensorFlow模型训练
首先确定创建特征结果HBASE表:这里创建了两个表,分别保存我们的用户标签向量和文章向量特征
- 用户特征中心表: ctr_feature_user
- 文章特征中心表: ctr_feature_article
# 表名 列族(频道) 主键(user_id), 列名
create 'ctr_feature_user', 'channel'
4 column=channel:13, timestamp=1555647172980, value=[]
4 column=channel:14, timestamp=1555647172980, value=[]
4 column=channel:15, timestamp=1555647172980, value=[]
4 column=channel:16, timestamp=1555647172980, value=[]
4 column=channel:18, timestamp=1555647172980, value=[0.2156294170196073, 0.2156294170196073, 0.2156294170196073, 0.2156294170196073, 0.2156294170196073, 0.2156294170196073, 0.2156294170196073, 0.2156294170196073, 0.2156294170196073, 0.2156294170196073]
4 column=channel:19, timestamp=1555647172980, value=[]
4 column=channel:20, timestamp=1555647172980, value=[]
4 column=channel:2, timestamp=1555647172980, value=[]
4 column=channel:21, timestamp=1555647172980, value=[]
create 'ctr_feature_article', 'article'
COLUMN CELL
article:13401 timestamp=1555635749357, value=[18.0,0.08196639249252607,0.11217275332895373,0.1353835167902181,0.16086650318453152,0.16356418791892943,0.16740082750337945,0.18091837445730974,0.1907214431716628,0.2........................-0.04634634410271921,-0.06451843378804649,-0.021564142420785692,0.10212902152136256]
上面这个建表的里面的列的选取,应该是跟着我们模型用到的特征来的,对于上面的逻辑回归模型, 主要用到特征是'channel_id', 'articlevector', 'weights', 'article_weights'
, 这里面用户的权重特征一列,所以用户表里面的value保存的就是这个特征,保存到HBase,方便后面的直接读取。 而channel_id, articlevector
和artcile_weight
, 是文章的画像特征提取的,从后面的代码里面会看到,这里把这三列直接用VectorAssembler()
函数拼接成了一列feature, 这就是ctr_feature_article的value。
如果想离线分析,这里可以创建Hive外部表, 一般不需要离线分析
create external table ctr_feature_user_hbase(
user_id STRING comment "user_id",
user_channel map comment "user_channel")
COMMENT "ctr table"
STORED BY 'org.apache.hadoop.hive.hbase.HBaseStorageHandler'
WITH SERDEPROPERTIES ("hbase.columns.mapping" = ":key,channel:")
TBLPROPERTIES ("hbase.table.name" = "ctr_feature_user");
create external table ctr_feature_article_hbase(
article_id STRING comment "article_id",
article_feature map comment "article")
COMMENT "ctr table"
STORED BY 'org.apache.hadoop.hive.hbase.HBaseStorageHandler'
WITH SERDEPROPERTIES ("hbase.columns.mapping" = ":key,article:")
TBLPROPERTIES ("hbase.table.name" = "ctr_feature_article");
3.1 用户特征中心更新
目的:计算用户特征更新到HBase
- 每个用户会有25个频道的画像结果
- 对25个频道都进行特征抽取,10个主题词权重大的进行保存
步骤:
- 获取特征进行用户画像权重过滤
- 特征批量存储
获取特征进行用户画像权重过滤
# 构造样本
ctr.spark.sql("use profile")
user_profile_hbase = ctr.spark.sql(
"select user_id, information.birthday, information.gender, article_partial, env from user_profile_hbase")
# 特征工程处理
# 抛弃获取值少的特征
user_profile_hbase = user_profile_hbase.drop('env', 'birthday', 'gender')
def get_user_id(row):
return int(row.user_id.split(":")[1]), row.article_partial
user_profile_hbase_temp = user_profile_hbase.rdd.map(get_user_id)
from pyspark.sql.types import *
_schema = StructType([
StructField("user_id", LongType()),
StructField("weights", MapType(StringType(), DoubleType()))
])
user_profile_hbase_schema = ctr.spark.createDataFrame(user_profile_hbase_temp, schema=_schema)
def frature_preprocess(row):
from pyspark.ml.linalg import Vectors
channel_weights = []
for i in range(1, 26):
try:
_res = sorted([row.weights[key] for key
in row.weights.keys() if key.split(':')[0] == str(i)])[:10]
channel_weights.append(_res)
except:
channel_weights.append([0.0] * 10)
return row.user_id, channel_weights
# 这个res首先是个列表,第0个元素是用户id, 第1个元素是一个二维数组, res[1][0]表示第1个频道的用户10个标签权重,依次类推
res = user_profile_hbase_schema.rdd.map(frature_preprocess).collect()
特征批量存储, 保护用户每个频道的特征
import happybase
# 批量插入Hbase数据库中
pool = happybase.ConnectionPool(size=10, host='hadoop-master', port=9090)
with pool.connection() as conn:
ctr_feature = conn.table('ctr_feature_user')
with ctr_feature.batch(transaction=True) as b: # 批量存储打开
for i in range(len(res)): # 遍历用户
for j in range(25): # 遍历每个频道
b.put("{}".format(res[i][0]).encode(),{"channel:{}".format(j+1).encode(): str(res[i][1][j]).encode()})
conn.close() # 关闭之后,会统一的把数据进行插入,而不是一条条的走
这样,用户画像的特征保存完毕,后面用的时候,就需要在这个ctr_feature_user数据库里面拿相应用户的特征就OK了。
3.2 文章特征中心更新
我们需要保存的文章特征有哪些?
- 关键词权重
- 文章的频道
- 文章向量结果
存储这些特征以便于后面实时排序时候快速提取特征。步骤如下:
- 读取相关文章画像
- 进行文章相关特征提取和处理
- 合并文章所有特征作为模型训练或者预测的初始特征
- 文章特征存储到HBase
下面一一看:
读取相关画像, 这里使用的article数据库,文章画像在article_profile表里。
ctr.spark.sql("use article")
article_profile = ctr.spark.sql("select * from article_profile")
进行文章相关特征和提取
文章的关键词特征,也是取最关键的前10个关键词的权重特征
def article_profile_to_feature(row):
try:
weights = sorted(row.keywords.values())[:10]
except Exception as e:
weights = [0.0] * 10
return row.article_id, row.channel_id, weights
article_profile = article_profile.rdd.map(article_profile_to_feature).toDF(['article_id', 'channel_id', 'weights'])
article_profile.show()
文章的向量特征, 把这个拼接到前面的表上去。文章向量读取并转成向量, 这里记得要用Vectors把数组类型的向量转成向量。
article_vector = ctr.spark.sql("select * from article_vector")
article_feature = article_profile.join(article_vector, on=['article_id'], how='inner')
def feature_to_vector(row):
from pyspark.ml.linalg import Vectors
return row.article_id, row.channel_id, Vectors.dense(row.weights), Vectors.dense(row.articlevector)
article_feature = article_feature.rdd.map(feature_to_vector).toDF(['article_id', 'channel_id', 'weights', 'articlevector'])
指定所有文章进行特征合并, 这里用到了VectorAssmbler()
函数进行封装,这样可以供后面的逻辑回归直接使用。
# 保存特征数据
cols2 = ['article_id', 'channel_id', 'weights', 'articlevector']
# 做特征的指定指定合并
article_feature_two = VectorAssembler().setInputCols(cols2[1:4]).setOutputCol("features").transform(article_feature)
这样特征就处理完了,这个features的长度是111维(1+10+100)
接下来保存到数据库。
# 保存到特征数据库中
def save_article_feature_to_hbase(partition):
import happybase
pool = happybase.ConnectionPool(size=10, host='hadoop-master')
with pool.connection() as conn:
table = conn.table('ctr_feature_article')
for row in partition:
table.put('{}'.format(row.article_id).encode(),
{'article:{}'.format(row.article_id).encode(): str(row.features).encode()})
article_feature_two.foreachPartition(save_article_feature_to_hbase)
3.3 离线特征中心定时更新
这里又是定时更新的那一套了,首先先把上面的代码进行合并,封装到一个类里面去。
下面就是完整的逻辑代码了, 这个要写到pycharm中, 在offline下面增加一个update_feature.py
文件,在里面加入代码:
class FeaturePlatform(SparkSessionBase):
"""特征更新平台
"""
SPARK_APP_NAME = "featureCenter"
ENABLE_HIVE_SUPPORT = True
def __init__(self):
# _create_spark_session
# _create_spark_hbase用户spark sql 操作hive对hbase的外部表
self.spark = self._create_spark_hbase()
def update_user_ctr_feature_to_hbase(self):
#上面更新用户画像特征中心的代码
def get_user_id(row):
pass
def feature_preprocess(row):
pass
# 插入到HBase
def update_article_ctr_feature_to_hbase(self):
# 下面更新文章画像特征中心的代码
def article_profile_to_feature(row):
pass
def feature_to_vector(row):
pass
# 保存到HBase
添加update.py
更新程序
def update_ctr_feature():
"""
定时更新用户、文章特征
:return:
"""
fp = FeaturePlatform()
fp.update_user_ctr_feature_to_hbase()
fp.update_article_ctr_feature_to_hbase()
把定时更新的函数添加到apscheduler中定时运行即可。
# 添加一个定时运行文章画像更新的任务,每隔1个小时运行一次
scheduler.add_job(update_article_profile, trigger='interval', hours=1)
# 添加一个定时运行用户画像更新的任务, 每隔2个小时运行一次
scheduler.add_job(update_article_profile, trigger='interval', hours=2)
# 添加一个定时运行用户召回更新的任务, 每隔3个小时运行一次
scheduler.add_job(update_user_recall, trigger='interval', hours=3)
# 添加定时更新用户文章特征结果的程序,每个4小时更新一次
scheduler.add_job(update_ctr_feature, trigger='interval', hours=4)
因为这个用户文章特征结果更新的这个程序是依赖于用户画像和文章画像的,所以更新的间隔要高于前面的两个,这样等用户文章画像更新好了再去计算它。
到这里,离线部分的定时更新任务就基本完成了,主要有上面四件事情要做, 必须要明白各个事情的目的,以及各个事情是怎么做。 这里简单的梳理下: 重点知识
文章画像和用户画像的更新,是为了后面提取特征,训练模型用的。 而用户召回更新的目的是为了能够后面快速的产生推荐候选集。 而用户文章特征中心,又直接保存着用户和文章的特征,这些特征又可以直接供模型预测。 这几个进行配合就能够很快的进行推荐。
比如, 我们先根据已有的用户画像和文章画像,抽取出特征来,去训练一个逻辑回归模型并保存起来。
这时候如果新来了某个用户, 我们拿到他的用户id, 根据用户召回更新的结果集,能够快速拿出召回回来的几百篇的候选文章, 而根据保存好的特征中心的结果, 对于这几百篇候选文章,根据用户id,文章id就立即能拿出模型可以用来预测的特征, 那么我们又有之前训练好的逻辑回归模型,就可以直接对每篇候选文章进行预测用户的点击概率,根据这个概率从大到小排序,把靠前的N篇推给用户即可。
这就完成了线下推荐的逻辑。
文章画像的更新(第三,四篇文章)
- 文章TFIDF, TextRank, 关键词,主题词, 文章向量
用户画像更新(第五篇文章)
- 用户每个频道的关键词以及权重(文章的关键词打过来直接,然后根据用户的历史点击行为以及时间进行加权)
用户召回(第六篇文章)
- ALS模型召回, 内容召回
- 提供召回文章给用户推荐使用
特征中心平台
- 用户的特征, 文章特征
- 提供实时排序使用
4. 实时计算业务介绍
4.1 业务与需求
实时计算的目的:
- 解决用户的冷启动问题
- 实时计算能够根据用户的点击实时反馈,快速跟踪用户的喜好
下面介绍下实时计算的业务图,也就是这一块到底在干个什么事情, 先放一张图过来:
上面说了,实时计算业务主要是解决用户的冷启动问题,然后根据用户的点击实时反馈,快速跟踪用户喜好用的。 那么是怎么做到的呢? 用户冷启动问题,就是新来了某个用户,它没有任何点击行为,那么系统怎么给他推荐文章呢? 下面简单描述下这个流程,就是上面图里面画的:
首先,对于新来的用户,系统时无法根据日志系统收集日志数据,然后构建画像走离线部分的,那么系统一般会把频道内最热门的文章, 最新的文章先推荐给这个用户? 那么这个热门和新文章是怎么收集的呢?
- 对于最新文章,这个是好判断,根据发布时间,把各个频道最新的文章通过kafka收集到spark streaming, 然后缓存到Redis中, 就可以直接推给新来的用户了
- 对于热门文章的判断,这个是基于之前的其他用户的日志收集,也就是会发现,对于某个用户,他的历史点击行为会进入日志系统,然后通过flume进行收集,这时候会走两个方向,一个是离线那边的日志数据,走离线部分的业务逻辑, 而另一个是走kafka,这个是直接可以和flume进行对接的, 走kafka的这个方向, 在Spark Streaming里面会根据用户的点击日志,计算文章的热度(比如看了就会给文章加1分),这样就能根据文章的得分得到热度,然后把最热门的一些文章进行推荐给用户。
通过上面的方式,就可以解决用户的冷启动问题, 那么实时反馈和快速跟踪呢?
对于新来的用户,通过上面的两种方式,就能推荐给用户一些热门的和最新的文章, 那么实时反馈的意思是根据用户的行为作出反馈,比如用户点击了给他推荐的某篇文章, 这时候会发生事情呢? 这时候,立即把用户的这个点击行为记录到日志系统,然后通过flume进行采集,此时再进入kafka, 通过spark streaming直接计算与用户点击的文章相似的N篇文章,这个是能计算的,毕竟我们HBase里面存储了各个文章的内容信息,那么就完全可以基于内容去进行召回,得到相似的N篇文章之后,就把这N篇文章推荐给用户。
通过不断的对用户的点击行为实时反馈,慢慢的用户就会积累阅读量,也会有越来越丰富的点击日志等, 到了后面,就能进行离线部分的那一套流程啦。
原来这个实时计算业务是这么玩的啊,学到了又。 下面就是上面这三条线的技术实现细节了。
下面先走在线的内容召回。 就是上面蓝色的那个流程线。
4.2 实时日志分析
日志数据我们已经收集到hadoop中,但是做实时分析的时候,我们需要将每个时刻用户产生的点击行为收集到KAFKA当中,等待spark streaming程序去消费。所以下面先跑通flume收集日志到Kafka的流程。
下面其实又是大数据环境中的测试工作了,由于我这边目前是特殊时期,所以下面的测试工作我先把流程梳理通,先不进行试验了,等过了4月份,我会尝试把这里的实验补充上来(金三银四大家都懂哈哈, 还有算法的一些知识没有学完,这里先不一步一步的敲环境了)
4.2.1 Flume收集日志到Kafka
目的: 收集本地实时日志行为数据,到kafka
步骤:
- 开启zookeeper以及kafka测试
- 开启zookeeper
- 开启kafka服务器
- 开启kafka生产者, 进行消费者读取
- 创建flume配置文件, 开启flume
- 开启kafka进行日志写入测试
- 脚本添加以及supervisor管理
关于kafka,我之前也安装完毕,其实也测试成功了,我觉得应该按照下面的流程走,我这边没问题。 看下流程:
开启zookeeper,需要在一直在服务器端实时运行,以守护进程运行, 这里的目录先用人家的了,因为我没有具体调试,后面会进行修改:
/root/bigdata/kafka/bin/zookeeper-server-start.sh -daemon /root/bigdata/kafka/config/zookeeper.properties
这里开启的是kafka本身的zookeeper服务, 还不是HBase的那个zookeeper来,这里要注意一下。
以及kafka的测试:
/root/bigdata/kafka/bin/kafka-server-start.sh /root/bigdata/kafka/config/server.properties
测试:
开启消息生产者
/root/bigdata/kafka/bin/kafka-console-producer.sh --broker-list 192.168.19.19092 --sync --topic click-trace
开启消费者
/root/bigdata/kafka/bin/kafka-console-consumer.sh --bootstrap-server 192.168.19.137:9092 --topic click-trace
这里只是一个测试,通过这个测试,看看kafka的生产者和消费者能不能走通。
下面就是对接到我们这里的任务了。
修改原来收集日志的文件,添加flume收集日志行为到kafka的source, channel, sink, 这个就是修改flume的配置文件 flume/conf/collect_click.conf
,之前是把日志收集到了hadoop(hdfs)上,这里也要收集一份到kafka里面去。数据来源不用改, sinks会加一个k2, channels会加一个c2.
a1.sources = s1
a1.sinks = k1 k2
a1.channels = c1 c2
a1.sources.s1.channels= c1 c2
a1.sources.s1.type = exec
a1.sources.s1.command = tail -F /root/logs/userClick.log
a1.sources.s1.interceptors=i1 i2
a1.sources.s1.interceptors.i1.type=regex_filter
a1.sources.s1.interceptors.i1.regex=\\{.*\\}
a1.sources.s1.interceptors.i2.type=timestamp
# channel1
a1.channels.c1.type=memory
a1.channels.c1.capacity=30000
a1.channels.c1.transactionCapacity=1000
# channel2
a1.channels.c2.type=memory
a1.channels.c2.capacity=30000
a1.channels.c2.transactionCapacity=1000
# k1
a1.sinks.k1.type=hdfs
a1.sinks.k1.channel=c1
a1.sinks.k1.hdfs.path=hdfs://192.168.19.137:9000/user/hive/warehouse/profile.db/user_action/%Y-%m-%d
a1.sinks.k1.hdfs.useLocalTimeStamp = true
a1.sinks.k1.hdfs.fileType=DataStream
a1.sinks.k1.hdfs.writeFormat=Text
a1.sinks.k1.hdfs.rollInterval=0
a1.sinks.k1.hdfs.rollSize=10240
a1.sinks.k1.hdfs.rollCount=0
a1.sinks.k1.hdfs.idleTimeout=60
# k2
a1.sinks.k2.channel=c2
a1.sinks.k2.type=org.apache.flume.sink.kafka.KafkaSink
a1.sinks.k2.kafka.bootstrap.servers=192.168.19.137:9092
a1.sinks.k2.kafka.topic=click-trace # 行为数据都在这个topic当中,其他的主题读不到
a1.sinks.k2.kafka.batchSize=20
a1.sinks.k2.kafka.producer.requiredAcks=1
接下来,开启flume新的配置进行测试, 开启之前,关闭原来的flume程序,这个是在supervisor里面的status一下, 然后看看collect-click是不是开着,如果开着的话stop collect-click。
下面这个collect-click脚本,不用动, 这个是第二篇文章里面设置的那个,这里不用动,因为我们修改了flume的配置,这里还是正常开启flume即可。
#!/usr/bin/env bash
export JAVA_HOME=/root/bigdata/jdk
export HADOOP_HOME=/root/bigdata/hadoop
export PATH=$PATH:$JAVA_HOME/bin:$HADOOP_HOME/bin
/root/bigdata/flume/bin/flume-ng agent -c /root/bigdata/flume/conf -f /root/bigdata/flume/conf/collect_click.conf -Dflume.root.logger=INFO,console -name a1
接下来, 开启kafka进行日志写入测试:
开启kafka的脚本测试, 为了保证每次能够正常把zookeeper也放入脚本中,关闭之前的zookeeper, 统一在kafka的开启脚本中加入zookeeper。
在项目toutiao_project下面的scripts脚本文件里面,新建一个start_kafka.sh
,写入下面代码:这个脚本是专门开启kafka的。
#!/usr/bin/env bash
/root/bigdata/kafka/bin/zookeeper-server-start.sh -daemon /root/bigdata/kafka/config/zookeeper.properties
/root/bigdata/kafka/bin/kafka-server-start.sh /root/bigdata/kafka/config/server.properties
/root/bigdata/kafka/bin/kafka-topics.sh --zookeeper 192.168.19.137:2181 --create --replication-factor 1 --topic click-trace --partitions 1
4.2.2 supervisor添加脚本
添加supervisor的脚本, 在/etc/supervisor/recol.conf文件中添加下面代码:(当然我的大数据环境不是这个,到时候我得参考我前面的第二篇文章进行修改)
[program:kafka]
command=/bin/bash /root/toutiao_project/scripts/start_kafka.sh
user=root
autorestart=true
redirect_stderr=true
stdout_logfile=/root/logs/kafka.log
loglevel=info
stopsignal=KILL
stopasgroup=true
killasgroup=true
supervisor进行update。开启顺序如下:
4.2.3 测试
开启kafka消费者
/root/bigdata/kafka/bin/kafka-console-consumer.sh --bootstrap-server 192.168.19.137:9092 --topic click-trace
写入一次点击数据:
echo {\"actionTime\":\"2019-04-10 21:04:39\",\"readTime\":\"\",\"channelId\":18,\"param\":{\"action\": \"click\", \"userId\": \"2\", \"articleId\": \"14299\", \"algorithmCombine\": \"C2\"}} >> userClick.log
这里的userClick.log是flume进行收集日志的地方, 这里写完了之后,就会发现在kafka的消费者那边的窗口下会出来这条数据,说明kafka可以读到flume收集的日志了。这里要注意下上面这个代码执行完了之后,其实是有两条数据线走向的, 因为flume是有两个channel和sink的:
- flume -> hadoop -> user_action
- flume -> kafka(click_trace主题下) -> 消费者读取消费(spark streaming或者storm等)
在后面的在线内容召回,文章,热门召回,要保证上面的那两个进程collect-click和kafka是开启的。
测通了之后,下面就看看怎么去基于用户的点击文章内容,去召回新文章过来了。看看spark streaming是怎么消费的。
4.3 实时召回业务的实现
实时召回会用基于画像相似的文章推荐, 在头条项目里面创建online文件夹,建立在线实时处理程序
目的: 上面用户行为点击日志已经可以通过flume进kafka了, 而下面就是用spark streaming读取kafka当中某个topic当中的日志行为数据(曝光,点击,分享等),计算该用户操作的这篇文章的相似文章给用户推荐(直接写入cb_recall, online在线内容召回)
步骤:
- 配置spark streaming信息
- 读取点击行为日志数据, 获取相似文章列表
- 过滤历史文章集合
- 存入召回结果以及历史记录结果
下面一一来看。
4.3.1 创建spark streaming配置信息以及happybase
导入默认的配置,SPARK_ONLINE_CONFIG, 在seeting目录下面的default.py
文件内加入:
# 增加spark online 启动配置
class DefaultConfig(object):
"""默认的一些配置信息
"""
SPARK_ONLINE_CONFIG = (
("spark.app.name", "onlineUpdate"), # 设置启动的spark的app名称,没有提供,将随机产生一个名称
("spark.master", "yarn"),
("spark.executor.instances", 4)
)
在online目录下面创建一个__init__.py
文件, 在这里面统一配置StreamingContext, 导入模块时就可以直接使用了,和离线的那个sparkSession的目的差不多:
# 添加sparkstreaming启动对接kafka的配置
from pyspark import SparkConf
from pyspark.sql import SparkSession
from pyspark import SparkContext
from pyspark.streaming import StreamingContext
from pyspark.streaming.kafka import KafkaUtils
from setting.default import DefaultConfig
import happybase
# 用于读取hbase缓存结果配置
pool = happybase.ConnectionPool(size=10, host='hadoop-master', port=9090)
# 1、创建conf
conf = SparkConf()
conf.setAll(DefaultConfig.SPARK_ONLINE_CONFIG)
# 建立spark session以及spark streaming context
sc = SparkContext(conf=conf)
# 创建Streaming Context 这个东西就类似于离线的按个SparkSession
stream_c = StreamingContext(sc, 60) # 这个60是个时间, 等过60秒时候, SparkStreaming就会自动与spark断开连接
配置streaming 读取Kafka的配置,在配置文件中增加KAFKAIP和端口, 端口是在default.py里面加入:
# KAFKA配置
# 指定要读数据的端口号,生产者把数据生产到9092, 这是kafka的默认端口, 所以消费者streaming读数据的时候,也是去这个端口读
KAFKA_SERVER = "192.168.19.137:9092"
kafka的读取配置,是在__init__.py
里面加:
# 基于内容召回配置,用于收集用户行为,获取相似文章实时推荐
similar_kafkaParams = {"metadata.broker.list": DefaultConfig.KAFKA_SERVER, "group.id": 'similar'}
# 这里的SIMILAR_DS就相当于一个消费者了,它会从指定的主题,指定的组里面读取数据
SIMILAR_DS = KafkaUtils.createDirectStream(stream_c, ['click-trace'], similar_kafkaParams)
这里涉及到了一个kafka里面数据分组的问题, 一条点击日志如果有多个消费者使用的时候怎么办呢?
所以这里就用到了这个group.id
参数, 表示属于哪个组里面。 当然你说,为啥不弄成两个主题呢? 当然是可以的,但是为了节约资源,往往不会对同一条数据分成两个主题供两个消费者去消费,而是在一个主题下分两个组供两个消费者消费。 如果不分组的时候是啥情况呢? 不分组的话,一个消费者拿去消费了之后,这条数据就没了,另个消费者就没法用了。 而分成了两个组之后,这里会拷贝一份数据,相当于两个组里面都可以拿到数据去消费。
4.3.2 创建online_update文件,建立在线召回类
这个是在online目录下创建online_update.py文件,然后加入下面的代码:
import os
import sys
BASE_DIR = os.path.dirname(os.getcwd())
sys.path.insert(0, os.path.join(BASE_DIR))
print(BASE_DIR)
PYSPARK_PYTHON = "/miniconda2/envs/reco_sys/bin/python"
# 当存在多个版本时,不指定很可能会导致出错
os.environ["PYSPARK_PYTHON"] = PYSPARK_PYTHON
os.environ["PYSPARK_DRIVER_PYTHON"] = PYSPARK_PYTHON
# 注意,如果是使用jupyter或ipython中,利用spark streaming链接kafka的话,必须加上下面语句
# 同时注意:spark version>2.2.2的话,pyspark中的kafka对应模块已被遗弃,因此这里暂时只能用2.2.2版本的spark
os.environ["PYSPARK_SUBMIT_ARGS"] = "--packages org.apache.spark:spark-streaming-kafka-0-8_2.11:2.2.2 pyspark-shell"
from online import stream_sc, SIMILAR_DS, pool
from setting.default import DefaultConfig
from datetime import datetime
import setting.logging as lg
import logging
import redis
import json
import time
上面是导入的一些包,接下来编写OnlineRecall类,代码如下,也是在上面的文件里加入,但这里注意一点,我们在kafka中得到的数据长这样,这是一个rdd
2019-03-05 10:19:40 0 {"action":"exposure","userId":"2","articleId":"[16000, 44371, 16421, 16181, 17454]","algorithmCombine":"C2"} 2019-03-05
Time taken: 3.72 seconds, Fetched: 1 row(s)
所以下面的处理逻辑代码是这样子: 首先拿到这样的一行rdd, 然后判断一下点击的行为日志类型,如果点击了或者收藏或者是分享,也就是与文章有互动了,这时候我们就打开HBase中存储的article_similar表, 从这里面找到与当前文章相似的K篇文章,先拿出来。
然后再从history_recall表里面找到历史推荐过的文章,如果发现上面的K篇文章有在历史文章的,那么就进行过滤。接下来,就把剩下的文章进行召回, 存入HBase的cb_recall Table,这是在第六篇文章里面建立的一个表,存储多路召回的结果:
这里会有一个online的列族, 我们把实时内容召回的文章写到这个里面去,同时还要写入到历史召回表里面去。下面就是详细的逻辑:
class OnlineRecall(object):
"""在线处理计算平台
1. 在线内容召回, 实时写入用户点击或者操作文章的相似文章
2. 在线新文章召回
3. 在线热门文章召回
"""
def __init__(self):
pass
def _update_online_cb(self):
"""
通过点击行为更新用户的cb召回表中的online召回结果
:return:
"""
def foreachFunc(rdd):
# rdd ---- > 数据本身 rdd.collect()
# [row(1,2,3), row(4,5,2)] ---->[[1,2,3], [4,5,2]]
import hadppbase
# 初始化happybase连接
pool = happybase.ConnectionPool(size=10, host='hadoop-master', port=9090)
for data in rdd.collect():
logger.info(
"{}, INFO: rdd filter".format(datetime.now().strftime('%Y-%m-%d %H:%M:%S')))
# 判断日志行为类型,只处理点击流日志
if data["param"]["action"] in ["click", "collect", "share"]:
# print(data)
with pool.connection() as conn:
try:
# 相似文章表
sim_table = conn.table("article_similar")
# 根据用户点击流日志涉及文章找出与之最相似文章(基于内容的相似),选取TOP-k相似的作为召回推荐结果
_dic = sim_table.row(str(data["param"]["articleId"]).encode(), columns=[b"similar"])
_srt = sorted(_dic.items(), key=lambda obj: obj[1], reverse=True) # 按相似度排序
if _srt:
topKSimIds = [int(i[0].split(b":")[1]) for i in _srt[:self.k]]
# 根据历史推荐集过滤,已经给用户推荐过的文章
history_table = conn.table("history_recall")
_history_data = history_table.cells(
b"reco:his:%s" % data["param"]["userId"].encode(),
b"channel:%d" % data["channelId"]
)
# print("_history_data: ", _history_data)
history = []
if len(_history_data) > 1:
for l in _history_data:
history.extend(l)
# 根据历史召回记录,过滤召回结果
recall_list = list(set(topKSimIds) - set(history_data))
# print("recall_list: ", recall_list)
logger.info("{}, INFO: store user:{} cb_recall data".format(datetime.now().strftime('%Y-%m-%d %H:%M:%S'), data["param"]["userId"]))
if recall_list:
# 如果有推荐结果集,那么将数据添加到cb_recall表中,同时记录到历史记录表中
logger.info(
"{}, INFO: get online-recall data".format(datetime.now().strftime('%Y-%m-%d %H:%M:%S')))
recall_table = conn.table("cb_recall")
recall_table.put(
b"recall:user:%s" % data["param"]["userId"].encode(),
{b"online:%d" % data["channelId"]: str(recall_list).encode()}
)
history_table.put(
b"reco:his:%s" % data["param"]["userId"].encode(),
{b"channel:%d" % data["channelId"]: str(recall_list).encode()}
)
except Exception as e:
logger.info("{}, WARN: {}".format(datetime.now().strftime('%Y-%m-%d %H:%M:%S'), e))
finally:
conn.close()
# 我们开始拿过来的是一个json字符串
# 所以我们这里要进行一个格式的转换
# Streaming接收的时候,内容是两个部分
# x: [标记结果, 'json......'] 我们是要拿到这个1
# x 可以是多次点击的行为数据,同时拿到多条数据
SIMILAR_DS.map(lambda x: json.loads(x[1])).foreachRDD(foreachFunc)
return None
SparkStream一般是实时运行,因为要实时监控kafka里面的数据, 所以这里开启实时运行,同时增加日志打印, 主函数如下:
if __name__ == '__main__':
# 启动日志配置
lg.create_logger()
op = OnlineRecall() # 创建实时召回对象
op._update_online_cb() # 调用实时召回文章的方法
stream_c.start() # 开启spark streaming
# 使用 ctrl+c 可以退出服务 # 下面是让他实时运行
_ONE_DAY_IN_SECONDS = 60 * 60 * 24
try:
while True:
time.sleep(_ONE_DAY_IN_SECONDS)
except KeyboardInterrupt:
server.stop(0)
日志文件添加如下:
# 添加到需要打印日志内容的文件中
logger = logging.getLogger('online')
# 下面这个添加到setting目录下的logging.py里面
# 在线更新日志
# 离线处理更新打印日志
trace_file_handler = logging.FileHandler(
os.path.join(logging_file_dir, 'online.log')
)
trace_file_handler.setFormatter(logging.Formatter('%(message)s'))
log_trace = logging.getLogger('online')
log_trace.addHandler(trace_file_handler)
log_trace.setLevel(logging.INFO)
这样,实时内容召回就做完了, 下面是热门文章与新文章的召回。
4.4 热门与新文章召回
这里会使用spark streaming完成召回创建。
对于热门文章的记录: 通过对日志数据的处理, 来实时增加文章的点击次数等信息。
4.4.1 热门文章和新文章怎么去存
新文章由头条后台审核通过的文章传入kafka。这里热门文章和新文章召回的结果,我们直接存入redis中。相关的键值看下:
对于最新的文章存入redis, 我们使用了一个zadd函数, 这里的键是ch{}:new, 后面直接跟着值{article:time.time()})
# 新文章存储
# ZADD ZRANGE
# ZADD key score member [[score member] [score member] ...]
# ZRANGE page_rank 0 -1
client.zadd("ch:{}:new".format(channel_id), {article_id: time.time()})
而对于热门文章,我们用一个zincrby函数, 这里要保持自增,也就是如果多个用户都点击了这篇文章,我们过来一个就要保证自增一下点击次数, 所以这里的这个函数这么玩:
# 热门文章存储
# ZINCRBY key increment member
# ZSCORE
# 为有序集 key 的成员 member 的 score 值加上增量 increment 。
client.zincrby("ch:{}:hot".format(row['channelId']), 1, row['param']['articleId'])
# ZREVRANGE key start stop [WITHSCORES]
client.zrevrange(ch:{}:new, 0, -1)
下面依然是先配置信息。
4.4.2 添加热门及新文章kafka配置信息
这里的配置也是在online目录下面的__init__.py
中添加:
# hot文章的读取配置
# 添加sparkstreaming启动对接kafka的配置
# 配置KAFKA相关,用于热门文章KAFKA读取
click_kafkaParams = {"metadata.broker.list": DefaultConfig.KAFKA_SERVER}
# 这里又建了一个消费者, 这里采用了默认分组
HOT_DS = KafkaUtils.createDirectStream(stream_c, ['click-trace'], click_kafkaParams)
# new-article,新文章的读取 KAFKA配置
# 这里新开了一个主题
NEW_ARTICLE_DS = KafkaUtils.createDirectStream(stream_c, ['new-article'], click_kafkaParams)
并且导入相关包:
from online import HOT_DS, NEW_ARTICLE_DS
下面得在kafka的启动脚本中添加新增主题的启动,注意此时的先关掉原来的flume和kafka,加上下面的代码,重新启动。在scripts的start_kafka.sh脚本中添加下面的命令,注释不要添加进去
# 增加一个新文章的topic,这里会与后台对接
/root/bigdata/kafka/bin/kafka-topics.sh --zookeeper 192.168.19.137:2181 --create --replication-factor 1 --topic new-article --partitions 1
4.4.3 编写热门文章收集程序
在线实时进行Redis读取存储:,首先在settinig目录下面的default.py
下面加Redis的端口配置:
# redis IP 和端口配置
REDIS_HOST = '192.168.19.137'
REDIS_PORT = 6379
下面是在线热门文章召回的代码:
from setting.default import DefaultConfig
class OnlineRecall(object):
"""实时处理(流式计算)部分
"""
def __init__(self):
self.client = redis.StrictRedis(host=DefaultConfig.REDIS_HOST,
port=DefaultConfig.REDIS_PORT,
db=10)
# 在线召回筛选TOP-k个结果
self.k = 20
下面是收集热门文章的代码:
def _update_hot_redis(self):
"""更新热门文章 click-trace
收集用户行为,更新热门文章
:return:
"""
client = self.client
def updateHotArt(rdd):
for row in rdd.collect():
logger.info("{}, INFO: {}".format(datetime.now().strftime('%Y-%m-%d %H:%M:%S'), row))
# 如果是曝光参数,和阅读时长选择过滤
if row['param']['action'] == 'exposure' or row['param']['action'] == 'read':
pass
else:
# 解析每条行为日志,然后进行分析保存点击,喜欢,分享次数,这里所有行为都自增1
client.zincrby("ch:{}:hot".format(row['channelId']), 1, row['param']['articleId'])
HOT_DS.map(lambda x: json.loads(x[1])).foreachRDD(updateHotArt)
return None
拿下面的结果测试:
echo {\"actionTime\":\"2019-04-10 21:04:39\",\"readTime\":\"\",\"channelId\":18,\"param\":{\"action\": \"click\", \"userId\": \"2\", \"articleId\": \"14299\", \"algorithmCombine\": \"C2\"}} >> userClick.log
看看打印日志结果,以及Redis是否存入了结果热门文章:
4.4.4 编写新文章收集程序
新文章如何而来,黑马头条后台在文章发布之后,会将新文章ID以固定格式传到KAFKA的new-article topic当中, 这个是需要和web后台协商的。
新文章的代码如下:
def _update_new_redis(self):
"""更新频道新文章 new-article
:return:
"""
client = self.client
def computeFunction(rdd):
for row in rdd.collect():
channel_id, article_id = row.split(',')
logger.info("{}, INFO: get kafka new_article each data:channel_id{}, article_id{}".format(
datetime.now().strftime('%Y-%m-%d %H:%M:%S'), channel_id, article_id))
client.zadd("ch:{}:new".format(channel_id), {article_id: time.time()})
# 这里不需要json.load了,这里就是一个传了个字符串 'article_id, time'
NEW_ARTICLE_DS.map(lambda x: x[1]).foreachRDD(computeFunction)
return None
测试: 这里安装一个包:pip install kafka-python
查看所有本地topic的情况:
from kafka import KafkaClient
client = KafkaClient(hosts="127.0.0.1:9092")
for topic in client.topics:
print topic
下面构建一个kafka生产者,生产一条数据, 表示新发了一篇文章
from kafka import KafkaProducer
# kafka消息生产者
kafka_producer = KafkaProducer(bootstrap_servers=['192.168.19.137:9092'])
# 构造消息并发送
msg = '{},{}'.format(18, 13891)
kafka_producer.send('new-article', msg.encode())
这时候看看再Redis中能否得到结果:
4.4.5 添加supervisor在线实时运行进程管理
把上面的实时新文章和热门文章的收集函数汇总到之前的那个OnlineRecall类里面。 然后在online_update.py
主函数中加入
if __name__ == '__main__':
# 启动日志配置
lg.create_logger()
op = OnlineRecall() # 创建实时召回对象
op._update_online_cb() # 调用实时召回文章的方法
op._update_hot_redis() # 热门文章收集
op._update_new_redis() # 新文章收集
stream_c.start() # 开启spark streaming
# 使用 ctrl+c 可以退出服务 # 下面是让他实时运行
_ONE_DAY_IN_SECONDS = 60 * 60 * 24
try:
while True:
time.sleep(_ONE_DAY_IN_SECONDS)
except KeyboardInterrupt:
server.stop(0)
下面还需要添加到supervisor进行实时运行,在线的实时计算业务,是需要一直去开启运行的。注意,这里就不是定时每隔多长时间运行了,而是实时在运行。
etc/supervisor/reco.conf
增加以下配置
# 这里的环境要制定好
[program:online]
environment=JAVA_HOME=/root/bigdata/jdk,SPARK_HOME=/root/bigdata/spark,HADOOP_HOME=/root/bigdata/hadoop,PYSPARK_PYTHON=/miniconda2/envs/reco_sys/bin/python ,PYSPARK_DRIVER_PYTHON=/miniconda2/envs/reco_sys/bin/python,PYSPARK_SUBMIT_ARGS='--packages org.apache.spark:spark-streaming-kafka-0-8_2.11:2.2.2 pyspark-shell'
command=/miniconda2/envs/reco_sys/bin/python /root/toutiao_project/reco_sys/online/online_update.py
directory=/root/toutiao_project/reco_sys/online
user=root
autorestart=true
redirect_stderr=true
stdout_logfile=/root/logs/onlinesuper.log # 再次强调下这个日志和我们自己定义的那个是不一样的,这个日志表示的是在启动过程中各个组件的日志记录, 而我们自定义的是运行程序的时候,打印的一些输出日志
loglevel=info
stopsignal=KILL
stopasgroup=true
killasgroup=true
然后在supervisor中update下。
新文章收集逻辑总体写下:
- 协商好新文章发送格式以及topic
- 重新开启kafka, 配置好kafka新的topic
- streaming程序对接读取逻辑, 存储Redis, 实时运行程序,检测数据是否有新数据到来。
5. 小总
今天的内容属实有些多,从离线排序模块 -> 特征中心的维护与实时更新 -> 在线计算的业务逻辑 -> 三种召回方式去解决用户冷启动,技术细节也是非常多, 下面依然是一张导图把知识拎起来:
参考: