1. 写在前面
这几天打算整理一个模拟真实情景进行广告推荐的一个小Demon, 这个项目使用的阿里巴巴提供的一个淘宝广告点击率预估的数据集, 采用lambda架构,实现一个离线和在线相结合的实时推荐系统,对非搜索类型的广告进行点击率预测和推荐(没有搜索词,没有广告的内容特征信息)。这个感觉挺接近于工业上的那种推荐系统了,通过这个推荐系统,希望能从工程的角度了解推荐系统的流程,也顺便学习一下大数据的相关技术,这次会涉及到大数据平台上的数据处理, 离线处理业务和在线处理业务, 涉及到的技术包括大数据的各种技术,包括Hadoop,Spark(Spark SQL, Spqrk ML, Spark-Streaming), Redis,Hive,HBase,Kafka和Flume等, 机器学习的相关技术(数据预处理,模型的离线训练和在线更新等。所以这几天的时间借机会走一遍这个流程,这里也详细记录一下,方便以后回看和回练, 这次的课程是跟着B站上的一个课程走的, 讲的挺详细的,就是没有课件和资料,得需要自己搞,并且在实战这次的推荐系统之前,最好是有一整套的大数据环境(我已经搭建好了), 然后就可以来玩这个系统了哈哈, 现在开始 😉
上一篇文章已经完成了排序模型部分的数据准备工作,数据集里面的另外三个分析且数据预处理了一下,保存了起来。这个过程走的异常的艰难, 但是走平了之后,后面的内容相对就好走一些了。比如今天的这篇文章, 主要是用LR实现CTR预估的操作, 重点就是学习一下逻辑回归模型进行点击率预测的流程,别忘了这里用的可是分布式,pyspark.ml里面的逻辑回归模型, 需要有一套处理流程的,且和sklearn里面的逻辑回归在处理上有些不太一样, 下面看一下。
这篇文章逻辑简单,首先先用一个简单的例子数据集走一遍逻辑回归点击率预测的流程,然后再放上真实的电商数据集进行逻辑回归的预测任务。
- Spark逻辑回归模型使用流程梳理
- 基于LR的点击率预测模型训练
Ok, let’s go!
开启三台虚拟机,开启Hadoop集群,spark集群,开启远程jupyter, 配置pyspark,然后开始。
2. Spark逻辑回归模型使用流程梳理
这个是先用一个简单的自制数据集来走一遍逻辑回归分类的流程,首先准备的数据集长下面这个样子:
# 样本数据集
sample_dataset = [
(0, "male", 37, 10, "no", 3, 18, 7, 4),
(0, "female", 27, 4, "no", 4, 14, 6, 4),
(0, "female", 32, 15, "yes", 1, 12, 1, 4),
(0, "male", 57, 15, "yes", 5, 18, 6, 5),
(0, "male", 22, 0.75, "no", 2, 17, 6, 3),
(0, "female", 32, 1.5, "no", 2, 17, 5, 5),
(0, "female", 22, 0.75, "no", 2, 12, 1, 3),
(0, "male", 57, 15, "yes", 2, 14, 4, 4),
(0, "female", 32, 15, "yes", 4, 16, 1, 2),
(0, "male", 22, 1.5, "no", 4, 14, 4, 5),
(0, "male", 37, 15, "yes", 2, 20, 7, 2),
(0, "male", 27, 4, "yes", 4, 18, 6, 4),
(0, "male", 47, 15, "yes", 5, 17, 6, 4),
(0, "female", 22, 1.5, "no", 2, 17, 5, 4),
(0, "female", 27, 4, "no", 4, 14, 5, 4),
(0, "female", 37, 15, "yes", 1, 17, 5, 5),
(0, "female", 37, 15, "yes", 2, 18, 4, 3),
(0, "female", 22, 0.75, "no", 3, 16, 5, 4),
(0, "female", 22, 1.5, "no", 2, 16, 5, 5),
(0, "female", 27, 10, "yes", 2, 14, 1, 5),
(1, "female", 32, 15, "yes", 3, 14, 3, 2),
(1, "female", 27, 7, "yes", 4, 16, 1, 2),
(1, "male", 42, 15, "yes", 3, 18, 6, 2),
(1, "female", 42, 15, "yes", 2, 14, 3, 2),
(1, "male", 27, 7, "yes", 2, 17, 5, 4),
(1, "male", 32, 10, "yes", 4, 14, 4, 3),
(1, "male", 47, 15, "yes", 3, 16, 4, 2),
(0, "male", 37, 4, "yes", 2, 20, 6, 4)
]
columns = ["affairs", "gender", "age", "label", "children", "religiousness", "education", "occupation", "rating"]
# pandas构建DataFrame
pdf = pd.DataFrame(sample_dataset, columns=columns)
# 转成spark的DataFrame
df = spark.createDataFrame(pdf)
这里的建立spark DataFrame的一个技巧是从pandas的DataFrame进行转,速度快且方便,因为如果直接建立spark的DataFrame,需要定义schema,就是读入的模式,毕竟默认都是字符串类型的。比较麻烦,而这里相对容易。看眼数据:
下面确定label为affairs列,然后其他列作为特征值, 这里选择出几个特征来看看。
# 特征选取:affairs为目标值,其余为特征值
df2 = df.select("affairs","age", "religiousness", "education", "occupation", "rating")
# 用于计算特征向量的字段
colArray2 = ["age", "religiousness", "education", "occupation", "rating"]
spark里面的逻辑回归用的时候需要一点,就是需要计算特征向量,也就是要把特征合并到一起来。使用下面的操作VectorAssembler
,这个要记好:
from pyspark.ml.feature import VectorAssembler
# 计算出特征向量 这个操作必须要做 输入特征,输出label
df3 = VectorAssembler().setInputCols(colArray2).setOutputCol("features").transform(df2)
df3.show(truncate=False)
看眼处理后的向量特征:
就是把前面的几列特征都放到一块去。逻辑回归模型的输入特征,要符合这个features的格式。
接下来就是切分数据集了, 这里用到了randomSplit()
函数,看代码:
trainDF, testDF = df3.randomSplit([0.8, 0.2])
看下训练集和测试集:
下面就是逻辑回归的训练过程了, 这个就比较简单了:
from pyspark.ml.classification import LogisticRegression
# 创建逻辑回归训练器
lr = LogisticRegression()
# 训练模型
model = lr.setLabelCol("affairs").setFeaturesCol("features").fit(trainDF)
# 预测数据
model.transform(testDF).show(5)
我们看下预测结果:
竟然这几个都预测对了,厉害了哈。
上面就是应用逻辑回归的一个小demo,下面我们梳理一下spark的LR模型的流程
-
①准备数据,准备一个dataframe,所有的特征放到dataframe的一列中,目标放到dataframe的一列中, 用的
VectorAssembler()
函数。df3 = VectorAssembler().setInputCols(colArray2).setOutputCol('feautures').transform(df2)
-
② 创建 LogisticRegression对象训练模型
lr = LogisticRegression() model = lr.setLabelCol('affairs').setFeaturesCol('feautures').fit(trainDF)
-
③ 预测数据
model.transform(testDF).show(5)
还是比较简单的,下面就来看看我们的电商广告的数据集了。
3. 基于LR的点击率预测模型训练
这里我们用了上一篇处理的三个数据集,广告点击样本数据集(raw_sample)、广告基本特征数据集(ad_feature)、用户基本信息数据集(user_profile), 基于这三个构建出了一个完整的样本数据集,并按日期划分为了训练集(前七天)和测试集(最后一天),利用逻辑回归进行训练。训练模型时,通过对类别特征数据进行处理,一定程度达到提高了模型的效果。
我们由于有了上一篇的工作,这里就直接从本地导入三个数据,然后构建成spark的DataFrame,这里没有按照它教程上的把之前的再来一遍。也不知道行不行, 先走再说,上代码:
# 读入数据,并转成spark的DataFrame
filepath = "preprocessdata/"
new_ad_feature = pd.read_csv(filepath + "new_ad_feature.csv")
new_raw_sample = pd.read_csv(filepath + "new_raw_sample.csv")
new_user_profile = pd.read_csv(filepath + "new_user_profile.csv")
ad_feature_df = spark.createDataFrame(new_ad_feature)
raw_sample_df = spark.createDataFrame(new_raw_sample)
user_profile_df = spark.createDataFrame(new_user_profile)
这样数据都导入进来了, 只不过之前的那些向量格式类型的都成了字符串了。这里估计后期这两个需要重新one-hot处理。
果真,后面趟了一遍之后,发现这里需要先处理,也就是这俩string,得去掉,然后重新one-hot转。所以对于raw_sample_df
来说, 需要做下面处理:
raw_sample_df = raw_sample_df.drop('pid_value')
raw_sample_df = raw_sample_df.drop('pid_feature')
# StringIndexer对指定字符串列进行特征处理
stringindexer = StringIndexer(inputCol='pid', outputCol='pid_feature')
# 对处理出来的特征处理列进行,独热编码
encoder = OneHotEncoder(dropLast=False, inputCol='pid_feature', outputCol='pid_value')
pipeline = Pipeline(stages=[stringindexer, encoder])
pipeline_model = pipeline.fit(raw_sample_df)
new_raw_sample_df = pipeline_model.transform(raw_sample_df)
感受一下区别:
同理的,对于user_profile的那两个和消费水平相关的特征,也是同样的处理方式,先删除,然后重新转。
drop_cols = ['pl_onehot_feature', 'pl_onehot_value', 'nucl_onehot_feature', 'nucl_onehot_value']
for col in drop_cols:
user_profile_df = user_profile_df.drop(col)
user_profile_df = user_profile_df.withColumn("pvalue_level", user_profile_df.pvalue_level.cast(StringType()))\
.withColumn("new_user_class_level", user_profile_df.new_user_class_level.cast(StringType()))
stringindexer = StringIndexer(inputCol='pvalue_level', outputCol='pl_onehot_feature')
encoder = OneHotEncoder(dropLast=False, inputCol='pl_onehot_feature', outputCol='pl_onehot_value')
pipeline = Pipeline(stages=[stringindexer, encoder])
pipeline_fit = pipeline.fit(user_profile_df)
user_profile_df1 = pipeline_fit.transform(user_profile_df)
stringindexer = StringIndexer(inputCol='new_user_class_level', outputCol='nucl_onehot_feature')
encoder = OneHotEncoder(dropLast=False, inputCol='nucl_onehot_feature', outputCol='nucl_onehot_value')
pipeline = Pipeline(stages=[stringindexer, encoder])
pipeline_fit = pipeline.fit(user_profile_df1)
user_profile = pipeline_fit.transform(user_profile_df1)
下面进行DataFrame的数据合并,这里用到了join
函数, 具体的文档可以参考这里 pyspark.sql.DataFrame.join, 首先是把raw_sample_df
和ad_feature_df
合并, 也就是把后者拼到前者的身上。
# 下面数据合并 raw_sample_df和ad_feature_df, join用法类似于pandas的merge
# 参数1: 另一个表
# 参数2: 连接条件
# 参数3: 连接方式
condition = [new_raw_sample_df.adgroupId == ad_feature_df.adgroupId]
_ = new_raw_sample_df.join(ad_feature_df, condition, 'outer')
# _和user_profile_df合并条件
condition2 = [_.userId==user_profile.userId]
datasets = _.join(user_profile, condition2, "outer")
这里的join
函数体现出来了。和pandas的merge函数很像。这样数据都合到了一块。
这里的数据量还是挺大的,200多万条数据,字段很多,下面去除掉一些不需要的字段。比如user_profile表,除了前面处理的pvalue_level
和new_user_class_level
需要作为特征以外, 这两个关于消费水平的特征能体现用户的购买能力,下面再分析其他几个类别的数值情况
- cms_segid: 97
- cms_group_id: 13
- final_gender_code: 2
- age_level: 7
- shopping_level: 3
- occupation: 2
- pvalue_level
- new_user_class_level
- price
根据经验,以上几个分类特征都一定程度能体现用户在购物方面的特征,且类别都较少,都可以用来作为用户特征。根据这样的分析,人为的去掉一些之后,进行特征选择:
# 剔除冗余、不需要的字段
useful_cols = [
#
# 时间字段,划分训练集和测试集
"timestamp",
# label目标值字段
"clk",
# 特征值字段
"pid_value", # 资源位的特征向量
"price", # 广告价格
"cms_segid", # 用户微群ID
"cms_group_id", # 用户组ID
"final_gender_code", # 用户性别特征,[1,2]
"age_level", # 年龄等级,1-
"shopping_level",
"occupation",
"pl_onehot_value",
"nucl_onehot_value"
]
# 筛选指定字段数据,构建新的数据集
datasets_1 = datasets.select(*useful_cols)
# 由于前面使用的是outer方式合并的数据,产生了部分空值数据,这里必须先剔除掉
datasets_1 = datasets_1.dropna()
print("剔除空值数据后,还剩:", datasets_1.count())
## 210162
这里的选择某些列用的select
函数, 这里的时间特征帮助我们划分数据集, clk特征是目标字段, 剩下的特征值字段都是和用户或者广告强相关的数据。然后我们去除掉空值样本,得到了逻辑回归模型的最终数据集。这时候还有21万多数据,还行应该。
下面需要把用到的特征合并到一块,也就是计算特征向量,还记得上面的VectorAssembler
吗? 然后根据时间划分数据集, 把前七天的行为数据当做训练集,后面的1天当做测试集。
# 根据特征字段计算特征向量
datasets_1 = VectorAssembler().setInputCols(useful_cols[2:]).setOutputCol("features").transform(datasets_1)
这里如果不处理的话,就会报错IllegalArgumentException: 'Data type StringType is not supported.'
, 这里的原因是one-hot的这两个目前都是String Type
(从pandas的DataFrame转过来的锅),于是这里需要先处理上面的dataset数据集,然后再挑选列。
我这边之前处理了一下,就不报错了, 下面划分开数据集,然后逻辑回归来:
# 训练数据集: 约7天的数据
train_data = datasets_1.filter(datasets_1.timestamp<=(1494691186-24*60*60))
# 测试数据集:约1天的数据量
test_data = datasets_1.filter(datasets_1.timestamp>(1494691186-24*60*60))
# 所有的特征的特征向量已经汇总到在features字段中
from pyspark.ml.classification import LogisticRegression
lr = LogisticRegression()
# 设置目标字段、特征值字段并训练 features就是合并之后的所有特征的那个向量
model = lr.setLabelCol("clk").setFeaturesCol("features").fit(train_data)
# 模型保存
model.save("hdfs://master:9000/user/icss/RecommendSystem/model/CTRModel_Normal.obj")
下面模型预测:
#model = LogisticRegressionModel.load("hdfs://master:9000/user/icss/RecommendSystem/model/CTRModel_Normal.obj")
# 根据测试数据进行预测
result = model.transform(test_data)
# 按probability升序排列数据,probability表示预测结果的概率
# 如果预测值是0,其概率是0.9248,那么反之可推出1的可能性就是1-0.9248=0.0752,即点击概率约为7.52%
# 因为前面提到广告的点击率一般都比较低,所以预测值通常都是0,因此通常需要反减得出点击的概率
result.select("clk", "price", "probability", "prediction").sort("probability").show(5)
结果如下:
查看样本中点击的被实际点击的条目的预测情况:
这里需要注意的一点就是像点击率这种东西, 点击概率肯定是非常小非常小,所以预测值是0是正常的, 所以我们一般推荐的时候,不会看这种预测值,而是把点击概率从大到小排序(不点击概率从小到大排序), 然后把最靠前的多少个推给用户。所以这里评估模型好坏的时候, 就是先把测试集按照预测0概率的这一列从小到大排序,然后假设是给用户推10个,那么就取前10个, 看看用户真正点击的有几个, 然后就能算出模型的预测好坏来, 所以prediction根本就不参考,主要是看probability。
对了,这里看一下train_data,主要看看那个合并之后的特征features长啥样,后面要进行一些改进:
我们目前用了18个特征, 其实这里面还有很多类别的特征我们并没有进行one-hot处理,模型默认是当做连续特征算的,比如shopping_level这种,所以下面尝试优化一下,这这些类别型的特征都转成one-hot试试。
4. 尝试优化效果 — 训练CTRModel_AllOneHot
下面具体的分析一下每个特征, 然后尝试进行一些优化, 目前的特征处理情况:
- “pid_value”, 类别型特征,已被转换为多维特征==> 2维
- “price”, 统计型特征 ===> 1维
- “cms_segid”, 类别型特征,约97个分类 ===> 1维
- “cms_group_id”, 类别型特征,约13个分类 ==> 1维
- “final_gender_code”, 类别型特征,2个分类 ==> 1维
- “age_level”, 类别型特征,7个分类 ==> 1维
- “shopping_level”, 类别型特征,3个分类 ==> 1维
- “occupation”, 类别型特征,2个分类 ==> 1维
- “pl_onehot_value”, 类别型特征,已被转换为多维特征 ==> 4维
- “nucl_onehot_value” 类别型特征,已被转换为多维特征 ==> 5维
其实,上面那些少类别的特征都可以进行独热编码,将单一变量变为多变量,相当于增加了相关特征的数量。
- “cms_segid”, 类别型特征,约97个分类 ===> 97维 舍弃
- “cms_group_id”, 类别型特征,约13个分类 ==> 13维
- “final_gender_code”, 类别型特征,2个分类 ==> 2维
- “age_level”, 类别型特征,7个分类 ==>7维
- “shopping_level”, 类别型特征,3个分类 ==> 3维
- “occupation”, 类别型特征,2个分类 ==> 2维
但由于cms_segid分类过多,这里考虑舍弃,避免数据过于稀疏。 好, 下面按照上面同样的套路,进行OneHot,大体上五步搞定:
- 转数据类型,把类型转成字符串
- 独热编码函数编写和封装
- 独热编码
- 重新选择特征
- lr的训练和预测
下面开始搞,基于划分数据集之前的datasets_1
数据集。
首先,把上面的五个类型转成字符串数据:
# 先将下列五列数据转为字符串类型,以便于进行热独编码
# - "cms_group_id", 类别型特征,约13个分类 ==> 13
# - "final_gender_code", 类别型特征,2个分类 ==> 2
# - "age_level", 类别型特征,7个分类 ==>7
# - "shopping_level", 类别型特征,3个分类 ==> 3
# - "occupation", 类别型特征,2个分类 ==> 2
datasets_2 = datasets_1.withColumn("cms_group_id", datasets.cms_group_id.cast(StringType()))\
.withColumn("final_gender_code", datasets.final_gender_code.cast(StringType()))\
.withColumn("age_level", datasets.age_level.cast(StringType()))\
.withColumn("shopping_level", datasets.shopping_level.cast(StringType()))\
.withColumn("occupation", datasets.occupation.cast(StringType()))
下面写一个函数进行独热编码,由于这里是五个特征, 这里封装成一个函数, 且对五个字段进行编码工作:
def oneHotEncoder(col1, col2, col3, data):
stringindexer = StringIndexer(inputCol=col1, outputCol=col2)
encoder = OneHotEncoder(dropLast=False, inputCol=col2, outputCol=col3)
pipeline = Pipeline(stages=[stringindexer, encoder])
pipeline_fit = pipeline.fit(data)
return pipeline_fit.transform(data)
# 对这五个字段进行热独编码
# "cms_group_id",
# "final_gender_code",
# "age_level",
# "shopping_level",
# "occupation",
datasets_2 = oneHotEncoder("cms_group_id", "cms_group_id_feature", "cms_group_id_value", datasets_2)
datasets_2 = oneHotEncoder("final_gender_code", "final_gender_code_feature", "final_gender_code_value", datasets_2)
datasets_2 = oneHotEncoder("age_level", "age_level_feature", "age_level_value", datasets_2)
datasets_2 = oneHotEncoder("shopping_level", "shopping_level_feature", "shopping_level_value", datasets_2)
datasets_2 = oneHotEncoder("occupation", "occupation_feature", "occupation_value", datasets_2)
这个过程快速捕获了一下分布式的计算过程,原来是这个样子的:
下面看一下特征对应关系, 也就是每个特征值对应的one-hot之后的那个1的位置关系,因为之前说过,往redis存的时候,还是存的原特征,这样空间少,但是后面的这个对应关系得知道,方便后面one-hot特征的一个还原。
其他几个的操作类似:
datasets_2.groupBy("cms_group_id").min("cms_group_id_feature").show()
datasets_2.groupBy("final_gender_code").min("final_gender_code_feature").show()
datasets_2.groupBy("age_level").min("age_level_feature").show()
datasets_2.groupBy("shopping_level").min("shopping_level_feature").show()
datasets_2.groupBy("occupation").min("occupation_feature").show()
下面就是第三步,选字段特征, 这里保留那些OneHot之后的特征字段就可以了。
# 由于热独编码后,特征字段不再是之前的字段,重新定义特征值字段
feature_cols = [
# 特征值
"price",
"cms_group_id_value",
"final_gender_code_value",
"age_level_value",
"shopping_level_value",
"occupation_value",
"pid_value",
"pl_onehot_value",
"nucl_onehot_value"
]
datasets_2 = VectorAssembler().setInputCols(feature_cols).setOutputCol("features1").transform(datasets_2)
train_datasets_2 = datasets_2.filter(datasets_2.timestamp<=(1494691186-24*60*60))
test_datasets_2 = datasets_2.filter(datasets_2.timestamp>(1494691186-24*60*60))
下面就可以看一下, 处理之后的数据长什么样子了:
后面就是一样的操作套路了,训练逻辑回归模型,这里我们用的特征是features1,这里也保存一下这个模型,然后做预测,直接把所有代码放上了:
lr2 = LogisticRegression()
#设置目标值对应的列 setFeaturesCol 设置特征值对应的列名
model2 = lr2.setLabelCol("clk").setFeaturesCol("features1").fit(train_datasets_2)
# 存储模型
model2.save("hdfs://master:9000/user/icss/RecommendSystem/model/CTRModel_AllOneHot.obj")
# 载入训练好的模型
#model2 = LogisticRegressionModel.load("hdfs://localhost:9000/models/CTRModel_AllOneHot.obj")
result_2 = model2.transform(test_datasets_2)
# 按probability升序排列数据,probability表示预测结果的概率
result_2.select("clk", "price", "probability", "prediction").sort("probability").show(5)
这里看下新模型的预测结果:
课程里面说对比前面的result_1的预测结果,能发现这里的预测率稍微准确了一点,但是我这里对比了一下,两个差不多,甚至这个这么看有点差,我猜可能是数据的问题,我这里采样过火了,课程里面用的全量的几千万的数据集,我这里只有20多万,差距有些大。再对比点击过的商品的点击预测概率:
result_2.filter(result_2.clk==1).select("clk", "price", "probability", "prediction").sort("probability").show(10)
# 从该结果也可以看出,result_2的点击率预测率普遍要比result_1高出一点点
结果如下, 这里会发现result_2的点击率预测确实要比result_1高出了些。还有个0.08多的。
因此可见对特征的细化处理,可以帮助提高模型效果。
5. 总结
这篇文章比较简单,总结起来就是建立LR模型完成CTR预估的过程,更准确的讲是离线的CTR预估,通过线下的数据训练了逻辑回归模型并进行保存,这两个模型我们后面要用,因为后面实时推荐的时候,基于这两个模型来进行预测, 只不过是特征会实时的进行改变了。主要分为3块,第一个就是先用一个小demo走了一遍逻辑回归预测的流程,然后就是用了之前处理的真实数据, 最后是对真实数据的特征进行优化又走了一遍。总体上还是挺清晰的。
下一篇和下下篇文章就到了好玩的东西了,也就是真正的离线召回步骤和实时推荐步骤。前面第二篇文章虽然是走了一下离线召回的流程, 但是我们召回的是有关广告的类别或者品牌,而我们排序真正需要的是广告是否点击,所以我们还需要根据召回的广告类别或者品牌匹配出对应的广告回来存到redis里面供后面模型使用, 并且还需要把一些重要特征,比如广告的特征和用户的特征要进行缓存到redis里面,这样才能供后面的实时推荐使用。 所以下一篇文章就来干这两个事了,继续Rush 😉