$$
F=\frac{a}{1}
$$
只有建立起深度学习推荐系统的知识体系,从系统的层面考虑问题,我们才能够实现整体效果上的优化。与你一同从“0”开始,搭建一个“工业级”的“深度学习”推荐系统。
在所有业界巨头的推荐引擎都由深度学习驱动的今天,作为一名推荐系统从业者,我们不应该止步于,或者说满足于继续使用协同过滤、矩阵分解这类传统方法,而应该加深对深度学习模型的理解,加强对大数据平台的熟悉程度,培养结合业务和模型的技术直觉,提高我们整体的技术格局,这些都是我们取得成功的关键。
推荐系统要解决的问题用一句话总结就是,在“信息过载”的情况下,用户如何高效获取感兴趣的信息。
推荐系统要处理的问题就可以被形式化地定义为:对于某个用户U(User),在特定场景C(Context)下,针对海量的“物品”信息构建一个函数 ,预测用户对特定候选物品I(Item)的喜好程度,再根据喜好程度对所有候选物品进行排序,生成推荐列表的问题。
抽象出推荐系统的逻辑框架:
其实正是因为深度学习复杂的模型结构,让深度学习模型具备了理论上拟合任何函数的能力。
让深度学习模型的神经网络模拟很多用户兴趣的变迁过程,甚至用户做出决定的思考过程。
比如阿里巴巴的深度学习模型——深度兴趣进化网络(如图 3),它利用了三层序列模型的结构,模拟了用户在购买商品时兴趣进化的过程,如此强大的数据拟合能力和对用户行为的理解能力,是传统机器学习模型不具备的。
如何利用 One-hot 编码处理类别型特征
广义上来讲,所有的特征都可以分为两大类。
编码方式:
数值型特征 - 直接把这个数值放到特征向量上相应的维度上就可以了
类别特征、ID特征 - 利用onehot编码
比如,在我们的 SparrowRecsys 中,用户 U 观看过电影 M,
Multi-hot 编码(多热编码)
比如,对于历史行为序列类、标签特征等数据来说,用户往往会与多个物品产生交互行为, 或者一个物品被打上多个标签,这时最常用的特征向量生成方式就是把其转换成 Multi-hot 编码。在 SparrowRecsys 中,因为每个电影都是有多个 Genre(风格)类别的,所以我们就可以用 Multi-hot 编码完成标签到向量的转换。你可以自己尝试着用 Spark 实现该过程,也可以🚩参考 SparrowRecsys 项目中
multiHotEncoderExample
的实现,我就不多说啦。
这里,我们使用 Spark 的机器学习库 MLlib
来完成 One-hot 特征的处理。其中,最主要的步骤是,
OneHotEncoderEstimator
,fit
函数完成指定特征的预处理,transform
函数将原始特征转换成 One-hot 特征。实现思路大体上就是这样,具体的步骤可以参考下面给出的源码:
def oneHotEncoderExample(samples:DataFrame): Unit ={
//samples样本集中的每一条数据代表一部电影的信息,其中movieId为电影id
val samplesWithIdNumber = samples.withColumn("movieIdNumber", col("movieId").cast(sql.types.IntegerType))
//利用Spark的机器学习库Spark MLlib创建One-hot编码器
val oneHotEncoder = new OneHotEncoderEstimator()
.setInputCols(Array("movieIdNumber"))
.setOutputCols(Array("movieIdVector"))
.setDropLast(false)
//训练One-hot编码器,并完成从id特征到One-hot向量的转换
val oneHotEncoderSamples = oneHotEncoder.fit(samplesWithIdNumber).transform(samplesWithIdNumber)
//打印最终样本的数据结构
oneHotEncoderSamples.printSchema()
//打印10条样本查看结果
oneHotEncoderSamples.show(10)
_(参考 com.wzhe.sparrowrecsys.offline.spark.featureeng.FeatureEngineering__中的oneHotEncoderExample函数)_
一是特征的尺度,二是特征的分布。
比如在电影推荐中有两个特征,一个是电影的评价次数 fr,一个是电影的平均评分 fs。评价次数其实是一个数值无上限的特征,在 SparrowRecsys 所用 MovieLens 数据集上,fr 的范围一般在[0,10000]之间。对于电影的平均评分来说,因为我们采用了 5 分为满分的评分,所以特征 fs 的取值范围在[0,5]之间。
由于 fr 和 fs 两个特征的尺度差距太大,如果我们把特征的原始数值直接输入推荐模型,就会导致这两个特征对于模型的影响程度有显著的区别。如果模型中未做特殊处理的话,fr 这个特征由于波动范围高出 fs 几个量级,可能会完全掩盖 fs 作用,这当然是我们不愿意看到的。为此我们希望把两个特征的尺度拉平到一个区域内,通常是[0,1]范围,这就是所谓归一化。
比如图 5 就显示了 Sparrow Recsys 中编号在前 1000 的电影平均评分分布。你可以很明显地看到,由于人们打分有“中庸偏上”的倾向,因此评分大量集中在 3.5 的附近,而且越靠近 3.5 的密度越大。这对于模型学习来说也不是一个好的现象,因为特征的区分度并不高。
图5 电影的平均评分分布
我们经常会用分桶的方式来解决特征值分布极不均匀的问题。所谓“分桶(Bucketing)”,就是将样本按照某特征的值从高到低排序,然后按照桶的数量找到分位数,将样本分到各自的桶中,再用桶 ID 作为特征值。
在 Spark MLlib 中,分别提供了两个转换器 MinMaxScaler
和 QuantileDiscretizer
,来进行归一化和分桶的特征处理。它们的使用方法和之前介绍的 OneHotEncoderEstimator 一样,都是:
下面的代码就是 SparrowRecSys 利用这两个转换器完成特征归一化和分桶的过程。
def ratingFeatures(samples:DataFrame): Unit ={
samples.printSchema()
samples.show(10)
//利用打分表ratings计算电影的平均分、被打分次数等数值型特征
val movieFeatures = samples.groupBy(col("movieId"))
.agg(count(lit(1)).as("ratingCount"),
avg(col("rating")).as("avgRating"),
variance(col("rating")).as("ratingVar"))
.withColumn("avgRatingVec", double2vec(col("avgRating")))
movieFeatures.show(10)
//分桶处理,创建QuantileDiscretizer进行分桶,将打分次数这一特征分到100个桶中
val ratingCountDiscretizer = new QuantileDiscretizer()
.setInputCol("ratingCount")
.setOutputCol("ratingCountBucket")
.setNumBuckets(100)
//归一化处理,创建MinMaxScaler进行归一化,将平均得分进行归一化
val ratingScaler = new MinMaxScaler()
.setInputCol("avgRatingVec")
.setOutputCol("scaleAvgRating")
//创建一个pipeline,依次执行两个特征处理过程
val pipelineStage: Array[PipelineStage] = Array(ratingCountDiscretizer, ratingScaler)
val featurePipeline = new Pipeline().setStages(pipelineStage)
val movieProcessedFeatures = featurePipeline.fit(movieFeatures).transform(movieFeatures)
//打印最终结果
movieProcessedFeatures.show(
_(参考 com.wzhe.sparrowrecsys.offline.spark.featureeng.FeatureEngineering中的ratingFeatures函数)_
在经典的 YouTube 深度推荐模型中,我们就可以看到一些很有意思的处理方法。比如,在处理观看时间间隔(time since last watch)和视频曝光量(#previous impressions)这两个特征的时,YouTube 模型对它们进行归一化后,又将它们各自处理成了三个特征(图 6 中红框内的部分),分别是原特征值 x,特征值的平方x^2,以及特征值的开方,这又是为什么呢?
其实,无论是平方还是开方操作,改变的还是这个特征值的分布,这些操作与分桶操作一样,都是希望通过改变特征的分布,让模型能够更好地学习到特征内包含的有价值信息。但由于我们没法通过人工的经验判断哪种特征处理方式更好,所以索性把它们都输入模型,让模型来做选择。
这里其实自然而然地引出了我们进行特征处理的一个原则,就是特征处理并没有标准答案,不存在一种特征处理方式是一定好于另一种的。
Normalizer、StandardScaler、RobustScaler、MinMaxScaler 都是用让数据无量纲化 Normalizer: 正则化;(和Python的sklearn一样是按行处理,而不是按列[每一列是一个特征]处理,原因是:Normalization主要思想是对每个样本计算其p-范数,然后对该样本中每个元素除以该范数,这样处理的结果是使得每个处理后样本的p-范数(l1-norm,l2-norm)等于1。)针对每行样本向量:l1: 每个元素/样本中每个元素绝对值的和,l2: 每个元素/样本中每个元素的平方和开根号,lp: 每个元素/每个元素的p次方和的p次根,默认用l2范数。
StandardScaler:数据标准化;(xi - u) / σ 【u:均值,σ:方差】当数据(x)按均值(μ)中心化后,再按标准差(σ)缩放,数据就会服从为均值为0,方差为1的正态分布(即标准正态分布)。
RobustScaler: (xi - median) / IQR 【median是样本的中位数,IQR是样本的 四分位距:根据第1个四分位数和第3个四分位数之间的范围来缩放数据】
MinMaxScaler:数据归一化,(xi - min(x)) / (max(x) - min(x)) ;当数据(x)按照最小值中心化后,再按极差(最大值 - 最小值)缩放,数据移动了最小值个单位,并且会被收敛到 [0,1]之间
用 Embedding 方法进行相似物品推荐,几乎成了业界最流行的做法
Embedding 就是用一个数值向量“表示”一个对象(Object)的方法
Netflix 应用的电影 Embedding 向量方法,就是一个非常直接的推荐系统应用。从 Netflix 利用矩阵分解方法生成的电影和用户的 Embedding 向量示意图中,我们可以看出不同的电影和用户分布在一个二维的空间内,由于 Embedding 向量保存了它们之间的相似性关系,因此有了这个 Embedding 空间之后,我们再进行电影推荐就非常容易了。具体来说就是,我们直接找出某个用户向量周围的电影向量,然后把这些电影推荐给这个用户就可以了。这就是 Embedding 技术在推荐系统中最直接的应用。
首先,Embedding 是处理稀疏特征的利器。
因为推荐场景中的类别、ID 型特征非常多,大量使用 One-hot 编码会导致样本特征向量极度稀疏,而深度学习的结构特点又不利于稀疏特征向量的处理,因此几乎所有深度学习推荐模型都会由 Embedding 层负责将稀疏高维特征向量转换成稠密低维特征向量。所以说各类 Embedding 技术是构建深度学习推荐模型的基础性操作。
其次,Embedding 可以融合大量有价值信息,本身就是极其重要的特征向量 。
相比由原始信息直接处理得来的特征向量,Embedding 的表达能力更强,特别是 Graph Embedding 技术被提出后,Embedding 几乎可以引入任何信息进行编码,使其本身就包含大量有价值的信息,所以通过预训练得到的 Embedding 向量本身就是极其重要的特征向量。
经典的 Embedding 方法,Word2vec。
想要训练 Word2vec 模型,我们需要准备由一组句子组成的语料库。假设其中一个长度为 T 的句子包含的词有 w1,w2……wt,并且我们假定每个词都跟其相邻词的关系最密切。
根据模型假设的不同,Word2vec 模型分为两种形式,CBOW 模型(图 3 左)和 Skip-gram 模型(图 3 右)。
其中,CBOW 模型假设句子中每个词的选取都由相邻的词决定,因此我们就看到 CBOW 模型的输入是 wt周边的词,预测的输出是 wt。
Skip-gram 模型则正好相反,它假设句子中的每个词都决定了相邻词的选取,所以你可以看到 Skip-gram 模型的输入是 wt,预测的输出是 wt周边的词。
按照一般的经验,Skip-gram 模型的效果会更好一些,所以我接下来也会以 Skip-gram 作为框架,来给你讲讲 Word2vec 的模型细节。
作为一个自然语言处理的模型,训练 Word2vec 的样本当然来自于语料库,比如我们想训练一个电商网站中关键词的 Embedding 模型,那么电商网站中所有物品的描述文字就是很好的语料库。
我们从语料库中抽取一个句子,选取一个长度为 2c+1(目标词前后各选 c 个词)的滑动窗口,将滑动窗口由左至右滑动,每移动一次,窗口中的词组就形成了一个训练样本。根据 Skip-gram 模型的理念,中心词决定了它的相邻词,我们就可以根据这个训练样本定义出 Word2vec 模型的输入和输出,输入是样本的中心词,输出是所有的相邻词。
为了方便你理解,我再举一个例子。这里我们选取了“Embedding 技术对深度学习推荐系统的重要性”作为句子样本。
它的结构本质上就是一个三层的神经网络(如图 5)。
它的输入层和输出层的维度都是 V,这个 V 其实就是语料库词典的大小。假设语料库一共使用了 10000 个词,那么 V 就等于 10000。根据图 4 生成的训练样本,这里的输入向量自然就是由输入词转换而来的 One-hot 编码向量,输出向量则是由多个输出词转换而来的 Multi-hot 编码向量,显然,基于 Skip-gram 框架的 Word2vec 模型解决的是一个多分类问题。
🚩Multi-hot 编码向量: [0,0,1,1,0,1,0,0,0]这种吧
隐层的维度是 N,N 的选择就需要一定的调参能力了,我们需要对模型的效果和模型的复杂度进行权衡,来决定最后 N 的取值,并且最终每个词的 Embedding 向量维度也由 N 来决定。
最后是激活函数的问题,这里我们需要注意的是,隐层神经元是没有激活函数的,或者说采用了输入即输出的恒等函数作为激活函数,而输出层神经元采用了 softmax 作为激活函数。
你可能会问为什么要这样设置 Word2vec 的神经网络,以及我们为什么要这样选择激活函数呢?因为这个神经网络其实是为了表达从输入向量到输出向量的这样的一个条件概率关系,我们看下面的式子:
这个由输入词 WI 预测输出词 WO 的条件概率,其实就是 Word2vec 神经网络要表达的东西。我们通过极大似然的方法去最大化这个条件概率,就能够让相似的词的内积距离更接近,这就是我们希望 Word2vec 神经网络学到的。
归一化指数函数(softmax)中,样本向量x属于第j类的概率是:
与上式相同。其在多种基于概率的多分类问题方法中都有着广泛应用。
Softmax 就是 Soft 版本的 ArgMax,“softmax 的作用是把 一个序列,变成概率,即被选为 max 的概率。
交叉熵损失函数是搭配softmax使用的损失函数。
softmax是激活函数,交叉熵是损失函数。一句话概括:
softmax把分类输出标准化成概率分布
cross-entropy(交叉熵)刻画预测分类和真实结果之间的相似度。
参考论文:Word2vec Parameter Learning Explained
如果你是一个理论派,其实 Word2vec 还有很多值得挖掘的东西,比如,为了节约训练时间,Word2vec 经常会采用负采样(Negative Sampling)或者分层 softmax(Hierarchical Softmax)的训练方法。关于这一点,我推荐你去阅读《Word2vec Parameter Learning Explained》这篇文章,相信你会找到最详细和准确的解释。
这个 Embedding 在哪呢?其实,它就藏在输入层到隐层的权重矩阵 WVxN 中。
你可以看到,输入向量矩阵 WVxN 的每一个行向量对应的就是我们要找的“词向量”。比如我们要找词典里第 i 个词对应的 Embedding,因为输入向量是采用 One-hot 编码的,所以输入向量的第 i 维就应该是 1,那么输入向量矩阵 WVxN 中第 i 行的行向量自然就是该词的 Embedding 啦。输出向量矩阵 W′ 也遵循这个道理,确实是这样的,但一般来说,我们还是习惯于使用输入向量矩阵作为词向量矩阵。
在实际的使用过程中,我们往往会把输入向量矩阵转换成词向量查找表(Lookup table,如图 7 所示)。
例如,输入向量是 10000 个词组成的 One-hot 向量,隐层维度是 300 维,那么输入层到隐层的权重矩阵为 10000x300 维。
在转换为词向量 Lookup table 后,每行的权重即成了对应词的 Embedding 向量。如果我们把这个查找表存储到线上的数据库中,就可以轻松地在推荐物品的过程中使用 Embedding 去计算相似性等重要的特征了。
Word2vec 的研究中提出的模型结构、目标函数、负采样方法、负采样中的目标函数在后续的研究中被重复使用并被屡次优化。掌握 Word2vec 中的每一个细节成了研究 Embedding 的基础。从这个意义上讲,熟练掌握本节课的内容是非常重要的。
既然 Word2vec 可以对词“序列”中的词进行 Embedding,那么对于用户购买“序列”中的一个商品,用户观看“序列”中的一个电影,也应该存在相应的 Embedding 方法。
于是,微软于 2015 年提出了 Item2Vec 方法,它是对 Word2vec 方法的推广,使 Embedding 方法适用于几乎所有的序列数据。Item2Vec 模型的技术细节几乎和 Word2vec 完全一致,只要能够用序列数据的形式把我们要表达的对象表示出来,再把序列数据“喂”给 Word2vec 模型,我们就能够得到任意物品的 Embedding 了。Item2vec 的提出对于推荐系统来说当然是至关重要的,因为它使得“万物皆 Embedding”成为了可能。对于推荐系统来说,Item2vec 可以利用物品的 Embedding 直接求得它们的相似性,或者作为重要的特征输入推荐模型进行训练,这些都有助于提升推荐系统的效果
这节课,我们一起学习了深度学习推荐系统中非常重要的知识点,Embedding。Embedding 就是用一个数值向量“表示”一个对象的方法。通过 Embedding,我们又引出了 Word2vec,Word2vec 是生成对“词”的向量表达的模型。其中,Word2vec 的训练样本是通过滑动窗口一一截取词组生成的。在训练完成后,模型输入向量矩阵的行向量,就是我们要提取的词向量。最后,我们还学习了 Item2vec,它是 Word2vec 在任意序列数据上的推广。
最典型的就是我们每天都在使用的社交网络(如图 1-a)。从社交网络中,我们可以发现意见领袖,可以发现社区,再根据这些“社交”特性进行社交化的推荐,如果我们可以对社交网络中的节点进行 Embedding 编码,社交化推荐的过程将会非常方便。
知识图谱也是近来非常火热的研究和应用方向。像图 1b 中描述的那样,知识图谱中包含了不同类型的知识主体(如人物、地点等),附着在知识主体上的属性(如人物描述,物品特点),以及主体和主体之间、主体和属性之间的关系。如果我们能够对知识图谱中的主体进行 Embedding 化,就可以发现主体之间的潜在关系,这对于基于内容和知识的推荐系统是非常有帮助的。
还有一类非常重要的图数据就是行为关系类图数据。这类数据几乎存在于所有互联网应用中,它事实上是由用户和物品组成的“二部图”(也称二分图,如图 1c)。用户和物品之间的相互行为生成了行为关系图。借助这样的关系图,我们自然能够利用 Embedding 技术发掘出物品和物品之间、用户和用户之间,以及用户和物品之间的关系,从而应用于推荐系统的进一步推荐。
毫无疑问,图数据是具备巨大价值的,如果能将图中的节点 Embedding 化,对于推荐系统来说将是非常有价值的特征。那下面,我们就进入正题,一起来学习基于图数据的 Graph Embedding 方法。
它的主要思想是在由物品组成的图结构上进行随机游走,产生大量物品序列,然后将这些物品序列作为训练样本输入 Word2vec 进行训练,最终得到物品的 Embedding。因此,DeepWalk 可以被看作连接序列 Embedding 和 Graph Embedding 的一种过渡方法。
学习 Deep Walk 方法关键在于理解它的算法流程:
其中,随机游走采样的次数、长度等都属于超参数,需要我们根据具体应用进行调整。
唯一需要形式化定义的就是随机游走的跳转概率,也就是到达节点 vi后,下一步遍历 vi 的邻接点 vj 的概率。
如果物品关系图是有向有权图,那么从节点 vi 跳转到节点 vj 的概率定义如下:即 DeepWalk 的跳转概率就是跳转边的权重占所有相关出边权重之和的比例。
如果物品相关图是无向无权重图,那么跳转概率将是上面这个公式的一个特例,即权重 Mij将为常数 1,且 N+(vi) 应是节点 vi所有“边”的集合,而不是所有“出边”的集合。
其中,N+(vi) 是节点 vi所有的出边集合,Mij是节点 vi到节点 vj边的权重,
Node2vec 通过调整随机游走跳转概率的方法,让 Graph Embedding 的结果在网络的同质性(Homophily)和结构性(Structural Equivalence)中进行权衡,可以进一步把不同的 Embedding 输入推荐模型,让推荐系统学习到不同的网络结构特点。
我这里所说的网络的“同质性”指的是距离相近节点的 Embedding 应该尽量近似,(DFS)
如图 3 所示,节点 u 与其相连的节点 s1、s2、s3、s4的 Embedding 表达应该是接近的,这就是网络“同质性”的体现。
在电商网站中,同质性的物品很可能是同品类、同属性,或者经常被一同购买的物品。
而“结构性”指的是结构上相似的节点的 Embedding 应该尽量接近,(BFS)
比如图 3 中节点 u 和节点 s6都是各自局域网络的中心节点,它们在结构上相似,所以它们的 Embedding 表达也应该近似,这就是“结构性”的体现。
在电商网站中,结构性相似的物品一般是各品类的爆款、最佳凑单商品等拥有类似趋势或者结构性属性的物品。
首先,为了使 Graph Embedding 的结果能够表达网络的“结构性”,在随机游走的过程中,我们需要让游走的过程更倾向于 BFS(Breadth First Search,宽度优先搜索),因为 BFS 会更多地在当前节点的邻域中进行游走遍历,相当于对当前节点周边的网络结构进行一次“微观扫描”。当前节点是“局部中心节点”,还是“边缘节点”,亦或是“连接性节点”,其生成的序列包含的节点数量和顺序必然是不同的,从而让最终的 Embedding 抓取到更多结构性信息。
而为了表达“同质性”,随机游走要更倾向于 DFS(Depth First Search,深度优先搜索)才行,因为 DFS 更有可能通过多次跳转,游走到远方的节点上。但无论怎样,DFS 的游走更大概率会在一个大的集团内部进行,这就使得一个集团或者社区内部节点的 Embedding 更为相似,从而更多地表达网络的“同质性”。
它主要是通过节点间的跳转概率来控制跳转的倾向性
αpq(t,x) 里的 dtx是指节点 t 到节点 x 的距离,比如节点 x1其实是与节点 t 直接相连的,所以这个距离 dtx就是 1,节点 t 到节点 t 自己的距离 dtt就是 0,而 x2、x3这些不与 t 相连的节点,dtx就是 2。
此外,αpq(t,x) 中的参数 p 和 q 共同控制着随机游走的倾向性。
- 参数 p 被称为返回参数(Return Parameter),p 越小,随机游走回节点 t 的可能性越大,Node2vec 就更注重表达网络的结构性。
- 参数 q 被称为进出参数(In-out Parameter),q 越小,随机游走到远方节点的可能性越大,Node2vec 更注重表达网络的同质性。反之,当前节点更可能在附近节点游走。你可以自己尝试给 p 和 q 设置不同大小的值,算一算从 v 跳转到 t、x1、x2和 x3的跳转概率。这样一来,应该就不难理解我刚才所说的随机游走倾向性的问题啦。
已经学习了好几种主流的 Embedding 方法,包括序列数据的 Embedding 方法,Word2vec 和 Item2vec,以及图数据的 Embedding 方法,Deep Walk 和 Node2vec。
由于 Embedding 的产出就是一个数值型特征向量,所以 Embedding 技术本身就可以视作特征处理方式的一种。只不过与简单的 One-hot 编码等方式不同,Embedding 是一种更高阶的特征处理方法,它具备了把序列结构、网络结构、甚至其他特征融合到一个特征向量中的能力。
应用方式大致有三种,分别是“直接应用”“预训练应用”和“End2End 应用”。
其中,“直接应用”最简单,就是在我们得到 Embedding 向量之后,直接利用 Embedding 向量的相似性实现某些推荐系统的功能。典型的功能有,利用物品 Embedding 间的相似性实现相似物品推荐,利用物品 Embedding 和用户 Embedding 的相似性实现“猜你喜欢”等经典推荐功能,还可以利用物品 Embedding 实现推荐系统中的召回层等。当然,如果你还不熟悉这些应用细节,也完全不用担心,我们在之后的课程中都会讲到。
“预训练应用”指的是在我们预先训练好物品和用户的 Embedding 之后,不直接应用,而是把这些 Embedding 向量作为特征向量的一部分,跟其余的特征向量拼接起来,作为推荐模型的输入参与训练。这样做能够更好地把其他特征引入进来,让推荐模型作出更为全面且准确的预测。
第三种应用叫做“End2End 应用”。看上去这是个新的名词,它的全称叫做“End to End Training”,也就是端到端训练。不过,它其实并不神秘,就是指我们不预先训练 Embedding,而是把 Embedding 的训练与深度学习推荐模型结合起来,采用统一的、端到端的方式一起训练,直接得到包含 Embedding 层的推荐模型。这种方式非常流行,比如图 6 就展示了三个包含 Embedding 层的经典模型,分别是微软的 Deep Crossing,UCL 提出的 FNN 和 Google 的 Wide&Deep。它们的实现细节我们也会在后续课程里面介绍,你这里只需要了解这个概念就可以了。
Embedding预训练的优点:
1.更快。因为对于End2End的方式,Embedding层的优化还受推荐算法的影响,这会增加计算量。
2.End2End难收敛。推荐算法是以Embedding为前提的,在端到端的方式中,在训练初期由于Embedding层的结果没有意义,所以推荐模块的优化也可能不太有意义,可能无法有效收敛。
Embedding端到端的优点:
这节课我们一起学习了 Graph Embedding 的两种主要方法,分别是 Deep Walk 和 Node2vec,并且我们还总结了 Embedding 技术在深度学习推荐系统中的应用方法。
学习 Deep Walk 方法关键在于理解它的算法流程:
其中,随机游走采样的次数、长度等都属于超参数,需要我们根据具体应用进行调整。
而 Node2vec 相比于 Deep Walk,增加了随机游走过程中跳转概率的倾向性。如果倾向于宽度优先搜索,则 Embedding 结果更加体现“结构性”。如果倾向于深度优先搜索,则更加体现“同质性”。
最后,我们介绍了 Embedding 技术在深度学习推荐系统中的三种应用方法,“直接应用”“预训练”和“End2End 训练”。
如何使用Spark生成Item2vec和Graph Embedding?
此节,会在 Spark 平台上,完成 Item2vec 和基于 Deep Walk 的 Graph Embedding 的训练。
但是 Spark 作为一个原生的分布式计算平台,在处理大数据方面还是比 TensorFlow 等深度学习平台更具有优势,而且业界的很多公司仍然在使用 Spark 训练一些结构比较简单的机器学习模型
Item2vec 要处理的是类似文本句子、观影序列之类的序列数据。那在真正开始 Item2vec 的训练之前,我们还要先为它准备好训练用的序列数据。
那在真正开始 Item2vec 的训练之前,我们还要先为它准备好训练用的序列数据。在 MovieLens 数据集中,有一张叫 rating(评分)的数据表,里面包含了用户对看过电影的评分和评分的时间。既然时间和评分历史都有了,我们要用的观影序列自然就可以通过处理 rating 表得到啦。
在使用观影序列编码之前,我们还要再明确两个问题。
但对用户来说,当然只有看过这部电影才能够评价它,所以,我们几乎可以把评分序列当作是观影序列。
这里,我是建议对评分做一个过滤,只放用户打分比较高的电影。为什么这么做呢?我们要思考一下 Item2vec 这个模型本质上是要学习什么。我们是希望 Item2vec 能够学习到物品之间的近似性。既然这样,我们当然是希望评分好的电影靠近一些,评分差的电影和评分好的电影不要在序列中结对出现。
就是对一个用户来说,我们先过滤掉他评分低的电影,再把他评论过的电影按照时间戳排序。这样,我们就得到了一个用户的观影序列,所有用户的观影序列就组成了 Item2vec 的训练样本集。
其实很简单,我们只需要明白这 5 个关键步骤就可以实现了:
where
语句过滤评分低的评分记录;groupBy userId
操作聚合每个用户的评分记录,DataFrame 中每条记录是一个用户的评分序列;sortUdf
,用它实现每个用户的评分记录按照时间戳进行排序;具体的实现过程,我还是建议你来参考我下面给出的代码,重要的地方我也都加上了注释,方便你来理解。
def processItemSequence(sparkSession: SparkSession): RDD[Seq[String]] ={
//设定rating数据的路径并用spark载入数据
val ratingsResourcesPath = this.getClass.getResource("/webroot/sampledata/ratings.csv")
val ratingSamples = sparkSession.read.format("csv").option("header", "true").load(ratingsResourcesPath.getPath)
//实现一个用户定义的操作函数(UDF),用于之后的排序
val sortUdf: UserDefinedFunction = udf((rows: Seq[Row]) => {
rows.map { case Row(movieId: String, timestamp: String) => (movieId, timestamp) }
.sortBy { case (movieId, timestamp) => timestamp }
.map { case (movieId, timestamp) => movieId }
})
//把原始的rating数据处理成序列数据
val userSeq = ratingSamples
.where(col("rating") >= 3.5) //过滤掉评分在3.5一下的评分记录
.groupBy("userId") //按照用户id分组
.agg(sortUdf(collect_list(struct("movieId", "timestamp"))) as "movieIds") //每个用户生成一个序列并用刚才定义好的udf函数按照timestamp排序
.withColumn("movieIdStr", array_join(col("movieIds"), " "))
//把所有id连接成一个String,方便后续word2vec模型处理
//把序列数据筛选出来,丢掉其他过程数据
userSeq.select("movieIdStr").rdd.map(r => r.getAs[String]("movieIdStr").split(" ").toSeq)
Item2vec:模型训练训练数据准备好了,就该进入我们这堂课的重头戏,模型训练了。手写 Item2vec 的整个训练过程肯定是一件让人比较“崩溃”的事情,好在 Spark MLlib 已经为我们准备好了方便调用的 Word2vec 模型接口。我先把训练的代码贴在下面,然后再带你一步步分析每一行代码是在做什么。
从上面的代码中我们可以看出,Spark 的 Word2vec 模型训练过程非常简单,只需要四五行代码就可以完成。接下来,我就按照从上到下的顺序,依次给你解析其中 3 个关键的步骤。
首先是创建 Word2vec 模型并设定模型参数。我们要清楚 Word2vec 模型的关键参数有 3 个,分别是 setVectorSize、setWindowSize 和 setNumIterations。其中,setVectorSize 用于设定生成的 Embedding 向量的维度,setWindowSize 用于设定在序列数据上采样的滑动窗口大小,setNumIterations 用于设定训练时的迭代次数。这些超参数的具体选择就要根据实际的训练效果来做调整了。
其次,模型的训练过程非常简单,就是调用模型的 fit 接口。训练完成后,模型会返回一个包含了所有模型参数的对象。
最后一步就是提取和保存 Embedding 向量,我们可以从最后的几行代码中看到,调用 getVectors 接口就可以提取出某个电影 ID 对应的 Embedding 向量,之后就可以把它们保存到文件或者其他数据库中,供其他模块使用了。在模型训练完成后,我们再来验证一下训练的结果是不是合理。我在代码中求取了 ID 为 592 电影的相似电影。这部电影叫 Batman 蝙蝠侠,我把通过 Item2vec 得到相似电影放到了下面,你可以从直观上判断一下这个结果是不是合理。
小结这节课,我们运用 Spark 实现了经典的 Embedding 方法 Item2vec 和 Deep Walk。它们的理论知识你应该已经在前两节课的学习中掌握了,这里我就总结一下实践中应该注意的几个要点。
关于 Item2vec 的 Spark 实现,你应该注意的是训练 Word2vec 模型的几个参数 VectorSize
、WindowSize
、NumIterations
等,知道它们各自的作用。它们分别是用来设置 Embedding 向量的维度,在序列数据上采样的滑动窗口大小,以及训练时的迭代次数。
而在 Deep Walk 的实现中,我们应该着重理解的是,生成物品间的转移概率矩阵的方法,以及通过随机游走生成训练样本过程。最后,我还是把这节课的重点知识总结在了一张表格中,希望能帮助你进一步巩固。
工业级推荐服务器的功能。
红色的部分就是我们要详细来讲的线上服务模块。
可以看到,线上服务模块的功能非常繁杂,它不仅需要跟离线训练好的模型打交道,把离线模型进行上线,在线进行模型服务(Model Serving),还需要跟数据库打交道,把候选物品和离线处理好的特征载入到服务器。
而且线上服务器内部的逻辑也十分地复杂,不仅包括了一些经典的过程,比如召回层和排序层,还包括一些业务逻辑,比如照顾推荐结果多样性,流行度的一些硬性的混合规则,甚至还包括了一些 AB 测试相关的测试代码。
宏观来讲,高并发推荐服务的整体架构主要由三个重要机制支撑,它们分别是负载均衡、缓存、推荐服务降级机制。下面,我们一一来看。
“负载均衡”提升服务能力,“缓存”降低服务压力,“服务降级”机制保证故障时刻的服务不崩溃,压力不传导,这三点可以看成是一个成熟稳定的高并发推荐服务的基石。
小结今天我们学习了推荐系统存储模块的设计原则和具体的解决方案,并且利用 Sparrow Recsys 进行了实战。在设计推荐系统存储方案时,我们一般要遵循“分级存储”的原则,在开销和性能之间取得权衡。
在 Sparrow Recsys 的实战中,我们安装并操作了内存数据库 Redis,你要记住 Redis 的特点“Key-value 形式存储”和“纯内存数据库”。在具体的特征存取过程中,我们应该熟悉利用 jedis 执行 SET,GET 等 Redis 常用操作的方法。
对于搭建一套完整的推荐服务来说,我们已经迈过了两大难关,分别是用 Jetty Server 搭建推荐服务器问题,以及用 Redis 解决特征存储的问题。下节课,我们会一起来挑战线上服务召回层的设计。
那在推荐物品候选集规模非常大的时候,我们该如何快速又准确地筛选掉不相关物品,从而节约排序时所消耗的计算资源呢?这其实就是推荐系统召回层要解决的问题。今天,我就从三个召回层技术方案入手,带你一起来解决这个问题。
从技术架构的角度来说,“召回层”处于推荐系统的线上服务模块之中,推荐服务器从数据库或内存中拿到所有候选物品集合后,会依次经过召回层、排序层、再排序层(也被称为补充算法层),才能够产生用户最终看到的推荐列表。
既然线上服务需要这么多“层”才能产生最终的结果,不同层之间的功能特点有什么区别呢?
召回层就是要快速、准确地过滤出相关物品,缩小候选集,
排序层则要以提升推荐效果为目标,作出精准的推荐列表排序。
再详细一点说,我们可以从候选集规模、模型复杂程度、特征数量、处理速度、排序精度等几个角度来对比召回层和排序层的特点:
需要注意的是,在我们设计召回层时,计算速度和召回率其实是两个矛盾的指标。怎么理解呢?比如说,为了提高计算速度,我们需要使召回策略尽量简单,而为了提高召回率或者说召回精度,让召回策略尽量把用户感兴趣的物品囊括在内,这又要求召回策略不能过于简单,否则召回物品就无法满足排序模型的要求。
单策略召回指的是,通过制定一条规则或者利用一个简单模型来快速地召回可能的相关物品。
这里的规则其实就是用户可能感兴趣的物品的特点,我们拿 SparrowRecSys 里面的电影推荐为例。在推荐电影的时候,我们首先要想到用户可能会喜欢什么电影。按照经验来说,很有可能是这三类,分别是大众口碑好的、近期非常火热的,以及跟我之前喜欢的电影风格类似的。
基于其中任何一条,我们都可以快速实现一个单策略召回层。比如在 SparrowRecSys 中,我就制定了这样一条召回策略:如果用户对电影 A 的评分较高,比如超过 4 分,那么我们就将与 A 风格相同,并且平均评分在前 50 的电影召回,放入排序候选集中。
计算速度一定是非常快的。
就是它有很强的局限性,因为大多数时候用户的兴趣是非常多元的,他们不仅喜欢自己感兴趣的,也喜欢热门的,当然很多时候也喜欢新上映的。这时候,单一策略就难以满足用户的潜在需求了,那有没有更全面的召回策略呢?
所谓“多路召回策略”,就是指采用不同的策略、特征或简单模型,分别召回一部分候选集,然后把候选集混合在一起供后续排序模型使用的策略。
其中,各简单策略保证候选集的快速召回,从不同角度设计的策略又能保证召回率接近理想的状态,不至于损害排序效果。所以,多路召回策略是在计算速度和召回率之间进行权衡的结果。
我们还是以电影推荐为例来做进一步的解释。下面是我给出的电影推荐中常用的多路召回策略,包括热门电影、风格类型、高分评价、最新上映以及朋友喜欢等等。除此之外,我们也可以把一些推断速度比较快的简单模型(比如逻辑回归,协同过滤等)生成的推荐结果放入多路召回层中,形成综合性更好的候选集。具体的操作过程就是,我们分别执行这些策略,让每个策略选取 Top K 个物品,最后混合多个 Top K 物品,就形成了最终的多路召回候选集。整个过程就如下所示:
在 SparrowRecsys 中,我们就实现了由风格类型、高分评价、最新上映,这三路召回策略组成的多路召回方法,具体代码如下
public static List<Movie> multipleRetrievalCandidates(List<Movie> userHistory){
HashSet<String> genres = new HashSet<>();
//根据用户看过的电影,统计用户喜欢的电影风格
for (Movie movie : userHistory){
genres.addAll(movie.getGenres());
}
//根据用户喜欢的风格召回电影候选集
HashMap<Integer, Movie> candidateMap = new HashMap<>();
for (String genre : genres){
List<Movie> oneCandidates = DataManager.getInstance().getMoviesByGenre(genre, 20, "rating");
for (Movie candidate : oneCandidates){
candidateMap.put(candidate.getMovieId(), candidate);
}
}
//召回所有电影中排名最高的100部电影
List<Movie> highRatingCandidates = DataManager.getInstance().getMovies(100, "rating");
for (Movie candidate : highRatingCandidates){
candidateMap.put(candidate.getMovieId(), candidate);
}
//召回最新上映的100部电影
List<Movie> latestCandidates = DataManager.getInstance().getMovies(100, "releaseYear");
for (Movie candidate : latestCandidates){
candidateMap.put(candidate.getMovieId(), candidate);
}
//去除用户已经观看过的电影
for (Movie movie : userHistory){
candidateMap.remove(movie.getMovieId());
}
//形成最终的候选集
return new ArrayList<>(candidateMap.values());
}
在实现的过程中,为了进一步优化召回效率,我们还可以通过多线程并行、建立标签 / 特征索引、建立常用召回集缓存等方法来进一步完善它。
不过,多路召回策略虽然能够比较全面地照顾到不同的召回方法,但也存在一些缺点。比如,在确定每一路的召回物品数量时,往往需要大量的人工参与和调整,具体的数值需要经过大量线上 AB 测试来决定。此外,因为策略之间的信息和数据是割裂的,所以我们很难综合考虑不同策略对一个物品的影响。
在第 5 讲和第 6 讲中,我们已经介绍了多种离线生成物品 Embedding 的方案。事实上,利用物品和用户 Embedding 相似性来构建召回层,是深度学习推荐系统中非常经典的技术方案。我们可以把它的优势总结为三方面。
一方面,多路召回中使用的“兴趣标签”“热门度”“流行趋势”“物品属性”等信息都可以作为 Embedding 方法中的附加信息(Side Information),融合进最终的 Embedding 向量中 。因此,在利用 Embedding 召回的过程中,我们就相当于考虑到了多路召回的多种策略。
另一方面,Embedding 召回的评分具有连续性。我们知道,多路召回中不同召回策略产生的相似度、热度等分值不具备可比性,所以我们无法据此来决定每个召回策略放回候选集的大小。但是,Embedding 召回却可以把 Embedding 间的相似度作为唯一的判断标准,因此它可以随意限定召回的候选集大小。
连续性指的是其评分是 用户embedding向量 内积 物品embedding向量,其结果是连续的
最后,在线上服务的过程中,Embedding 相似性的计算也相对简单和直接。通过简单的点积或余弦相似度的运算就能够得到相似度得分,便于线上的快速召回。
在 SparrowRecsys 中,我们也实现了基于 Embedding 的召回方法。我具体代码放在下面,你可以参考一下。
public static List<Movie> retrievalCandidatesByEmbedding(User user){
if (null == user){
return null;
}
//获取用户embedding向量
double[] userEmbedding = DataManager.getInstance().getUserEmbedding(user.getUserId(), "item2vec");
if (null == userEmbedding){
return null;
}
//获取所有影片候选集(这里取评分排名前10000的影片作为全部候选集)
List<Movie> allCandidates = DataManager.getInstance().getMovies(10000, "rating");
HashMap<Movie,Double> movieScoreMap = new HashMap<>();
//逐一获取电影embedding,并计算与用户embedding的相似度
for (Movie candidate : allCandidates){
double[] itemEmbedding = DataManager.getInstance().getItemEmbedding(candidate.getMovieId(), "item2vec");
double similarity = calculateEmbeddingSimilarity(userEmbedding, itemEmbedding);
movieScoreMap.put(candidate, similarity);
}
List<Map.Entry<Movie,Double>> movieScoreList = new ArrayList<>(movieScoreMap.entrySet());
//按照用户-电影embedding相似度进行候选电影集排序
movieScoreList.sort(Map.Entry.comparingByValue());
//生成并返回最终的候选集
List<Movie> candidates = new ArrayList<>();
for (Map.Entry<Movie,Double> movieScoreEntry : movieScoreList){
candidates.add(movieScoreEntry.getKey());
}
return candidates.subList(0, Math.min(candidates.size(), size));
}
这里,我再带你简单梳理一下整体的实现思路。总的来说,我们通过三步生成了最终的候选集。
在这三步之中,最主要的时间开销在第二步,虽然它的时间复杂度是线性的,但当物品集过大时(比如达到了百万以上的规模),线性的运算也可能造成很大的时间开销。那有没有什么方法能进一步缩小 Embedding 召回层的运算时间呢?这个问题我们留到下节课来讨论。
今天,我们一起讨论了推荐系统中召回层的功能特点和实现方法。并且重点讲解了单策略召回、多路召回,以及深度学习推荐系统中常用的基于 Embedding 的召回。
总的来说,关于召回层的重要内容,我总结成了一个特点,三个方案。
特点就是召回层的功能特点:召回层要快速准确地过滤出相关物品,缩小候选集。三个方案指的是实现召回层的三个技术方案:简单快速的单策略召回、业界主流的多路召回、深度学习推荐系统中最常用的 Embedding 召回。
这三种方法基本囊括了现在业界推荐系统的主流召回方法,希望通过这节课的学习,你能掌握这一关键模块的实现方法。
相信你也一定发现了,召回层技术的发展是循序渐进的,因此我希望你不仅能够学会应用它们,更能够站在前人的技术基础上,进一步推进它的发展,这也是工程师这份职业最大的魅力。
在深度学习推荐系统中,我们经常采用 Embedding 召回这一准确又便捷的方法。但是,在面对百万甚至更高量级的候选集时,线性地逐一计算 Embedding 间的相似度,往往会造成极大的服务延迟。
假设,用户和物品的 Embeding 都在一个 k 维的 Embedding 空间中,物品总数为 n,那么遍历计算一个用户和所有物品向量相似度的时间复杂度是多少呢?不难算出是 O(k×n)。虽然这一复杂度是线性的,但物品总数 n 达到百万甚至千万量级时,线性的时间复杂度也是线上服务不能承受的。
这个时候,我们要解决的问题就是,如何快速找到与一个 Embedding 最相似的 Embedding?这直接决定了召回层的执行速度,进而会影响推荐服务器的响应延迟。
业界解决近似 Embedding 搜索的主要方法,局部敏感哈希。
一种是聚类,我们把相似的点聚类到一起,不就可以快速地找到彼此间的最近邻了吗?另一种是索引,比如,我们通过某种数据结构建立基于向量距离的索引,在查找最近邻的时候,通过索引快速缩小范围来降低复杂度。这两种想法可不可行呢?我们一一尝试一下。
对于聚类问题,我想最经典的算法当属 K-means。它完成聚类的过程主要有以下几步:
到这里,整个 K-means 的迭代更新过程就完成了,你可以看下图 2。
如果我们能够在离线计算好每个 Embedding 向量的类别,在线上我们只需要在同一个类别内的 Embedding 向量中搜索就可以了,这会大大缩小了 Embedding 的搜索范围,时间复杂度自然就下降了。
但这个过程还是存在着一些边界情况。比如,
所以基于聚类的搜索还是有一定局限性的,解决上面的问题也会增加过多冗余过程,得不偿失。
既然聚类有局限性,那索引能不能奏效呢?我们这里可以尝试一下经典的向量空间索引方法 Kd-tree(K-dimension tree)。与聚类不同,它是为空间中的点 / 向量建立一个索引。这该怎么理解呢?
举个例子,你可以看下图 3 中的点云,我们先用红色的线把点云一分为二,再用深蓝色的线把各自片区的点云一分为二,以此类推,直到每个片区只剩下一个点,这就完成了空间索引的构建。如果我们能够把这套索引“搬”到线上,就可以利用二叉树的结构快速找到邻接点。比如,希望找到点 q 的 m 个邻接点,我们就可以先搜索它相邻子树下的点,如果数量不够,我们可以向上回退一个层级,搜索它父片区下的其他点,直到数量凑够 m 个为止。
图3 Kd-tree索引
听上去 Kd-tree 索引似乎是一个完美的方案,但它还是无法完全解决边缘点最近邻的问题。
它用简洁而高效的方法几乎完美地解决了这一问题。
局部敏感哈希的基本思想是希望让相邻的点落入同一个“桶”,这样在进行最近邻搜索时,我们仅需要在一个桶内,或相邻几个桶内的元素中进行搜索即可。如果保持每个桶中的元素个数在一个常数附近,我们就可以把最近邻搜索的时间复杂度降低到常数级别。
下面,我们以基于欧式距离的最近邻搜索为例,来解释构建局部敏感哈希“桶”的过程。
欧式空间中,将高维空间的点映射到低维空间,原本接近的点在低维空间中肯定依然接近,但原本远离的点则有一定概率变成接近的点。
利用低维空间可以保留高维空间相近距离关系的性质,我们就可以构造局部敏感哈希“桶”。对于 Embedding 向量来说,由于 Embedding 大量使用内积操作计算相似度,因此我们也可以用内积操作来构建局部敏感哈希桶。那我们利用内积操作可以将 v 映射到一维空间,得到数值:
h(v) = v ⋅ x
v 是高维空间中的 k 维 Embedding 向量,x 是随机生成的 k 维映射向量。
而且,我们刚刚说了,一维空间也会部分保存高维空间的近似距离信息。因此,我们可以使用哈希函数 h(v) 进行分桶,公式为:
其中, ⌊⌋ 是向下取整操作, w 是分桶宽度,b 是 0 到 w 间的一个均匀分布随机变量,避免分桶边界固化。
不过,映射操作会损失部分距离信息,如果我们仅采用一个哈希函数进行分桶,必然存在相近点误判的情况,因此,我们可以采用 m 个哈希函数同时进行分桶。如果两个点同时掉进了 m 个桶,那它们是相似点的概率将大大增加。通过分桶找到相邻点的候选集合后,我们就可以在有限的候选集合中通过遍历找到目标点真正的 K 近邻了。
刚才我们讲的哈希策略是基于内积操作来制定的,内积相似度也是我们经常使用的相似度度量方法,事实上距离的定义有很多种,比如“曼哈顿距离”“切比雪夫距离”“汉明距离”等等。针对不同的距离定义,分桶函数的定义也有所不同,但局部敏感哈希通过分桶方式保留部分距离信息,大规模降低近邻点候选集的本质思想是通用的。
这就涉及局部敏感哈希的多桶策略。如果有多个分桶函数的话,具体应该如何处理不同桶之间的关系呢?这就涉及局部敏感哈希的多桶策略。
假设有 A、B、C、D、E 五个点,有 h1和 h2两个分桶函数。使用 h1来分桶时,A 和 B 掉到了一个桶里,C、D、E 掉到了一个桶里;使用 h2来分桶时,A、C、D 掉到了一个桶里,B、E 在一个桶。那么请问如果我们想找点 C 的最近邻点,应该怎么利用两个分桶结果来计算呢?假设有 A、B、C、D、E 五个点,有 h1和 h2两个分桶函数。使用 h1来分桶时,A 和 B 掉到了一个桶里,C、D、E 掉到了一个桶里;使用 h2来分桶时,A、C、D 掉到了一个桶里,B、E 在一个桶。那么请问如果我们想找点 C 的最近邻点,应该怎么利用两个分桶结果来计算呢?
用“且”操作作为多桶策略,可以最大程度地减少候选点数量。但是,由于哈希分桶函数不是一个绝对精确的操作,点 D 也只是最有可能的最近邻点,不是一定的最近邻点,因此,“且”操作其实也增大了漏掉最近邻点的概率。
采用“或”(Or)操作作为多桶策略,这个时候,我们可以看到候选集中会有三个点,分别是 A、D、E。这样一来,虽然我们增大了候选集的规模,减少了漏掉最近邻点的可能性,但增大了后续计算的开销。
这些都还是工程上的权衡问题。我虽然不能给出具体的最佳数值,但可以给你一些取值的建议:
点数越多,我们越应该增加每个分桶函数中桶的个数;相反,点数越少,我们越应该减少桶的个数;
Embedding 向量的维度越大,我们越应该增加哈希函数的数量,尽量采用且的方式作为多桶策略;相反,Embedding 向量维度越小,我们越应该减少哈希函数的数量,多采用或的方式作为分桶策略。
答案是可以的,如果我们能够精确地控制每个桶内的点的规模是 C,假设每个 Embedding 的维度是 N,那么找到最近邻点的时间开销将永远在 O(C⋅N) 量级。采用多桶策略之后,
假设分桶函数数量是 K,那么时间开销也在 O(K⋅C⋅N) 量级,这仍然是一个常数。
利用 Sparrow Recsys 训练好的物品 Embedding,来实现局部敏感哈希的快速搜索吧。为了保证跟 Embedding 部分的平台统一,这一次我们继续使用 Spark MLlib 完成 LSH 的实现。
BucketLength
和 NumHashTables
这两个参数。其中,BucketLength 指的就是分桶公式中的分桶宽度 w,NumHashTables 指的是多桶策略中的分桶次数。fit
函数训练模型,再调用 transform
函数完成分桶的过程,具体的实现你可以参考下面的代码。def embeddingLSH(spark:SparkSession, movieEmbMap:Map[String, Array[Float]]): Unit ={
//将电影embedding数据转换成dense Vector的形式,便于之后处理
val movieEmbSeq = movieEmbMap.toSeq.map(item => (item._1, Vectors.dense(item._2.map(f => f.toDouble))))
val movieEmbDF = spark.createDataFrame(movieEmbSeq).toDF("movieId", "emb")
//利用Spark MLlib创建LSH分桶模型
val bucketProjectionLSH = new BucketedRandomProjectionLSH()
.setBucketLength(0.1)
.setNumHashTables(3)
.setInputCol("emb")
.setOutputCol("bucketId")
//训练LSH分桶模型
val bucketModel = bucketProjectionLSH.fit(movieEmbDF)
//进行分桶
val embBucketResult = bucketModel.transform(movieEmbDF)
//打印分桶结果
println("movieId, emb, bucketId schema:")
embBucketResult.printSchema()
println("movieId, emb, bucketId data result:")
embBucketResult.show(10, truncate = false)
//尝试对一个示例Embedding查找最近邻
println("Approximately searching for 5 nearest neighbors of the sample embedding:")
val sampleEmb = Vectors.dense(0.795,0.583,1.120,0.850,0.174,-0.839,-0.0633,0.249,0.673,-0.237)
bucketModel.approxNearestNeighbors(movieEmbDF, sampleEmb, 5).show(truncate = false)
}
使用 LSH 模型对电影 Embedding 进行分桶得到的五个结果打印了出来,如下所示:
+-------+-----------------------------+------------------+
|movieId|emb |bucketId |
+-------+-----------------------------+------------------------+
|710 |[0.04211471602320671,..] |[[-2.0], [14.0], [8.0]] |
|205 |[0.6645985841751099,...] |[[-4.0], [3.0], [5.0]] |
|45 |[0.4899883568286896,...] |[[-6.0], [-1.0], [2.0]] |
|515 |[0.6064003705978394,...] |[[-3.0], [-1.0], [2.0]] |
|574 |[0.5780771970748901,...] |[[-5.0], [2.0], [0.0]] |
+-------+-----------------------------+------------------------+
你可以看到在 BucketId 这一列,因为我们之前设置了 NumHashTables
参数为 3,所以每一个 Embedding 对应了 3 个 BucketId。在实际的最近邻搜索过程中,我们就可以利用刚才讲的多桶策略进行搜索了。
事实上,在一些超大规模的最近邻搜索问题中,索引、分桶的策略还能进一步复杂。如果你有兴趣深入学习,我推荐你去了解一下Facebook 的开源向量最近邻搜索库 FAISS,这是一个在业界广泛应用的开源解决方案。
悄悄告诉大家:embedding层K值的初始判断,有个经验公式:K= Embedding维数开4次方 ,x=初始的维度数; 后续,K值调参按照2的倍数进行调整,例如:2,4,8,16;
本节课,我们一起解决了“Embedding 最近邻搜索”问题。
事实上,对于推荐系统来说,我们可以把召回最相似物品 Embedding 的问题,看成是在高维的向量空间内搜索最近邻点的过程。遇到最近邻问题,我们一般会采用聚类和索引这两种方法。但是聚类和索引都无法完全解决边缘点最近邻的问题,并且对于聚类来说,中心点的数量 k 也并不好确定,而对于 Kd-tree 索引来说,Kd-tree 索引的结构并不简单,离线和在线维护的过程也相对复杂。🚩??
因此,解决最近邻问题最“完美”的办法就是使用局部敏感哈希,在每个桶内点的数量接近时,它能够把最近邻查找的时间控制在常数级别。为了进一步提高最近邻搜索的效率或召回率,我们还可以采用多桶策略,首先是基于“且”操作的多桶策略能够进一步减少候选集规模,增加计算效率,其次是基于“或”操作的多桶策略则能够提高召回率,减少漏掉最近邻点的可能性。
最后,我在下面列出了各种方法的优缺点,希望能帮助你做一个快速的复盘。
由于各个公司技术栈的特殊性,采用不同的机器学习平台,模型服务的方法会截然不同,不仅如此,使用不同的模型结构和模型存储方式,也会让模型服务的方法产生区别。总的来说,那业界主流的模型服务方法有 4 种,分别是预存推荐结果或 Embedding 结果、预训练 Embedding+ 轻量级线上模型、PMML 模型以及 TensorFlow Serving。接下来,我们就详细讲讲这些方法的实现原理,通过对比它们的优缺点,相信你会找到最合适自己业务场景的方法。
数据和模型部分的实现,其实和我们第 8 讲讲的 Embedding 的实战思路是一样的,我们可以选用 Item2vec、Deep Walk 等不同的 Embedding 方法,来生成物品 Embedding 向量。考虑到大数据条件下,数据处理与训练的一致性,在 Sparrow Recsys 中,我们会采用 Spark 进行数据处理,同时选择 Spark MLlib 进行 Embedding 的训练。这部分内容的代码,你可以参考项目中的_com.wzhe.sparrowrecsys.offline.spark.embedding.__Embedding_对象,它定义了所有项目中用到的 Embedding 方法。
线上服务部分是直接接收并处理用户推荐请求的部分,从架构图的最左边到最右边,我们可以看到三个主要步骤:候选物品库的建立、召回层的实现、排序层的实现。我们逐个来讲一讲。
首先是候选物品库的建立。Sparrow Recsys 中候选物品库的建立采用了非常简单的方式,就是直接把 MovieLens 数据集中的物品数据载入到内存中。但对于业界比较复杂的推荐业务来说,候选集的选取往往是有很多条件的, 比如物品可不可用,有没有过期,有没有其他过滤条件等等,所以,工业级推荐系统往往会通过比较复杂的 SQL 查询,或者 API 查询来获取候选集。
第二步是召回层的实现。我们在第 11 讲曾经详细学习了召回层的技术,这里终于可以学以致用了。因为物品的 Embedding 向量已经在离线生成,所以我们可以自然而然的使用 Embedding 召回的方法来完成召回层的实现。同时,Sparrow Recsys 也实现了基于物品 metadata(元信息)的多路召回方法,具体的实现你可以参照com.wzhe.sparrowrecsys.online.recprocess.SimilarMovieProcess类中的 multipleRetrievalCandidates
函数和 retrievalCandidatesByEmbedding
函数。
第三步是排序层的实现。根据 Embedding 相似度来进行“相似物品推荐”,是深度学习推荐系统最主流的解决方案,所以在 Sparrow Recsys 中,我们当然也是先根据召回层过滤出候选集,再从 Redis 中取出相应的 Embedding 向量,然后计算目标物品和候选物品之间的相似度,最后进行排序就可以了。
这里“相似度”的定义是多样的,可以是余弦相似度,也可以是内积相似度,还可以根据你训练 Embedding 时定义的不同相似度指标来确定。因为在 Word2vec 中,相似度的定义是内积相似度,所以, 这里我们也采用内积作为相似度的计算方法。同样,具体的实现,你可以参照 com.wzhe.sparrowrecsys.online.recprocess.SimilarMovieProcess 类中的 ranker 函数。
经历了这三个主要的线上服务步骤,Sparrow Recsys 就可以向用户返回推荐列表了。所以接下来,我们要解决的问题就是,怎么把这些结果通过前端页面展示给用户。
Sparrow Recsys 的前端部分采用了最简单的 HTML+AJAX 请求的方式。AJAX 的全称是 Asynchronous JavaScript and XML,异步 JavaScript 和 XML 请求。
Sparrow Recsys 开源项目中自带的 MovieLens 数据集是经过我采样后的缩小集,所以基于这个数据集训练出的模型的准确性和稳定性是比较低的。如果你有兴趣的话可以去MovieLens 官网选择 MovieLens 20M Dataset 下载并重新训练,相信会得到更准确的推荐结果。
https://grouplens.org/datasets/movielens/
方法一:人肉测试(SpotCheck)。
做一个抽样测试,看一看基于 Embedding 的相似推荐结果是不是符合你自己的常识。
直观上来看,《Free Willy》的推荐结果就非常不错,因为你可以看到相似电影中都是适合儿童看的,甚至这些电影都和动物相关。但是《玩具总动员》就不一样了,它的相似电影里不仅有动画片,还有《真实的谎言》(《True Lies》)、《阿甘正传》这类明显偏成人的电影。这明显不是一个非常好的推荐结果。
为什么会出现这样的结果呢?我们来做一个推测。事实上,《玩具总动员》本身是一部非常流行的电影,跟它近似的也都是类似《真实的谎言》、《阿甘正传》这类很热门的电影。这就说明了一个问题,热门电影其实很容易跟其他大部分电影产生相似性,因为它们会出现在大多数用户的评分序列中。
针对这个问题,其实仅利用基于用户行为序列的 Embedding 方法是很难解决的。这需要我们引入更多内容型特征进行有针对性的改进,比如电影类型、海报风格,或者在训练中有意减少热门电影的样本权重,增大冷门电影的样本权重等等。总的来说,遇到推荐结果不合理的情况,我们需要做更多的调查研究,发掘这些结果出现的真实原因,才能找到改进方向。
方法二:指定 Ground truth(可以理解为标准答案)。 虽然我们说,相似影片的 Ground truth 因人而异。但如果只是为了进行初步评估,我们也可以指定一些比较权威的验证集。比如,对于相似影片来说,我们可以利用 IMDB 的 more like this 的结果去做验证我们的相似电影结果。当然要补充说明的是,要注意有些 Ground truth 数据集的可用范围,不能随意在商业用途中使用未经许可的数据集。
方法三:利用商业指标进行评估。 既然相似影片比较难以直接衡量,那我们不如换一个角度,来思考一下做相似影片这个功能的目的是什么。对于一个商业网站来说,无非是提高点击率,播放量等等。因此,我们完全可以跃过评估相似度这样一个过程,直接去评估它的终极商业指标。
举个例子,我们可以通过上线一个新的相似电影模型,让相似电影这个功能模块的点击率提高,假设提高了 5%,那这就是一个成功的模型改进。至于相似电影到底有没有那么“相似”,我们反而不用那么纠结了。
这节课,
既然有做菜的过程,当然也有品菜的阶段。针对相似物品推荐这一常见的功能,我们可以使用人肉测试、Ground truth 和商业指标评估这三种方法对得到的结果进行评估。也希望你能够在实际的业务场景中活学活用,用评估结果指导模型的下一步改进。
我希望,通过这节课的总结和实战,能让你融会贯通的厘清我们学过的知识。所以我把你需要掌握的重要知识点,总结在了一张图里,你可以利用它复习巩固。
“用户行为数据是推荐系统最常用,也是最关键的数据。用户的潜在兴趣、用户对物品的评价好坏都反映在用户的行为历史中”。
而协同过滤算法,就是一种完全依赖用户和物品之间行为关系的推荐算法。 “协同大家的反馈、评价和意见一起对海量的信息进行过滤,从中筛选出用户可能感兴趣的信息”。
从共现矩阵中我们可以知道,用户 B 和用户 C 由于跟用户 X 的行向量近似,被选为 Top n(这里假设 n 取 2)相似用户,
计算用户相似度其实并不是什么难事,因为在共现矩阵中,每个用户对应的行向量其实就可以当作一个用户的 Embedding 向量。最经典的方法就是利用余弦相似度了,它衡量了用户向量 i 和用户向量 j 之间的向量夹角大小。夹角越小,余弦相似度越大,两个用户越相似,它的定义如下:
$$
sim(i,j)=cos(i,j)=\frac{i⋅j}{∣∣i∣∣×∥j∣∣}
$$
除了最常用的余弦相似度之外,相似度的定义还有皮尔逊相关系数、欧式距离等等。
在获得 Top n 个相似用户之后,利用 Top n 用户生成最终的用户 u 对物品 p 的评分是一个比较直接的过程。这里,我们假设的是“目标用户与其相似用户的喜好是相似的”,根据这个假设,我们可以利用相似用户的已有评价对目标用户的偏好进行预测。最常用的方式是,利用用户相似度和相似用户评价的加权平均值,来获得目标用户的评价预测,公式如下所示
其中,权重 w_u,s 是用户 u 和用户 s 的相似度,R_s,p 是用户 s 对物品 p 的评分。
在获得用户 u 对不同物品的评价预测后,最终的推荐列表根据评价预测得分进行排序即可得到。到这里,我们就完成了协同过滤的全部推荐过程。
协同过滤缺点,那就是共现矩阵往往非常稀疏,在用户历史行为很少的情况下,寻找相似用户的过程并不准确。
Netflix 对协同过滤算法进行了改进,提出了矩阵分解算法,加强了模型处理稀疏矩阵的能力。
图2 “协同过滤(左a)”和“矩阵分解(右b)”的原理图
如图 2(a) 所示,协同过滤算法找到用户可能喜欢的视频的方式很直观,就是利用用户的观看历史,找到跟目标用户 Joe 看过同样视频的相似用户,然后找到这些相似用户喜欢看的其他视频,推荐给目标用户 Joe。
矩阵分解算法则是期望为每一个用户和视频生成一个隐向量,将用户和视频定位到隐向量的表示空间上(如图 2(b) 所示),距离相近的用户和视频表明兴趣特点接近,在推荐过程中,我们就应该把距离相近的视频推荐给目标用户。例如,如果希望为图 2(b) 中的用户 Dave 推荐视频,我们可以找到离 Dave 的用户向量最近的两个视频向量,它们分别是《Ocean’s 11》和《The Lion King》,然后我们可以根据向量距离由近到远的顺序生成 Dave 的推荐列表。
个时候你肯定觉得,矩阵分解不就是相当于一种 Embedding 方法嘛。没错,矩阵分解的主要过程,就是先分解协同过滤生成的共现矩阵,生成用户和物品的隐向量,再通过用户和物品隐向量间的相似性进行推荐。
那这个过程的关键就在于如何分解这个共现矩阵了。从形式上看,矩阵分解的过程是直观的,就是把一个 mxn 的共现矩阵,分解成一个 mxk 的用户矩阵和 kxn 的物品矩阵相乘的形式(如图 3)。
图3 矩阵分解示意图
有了用户矩阵和物品矩阵,用户隐向量和物品隐向量就非常好提取了。用户隐向量就是用户矩阵相应的行向量,而物品隐向量就是物品矩阵相应的列向量。那这个过程的关键就在于如何分解这个共现矩阵了。从形式上看,矩阵分解的过程是直观的,就是把一个 mxn 的共现矩阵,分解成一个 mxk 的用户矩阵和 kxn 的物品矩阵相乘的形式(如图 3)。
图3 矩阵分解示意图
有了用户矩阵和物品矩阵,用户隐向量和物品隐向量就非常好提取了。用户隐向量就是用户矩阵相应的行向量,而物品隐向量就是物品矩阵相应的列向量。
最常用的方法就是梯度下降。梯度下降的原理我们在第 3 讲学习过,简单来说就是通过求取偏导的形式来更新权重。梯度更新的公式是
(w * * t + 1 = w * * t − α ∗ ∂w∂L)
。为了实现梯度下降,最重要的一步是定义损失函数 L,定义好损失函数我们才能够通过求导的方式找到梯度方向,这里我们就给出矩阵分解损失函数的定义如下。
这个目标函数里面,*r**_u**i* 是共现矩阵里面用户 u 对物品 i 的评分,*q_**i* 是物品向量,*pu* 是用户向量,K 是所有用户评分物品的全体集合。通过目标函数的定义我们可以看到,我们要求的物品向量和用户向量,是希望让物品向量和用户向量之积跟原始的评分之差的平方尽量小。简单来说就是,我们希望用户矩阵和物品矩阵的乘积尽量接近原来的共现矩阵。**
在通过训练得到用户隐向量和物品隐向量之后,在服务器内部的推荐过程跟我们之前讲过的 Embedding 推荐是一样的,你也已经在 Sparrow RecSys 里面实践过,是这方面的专家了,我就不再多说了。
我们可以看到,因为 Spark MLlib 已经帮我们封装好了模型,所以矩阵分解算法实现起来非常简单,还是通过我们熟悉的三步来完成,
但是有一点我们需要注意,就是在模型中,我们需要在模型中指定训练样本中用户 ID 对应的列 userIdInt 和物品 ID 对应的列 movieIdInt,并且两个 ID 列对应的数据类型需要是 int 类型的。
// 建立矩阵分解模型
val als = new ALS()
.setMaxIter(5)
.setRegParam(0.01)
.setUserCol("userIdInt")
.setItemCol("movieIdInt")
.setRatingCol("ratingFloat")
//训练模型
val model = als.fit(training)
//得到物品向量和用户向量
model.itemFactors.show(10, truncate = false)
model.userFactors.show(10, truncate = false
其实,矩阵分解算法得出的结果,你完全可以把它当作 Embedding 来处理。具体怎么做呢?在讲 Redis 的时候,我们就已经实现过物品 Embedding 和用户 Embedding 的存储和线上预估的过程了,你可以直接参考它。最后,我建议你利用矩阵分解后的用户和物品隐向量,仿照其他 Embedding 的实现,在 Sparrow RecSys 中动手实现一下线上部署的过程,这样你就可以看到矩阵分解模型的实际效果了。
这节课我们一起学习了协同过滤算法,以及它的后续算法矩阵分解,它是最经典的推荐算法。
总结来说,协同过滤是一种协同大家的反馈、评价和意见,对海量的信息进行过滤,从中筛选出用户感兴趣信息的一种推荐算法。它的实现过程主要有三步,
但是协同过滤处理稀疏矩阵的能力比较差,因此,矩阵分解算法被提出了,它通过分解共现矩阵,生成用户向量矩阵和物品向量矩阵,进而得到用户隐向量和物品隐向量。你可以完全把最后的结果当作用户 Embedding 和物品 Embedding 来处理。
针对这节课的重要知识点,我把它们都列在了下面的表格里,你可以看看。
能够显著提升推荐系统的效果,原因主要有两点,
这其中典型的例子就是阿里巴巴的模型 DIN(深度兴趣网络)和 DIEN(深度兴趣进化网络)。它们通过在模型结构中引入注意力机制和模拟兴趣进化的序列模型,来更好地模拟用户的行为。
图4 DIN模型(左)和DIEN的模型(右)示意图
我们重点关注图 4 的 DIN 模型,它在神经网络中增加了一个叫做“激活单元“的结构,这个单元就是为了模拟人类的注意力机制。
举个例子来说,我们在购买电子产品,比如说笔记本电脑的时候,更容易拿之前购买电脑的经验,或者其他电子产品的经验来指导当前的购买行为,很少会借鉴购买衣服和鞋子的经验。这就是一个典型的注意力机制,我们只会注意到相关度更高的历史购买行为,而 DIN 模型就是模拟了人类的注意力特点。
DIN 模型的改进版 DIEN 模型就更厉害了,它不仅引入了注意力机制,还模拟了用户兴趣随时间的演化过程。我们来看那些彩色的层,这一层层的序列结构模拟的正是用户兴趣变迁的历史,通过模拟变迁的历史,DIEN 模型可以更好地预测下一步用户会喜欢什么。
首先,我们来看整个演化图最中心部分,这是深度学习最基础的结构,我们叫它“多层神经网络”或者“多层感知机”,简称 MLP(MultiLayer Perceptron)。多层感知机的原理我们在第 3 讲中讲过,它就像一个黑盒,会对输入的特征进行深度地组合交叉,然后输出对兴趣值的预测。其他的深度推荐模型全都是在多层感知机的基础上,进行结构上的改进而生成的,所以“多层感知机”是整个演化图的核心。
从多层感知机向上,还有一个重点模型我们需要知道,那就是 Deep Crossing。Deep Crossing 实际上是一类经典深度学习模型的代表,相比于 MLP,Deep Crossing 在原始特征和 MLP 之间加入了 Embedding 层。这样一来,输入的稀疏特征先转换成稠密 Embedding 向量,再参与到 MLP 中进行训练,这就解决了 MLP 不善于处理稀疏特征的问题。可以说,Embedding+MLP 的结构是最经典,也是应用最广的深度学习推荐模型结构。
从 MLP 向下,我们看到了 Google 提出的推荐模型 Wide&Deep。它把深层的 MLP 和单层的神经网络结合起来,希望同时让网络具备很好的“记忆性”和“泛化性”。对“记忆性”和“泛化性”这两个名词陌生的同学也不用着急,我们后面的课程会专门来讲解 Wide&Deep。
Wide&Deep 提出以来,凭借着“易实现”“易落地”“易改造”的特点,获得了业界的广泛应用。围绕着 Wide&Deep 还衍生出了诸多变种,比如,通过改造 Wide 部分提出的 Deep&Cross 和 DeepFM,通过改造 Deep 部分提出的 AFM、NFM 等等。总之,Wide&Deep 是业界又一得到广泛应用的深度推荐模型。
除此之外,我们还可以看到经典的深度学习模型跟其他机器学习子领域的交叉。这里,我给你举 3 个比较著名的例子:第 1 个是深度学习和注意力机制的结合,诞生了阿里的深度兴趣网络 DIN,浙大和新加坡国立提出的 AFM 等等;第 2 个是把序列模型引入 MLP+Embedding 的经典结构,诞生了阿里的深度兴趣进化网络 DIEN;第 3 个是把深度学习和强化学习结合在一起,诞生了微软的深度强化学习网络 DRN,以及包括美团、阿里在内的非常有价值的业界应用。
看了这诸多模型的演进过程,你肯定想问模型的演化有什么规律可循吗?接下来,我就把我总结出的,关于模型改进的四个方向告诉你 。
一是改变神经网络的复杂程度。 从最简单的单层神经网络模型 AutoRec,到经典的深度神经网络结构 Deep Crossing,它们主要的进化方式在于增加了深度神经网络的层数和结构复杂度。
二是改变特征交叉方式。 这种演进方式的要点在于大大提高了深度学习网络中特征交叉的能力。比如说,改变了用户向量和物品向量互操作方式的 NeuralCF,定义了多种特征向量交叉操作的 PNN 等等。
三是把多种模型组合应用。 组合模型主要指的就是以 Wide&Deep 模型为代表的一系列把不同结构组合在一起的改进思路。它通过组合两种甚至多种不同特点、优势互补的深度学习网络,来提升模型的综合能力。
四是让深度推荐模型和其他领域进行交叉。 我们从 DIN、DIEN、DRN 等模型中可以看出,深度推荐模型无时无刻不在从其他研究领域汲取新的知识。事实上,这个过程从未停歇,我们从今年的推荐系统顶会 Recsys2020 中可以看到,NLP 领域的著名模型 Bert 又与推荐模型结合起来,并且产生了非常好的效果。一般来说,自然语言处理、图像处理、强化学习这些领域都是推荐系统经常汲取新知识的地方。
这节课,我们通过学习深度学习对推荐系统的影响要素,以及经典深度学习模型之间的关系,初步建立起了深度学习模型的知识库。
我们知道,深度学习能够提升推荐系统的效果有两个关键因素,分别是它的“强拟合能力”和“结构的灵活性”。
对于“强拟合能力”来说,深度学习模型可以大大增加模型的“非线性”拟合能力,对复杂数据模型进行更准确的分类,避免“欠拟合”现象的发生,从而提升推荐效果。
对于“结构的灵活性”来说,深度学习模型可以通过灵活调整自身的结构,更轻松恰当地模拟人们的思考过程和行为过程,把用户猜得更透。
而整个深度学习推荐模型的演化过程,是从最经典的多层神经网络向不同方向开枝散叶,比如结合协同过滤发展出了 NerualCF,加入 Embedding 层发展出以 Deep Crossing 为代表的 Embedding+MLP 的结构,以及把深度神经网络和单层网络结合起来发展出 Wide&Deep 模型等等。
在这节课,我们可以先忽略每个模型的细节,着重建立一个整体的知识框架。之后的课程中,我不仅会带你一一揭晓它们的技术细节,还会利用 TensorFlow 实现其中几个经典的模型。期待继续与你一起学习!
最后,我还是把这节课的重点知识梳理成了表格的形式,你可以借助它来复习巩固。
MovieLens 数据集中,可供我们提取特征的数据表有两个,分别是 movies 表和 ratings 表,它们的数据格式如下:
图1 电影基本数据movies表(左),用户评分数据ratings表(右)
接下来,我们按照“物品特征”“用户特征”“场景特征”,这三大类推荐系统特征的顺序,来看一看从这两张表中能提取出什么样的特征。
“物品特征”在我们的项目里指的就是电影特征了,从 movies 表中我们可以提取出电影的基本信息,包括 movieId、title(电影名)、releaseYear(发布年份)和 genre(风格类型)。除此之外,我们还可以利用 ratings 表为每个电影提取出一些统计类的特征,包括电影的平均评分、评分标准差等等。
接下来是“用户特征”。乍一看,从 movies 和 ratings 表中,除了 userId 我们好像找不到其他直接可用的用户信息了。这个时候,千万不要忘了我们之前提到过的,用户特征最重要的部分就是历史行为特征。
所以,从用户的评分历史中,我们其实可以提取出非常多有价值的特征。比如,我们可以根据 ratings 表的历史联合 movies 表的电影信息,提取出用户统计类特征,它包括用户评分总数、用户平均评分、用户评分标准差、用户好评电影的发布年份均值、用户好评电影的发布年份标准差、用户最喜欢的电影风格,以及用户好评电影 ID 等等。
最后是“场景特征”。我们可用的场景特征就一个,那就是评分的时间戳,我们把它作为代表时间场景的特征放到特征工程中。
com.wzhe.sparrowrecsys.offline.spark.featureeng.FeatureEngForRecModel 对象,里面包含了所有特征工程的代码。这里,我们只讲几个有代表性的统计型特征的处理方法。
val movieRatingFeatures = samplesWithMovies3.groupBy(col(“movieId”))
.agg(count(lit(1)).as(“movieRatingCount”),
avg(col(“rating”)).as(“movieAvgRating”),
stddev(col(“rating”)).as(“movieRatingStddev”))
计算统计型特征的典型方法,就是利用 Spark 中的 groupBy 操作,将原始评分数据按照 movieId 分组,然后用 agg 聚合操作来计算一些统计型特征。比如,在上面的代码中,我们就分别使用了 count 内置聚合函数来统计电影评价次数(movieRatingCount),用 avg 函数来统计评分均值(movieAvgRating),以及使用 stddev 函数来计算评价分数的标准差(movieRatingStddev)。
特征处理具体过程,我们就讲完了。不过,这里我还想和你多分享一些我的经验。一般来说,我们不会人为预设哪个特征有用,哪个特征无用,而是让模型自己去判断,如果一个特征的加入没有提升模型效果,我们再去除这个特征。就像我刚才虽然提取了不少特征,但并不是说每个模型都会使用全部的特征,而是根据模型结构、模型效果有针对性地部分使用它们。在接下来的课程中,我们还会详细探讨不同模型对这些特征的具体使用方法。
我们可以看到,代码中有一个over(Window.partitionBy(“userId”).orderBy(col(“timestamp”)))操作,它的意思是,在做 rating 平均这个操作的时候,我们不要对这个 userId 下面的所有评分取平均值,而是要创建一个滑动窗口,先把这个用户下面的评分按照时间排序,再让这个滑动窗口一一滑动,滑动窗口的位置始终在当前 rating 前一个 rating 的位置。这样,我们再对滑动窗口内的分数做平均,就不会引入未来信息了。
类似的操作,我使用在了所有与历史行为有关的特征中,你也可以在 SparrowRecsys 的源码中看到。
在生成好特征和训练样本之后,还有一个问题需要我们解决,那就是特征的线上存储问题。因为训练样本是供离线训练使用的,而线上模型推断过程是要使用线上特征的。
好在,特征数据库 Redis 已经为我们提供了解决办法。我们把用户特征和物品特征分别存入 Redis,线上推断的时候,再把所需的用户特征和物品特征分别取出,拼接成模型所需的特征向量就可以了。
FeatureEngForRecModel 中的 extractAndSaveUserFeaturesToRedis 函数给出了详细的 Redis 操作,我把其中的关键操作放在了下面。
这节课,我们选择 Spark 作为特征和样本处理的平台,是因为 Spark 更擅长海量数据的分布式处理,为 TensorFlow 减轻数据处理的负担。在选择具体特征的过程中,我们遵循了“物品特征”“用户特征”“场景特征”这三大类特征分类方式,基于 MovieLens 的 ratings 表和 movies 表完成了特征抽取。
在样本处理过程中,我们选用评分和基于评分生成的好评差评标识作为样本标签,并基于 ratings 表的每条数据,通过联合物品和用户数据生成训练样本。在训练样本的生成中,要特别注意“未来信息”的问题,利用 Spark 中的 window 函数滑动生成历史行为相关特征。最后我们利用 Redis 的 hset 操作把线上推断用的特征存储 Redis。
这些重点内容,我也都总结在了下面的表格里,你可以看一看。
原因就在于 Feature#1 代表的是类别型特征经过 One-hot 编码后生成的特征向量,而 Feature#2 代表的是数值型特征。所以我们需要通过连接到 Embedding 层的方式,把这个稀疏的 One-hot 向量转换成比较稠密的 Embedding 向量。
Stacking 层中文名是堆叠层,我们也经常叫它连接(Concatenate)层。它的作用比较简单,就是把不同的 Embedding 特征和数值型特征拼接在一起,形成新的包含全部特征的特征向量。
MLP 层就是我们开头提到的多层神经网络层,在图 1 中指的是 Multiple Residual Units 层,中文叫多层残差网络。微软在实现 Deep Crossing 时针对特定的问题选择了残差神经元
MLP 层的特点是全连接,就是不同层的神经元两两之间都有连接。
MLP 层的作用是让特征向量不同维度之间做充分的交叉,让模型能够抓取到更多的非线性特征和组合特征的信息,这就使深度学习模型在表达能力上较传统机器学习模型大为增强。
最后是 Scoring 层,它也被称为输出层。虽然深度学习模型的结构可以非常复杂,但最终我们要预测的目标就是一个分类的概率。如果是点击率预估,就是一个二分类问题,那我们就可以采用逻辑回归作为输出层神经元,而如果是类似图像分类这样的多分类问题,我们往往在输出层采用 softmax 这样的多分类模型。
到这里,我们就讲完了 Embedding+MLP 的五层结构。它的结构重点用一句话总结就是,对于类别特征,先利用 Embedding 层进行特征稠密化,再利用 Stacking 层连接其他特征,输入 MLP 的多层结构,最后用 Scoring 层预估结果。
这节课是我们深度学习模型实践的第一课,我们要掌握两个重点内容,
一是 Embedding+MLP 的模型结构,
Embedding+MLP 主要是由 Embedding 部分和 MLP 部分这两部分组成,使用 Embedding 层是为了将类别型特征转换成 Embedding 向量,MLP 部分是通过多层神经网络拟合优化目标。
具体来说,以微软的 Deep Crossing 为例,模型一共分为 5 层,从下到上分别是 Feature 层、Embedding 层、Stacking 层、MLP 层和 Scoring 层。
二是 Embedding+MLP 模型的 TensorFlow 实现。
在 TensorFlow 实践部分,我们利用上节课处理好的特征和训练数据,实现了 Sparrow Recsys 项目中的第一个深度学习模型。在实践过程中,我们要重点掌握类别型特征的处理方法,模型的定义方式和训练方式,以及最后的模型评估方法。
我也把这些重点知识总结在了一张表格里,你可以利用它来认真回顾。
今天,我们一起完成了 Embedding MLP 模型的实现。在之后的课程中,我们会进一步实现其他深度学习模型,通过模型的评估进行效果上的对比。另外,我们也会利用训练出的深度学习模型完成 Sparrow Recsys 的猜你喜欢功能,期待与你一起不断完善我们的项目。
在上一节的实践准备课程中,我们已经为模型训练准备好了可用的训练样本和特征。秉着“类别型特征 Embedding 化,数值型特征直接输入 MLP”的原则,我们选择 movieId、userId、movieGenre、userGenre 作为 Embedding 化的特征,选择物品和用户的统计型特征作为直接输入 MLP 的数值型特征,具体的特征选择你可以看看下面的表格:
选择好特征后,就是 MLP 部分的模型设计了。我们选择了一个三层的 MLP 结构,其中前两层是 128 维的全连接层。我们这里采用好评 / 差评标签作为样本标签,因此要解决的是一个类 CTR 预估的二分类问题,对于二分类问题,我们最后一层采用单个 sigmoid 神经元作为输出层就可以了。
其实,我们对模型层数和每个层内维度的选择是一个超参数调优的问题,这里的选择不能保证最优,我们需要在实战中需要根据模型的效果进行超参数的搜索,找到最适合的模型参数。
这节课,我们一起实现了业界影响力非常大的深度学习模型 Wide&Deep,它是由 Wide 部分和 Deep 部分组成的。其中,Wide 部分主要是为了增强模型的“记忆能力”,让模型记住“如果 A,那么 B”这样的简单但数量非常多的规则。Deep 部分是为了增强模型的“泛化能力”,让模型具备对于稀缺样本、以及从未出现过的特征组合的预测能力。Wide&Deep 正是通过这样取长补短的方式,让模型的综合能力提升。
在具体实践的时候,我们继续使用 TensorFlow 的 Keras 接口实现了 Wide&Deep 模型。相比上节课 Embedding MLP 模型的实现,我们新加入了“用户已好评电影”和“当前评价电影”组成的交叉特征 crossed_feature,让 Wide 部分学习“一个喜欢电影 A 的用户,也会喜欢电影 B”这样的规则。
好了,这就是我们这节课的主要内容,同样,我也把重要的知识点总结在了表格里,你可以利用它来巩固复习。