「回顾2022,展望2023,我正在参与2022年终总结征文大赛活动」
2022年只剩一个小尾巴了,回想年初的时候制定的 flag 终于还是不好意思的失约了。flag 仍在,完成的却是不多,但这个年终的总结还是想写点什么,让自己少留一些遗憾。
这一年最大的成就是我跟着之前的老师,参加了一个大学的人工智能学习课程,课程很好,也带我打开了新世界的大门。
推荐系统的前世今生
随着互联网和移动技术的高速发展,搜索推荐系统以及相关的技术得到了飞速发展,推荐系统可以根据用户的个性化需求,在海量的信息中确定提供给用户喜欢的具体内容,更甚至可以根据用户的历史浏览习惯和行为挖掘到隐藏在深处的隐性需求。不知道你有没有过这种感觉,现在的手机简直可以窥探自己大脑,上一秒你想到的下一秒就推送到了,好像比自己更加了解自己啊!
这个推荐系统在互联网和传统行业中都有着大量的应用。在互联网中,几乎所有平台都用到了推荐系统,像新闻资讯、影视剧、知识社区、电商的商品推荐等;而在传统行业中,某些企业的营销也会用到,比如金融产品推荐等。
而这个强大的推荐系统涉及的技术也是非常多的、对工程的要求也是非常高的,如果想学的精通还是要付出非常大的精力的。很幸运,我遇见了飞桨,接触到了 PaddleRec推荐算法库,可以对推荐系统进行一个入门级的实现。接下来,我们就一起来实现一下吧。
什么是 PaddleRec
PaddleRec 是为初学者、AI从业或科研人员推出模型库,有推荐系统的全流程解决方案,开箱即用,包含内容理解、匹配、召回、排序、 多任务、重排序等多个任务的完整推荐搜索算法库。
PaddleRec推荐模型库的文件夹的文件目录如图:
- models:这是对用户来说用的比较多的,其中包含推荐系统各环节可能会用到的模型,包括召回(recall)、排序(rank)、匹配(match)等,以及有些模型可以同时完成多个环节的任务,这类模型则被归纳到多任务文件夹(multitask)下。
下图是每个模型文件夹下的内容:
模型的使用方法
环境准备:
- python 2.7/3.5/3.6/3.7
- PaddlePaddle >= 2.0
安装飞桨。请点击这里安装飞桨深度学习框架,然后执行如下命令安装飞桨框架。
python -m pip install paddlepaddle==2.0.0rc1 -i https://mirror.baidu.com/pypi/simple
下载代码:
git clone https://github.com/PaddlePaddle/PaddleRec.git
cd PaddleRec
修改配置文件config.yaml:
# 进入模型目录
# cd models/www/xxx # 在任意目录均可运行
# 动态图训练
python -u yyy/tools/trainer.py -m zzz/config.yaml # 全量数据运行config_bigdata.yaml
# 动态图预测
python -u yyy/tools/infer.py -m zzz/config.yaml
# 静态图训练
python -u yyy/tools/static_trainer.py -m zzz/config.yaml # 全量数据运行config_bigdata.yaml
# 静态图预测
python -u yyy/tools/static_infer.py -m zzz/config.yaml
电影推荐场景说明
对给定用户,根据该用户历史的电影评分数据以及浏览习惯,给他推荐他可能感兴趣的其他电影。一般来说,推荐流程主要包含两部分:召回、排序。这里我们使用电影评分数据集训练一个推荐系统,最终根据推荐的电影类别与用户喜好进行比较,验证我们的推荐系统是否有效。
- 召回:主要在于降低候选集规模,从全量的候选集中得到用户可能感兴趣的一小部分候选集;
- 排序:将召回阶段得到的候选集进行精准排序,推荐给用户。
启动召回模型的动态图训练:
#进入相应目录下
cd PaddleRec/models/demo/movie_recommand
#在命令“python -u”后跟trainer.py和config.yaml的路径。
python3 -u ../../../tools/trainer.py -m recall/config.yaml
电影推荐系统的实现
- 数据准备
这里我们把数据分为训练集和测试集。
MovieLens数据集是一个关于电影评分的数据集,数据来自于IMDB等电影评分网,其中保罗用户对电影的评分,人口统计学特征及电影描述等。这里我们选择一个 1M 左右的子集ml-1m,其中包含了 6000 多位用户对近 3900 个电影的 100 多万条评分,评分分为 1-5 的整数,每个电影的评分数据至少有 20 条。
读取模型数据:
这里使用的是 movie_reader_dygraph.py
from __future__ import print_function
import numpy as np
#引入IterableDataset基类
from paddle.io import IterableDataset
#创建一个子类,继承IterableDataset的基类
class RecDataset(IterableDataset):
def __init__(self, file_list, config):
super(RecDataset, self).__init__()
self.file_list = file_list
def __iter__(self):
full_lines = []
self.data = []
for file in self.file_list:
with open(file, "r") as rf:
# 以行为单位,逐行读取数据
for l in rf:
output_list = []
line = l.strip().split(" ")
sparse_slots = ["logid", "time", "userid", "gender", "age", "occupation", "movieid", "title", "genres", "label"]
#logid和time这两个特征,训练模型时并不需要用到,故不必加入output_list
logid = line[0].strip().split(":")[1]
time = line[1].strip().split(":")[1]
#向output_list中加入用户特征:userid:1个数,gender:1个数,age:1个数,occupation:1个数
userid = line[2].strip().split(":")[1]
output_list.append(np.array([float(userid)]))
gender = line[3].strip().split(":")[1]
output_list.append(np.array([float(gender)]))
age = line[4].strip().split(":")[1]
output_list.append(np.array([float(age)]))
occupation = line[5].strip().split(":")[1]
output_list.append(np.array([float(occupation)]))
#向output_list中加入电影特征:movieid:1个数,title:4个数,genres:3个数
movieid = line[6].strip().split(":")[1]
output_list.append(np.array([float(movieid)]))
title = []
genres = []
for i in line:
if i.strip().split(":")[0] == "title":
title.append(float(i.strip().split(":")[1]))
if i.strip().split(":")[0] == "genres":
genres.append(float(i.strip().split(":")[1]))
output_list.append(np.array(title))
output_list.append(np.array(genres))
#向output_list中加入标签:label:1个数
label = line[-1].strip().split(":")[1]
output_list.append(np.array([float(label)]))
#返回一个可以迭代的reader方法
yield output_list
- 模型设计
推荐系统一般包含两个模块,召回和排序。
排序就是将用户感兴趣的内容按照点击率排序,一般用于数据量少的情况。 召回用于数据量大的时候,从不同角度筛选用户感兴趣的内容,最为候选数据集,然后对候选数据集进行精准排序。
召回模型
目的是从大量电影库中选出部分候选,输入给排序模块。
输入部分:
示例中的dygraph_model.py将使用如下代码读取数据:
def create_feeds(batch):
user_sparse_inputs = [
paddle.to_tensor(batch[i].numpy().astype('int64').reshape(-1, 1))
for i in range(4)
]
mov_sparse_inputs = [
paddle.to_tensor(batch[4].numpy().astype('int64').reshape(-1, 1)),
paddle.to_tensor(batch[5].numpy().astype('int64').reshape(-1, 4)),
paddle.to_tensor(batch[6].numpy().astype('int64').reshape(-1, 3))
]
label_input = paddle.to_tensor(batch[7].numpy().astype('int64').reshape(-1,1))
return user_sparse_inputs, mov_sparse_inputs, label_input
组网
class DNNLayer(nn.Layer):
#在使用动态图时,针对一些比较复杂的网络结构,可以使用Layer子类定义的方式来进行模型代码编写,在__init__构造函数中进行组网Layer的声明,
#在forward中使用声明的Layer变量进行前向计算。子类组网方式也可以实现sublayer的复用,针对相同的layer可以在构造函数中一次性定义,在forward中多次调用。
def __init__(self, sparse_feature_number, sparse_feature_dim, fc_sizes):
super(DNNLayer, self).__init__()
self.sparse_feature_number = sparse_feature_number
self.sparse_feature_dim = sparse_feature_dim
self.fc_sizes = fc_sizes
#声明embedding层,建立emb表将数据映射为向量
self.embedding = paddle.nn.Embedding(
self.sparse_feature_number,
self.sparse_feature_dim,
padding_idx=0,
sparse=True,
weight_attr=paddle.ParamAttr(
name="SparseFeatFactors",
initializer=paddle.nn.initializer.Uniform()))
#使用循环的方式创建全连接层,可以在超参数中通过一个数组确定使用几个全连接层以及每个全连接层的神经元数量。
#本例中使用了4个全连接层,并在每个全连接层后增加了relu激活层。
user_sizes = [36] + self.fc_sizes
acts = ["relu" for _ in range(len(self.fc_sizes))]
self._user_layers = []
for i in range(len(self.fc_sizes)):
linear = paddle.nn.Linear(
in_features=user_sizes[i],
out_features=user_sizes[i + 1],
weight_attr=paddle.ParamAttr(
initializer=paddle.nn.initializer.Normal(
std=1.0 / math.sqrt(user_sizes[i]))))
self.add_sublayer('linear_user_%d' % i, linear)
self._user_layers.append(linear)
if acts[i] == 'relu':
act = paddle.nn.ReLU()
self.add_sublayer('user_act_%d' % i, act)
self._user_layers.append(act)
#电影特征和用户特征使用了不同的全连接层,不共享参数
movie_sizes = [27] + self.fc_sizes
acts = ["relu" for _ in range(len(self.fc_sizes))]
self._movie_layers = []
for i in range(len(self.fc_sizes)):
linear = paddle.nn.Linear(
in_features=movie_sizes[i],
out_features=movie_sizes[i + 1],
weight_attr=paddle.ParamAttr(
initializer=paddle.nn.initializer.Normal(
std=1.0 / math.sqrt(movie_sizes[i]))))
self.add_sublayer('linear_movie_%d' % i, linear)
self._movie_layers.append(linear)
if acts[i] == 'relu':
act = paddle.nn.ReLU()
self.add_sublayer('movie_act_%d' % i, act)
self._movie_layers.append(act)
def forward(self, batch_size, user_sparse_inputs, mov_sparse_inputs, label_input):
#对用户特征建模, 所有用户sparse特征查对应的emb表,获得特征权重
user_sparse_embed_seq = []
for s_input in user_sparse_inputs:
emb = self.embedding(s_input)
emb = paddle.reshape(emb, shape=[-1, self.sparse_feature_dim])
user_sparse_embed_seq.append(emb)
#对电影特征建模, 所有电影sparse特征查对应的emb表,获得特征权重
mov_sparse_embed_seq = []
for s_input in mov_sparse_inputs:
s_input = paddle.reshape(s_input,shape = [batch_size, -1])
emb = self.embedding(s_input)
emb = paddle.sum(emb, axis = 1)
emb = paddle.reshape(emb, shape=[-1, self.sparse_feature_dim])
mov_sparse_embed_seq.append(emb)
#查表结果拼接在一起,构成用户特征权重向量
user_features = paddle.concat(user_sparse_embed_seq,axis=1)
#查表结果拼接在一起,构成电影特征权重向量
mov_features = paddle.concat(mov_sparse_embed_seq,axis=1)
#通过4层全链接层,获得用于计算相似度的用户特征和电影特征
for n_layer in self._user_layers:
user_features = n_layer(user_features)
for n_layer in self._movie_layers:
mov_features = n_layer(mov_features)
#使用余弦相似度算子,计算用户和电影的相似程度
sim = F.cosine_similarity(user_features, mov_features, axis=1).reshape([-1, 1])
#对输入Tensor进行缩放和偏置,获得合适的输出指标
predict = paddle.scale(sim,scale=5)
return predict
损失函数
此处使用均方差损失函数。square_error_cost(input,lable):接受输入预测值和目标值,并返回方差估计,即为(y-y_predict)的平方。
cost = F.square_error_cost(predict, paddle.cast(x=label_input, dtype='float32'))
avg_cost = paddle.mean(cost)
超参配置
PaddleRec中模型超参的配置均体现在config.yaml文件中hyper_parameters模块,本示例超参配置如下所示,其中参数解释如下:
- class:优化器类型;
- learning_rate:学习率;
- sparse_feature_number:稀疏特征数量;
- sparse_feature_dim:稀疏特征维度;
- fc_sizes:全连接层的规模。
# hyper parameters of user-defined network
hyper_parameters:
# optimizer config
optimizer:
class: Adam
learning_rate: 0.001
# user-defined <key, value> pairs
sparse_feature_number: 600000
sparse_feature_dim: 9
fc_sizes: [512, 256, 128, 32]
在简单了解召回模型和其组网实现之后,我们来看下如何做到一键式启动训练。 首先执行如下命令启动训练。我们在训练集上训练了五个epoch,在每个epoch后保存了训练出的模型参数文件。在config.yaml文件中的配置如下所示:
runner:
train_data_dir: "../data/train" #训练数据的路径
train_reader_path: "reader" # importlib format
train_batch_size: 128
model_save_path: "output_model_recall" #模型训练完后保存在该目录下
use_gpu: true #是否使用gpu进行训练
epochs: 5 #训练5个epoch
print_interval: 20 #每隔20个batch输出一次指标
test_data_dir: "../data/test"
infer_reader_path: "reader" # importlib format
infer_batch_size: 128
infer_load_path: "output_model_recall"
infer_start_epoch: 4 #模型从第五个epoch开始测试(epoch 0为第一个epoch)
infer_end_epoch: 5 #模型测试到第五个epoch为止。[infer_start_epoch,infer_end_epoch)
runner_result_dump_path: "recall_infer_result" #模型测试完后指标保存在该目录下
在这里我们推荐使用AIstudio中带gpu的高级版环境进行训练,默认的use_gpu选项为true。若需要使用基础版进行训练,需要用户自行在PaddleRec/models/demo/movie_recommand目录下分别将recall和rank目录中的config.yaml文件中将use_gpu选项改为False。
In [1]
# 动态图训练recall模型,使用gpu每轮训练耗时约1.5分钟,训练5轮
!cd PaddleRec/models/demo/movie_recommand && python -u ../../../tools/trainer.py -m recall/config.yaml
In [5]
# 我们对训练出的模型进行测试,目的是选择出效果最好的模型,由于我们只保存了5组模型参数,数量较少,所以我们仅使用最后一组模型参数进行测试。
# recall模型动态图测试(在数据集的测试集中执行测试)
!cd PaddleRec/models/demo/movie_recommand && python -u infer.py -m recall/config.yaml
我们可以看到,测试过程中打印了很多信息,那么有没有直观的测试结果呢?PaddleRec在这里准备了测试结果解析脚本parse.py,我们可以执行该脚本对测试结果进行分析,以便确定训练出的模型效果。
In [6]
# 离线召回测试结果解析
!pip install py27hash
!echo "recall offline test result:"
!cd PaddleRec/models/demo/movie_recommand && python parse.py recall_offline recall_infer_result
Looking in indexes: https://mirror.baidu.com/pypi/simple/
Requirement already satisfied: py27hash in /opt/conda/envs/python35-paddle120-env/lib/python3.7/site-packages (1.0.2)
recall offline test result:
total: 6016; correct: 1835
accuracy: 0.30501994680851063
mae: 0.8757134295922752
从显示信息中我们可以看到模型的准确率(accuracy),以及MAE值,平均绝对误差,即绝对误差的平均值,用户可以根据这个值判断模型的效果。一般MAE越小则说明误差越小,代表模型效果越好。由于本教程仅是演示,所以训练并不充足,导致这两个评估值并不理想,因此在实际业务中,大家需要多训练几个epoch,以保证模型的效果。相应的,训练过程中也会保存更多的模型参数,一般建议大家选择最后保存几个模型进行测试,然后根据测试和分析的结果选出最优的模型。
排序模型
经过推理将用户感兴趣的内容按照点击率由高到低推荐给用户
示例中的dygraph_model.py将使用如下代码读取数据
def create_feeds(batch):
user_sparse_inputs = [
paddle.to_tensor(batch[i].numpy().astype('int64').reshape(-1, 1))
for i in range(4)
]
mov_sparse_inputs = [
paddle.to_tensor(batch[4].numpy().astype('int64').reshape(-1, 1)),
paddle.to_tensor(batch[5].numpy().astype('int64').reshape(-1, 4)),
paddle.to_tensor(batch[6].numpy().astype('int64').reshape(-1, 3))
]
label_input = paddle.to_tensor(batch[7].numpy().astype('int64').reshape(-1,1))
return user_sparse_inputs, mov_sparse_inputs, label_input
组网部分net.py的代码如下所示
class DNNLayer(nn.Layer):
#在使用动态图时,针对一些比较复杂的网络结构,可以使用Layer子类定义的方式来进行模型代码编写,在__init__构造函数中进行组网Layer的声明,
#在forward中使用声明的Layer变量进行前向计算。子类组网方式也可以实现sublayer的复用,针对相同的layer可以在构造函数中一次性定义,在forward中多次调用
def __init__(self, sparse_feature_number, sparse_feature_dim, fc_sizes):
super(DNNLayer, self).__init__()
self.sparse_feature_number = sparse_feature_number
self.sparse_feature_dim = sparse_feature_dim
self.fc_sizes = fc_sizes
#声明embedding层,建立emb表将数据映射为向量
self.embedding = paddle.nn.Embedding(
self.sparse_feature_number,
self.sparse_feature_dim,
padding_idx=0,
sparse=True,
weight_attr=paddle.ParamAttr(
name="SparseFeatFactors",
initializer=paddle.nn.initializer.Uniform()))
#使用循环的方式创建全连接层,可以在超参数中通过一个数组确定使用几个全连接层以及每个全连接层的神经元数量。
#本例中使用了4个全连接层,并在每个全连接层后增加了relu激活层。
sizes = [63] + self.fc_sizes + [1]
acts = ["relu" for _ in range(len(self.fc_sizes))] + ["sigmoid"]
self._layers = []
for i in range(len(self.fc_sizes)+1):
linear = paddle.nn.Linear(
in_features=sizes[i],
out_features=sizes[i + 1],
weight_attr=paddle.ParamAttr(
initializer=paddle.nn.initializer.Normal(
std=1.0 / math.sqrt(sizes[i]))))
self.add_sublayer('linear_%d' % i, linear)
self._layers.append(linear)
if acts[i] == 'relu':
act = paddle.nn.ReLU()
self.add_sublayer('act_%d' % i, act)
self._layers.append(act)
if acts[i] == 'sigmoid':
act = paddle.nn.layer.Sigmoid()
self.add_sublayer('act_%d' % i, act)
self._layers.append(act)
def forward(self, batch_size, user_sparse_inputs, mov_sparse_inputs, label_input):
#对用户特征建模, 所有用户sparse特征查对应的emb表,获得特征权重
user_sparse_embed_seq = []
for s_input in user_sparse_inputs:
emb = self.embedding(s_input)
emb = paddle.reshape(emb, shape=[-1, self.sparse_feature_dim])
user_sparse_embed_seq.append(emb)
#对电影特征建模, 所有电影sparse特征查对应的emb表,获得特征权重
mov_sparse_embed_seq = []
for s_input in mov_sparse_inputs:
s_input = paddle.reshape(s_input,shape = [batch_size, -1])
emb = self.embedding(s_input)
emb = paddle.sum(emb, axis = 1)
emb = paddle.reshape(emb, shape=[-1, self.sparse_feature_dim])
mov_sparse_embed_seq.append(emb)
#查表结果拼接在一起,混合用户特征和电影特征,相比召回模型,排序模型更早地完成了特征交叉
features = paddle.concat(user_sparse_embed_seq + mov_sparse_embed_seq,axis=1)
#利用DNN网络进行训练,使用sigmoid激活的全链接层,旨在对候选集合进行更加精确的打分
for n_layer in self._layers:
features = n_layer(features)
#对输入Tensor进行缩放和偏置,获得合适的输出指标
predict = paddle.scale(features,scale=5)
return predict
损失函数
此处使用均方差损失函数。square_error_cost(input,lable):接受输入预测值和目标值,并返回方差估计,即为(y-y_predict)的平方。
cost = F.square_error_cost(predict,paddle.cast(x=label_input, dtype='float32'))
avg_cost = paddle.mean(cost)
超参配置
PaddleRec中模型超参的配置均体现在config.yaml文件中hyper_parameters模块,本示例超参配置如下所示,其中参数解释如下:
- class:优化器类型;
- learning_rate:学习率;
- sparse_feature_number:稀疏特征数量;
- sparse_feature_dim:稀疏特征维度;
- fc_sizes:全连接层的规模。
# hyper parameters of user-defined network
hyper_parameters:
# optimizer config
optimizer:
class: Adam
learning_rate: 0.001
# user-defined <key, value> pairs
sparse_feature_number: 600000
sparse_feature_dim: 9
fc_sizes: [512, 256, 128, 32]
和召回模型的操作方法相似,用户同样可以使用如下三个命令对排序模型完成训练、测试和结果分析,因此不再赘述。
In [7]
# 动态图训练rank模型(在数据集的训练集中执行训练)
# 使用gpu每轮训练耗时约1.5分钟,训练5轮
!cd PaddleRec/models/demo/movie_recommand && python -u ../../../tools/trainer.py -m rank/config.yaml
In [8]
# rank模型动态图测试(在数据集的测试集中执行测试)
!cd PaddleRec/models/demo/movie_recommand && python -u infer.py -m rank/config.yaml
In [9]
# 离线排序测试结果解析
!echo "rank offline test result:"
!cd PaddleRec/models/demo/movie_recommand && python parse.py rank_offline rank_infer_result
rank offline test result:
total: 6016; correct: 1754
accuracy: 0.2915558510638298
mae: 0.9075403101405298
从显示信息中我们可以看到本示例的排序模型也是使用准确率和MAE值作为评价指标。同样在实际业务中,用户需要优化下训练策略,以确保训练效果
- 优化策略
调整超参
增加训练轮数
在训练模型的时候,效果较差可能是因为欠拟合引起的。我们可以增加训练的轮数,让模型获得更充分的训练,以此来提高模型的效果。
以本教程中recall/config.yaml为例:
更换优化器
在训练模型的时候,我们可以更换优化器,尝试不同的学习率以求获得提升。在PaddleRec中,我们提供SGD/Adam/AdaGrad优化器供您尝试。也可以通过learning_rate选项修改学习率。
仍然以本教程中recall/config.yaml为例:
调整全连接层
在训练模型的时候,我们可以很方便的指定模型的全连接层共有几层,以及每一层的维度。
- 模型部署