feat(WIKI): wiki 2025 BREAKING CHANGE: Older content is categorized into older folders
This commit is contained in:
352
2023旧版内容/4.人工智能/ch02/ch2.2/ch2.2.1.md
Normal file
352
2023旧版内容/4.人工智能/ch02/ch2.2/ch2.2.1.md
Normal file
@@ -0,0 +1,352 @@
|
||||
### GBDT+LR简介
|
||||
|
||||
前面介绍的协同过滤和矩阵分解存在的劣势就是仅利用了用户与物品相互行为信息进行推荐, 忽视了用户自身特征, 物品自身特征以及上下文信息等,导致生成的结果往往会比较片面。 而这次介绍的这个模型是2014年由Facebook提出的GBDT+LR模型, 该模型利用GBDT自动进行特征筛选和组合, 进而生成新的离散特征向量, 再把该特征向量当做LR模型的输入, 来产生最后的预测结果, 该模型能够综合利用用户、物品和上下文等多种不同的特征, 生成较为全面的推荐结果, 在CTR点击率预估场景下使用较为广泛。
|
||||
|
||||
下面首先会介绍逻辑回归和GBDT模型各自的原理及优缺点, 然后介绍GBDT+LR模型的工作原理和细节。
|
||||
|
||||
### 逻辑回归模型
|
||||
|
||||
逻辑回归模型非常重要, 在推荐领域里面, 相比于传统的协同过滤, 逻辑回归模型能够综合利用用户、物品、上下文等多种不同的特征生成较为“全面”的推荐结果, 关于逻辑回归的更多细节, 可以参考下面给出的链接,这里只介绍比较重要的一些细节和在推荐中的应用。
|
||||
|
||||
逻辑回归是在线性回归的基础上加了一个 Sigmoid 函数(非线形)映射,使得逻辑回归成为了一个优秀的分类算法, 学习逻辑回归模型, 首先应该记住一句话:**逻辑回归假设数据服从伯努利分布,通过极大化似然函数的方法,运用梯度下降来求解参数,来达到将数据二分类的目的。**
|
||||
|
||||
相比于协同过滤和矩阵分解利用用户的物品“相似度”进行推荐, 逻辑回归模型将问题看成了一个分类问题, 通过预测正样本的概率对物品进行排序。这里的正样本可以是用户“点击”了某个商品或者“观看”了某个视频, 均是推荐系统希望用户产生的“正反馈”行为, 因此**逻辑回归模型将推荐问题转化成了一个点击率预估问题**。而点击率预测就是一个典型的二分类, 正好适合逻辑回归进行处理, 那么逻辑回归是如何做推荐的呢? 过程如下:
|
||||
1. 将用户年龄、性别、物品属性、物品描述、当前时间、当前地点等特征转成数值型向量
|
||||
2. 确定逻辑回归的优化目标,比如把点击率预测转换成二分类问题, 这样就可以得到分类问题常用的损失作为目标, 训练模型
|
||||
3. 在预测的时候, 将特征向量输入模型产生预测, 得到用户“点击”物品的概率
|
||||
4. 利用点击概率对候选物品排序, 得到推荐列表
|
||||
|
||||
推断过程可以用下图来表示:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20200909215410263.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" alt="在这里插入图片描述" style="zoom:55%;" />
|
||||
</div>
|
||||
|
||||
这里的关键就是每个特征的权重参数$w$, 我们一般是使用梯度下降的方式, 首先会先随机初始化参数$w$, 然后将特征向量(也就是我们上面数值化出来的特征)输入到模型, 就会通过计算得到模型的预测概率, 然后通过对目标函数求导得到每个$w$的梯度, 然后进行更新$w$
|
||||
|
||||
这里的目标函数长下面这样:
|
||||
|
||||
$$
|
||||
J(w)=-\frac{1}{m}\left(\sum_{i=1}^{m}\left(y^{i} \log f_{w}\left(x^{i}\right)+\left(1-y^{i}\right) \log \left(1-f_{w}\left(x^{i}\right)\right)\right)\right.
|
||||
$$
|
||||
求导之后的方式长这样:
|
||||
$$
|
||||
w_{j} \leftarrow w_{j}-\gamma \frac{1}{m} \sum_{i=1}^{m}\left(f_{w}\left(x^{i}\right)-y^{i}\right) x_{j}^{i}
|
||||
$$
|
||||
这样通过若干次迭代, 就可以得到最终的$w$了, 关于这些公式的推导,可以参考下面给出的文章链接, 下面我们分析一下逻辑回归模型的优缺点。
|
||||
|
||||
**优点:**
|
||||
1. LR模型形式简单,可解释性好,从特征的权重可以看到不同的特征对最后结果的影响。
|
||||
2. 训练时便于并行化,在预测时只需要对特征进行线性加权,所以**性能比较好**,往往适合处理**海量id类特征**,用id类特征有一个很重要的好处,就是**防止信息损失**(相对于范化的 CTR 特征),对于头部资源会有更细致的描述
|
||||
3. 资源占用小,尤其是内存。在实际的工程应用中只需要存储权重比较大的特征及特征对应的权重。
|
||||
4. 方便输出结果调整。逻辑回归可以很方便的得到最后的分类结果,因为输出的是每个样本的概率分数,我们可以很容易的对这些概率分数进行cutoff,也就是划分阈值(大于某个阈值的是一类,小于某个阈值的是一类)
|
||||
|
||||
**当然, 逻辑回归模型也有一定的局限性**
|
||||
1. 表达能力不强, 无法进行特征交叉, 特征筛选等一系列“高级“操作(这些工作都得人工来干, 这样就需要一定的经验, 否则会走一些弯路), 因此可能造成信息的损失
|
||||
2. 准确率并不是很高。因为这毕竟是一个线性模型加了个sigmoid, 形式非常的简单(非常类似线性模型),很难去拟合数据的真实分布
|
||||
3. 处理非线性数据较麻烦。逻辑回归在不引入其他方法的情况下,只能处理线性可分的数据, 如果想处理非线性, 首先对连续特征的处理需要先进行**离散化**(离散化的目的是为了引入非线性),如上文所说,人工分桶的方式会引入多种问题。
|
||||
4. LR 需要进行**人工特征组合**,这就需要开发者有非常丰富的领域经验,才能不走弯路。这样的模型迁移起来比较困难,换一个领域又需要重新进行大量的特征工程。
|
||||
|
||||
所以如何**自动发现有效的特征、特征组合,弥补人工经验不足,缩短LR特征实验周期**,是亟需解决的问题, 而GBDT模型, 正好可以**自动发现特征并进行有效组合**
|
||||
|
||||
### GBDT模型
|
||||
|
||||
GBDT全称梯度提升决策树,在传统机器学习算法里面是对真实分布拟合的最好的几种算法之一,在前几年深度学习还没有大行其道之前,gbdt在各种竞赛是大放异彩。原因大概有几个,一是效果确实挺不错。二是即可以用于分类也可以用于回归。三是可以筛选特征, 所以这个模型依然是一个非常重要的模型。
|
||||
|
||||
GBDT是通过采用加法模型(即基函数的线性组合),以及不断减小训练过程产生的误差来达到将数据分类或者回归的算法, 其训练过程如下:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20200908202508786.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" style="zoom:65%;" />
|
||||
</div>
|
||||
gbdt通过多轮迭代, 每轮迭代会产生一个弱分类器, 每个分类器在上一轮分类器的残差基础上进行训练。 gbdt对弱分类器的要求一般是足够简单, 并且低方差高偏差。 因为训练的过程是通过降低偏差来不断提高最终分类器的精度。 由于上述高偏差和简单的要求,每个分类回归树的深度不会很深。最终的总分类器是将每轮训练得到的弱分类器加权求和得到的(也就是加法模型)。
|
||||
|
||||
关于GBDT的详细细节,依然是可以参考下面给出的链接。这里想分析一下GBDT如何来进行二分类的,因为我们要明确一点就是**gbdt 每轮的训练是在上一轮的训练的残差基础之上进行训练的**, 而这里的残差指的就是当前模型的负梯度值, 这个就要求每轮迭代的时候,弱分类器的输出的结果相减是有意义的, 而**gbdt 无论用于分类还是回归一直都是使用的CART 回归树**, 那么既然是回归树, 是如何进行二分类问题的呢?
|
||||
|
||||
GBDT 来解决二分类问题和解决回归问题的本质是一样的,都是通过不断构建决策树的方式,使预测结果一步步的接近目标值, 但是二分类问题和回归问题的损失函数是不同的, 关于GBDT在回归问题上的树的生成过程, 损失函数和迭代原理可以参考给出的链接, 回归问题中一般使用的是平方损失, 而二分类问题中, GBDT和逻辑回归一样, 使用的下面这个:
|
||||
|
||||
$$
|
||||
L=\arg \min \left[\sum_{i}^{n}-\left(y_{i} \log \left(p_{i}\right)+\left(1-y_{i}\right) \log \left(1-p_{i}\right)\right)\right]
|
||||
$$
|
||||
其中, $y_i$是第$i$个样本的观测值, 取值要么是0要么是1, 而$p_i$是第$i$个样本的预测值, 取值是0-1之间的概率,由于我们知道GBDT拟合的残差是当前模型的负梯度, 那么我们就需要求出这个模型的导数, 即$\frac{dL}{dp_i}$, 对于某个特定的样本, 求导的话就可以只考虑它本身, 去掉加和号, 那么就变成了$\frac{dl}{dp_i}$, 其中$l$如下:
|
||||
$$
|
||||
\begin{aligned}
|
||||
l &=-y_{i} \log \left(p_{i}\right)-\left(1-y_{i}\right) \log \left(1-p_{i}\right) \\
|
||||
&=-y_{i} \log \left(p_{i}\right)-\log \left(1-p_{i}\right)+y_{i} \log \left(1-p_{i}\right) \\
|
||||
&=-y_{i}\left(\log \left(\frac{p_{i}}{1-p_{i}}\right)\right)-\log \left(1-p_{i}\right)
|
||||
\end{aligned}
|
||||
$$
|
||||
如果对逻辑回归非常熟悉的话, $\left(\log \left(\frac{p_{i}}{1-p_{i}}\right)\right)$一定不会陌生吧, 这就是对几率比取了个对数, 并且在逻辑回归里面这个式子会等于$\theta X$, 所以才推出了$p_i=\frac{1}{1+e^-{\theta X}}$的那个形式。 这里令$\eta_i=\frac{p_i}{1-p_i}$, 即$p_i=\frac{\eta_i}{1+\eta_i}$, 则上面这个式子变成了:
|
||||
|
||||
$$
|
||||
\begin{aligned}
|
||||
l &=-y_{i} \log \left(\eta_{i}\right)-\log \left(1-\frac{e^{\log \left(\eta_{i}\right)}}{1+e^{\log \left(\eta_{i}\right)}}\right) \\
|
||||
&=-y_{i} \log \left(\eta_{i}\right)-\log \left(\frac{1}{1+e^{\log \left(\eta_{i}\right)}}\right) \\
|
||||
&=-y_{i} \log \left(\eta_{i}\right)+\log \left(1+e^{\log \left(\eta_{i}\right)}\right)
|
||||
\end{aligned}
|
||||
$$
|
||||
这时候,我们对$log(\eta_i)$求导, 得
|
||||
$$
|
||||
\frac{d l}{d \log (\eta_i)}=-y_{i}+\frac{e^{\log \left(\eta_{i}\right)}}{1+e^{\log \left(\eta_{i}\right)}}=-y_i+p_i
|
||||
$$
|
||||
这样, 我们就得到了某个训练样本在当前模型的梯度值了, 那么残差就是$y_i-p_i$。GBDT二分类的这个思想,其实和逻辑回归的思想一样,**逻辑回归是用一个线性模型去拟合$P(y=1|x)$这个事件的对数几率$log\frac{p}{1-p}=\theta^Tx$**, GBDT二分类也是如此, 用一系列的梯度提升树去拟合这个对数几率, 其分类模型可以表达为:
|
||||
$$
|
||||
P(Y=1 \mid x)=\frac{1}{1+e^{-F_{M}(x)}}
|
||||
$$
|
||||
|
||||
下面我们具体来看GBDT的生成过程, 构建分类GBDT的步骤有两个:
|
||||
1. 初始化GBDT
|
||||
和回归问题一样, 分类 GBDT 的初始状态也只有一个叶子节点,该节点为所有样本的初始预测值,如下:
|
||||
$$
|
||||
F_{0}(x)=\arg \min _{\gamma} \sum_{i=1}^{n} L(y, \gamma)
|
||||
$$
|
||||
上式里面, $F$代表GBDT模型, $F_0$是模型的初识状态, 该式子的意思是找到一个$\gamma$,使所有样本的 Loss 最小,在这里及下文中,$\gamma$都表示节点的输出,即叶子节点, 且它是一个 $log(\eta_i)$ 形式的值(回归值),在初始状态,$\gamma =F_0$。
|
||||
|
||||
下面看例子(该例子来自下面的第二个链接), 假设我们有下面3条样本:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20200910095539432.png#pic_center" alt="在这里插入图片描述" style="zoom:80%;" />
|
||||
</div>
|
||||
|
||||
我们希望构建 GBDT 分类树,它能通过「喜欢爆米花」、「年龄」和「颜色偏好」这 3 个特征来预测某一个样本是否喜欢看电影。我们把数据代入上面的公式中求Loss:
|
||||
$$
|
||||
\operatorname{Loss}=L(1, \gamma)+L(1, \gamma)+L(0, \gamma)
|
||||
$$
|
||||
为了令其最小, 我们求导, 且让导数为0, 则:
|
||||
$$
|
||||
\operatorname{Loss}=p-1 + p-1+p=0
|
||||
$$
|
||||
于是, 就得到了初始值$p=\frac{2}{3}=0.67, \gamma=log(\frac{p}{1-p})=0.69$, 模型的初识状态$F_0(x)=0.69$
|
||||
|
||||
2. 循环生成决策树
|
||||
这里回忆一下回归树的生成步骤, 其实有4小步, 第一就是计算负梯度值得到残差, 第二步是用回归树拟合残差, 第三步是计算叶子节点的输出值, 第四步是更新模型。 下面我们一一来看:
|
||||
|
||||
1. 计算负梯度得到残差
|
||||
$$
|
||||
r_{i m}=-\left[\frac{\partial L\left(y_{i}, F\left(x_{i}\right)\right)}{\partial F\left(x_{i}\right)}\right]_{F(x)=F_{m-1}(x)}
|
||||
$$
|
||||
此处使用$m-1$棵树的模型, 计算每个样本的残差$r_{im}$, 就是上面的$y_i-pi$, 于是例子中, 每个样本的残差:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20200910101154282.png#pic_center" alt="在这里插入图片描述" style="zoom:80%;" />
|
||||
</div>
|
||||
|
||||
2. 使用回归树来拟合$r_{im}$, 这里的$i$表示样本哈,回归树的建立过程可以参考下面的链接文章,简单的说就是遍历每个特征, 每个特征下遍历每个取值, 计算分裂后两组数据的平方损失, 找到最小的那个划分节点。 假如我们产生的第2棵决策树如下:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20200910101558282.png#pic_center" alt="在这里插入图片描述" style="zoom:80%;" />
|
||||
</div>
|
||||
|
||||
3. 对于每个叶子节点$j$, 计算最佳残差拟合值
|
||||
$$
|
||||
\gamma_{j m}=\arg \min _{\gamma} \sum_{x \in R_{i j}} L\left(y_{i}, F_{m-1}\left(x_{i}\right)+\gamma\right)
|
||||
$$
|
||||
意思是, 在刚构建的树$m$中, 找到每个节点$j$的输出$\gamma_{jm}$, 能使得该节点的loss最小。 那么我们看一下这个$\gamma$的求解方式, 这里非常的巧妙。 首先, 我们把损失函数写出来, 对于左边的第一个样本, 有
|
||||
$$
|
||||
L\left(y_{1}, F_{m-1}\left(x_{1}\right)+\gamma\right)=-y_{1}\left(F_{m-1}\left(x_{1}\right)+\gamma\right)+\log \left(1+e^{F_{m-1}\left(x_{1}\right)+\gamma}\right)
|
||||
$$
|
||||
这个式子就是上面推导的$l$, 因为我们要用回归树做分类, 所以这里把分类的预测概率转换成了对数几率回归的形式, 即$log(\eta_i)$, 这个就是模型的回归输出值。而如果求这个损失的最小值, 我们要求导, 解出令损失最小的$\gamma$。 但是上面这个式子求导会很麻烦, 所以这里介绍了一个技巧就是**使用二阶泰勒公式来近似表示该式, 再求导**, 还记得伟大的泰勒吗?
|
||||
$$
|
||||
f(x+\Delta x) \approx f(x)+\Delta x f^{\prime}(x)+\frac{1}{2} \Delta x^{2} f^{\prime \prime}(x)+O(\Delta x)
|
||||
$$
|
||||
这里就相当于把$L(y_1, F_{m-1}(x_1))$当做常量$f(x)$, $\gamma$作为变量$\Delta x$, 将$f(x)$二阶展开:
|
||||
$$
|
||||
L\left(y_{1}, F_{m-1}\left(x_{1}\right)+\gamma\right) \approx L\left(y_{1}, F_{m-1}\left(x_{1}\right)\right)+L^{\prime}\left(y_{1}, F_{m-1}\left(x_{1}\right)\right) \gamma+\frac{1}{2} L^{\prime \prime}\left(y_{1}, F_{m-1}\left(x_{1}\right)\right) \gamma^{2}
|
||||
$$
|
||||
这时候再求导就简单了
|
||||
$$
|
||||
\frac{d L}{d \gamma}=L^{\prime}\left(y_{1}, F_{m-1}\left(x_{1}\right)\right)+L^{\prime \prime}\left(y_{1}, F_{m-1}\left(x_{1}\right)\right) \gamma
|
||||
$$
|
||||
Loss最小的时候, 上面的式子等于0, 就可以得到$\gamma$:
|
||||
$$
|
||||
\gamma_{11}=\frac{-L^{\prime}\left(y_{1}, F_{m-1}\left(x_{1}\right)\right)}{L^{\prime \prime}\left(y_{1}, F_{m-1}\left(x_{1}\right)\right)}
|
||||
$$
|
||||
**因为分子就是残差(上述已经求到了), 分母可以通过对残差求导,得到原损失函数的二阶导:**
|
||||
$$
|
||||
\begin{aligned}
|
||||
L^{\prime \prime}\left(y_{1}, F(x)\right) &=\frac{d L^{\prime}}{d \log (\eta_1)} \\
|
||||
&=\frac{d}{d \log (\eta_1)}\left[-y_{i}+\frac{e^{\log (\eta_1)}}{1+e^{\log (\eta_1)}}\right] \\
|
||||
&=\frac{d}{d \log (\eta_1)}\left[e^{\log (\eta_1)}\left(1+e^{\log (\eta_1)}\right)^{-1}\right] \\
|
||||
&=e^{\log (\eta_1)}\left(1+e^{\log (\eta_1)}\right)^{-1}-e^{2 \log (\eta_1)}\left(1+e^{\log (\eta_1)}\right)^{-2} \\
|
||||
&=\frac{e^{\log (\eta_1)}}{\left(1+e^{\log (\eta_1)}\right)^{2}} \\
|
||||
&=\frac{\eta_1}{(1+\eta_1)}\frac{1}{(1+\eta_1)} \\
|
||||
&=p_1(1-p_1)
|
||||
\end{aligned}
|
||||
$$
|
||||
这时候, 就可以算出该节点的输出:
|
||||
$$
|
||||
\gamma_{11}=\frac{r_{11}}{p_{10}\left(1-p_{10}\right)}=\frac{0.33}{0.67 \times 0.33}=1.49
|
||||
$$
|
||||
这里的下面$\gamma_{jm}$表示第$m$棵树的第$j$个叶子节点。 接下来是右边节点的输出, 包含样本2和样本3, 同样使用二阶泰勒公式展开:
|
||||
$$
|
||||
\begin{array}{l}
|
||||
L\left(y_{2}, F_{m-1}\left(x_{2}\right)+\gamma\right)+L\left(y_{3}, F_{m-1}\left(x_{3}\right)+\gamma\right) \\
|
||||
\approx L\left(y_{2}, F_{m-1}\left(x_{2}\right)\right)+L^{\prime}\left(y_{2}, F_{m-1}\left(x_{2}\right)\right) \gamma+\frac{1}{2} L^{\prime \prime}\left(y_{2}, F_{m-1}\left(x_{2}\right)\right) \gamma^{2} \\
|
||||
+L\left(y_{3}, F_{m-1}\left(x_{3}\right)\right)+L^{\prime}\left(y_{3}, F_{m-1}\left(x_{3}\right)\right) \gamma+\frac{1}{2} L^{\prime \prime}\left(y_{3}, F_{m-1}\left(x_{3}\right)\right) \gamma^{2}
|
||||
\end{array}
|
||||
$$
|
||||
求导, 令其结果为0,就会得到, 第1棵树的第2个叶子节点的输出:
|
||||
$$
|
||||
\begin{aligned}
|
||||
\gamma_{21} &=\frac{-L^{\prime}\left(y_{2}, F_{m-1}\left(x_{2}\right)\right)-L^{\prime}\left(y_{3}, F_{m-1}\left(x_{3}\right)\right)}{L^{\prime \prime}\left(y_{2}, F_{m-1}\left(x_{2}\right)\right)+L^{\prime \prime}\left(y_{3}, F_{m-1}\left(x_{3}\right)\right)} \\
|
||||
&=\frac{r_{21}+r_{31}}{p_{20}\left(1-p_{20}\right)+p_{30}\left(1-p_{30}\right)} \\
|
||||
&=\frac{0.33-0.67}{0.67 \times 0.33+0.67 \times 0.33} \\
|
||||
&=-0.77
|
||||
\end{aligned}
|
||||
$$
|
||||
可以看出, 对于任意叶子节点, 我们可以直接计算其输出值:
|
||||
$$
|
||||
\gamma_{j m}=\frac{\sum_{i=1}^{R_{i j}} r_{i m}}{\sum_{i=1}^{R_{i j}} p_{i, m-1}\left(1-p_{i, m-1}\right)}
|
||||
$$
|
||||
|
||||
4. 更新模型$F_m(x)$
|
||||
$$
|
||||
F_{m}(x)=F_{m-1}(x)+\nu \sum_{j=1}^{J_{m}} \gamma_{m}
|
||||
$$
|
||||
|
||||
这样, 通过多次循环迭代, 就可以得到一个比较强的学习器$F_m(x)$
|
||||
|
||||
<br>
|
||||
|
||||
**下面分析一下GBDT的优缺点:**
|
||||
|
||||
我们可以把树的生成过程理解成**自动进行多维度的特征组合**的过程,从根结点到叶子节点上的整个路径(多个特征值判断),才能最终决定一棵树的预测值, 另外,对于**连续型特征**的处理,GBDT 可以拆分出一个临界阈值,比如大于 0.027 走左子树,小于等于 0.027(或者 default 值)走右子树,这样很好的规避了人工离散化的问题。这样就非常轻松的解决了逻辑回归那里**自动发现特征并进行有效组合**的问题, 这也是GBDT的优势所在。
|
||||
|
||||
但是GBDT也会有一些局限性, 对于**海量的 id 类特征**,GBDT 由于树的深度和棵树限制(防止过拟合),不能有效的存储;另外海量特征在也会存在性能瓶颈,当 GBDT 的 one hot 特征大于 10 万维时,就必须做分布式的训练才能保证不爆内存。所以 GBDT 通常配合少量的反馈 CTR 特征来表达,这样虽然具有一定的范化能力,但是同时会有**信息损失**,对于头部资源不能有效的表达。
|
||||
|
||||
所以, 我们发现其实**GBDT和LR的优缺点可以进行互补**。
|
||||
|
||||
### GBDT+LR模型
|
||||
2014年, Facebook提出了一种利用GBDT自动进行特征筛选和组合, 进而生成新的离散特征向量, 再把该特征向量当做LR模型的输入, 来产生最后的预测结果, 这就是著名的GBDT+LR模型了。GBDT+LR 使用最广泛的场景是CTR点击率预估,即预测当给用户推送的广告会不会被用户点击。
|
||||
|
||||
有了上面的铺垫, 这个模型解释起来就比较容易了, 模型的总体结构长下面这样:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20200910161923481.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" alt="在这里插入图片描述" style="zoom:67%;" />
|
||||
</div>
|
||||
**训练时**,GBDT 建树的过程相当于自动进行的特征组合和离散化,然后从根结点到叶子节点的这条路径就可以看成是不同特征进行的特征组合,用叶子节点可以唯一的表示这条路径,并作为一个离散特征传入 LR 进行**二次训练**。
|
||||
|
||||
比如上图中, 有两棵树,x为一条输入样本,遍历两棵树后,x样本分别落到两颗树的叶子节点上,每个叶子节点对应LR一维特征,那么通过遍历树,就得到了该样本对应的所有LR特征。构造的新特征向量是取值0/1的。 比如左树有三个叶子节点,右树有两个叶子节点,最终的特征即为五维的向量。对于输入x,假设他落在左树第二个节点,编码[0,1,0],落在右树第二个节点则编码[0,1],所以整体的编码为[0,1,0,0,1],这类编码作为特征,输入到线性分类模型(LR or FM)中进行分类。
|
||||
|
||||
**预测时**,会先走 GBDT 的每棵树,得到某个叶子节点对应的一个离散特征(即一组特征组合),然后把该特征以 one-hot 形式传入 LR 进行线性加权预测。
|
||||
|
||||
这个方案应该比较简单了, 下面有几个关键的点我们需要了解:
|
||||
1. **通过GBDT进行特征组合之后得到的离散向量是和训练数据的原特征一块作为逻辑回归的输入, 而不仅仅全是这种离散特征**
|
||||
2. 建树的时候用ensemble建树的原因就是一棵树的表达能力很弱,不足以表达多个有区分性的特征组合,多棵树的表达能力更强一些。GBDT每棵树都在学习前面棵树尚存的不足,迭代多少次就会生成多少棵树。
|
||||
3. RF也是多棵树,但从效果上有实践证明不如GBDT。且GBDT前面的树,特征分裂主要体现对多数样本有区分度的特征;后面的树,主要体现的是经过前N颗树,残差仍然较大的少数样本。优先选用在整体上有区分度的特征,再选用针对少数样本有区分度的特征,思路更加合理,这应该也是用GBDT的原因。
|
||||
4. 在CRT预估中, GBDT一般会建立两类树(非ID特征建一类, ID类特征建一类), AD,ID类特征在CTR预估中是非常重要的特征,直接将AD,ID作为feature进行建树不可行,故考虑为每个AD,ID建GBDT树。
|
||||
1. 非ID类树:不以细粒度的ID建树,此类树作为base,即便曝光少的广告、广告主,仍可以通过此类树得到有区分性的特征、特征组合
|
||||
2. ID类树:以细粒度 的ID建一类树,用于发现曝光充分的ID对应有区分性的特征、特征组合
|
||||
|
||||
### 编程实践
|
||||
|
||||
下面我们通过kaggle上的一个ctr预测的比赛来看一下GBDT+LR模型部分的编程实践, [数据来源](https://github.com/zhongqiangwu960812/AI-RecommenderSystem/tree/master/Rank/GBDT%2BLR/data)
|
||||
|
||||
我们回顾一下上面的模型架构, 首先是要训练GBDT模型, GBDT的实现一般可以使用xgboost, 或者lightgbm。训练完了GBDT模型之后, 我们需要预测出每个样本落在了哪棵树上的哪个节点上, 然后通过one-hot就会得到一些新的离散特征, 这和原来的特征进行合并组成新的数据集, 然后作为逻辑回归的输入,最后通过逻辑回归模型得到结果。
|
||||
|
||||
根据上面的步骤, 我们看看代码如何实现:
|
||||
|
||||
假设我们已经有了处理好的数据x_train, y_train。
|
||||
|
||||
1. **训练GBDT模型**
|
||||
|
||||
GBDT模型的搭建我们可以通过XGBOOST, lightgbm等进行构建。比如:
|
||||
|
||||
```python
|
||||
gbm = lgb.LGBMRegressor(objective='binary',
|
||||
subsample= 0.8,
|
||||
min_child_weight= 0.5,
|
||||
colsample_bytree= 0.7,
|
||||
num_leaves=100,
|
||||
max_depth = 12,
|
||||
learning_rate=0.05,
|
||||
n_estimators=10,
|
||||
)
|
||||
|
||||
gbm.fit(x_train, y_train,
|
||||
eval_set = [(x_train, y_train), (x_val, y_val)],
|
||||
eval_names = ['train', 'val'],
|
||||
eval_metric = 'binary_logloss',
|
||||
# early_stopping_rounds = 100,
|
||||
)
|
||||
```
|
||||
|
||||
2. **特征转换并构建新的数据集**
|
||||
|
||||
通过上面我们建立好了一个gbdt模型, 我们接下来要用它来预测出样本会落在每棵树的哪个叶子节点上, 为后面的离散特征构建做准备, 由于不是用gbdt预测结果而是预测训练数据在每棵树上的具体位置, 就需要用到下面的语句:
|
||||
|
||||
```python
|
||||
model = gbm.booster_ # 获取到建立的树
|
||||
|
||||
# 每个样本落在每个树的位置 , 下面两个是矩阵 (样本个数, 树的棵树) , 每一个数字代表某个样本落在了某个数的哪个叶子节点
|
||||
gbdt_feats_train = model.predict(train, pred_leaf = True)
|
||||
gbdt_feats_test = model.predict(test, pred_leaf = True)
|
||||
|
||||
# 把上面的矩阵转成新的样本-特征的形式, 与原有的数据集合并
|
||||
gbdt_feats_name = ['gbdt_leaf_' + str(i) for i in range(gbdt_feats_train.shape[1])]
|
||||
df_train_gbdt_feats = pd.DataFrame(gbdt_feats_train, columns = gbdt_feats_name)
|
||||
df_test_gbdt_feats = pd.DataFrame(gbdt_feats_test, columns = gbdt_feats_name)
|
||||
|
||||
# 构造新数据集
|
||||
train = pd.concat([train, df_train_gbdt_feats], axis = 1)
|
||||
test = pd.concat([test, df_test_gbdt_feats], axis = 1)
|
||||
train_len = train.shape[0]
|
||||
data = pd.concat([train, test])
|
||||
```
|
||||
|
||||
3. **离散特征的独热编码,并划分数据集**
|
||||
|
||||
```python
|
||||
# 新数据的新特征进行读入编码
|
||||
for col in gbdt_feats_name:
|
||||
onehot_feats = pd.get_dummies(data[col], prefix = col)
|
||||
data.drop([col], axis = 1, inplace = True)
|
||||
data = pd.concat([data, onehot_feats], axis = 1)
|
||||
|
||||
# 划分数据集
|
||||
train = data[: train_len]
|
||||
test = data[train_len:]
|
||||
|
||||
x_train, x_val, y_train, y_val = train_test_split(train, target, test_size = 0.3, random_state = 2018)
|
||||
```
|
||||
|
||||
4. **训练逻辑回归模型作最后的预测**
|
||||
|
||||
```python
|
||||
# 训练逻辑回归模型
|
||||
lr = LogisticRegression()
|
||||
lr.fit(x_train, y_train)
|
||||
tr_logloss = log_loss(y_train, lr.predict_proba(x_train)[:, 1])
|
||||
print('tr-logloss: ', tr_logloss)
|
||||
val_logloss = log_loss(y_val, lr.predict_proba(x_val)[:, 1])
|
||||
print('val-logloss: ', val_logloss)
|
||||
|
||||
# 预测
|
||||
y_pred = lr.predict_proba(test)[:, 1]
|
||||
```
|
||||
|
||||
上面我们就完成了GBDT+LR模型的基本训练步骤, 具体详细的代码可以参考链接。
|
||||
|
||||
### 思考
|
||||
1. **为什么使用集成的决策树? 为什么使用GBDT构建决策树而不是随机森林?**
|
||||
2. **面对高维稀疏类特征的时候(比如ID类特征), 逻辑回归一般要比GBDT这种非线性模型好, 为什么?**
|
||||
|
||||
|
||||
**参考资料**
|
||||
|
||||
* 王喆 - 《深度学习推荐系统》
|
||||
* [决策树之 GBDT 算法 - 分类部分](https://www.jianshu.com/p/f5e5db6b29f2)
|
||||
* [深入理解GBDT二分类算法](https://zhuanlan.zhihu.com/p/89549390?utm_source=zhihu)
|
||||
* [逻辑回归、优化算法和正则化的幕后细节补充](https://blog.csdn.net/wuzhongqiang/article/details/108456051)
|
||||
* [梯度提升树GBDT的理论学习与细节补充](https://blog.csdn.net/wuzhongqiang/article/details/108471107)
|
||||
* [推荐系统遇上深度学习(十)--GBDT+LR融合方案实战](https://zhuanlan.zhihu.com/p/37522339)
|
||||
* [CTR预估中GBDT与LR融合方案](https://blog.csdn.net/lilyth_lilyth/article/details/48032119)
|
||||
* [GBDT+LR算法解析及Python实现](https://www.cnblogs.com/wkang/p/9657032.html)
|
||||
* [常见计算广告点击率预估算法总结](https://zhuanlan.zhihu.com/p/29053940)
|
||||
* [GBDT--分类篇](https://blog.csdn.net/On_theway10/article/details/83576715?utm_medium=distribute.pc_relevant.none-task-blog-BlogCommendFromMachineLearnPai2-5.channel_param&depth_1-utm_source=distribute.pc_relevant.none-task-blog-BlogCommendFromMachineLearnPai2-5.channel_param)
|
||||
|
||||
**论文**
|
||||
|
||||
* [http://quinonero.net/Publications/predicting-clicks-facebook.pdf 原论文](http://quinonero.net/Publications/predicting-clicks-facebook.pdf)
|
||||
* [Predicting Clicks: Estimating the Click-Through Rate for New Ads](https://www.microsoft.com/en-us/research/publication/predicting-clicks-estimating-the-click-through-rate-for-new-ads/)\
|
||||
* [Greedy Fun tion Approximation : A Gradient Boosting](https://www.semanticscholar.org/paper/Greedy-Fun-tion-Approximation-%3A-A-Gradient-Boosting-Friedman/0d97ee4888506beb30a3f3b6552d88a9b0ca11f0?p2df)
|
||||
|
||||
277
2023旧版内容/4.人工智能/ch02/ch2.2/ch2.2.2/AutoInt.md
Normal file
277
2023旧版内容/4.人工智能/ch02/ch2.2/ch2.2.2/AutoInt.md
Normal file
@@ -0,0 +1,277 @@
|
||||
## 写在前面
|
||||
AutoInt(Automatic Feature Interaction),这是2019年发表在CIKM上的文章,这里面提出的模型,重点也是在特征交互上,而所用到的结构,就是大名鼎鼎的transformer结构了,也就是通过多头的自注意力机制来显示的构造高阶特征,有效的提升了模型的效果。所以这个模型的提出动机比较简单,和xdeepFM这种其实是一样的,就是针对目前很多浅层模型无法学习高阶的交互, 而DNN模型能学习高阶交互,但确是隐性学习,缺乏可解释性,并不知道好不好使。而transformer的话,我们知道, 有着天然的全局意识,在NLP里面的话,各个词通过多头的自注意力机制,就能够使得各个词从不同的子空间中学习到与其它各个词的相关性,汇聚其它各个词的信息。 而放到推荐系统领域,同样也是这个道理,无非是把词换成了这里的离散特征而已, 而如果通过多个这样的交叉块堆积,就能学习到任意高阶的交互啦。这其实就是本篇文章的思想核心。
|
||||
|
||||
## AutoInt模型的理论及论文细节
|
||||
### 动机和原理
|
||||
这篇文章的前言部分依然是说目前模型的不足,以引出模型的动机所在, 简单的来讲,就是两句话:
|
||||
1. 浅层的模型会受到交叉阶数的限制,没法完成高阶交叉
|
||||
2. 深层模型的DNN在学习高阶隐性交叉的效果并不是很好, 且不具有可解释性
|
||||
|
||||
于是乎:
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/60f5f213f34d4e2b9bdb800e6f029b34.png#pic_center" alt="image-20210308142624189" style="zoom: 80%;" />
|
||||
</div>
|
||||
|
||||
那么是如何做到的呢? 引入了transformer, 做成了一个特征交互层, 原理如下:
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/d05a80906b484ab7a026e52ed2d8f9d4.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBATWlyYWNsZTgwNzA=,size_1,color_FFFFFF,t_70,g_se,x_16#pic_center" alt="image-20210308142624189" style="zoom: 80%;" />
|
||||
</div>
|
||||
|
||||
### AutoInt模型的前向过程梳理
|
||||
下面看下AutoInt模型的结构了,并不是很复杂
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/1aeabdd3cee74cbf814d7eed3147be4e.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBATWlyYWNsZTgwNzA=,size_1,color_FFFFFF,t_70,g_se,x_1#pic_center" alt="image-20210308142624189" style="zoom: 85%;" />
|
||||
</div>
|
||||
|
||||
#### Input Layer
|
||||
输入层这里, 用到的特征主要是离散型特征和连续性特征, 这里不管是哪一类特征,都会过embedding层转成低维稠密的向量,是的, **连续性特征,这里并没有经过分桶离散化,而是直接走embedding**。这个是怎么做到的呢?就是就是类似于预训练时候的思路,先通过item_id把连续型特征与类别特征关联起来,最简单的,就是把item_id拿过来,过完embedding层取出对应的embedding之后,再乘上连续值即可, 所以这个连续值事先一定要是归一化的。 当然,这个玩法,我也是第一次见。 学习到了, 所以模型整体的输入如下:
|
||||
|
||||
$$
|
||||
\mathbf{x}=\left[\mathbf{x}_{1} ; \mathbf{x}_{2} ; \ldots ; \mathbf{x}_{\mathbf{M}}\right]
|
||||
$$
|
||||
这里的$M$表示特征的个数, $X_1, X_2$这是离散型特征, one-hot的形式, 而$X_M$在这里是连续性特征。过embedding层的细节应该是我上面说的那样。
|
||||
#### Embedding Layer
|
||||
embedding层的作用是把高维稀疏的特征转成低维稠密, 离散型的特征一般是取出对应的embedding向量即可, 具体计算是这样:
|
||||
$$
|
||||
\mathbf{e}_{\mathbf{i}}=\mathbf{V}_{\mathbf{i}} \mathbf{x}_{\mathbf{i}}
|
||||
$$
|
||||
对于第$i$个离散特征,直接第$i$个嵌入矩阵$V_i$乘one-hot向量就取出了对应位置的embedding。 当然,如果输入的时候不是个one-hot, 而是个multi-hot的形式,那么对应的embedding输出是各个embedding求平均得到的。
|
||||
|
||||
$$
|
||||
\mathbf{e}_{\mathbf{i}}=\frac{1}{q} \mathbf{V}_{\mathbf{i}} \mathbf{x}_{\mathbf{i}}
|
||||
$$
|
||||
比如, 推荐里面用户的历史行为item。过去点击了多个item,最终的输出就是这多个item的embedding求平均。
|
||||
而对于连续特征, 我上面说的那样, 也是过一个embedding矩阵取相应的embedding, 不过,最后要乘一个连续值
|
||||
$$
|
||||
\mathbf{e}_{\mathbf{m}}=\mathbf{v}_{\mathbf{m}} x_{m}
|
||||
$$
|
||||
这样,不管是连续特征,离散特征还是变长的离散特征,经过embedding之后,都能得到等长的embedding向量。 我们把这个向量拼接到一块,就得到了交互层的输入。
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/089b846a7f5c4125bc99a5a60e03d1ff.png#pic_center" alt="image-20210308142624189" style="zoom: 60%;" />
|
||||
</div>
|
||||
|
||||
#### Interacting Layer
|
||||
这个是本篇论文的核心了,其实这里说的就是transformer块的前向传播过程,所以这里我就直接用比较白话的语言简述过程了,不按照论文中的顺序展开了。
|
||||
|
||||
通过embedding层, 我们会得到M个向量$e_1, ...e_M$,假设向量的维度是$d$维, 那么这个就是一个$d\times M$的矩阵, 我们定一个符号$X$。 接下来我们基于这个矩阵$X$,做三次变换,也就是分别乘以三个矩阵$W_k^{(h)}, W_q^{(h)},W_v^{(h)}$, 这三个矩阵的维度是$d'\times d$的话, 那么我们就会得到三个结果:
|
||||
$$Q^{(h)}=W_q^{(h)}\times X \\ K^{(h)} = W_k^{(h)} \times X \\ V^{(h)} = W_v^{(h)} \times X$$
|
||||
这三个矩阵都是$d'\times M$的。这其实就完成了一个Head的操作。所谓的自注意力, 就是$X$通过三次变换得到的结果之间,通过交互得到相关性,并通过相关性进行加权汇总,全是$X$自发的。 那么是怎么做到的呢?首先, 先进行这样的操作:
|
||||
$$Score(Q^h,K^h)=Q^h \times {K^h}^T$$
|
||||
这个结果得到的是一个$d'\times d'$的矩阵, 那么这个操作到底是做了一个什么事情呢?
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20200220195022623.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" alt="image-20210308142624189" style="zoom: 90%;" />
|
||||
</div>
|
||||
|
||||
假设这里的$c_1..c_6$是我们的6个特征, 而每一行代表每个特征的embedding向量,这样两个矩阵相乘,相当于得到了当前特征与其它特征两两之间的內积值, 而內积可以表示两个向量之间的相似程度。所以得到的结果每一行,就代表当前这个特征与其它特征的相似性程度。
|
||||
|
||||
接下来,我们对$Score(Q^h,K^h)$, 在最后一个维度上进行softmax,就根据相似性得到了权重信息,这其实就是把相似性分数归一化到了0-1之间
|
||||
|
||||
$$Attention(Q^h,K^h)=Softmax(Score(Q^h,K^h))$$
|
||||
接下来, 我们再进行这样的一步操作
|
||||
$$E^{(h)}=Attention(Q^h,K^h) \times V$$
|
||||
这样就得到了$d'\times M$的矩阵$E$, 这步操作,其实就是一个加权汇总的过程, 对于每个特征, 先求与其它特征的相似度,然后得到一个权重,再回乘到各自的特征向量再求和。 只不过这里的特征是经过了一次线性变化的过程,降维到了$d'$。
|
||||
|
||||
上面是我从矩阵的角度又过了一遍, 这个是直接针对所有的特征向量一部到位。 论文里面的从单个特征的角度去描述的,只说了一个矩阵向量过多头注意力的操作。
|
||||
$$
|
||||
\begin{array}{c}
|
||||
\alpha_{\mathbf{m}, \mathbf{k}}^{(\mathbf{h})}=\frac{\exp \left(\psi^{(h)}\left(\mathbf{e}_{\mathbf{m}}, \mathbf{e}_{\mathbf{k}}\right)\right)}{\sum_{l=1}^{M} \exp \left(\psi^{(h)}\left(\mathbf{e}_{\mathbf{m}}, \mathbf{e}_{1}\right)\right)} \\
|
||||
\psi^{(h)}\left(\mathbf{e}_{\mathbf{m}}, \mathbf{e}_{\mathbf{k}}\right)=\left\langle\mathbf{W}_{\text {Query }}^{(\mathbf{h})} \mathbf{e}_{\mathbf{m}}, \mathbf{W}_{\text {Key }}^{(\mathbf{h})} \mathbf{e}_{\mathbf{k}}\right\rangle
|
||||
\end{array} \\
|
||||
\widetilde{\mathbf{e}}_{\mathrm{m}}^{(\mathbf{h})}=\sum_{k=1}^{M} \alpha_{\mathbf{m}, \mathbf{k}}^{(\mathbf{h})}\left(\mathbf{W}_{\text {Value }}^{(\mathbf{h})} \mathbf{e}_{\mathbf{k}}\right)
|
||||
$$
|
||||
|
||||
这里会更好懂一些, 就是相当于上面矩阵的每一行操作拆开了, 首先,整个拼接起来的embedding矩阵还是过三个参数矩阵得到$Q,K,V$, 然后是每一行单独操作的方式,对于某个特征向量$e_k$,与其它的特征两两內积得到权重,然后在softmax,回乘到对应向量,然后进行求和就得到了融合其它特征信息的新向量。 具体过程如图:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/700bf353ce2f4c229839761e7815515d.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBATWlyYWNsZTgwNzA=,size_1,color_FFFFFF,t_70,g_se,x_16#pic_center" alt="image-20210308142624189" style="zoom: 80%;" />
|
||||
</div>
|
||||
|
||||
上面的过程是用了一个头,理解的话就类似于从一个角度去看特征之间的相关关系,用论文里面的话讲,这是从一个子空间去看, 如果是想从多个角度看,这里可以用多个头,即换不同的矩阵$W_q,W_k,W_v$得到不同的$Q,K,V$然后得到不同的$e_m$, 每个$e_m$是$d'\times 1$的。
|
||||
|
||||
然后,多个头的结果concat起来
|
||||
$$
|
||||
\widetilde{\mathbf{e}}_{\mathrm{m}}=\widetilde{\mathbf{e}}_{\mathrm{m}}^{(1)} \oplus \widetilde{\mathbf{e}}_{\mathrm{m}}^{(2)} \oplus \cdots \oplus \widetilde{\mathbf{e}}_{\mathbf{m}}^{(\mathbf{H})}
|
||||
$$
|
||||
这是一个$d'\times H$的向量, 假设有$H$个头。
|
||||
|
||||
接下来, 过一个残差网络层,这是为了保留原始的特征信息
|
||||
$$
|
||||
\mathbf{e}_{\mathbf{m}}^{\mathrm{Res}}=\operatorname{ReL} U\left(\widetilde{\mathbf{e}}_{\mathbf{m}}+\mathbf{W}_{\text {Res }} \mathbf{e}_{\mathbf{m}}\right)
|
||||
$$
|
||||
这里的$e_m$是$d\times 1$的向量, $W_{Res}$是$d'H\times d$的矩阵, 最后得到的$e_m^{Res}$是$d'H\times 1$的向量, 这是其中的一个特征,如果是$M$个特征堆叠的话,最终就是$d'HM\times 1$的矩阵, 这个就是Interacting Layer的结果输出。
|
||||
#### Output Layer
|
||||
输出层就非常简单了,加一层全连接映射出输出值即可:
|
||||
$$
|
||||
\hat{y}=\sigma\left(\mathbf{w}^{\mathrm{T}}\left(\mathbf{e}_{1}^{\mathbf{R e s}} \oplus \mathbf{e}_{2}^{\mathbf{R e s}} \oplus \cdots \oplus \mathbf{e}_{\mathbf{M}}^{\text {Res }}\right)+b\right)
|
||||
$$
|
||||
这里的$W$是$d'HM\times 1$的, 这样最终得到的是一个概率值了, 接下来交叉熵损失更新模型参数即可。
|
||||
|
||||
AutoInt的前向传播过程梳理完毕。
|
||||
|
||||
### AutoInt的分析
|
||||
这里论文里面分析了为啥AutoInt能建模任意的高阶交互以及时间复杂度和空间复杂度的分析。我们一一来看。
|
||||
|
||||
关于建模任意的高阶交互, 我们这里拿一个transformer块看下, 对于一个transformer块, 我们发现特征之间完成了一个2阶的交互过程,得到的输出里面我们还保留着1阶的原始特征。
|
||||
|
||||
那么再经过一个transformer块呢? 这里面就会有2阶和1阶的交互了, 也就是会得到3阶的交互信息。而此时的输出,会保留着第一个transformer的输出信息特征。再过一个transformer块的话,就会用4阶的信息交互信息, 其实就相当于, 第$n$个transformer里面会建模出$n+1$阶交互来, 这个与CrossNet其实有异曲同工之妙的,无法是中间交互时的方式不一样。 前者是bit-wise级别的交互,而后者是vector-wise的交互。
|
||||
|
||||
所以, AutoInt是可以建模任意高阶特征的交互的,并且这种交互还是显性。
|
||||
|
||||
关于时间复杂度和空间复杂度,空间复杂度是$O(Ldd'H)$级别的, 这个也很好理解,看参数量即可, 3个W矩阵, H个head,再假设L个transformer块的话,参数量就达到这了。 时间复杂度的话是$O(MHd'(M+d))$的,论文说如果d和d'很小的话,其实这个模型不算复杂。
|
||||
### 3.4 更多细节
|
||||
这里整理下实验部分的细节,主要是对于一些超参的实验设置,在实验里面,作者首先指出了logloss下降多少算是有效呢?
|
||||
>It is noticeable that a slightly higher AUC or lower Logloss at 0.001-level is regarded significant for CTR prediction task, which has also been pointed out in existing works
|
||||
|
||||
这个和在fibinet中auc说的意思差不多。
|
||||
|
||||
在这一块,作者还写到了几个观点:
|
||||
1. NFM use the deep neural network as a core component to learning high-order feature interactions, they do not guarantee improvement over FM and AFM.
|
||||
2. AFM准确的说是二阶显性交互基础上加了交互重要性选择的操作, 这里应该是没有在上面加全连接
|
||||
3. xdeepFM这种CIN网络,在实际场景中非常难部署,不实用
|
||||
4. AutoInt的交互层2-3层差不多, embedding维度16-24
|
||||
5. 在AutoInt上面加2-3层的全连接会有点提升,但是提升效果并不是很大
|
||||
|
||||
所以感觉AutoInt这篇paper更大的价值,在于给了我们一种特征高阶显性交叉与特征选择性的思路,就是transformer在这里起的功效。所以后面用的时候, 更多的应该考虑如何用这种思路或者这个交互模块,而不是直接搬模型。
|
||||
|
||||
## AutoInt模型的简单复现及结构解释
|
||||
经过上面的分析, AutoInt模型的核心其实还是Transformer,所以代码部分呢? 主要还是Transformer的实现过程, 这个之前在整理DSIN的时候也整理过,由于Transformer特别重要,所以这里再重新复习一遍, 依然是基于Deepctr,写成一个简版的形式。
|
||||
|
||||
```python
|
||||
def AutoInt(linear_feature_columns, dnn_feature_columns, att_layer_num=3, att_embedding_size=8, att_head_num=2, att_res=True):
|
||||
"""
|
||||
:param att_layer_num: transformer块的数量,一个transformer块里面是自注意力计算 + 残差计算
|
||||
:param att_embedding_size: 文章里面的d', 自注意力时候的att的维度
|
||||
:param att_head_num: 头的数量或者自注意力子空间的数量
|
||||
:param att_res: 是否使用残差网络
|
||||
"""
|
||||
# 构建输入层,即所有特征对应的Input()层,这里使用字典的形式返回,方便后续构建模型
|
||||
dense_input_dict, sparse_input_dict = build_input_layers(linear_feature_columns+dnn_feature_columns)
|
||||
|
||||
# 构建模型的输入层,模型的输入层不能是字典的形式,应该将字典的形式转换成列表的形式
|
||||
# 注意:这里实际的输入预Input层对应,是通过模型输入时候的字典数据的key与对应name的Input层
|
||||
input_layers = list(dense_input_dict.values()) + list(sparse_input_dict.values())
|
||||
|
||||
# 线性部分的计算逻辑 -- linear
|
||||
linear_logits = get_linear_logits(dense_input_dict, sparse_input_dict, linear_feature_columns)
|
||||
|
||||
# 构建维度为k的embedding层,这里使用字典的形式返回,方便后面搭建模型
|
||||
# 线性层和dnn层统一的embedding层
|
||||
embedding_layer_dict = build_embedding_layers(linear_feature_columns+dnn_feature_columns, sparse_input_dict, is_linear=False)
|
||||
|
||||
# 构造self-att的输入
|
||||
att_sparse_kd_embed = concat_embedding_list(dnn_feature_columns, sparse_input_dict, embedding_layer_dict, flatten=False)
|
||||
att_input = Concatenate(axis=1)(att_sparse_kd_embed) # (None, field_num, embed_num)
|
||||
|
||||
# 下面的循环,就是transformer的前向传播,多个transformer块的计算逻辑
|
||||
for _ in range(att_layer_num):
|
||||
att_input = InteractingLayer(att_embedding_size, att_head_num, att_res)(att_input)
|
||||
att_output = Flatten()(att_input)
|
||||
att_logits = Dense(1)(att_output)
|
||||
|
||||
# DNN侧的计算逻辑 -- Deep
|
||||
# 将dnn_feature_columns里面的连续特征筛选出来,并把相应的Input层拼接到一块
|
||||
dnn_dense_feature_columns = list(filter(lambda x: isinstance(x, DenseFeat), dnn_feature_columns)) if dnn_feature_columns else []
|
||||
dnn_dense_feature_columns = [fc.name for fc in dnn_dense_feature_columns]
|
||||
dnn_concat_dense_inputs = Concatenate(axis=1)([dense_input_dict[col] for col in dnn_dense_feature_columns])
|
||||
|
||||
# 将dnn_feature_columns里面的离散特征筛选出来,相应的embedding层拼接到一块
|
||||
dnn_sparse_kd_embed = concat_embedding_list(dnn_feature_columns, sparse_input_dict, embedding_layer_dict, flatten=True)
|
||||
dnn_concat_sparse_kd_embed = Concatenate(axis=1)(dnn_sparse_kd_embed)
|
||||
|
||||
# DNN层的输入和输出
|
||||
dnn_input = Concatenate(axis=1)([dnn_concat_dense_inputs, dnn_concat_sparse_kd_embed, att_output])
|
||||
dnn_out = get_dnn_output(dnn_input)
|
||||
dnn_logits = Dense(1)(dnn_out)
|
||||
|
||||
# 三边的结果stack
|
||||
stack_output = Add()([linear_logits, dnn_logits])
|
||||
|
||||
# 输出层
|
||||
output_layer = Dense(1, activation='sigmoid')(stack_output)
|
||||
|
||||
model = Model(input_layers, output_layer)
|
||||
|
||||
return model
|
||||
```
|
||||
这里由于大部分都是之前见过的模块,唯一改变的地方,就是加了一个`InteractingLayer`, 这个是一个transformer块,在这里面实现特征交互。而这个的结果输出,最终和DNN的输出结合到一起了。 而这个层,主要就是一个transformer块的前向传播过程。这应该算是最简单的一个版本了:
|
||||
|
||||
```python
|
||||
class InteractingLayer(Layer):
|
||||
"""A layer user in AutoInt that model the correction between different feature fields by multi-head self-att mechanism
|
||||
input: 3维张量, (none, field_num, embedding_size)
|
||||
output: 3维张量, (none, field_num, att_embedding_size * head_num)
|
||||
"""
|
||||
def __init__(self, att_embedding_size=8, head_num=2, use_res=True, seed=2021):
|
||||
super(InteractingLayer, self).__init__()
|
||||
self.att_embedding_size = att_embedding_size
|
||||
self.head_num = head_num
|
||||
self.use_res = use_res
|
||||
self.seed = seed
|
||||
|
||||
|
||||
def build(self, input_shape):
|
||||
embedding_size = int(input_shape[-1])
|
||||
|
||||
# 定义三个矩阵Wq, Wk, Wv
|
||||
self.W_query = self.add_weight(name="query", shape=[embedding_size, self.att_embedding_size * self.head_num],
|
||||
dtype=tf.float32, initializer=tf.keras.initializers.TruncatedNormal(seed=self.seed))
|
||||
self.W_key = self.add_weight(name="key", shape=[embedding_size, self.att_embedding_size * self.head_num],
|
||||
dtype=tf.float32, initializer=tf.keras.initializers.TruncatedNormal(seed=self.seed+1))
|
||||
self.W_value = self.add_weight(name="value", shape=[embedding_size, self.att_embedding_size * self.head_num],
|
||||
dtype=tf.float32, initializer=tf.keras.initializers.TruncatedNormal(seed=self.seed+2))
|
||||
|
||||
if self.use_res:
|
||||
self.W_res = self.add_weight(name="res", shape=[embedding_size, self.att_embedding_size * self.head_num],
|
||||
dtype=tf.float32, initializer=tf.keras.initializers.TruncatedNormal(seed=self.seed+3))
|
||||
|
||||
super(InteractingLayer, self).build(input_shape)
|
||||
|
||||
def call(self, inputs):
|
||||
# inputs (none, field_nums, embed_num)
|
||||
|
||||
querys = tf.tensordot(inputs, self.W_query, axes=(-1, 0)) # (None, field_nums, att_emb_size*head_num)
|
||||
keys = tf.tensordot(inputs, self.W_key, axes=(-1, 0))
|
||||
values = tf.tensordot(inputs, self.W_value, axes=(-1, 0))
|
||||
|
||||
# 多头注意力计算 按照头分开 (head_num, None, field_nums, att_embed_size)
|
||||
querys = tf.stack(tf.split(querys, self.head_num, axis=2))
|
||||
keys = tf.stack(tf.split(keys, self.head_num, axis=2))
|
||||
values = tf.stack(tf.split(values, self.head_num, axis=2))
|
||||
|
||||
# Q * K, key的后两维转置,然后再矩阵乘法
|
||||
inner_product = tf.matmul(querys, keys, transpose_b=True) # (head_num, None, field_nums, field_nums)
|
||||
normal_att_scores = tf.nn.softmax(inner_product, axis=-1)
|
||||
|
||||
result = tf.matmul(normal_att_scores, values) # (head_num, None, field_nums, att_embed_size)
|
||||
result = tf.concat(tf.split(result, self.head_num, ), axis=-1) # (1, None, field_nums, att_emb_size*head_num)
|
||||
result = tf.squeeze(result, axis=0) # (None, field_num, att_emb_size*head_num)
|
||||
|
||||
if self.use_res:
|
||||
result += tf.tensordot(inputs, self.W_res, axes=(-1, 0))
|
||||
|
||||
result = tf.nn.relu(result)
|
||||
|
||||
return result
|
||||
```
|
||||
这就是一个Transformer块做的事情,这里只说两个小细节:
|
||||
* 第一个是参数初始化那个地方, 后面的seed一定要指明出参数来,我第一次写的时候, 没有用seed=,结果导致训练有问题。
|
||||
* 第二个就是这里自注意力机制计算的时候,这里的多头计算处理方式, **把多个头分开,采用堆叠的方式进行计算(堆叠到第一个维度上去了)**。只有这样才能使得每个头与每个头之间的自注意力运算是独立不影响的。如果不这么做的话,最后得到的结果会含有当前单词在这个头和另一个单词在另一个头上的关联,这是不合理的。
|
||||
|
||||
OK, 这就是AutoInt比较核心的部分了,当然,上面自注意部分的输出结果与DNN或者Wide部分结合也不一定非得这么一种形式,也可以灵活多变,具体得结合着场景来。详细代码依然是看后面的GitHub啦。
|
||||
|
||||
## 总结
|
||||
这篇文章整理了AutoInt模型,这个模型的重点是引入了transformer来实现特征之间的高阶显性交互, 而transformer的魅力就是多头的注意力机制,相当于在多个子空间中, 根据不同的相关性策略去让特征交互然后融合,在这个交互过程中,特征之间计算相关性得到权重,并加权汇总,使得最终每个特征上都有了其它特征的信息,且其它特征的信息重要性还有了权重标识。 这个过程的自注意力计算以及汇总是一个自动的过程,这是很powerful的。
|
||||
|
||||
所以这篇文章的重要意义是又给我们传授了一个特征交互时候的新思路,就是transformer的多头注意力机制。
|
||||
|
||||
在整理transformer交互层的时候, 这里忽然想起了和一个同学的讨论, 顺便记在这里吧,就是:
|
||||
> 自注意力里面的Q,K能用一个吗? 也就是类似于只用Q, 算注意力的时候,直接$QQ^T$, 得到的矩阵维度和原来的是一样的,并且在参数量上,由于去掉了$w_k$矩阵, 也会有所减少。
|
||||
|
||||
关于这个问题, 我目前没有尝试用同一个的效果,但总感觉是违背了当时设计自注意力的初衷,最直接的一个结论,就是这里如果直接$QQ^T$,那么得到的注意力矩阵是一个对称的矩阵, 这在汇总信息的时候可能会出现问题。 因为这基于了一个假设就是A特征对于B特征的重要性,和B特征对于A的重要性是一致的, 这个显然是不太符合常规的。 比如"学历"这个特征和"职业"这个特征, 对于计算机行业,高中生和研究生或许都可以做, 但是对于金融类的行业, 对学历就有着很高的要求。 这就说明对于职业这个特征, 学历特征对其影响很大。 而如果是看学历的话,研究生学历或许可以入计算机,也可以入金融, 可能职业特征对学历的影响就不是那么明显。 也就是学历对于职业的重要性可能会比职业对于学历的重要性要大。 所以我感觉直接用同一个矩阵,在表达能力上会受到限制。当然,是自己的看法哈, 这个问题也欢迎一块讨论呀!
|
||||
|
||||
|
||||
**参考资料**:
|
||||
* [AutoInt: Automatic Feature Interaction Learning via Self-Attentive Neural Networks](https://arxiv.org/abs/1810.11921)
|
||||
* [AutoInt:基于Multi-Head Self-Attention构造高阶特征](https://zhuanlan.zhihu.com/p/60185134)
|
||||
155
2023旧版内容/4.人工智能/ch02/ch2.2/ch2.2.2/DCN.md
Normal file
155
2023旧版内容/4.人工智能/ch02/ch2.2/ch2.2.2/DCN.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# DCN
|
||||
## 动机
|
||||
Wide&Deep模型的提出不仅综合了“记忆能力”和“泛化能力”, 而且开启了不同网络结构融合的新思路。 所以后面就有各式各样的模型改进Wide部分或者Deep部分, 而Deep&Cross模型(DCN)就是其中比较典型的一个,这是2017年斯坦福大学和谷歌的研究人员在ADKDD会议上提出的, 该模型针对W&D的wide部分进行了改进, 因为Wide部分有一个不足就是需要人工进行特征的组合筛选, 过程繁琐且需要经验, 而2阶的FM模型在线性的时间复杂度中自动进行特征交互,但是这些特征交互的表现能力并不够,并且随着阶数的上升,模型复杂度会大幅度提高。于是乎,作者用一个Cross Network替换掉了Wide部分,来自动进行特征之间的交叉,并且网络的时间和空间复杂度都是线性的。 通过与Deep部分相结合,构成了深度交叉网络(Deep & Cross Network),简称DCN。
|
||||
|
||||
## 模型结构及原理
|
||||
|
||||
这个模型的结构是这个样子的:
|
||||
<div align=center>
|
||||
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片dcn.png" style="zoom:67%;" />
|
||||
</div>
|
||||
|
||||
这个模型的结构也是比较简洁的, 从下到上依次为:Embedding和Stacking层, Cross网络层与Deep网络层并列, 以及最后的输出层。下面也是一一为大家剖析。
|
||||
|
||||
### Embedding和Stacking 层
|
||||
|
||||
Embedding层我们已经非常的熟悉了吧, 这里的作用依然是把稀疏离散的类别型特征变成低维密集型。
|
||||
$$
|
||||
\mathbf{x}_{\text {embed, } i}=W_{\text {embed, } i} \mathbf{x}_{i}
|
||||
$$
|
||||
其中对于某一类稀疏分类特征(如id),$X_{embed, i}$是第个$i$分类值(id序号)的embedding向量。$W_{embed,i}$是embedding矩阵, $n_e\times n_v$维度, $n_e$是embedding维度, $n_v$是该类特征的唯一取值个数。$x_i$属于该特征的二元稀疏向量(one-hot)编码的。 【实质上就是在训练得到的Embedding参数矩阵中找到属于当前样本对应的Embedding向量】。其实绝大多数基于深度学习的推荐模型都需要Embedding操作,参数学习是通过神经网络进行训练。
|
||||
|
||||
最后,该层需要将所有的密集型特征与通过embedding转换后的特征进行联合(Stacking):
|
||||
$$
|
||||
\mathbf{x}_{0}=\left[\mathbf{x}_{\text {embed, } 1}^{T}, \ldots, \mathbf{x}_{\text {embed, }, k}^{T}, \mathbf{x}_{\text {dense }}^{T}\right]
|
||||
$$
|
||||
一共$k$个类别特征, dense是数值型特征, 两者在特征维度拼在一块。 上面的这两个操作如果是看了前面的模型的话,应该非常容易理解了。
|
||||
|
||||
### Cross Network
|
||||
|
||||
这个就是本模型最大的亮点了【Cross网络】, 这个思路感觉非常Nice。设计该网络的目的是增加特征之间的交互力度。交叉网络由多个交叉层组成, 假设第$l$层的输出向量$x_l$, 那么对于第$l+1$层的输出向量$x_{l+1}$表示为:
|
||||
|
||||
$$
|
||||
\mathbf{x}_{l+1}=\mathbf{x}_{0} \mathbf{x}_{l}^{T} \mathbf{w}_{l}+\mathbf{b}_{l}+\mathbf{x}_{l}=f\left(\mathbf{x}_{l}, \mathbf{w}_{l}, \mathbf{b}_{l}\right)+\mathbf{x}_{l}
|
||||
$$
|
||||
可以看到, 交叉层的二阶部分非常类似PNN提到的外积操作, 在此基础上增加了外积操作的权重向量$w_l$, 以及原输入向量$x_l$和偏置向量$b_l$。 交叉层的可视化如下:
|
||||
|
||||
<div align=center> <img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片cross.png" style="zoom:67%;" />
|
||||
</div>
|
||||
|
||||
可以看到, 每一层增加了一个$n$维的权重向量$w_l$(n表示输入向量维度), 并且在每一层均保留了输入向量, 因此输入和输出之间的变化不会特别明显。关于这一层, 原论文里面有个具体的证明推导Cross Network为啥有效, 不过比较复杂,这里我拿一个式子简单的解释下上面这个公式的伟大之处:
|
||||
|
||||
> **我们根据上面这个公式, 尝试的写前面几层看看:**
|
||||
>
|
||||
> $l=0:\mathbf{x}_{1} =\mathbf{x}_{0} \mathbf{x}_{0}^{T} \mathbf{w}_{0}+ \mathbf{b}_{0}+\mathbf{x}_{0}$
|
||||
>
|
||||
> $l=1:\mathbf{x}_{2} =\mathbf{x}_{0} \mathbf{x}_{1}^{T} \mathbf{w}_{1}+ \mathbf{b}_{1}+\mathbf{x}_{1}=\mathbf{x}_{0} [\mathbf{x}_{0} \mathbf{x}_{0}^{T} \mathbf{w}_{0}+ \mathbf{b}_{0}+\mathbf{x}_{0}]^{T}\mathbf{w}_{1}+\mathbf{b}_{1}+\mathbf{x}_{1}$
|
||||
>
|
||||
> $l=2:\mathbf{x}_{3} =\mathbf{x}_{0} \mathbf{x}_{2}^{T} \mathbf{w}_{2}+ \mathbf{b}_{2}+\mathbf{x}_{2}=\mathbf{x}_{0} [\mathbf{x}_{0} [\mathbf{x}_{0} \mathbf{x}_{0}^{T} \mathbf{w}_{0}+ \mathbf{b}_{0}+\mathbf{x}_{0}]^{T}\mathbf{w}_{1}+\mathbf{b}_{1}+\mathbf{x}_{1}]^{T}\mathbf{w}_{2}+\mathbf{b}_{2}+\mathbf{x}_{2}$
|
||||
|
||||
我们暂且写到第3层的计算, 我们会发现什么结论呢? 给大家总结一下:
|
||||
|
||||
1. $\mathrm{x}_1$中包含了所有的$\mathrm{x}_0$的1,2阶特征的交互, $\mathrm{x}_2$包含了所有的$\mathrm{x}_1, \mathrm{x}_0$的1、2、3阶特征的交互,$\mathrm{x}_3$中包含了所有的$\mathrm{x}_2$, $\mathrm{x}_1$与$\mathrm{x}_0$的交互,$\mathrm{x}_0$的1、2、3、4阶特征交互。 因此, 交叉网络层的叉乘阶数是有限的。 **第$l$层特征对应的最高的叉乘阶数$l+1$**
|
||||
|
||||
2. Cross网络的参数是共享的, 每一层的这个权重特征之间共享, 这个可以使得模型泛化到看不见的特征交互作用, 并且对噪声更具有鲁棒性。 例如两个稀疏的特征$x_i,x_j$, 它们在数据中几乎不发生交互, 那么学习$x_i,x_j$的权重对于预测没有任何的意义。
|
||||
|
||||
3. 计算交叉网络的参数数量。 假设交叉层的数量是$L_c$, 特征$x$的维度是$n$, 那么总共的参数是:
|
||||
|
||||
$$
|
||||
n\times L_c \times 2
|
||||
$$
|
||||
这个就是每一层会有$w$和$b$。且$w$维度和$x$的维度是一致的。
|
||||
|
||||
4. 交叉网络的时间和空间复杂度是线性的。这是因为, 每一层都只有$w$和$b$, 没有激活函数的存在,相对于深度学习网络, 交叉网络的复杂性可以忽略不计。
|
||||
|
||||
5. Cross网络是FM的泛化形式, 在FM模型中, 特征$x_i$的权重$v_i$, 那么交叉项$x_i,x_j$的权重为$<x_i,x_j>$。在DCN中, $x_i$的权重为${W_K^{(i)}}_{k=1}^l$, 交叉项$x_i,x_j$的权重是参数${W_K^{(i)}}_{k=1}^l$和${W_K^{(j)}}_{k=1}^l$的乘积,这个看上面那个例子展开感受下。因此两个模型都各自学习了独立于其他特征的一些参数,并且交叉项的权重是相应参数的某种组合。FM只局限于2阶的特征交叉(一般),而DCN可以构建更高阶的特征交互, 阶数由网络深度决定,并且交叉网络的参数只依据输入的维度线性增长。
|
||||
|
||||
6. 还有一点我们也要了解,对于每一层的计算中, 都会跟着$\mathrm{x}_0$, 这个是咱们的原始输入, 之所以会乘以一个这个,是为了保证后面不管怎么交叉,都不能偏离我们的原始输入太远,别最后交叉交叉都跑偏了。
|
||||
|
||||
7. $\mathbf{x}_{l+1}=f\left(\mathbf{x}_{l}, \mathbf{w}_{l}, \mathbf{b}_{l}\right)+\mathbf{x}_{l}$, 这个东西其实有点跳远连接的意思,也就是和ResNet也有点相似,无形之中还能有效的缓解梯度消失现象。
|
||||
|
||||
好了, 关于本模型的交叉网络的细节就介绍到这里了。这应该也是本模型的精华之处了,后面就简单了。
|
||||
|
||||
### Deep Network
|
||||
|
||||
这个就和上面的D&W的全连接层原理一样。这里不再过多的赘述。
|
||||
$$
|
||||
\mathbf{h}_{l+1}=f\left(W_{l} \mathbf{h}_{l}+\mathbf{b}_{l}\right)
|
||||
$$
|
||||
具体的可以参考W&D模型。
|
||||
|
||||
### 组合输出层
|
||||
|
||||
这个层负责将两个网络的输出进行拼接, 并且通过简单的Logistics回归完成最后的预测:
|
||||
$$
|
||||
p=\sigma\left(\left[\mathbf{x}_{L_{1}}^{T}, \mathbf{h}_{L_{2}}^{T}\right] \mathbf{w}_{\text {logits }}\right)
|
||||
$$
|
||||
其中$\mathbf{x}_{L_{1}}^{T}$和$\mathbf{h}_{L_{2}}^{T}$分别表示交叉网络和深度网络的输出。
|
||||
最后二分类的损失函数依然是交叉熵损失:
|
||||
$$
|
||||
\text { loss }=-\frac{1}{N} \sum_{i=1}^{N} y_{i} \log \left(p_{i}\right)+\left(1-y_{i}\right) \log \left(1-p_{i}\right)+\lambda \sum_{l}\left\|\mathbf{w}_{i}\right\|^{2}
|
||||
$$
|
||||
|
||||
Cross&Deep模型的原理就是这些了,其核心部分就是Cross Network, 这个可以进行特征的自动交叉, 避免了更多基于业务理解的人工特征组合。 该模型相比于W&D,Cross部分表达能力更强, 使得模型具备了更强的非线性学习能力。
|
||||
|
||||
## 代码实现
|
||||
|
||||
下面我们看下DCN的代码复现,这里主要是给大家说一下这个模型的设计逻辑,参考了deepctr的函数API的编程风格, 具体的代码以及示例大家可以去参考后面的GitHub,里面已经给出了详细的注释, 这里主要分析模型的逻辑这块。关于函数API的编程式风格,我们还给出了一份文档, 大家可以先看这个,再看后面的代码部分,会更加舒服些。
|
||||
|
||||
从上面的结构图我们也可以看出, DCN的模型搭建,其实主要分为几大模块, 首先就是建立输入层,用到的函数式`build_input_layers`,有了输入层之后, 我们接下来是embedding层的搭建,用到的函数是`build_embedding_layers`, 这个层的作用是接收离散特征,变成低维稠密。 接下来就是把连续特征和embedding之后的离散特征进行拼接,分别进入wide端和deep端。 wide端就是交叉网络,而deep端是DNN网络, 这里分别是`CrossNet()`和`get_dnn_output()`, 接下来就是把这两块的输出拼接得到最后的输出了。所以整体代码如下:
|
||||
|
||||
```python
|
||||
def DCN(linear_feature_columns, dnn_feature_columns):
|
||||
# 构建输入层,即所有特征对应的Input()层,这里使用字典的形式返回,方便后续构建模型
|
||||
dense_input_dict, sparse_input_dict = build_input_layers(linear_feature_columns + dnn_feature_columns)
|
||||
|
||||
# 构建模型的输入层,模型的输入层不能是字典的形式,应该将字典的形式转换成列表的形式
|
||||
# 注意:这里实际的输入与Input()层的对应,是通过模型输入时候的字典数据的key与对应name的Input层
|
||||
input_layers = list(dense_input_dict.values()) + list(sparse_input_dict.values())
|
||||
|
||||
# 构建维度为k的embedding层,这里使用字典的形式返回,方便后面搭建模型
|
||||
embedding_layer_dict = build_embedding_layers(dnn_feature_columns, sparse_input_dict, is_linear=False)
|
||||
|
||||
concat_dense_inputs = Concatenate(axis=1)(list(dense_input_dict.values()))
|
||||
|
||||
# 将特征中的sparse特征筛选出来
|
||||
sparse_feature_columns = list(filter(lambda x: isinstance(x, SparseFeat), linear_feature_columns)) if linear_feature_columns else []
|
||||
|
||||
sparse_kd_embed = concat_embedding_list(sparse_feature_columns, sparse_input_dict, embedding_layer_dict, flatten=True)
|
||||
|
||||
concat_sparse_kd_embed = Concatenate(axis=1)(sparse_kd_embed)
|
||||
|
||||
dnn_input = Concatenate(axis=1)([concat_dense_inputs, concat_sparse_kd_embed])
|
||||
|
||||
dnn_output = get_dnn_output(dnn_input)
|
||||
|
||||
cross_output = CrossNet()(dnn_input)
|
||||
|
||||
# stack layer
|
||||
stack_output = Concatenate(axis=1)([dnn_output, cross_output])
|
||||
|
||||
# 这里的激活函数使用sigmoid
|
||||
output_layer = Dense(1, activation='sigmoid')(stack_output)
|
||||
|
||||
model = Model(input_layers, output_layer)
|
||||
return model
|
||||
```
|
||||
|
||||
这个模型的实现过程和DeepFM比较类似,这里不画草图了,如果想看的可以去参考DeepFM草图及代码之间的对应关系。
|
||||
|
||||
下面是一个通过keras画的模型结构图,为了更好的显示,类别特征都只是选择了一小部分,画图的代码也在github中。
|
||||
|
||||
<div align=center> <img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片DCN.png" alt="image-20210308143101261" style="zoom: 50%;" />
|
||||
</div>
|
||||
|
||||
## 思考
|
||||
|
||||
1. 请计算Cross Network的复杂度,需要的变量请自己定义。
|
||||
2. 在实现矩阵计算$x_0*x_l^Tw$的过程中,有人说要先算前两个,有人说要先算后两个,请问那种方式更好?为什么?
|
||||
|
||||
**参考资料**
|
||||
* 《深度学习推荐系统》 --- 王喆
|
||||
* [Deep&Cross模型原论文](https://arxiv.org/abs/1708.05123)
|
||||
* AI上推荐 之 Wide&Deep与Deep&Cross模型(记忆与泛化并存的华丽转身)
|
||||
* [Wide&Deep模型的进阶---Cross&Deep模型](https://mp.weixin.qq.com/s/DkoaMaXhlgQv1NhZHF-7og)
|
||||
|
||||
147
2023旧版内容/4.人工智能/ch02/ch2.2/ch2.2.2/FM.md
Normal file
147
2023旧版内容/4.人工智能/ch02/ch2.2/ch2.2.2/FM.md
Normal file
@@ -0,0 +1,147 @@
|
||||
### FM模型的引入
|
||||
|
||||
#### 逻辑回归模型及其缺点
|
||||
|
||||
FM模型其实是一种思路,具体的应用稍少。一般来说做推荐CTR预估时最简单的思路就是将特征做线性组合(逻辑回归LR),传入sigmoid中得到一个概率值,本质上这就是一个线性模型,因为sigmoid是单调增函数不会改变里面的线性模型的CTR预测顺序,因此逻辑回归模型效果会比较差。也就是LR的缺点有:
|
||||
|
||||
* 是一个线性模型
|
||||
* 每个特征对最终输出结果独立,需要手动特征交叉($x_i*x_j$),比较麻烦
|
||||
|
||||
<br>
|
||||
|
||||
#### 二阶交叉项的考虑及改进
|
||||
|
||||
由于LR模型的上述缺陷(主要是手动做特征交叉比较麻烦),干脆就考虑所有的二阶交叉项,也就是将目标函数由原来的
|
||||
|
||||
$$
|
||||
y = w_0+\sum_{i=1}^nw_ix_i
|
||||
$$
|
||||
变为
|
||||
|
||||
$$
|
||||
y = w_0+\sum_{i=1}^nw_ix_i+\sum_{i=1}^{n-1}\sum_{i+1}^nw_{ij}x_ix_j
|
||||
$$
|
||||
但这个式子有一个问题,**只有当$x_i$与$x_j$均不为0时这个二阶交叉项才会生效**,后面这个特征交叉项本质是和多项式核SVM等价的,为了解决这个问题,我们的FM登场了!
|
||||
|
||||
FM模型使用了如下的优化函数:
|
||||
|
||||
$$
|
||||
y = w_0+\sum_{i=1}^nw_ix_i+\sum_{i=1}^{n}\sum_{i+1}^n\lt v_i,v_j\gt x_ix_j
|
||||
$$
|
||||
事实上做的唯一改动就是把$w_{ij}$替换成了$\lt v_i,v_j\gt$,大家应该就看出来了,这实际上就有深度学习的意味在里面了,实质上就是给每个$x_i$计算一个embedding,然后将两个向量之间的embedding做内积得到之前所谓的$w_{ij}$好处就是这个模型泛化能力强 ,即使两个特征之前从未在训练集中**同时**出现,我们也不至于像之前一样训练不出$w_{ij}$,事实上只需要$x_i$和其他的$x_k$同时出现过就可以计算出$x_i$的embedding!
|
||||
|
||||
<br>
|
||||
|
||||
### FM公式的理解
|
||||
|
||||
从公式来看,模型前半部分就是普通的LR线性组合,后半部分的交叉项:特征组合。首先,单从模型表达能力上来看,FM是要强于LR的,至少它不会比LR弱,当交叉项参数$w_{ij}$全为0的时候,整个模型就退化为普通的LR模型。对于有$n$个特征的模型,特征组合的参数数量共有$1+2+3+\cdots + n-1=\frac{n(n-1)}{2}$个,并且任意两个参数之间是独立的。所以说特征数量比较多的时候,特征组合之后,维度自然而然就高了。
|
||||
|
||||
> 定理:任意一个实对称矩阵(正定矩阵)$W$都存在一个矩阵$V$,使得 $W=V.V^{T}$成立。
|
||||
|
||||
类似地,所有二次项参数$\omega_{ij}$可以组成一个对称阵$W$(为了方便说明FM的由来,对角元素可以设置为正实数),那么这个矩阵就可以分解为$W=V^TV$,$V$ 的第$j$列($v_{j}$)便是第$j$维特征($x_{j}$)的隐向量。
|
||||
|
||||
$$
|
||||
\hat{y}(X) = \omega_{0}+\sum_{i=1}^{n}{\omega_{i}x_{i}}+\sum_{i=1}^{n-1}{\sum_{j=i+1}^{n} \color{red}{<v_{i},v_{j}>x_{i}x_{j}}}
|
||||
$$
|
||||
|
||||
需要估计的参数有$\omega_{0}∈ R$,$\omega_{i}∈ R$,$V∈ R$,$< \cdot, \cdot>$是长度为$k$的两个向量的点乘,公式如下:
|
||||
|
||||
$$
|
||||
<v_{i},v_{j}> = \sum_{f=1}^{k}{v_{i,f}\cdot v_{j,f}}
|
||||
$$
|
||||
|
||||
上面的公式中:
|
||||
|
||||
- $\omega_{0}$为全局偏置;
|
||||
- $\omega_{i}$是模型第$i$个变量的权重;
|
||||
- $\omega_{ij} = < v_{i}, v_{j}>$特征$i$和$j$的交叉权重;
|
||||
- $v_{i} $是第$i$维特征的隐向量;
|
||||
- $<\cdot, \cdot>$代表向量点积;
|
||||
- $k(k<<n)$为隐向量的长度,包含 $k$ 个描述特征的因子。
|
||||
|
||||
FM模型中二次项的参数数量减少为 $kn $个,远少于多项式模型的参数数量。另外,参数因子化使得 $x_{h}x_{i}$ 的参数和 $x_{i}x_{j}$ 的参数不再是相互独立的,因此我们可以在样本稀疏的情况下相对合理地估计FM的二次项参数。具体来说,$x_{h}x_{i}$ 和 $x_{i}x_{j}$的系数分别为 $\lt v_{h},v_{i}\gt$ 和 $\lt v_{i},v_{j}\gt$ ,它们之间有共同项 $v_{i}$ 。也就是说,所有包含“ $x_{i}$ 的非零组合特征”(存在某个 $j \ne i$ ,使得 $x_{i}x_{j}\neq 0$ )的样本都可以用来学习隐向量$v_{i}$,这很大程度上避免了数据稀疏性造成的影响。而在多项式模型中,$w_{hi}$ 和 $w_{ij}$ 是相互独立的。
|
||||
|
||||
显而易见,FM的公式是一个通用的拟合方程,可以采用不同的损失函数用于解决regression、classification等问题,比如可以采用MSE(Mean Square Error)loss function来求解回归问题,也可以采用Hinge/Cross-Entropy loss来求解分类问题。当然,在进行二元分类时,FM的输出需要使用sigmoid函数进行变换,该原理与LR是一样的。直观上看,FM的复杂度是 $O(kn^2)$ 。但是FM的二次项可以化简,其复杂度可以优化到 $O(kn)$ 。由此可见,FM可以在线性时间对新样本作出预测。
|
||||
|
||||
**证明**:
|
||||
$$
|
||||
\begin{aligned}
|
||||
\sum_{i=1}^{n-1}{\sum_{j=i+1}^{n}{<v_i,v_j>x_ix_j}}
|
||||
&= \frac{1}{2}\sum_{i=1}^{n}{\sum_{j=1}^{n}{<v_i,v_j>x_ix_j}} - \frac{1}{2} {\sum_{i=1}^{n}{<v_i,v_i>x_ix_i}} \\
|
||||
&= \frac{1}{2} \left( \sum_{i=1}^{n}{\sum_{j=1}^{n}{\sum_{f=1}^{k}{v_{i,f}v_{j,f}x_ix_j}}} - \sum_{i=1}^{n}{\sum_{f=1}^{k}{v_{i,f}v_{i,f}x_ix_i}} \right) \\
|
||||
&= \frac{1}{2}\sum_{f=1}^{k}{\left[ \left( \sum_{i=1}^{n}{v_{i,f}x_i} \right) \cdot \left( \sum_{j=1}^{n}{v_{j,f}x_j} \right) - \sum_{i=1}^{n}{v_{i,f}^2 x_i^2} \right]} \\
|
||||
&= \frac{1}{2}\sum_{f=1}^{k}{\left[ \left( \sum_{i=1}^{n}{v_{i,f}x_i} \right)^2 - \sum_{i=1}^{n}{v_{i,f}^2 x_i^2} \right]}
|
||||
\end{aligned}
|
||||
$$
|
||||
**解释**:
|
||||
|
||||
- $v_{i,f}$ 是一个具体的值;
|
||||
- 第1个等号:对称矩阵 $W$ 对角线上半部分;
|
||||
- 第2个等号:把向量内积 $v_{i}$,$v_{j}$ 展开成累加和的形式;
|
||||
- 第3个等号:提出公共部分;
|
||||
- 第4个等号: $i$ 和 $j$ 相当于是一样的,表示成平方过程。
|
||||
|
||||
<br>
|
||||
|
||||
### FM优缺点
|
||||
|
||||
**优点**
|
||||
1. 通过向量内积作为交叉特征的权重,可以在数据非常稀疏的情况下还能有效的训练处交叉特征的权重(因为不需要两个特征同时不为零)
|
||||
2. 可以通过公式上的优化,得到O(nk)的计算复杂度,k一般比较小,所以基本上和n是正相关的,计算效率非常高
|
||||
3. 尽管推荐场景下的总体特征空间非常大,但是FM的训练和预测只需要处理样本中的非零特征,这也提升了模型训练和线上预测的速度
|
||||
4. 由于模型的计算效率高,并且在稀疏场景下可以自动挖掘长尾低频物料。所以在召回、粗排和精排三个阶段都可以使用。应用在不同阶段时,样本构造、拟合目标及线上服务都有所不同(注意FM用于召回时对于user和item相似度的优化)
|
||||
5. 其他优点及工程经验参考石塔西的文章
|
||||
|
||||
**缺点**
|
||||
1. 只能显示的做特征的二阶交叉,对于更高阶的交叉无能为力。对于此类问题,后续就提出了各类显示、隐式交叉的模型,来充分挖掘特征之间的关系
|
||||
|
||||
### 代码实现
|
||||
|
||||
```python
|
||||
class FM(Layer):
|
||||
"""显示特征交叉,直接按照优化后的公式实现即可
|
||||
注意:
|
||||
1. 传入进来的参数看起来是一个Embedding权重,没有像公式中出现的特征,那是因
|
||||
为,输入的id特征本质上都是onehot编码,取出对应的embedding就等价于特征乘以
|
||||
权重。所以后续的操作直接就是对特征进行操作
|
||||
2. 在实现过程中,对于公式中的平方的和与和的平方两部分,需要留意是在哪个维度
|
||||
上计算,这样就可以轻松实现FM特征交叉模块
|
||||
"""
|
||||
def __init__(self, **kwargs):
|
||||
super(FM, self).__init__(**kwargs)
|
||||
|
||||
def build(self, input_shape):
|
||||
if not isinstance(input_shape, list) or len(input_shape) < 2:
|
||||
raise ValueError('`FM` layer should be called \
|
||||
on a list of at least 2 inputs')
|
||||
super(FM, self).build(input_shape) # Be sure to call this somewhere!
|
||||
|
||||
def call(self, inputs, **kwargs):
|
||||
"""
|
||||
inputs: 是一个列表,列表中每个元素的维度为:(None, 1, emb_dim), 列表长度
|
||||
为field_num
|
||||
"""
|
||||
concated_embeds_value = Concatenate(axis=1)(inputs) #(None,field_num,emb_dim)
|
||||
# 根据最终优化的公式计算即可,需要注意的是计算过程中是沿着哪个维度计算的,将代码和公式结合起来看会更清晰
|
||||
square_of_sum = tf.square(tf.reduce_sum(
|
||||
concated_embeds_value, axis=1, keepdims=True)) # (None, 1, emb_dim)
|
||||
sum_of_square = tf.reduce_sum(
|
||||
concated_embeds_value * concated_embeds_value,
|
||||
axis=1, keepdims=True) # (None, 1, emb_dim)
|
||||
cross_term = square_of_sum - sum_of_square
|
||||
cross_term = 0.5 * tf.reduce_sum(cross_term, axis=2, keepdims=False)#(None,1)
|
||||
return cross_term
|
||||
|
||||
def compute_output_shape(self, input_shape):
|
||||
return (None, 1)
|
||||
|
||||
def get_config(self):
|
||||
return super().get_config()
|
||||
```
|
||||
|
||||
|
||||
**参考资料**
|
||||
* [FM:推荐算法中的瑞士军刀](https://zhuanlan.zhihu.com/p/343174108)
|
||||
* [FM算法解析](https://zhuanlan.zhihu.com/p/37963267)
|
||||
* [FM论文原文](https://www.csie.ntu.edu.tw/~b97053/paper/Rendle2010FM.pdf)
|
||||
* [AI上推荐 之 FM和FFM](https://blog.csdn.net/wuzhongqiang/article/details/108719417)
|
||||
451
2023旧版内容/4.人工智能/ch02/ch2.2/ch2.2.2/FiBiNet.md
Normal file
451
2023旧版内容/4.人工智能/ch02/ch2.2/ch2.2.2/FiBiNet.md
Normal file
@@ -0,0 +1,451 @@
|
||||
## 写在前面
|
||||
FiBiNET(Feature Importance and Bilinear feature Interaction)是2019年发表在RecSys的一个模型,来自新浪微博张俊林老师的团队。这个模型如果从模型演化的角度来看, 主要是在特征重要性以及特征之间交互上做出了探索。所以,如果想掌握FiBiNet的话,需要掌握两大核心模块:
|
||||
* 模型的特征重要性选择 --- SENET网络
|
||||
* 特征之间的交互 --- 双线性交叉层(组合了内积和哈达玛积)
|
||||
|
||||
|
||||
## FiBiNet? 我们先需要先了解这些
|
||||
|
||||
FiBiNet的提出动机是因为在特征交互这一方面, 目前的ctr模型要么是简单的两两embedding内积(这里针对离散特征), 比如FM,FFM。 或者是两两embedding进行哈达玛积(NFM这种), 作者认为这两种交互方式还是过于简单, 另外像NFM这种,FM这种,也忽视了特征之间的重要性程度。
|
||||
|
||||
对于特征重要性,作者在论文中举得例子非常形象
|
||||
>the feature occupation is more important than the feature hobby when we predict a person’s income
|
||||
|
||||
所以要想让模型学习到更多的信息, 从作者的角度来看,首先是离散特征之间的交互必不可少,且需要更细粒度。第二个就是需要考虑不同特征对于预测目标的重要性程度,给不同的特征根据重要性程度进行加权。 写到这里, 如果看过之前的文章的话,这个是不是和某些模型有些像呀, 没错,AFM其实考虑了这一点, 不过那里是用了一个Attention网络对特征进行的加权, 这里采用了另一种思路而已,即SENET, 所以这里我们如果是考虑特征重要性程度的话, 就有了两种思路:
|
||||
* Attention
|
||||
* SENET
|
||||
|
||||
而考虑特征交互的话, 思路应该会更多:
|
||||
* PNN里面的内积和外积
|
||||
* NFM里面的哈达玛积
|
||||
* 这里的双线性函数交互(内积和哈达玛积的组合)
|
||||
|
||||
所以,读论文, 这些思路感觉要比模型本身重要,而读论文还有一个有意思的事情,那就是我们既能了解思路,也能想一下,为啥这些方法会有效果呢? 我们自己能不能提出新的方法来呢? 如果读一篇paper,再顺便把后面的这些问题想通了, 那么这篇paper对于我们来说就发挥效用了, 后面就可以用拉马努金式方法训练自己的思维。
|
||||
|
||||
在前面的准备工作中,作者依然是带着我们梳理了整个推荐模型的演化过程, 我们也简单梳理下,就当回忆:
|
||||
* FNN: 下面是经过FM预训练的embedding层, 也就是先把FM训练好,得到各个特征的embedding,用这个embedding初始化FNN下面的embedding层, 上面是DNN。 这个模型用的不是很多,缺点是只能搞隐性高阶交互,并且下面的embedding和高层的DNN配合不是很好。
|
||||
* WDL: 这是一个经典的W&D架构, w逻辑回归维持记忆, DNN保持高阶特征交互。问题是W端依然需要手动特征工程,也就是低阶交互需要手动来搞,需要一定的经验。一般工业上也不用了。
|
||||
* DeepFM:对WDL的逻辑回归进行升级, 把逻辑回归换成FM, 这样能保证低阶特征的自动交互, 兼顾记忆和泛化性能,低阶和高阶交互。 目前这个模型在工业上非常常用,效果往往还不错,SOTA模型。
|
||||
* DCN: 认为DeepFM的W端的FM的交互还不是很彻底,只能到二阶交互。所以就提出了一种交叉性网络,可以在W端完成高阶交互。
|
||||
* xDeepFM: DCN的再次升级,认为DCN的wide端交叉网络这种element-wise的交互方式不行,且不是显性的高阶交互,所以提出了一个专门用户高阶显性交互的CIN网络, vector-wise层次上的特征交互。
|
||||
* NFM: 下层是FM, 中间一个交叉池化层进行两两交互,然后上面接DNN, 工业上用的不多。
|
||||
* AFM: 从NFM的基础上,考虑了交互完毕之后的特征重要性程度, 从NFM的基础上加了一个Attention网络,所以如果用的话,也应该用AFM。
|
||||
|
||||
综上, 这几个网络里面最常用的还是属DeepFM了, 当然对于交互来讲,在我的任务上试过AFM和xDeepFM, 结果是AFM和DeepFM差不多持平, 而xDeepFM要比这俩好一些,但并不多,而考虑完了复杂性, 还是DeepFM或者AFM。
|
||||
|
||||
对于上面模型的问题,作者说了两点,第一个是大部分模型没有考虑特征重要性,也就是交互完事之后,没考虑对于预测目标来讲谁更重要,一视同仁。 第二个是目前的两两特征交互,大部分依然是内积或者哈达玛积, 作者认为还不是细粒度(fine-grained way)交互。
|
||||
|
||||
那么,作者是怎么针对这两个问题进行改进的呢? 为什么这么改进呢?
|
||||
|
||||
## FiBiNet模型的理论以及论文细节
|
||||
这里我们直接分析模型架构即可, 因为这个模型不是很复杂,也非常好理解前向传播的过程:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210703160140322.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" alt="image-20210308142624189" style="zoom: 80%;" />
|
||||
</div>
|
||||
|
||||
从模型架构上来看,如果把我框出来的两部分去掉, 这个基本上就退化成了最简单的推荐深度模型DeepCrossing,甚至还比不上那个(那个还用了残差网络)。不过,加上了两个框,效果可就不一样了。所以下面重点是剖析下这两个框的结构,其他的简单一过即可。
|
||||
>梳理细节之前, 先说下前向传播的过程。 <br>
|
||||
>首先,我们输入的特征有离散和连续,对于连续的特征,输入完了之后,先不用管,等待后面拼起来进DNN即可,这里也没有刻意处理连续特征。
|
||||
><br>对于离散特征,过embedding转成低维稠密,一般模型的话,这样完了之后,就去考虑embedding之间交互了。 而这个模型不是, 在得到离散特征的embedding之后,分成了两路
|
||||
>* 一路保持原样, 继续往后做两两之间embedding交互,不过这里的交互方式,不是简单的内积或者哈达玛积,而是采用了非线性函数,这个后面会提到。
|
||||
>* 另一路,过一个SENET Layer, 过完了之后得到的输出是和原来embedding有着相同维度的,这个SENET的理解方式和Attention网络差不多,也是根据embedding的重要性不同出来个权重乘到了上面。 这样得到了SENET-like Embedding,就是加权之后的embedding。 这时候再往上两两双线性交互。
|
||||
>
|
||||
>两路embedding都两两交互完事, Flatten展平,和连续特征拼在一块过DNN输出。
|
||||
|
||||
|
||||
### Embedding Layer
|
||||
这个不多讲, 整理这个是为了后面统一符号。
|
||||
|
||||
假设我们有$f$个离散特征,经过embedding层之后,会得到$E=\left[e_{1}, e_{2}, \cdots, e_{i}, \cdots, e_{f}\right]$, 其中$e_{i} \in R^{k}$,表示第$i$个离散特征对应的embedding向量,$k$维。
|
||||
|
||||
### SENET Layer
|
||||
这是第一个重点,首先这个网络接收的输入是上面的$E=\left[e_{1}, e_{2}, \cdots, e_{i}, \cdots, e_{f}\right]$, 网络的输出也是个同样大小的张量`(None, f, k)`矩阵。 结构如下:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210703162008862.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" alt="image-20210308142624189" style="zoom: 80%;" />
|
||||
</div>
|
||||
|
||||
SENet由自动驾驶公司Momenta在2017年提出,在当时,是一种应用于图像处理的新型网络结构。它基于CNN结构,**通过对特征通道间的相关性进行建模,对重要特征进行强化来提升模型准确率,本质上就是针对CNN中间层卷积核特征的Attention操作**。ENet仍然是效果最好的图像处理网络结构之一。
|
||||
>SENet能否用到推荐系统?--- 张俊林老师的知乎(链接在文末)<br>
|
||||
>推荐领域里面的特征有个特点,就是海量稀疏,意思是大量长尾特征是低频的,而这些低频特征,去学一个靠谱的Embedding是基本没希望的,但是你又不能把低频的特征全抛掉,因为有一些又是有效的。既然这样,**如果我们把SENet用在特征Embedding上,类似于做了个对特征的Attention,弱化那些不靠谱低频特征Embedding的负面影响,强化靠谱低频特征以及重要中高频特征的作用,从道理上是讲得通的**
|
||||
|
||||
所以拿来用了再说, 把SENet放在Embedding层之上,通过SENet网络,动态地学习这些特征的重要性。**对于每个特征学会一个特征权重,然后再把学习到的权重乘到对应特征的Embedding里,这样就可以动态学习特征权重,通过小权重抑制噪音或者无效低频特征,通过大权重放大重要特征影响的目的**。在推荐系统里面, 结构长这个样子:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210703161807139.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" alt="image-20210308142624189" style="zoom: 80%;" />
|
||||
</div>
|
||||
|
||||
下面看下这个网络里面的具体计算过程, SENET主要分为三个步骤Squeeze, Excitation, Re-weight。
|
||||
|
||||
* **在Squeeze阶段**,我们对每个特征的Embedding向量进行数据压缩与信息汇总,如下:
|
||||
|
||||
$$
|
||||
z_{i}=F_{s q}\left(e_{i}\right)=\frac{1}{k} \sum_{t=1}^{k} e_{i}^{(t)}
|
||||
$$
|
||||
|
||||
假设某个特征$v_i$是$k$维大小的$Embedding$,那么我们对$Embedding$里包含的$k$维数字求均值,得到能够代表这个特征汇总信息的数值 $z_i$,也就是说,把第$i$个特征的$Embedding$里的信息压缩到一个数值。原始版本的SENet,在这一步是对CNN的二维卷积核进行$Max$操作的,这里等于对某个特征Embedding元素求均值。我们试过,在推荐领域均值效果比$Max$效果好,这也很好理解,因为**图像领域对卷积核元素求$Max$,等于找到最强的那个特征,而推荐领域的特征$Embedding$,每一位的数字都是有意义的,所以求均值能更好地保留和融合信息**。通过Squeeze阶段,对于每个特征$v_i$ ,都压缩成了单个数值$z_i$,假设特征Embedding层有$f$个特征,就形成Squeeze向量$Z$,向量大小$f$。
|
||||
|
||||
* **Excitation阶段**,这个阶段引入了中间层比较窄的两层MLP网络,作用在Squeeze阶段的输出向量$Z$上,如下:
|
||||
|
||||
$$
|
||||
A=F_{e x}(Z)=\sigma_{2}\left(W_{2} \sigma_{1}\left(W_{1} Z\right)\right)
|
||||
$$
|
||||
|
||||
$\sigma$非线性激活函数,一般$relu$。本质上,这是在做特征的交叉,也就是说,每个特征以一个$Bit$来表征,通过MLP来进行交互,通过交互,得出这么个结果:对于当前所有输入的特征,通过相互发生关联,来动态地判断哪些特征重要,哪些特征不重要。
|
||||
|
||||
其中,第一个MLP的作用是做特征交叉,第二个MLP的作用是为了保持输出的大小维度。因为假设Embedding层有$f$个特征,那么我们需要保证输出$f$个权重值,而第二个MLP就是起到将大小映射到$f$个数值大小的作用。<br><br>这样,经过两层MLP映射,就会产生$f$个权重数值,第$i$个数值对应第$i$个特征Embedding的权重$a_i$ 。<br><br>这个东西有没有感觉和自动编码器很像,虽然不是一样的作用, 但网络结构是一样的。这就是知识串联的功效哈哈。
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/2021070316343673.png#pic_center" alt="image-20210308142624189" style="zoom: 70%;" />
|
||||
</div>
|
||||
|
||||
瞬间是不是就把SENet这里的网络结构记住了哈哈。下面再分析下维度, SENet的输入是$E$,这个是`(None, f, k)`的维度, 通过Squeeze阶段,得到了`(None, f)`的矩阵,这个也就相当于Layer L1的输入(当然这里没有下面的偏置哈),接下来过MLP1, 这里的$W_{1} \in R^{f \times \frac{f}{r}}, W_{2} \in R^{\frac{f}{r} \times f}$, 这里的$r$叫做reduction
|
||||
ratio, $\frac{f}{r}$这个就是中间层神经元的个数, $r$表示了压缩的程度。
|
||||
|
||||
* Re-Weight
|
||||
我们把Excitation阶段得到的每个特征对应的权重$a_i$,再乘回到特征对应的Embedding里,就完成了对特征重要性的加权操作。
|
||||
$$V=F_{\text {ReWeight }}(A, E)=\left[a_{1} \cdot e_{1}, \cdots, a_{f} \cdot e_{f}\right]=\left[v_{1}, \cdots, v_{f}\right]$$
|
||||
$a_{i} \in R, e_{i} \in R^{k}$, and $v_{i} \in R^{k}$。$a_i$数值大,说明SENet判断这个特征在当前输入组合里比较重要, $a_i$数值小,说明SENet判断这个特征在当前输入组合里没啥用。如果非线性函数用Relu,会发现大量特征的权重会被Relu搞成0,也就是说,其实很多特征是没啥用的。
|
||||
|
||||
|
||||
这样,就可以将SENet引入推荐系统,用来对特征重要性进行动态判断。注意,**所谓动态,指的是比如对于某个特征,在某个输入组合里可能是没用的,但是换一个输入组合,很可能是重要特征。它重要不重要,不是静态的,而是要根据当前输入,动态变化的**。
|
||||
|
||||
这里正确的理解,算是一种特征重要性选择的思路, SENET和AFM的Attention网络是起着同样功效的一个网络。只不过那个是在特征交互之后进行特征交互重要性的选择,而这里是从embedding这里先压缩,再交互,再选择,去掉不太重要的特征。 **考虑特征重要性上的两种考虑思路,难以说孰好孰坏,具体看应用场景**。 不过如果分析下这个东西为啥会有效果, 就像张俊林老师提到的那样, 在Excitation阶段, 各个特征过了一个MLP进行了特征组合, 这样就真有可能过滤掉对于当前的交互不太重要的特征。 至于是不是, 那神经网络这东西就玄学了,让网络自己去学吧。
|
||||
|
||||
### Bilinear-Interaction Layer
|
||||
特征重要性选择完事, 接下来就是研究特征交互, 这里作者直接就列出了目前的两种常用交互以及双线性交互:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210703165031369.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" alt="image-20210308142624189" style="zoom: 70%;" />
|
||||
</div>
|
||||
|
||||
这个图其实非常了然了。以往模型用的交互, 内积的方式(FM,FFM)这种或者哈达玛积的方式(NFM,AFM)这种。
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210703165221794.png#pic_center" alt="image-20210308142624189" style="zoom: 70%;" >
|
||||
</div>
|
||||
|
||||
所谓的双线性,其实就是组合了内积和哈达玛积的操作,看上面的右图。就是在$v_i$和$v_j$之间先加一个$W$矩阵, 这个$W$矩阵的维度是$(f,f)$, $v_i, v_j$是$(1,f)$的向量。 先让$v_i$与$W$内积,得到$(1,f)$的向量,这时候先仔细体会下这个**新向量的每个元素,相当于是原来向量$v_i$在每个维度上的线性组合了**。这时候再与$v_j$进行哈达玛积得到结果。
|
||||
|
||||
这里我不由自主的考虑了下双线性的功效,也就是为啥作者会说双线性是细粒度,下面是我自己的看法哈。
|
||||
* 如果我们单独先看内积操作,特征交互如果是两个向量直接内积,这时候, 结果大的,说明两个向量相似或者特征相似, 但向量内积,其实是相当于向量的各个维度先对应位置元素相乘再相加求和。 这个过程中认为的是向量的各个维度信息的重要性是一致的。类似于$v_1+v_2+..v_k$, 但真的一致吗? --- **内积操作没有考虑向量各个维度的重要性**
|
||||
* 如果我们单独看哈达玛积操作, 特征交互如果是两个向量哈达玛积,这时候,是各个维度对应位置元素相乘得到一个向量, 而这个向量往往后面会进行线性或者非线性交叉的操作, 最后可能也会得到具体某个数值,但是这里经过了线性或者非线性交叉操作之后, 有没有感觉把向量各个维度信息的重要性考虑了进来? 就类似于$w_1v_{i1j1}+w_2k_{v2j2},...w_kv_{vkjk}$。 如果模型认为重要性相同,那么哈达玛积还有希望退化成内积,所以哈达玛积感觉考虑的比内积就多了一些。 --- **哈达玛积操作自身也没有考虑各个维度重要性,但通过后面的线性或者非线性操作,有一定的维度重要性在里面**
|
||||
* 再看看这个双线性, 是先内积再哈达玛积。这个内积操作不是直接$v_i$和$v_j$内积,而是中间引入了个$W$矩阵,参数可学习。 那么$v_i$和$W$做内积之后,虽然得到了同样大小的向量,但是这个向量是$v_i$各个维度元素的线性组合,相当于$v_i$变成了$[w_{11}v_{i1}+...w_{1k}v_{ik}, w_{21}v_{i1}+..w_{2k}v_{ik}, ...., w_{k1}v_{i1}+...w_{kk}v_{ik}]$, 这时候再与$v_j$哈达玛积的功效,就变成了$[(w_{11}v_{i1}+...w_{1k}v_{ik})v_{j1}, (w_{21}v_{i1}+..w_{2k}v_{ik})v_{j2}, ...., (w_{k1}v_{i1}+...w_{kk}v_{ik})v_{j_k}]$, 这时候,就可以看到,如果这里的$W$是个对角矩阵,那么这里就退化成了哈达玛积。 所以双线性感觉考虑的又比哈达玛积多了一些。如果后面再走一个非线性操作的话,就会发现这里同时考虑了两个交互向量各个维度上的重要性。---**双线性操作同时可以考虑交互的向量各自的各个维度上的重要性信息, 这应该是作者所说的细粒度,各个维度上的重要性**
|
||||
|
||||
**当然思路是思路,双线性并不一定见得一定比哈达玛积有效, SENET也不一定就会比原始embedding要好,一定要辩证看问题**
|
||||
|
||||
这里还有个厉害的地方在于这里的W有三种选择方式,也就是三种类型的双线性交互方式。
|
||||
1. Field-All Type
|
||||
$$
|
||||
p_{i j}=v_{i} \cdot W \odot v_{j}
|
||||
$$
|
||||
也就是所有的特征embedding共用一个$W$矩阵,这也是Field-All的名字来源。$W \in R^{k \times k}, \text { and } v_{i}, v_{j} \in R^{k}$。这种方式最简单
|
||||
2. Field-Each Type
|
||||
$$
|
||||
p_{i j}=v_{i} \cdot W_{i} \odot v_{j}
|
||||
$$
|
||||
每个特征embedding共用一个$W$矩阵, 那么如果有$f$个特征的话,这里的$W_i$需要$f$个。所以这里的参数个数$f-1\times k\times k$, 这里的$f-1$是因为两两组合之后,比如`[0,1,2]`, 两两组合`[0,1], [0,2], [1,2]`。 这里用到的域是0和1。
|
||||
3. Field-Interaction Type
|
||||
$$
|
||||
p_{i j}=v_{i} \cdot W_{i j} \odot v_{j}
|
||||
$$
|
||||
每组特征交互的时候,用一个$W$矩阵, 那么这里如果有$f$个特征的话,需要$W_{ij}$是$\frac{f(f-1)}{2}$个。参数个数$\frac{f(f-1)}{2}\times k\times k$个。
|
||||
|
||||
不知道看到这里,这种操作有没有种似曾相识的感觉, 有没有想起FM和FFM, 反正我是不自觉的想起了哈哈,不知道为啥。总感觉FM的风格和上面的Field-All很像, 而FFM和下面的Field-Interaction很像。
|
||||
|
||||
我们的原始embedding和SKNET-like embedding都需要过这个层,那么得到的就是一个双线性两两组合的矩阵, 维度是$(\frac{f(f-1)}{2}, k)$的矩阵。
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210703173830995.png#pic_center" alt="image-20210308142624189" style="zoom: 70%;" >
|
||||
</div>
|
||||
|
||||
### Combination Layer
|
||||
这个层的作用就是把目前得到的特征拼起来
|
||||
$$
|
||||
c=F_{\text {concat }}(p, q)=\left[p_{1}, \cdots, p_{n}, q_{1}, \cdots, q_{n}\right]=\left[c_{1}, \cdots, c_{2 n}\right]
|
||||
$$
|
||||
这里他直拼了上面得到的两个离散特征通过各种交互之后的形式,如果是还有连续特征的话,也可以在这里拼起来,然后过DNN,不过这里其实还省略了一步操作就是Flatten,先展平再拼接。
|
||||
|
||||
### DNN和输出层
|
||||
这里就不多说了, DNN的话普通的全连接网络, 再捕捉一波高阶的隐性交互。
|
||||
$$
|
||||
a^{(l)}=\sigma\left(W^{(l)} a^{(l-1)}+b^{(l)}\right)
|
||||
$$
|
||||
而输出层
|
||||
$$
|
||||
\hat{y}=\sigma\left(w_{0}+\sum_{i=0}^{m} w_{i} x_{i}+y_{d}\right)
|
||||
$$
|
||||
分类问题损失函数:
|
||||
$$
|
||||
\operatorname{loss}=-\frac{1}{N} \sum_{i=1}^{N}\left(y_{i} \log \left(\hat{y}_{i}\right)+\left(1-y_{i}\right) * \log \left(1-\hat{y}_{i}\right)\right)
|
||||
$$
|
||||
这里就不解释了。
|
||||
|
||||
### 其他重要细节
|
||||
实验部分,这里作者也是做了大量的实验来证明提出的模型比其他模型要好,这个就不说了。
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/2021070317512940.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" alt="image-20210308142624189" style="zoom: 70%;" >
|
||||
</div>
|
||||
|
||||
竟然比xDeepFM都要好。
|
||||
|
||||
在模型评估指标上,用了AUC和Logloss,这个也是常用的指标,Logloss就是交叉熵损失, 反映了样本的平均偏差,经常作为模型的损失函数来做优化,可是,当训练数据正负样本不平衡时,比如我们经常会遇到正样本很少,负样本很多的情况,此时LogLoss会倾向于偏向负样本一方。 而AUC评估不会受很大影响,具体和AUC的计算原理有关。这个在这里就不解释了。
|
||||
|
||||
其次了解到的一个事情:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210703175052617.png#pic_center" alt="image-20210308142624189" style="zoom: 70%;" >
|
||||
</div>
|
||||
|
||||
接下来,得整理下双线性与哈达玛积的组合类型,因为我们这个地方其实有两路embedding的, 一路是原始embedding, 一路是SKNet侧的embedding。而面临的组合方式,有双线性和哈达玛积两种。那么怎么组合会比较好呢? 作者做了实验。结论是,作者建议:
|
||||
>深度学习模型中,原始那边依然哈达玛,SE那边双线性, 可能更有效, 不过后面的代码实现里面,都用了双线性。
|
||||
|
||||
|
||||
而具体,在双线性里面,那种W的原则有效呢? 这个视具体的数据集而定。
|
||||
|
||||
超参数选择,主要是embedding维度以及DNN层数, embedding维度这个10-50, 不同的数据集可能表现不一样, 但尽量不要超过50了。否则在DNN之前的特征维度会很大。
|
||||
|
||||
DNN层数,作者这里建议3层,而每一层神经单元个数,也是没有定数了。
|
||||
|
||||
这里竟然没有说$r$的确定范围。 Deepctr里面默认是3。
|
||||
|
||||
对于实际应用的一些经验:
|
||||
1. SE-FM 在实验数据效果略高于 FFM,优于FM,对于模型处于低阶的团队,升级FM、SE-FM成本比较低
|
||||
|
||||
2. deepSE-FM 效果优于DCN、XDeepFM 这类模型,相当于**XDeepFM这种难上线的模型**来说,很值得尝试,不过大概率怀疑是**增加特征交叉的效果,特征改进比模型改进work起来更稳**
|
||||
3. 实验中增加embeding 长度费力不讨好,效果增加不明显,如果只是增加长度不改变玩法边际效应递减,**不增加长度增加emmbedding 交叉方式类似模型的ensemble更容易有效果**
|
||||
|
||||
|
||||
## FiBiNet模型的代码复现及重要结构解释
|
||||
这里的话,参考deepctr修改的简化版本。
|
||||
### 全貌
|
||||
|
||||
对于输入,就不详细的说了,在xDeepFM那里已经解释了, 首先网络的整体全貌:
|
||||
|
||||
```python
|
||||
def fibinet(linear_feature_columns, dnn_feature_columns, bilinear_type='interaction', reduction_ratio=3, hidden_units=[128, 128]):
|
||||
"""
|
||||
:param linear_feature_columns, dnn_feature_columns: 封装好的wide端和deep端的特征
|
||||
:param bilinear_type: 双线性交互类型, 有'all', 'each', 'interaction'三种
|
||||
:param reduction_ratio: senet里面reduction ratio
|
||||
:param hidden_units: DNN隐藏单元个数
|
||||
"""
|
||||
|
||||
# 构建输出层, 即所有特征对应的Input()层, 用字典的形式返回,方便后续构建模型
|
||||
dense_input_dict, sparse_input_dict = build_input_layers(linear_feature_columns + dnn_feature_columns)
|
||||
|
||||
# 构建模型的输入层,模型的输入层不能是字典的形式,应该将字典的形式转换成列表的形式
|
||||
# 注意:这里实际的输入预Input层对应,是通过模型输入时候的字典数据的key与对应name的Input层
|
||||
input_layers = list(dense_input_dict.values()) + list(sparse_input_dict.values())
|
||||
|
||||
# 线性部分的计算逻辑 -- linear
|
||||
linear_logits = get_linear_logits(dense_input_dict, sparse_input_dict, linear_feature_columns)
|
||||
|
||||
# 构建维度为k的embedding层,这里使用字典的形式返回,方便后面搭建模型
|
||||
# 线性层和dnn层统一的embedding层
|
||||
embedding_layer_dict = build_embedding_layers(linear_feature_columns+dnn_feature_columns, sparse_input_dict, is_linear=False)
|
||||
|
||||
# DNN侧的计算逻辑 -- Deep
|
||||
# 将dnn_feature_columns里面的连续特征筛选出来,并把相应的Input层拼接到一块
|
||||
dnn_dense_feature_columns = list(filter(lambda x: isinstance(x, DenseFeat), dnn_feature_columns)) if dnn_feature_columns else []
|
||||
dnn_dense_feature_columns = [fc.name for fc in dnn_dense_feature_columns]
|
||||
dnn_concat_dense_inputs = Concatenate(axis=1)([dense_input_dict[col] for col in dnn_dense_feature_columns])
|
||||
|
||||
# 将dnn_feature_columns里面的离散特征筛选出来,相应的embedding层拼接到一块,然后过SENet_layer
|
||||
dnn_sparse_kd_embed = concat_embedding_list(dnn_feature_columns, sparse_input_dict, embedding_layer_dict, flatten=False)
|
||||
sparse_embedding_list = Concatenate(axis=1)(dnn_sparse_kd_embed)
|
||||
|
||||
# SENet layer
|
||||
senet_embedding_list = SENETLayer(reduction_ratio)(sparse_embedding_list)
|
||||
|
||||
# 双线性交互层
|
||||
senet_bilinear_out = BilinearInteraction(bilinear_type=bilinear_type)(senet_embedding_list)
|
||||
raw_bilinear_out = BilinearInteraction(bilinear_type=bilinear_type)(sparse_embedding_list)
|
||||
|
||||
bilinear_out = Flatten()(Concatenate(axis=1)([senet_bilinear_out, raw_bilinear_out]))
|
||||
|
||||
# DNN层的输入和输出
|
||||
dnn_input = Concatenate(axis=1)([bilinear_out, dnn_concat_dense_inputs])
|
||||
dnn_out = get_dnn_output(dnn_input, hidden_units=hidden_units)
|
||||
dnn_logits = Dense(1)(dnn_out)
|
||||
|
||||
# 最后的输出
|
||||
final_logits = Add()([linear_logits, dnn_logits])
|
||||
|
||||
# 输出层
|
||||
output_layer = Dense(1, activation='sigmoid')(final_logits)
|
||||
|
||||
model = Model(input_layers, output_layer)
|
||||
|
||||
return model
|
||||
```
|
||||
这里依然是是采用了线性层计算与DNN相结合的方式。 前向传播这里也不详细描述了。这里面重点是SENETLayer和BilinearInteraction层,其他的和之前网络模块基本上一样。
|
||||
|
||||
### SENETLayer
|
||||
这里的输入是`[None, field_num embed_dim]`的维度,也就是离散特征的embedding, 拿到这个输入之后,三个步骤,得到的是一个`[None, feild_num, embed_dim]`的同样维度的矩阵,只不过这里是SKNET-like embedding了。
|
||||
|
||||
```python
|
||||
class SENETLayer(Layer):
|
||||
def __init__(self, reduction_ratio, seed=2021):
|
||||
super(SENETLayer, self).__init__()
|
||||
self.reduction_ratio = reduction_ratio
|
||||
self.seed = seed
|
||||
|
||||
def build(self, input_shape):
|
||||
# input_shape [None, field_nums, embedding_dim]
|
||||
self.field_size = input_shape[1]
|
||||
self.embedding_size = input_shape[-1]
|
||||
|
||||
# 中间层的神经单元个数 f/r
|
||||
reduction_size = max(1, self.field_size // self.reduction_ratio)
|
||||
|
||||
# FC layer1和layer2的参数
|
||||
self.W_1 = self.add_weight(shape=(
|
||||
self.field_size, reduction_size), initializer=glorot_normal(seed=self.seed), name="W_1")
|
||||
self.W_2 = self.add_weight(shape=(
|
||||
reduction_size, self.field_size), initializer=glorot_normal(seed=self.seed), name="W_2")
|
||||
|
||||
self.tensordot = tf.keras.layers.Lambda(
|
||||
lambda x: tf.tensordot(x[0], x[1], axes=(-1, 0)))
|
||||
|
||||
# Be sure to call this somewhere!
|
||||
super(SENETLayer, self).build(input_shape)
|
||||
|
||||
def call(self, inputs):
|
||||
# inputs [None, field_num, embed_dim]
|
||||
|
||||
# Squeeze -> [None, field_num]
|
||||
Z = tf.reduce_mean(inputs, axis=-1)
|
||||
|
||||
# Excitation
|
||||
A_1 = tf.nn.relu(self.tensordot([Z, self.W_1])) # [None, reduction_size]
|
||||
A_2 = tf.nn.relu(self.tensordot([A_1, self.W_2])) # [None, field_num]
|
||||
|
||||
# Re-Weight
|
||||
V = tf.multiply(inputs, tf.expand_dims(A_2, axis=2)) # [None, field_num, embedding_dim]
|
||||
|
||||
return V
|
||||
```
|
||||
三个步骤还是比较好理解的, 这里这种自定义层权重的方式需要学习下。
|
||||
|
||||
### 4.3 BilinearInteraction Layer
|
||||
这里接收的输入同样是`[None, field_num embed_dim]`的维度离散特征的embedding。 输出是来两两交互完毕的矩阵$[None, \frac{f(f-1)}{2}, embed\_dim]$
|
||||
|
||||
这里的双线性交互有三种形式,具体实现的话可以参考下面的代码,我加了注释, 后面两种用到了组合的方式, 感觉人家这种实现方式还是非常巧妙的。
|
||||
```python
|
||||
class BilinearInteraction(Layer):
|
||||
"""BilinearInteraction Layer used in FiBiNET.
|
||||
Input shape
|
||||
- 3D tensor with shape: ``(batch_size,field_size,embedding_size)``.
|
||||
Output shape
|
||||
- 3D tensor with shape: ``(batch_size,filed_size*(filed_size-1)/2,embedding_size)``.
|
||||
"""
|
||||
def __init__(self, bilinear_type="interaction", seed=2021, **kwargs):
|
||||
super(BilinearInteraction, self).__init__(**kwargs)
|
||||
self.bilinear_type = bilinear_type
|
||||
self.seed = seed
|
||||
|
||||
def build(self, input_shape):
|
||||
# input_shape: [None, field_num, embed_num]
|
||||
self.field_size = input_shape[1]
|
||||
self.embedding_size = input_shape[-1]
|
||||
|
||||
if self.bilinear_type == "all": # 所有embedding矩阵共用一个矩阵W
|
||||
self.W = self.add_weight(shape=(self.embedding_size, self.embedding_size), initializer=glorot_normal(
|
||||
seed=self.seed), name="bilinear_weight")
|
||||
elif self.bilinear_type == "each": # 每个field共用一个矩阵W
|
||||
self.W_list = [self.add_weight(shape=(self.embedding_size, self.embedding_size), initializer=glorot_normal(
|
||||
seed=self.seed), name="bilinear_weight" + str(i)) for i in range(self.field_size-1)]
|
||||
elif self.bilinear_type == "interaction": # 每个交互用一个矩阵W
|
||||
self.W_list = [self.add_weight(shape=(self.embedding_size, self.embedding_size), initializer=glorot_normal(
|
||||
seed=self.seed), name="bilinear_weight" + str(i) + '_' + str(j)) for i, j in
|
||||
itertools.combinations(range(self.field_size), 2)]
|
||||
else:
|
||||
raise NotImplementedError
|
||||
|
||||
super(BilinearInteraction, self).build(input_shape) # Be sure to call this somewhere!
|
||||
|
||||
def call(self, inputs):
|
||||
# inputs: [None, field_nums, embed_dims]
|
||||
# 这里把inputs从field_nums处split, 划分成field_nums个embed_dims长向量的列表
|
||||
inputs = tf.split(inputs, self.field_size, axis=1) # [(None, embed_dims), (None, embed_dims), ..]
|
||||
n = len(inputs) # field_nums个
|
||||
|
||||
if self.bilinear_type == "all":
|
||||
# inputs[i] (none, embed_dims) self.W (embed_dims, embed_dims) -> (None, embed_dims)
|
||||
vidots = [tf.tensordot(inputs[i], self.W, axes=(-1, 0)) for i in range(n)] # 点积
|
||||
p = [tf.multiply(vidots[i], inputs[j]) for i, j in itertools.combinations(range(n), 2)] # 哈达玛积
|
||||
elif self.bilinear_type == "each":
|
||||
vidots = [tf.tensordot(inputs[i], self.W_list[i], axes=(-1, 0)) for i in range(n - 1)]
|
||||
# 假设3个域, 则两两组合[(0,1), (0,2), (1,2)] 这里的vidots是第一个维度, inputs是第二个维度 哈达玛积运算
|
||||
p = [tf.multiply(vidots[i], inputs[j]) for i, j in itertools.combinations(range(n), 2)]
|
||||
elif self.bilinear_type == "interaction":
|
||||
# combinations(inputs, 2) 这个得到的是两两向量交互的结果列表
|
||||
# 比如 combinations([[1,2], [3,4], [5,6]], 2)
|
||||
# 得到 [([1, 2], [3, 4]), ([1, 2], [5, 6]), ([3, 4], [5, 6])] (v[0], v[1]) 先v[0]与W点积,然后再和v[1]哈达玛积
|
||||
p = [tf.multiply(tf.tensordot(v[0], w, axes=(-1, 0)), v[1])
|
||||
for v, w in zip(itertools.combinations(inputs, 2), self.W_list)]
|
||||
else:
|
||||
raise NotImplementedError
|
||||
|
||||
output = Concatenate(axis=1)(p)
|
||||
return output
|
||||
```
|
||||
这里第一个是需要学习组合交互的具体实现方式, 人家的代码方式非常巧妙,第二个会是理解下。
|
||||
|
||||
关于FiBiNet网络的代码细节就到这里了,具体代码放到了我的GitHub链接上了。
|
||||
|
||||
## 总结
|
||||
这篇文章主要是整理了一个新模型, 这个模型是在特征重要性选择以及特征交互上做出了新的探索,给了我们两个新思路。 这里面还有两个重要的地方,感觉是作者对于SENET在推荐系统上的使用思考,也就是为啥能把这个东西迁过来,以及为啥双线性会更加细粒度,这种双线性函数的优势在哪儿?我们通常所说的知其然,意思是针对特征交互, 针对特征选择,我又有了两种考虑思路双线性和SENet, 而知其所以然,应该考虑为啥双线性或者SENET会有效呢? 当然在文章中给出了自己的看法,当然这个可能不对哈,是自己对于问题的一种思考, 欢迎伙伴们一块讨论。
|
||||
|
||||
我现在读论文,一般读完了之后,会刻意逼着自己想这么几个问题:
|
||||
>本篇论文核心是讲了个啥东西? 是为啥会提出这么个东西? 为啥这个新东西会有效? 与这个新东西类似的东西还有啥? 在工业上通常会怎么用?
|
||||
|
||||
一般经过这样的灵魂5问就能把整篇论文拎起来了,而读完了这篇文章,你能根据这5问给出相应的答案吗? 欢迎在下方留言呀。
|
||||
|
||||
还有一种读论文的厉害姿势,和张俊林老师学的,就是拉马努金式思维,就是读论文之前,看完题目之后, 不要看正文,先猜测作者在尝试解决什么样的问题,比如
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210703183445412.png#pic_center" alt="image-20210308142624189" style="zoom: 70%;" >
|
||||
</div>
|
||||
|
||||
看到特征重要性和双线性特征交互, 就大体上能猜测到这篇推荐论文讲的应该是和特征选择和特征交互相关的知识。 那么如果是我解决这两方面的话应该怎么解决呢?
|
||||
* 特征选择 --- 联想到Attention
|
||||
* 特征交互 --- 联想到哈达玛积或者内积
|
||||
|
||||
这时候, 就可以读论文了,读完之后, 对比下人家提出的想法和自己的想法的区别,考虑下为啥会有这样的区别? 然后再就是上面的灵魂5问, 通过这样的方式读论文, 能够理解的更加深刻,就不会再有读完很多论文,依然很虚的感觉,啥也没记住了。 如果再能花点时间总结输出下, 和之前的论文做一个对比串联,再花点时间看看代码,复现下,用到自己的任务上。 那么这样, 就算是真正把握住模型背后的思想了,而不是仅仅会个模型而已, 并且这种读论文方式,只要习惯了之后, 读论文会很快,因为我隐约发现,万变不离其宗, 论文里面抛去实验部分,抛去前言部分, 剩下的精华其实没有几页的。当然整理会花费时间, 但也有相应的价值在里面。 我以后整理,也是以经典思路模型为主, 对于一般的,我会放到论文总结的专栏里面,一下子两三篇的那种整理,只整理大体思路就即可啦。
|
||||
|
||||
下面只整理来自工业大佬的使用经验和反思, 具体参考下面的第二篇参考:
|
||||
* 适用的数据集
|
||||
虽然模型是针对点击率预测的场景提出的,但可以尝试的数据场景也不少,比较适合包含大量categorical feature且这些feature cardinality本身很高,或者因为encode method导致的某些feature维度很高且稀疏的情况。推荐系统的场景因为大量的user/item属性都是符合这些要求的,所以效果格外好,但我们也可以举一反三把它推广到其他相似场景。另外,文字描述类的特征(比如人工标注的主观评价,名字,地址信息……)可以用tokenizer处理成int sequence/matrix作为embedding feature喂进模型,丰富的interaction方法可以很好的学习到这些样本中这些特征的相似之处并挖掘出一些潜在的关系。
|
||||
|
||||
* 回归和分类问题都可以做,无非改一下DNN最后一层的activation函数和objective,没有太大的差别。
|
||||
* 如果dense feature比较多而且是分布存在很多异常值的numeric feature,尽量就不要用FiBiNET了,相比大部分NN没有优势不说,SENET那里的一个最大池化极其容易把特征权重带偏,如果一定要上,可能需要修改一下池化的方法。
|
||||
|
||||
* DeepCTR的实现还把指定的linear feature作为类似于WDL中的wide部分直接输入到DNN的最后一层,以及DNN部分也吸收了一部分指定的dnn feature中的dense feature直接作为输入。毫无疑问,DeepCTR作者在尽可能的保留更多的特征作为输入防止信息的丢失。
|
||||
|
||||
|
||||
* 使用Field-Each方式能够达到最好的预测准确率,而且相比默认的Field-Interaction,参数也减少了不少,训练效率更高。当然,三种方式在准确率方面差异不是非常巨大。
|
||||
* reduce ratio设置到8效果最好,这方面我的经验和不少人达成了共识,SENET用于其他学习任务也可以得到相似的结论。 -- 这个试了下,确实有效果
|
||||
* 使用dropout方法扔掉hidden layer里的部分unit效果会更好,系数大约在0.3时最好,原文用的是0.5,请根据具体使用的网络结构和数据集特点自己调整。-- 这个有效果
|
||||
|
||||
* 在双线性部分引入Layer Norm效果可能会更好些
|
||||
|
||||
* 尝试在DNN部分使用残差防止DNN效果过差
|
||||
* 直接取出Bilinear的输出结果然后上XGBoost,也就是说不用它来训练而是作为一种特征embedding操作去使用, 这个方法可能发生leak
|
||||
* 在WDL上的调优经验: 适当调整DNN hideen layer之间的unit数量的减小比例,防止梯度爆炸/消失。
|
||||
|
||||
后记:
|
||||
>fibinet在我自己的任务上也试了下,确实会效果, 采用默认参数的话, 能和xdeepfm跑到同样的水平,而如果再稍微调调参, 就比xdeepfm要好些了。
|
||||
|
||||
**参考**:
|
||||
* [论文原文](https://arxiv.org/pdf/1905.09433.pdf)
|
||||
* [FiBiNET: paper reading + 实践调优经验](https://zhuanlan.zhihu.com/p/79659557)
|
||||
* [FiBiNET:结合特征重要性和双线性特征交互进行CTR预估](https://zhuanlan.zhihu.com/p/72931811)
|
||||
* [FiBiNET(新浪)](https://zhuanlan.zhihu.com/p/92130353)
|
||||
* [FiBiNet 网络介绍与源码浅析](https://zhuanlan.zhihu.com/p/343572144)
|
||||
* [SENET双塔模型及应用](https://mp.weixin.qq.com/s/Y3A8chyJ6ssh4WLJ8HNQqw)
|
||||
|
||||
|
||||
244
2023旧版内容/4.人工智能/ch02/ch2.2/ch2.2.2/PNN.md
Normal file
244
2023旧版内容/4.人工智能/ch02/ch2.2/ch2.2.2/PNN.md
Normal file
@@ -0,0 +1,244 @@
|
||||
# PNN
|
||||
## 动机
|
||||
在特征交叉的相关模型中FM, FFM都证明了特征交叉的重要性,FNN将神经网络的高阶隐式交叉加到了FM的二阶特征交叉上,一定程度上说明了DNN做特征交叉的有效性。但是对于DNN这种“add”操作的特征交叉并不能充分挖掘类别特征的交叉效果。PNN虽然也用了DNN来对特征进行交叉组合,但是并不是直接将低阶特征放入DNN中,而是设计了Product层先对低阶特征进行充分的交叉组合之后再送入到DNN中去。
|
||||
|
||||
PNN模型其实是对IPNN和OPNN的总称,两者分别对应的是不同的Product实现方法,前者采用的是inner product,后者采用的是outer product。在PNN的算法方面,比较重要的部分就是Product Layer的简化实现方法,需要在数学和代码上都能够比较深入的理解。
|
||||
|
||||
## 模型的结构及原理
|
||||
|
||||
> 在学习PNN模型之前,应当对于DNN结构具有一定的了解,同时已经学习过了前面的章节。
|
||||
|
||||
PNN模型的整体架构如下图所示:
|
||||
|
||||
<div align=center> <img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片image-20210308142624189.png" alt="image-20210308142624189" style="zoom: 50%;" /> </div>
|
||||
|
||||
一共分为五层,其中除了Product Layer别的layer都是比较常规的处理方法,均可以从前面的章节进一步了解。模型中最重要的部分就是通过Product层对embedding特征进行交叉组合,也就是上图中红框所显示的部分。
|
||||
|
||||
Product层主要有线性部分和非线性部分组成,分别用$l_z$和$l_p$来表示,
|
||||
|
||||
<div align=center> <img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片image-20210308143101261.png" alt="image-20210308143101261" style="zoom: 50%;" />
|
||||
</div>
|
||||
|
||||
1. 线性模块,一阶特征(未经过显示特征交叉处理),对应论文中的$l_z=(l_z^1,l_z^2, ..., l_z^{D_1})$
|
||||
2. 非线性模块,高阶特征(经过显示特征交叉处理),对应论文中的$l_p=(l_p^1,l_p^2, ..., l_p^{D_1})$
|
||||
|
||||
**线性部分**
|
||||
|
||||
先来解释一下$l_z$是如何计算得到的,在介绍计算$l_z$之前先介绍一下矩阵内积计算, 如下公式所示,用一句话来描述就是两个矩阵对应元素相称,然后将相乘之后的所有元素相加
|
||||
$$
|
||||
A \odot{B} = \sum_{i,j}A_{i,j}B_{i,j}
|
||||
$$
|
||||
$l_z^n$的计算就是矩阵内积,而$l_z$是有$D_1$个$l_z^n$组成,所以需要$D1$个矩阵求得,但是在代码实现的时候不一定是定义$D_1$个矩阵,可以将这些矩阵Flatten,具体的细节可以参考给出的代码。
|
||||
$$
|
||||
l_z=(l_z^1,l_z^2, ..., l_z^{D_1})\\
|
||||
l_z^n = W_z^n \odot{z} \\
|
||||
z = (z_1, z_2, ..., z_N)
|
||||
$$
|
||||
总之这一波操作就是将所有的embedding向量中的所有元素都乘以一个矩阵的对应元素,最后相加即可,这一部分比较简单(N表示的是特征的数量,M表示的是所有特征转化为embedding之后维度,也就是N*emb_dim)
|
||||
$$
|
||||
l_z^n = W_z^n \odot{z} = \sum_{i=1}^N \sum_{j=1}^M (W_z^n)_{i,j}z_{i,j}
|
||||
$$
|
||||
|
||||
### Product Layer
|
||||
**非线性部分**
|
||||
|
||||
上面介绍了线性部分$l_p$的计算,非线性部分的计算相比线性部分要复杂很多,先从整体上看$l_p$的计算
|
||||
$$
|
||||
l_p=(l_p^1,l_p^2, ..., l_p^{D_1}) \\
|
||||
l_p^n = W_p^n \odot{p} \\
|
||||
p = \{p_{i,j}\}, i=1,2,...,N,j=1,2,...,N
|
||||
$$
|
||||
从上述公式中可以发现,$l_p^n$和$l_z^n$类似需要$D_1$个$W_p^n$矩阵计算内积得到,重点就是如何求这个$p$,这里作者提出了两种方式,一种是使用内积计算,另一种是使用外积计算。
|
||||
|
||||
#### IPNN
|
||||
|
||||
使用内积实现特征交叉就和FM是类似的(两两向量计算内积),下面将向量内积操作表示如下表达式
|
||||
$$
|
||||
g(f_i,f_j) = <f_i, f_j>
|
||||
$$
|
||||
将内积的表达式带入$l_p^n$的计算表达式中有:
|
||||
$$
|
||||
\begin{aligned}
|
||||
|
||||
l_p^n &= W_p^n \odot{p} \\
|
||||
&= \sum_{i=1}^N \sum_{j=1}^N (W_p^n)_{i,j}p_{i,j} \\
|
||||
&= \sum_{i=1}^N \sum_{j=1}^N (W_p^n)_{i,j}<f_i, f_j>
|
||||
|
||||
\end{aligned}
|
||||
$$
|
||||
上面就提到了这里使用的内积是计算两两特征之间的内积,然而向量a和向量b的内积与向量b和向量a的内积是相同的,其实是没必要计算的,看一下下面FM的计算公式:
|
||||
$$
|
||||
\hat{y}(X) = \omega_{0}+\sum_{i=1}^{n}{\omega_{i}x_{i}}+\sum_{i=1}^{n}{\sum_{j=i+1}^{n} <v_{i},v_{j}>x_{i}x_{j}}
|
||||
$$
|
||||
也就是说计算的内积矩阵$p$是对称的,那么与其对应元素做矩阵内积的矩阵$W_p^n$也是对称的,对于可学习的权重来说如果是对称的是不是可以只使用其中的一半就行了呢,所以基于这个思考,对Inner Product的权重定义及内积计算进行优化,首先将权重矩阵分解$W_p^n=\theta^n \theta^{nT}$,此时$\theta^n \in R^N$(参数从原来的$N^2$变成了$N$),将分解后的$W_p^n$带入$l_p^n$的计算公式有:
|
||||
$$
|
||||
\begin{aligned}
|
||||
|
||||
l_p^n &= W_p^n \odot{p} \\
|
||||
&= \sum_{i=1}^N \sum_{j=1}^N (W_p^n)_{i,j}p_{i,j} \\
|
||||
&= \sum_{i=1}^N \sum_{j=1}^N \theta^n \theta^n <f_i, f_j> \\
|
||||
&= \sum_{i=1}^N \sum_{j=1}^N <\theta^n f_i, \theta^n f_j> \\
|
||||
&= <\sum_{i=1}^N \theta^n f_i, \sum_{j=1}^N \theta^n f_j> \\
|
||||
&= ||\sum_{i=1}^N \theta^n f_i||^2
|
||||
\end{aligned}
|
||||
$$
|
||||
所以优化后的$l_p$的计算公式为:
|
||||
$$
|
||||
l_p = (||\sum_{i=1}^N \theta^1 f_i||^2, ||\sum_{i=1}^N \theta^2 f_i||^2, ..., ||\sum_{i=1}^N \theta^{D_1} f_i||^2)
|
||||
$$
|
||||
这里为了好理解不做过多的解释,其实这里对于矩阵分解省略了一些细节,感兴趣的可以去看原文,最后模型实现的时候就是基于上面的这个公式计算的(给出的代码也是基于优化之后的实现)。
|
||||
|
||||
#### OPNN
|
||||
|
||||
使用外积实现相比于使用内积实现,唯一的区别就是使用向量的外积来计算矩阵$p$,首先定义向量的外积计算
|
||||
$$
|
||||
g(i,j) = f_i f_j^T
|
||||
$$
|
||||
从外积公式可以发现两个向量的外积得到的是一个矩阵,与上面介绍的内积计算不太相同,内积得到的是一个数值。内积实现的Product层是将计算得到的内积矩阵,乘以一个与其大小一样的权重矩阵,然后求和,按照这个思路的话,通过外积得到的$p$计算$W_p^n \odot{p}$相当于之前的内积值乘以权重矩阵对应位置的值求和就变成了,外积矩阵乘以权重矩阵中对应位置的子矩阵然后将整个相乘得到的大矩阵对应元素相加,用公式表示如下:
|
||||
$$
|
||||
\begin{aligned}
|
||||
l_p^n &= W_p^n \odot{p} \\
|
||||
&= \sum_{i=1}^N \sum_{j=1}^N (W_p^n)_{i,j}p_{i,j} \\
|
||||
&= \sum_{i=1}^N \sum_{j=1}^N (W_p^n)_{i,j} f_i f_j^T
|
||||
\end{aligned}
|
||||
$$
|
||||
需要注意的是此时的$(W_p^n)_{i,j}$表示的是一个矩阵,而不是一个值,此时计算$l_p$的复杂度是$O(D_1*N^2*M^2)$, 其中$N^2$表示的是特征的组合数量,$M^2$表示的是计算外积的复杂度。这样的复杂度肯定是无法接受的,所以为了优化复杂度,PNN的作者重新定义了$p$的计算方式:
|
||||
$$
|
||||
p=\sum_{i=1}^{N} \sum_{j=1}^{N} f_{i} f_{j}^{T}=f_{\Sigma}\left(f_{\Sigma}\right)^{T} \\
|
||||
f_{\Sigma}=\sum_{i=1}^{N} f_{i}
|
||||
$$
|
||||
需要注意,这里新定义的外积计算与传统的外积计算时不等价的,这里是为了优化计算效率重新定义的计算方式,从公式中可以看出,相当于先将原来的embedding向量在特征维度上先求和,变成一个向量之后再计算外积。加入原embedding向量表示为$E \in R^{N\times M}$,其中$N$表示特征的数量,M表示的是所有特征的总维度,即$N*emb\_dim$, 在特征维度上进行求和就是将$E \in R^{N\times M}$矩阵压缩成了$E \in R^M$, 然后两个$M$维的向量计算外积得到最终所有特征的外积交叉结果$p\in R^{M\times M}$,最终的$l_p^n$可以表示为:
|
||||
$$
|
||||
l_p^n = W_p^n \odot{p} = \sum_{i=1}^N \sum_{j=1}^N (W_p^n)_{i,j}p_{i,j} \\
|
||||
$$
|
||||
最终的计算方式和$l_z$的计算方式看起来差不多,但是需要注意外积优化后的$W_p^n$的维度是$R^{M \times M}$的,$M$表示的是特征矩阵的维度,即$N*emb\_dim$。
|
||||
|
||||
> 虽然叠加概念的引入可以降低计算开销,但是中间的精度损失也是很大的,性能与精度之间的tradeoff
|
||||
|
||||
## 代码实现
|
||||
|
||||
代码实现的整体逻辑比较简单,就是对类别特征进行embedding编码,然后通过embedding特征计算$l_z,l_p$, 接着将$l_z, l_p$的输出concat到一起输入到DNN中得到最终的预测结果
|
||||
|
||||
```python
|
||||
def PNN(dnn_feature_columns, inner=True, outer=True):
|
||||
# 构建输入层,即所有特征对应的Input()层,这里使用字典的形式返回,方便后续构建模型
|
||||
_, sparse_input_dict = build_input_layers(dnn_feature_columns)
|
||||
|
||||
# 构建模型的输入层,模型的输入层不能是字典的形式,应该将字典的形式转换成列表的形式
|
||||
# 注意:这里实际的输入与Input()层的对应,是通过模型输入时候的字典数据的key与对应name的Input层
|
||||
input_layers = list(sparse_input_dict.values())
|
||||
|
||||
# 构建维度为k的embedding层,这里使用字典的形式返回,方便后面搭建模型
|
||||
embedding_layer_dict = build_embedding_layers(dnn_feature_columns, sparse_input_dict, is_linear=False)
|
||||
|
||||
sparse_embed_list = concat_embedding_list(dnn_feature_columns, sparse_input_dict, embedding_layer_dict, flatten=False)
|
||||
|
||||
dnn_inputs = ProductLayer(units=32, use_inner=True, use_outer=True)(sparse_embed_list)
|
||||
|
||||
# 输入到dnn中,需要提前定义需要几个残差块
|
||||
output_layer = get_dnn_logits(dnn_inputs)
|
||||
|
||||
model = Model(input_layers, output_layer)
|
||||
return model
|
||||
```
|
||||
|
||||
PNN的难点就是Product层的实现,下面是Product 层实现的代码,代码中是使用优化之后$l_p$的计算方式编写的, 代码中有详细的注释,但是要完全理解代码还需要去理解上述说过的优化思路。
|
||||
|
||||
```python
|
||||
class ProductLayer(Layer):
|
||||
def __init__(self, units, use_inner=True, use_outer=False):
|
||||
super(ProductLayer, self).__init__()
|
||||
self.use_inner = use_inner
|
||||
self.use_outer = use_outer
|
||||
self.units = units # 指的是原文中D1的大小
|
||||
|
||||
def build(self, input_shape):
|
||||
# 需要注意input_shape也是一个列表,并且里面的每一个元素都是TensorShape类型,
|
||||
# 需要将其转换成list然后才能参与数值计算,不然类型容易错
|
||||
# input_shape[0] : feat_nums x embed_dims
|
||||
self.feat_nums = len(input_shape)
|
||||
self.embed_dims = input_shape[0].as_list()[-1]
|
||||
flatten_dims = self.feat_nums * self.embed_dims
|
||||
|
||||
# Linear signals weight, 这部分是用于产生Z的权重,因为这里需要计算的是两个元素对应元素乘积然后再相加
|
||||
# 等价于先把矩阵拉成一维,然后相乘再相加
|
||||
self.linear_w = self.add_weight(name='linear_w', shape=(flatten_dims, self.units), initializer='glorot_normal')
|
||||
|
||||
# inner product weight
|
||||
if self.use_inner:
|
||||
# 优化之后的内积权重是未优化时的一个分解矩阵,未优化时的矩阵大小为:D x N x N
|
||||
# 优化后的内积权重大小为:D x N
|
||||
self.inner_w = self.add_weight(name='inner_w', shape=(self.units, self.feat_nums), initializer='glorot_normal')
|
||||
|
||||
if self.use_outer:
|
||||
# 优化之后的外积权重大小为:D x embed_dim x embed_dim, 因为计算外积的时候在特征维度通过求和的方式进行了压缩
|
||||
self.outer_w = self.add_weight(name='outer_w', shape=(self.units, self.embed_dims, self.embed_dims), initializer='glorot_normal')
|
||||
|
||||
|
||||
def call(self, inputs):
|
||||
# inputs是一个列表
|
||||
# 先将所有的embedding拼接起来计算线性信号部分的输出
|
||||
concat_embed = Concatenate(axis=1)(inputs) # B x feat_nums x embed_dims
|
||||
# 将两个矩阵都拉成二维的,然后通过矩阵相乘得到最终的结果
|
||||
concat_embed_ = tf.reshape(concat_embed, shape=[-1, self.feat_nums * self.embed_dims])
|
||||
lz = tf.matmul(concat_embed_, self.linear_w) # B x units
|
||||
|
||||
# inner
|
||||
lp_list = []
|
||||
if self.use_inner:
|
||||
for i in range(self.units):
|
||||
# 相当于给每一个特征向量都乘以一个权重
|
||||
# self.inner_w[i] : (embed_dims, ) 添加一个维度变成 (embed_dims, 1)
|
||||
# concat_embed: B x feat_nums x embed_dims; delta = B x feat_nums x embed_dims
|
||||
delta = tf.multiply(concat_embed, tf.expand_dims(self.inner_w[i], axis=1))
|
||||
# 在特征之间的维度上求和
|
||||
delta = tf.reduce_sum(delta, axis=1) # B x embed_dims
|
||||
# 最终在特征embedding维度上求二范数得到p
|
||||
lp_list.append(tf.reduce_sum(tf.square(delta), axis=1, keepdims=True)) # B x 1
|
||||
|
||||
# outer
|
||||
if self.use_outer:
|
||||
# 外积的优化是将embedding矩阵,在特征间的维度上通过求和进行压缩
|
||||
feat_sum = tf.reduce_sum(concat_embed, axis=1) # B x embed_dims
|
||||
|
||||
# 为了方便计算外积,将维度进行扩展
|
||||
f1 = tf.expand_dims(feat_sum, axis=2) # B x embed_dims x 1
|
||||
f2 = tf.expand_dims(feat_sum, axis=1) # B x 1 x embed_dims
|
||||
|
||||
# 求外积, a * a^T
|
||||
product = tf.matmul(f1, f2) # B x embed_dims x embed_dims
|
||||
|
||||
# 将product与外积权重矩阵对应元素相乘再相加
|
||||
for i in range(self.units):
|
||||
lpi = tf.multiply(product, self.outer_w[i]) # B x embed_dims x embed_dims
|
||||
# 将后面两个维度进行求和,需要注意的是,每使用一次reduce_sum就会减少一个维度
|
||||
lpi = tf.reduce_sum(lpi, axis=[1, 2]) # B
|
||||
# 添加一个维度便于特征拼接
|
||||
lpi = tf.expand_dims(lpi, axis=1) # B x 1
|
||||
lp_list.append(lpi)
|
||||
|
||||
# 将所有交叉特征拼接到一起
|
||||
lp = Concatenate(axis=1)(lp_list)
|
||||
|
||||
# 将lz和lp拼接到一起
|
||||
product_out = Concatenate(axis=1)([lz, lp])
|
||||
|
||||
return product_out
|
||||
```
|
||||
|
||||
因为这个模型的整体实现框架比较简单,就不画实现的草图了,直接看模型搭建的函数即可,对于PNN重点需要理解Product的两种类型及不同的优化方式。
|
||||
|
||||
下面是一个通过keras画的模型结构图,为了更好的显示,类别特征都只是选择了一小部分,画图的代码也在github中。
|
||||
|
||||
<div align=center> <img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片PNN.png" alt="image-20210308143101261" style="zoom: 50%;" />
|
||||
</div>
|
||||
|
||||
## 思考题
|
||||
|
||||
1. 降低复杂度的具体策略与具体的product函数选择有关,IPNN其实通过矩阵分解,“跳过”了显式的product层,而OPNN则是直接在product层入手进行优化。看原文去理解优化的动机及细节。
|
||||
|
||||
|
||||
**参考文献**
|
||||
- [PNN原文论文](https://arxiv.org/pdf/1611.00144.pdf)
|
||||
- [推荐系统系列(四):PNN理论与实践](https://zhuanlan.zhihu.com/p/89850560)
|
||||
- [deepctr](https://github.com/shenweichen/DeepCTR)
|
||||
127
2023旧版内容/4.人工智能/ch02/ch2.2/ch2.2.3/AFM.md
Normal file
127
2023旧版内容/4.人工智能/ch02/ch2.2/ch2.2.3/AFM.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# AFM
|
||||
## AFM提出的动机
|
||||
|
||||
AFM的全称是Attentional Factorization Machines, 从模型的名称上来看是在FM的基础上加上了注意力机制,FM是通过特征隐向量的内积来对交叉特征进行建模,从公式中可以看出所有的交叉特征都具有相同的权重也就是1,没有考虑到不同的交叉特征的重要性程度:
|
||||
$$
|
||||
y_{fm} = w_0+\sum_{i=1}^nw_ix_i+\sum_{i=1}^{n}\sum_{i+1}^n\lt v_i,v_j\gt x_ix_j
|
||||
$$
|
||||
如何让不同的交叉特征具有不同的重要性就是AFM核心的贡献,在谈论AFM交叉特征注意力之前,对于FM交叉特征部分的改进还有FFM,其是考虑到了对于不同的其他特征,某个指定特征的隐向量应该是不同的(相比于FM对于所有的特征只有一个隐向量,FFM对于一个特征有多个不同的隐向量)。
|
||||
|
||||
## AFM模型原理
|
||||
<div align=center>
|
||||
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片image-20210131092744905.png" alt="image-20210131092744905" style="zoom: 50%;" />
|
||||
</div>
|
||||
上图表示的就是AFM交叉特征部分的模型结构(非交叉部分与FM是一样的,图中并没有给出)。AFM最核心的两个点分别是Pair-wise Interaction Layer和Attention-based Pooling。前者将输入的非零特征的隐向量两两计算element-wise product(哈达玛积,两个向量对应元素相乘,得到的还是一个向量),假如输入的特征中的非零向量的数量为m,那么经过Pair-wise Interaction Layer之后输出的就是$\frac{m(m-1)}{2}$个向量,再将前面得到的交叉特征向量组输入到Attention-based Pooling,该pooling层会先计算出每个特征组合的自适应权重(通过Attention Net进行计算),通过加权求和的方式将向量组压缩成一个向量,由于最终需要输出的是一个数值,所以还需要将前一步得到的向量通过另外一个向量将其映射成一个值,得到最终的基于注意力加权的二阶交叉特征的输出。(对于这部分如果不是很清楚,可以先看下面对两个核心层的介绍)
|
||||
|
||||
### Pair-wise Interaction Layer
|
||||
|
||||
FM二阶交叉项:所有非零特征对应的隐向量两两点积再求和,输出的是一个数值
|
||||
$$
|
||||
\sum_{i=1}^{n}\sum_{i+1}^n\lt v_i,v_j\gt x_ix_j
|
||||
$$
|
||||
AFM二阶交叉项(无attention):所有非零特征对应的隐向量两两对应元素乘积,然后再向量求和,输出的还是一个向量。
|
||||
$$
|
||||
\sum_{i=1}^{n}\sum_{i+1}^n (v_i \odot v_j) x_ix_j
|
||||
$$
|
||||
上述写法是为了更好的与FM进行对比,下面将公式变形方便与原论文中保持一致。首先是特征的隐向量。从上图中可以看出,作者对数值特征也对应了一个隐向量,不同的数值乘以对应的隐向量就可以得到不同的隐向量,相对于onehot编码的特征乘以1还是其本身(并没有什么变化),其实就是为了将公式进行统一。虽然论文中给出了对数值特征定义隐向量,但是在作者的代码中并没有发现有对数值特征进行embedding的过程([原论文代码链接](https://github.com/hexiangnan/attentional_factorization_machine/blob/master/code/AFM.py))具体原因不详。
|
||||
|
||||
按照论文的意思,特征的embedding可以表示为:$\varepsilon = {v_ix_i}$,经过Pair-wise Interaction Layer输出可得:
|
||||
$$
|
||||
f_{PI}(\varepsilon)=\{(v_i \odot v_j) x_ix_j\}_{i,j \in R_x}
|
||||
$$
|
||||
$R_x$表示的是有效特征集合。此时的$f_{PI}(\varepsilon)$表示的是一个向量集合,所以需要先将这些向量集合聚合成一个向量,然后在转换成一个数值:
|
||||
$$
|
||||
\hat{y} = p^T \sum_{(i,j)\in R_x}(v_i \odot v_j) x_ix_j + b
|
||||
$$
|
||||
上式中的求和部分就是将向量集合聚合成一个维度与隐向量维度相同的向量,通过向量$p$再将其转换成一个数值,b表示的是偏置。
|
||||
|
||||
从开始介绍Pair-wise Interaction Layer到现在解决的一个问题是,如何将使用哈达玛积得到的交叉特征转换成一个最终输出需要的数值,到目前为止交叉特征之间的注意力权重还没有出现。在没有详细介绍注意力之前先感性的认识一下如果现在已经有了每个交叉特征的注意力权重,那么交叉特征的输出可以表示为:
|
||||
$$
|
||||
\hat{y} = p^T \sum_{(i,j)\in R_x}\alpha_{ij}(v_i \odot v_j) x_ix_j + b
|
||||
$$
|
||||
就是在交叉特征得到的新向量前面乘以一个注意力权重$\alpha_{ij}$, 那么这个注意力权重如何计算得到呢?
|
||||
|
||||
### Attention-based Pooling
|
||||
|
||||
对于神经网络注意力相关的基础知识大家可以去看一下邱锡鹏老师的《神经网络与深度学习》第8章注意力机制与外部记忆。这里简单的叙述一下使用MLP实现注意力机制的计算。假设现在有n个交叉特征(假如维度是k),将nxk的数据输入到一个kx1的全连接网络中,输出的张量维度为nx1,使用softmax函数将nx1的向量的每个维度进行归一化,得到一个新的nx1的向量,这个向量所有维度加起来的和为1,每个维度上的值就可以表示原nxk数据每一行(即1xk的数据)的权重。用公式表示为:
|
||||
$$
|
||||
\alpha_{ij}' = h^T ReLU(W(v_i \odot v_j)x_ix_j + b)
|
||||
$$
|
||||
使用softmax归一化可得:
|
||||
$$
|
||||
\alpha_{ij} = \frac{exp(\alpha_{ij}')}{\sum_{(i,j)\in R_x}exp(\alpha_{ij}')}
|
||||
$$
|
||||
这样就得到了AFM二阶交叉部分的注意力权重,如果将AFM的一阶项写在一起,AFM模型用公式表示为:
|
||||
$$
|
||||
\hat{y}_{afm}(x) = w_0+\sum_{i=1}^nw_ix_i+p^T \sum_{(i,j)\in R_x}\alpha_{ij}(v_i \odot v_j) x_ix_j + b
|
||||
$$
|
||||
### AFM模型训练
|
||||
|
||||
AFM从最终的模型公式可以看出与FM的模型公式是非常相似的,所以也可以和FM一样应用于不同的任务,例如分类、回归及排序(不同的任务的损失函数是不一样的),AFM也有对防止过拟合进行处理:
|
||||
|
||||
1. 在Pair-wise Interaction Layer层的输出结果上使用dropout防止过拟合,因为并不是所有的特征组合对预测结果都有用,所以随机的去除一些交叉特征,让剩下的特征去自适应的学习可以更好的防止过拟合。
|
||||
2. 对Attention-based Pooling层中的权重矩阵$W$使用L2正则,作者没有在这一层使用dropout的原因是发现同时在特征交叉层和注意力层加dropout会使得模型训练不稳定,并且性能还会下降。
|
||||
|
||||
加上正则参数之后的回归任务的损失函数表示为:
|
||||
$$
|
||||
L = \sum_{x\in T} (\hat{y}_{afm}(x) - y(x))^2 + \lambda ||W||^2
|
||||
$$
|
||||
## AFM代码实现
|
||||
|
||||
1. linear part: 这部分是有关于线性计算,也就是FM的前半部分$w1x1+w2x2...wnxn+b$的计算。对于这一块的计算,我们用了一个get_linear_logits函数实现,后面再说,总之通过这个函数,我们就可以实现上面这个公式的计算过程,得到linear的输出
|
||||
2. dnn part: 这部分是后面交叉特征的那部分计算,这一部分需要使用注意力机制来将所有类别特征的embedding计算注意力权重,然后通过加权求和的方式将所有交叉之后的特征池化成一个向量,最终通过一个映射矩阵$p$将向量转化成一个logits值
|
||||
3. 最终将linear部分与dnn部分相加之后,通过sigmoid激活得到最终的输出
|
||||
|
||||
```python
|
||||
def AFM(linear_feature_columns, dnn_feature_columns):
|
||||
# 构建输入层,即所有特征对应的Input()层,这里使用字典的形式返回,方便后续构建模型
|
||||
dense_input_dict, sparse_input_dict = build_input_layers(linear_feature_columns + dnn_feature_columns)
|
||||
|
||||
# 将linear部分的特征中sparse特征筛选出来,后面用来做1维的embedding
|
||||
linear_sparse_feature_columns = list(filter(lambda x: isinstance(x, SparseFeat), linear_feature_columns))
|
||||
|
||||
# 构建模型的输入层,模型的输入层不能是字典的形式,应该将字典的形式转换成列表的形式
|
||||
# 注意:这里实际的输入与Input()层的对应,是通过模型输入时候的字典数据的key与对应name的Input层
|
||||
input_layers = list(dense_input_dict.values()) + list(sparse_input_dict.values())
|
||||
|
||||
# linear_logits由两部分组成,分别是dense特征的logits和sparse特征的logits
|
||||
linear_logits = get_linear_logits(dense_input_dict, sparse_input_dict, linear_sparse_feature_columns)
|
||||
|
||||
# 构建维度为k的embedding层,这里使用字典的形式返回,方便后面搭建模型
|
||||
# embedding层用户构建FM交叉部分和DNN的输入部分
|
||||
embedding_layers = build_embedding_layers(dnn_feature_columns, sparse_input_dict, is_linear=False)
|
||||
|
||||
# 将输入到dnn中的sparse特征筛选出来
|
||||
att_sparse_feature_columns = list(filter(lambda x: isinstance(x, SparseFeat), dnn_feature_columns))
|
||||
|
||||
att_logits = get_attention_logits(sparse_input_dict, att_sparse_feature_columns, embedding_layers) # B x (n(n-1)/2)
|
||||
|
||||
# 将linear,dnn的logits相加作为最终的logits
|
||||
output_logits = Add()([linear_logits, att_logits])
|
||||
|
||||
# 这里的激活函数使用sigmoid
|
||||
output_layers = Activation("sigmoid")(output_logits)
|
||||
|
||||
model = Model(input_layers, output_layers)
|
||||
return model
|
||||
```
|
||||
|
||||
关于每一块的细节,这里就不解释了,在我们给出的GitHub代码中,我们已经加了非常详细的注释,大家看那个应该很容易看明白, 为了方便大家的阅读,我们这里还给大家画了一个整体的模型架构图,帮助大家更好的了解每一块以及前向传播(画的图不是很规范,先将就看一下,后面我们会统一在优化一下这个手工图)。
|
||||
|
||||
<div align=center>
|
||||
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片image-20210307200304199.png" alt="image-20210307200304199" style="zoom:67%;" />
|
||||
</div>
|
||||
|
||||
下面是一个通过keras画的模型结构图,为了更好的显示,数值特征和类别特征都只是选择了一小部分,画图的代码也在github中。
|
||||
|
||||
<div align=center>
|
||||
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片AFM.png" alt="image-20210307200304199" style="zoom:67%;" />
|
||||
</div>
|
||||
|
||||
## 思考
|
||||
1. AFM与NFM优缺点对比。
|
||||
|
||||
|
||||
**参考资料**
|
||||
[原论文](https://www.ijcai.org/Proceedings/2017/0435.pdf)
|
||||
[deepctr](https://github.com/shenweichen/DeepCTR)
|
||||
156
2023旧版内容/4.人工智能/ch02/ch2.2/ch2.2.3/DeepFM.md
Normal file
156
2023旧版内容/4.人工智能/ch02/ch2.2/ch2.2.3/DeepFM.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# DeepFM
|
||||
## 动机
|
||||
对于CTR问题,被证明的最有效的提升任务表现的策略是特征组合(Feature Interaction), 在CTR问题的探究历史上来看就是如何更好地学习特征组合,进而更加精确地描述数据的特点。可以说这是基础推荐模型到深度学习推荐模型遵循的一个主要的思想。而组合特征大牛们研究过组合二阶特征,三阶甚至更高阶,但是面临一个问题就是随着阶数的提升,复杂度就成几何倍的升高。这样即使模型的表现更好了,但是推荐系统在实时性的要求也不能满足了。所以很多模型的出现都是为了解决另外一个更加深入的问题:如何更高效的学习特征组合?
|
||||
|
||||
为了解决上述问题,出现了FM和FFM来优化LR的特征组合较差这一个问题。并且在这个时候科学家们已经发现了DNN在特征组合方面的优势,所以又出现了FNN和PNN等使用深度网络的模型。但是DNN也存在局限性。
|
||||
|
||||
- **DNN局限**
|
||||
当我们使用DNN网络解决推荐问题的时候存在网络参数过于庞大的问题,这是因为在进行特征处理的时候我们需要使用one-hot编码来处理离散特征,这会导致输入的维度猛增。这里借用AI大会的一张图片:
|
||||
<div align=center>
|
||||
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片2021-02-22-10-11-15.png" style="zoom: 50%;" />
|
||||
</div>
|
||||
|
||||
这样庞大的参数量也是不实际的。为了解决DNN参数量过大的局限性,可以采用非常经典的Field思想,将OneHot特征转换为Dense Vector
|
||||
<div align=center>
|
||||
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片2021-02-22-10-11-40.png" style="zoom: 50%;" />
|
||||
</div>
|
||||
|
||||
此时通过增加全连接层就可以实现高阶的特征组合,如下图所示:
|
||||
<div align=center>
|
||||
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片2021-02-22-10-11-59.png" style="zoom:67%;" />
|
||||
</div>
|
||||
但是仍然缺少低阶的特征组合,于是增加FM来表示低阶的特征组合。
|
||||
|
||||
- **FNN和PNN**
|
||||
结合FM和DNN其实有两种方式,可以并行结合也可以串行结合。这两种方式各有几种代表模型。在DeepFM之前有FNN,虽然在影响力上可能并不如DeepFM,但是了解FNN的思想对我们理解DeepFM的特点和优点是很有帮助的。
|
||||
|
||||
<div align=center>
|
||||
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片2021-02-22-10-12-19.png" style="zoom:50%;" />
|
||||
</div>
|
||||
|
||||
FNN是使用预训练好的FM模块,得到隐向量,然后把隐向量作为DNN的输入,但是经过实验进一步发现,在Embedding layer和hidden layer1之间增加一个product层(如上图所示)可以提高模型的表现,所以提出了PNN,使用product layer替换FM预训练层。
|
||||
|
||||
- **Wide&Deep**
|
||||
FNN和PNN模型仍然有一个比较明显的尚未解决的缺点:对于低阶组合特征学习到的比较少,这一点主要是由于FM和DNN的串行方式导致的,也就是虽然FM学到了低阶特征组合,但是DNN的全连接结构导致低阶特征并不能在DNN的输出端较好的表现。看来我们已经找到问题了,将串行方式改进为并行方式能比较好的解决这个问题。于是Google提出了Wide&Deep模型(将前几章),但是如果深入探究Wide&Deep的构成方式,虽然将整个模型的结构调整为了并行结构,在实际的使用中Wide Module中的部分需要较为精巧的特征工程,换句话说人工处理对于模型的效果具有比较大的影响(这一点可以在Wide&Deep模型部分得到验证)。
|
||||
<div align=center>
|
||||
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/Javaimage-20200910214310877.png" alt="image-20200910214310877" style="zoom:65%;" />
|
||||
</div>
|
||||
如上图所示,该模型仍然存在问题:**在output Units阶段直接将低阶和高阶特征进行组合,很容易让模型最终偏向学习到低阶或者高阶的特征,而不能做到很好的结合。**
|
||||
|
||||
综上所示,DeepFM模型横空出世。
|
||||
|
||||
## 模型的结构与原理
|
||||
<div align=center>
|
||||
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片image-20210225180556628.png" alt="image-20210225180556628" style="zoom:50%;" />
|
||||
</div>
|
||||
|
||||
前面的Field和Embedding处理是和前面的方法是相同的,如上图中的绿色部分;DeepFM将Wide部分替换为了FM layer如上图中的蓝色部分
|
||||
|
||||
这幅图其实有很多的点需要注意,很多人都一眼略过了,这里我个人认为在DeepFM模型中有三点需要注意:
|
||||
|
||||
- **Deep模型部分**
|
||||
- **FM模型部分**
|
||||
- **Sparse Feature中黄色和灰色节点代表什么意思**
|
||||
|
||||
### FM
|
||||
详细内容参考FM模型部分的内容,下图是FM的一个结构图,从图中大致可以看出FM Layer是由一阶特征和二阶特征Concatenate到一起在经过一个Sigmoid得到logits(结合FM的公式一起看),所以在实现的时候需要单独考虑linear部分和FM交叉特征部分。
|
||||
$$
|
||||
\hat{y}_{FM}(x) = w_0+\sum_{i=1}^N w_ix_i + \sum_{i=1}^N \sum_{j=i+1}^N v_i^T v_j x_ix_j
|
||||
$$
|
||||
<div align=center>
|
||||
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片image-20210225181340313.png" alt="image-20210225181340313" style="zoom: 67%;" />
|
||||
</div>
|
||||
### Deep
|
||||
Deep架构图
|
||||
<div align=center>
|
||||
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片image-20210225181010107.png" alt="image-20210225181010107" style="zoom:50%;" />
|
||||
</div>
|
||||
Deep Module是为了学习高阶的特征组合,在上图中使用用全连接的方式将Dense Embedding输入到Hidden Layer,这里面Dense Embeddings就是为了解决DNN中的参数爆炸问题,这也是推荐模型中常用的处理方法。
|
||||
|
||||
Embedding层的输出是将所有id类特征对应的embedding向量concat到到一起输入到DNN中。其中$v_i$表示第i个field的embedding,m是field的数量。
|
||||
$$
|
||||
z_1=[v_1, v_2, ..., v_m]
|
||||
$$
|
||||
上一层的输出作为下一层的输入,我们得到:
|
||||
$$
|
||||
z_L=\sigma(W_{L-1} z_{L-1}+b_{L-1})
|
||||
$$
|
||||
其中$\sigma$表示激活函数,$z, W, b $分别表示该层的输入、权重和偏置。
|
||||
|
||||
最后进入DNN部分输出使用sigmod激活函数进行激活:
|
||||
$$
|
||||
y_{DNN}=\sigma(W^{L}a^L+b^L)
|
||||
$$
|
||||
|
||||
|
||||
## 代码实现
|
||||
DeepFM在模型的结构图中显示,模型大致由两部分组成,一部分是FM,还有一部分就是DNN, 而FM又由一阶特征部分与二阶特征交叉部分组成,所以可以将整个模型拆成三部分,分别是一阶特征处理linear部分,二阶特征交叉FM以及DNN的高阶特征交叉。在下面的代码中也能够清晰的看到这个结构。此外每一部分可能由是由不同的特征组成,所以在构建模型的时候需要分别对这三部分输入的特征进行选择。
|
||||
|
||||
- linear_logits: 这部分是有关于线性计算,也就是FM的前半部分$w1x1+w2x2...wnxn+b$的计算。对于这一块的计算,我们用了一个get_linear_logits函数实现,后面再说,总之通过这个函数,我们就可以实现上面这个公式的计算过程,得到linear的输出, 这部分特征由数值特征和类别特征的onehot编码组成的一维向量组成,实际应用中根据自己的业务放置不同的一阶特征(这里的dense特征并不是必须的,有可能会将数值特征进行分桶,然后在当做类别特征来处理)
|
||||
- fm_logits: 这一块主要是针对离散的特征,首先过embedding,然后使用FM特征交叉的方式,两两特征进行交叉,得到新的特征向量,最后计算交叉特征的logits
|
||||
- dnn_logits: 这一块主要是针对离散的特征,首先过embedding,然后将得到的embedding拼接成一个向量(具体的可以看代码,也可以看一下下面的模型结构图),通过dnn学习类别特征之间的隐式特征交叉并输出logits值
|
||||
|
||||
```python
|
||||
def DeepFM(linear_feature_columns, dnn_feature_columns):
|
||||
# 构建输入层,即所有特征对应的Input()层,这里使用字典的形式返回,方便后续构建模型
|
||||
dense_input_dict, sparse_input_dict = build_input_layers(linear_feature_columns + dnn_feature_columns)
|
||||
|
||||
# 将linear部分的特征中sparse特征筛选出来,后面用来做1维的embedding
|
||||
linear_sparse_feature_columns = list(filter(lambda x: isinstance(x, SparseFeat), linear_feature_columns))
|
||||
|
||||
# 构建模型的输入层,模型的输入层不能是字典的形式,应该将字典的形式转换成列表的形式
|
||||
# 注意:这里实际的输入与Input()层的对应,是通过模型输入时候的字典数据的key与对应name的Input层
|
||||
input_layers = list(dense_input_dict.values()) + list(sparse_input_dict.values())
|
||||
|
||||
# linear_logits由两部分组成,分别是dense特征的logits和sparse特征的logits
|
||||
linear_logits = get_linear_logits(dense_input_dict, sparse_input_dict, linear_sparse_feature_columns)
|
||||
|
||||
# 构建维度为k的embedding层,这里使用字典的形式返回,方便后面搭建模型
|
||||
# embedding层用户构建FM交叉部分和DNN的输入部分
|
||||
embedding_layers = build_embedding_layers(dnn_feature_columns, sparse_input_dict, is_linear=False)
|
||||
|
||||
# 将输入到dnn中的所有sparse特征筛选出来
|
||||
dnn_sparse_feature_columns = list(filter(lambda x: isinstance(x, SparseFeat), dnn_feature_columns))
|
||||
|
||||
fm_logits = get_fm_logits(sparse_input_dict, dnn_sparse_feature_columns, embedding_layers) # 只考虑二阶项
|
||||
|
||||
# 将所有的Embedding都拼起来,一起输入到dnn中
|
||||
dnn_logits = get_dnn_logits(sparse_input_dict, dnn_sparse_feature_columns, embedding_layers)
|
||||
|
||||
# 将linear,FM,dnn的logits相加作为最终的logits
|
||||
output_logits = Add()([linear_logits, fm_logits, dnn_logits])
|
||||
|
||||
# 这里的激活函数使用sigmoid
|
||||
output_layers = Activation("sigmoid")(output_logits)
|
||||
|
||||
model = Model(input_layers, output_layers)
|
||||
return model
|
||||
```
|
||||
|
||||
关于每一块的细节,这里就不解释了,在我们给出的GitHub代码中,我们已经加了非常详细的注释,大家看那个应该很容易看明白, 为了方便大家的阅读,我们这里还给大家画了一个整体的模型架构图,帮助大家更好的了解每一块以及前向传播(画的图不是很规范,先将就看一下,后面我们会统一在优化一下这个手工图)。
|
||||
|
||||
<div align=center>
|
||||
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片image-20210228161135777.png" alt="image-20210228161135777" />
|
||||
</div>
|
||||
|
||||
下面是一个通过keras画的模型结构图,为了更好的显示,数值特征和类别特征都只是选择了一小部分,画图的代码也在github中。
|
||||
|
||||
<div align=center>
|
||||
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片DeepFM.png" alt="image-20210225180556628" style="zoom:50%;" />
|
||||
</div>
|
||||
|
||||
## 思考
|
||||
1. 如果对于FM采用随机梯度下降SGD训练模型参数,请写出模型各个参数的梯度和FM参数训练的复杂度
|
||||
2. 对于下图所示,根据你的理解Sparse Feature中的不同颜色节点分别表示什么意思
|
||||
|
||||
<div align=center>
|
||||
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片image-20210225180556628.png" alt="image-20210225180556628" style="zoom:50%;" />
|
||||
</div>
|
||||
|
||||
|
||||
**参考资料**
|
||||
- [论文原文](https://arxiv.org/pdf/1703.04247.pdf)
|
||||
- [deepctr](https://github.com/shenweichen/DeepCTR)
|
||||
- [FM](https://github.com/datawhalechina/fun-rec/blob/master/docs/ch02/ch2.1/ch2.1.2/FM.md)
|
||||
- [推荐系统遇上深度学习(三)--DeepFM模型理论和实践](https://www.jianshu.com/p/6f1c2643d31b)
|
||||
- [FM算法公式推导](https://blog.csdn.net/qq_32486393/article/details/103498519)
|
||||
146
2023旧版内容/4.人工智能/ch02/ch2.2/ch2.2.3/NFM.md
Normal file
146
2023旧版内容/4.人工智能/ch02/ch2.2/ch2.2.3/NFM.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# NFM
|
||||
## 动机
|
||||
|
||||
NFM(Neural Factorization Machines)是2017年由新加坡国立大学的何向南教授等人在SIGIR会议上提出的一个模型,传统的FM模型仅局限于线性表达和二阶交互, 无法胜任生活中各种具有复杂结构和规律性的真实数据, 针对FM的这点不足, 作者提出了一种将FM融合进DNN的策略,通过引进了一个特征交叉池化层的结构,使得FM与DNN进行了完美衔接,这样就组合了FM的建模低阶特征交互能力和DNN学习高阶特征交互和非线性的能力,形成了深度学习时代的神经FM模型(NFM)。
|
||||
|
||||
那么NFM具体是怎么做的呢? 首先看一下NFM的公式:
|
||||
$$
|
||||
\hat{y}_{N F M}(\mathbf{x})=w_{0}+\sum_{i=1}^{n} w_{i} x_{i}+f(\mathbf{x})
|
||||
$$
|
||||
我们对比FM, 就会发现变化的是第三项,前两项还是原来的, 因为我们说FM的一个问题,就是只能到二阶交叉, 且是线性模型, 这是他本身的一个局限性, 而如果想突破这个局限性, 就需要从他的公式本身下点功夫, 于是乎,作者在这里改进的思路就是**用一个表达能力更强的函数来替代原FM中二阶隐向量内积的部分**。
|
||||
|
||||
<div align=center>
|
||||
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片1.png" style="zoom:70%;" />
|
||||
</div>
|
||||
而这个表达能力更强的函数呢, 我们很容易就可以想到神经网络来充当,因为神经网络理论上可以拟合任何复杂能力的函数, 所以作者真的就把这个$f(x)$换成了一个神经网络,当然不是一个简单的DNN, 而是依然底层考虑了交叉,然后高层使用的DNN网络, 这个也就是我们最终的NFM网络了:
|
||||
<div align=center>
|
||||
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片2.png" style="zoom:80%;" />
|
||||
</div>
|
||||
这个结构,如果前面看过了PNN的伙伴会发现,这个结构和PNN非常像,只不过那里是一个product_layer, 而这里换成了Bi-Interaction Pooling了, 这个也是NFM的核心结构了。这里注意, 这个结构中,忽略了一阶部分,只可视化出来了$f(x)$, 我们还是下面从底层一点点的对这个网络进行剖析。
|
||||
|
||||
## 模型结构与原理
|
||||
### Input 和Embedding层
|
||||
输入层的特征, 文章指定了稀疏离散特征居多, 这种特征我们也知道一般是先one-hot, 然后会通过embedding,处理成稠密低维的。 所以这两层还是和之前一样,假设$\mathbf{v}_{\mathbf{i}} \in \mathbb{R}^{k}$为第$i$个特征的embedding向量, 那么$\mathcal{V}_{x}=\left\{x_{1} \mathbf{v}_{1}, \ldots, x_{n} \mathbf{v}_{n}\right\}$表示的下一层的输入特征。这里带上了$x_i$是因为很多$x_i$转成了One-hot之后,出现很多为0的, 这里的$\{x_iv_i\}$是$x_i$不等于0的那些特征向量。
|
||||
|
||||
### Bi-Interaction Pooling layer
|
||||
在Embedding层和神经网络之间加入了特征交叉池化层是本网络的核心创新了,正是因为这个结构,实现了FM与DNN的无缝连接, 组成了一个大的网络,且能够正常的反向传播。假设$\mathcal{V}_{x}$是所有特征embedding的集合, 那么在特征交叉池化层的操作:
|
||||
$$
|
||||
f_{B I}\left(\mathcal{V}_{x}\right)=\sum_{i=1}^{n} \sum_{j=i+1}^{n} x_{i} \mathbf{v}_{i} \odot x_{j} \mathbf{v}_{j}
|
||||
$$
|
||||
|
||||
$\odot$表示两个向量的元素积操作,即两个向量对应维度相乘得到的元素积向量(可不是点乘呀),其中第$k$维的操作:
|
||||
$$
|
||||
\left(v_{i} \odot v_{j}\right)_{k}=\boldsymbol{v}_{i k} \boldsymbol{v}_{j k}
|
||||
$$
|
||||
|
||||
这便定义了在embedding空间特征的二阶交互,这个不仔细看会和感觉FM的最后一项很像,但是不一样,一定要注意这个地方不是两个隐向量的内积,而是元素积,也就是这一个交叉完了之后k个维度不求和,最后会得到一个$k$维向量,而FM那里内积的话最后得到一个数, 在进行两两Embedding元素积之后,对交叉特征向量取和, 得到该层的输出向量, 很显然, 输出是一个$k$维的向量。
|
||||
|
||||
注意, 之前的FM到这里其实就完事了, 上面就是输出了,而这里很大的一点改进就是加入特征池化层之后, 把二阶交互的信息合并, 且上面接了一个DNN网络, 这样就能够增强FM的表达能力了, 因为FM只能到二阶, 而这里的DNN可以进行多阶且非线性,只要FM把二阶的学习好了, DNN这块学习来会更加容易, 作者在论文中也说明了这一点,且通过后面的实验证实了这个观点。
|
||||
|
||||
如果不加DNN, NFM就退化成了FM,所以改进的关键就在于加了一个这样的层,组合了一下二阶交叉的信息,然后又给了DNN进行高阶交叉的学习,成了一种“加强版”的FM。
|
||||
|
||||
Bi-Interaction层不需要额外的模型学习参数,更重要的是它在一个线性的时间内完成计算,和FM一致的,即时间复杂度为$O\left(k N_{x}\right)$,$N_x$为embedding向量的数量。参考FM,可以将上式转化为:
|
||||
$$
|
||||
f_{B I}\left(\mathcal{V}_{x}\right)=\frac{1}{2}\left[\left(\sum_{i=1}^{n} x_{i} \mathbf{v}_{i}\right)^{2}-\sum_{i=1}^{n}\left(x_{i} \mathbf{v}_{i}\right)^{2}\right]
|
||||
$$
|
||||
后面代码复现NFM就是用的这个公式直接计算,比较简便且清晰。
|
||||
|
||||
### 隐藏层
|
||||
这一层就是全连接的神经网络, DNN在进行特征的高层非线性交互上有着天然的学习优势,公式如下:
|
||||
$$
|
||||
\begin{aligned}
|
||||
\mathbf{z}_{1}=&\sigma_{1}\left(\mathbf{W}_{1} f_{B I}
|
||||
\left(\mathcal{V}_{x}\right)+\mathbf{b}_{1}\right) \\
|
||||
\mathbf{z}_{2}=& \sigma_{2}\left(\mathbf{W}_{2} \mathbf{z}_{1}+\mathbf{b}_{2}\right) \\
|
||||
\ldots \ldots \\
|
||||
\mathbf{z}_{L}=& \sigma_{L}\left(\mathbf{W}_{L} \mathbf{z}_{L-1}+\mathbf{b}_{L}\right)
|
||||
\end{aligned}
|
||||
$$
|
||||
这里的$\sigma_i$是第$i$层的激活函数,可不要理解成sigmoid激活函数。
|
||||
|
||||
### 预测层
|
||||
这个就是最后一层的结果直接过一个隐藏层,但注意由于这里是回归问题,没有加sigmoid激活:
|
||||
$$
|
||||
f(\mathbf{x})=\mathbf{h}^{T} \mathbf{z}_{L}
|
||||
$$
|
||||
|
||||
所以, NFM模型的前向传播过程总结如下:
|
||||
$$
|
||||
\begin{aligned}
|
||||
\hat{y}_{N F M}(\mathbf{x}) &=w_{0}+\sum_{i=1}^{n} w_{i} x_{i} \\
|
||||
&+\mathbf{h}^{T} \sigma_{L}\left(\mathbf{W}_{L}\left(\ldots \sigma_{1}\left(\mathbf{W}_{1} f_{B I}\left(\mathcal{V}_{x}\right)+\mathbf{b}_{1}\right) \ldots\right)+\mathbf{b}_{L}\right)
|
||||
\end{aligned}
|
||||
$$
|
||||
这就是NFM模型的全貌, NFM相比较于其他模型的核心创新点是特征交叉池化层,基于它,实现了FM和DNN的无缝连接,使得DNN可以在底层就学习到包含更多信息的组合特征,这时候,就会减少DNN的很多负担,只需要很少的隐藏层就可以学习到高阶特征信息。NFM相比之前的DNN, 模型结构更浅,更简单,但是性能更好,训练和调参更容易。集合FM二阶交叉线性和DNN高阶交叉非线性的优势,非常适合处理稀疏数据的场景任务。在对NFM的真实训练过程中,也会用到像Dropout和BatchNormalization这样的技术来缓解过拟合和在过大的改变数据分布。
|
||||
|
||||
下面通过代码看下NFM的具体实现过程, 学习一些细节。
|
||||
|
||||
## 代码实现
|
||||
下面我们看下NFM的代码复现,这里主要是给大家说一下这个模型的设计逻辑,参考了deepctr的函数API的编程风格, 具体的代码以及示例大家可以去参考后面的GitHub,里面已经给出了详细的注释, 这里主要分析模型的逻辑这块。关于函数API的编程式风格,我们还给出了一份文档, 大家可以先看这个,再看后面的代码部分,会更加舒服些。下面开始:
|
||||
|
||||
这里主要说一下NFM模型的总体运行逻辑, 这样可以让大家从宏观的层面去把握模型的设计过程, 该模型所使用的数据集是criteo数据集,具体介绍参考后面的GitHub。 数据集的特征会分为dense特征(连续)和sparse特征(离散), 所以模型的输入层接收这两种输入。但是我们这里把输入分成了linear input和dnn input两种情况,而每种情况都有可能包含上面这两种输入。因为我们后面的模型逻辑会分这两部分走,这里有个细节要注意,就是光看上面那个NFM模型的话,是没有看到它线性特征处理的那部分的,也就是FM的前半部分公式那里图里面是没有的。但是这里我们要加上。
|
||||
$$
|
||||
\hat{y}_{N F M}(\mathbf{x})=w_{0}+\sum_{i=1}^{n} w_{i} x_{i}+f(\mathbf{x})
|
||||
$$
|
||||
所以模型的逻辑我们分成了两大部分,这里我分别给大家解释下每一块做了什么事情:
|
||||
|
||||
1. linear part: 这部分是有关于线性计算,也就是FM的前半部分$w1x1+w2x2...wnxn+b$的计算。对于这一块的计算,我们用了一个get_linear_logits函数实现,后面再说,总之通过这个函数,我们就可以实现上面这个公式的计算过程,得到linear的输出
|
||||
2. dnn part: 这部分是后面交叉特征的那部分计算,FM的最后那部分公式f(x)。 这一块主要是针对离散的特征,首先过embedding, 然后过特征交叉池化层,这个计算我们用了get_bi_interaction_pooling_output函数实现, 得到输出之后又过了DNN网络,最后得到dnn的输出
|
||||
|
||||
模型的最后输出结果,就是把这两个部分的输出结果加和(当然也可以加权),再过一个sigmoid得到。所以NFM的模型定义就出来了:
|
||||
|
||||
```python
|
||||
def NFM(linear_feature_columns, dnn_feature_columns):
|
||||
"""
|
||||
搭建NFM模型,上面已经把所有组块都写好了,这里拼起来就好
|
||||
:param linear_feature_columns: A list. 里面的每个元素是namedtuple(元组的一种扩展类型,同时支持序号和属性名访问组件)类型,表示的是linear数据的特征封装版
|
||||
:param dnn_feature_columns: A list. 里面的每个元素是namedtuple(元组的一种扩展类型,同时支持序号和属性名访问组件)类型,表示的是DNN数据的特征封装版
|
||||
"""
|
||||
# 构建输入层,即所有特征对应的Input()层, 这里使用字典的形式返回, 方便后续构建模型
|
||||
# 构建模型的输入层,模型的输入层不能是字典的形式,应该将字典的形式转换成列表的形式
|
||||
# 注意:这里实际的输入与Input()层的对应,是通过模型输入时候的字典数据的key与对应name的Input层
|
||||
dense_input_dict, sparse_input_dict = build_input_layers(linear_feature_columns+dnn_feature_columns)
|
||||
input_layers = list(dense_input_dict.values()) + list(sparse_input_dict.values())
|
||||
|
||||
# 线性部分的计算 w1x1 + w2x2 + ..wnxn + b部分,dense特征和sparse两部分的计算结果组成,具体看上面细节
|
||||
linear_logits = get_linear_logits(dense_input_dict, sparse_input_dict, linear_feature_columns)
|
||||
|
||||
# DNN部分的计算
|
||||
# 首先,在这里构建DNN部分的embedding层,之所以写在这里,是为了灵活的迁移到其他网络上,这里用字典的形式返回
|
||||
# embedding层用于构建FM交叉部分以及DNN的输入部分
|
||||
embedding_layers = build_embedding_layers(dnn_feature_columns, sparse_input_dict, is_linear=False)
|
||||
|
||||
# 过特征交叉池化层
|
||||
pooling_output = get_bi_interaction_pooling_output(sparse_input_dict, dnn_feature_columns, embedding_layers)
|
||||
|
||||
# 加个BatchNormalization
|
||||
pooling_output = BatchNormalization()(pooling_output)
|
||||
|
||||
# dnn部分的计算
|
||||
dnn_logits = get_dnn_logits(pooling_output)
|
||||
|
||||
# 线性部分和dnn部分的结果相加,最后再过个sigmoid
|
||||
output_logits = Add()([linear_logits, dnn_logits])
|
||||
output_layers = Activation("sigmoid")(output_logits)
|
||||
|
||||
model = Model(inputs=input_layers, outputs=output_layers)
|
||||
|
||||
return model
|
||||
```
|
||||
|
||||
有了上面的解释,这个模型的宏观层面相信就很容易理解了。关于这每一块的细节,这里就不解释了,在我们给出的GitHub代码中,我们已经加了非常详细的注释,大家看那个应该很容易看明白, 为了方便大家的阅读,我们这里还给大家画了一个整体的模型架构图,帮助大家更好的了解每一块以及前向传播。(画的图不是很规范,先将就看一下,后面我们会统一在优化一下这个手工图)。
|
||||
<div align=center>
|
||||
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片NFM_aaaa.png" alt="NFM_aaaa" style="zoom: 50%;" />
|
||||
</div>
|
||||
下面是一个通过keras画的模型结构图,为了更好的显示,数值特征和类别特征都只是选择了一小部分,画图的代码也在github中。
|
||||
<div align=center>
|
||||
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片nfm.png" alt="NFM_aaaa" style="zoom: 50%;" />
|
||||
</div>
|
||||
|
||||
## 思考题
|
||||
1. NFM中的特征交叉与FM中的特征交叉有何异同,分别从原理和代码实现上进行对比分析
|
||||
|
||||
**参考资料**
|
||||
- [论文原文](https://arxiv.org/pdf/1708.05027.pdf)
|
||||
- [deepctr](https://github.com/shenweichen/DeepCTR)
|
||||
- [AI上推荐 之 FNN、DeepFM与NFM(FM在深度学习中的身影重现)](https://blog.csdn.net/wuzhongqiang/article/details/109532267?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522161442951716780255224635%2522%252C%2522scm%2522%253A%252220140713.130102334.pc%255Fblog.%2522%257D&request_id=161442951716780255224635&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~blog~first_rank_v1~rank_blog_v1-1-109532267.pc_v1_rank_blog_v1&utm_term=NFM)
|
||||
117
2023旧版内容/4.人工智能/ch02/ch2.2/ch2.2.3/WideNDeep.md
Normal file
117
2023旧版内容/4.人工智能/ch02/ch2.2/ch2.2.3/WideNDeep.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# Wide & Deep
|
||||
## 动机
|
||||
|
||||
在CTR预估任务中利用手工构造的交叉组合特征来使线性模型具有“记忆性”,使模型记住共现频率较高的特征组合,往往也能达到一个不错的baseline,且可解释性强。但这种方式有着较为明显的缺点:
|
||||
|
||||
1. 特征工程需要耗费太多精力。
|
||||
2. 模型是强行记住这些组合特征的,对于未曾出现过的特征组合,权重系数为0,无法进行泛化。
|
||||
|
||||
为了加强模型的泛化能力,研究者引入了DNN结构,将高维稀疏特征编码为低维稠密的Embedding vector,这种基于Embedding的方式能够有效提高模型的泛化能力。但是,基于Embedding的方式可能因为数据长尾分布,导致长尾的一些特征值无法被充分学习,其对应的Embedding vector是不准确的,这便会造成模型泛化过度。
|
||||
|
||||
Wide&Deep模型就是围绕记忆性和泛化性进行讨论的,模型能够从历史数据中学习到高频共现的特征组合的能力,称为是模型的Memorization。能够利用特征之间的传递性去探索历史数据中从未出现过的特征组合,称为是模型的Generalization。Wide&Deep兼顾Memorization与Generalization并在Google Play store的场景中成功落地。
|
||||
|
||||
## 模型结构及原理
|
||||
<div align=center>
|
||||
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/Javaimage-20200910214310877.png" alt="image-20200910214310877" style="zoom:65%;" />
|
||||
</div>
|
||||
|
||||
其实wide&deep模型本身的结构是非常简单的,对于有点机器学习基础和深度学习基础的人来说都非常的容易看懂,但是如何根据自己的场景去选择那些特征放在Wide部分,哪些特征放在Deep部分就需要理解这篇论文提出者当时对于设计该模型不同结构时的意图了,所以这也是用好这个模型的一个前提。
|
||||
|
||||
**如何理解Wide部分有利于增强模型的“记忆能力”,Deep部分有利于增强模型的“泛化能力”?**
|
||||
|
||||
- wide部分是一个广义的线性模型,输入的特征主要有两部分组成,一部分是原始的部分特征,另一部分是原始特征的交叉特征(cross-product transformation),对于交互特征可以定义为:
|
||||
$$
|
||||
\phi_{k}(x)=\prod_{i=1}^d x_i^{c_{ki}}, c_{ki}\in \{0,1\}
|
||||
$$
|
||||
$c_{ki}$是一个布尔变量,当第i个特征属于第k个特征组合时,$c_{ki}$的值为1,否则为0,$x_i$是第i个特征的值,大体意思就是两个特征都同时为1这个新的特征才能为1,否则就是0,说白了就是一个特征组合。用原论文的例子举例:
|
||||
|
||||
> AND(user_installed_app=QQ, impression_app=WeChat),当特征user_installed_app=QQ,和特征impression_app=WeChat取值都为1的时候,组合特征AND(user_installed_app=QQ, impression_app=WeChat)的取值才为1,否则为0。
|
||||
|
||||
对于wide部分训练时候使用的优化器是带$L_1$正则的FTRL算法(Follow-the-regularized-leader),而L1 FTLR是非常注重模型稀疏性质的,也就是说W&D模型采用L1 FTRL是想让Wide部分变得更加的稀疏,即Wide部分的大部分参数都为0,这就大大压缩了模型权重及特征向量的维度。**Wide部分模型训练完之后留下来的特征都是非常重要的,那么模型的“记忆能力”就可以理解为发现"直接的",“暴力的”,“显然的”关联规则的能力。**例如Google W&D期望wide部分发现这样的规则:**用户安装了应用A,此时曝光应用B,用户安装应用B的概率大。**
|
||||
|
||||
- Deep部分是一个DNN模型,输入的特征主要分为两大类,一类是数值特征(可直接输入DNN),一类是类别特征(需要经过Embedding之后才能输入到DNN中),Deep部分的数学形式如下:
|
||||
$$
|
||||
a^{(l+1)} = f(W^{l}a^{(l)} + b^{l})
|
||||
$$
|
||||
**我们知道DNN模型随着层数的增加,中间的特征就越抽象,也就提高了模型的泛化能力。**对于Deep部分的DNN模型作者使用了深度学习常用的优化器AdaGrad,这也是为了使得模型可以得到更精确的解。
|
||||
|
||||
**Wide部分与Deep部分的结合**
|
||||
|
||||
W&D模型是将两部分输出的结果结合起来联合训练,将deep和wide部分的输出重新使用一个逻辑回归模型做最终的预测,输出概率值。联合训练的数学形式如下:需要注意的是,因为Wide侧的数据是高维稀疏的,所以作者使用了FTRL算法优化,而Deep侧使用的是 Adagrad。
|
||||
$$
|
||||
P(Y=1|x)=\delta(w_{wide}^T[x,\phi(x)] + w_{deep}^T a^{(lf)} + b)
|
||||
$$
|
||||
|
||||
## 代码实现
|
||||
|
||||
Wide侧记住的是历史数据中那些**常见、高频**的模式,是推荐系统中的“**红海**”。实际上,Wide侧没有发现新的模式,只是学习到这些模式之间的权重,做一些模式的筛选。正因为Wide侧不能发现新模式,因此我们需要**根据人工经验、业务背景,将我们认为有价值的、显而易见的特征及特征组合,喂入Wide侧**
|
||||
|
||||
Deep侧就是DNN,通过embedding的方式将categorical/id特征映射成稠密向量,让DNN学习到这些特征之间的**深层交叉**,以增强扩展能力。
|
||||
|
||||
模型的实现与模型结构类似由deep和wide两部分组成,这两部分结构所需要的特征在上面已经说过了,针对当前数据集实现,我们在wide部分加入了所有可能的一阶特征,包括数值特征和类别特征的onehot都加进去了,其实也可以加入一些与wide&deep原论文中类似交叉特征。只要能够发现高频、常见模式的特征都可以放在wide侧,对于Deep部分,在本数据中放入了数值特征和类别特征的embedding特征,实际应用也需要根据需求进行选择。
|
||||
|
||||
```python
|
||||
# Wide&Deep 模型的wide部分及Deep部分的特征选择,应该根据实际的业务场景去确定哪些特征应该放在Wide部分,哪些特征应该放在Deep部分
|
||||
def WideNDeep(linear_feature_columns, dnn_feature_columns):
|
||||
# 构建输入层,即所有特征对应的Input()层,这里使用字典的形式返回,方便后续构建模型
|
||||
dense_input_dict, sparse_input_dict = build_input_layers(linear_feature_columns + dnn_feature_columns)
|
||||
|
||||
# 将linear部分的特征中sparse特征筛选出来,后面用来做1维的embedding
|
||||
linear_sparse_feature_columns = list(filter(lambda x: isinstance(x, SparseFeat), linear_feature_columns))
|
||||
|
||||
# 构建模型的输入层,模型的输入层不能是字典的形式,应该将字典的形式转换成列表的形式
|
||||
# 注意:这里实际的输入与Input()层的对应,是通过模型输入时候的字典数据的key与对应name的Input层
|
||||
input_layers = list(dense_input_dict.values()) + list(sparse_input_dict.values())
|
||||
|
||||
# Wide&Deep模型论文中Wide部分使用的特征比较简单,并且得到的特征非常的稀疏,所以使用了FTRL优化Wide部分(这里没有实现FTRL)
|
||||
# 但是是根据他们业务进行选择的,我们这里将所有可能用到的特征都输入到Wide部分,具体的细节可以根据需求进行修改
|
||||
linear_logits = get_linear_logits(dense_input_dict, sparse_input_dict, linear_sparse_feature_columns)
|
||||
|
||||
# 构建维度为k的embedding层,这里使用字典的形式返回,方便后面搭建模型
|
||||
embedding_layers = build_embedding_layers(dnn_feature_columns, sparse_input_dict, is_linear=False)
|
||||
|
||||
dnn_sparse_feature_columns = list(filter(lambda x: isinstance(x, SparseFeat), dnn_feature_columns))
|
||||
|
||||
# 在Wide&Deep模型中,deep部分的输入是将dense特征和embedding特征拼在一起输入到dnn中
|
||||
dnn_logits = get_dnn_logits(dense_input_dict, sparse_input_dict, dnn_sparse_feature_columns, embedding_layers)
|
||||
|
||||
# 将linear,dnn的logits相加作为最终的logits
|
||||
output_logits = Add()([linear_logits, dnn_logits])
|
||||
|
||||
# 这里的激活函数使用sigmoid
|
||||
output_layer = Activation("sigmoid")(output_logits)
|
||||
|
||||
model = Model(input_layers, output_layer)
|
||||
return model
|
||||
```
|
||||
|
||||
关于每一块的细节,这里就不解释了,在我们给出的GitHub代码中,我们已经加了非常详细的注释,大家看那个应该很容易看明白, 为了方便大家的阅读,我们这里还给大家画了一个整体的模型架构图,帮助大家更好的了解每一块以及前向传播。(画的图不是很规范,先将就看一下,后面我们会统一在优化一下这个手工图)。
|
||||
|
||||
<div align=center>
|
||||
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片image-20210228160557072.png" alt="image-20210228160557072" style="zoom:67%;" />
|
||||
</div>
|
||||
|
||||
下面是一个通过keras画的模型结构图,为了更好的显示,数值特征和类别特征都只是选择了一小部分,画图的代码也在github中。
|
||||
|
||||
<div align=center>
|
||||
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片Wide&Deep.png" alt="image-20210228160557072" style="zoom:67%;" />
|
||||
</div>
|
||||
|
||||
## 思考
|
||||
1. 在你的应用场景中,哪些特征适合放在Wide侧,哪些特征适合放在Deep侧,为什么呢?
|
||||
2. 为什么Wide部分要用L1 FTRL训练?
|
||||
3. 为什么Deep部分不特别考虑稀疏性的问题?
|
||||
|
||||
思考题可以参考[见微知著,你真的搞懂Google的Wide&Deep模型了吗?](https://zhuanlan.zhihu.com/p/142958834)
|
||||
|
||||
|
||||
**参考资料**
|
||||
- [论文原文](https://arxiv.org/pdf/1606.07792.pdf)
|
||||
- [deepctr](https://github.com/shenweichen/DeepCTR)
|
||||
- [看Google如何实现Wide & Deep模型(1)](https://zhuanlan.zhihu.com/p/47293765)
|
||||
- [推荐系统系列(六):Wide&Deep理论与实践](https://zhuanlan.zhihu.com/p/92279796?utm_source=wechat_session&utm_medium=social&utm_oi=753565305866829824&utm_campaign=shareopn)
|
||||
- [见微知著,你真的搞懂Google的Wide&Deep模型了吗?](https://zhuanlan.zhihu.com/p/142958834)
|
||||
- [用NumPy手工打造 Wide & Deep](https://zhuanlan.zhihu.com/p/53110408)
|
||||
- [tensorflow官网的WideDeepModel](https://www.tensorflow.org/api_docs/python/tf/keras/experimental/WideDeepModel)
|
||||
- [详解 Wide & Deep 结构背后的动机](https://zhuanlan.zhihu.com/p/53361519)
|
||||
|
||||
565
2023旧版内容/4.人工智能/ch02/ch2.2/ch2.2.3/xDeepFM.md
Normal file
565
2023旧版内容/4.人工智能/ch02/ch2.2/ch2.2.3/xDeepFM.md
Normal file
@@ -0,0 +1,565 @@
|
||||
## 写在前面
|
||||
xDeepFM(eXtreme DeepFM),这是2018年中科大联合微软在KDD上提出的一个模型,在DeepFM的前面加了一个eXtreme,看这个名字,貌似是DeepFM的加强版,但当我仔细的读完原文之后才发现,如果论血缘关系,这个模型应该离着DCN更近一些,这个模型的改进出发点依然是如何更好的学习特征之间的高阶交互作用,从而挖掘更多的交互信息。而基于这样的动机,作者提出了又一个更powerful的网络来完成特征间的高阶显性交互(DCN的话是一个交叉网络), 这个网络叫做CIN(Compressed Interaction Network),这个网络也是xDeepFM的亮点或者核心创新点了(牛x的地方), 有了这个网络才使得这里的"Deep"变得名副其实。而xDeepFM的模型架构依然是w&D结构,更好的理解方式就是用这个CIN网络代替了DCN里面的Cross Network, 这样使得该网络同时能够显性和隐性的学习特征的高阶交互(显性由CIN完成,隐性由DNN完成)。 那么为啥需要同时学习特征的显性和隐性高阶交互呢? 为啥会用CIN代替Cross Network呢? CIN到底有什么更加强大之处呢? xDeepFM与之前的DeepFM以及FM的关系是怎样的呢? 这些问题都会在后面一一揭晓。
|
||||
|
||||
这篇文章的逻辑和前面一样,首先依然是介绍xDeepFM的理论部分和论文里面的细节,我觉得这篇文章的创新思路还是非常厉害的,也就是CIN的结构,在里面是会看到RNN和CNN的身影的,又会看到Cross Network的身影。所以这个结构我这次也是花了一些时间去理解,花了一些时间找解读文章看, 但讲真,解读文章真没有论文里面讲的清晰,所以我这次整理也是完全基于原论文加上我自己的理解进行解读。 当然,我水平有限,难免有理解不到位的地方,如果发现有错,也麻烦各位大佬帮我指出来呀。这样优秀的一个模型,不管是工业上还是面试里面,也是非常喜欢用或者考的内容,所以后面依然参考deepctr的代码,进行简化版的复现,重点看看CIN结构的实现过程。最后就是简单介绍和小总。
|
||||
|
||||
这篇文章依然比较长,首先是CIN结构本身可能比较难理解,前面需要一定的铺垫任务,比如一些概念(显隐性交叉,bit-wise和vector-wise等), 一些基础模型(FM,FNN,PNN,DNN等),DCN的Cross Network,有了这些铺垫后再理解CIN以及操作会简单些,而CIN本身运算也可能比较复杂,再加上里面时间复杂度和空间复杂度那块的分析,还有后面实验的各个小细节以最后论文还帮助我们串联了各种模型,我想在这篇文章中都整理一下。 再加上模型的复现内容,所以篇幅上还是会很长,各取所需吧还是哈哈。当然这篇文章的重点还是在CIN,这个也是面试里面非常喜欢问的点。
|
||||
|
||||
## xDeepFM? 我们需要先了解这些
|
||||
### 简介与进化动机
|
||||
再具体介绍xDeepFM之前,想先整理点铺垫的知识,也是以前的一些内容,是基于原论文的Introduction部分摘抄了一些,算是对前面内容的一些回顾吧,因为这段时间一直忙着找实习,也已经好久没有写这个系列的相关文章了。所以多少还是有点风格和知识上的遗忘哈哈。
|
||||
|
||||
首先是在推荐系统里面, 一般原始的特征很难让模型学习到隐藏在数据背后的规律,因为推荐系统中的原始特征往往非常稀疏,且维度非常高。所以如果想得到一个好的推荐系统,我们必须尽可能的制作更多的特征出来,而特征组合往往是比较好的方式,毕竟特征一般都不是独立存在的,那么特征究竟怎么组合呢? 这是一个比较值得研究的难题,并且好多学者在这上面也下足了工夫。 如果你说,特征组合是啥来? 不太清楚了呀,那么文章中这个例子正好能解决你的疑问
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210504201748649.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" alt="image-20210308142624189" style="zoom: 60%;" />
|
||||
</div>
|
||||
|
||||
起初的时候,是人工特征组合,这个往往在作比赛的时候会遇到,就是特征工程里面自己进行某些特征的交叉与组合来生成新的特征。 这样的方式会有几个问题,作者在论文里面总结了:
|
||||
1. 一般需要一些经验和时间才会得到比较好的特征组合,也就是找这样的组合对于人来说有着非常高的要求,无脑组合不可取 --- 需要一定的经验,耗费大量的时间
|
||||
2. 由于推荐系统中数据的维度规模太大了,如果人工进行组合,根本就不太可能实现 --- 特征过多,无法全面顾及特征的组合
|
||||
3. 手工制作的特征也没有一定的泛化能力,而恰巧推荐系统中的数据往往又非常稀疏 --- 手工组合无泛化能力
|
||||
|
||||
所以,让**模型自动的进行特征交叉组合**探索成了推荐模型里面的比较重要的一个任务,还记得吗? 这个也是模型进化的方向之一,之所以从前面进行引出,是因为本质上这篇的主角xDeepFM也是从这个方向上进行的探索, 那么既然又探索,那也说明了前面模型在这方面还有一定的问题,那么我们就来再综合理一理。
|
||||
|
||||
1. FM模型: 这个模型能够自动学习特征之间的两两交叉,并且比较厉害的地方就是用特征的隐向量内积去表示两两交叉后特征的重要程度,这使得模型在学习交互信息的同时,也让模型有了一定的泛化能力。 But,这个模型也有缺点,首先一般是只能应付特征的两两交叉,再高阶一点的交叉虽然行,但计算复杂,并且作者在论文中提到了高阶交叉的FM模型是不管有用还是无用的交叉都建模,这往往会带来一定的噪声。
|
||||
2. DNN模型: 这个非常熟悉了,深度学习到来之后,推荐模型的演化都朝着DNN的时代去了,原因之一就是因为DNN的多层神经网络可以比较出色的完成特征之间的高阶交互,只需要增加网络的层数,就可以轻松的学习交互,这是DNN的优势所在。 比较有代表的模型PNN,DeepCrossing模型等。 But, DNN并不是非常可靠,有下面几个问题。
|
||||
1. 首先,DNN的这种特征交互是隐性的,后面会具体说显隐性交互区别,但直观上理解,这种隐性交互我们是无法看到到底特征之间是怎么交互的,具体交互到了几阶,这些都是带有一定的不可解释性。
|
||||
2. 其次,DNN是bit-wise层级的交叉,关于bit-wise,后面会说,这种方式论文里面说一个embedding向量里面的各个元素也会相互影响, 这样我觉得在这里带来的一个问题就是可能会发生过拟合。 因为我们知道embedding向量的表示方法就是想从不同的角度去看待某个商品(比如颜色,价格,质地等),当然embedding各个维度是无可解释性的,但我们还是希望这各个维度更加独立一点好,也就是相关性不那么大为妙,这样的话往往能更加表示出各个商品的区别来。 但如果这各个维度上的元素也互相影响了(神经网络会把这个也学习进去), 那过拟合的风险会变大。当然,上面这个是我自己的感觉, 原作者只是给了这样一段:<br>
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/2021050420491452.png#pic_center" alt="image-20210308142624189" style="zoom: 60%;" />
|
||||
</div>
|
||||
|
||||
也就是**DNN是否能够真正的有效学习特征之间的高阶交互是个谜!**
|
||||
3. DNN还存在的问题就是学习高阶交互或许是可能,但没法再兼顾低阶交互,也就是记忆能力,所以后面w&D架构在成为了主流架构。
|
||||
3. Wide&Deep, DeepFM模型: 这两个模型是既有DNN的深度,也有FM或者传统模型的广度,兼顾记忆能力和泛化能力,也是后面的主流模型。但依然有不足之处,wide&Deep的话不用多讲,首先逻辑回归(宽度部分)仍然需要人工特征交叉,而深度部分的高阶交叉又是一个谜。 DeepFM的话是FM和DNN的组合,用FM替代了逻辑回归,这样至少是模型的自动交叉特征,结合了FM的二阶以及DNN的高阶交叉能力。 但如果DNN这块高度交叉是个谜的话,就有点玄乎了。
|
||||
4. DCN网络: 这个模型也是w&D架构,不过宽度那部分使用了一个Cross Network,这个网络的奇妙之处,就是扩展了FM这种只能显性交叉的二阶的模型, 通过交叉网络能真正的显性的进行特征间的高阶交叉。 具体结构后面还会在复习,毕竟这次的xdeepFM主要是在Cross Network的基础上进行的再升级,这也是为啥说论血缘关系,xdeepFM离DCN更近一些的原因。那么Cross network有啥问题呢? 这个想放在后面的2.4去说了,这样能更好的引出本篇主角来。
|
||||
|
||||
通过上面的一个梳理,首先是回忆起了前面这几个模型的发展脉络, 其次差不多也能明白xDeepFM到底再干个什么事情了,或者要解决啥问题了,xDeepFM其实简单的说,依然是研究如何自动的进行特征之间的交叉组合,以让模型学习的更好。 从上面这段梳理中,我们至少要得到3个重要信息:
|
||||
|
||||
1. 推荐模型如何有效的学习特征的交叉组合信息是非常重要的, 而原始的人工特征交叉组合不可取,如何让模型自动的学习交叉组合信息就变得非常关键
|
||||
2. 有的模型(FM)可以显性的交叉特征, 但往往没法高阶,只能到二阶
|
||||
3. 有的模型(DNN)可以进行高阶的特征交互,但往往是以一种无法解释的方式(隐性高阶交互),并且是bit-wise的形式,能不能真正的学习到高阶交互其实是个谜。
|
||||
4. 有的模型(DCN)探索了显性的高阶交叉特征,但仍然存在一些问题。
|
||||
|
||||
所以xDeepFM的改进动机来了: 更有效的高阶显性交叉特征(CIN),更高的泛化能力(vector-wise), 显性和隐性高阶特征的组合(CIN+DNN), 这就是xDeepFM了, 而这里面的关键,就是CIN网络了。在这之前,还是先把准备工作做足。
|
||||
|
||||
### Embedding Layer
|
||||
这个是为了回顾一下,简单一说,我们拿到的数据往往会有连续型数据和离散型或者叫类别型数据之分。 如果是连续型数据,那个不用多说,一般会归一化或者标准化处理,当然还可能进行一定的非线性化操作,就算处理完了。 而类别型数据,一般需要先LabelEncoder,转成类别型编码,然后再one-hot转成0或者1的编码格式。 比如论文里面的这个例子:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210504212842660.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" alt="image-20210308142624189" style="zoom: 60%;" />
|
||||
</div>
|
||||
|
||||
这样的数据往往是高维稀疏的,不利于模型的学习,所以往往在这样数据之后,加一个embedding层,把数据转成低维稠密的向量表示。 关于embedding的原理这里不说了,但这里要注意一个细节,就是如果某个特征每个样本只有一种取值,也就是one-hot里面只有一个地方是1,比如前面3个field。这时候,可以直接拿1所在位置的embedding当做此时类别特征的embedding向量。 但是如果某个特征域每个样本好多种取值,比如interests这个,有好几个1的这种,那么就拿到1所在位置的embedding向量之后**求和**来代表该类别特征的embedding。这样,经过embedding层之后,我们得到的数据成下面这样了:
|
||||
$$
|
||||
\mathbf{e}=\left[\mathbf{e}_{1}, \mathbf{e}_{2}, \ldots, \mathbf{e}_{m}\right]
|
||||
$$
|
||||
这个应该比较好理解,$e_i$表示的一个向量,一般是$D$维的(隐向量的维度), 那么假设$m$表示特征域的个数,那么此时的$\mathbf{e}$是$m\times D$的矩阵。
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210504213606429.png#pic_center" alt="image-20210308142624189" style="zoom: 60%;" />
|
||||
</div>
|
||||
|
||||
### bit-wise VS vector-wise
|
||||
这是特征交互的两种方式,需要了解下,因为论文里面的CIN结构是在vector-wise上完成特征交互的, 这里拿从网上找到的一个例子来解释概念。
|
||||
|
||||
假设隐向量的维度是3维, 如果两个特征对应的向量分别是$(a_1, b_1, c_1)$和$(a_2,b_2, c_2)$
|
||||
|
||||
1. bit-wise = element-wise
|
||||
在进行交互时,交互的形式类似于$f(w_1a_1a_2, w_2b_1b_2,w_3c_1c_2)$,此时我们认为特征交互发生在元素级别上,bit-wise的交互是以bit为最小单元的,也就是向量的每一位上交互,且学习一个$w_i$
|
||||
2. vector-wise
|
||||
如果特征交互形式类似于$f(w(a_1a_2,b_1b_2,c_1c_2))$, 我们认为特征交互发生在向量级别上,vector-wise交互是以整个向量为最小单元的,向量层级的交互,为交互完的向量学习一个统一的$w$
|
||||
|
||||
这个一直没弄明白后者为什么会比前者好,我也在讨论群里问过这个问题,下面是得到的一个伙伴的想法:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210504214822820.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" alt="image-20210308142624189" style="zoom: 80%;" />
|
||||
</div>
|
||||
|
||||
对于这个问题有想法的伙伴,也欢迎在下面评论,我自己的想法是bit-wise看上面的定义,仿佛是在元素的级别交叉,然后学习权重, 而vector-wise是在向量的级别交叉,然后学习统一权重,bit-wise具体到了元素级别上,虽然可能学习的更加细致,但这样应该会增加过拟合的风险,失去一定的泛化能力,再联想作者在论文里面解释的bit-wise:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210504215106707.png#pic_center" alt="image-20210308142624189" style="zoom: 60%;" />
|
||||
</div>
|
||||
|
||||
更觉得这个想法会有一定的合理性,就想我在DNN那里解释这个一样,把细节学的太细,就看不到整体了,佛曰:着相了哈哈。如果再联想下FM的设计初衷,FM是一个vector-wise的模型,它进行了显性的二阶特征交叉,却是embedding级别的交互,这样的好处是有一定的泛化能力到看不见的特征交互。 emmm, 我在后面整理Cross Network问题的时候,突然悟了一下, bit-wise最大的问题其实在于**违背了特征交互的初衷**, 我们本意上其实是让模型学习特征之间的交互,放到embedding的角度,也理应是embedding与embedding的相关作用交互, 但bit-wise已经没有了embedding的概念,以bit为最细粒度进行学习, 这里面既有不同embedding的bit交互,也有同一embedding的bit交互,已经**意识不到Field vector的概念**。 具体可以看Cross Network那里的解释,分析了Cross Network之后,可能会更好理解些。
|
||||
|
||||
这个问题最好是先这样理解或者自己思考下,因为xDeepFM的一个挺大的亮点就是保留了FM的这种vector-wise的特征交互模式,也是作者一直强调的,vector-wise应该是要比bit-wise要好的,否则作者就不会强调Cross Network的弊端之一就是bit-wise,而改进的方法就是设计了CIN,用的是vector-wise。
|
||||
|
||||
### 高阶隐性特征交互(DNN) VS 高阶显性特征交互(Cross Network)
|
||||
#### DNN的隐性高阶交互
|
||||
DNN非常擅长学习特征之间的高阶交互信息,但是隐性的,这个比较好理解了,前面也提到过:
|
||||
$$
|
||||
\begin{array}{c}
|
||||
\mathbf{x}^{1}=\sigma\left(\mathbf{W}^{(1)} \mathbf{e}+\mathbf{b}^{1}\right) \\
|
||||
\mathbf{x}^{k}=\sigma\left(\mathbf{W}^{(k)} \mathbf{x}^{(k-1)}+\mathbf{b}^{k}\right)
|
||||
\end{array}
|
||||
$$
|
||||
但是问题的话,前面也剖析过了, 简单总结:
|
||||
1. DNN 是一种隐性的方式学习特征交互, 但这种交互是不可解释性的, 没法看出究竟是学习了几阶的特征交互
|
||||
2. DNN是在bit-wise层级上学习的特征交互, 这个不同于传统的FM的vector-wise
|
||||
3. DNN是否能有效的学习高阶特征交互是个迷,其实不知道学习了多少重要的高阶交互,哪些高阶交互会有作用,高阶到了几阶等, 如果用的话,只能靠玄学二字来解释
|
||||
|
||||
#### Cross Network的显性高阶交互
|
||||
谈到显性高阶交互,这里就必须先分析一下我们大名鼎鼎的DCN网络的Cross Network了, 关于这个模型,我在[AI上推荐 之 Wide&Deep与Deep&Cross模型](https://blog.csdn.net/wuzhongqiang/article/details/109254498)文章中进行了一些剖析,这里再复习的话我又参考了一个大佬的文章,因为再把我之前的拿过来感觉没有啥意思,重新再阅读别人的文章很可能会再get新的点,于是乎还真的学习到了新东西,具体链接放到了下面。 这里我们重温下Cross Network,看看到底啥子叫显性高阶交互。再根据论文看看这样子的交互有啥问题。
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210505200049781.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" alt="image-20210308142624189" style="zoom: 60%;" />
|
||||
</div>
|
||||
|
||||
这里的输入$x_0$需要提醒下,首先对于离散的特征,需要进行embedding, 对于multi-hot的离散变量, 需要embedding之后再做一个简单的average pooling, 而dense特征,归一化, **然后和embedding的特征拼接到一块作为Cross层和Deep层的输入,也就是Dense特征会在这里进行拼接**。 下面回顾Cross Layer。
|
||||
|
||||
Cross的目的是一一种显性、可控且高效的方式,**自动**构造**有限高阶**交叉特征。 具体的公式如下:
|
||||
$$
|
||||
\boldsymbol{x}_{l+1}=\boldsymbol{x}_{0} \boldsymbol{x}_{l}^{T} \boldsymbol{w}_{l}+\boldsymbol{b}_{l}+\boldsymbol{x}_{l}=f\left(\boldsymbol{x}_{l}, \boldsymbol{w}_{l}, \boldsymbol{b}_{l}\right)+\boldsymbol{x}_{l}
|
||||
$$
|
||||
其中$\boldsymbol{x}_{l+1}, \boldsymbol{x}_{l}, \boldsymbol{x}_{0} \in \mathbb{R}^{d}$。有图有真相:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20201026200320611.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" alt="image-20210308142624189" style="zoom: 60%;" />
|
||||
</div>
|
||||
|
||||
Cross Layer的巧妙之处全部体现在上面的公式,下面放张图是为了更好的理解,这里我们回顾一些细节。
|
||||
1. 每层的神经元个数相同,都等于输入$\boldsymbol{x}_0$的维度$d$, 即每层的输入和输出维度是相等的(这个之前没有整理,没注意到)
|
||||
2. 残差网络的结构启发,每层的函数$\boldsymbol{f}$拟合的是$\boldsymbol{x}_{l+1}-\boldsymbol{x}_l$的残差,残差网络有很多优点,其中一个是处理梯度消失的问题,可以使得网络更“深”
|
||||
|
||||
那么显性交叉到底体会到哪里呢? 还是拿我之前举的那个例子:假设$\boldsymbol{x}_{0}=\left[\begin{array}{l}x_{0,1} \\ x_{0,2}\end{array}\right]$, 为了讨论各层,先令$\boldsymbol{b}_i=0$, 则
|
||||
|
||||
$$
|
||||
\boldsymbol{x}_{1}=\boldsymbol{x}_{0} \boldsymbol{x}_{0}^{T} \boldsymbol{w}_{0}+\boldsymbol{x}_{0}=\left[\begin{array}{l}
|
||||
x_{0,1} \\
|
||||
x_{0,2}
|
||||
\end{array}\right]\left[x_{0,1}, x_{0,2}\right]\left[\begin{array}{c}
|
||||
w_{0,1} \\
|
||||
w_{0,2}
|
||||
\end{array}\right]+\left[\begin{array}{l}
|
||||
x_{0,1} \\
|
||||
x_{0,2}
|
||||
\end{array}\right]=\left[\begin{array}{l}
|
||||
w_{0,1} x_{0,1}^{2}+w_{0,2} x_{0,1} x_{0,2}+x_{0,1} \\
|
||||
w_{0,1} x_{0,2} x_{0,1}+w_{0,2} x_{0,2}^{2}+x_{0,2}
|
||||
\end{array}\right] \\
|
||||
\begin{aligned}
|
||||
\boldsymbol{x}_{2}=& \boldsymbol{x}_{0} \boldsymbol{x}_{1}^{T} \boldsymbol{w}_{1}+\boldsymbol{x}_{1} \\
|
||||
=&\left[\begin{array}{l}
|
||||
w_{1,1} x_{0,1} x_{1,1}+w_{1,2} x_{0,1} x_{1,2}+x_{1,1} \\
|
||||
\left.w_{1,1} x_{0,2} x_{1,1}+w_{1,2} x_{0,2} x_{1,2}+x_{1,2}\right]
|
||||
\end{array}\right. \\
|
||||
&=\left[\begin{array}{l}
|
||||
\left.w_{0,1} w_{1,1} x_{0,1}^{3}+\left(w_{0,2} w_{1,1}+w_{0,1} w_{1,2}\right) x_{0,1}^{2} x_{0,2}+w_{0,2} w_{1,2} x_{0,1} x_{0,2}^{2}+\left(w_{0,1}+w_{1,1}\right) x_{0,1}^{2}+\left(w_{0,2}+w_{1,2}\right) x_{0,1} x_{0,2}+x_{0,1}\right] \\
|
||||
\ldots \ldots \ldots .
|
||||
\end{array}\right.
|
||||
\end{aligned}
|
||||
$$
|
||||
最后得到$y_{\text {cross }}=\boldsymbol{x}_{2}^{T} * \boldsymbol{w}_{\text {cross }} \in \mathbb{R}$参与到最后的loss计算。 可以看到$\boldsymbol{x}_1$包含了原始特征$x_{0,1},x_{0,2}$从一阶导二阶所有可能叉乘组合, 而$\boldsymbol{x}_2$包含了从一阶导三阶素有可能的叉乘组合, 而**显性特征组合的意思,就是最终的结果可以经过一系列转换,得到类似$W_{i,j}x_ix_j$的形式**, 上面这个可以说是非常明显了吧。
|
||||
1. **有限高阶**: 叉乘**阶数由网络深度决定**, 深度$L_c$对应最高$L_c+1$阶的叉乘
|
||||
2. **自动叉乘**:Cross输出包含了原始从一阶(本身)到$L_c+1$阶的**所有叉乘组合**, 而模型参数量仅仅随着输入维度**线性增长**:$2\times d\times L_c$
|
||||
3. **参数共享**: 不同叉乘项对应的权重不同,但并非每个叉乘组合对应独立的权重,通过参数共享,Cross有效**降低了参数数量**。 并且,使得模型有更强的**泛化性**和**鲁棒性**。例如,如果独立训练权重,当训练集中$x_{i} \neq 0 \wedge x_{j} \neq 0$这个叉乘特征没有出现,对应权重肯定是0,而参数共享不会,类似的,数据集中的一些噪声可以由大部分样本来纠正权重参数的学习
|
||||
|
||||
这里有一点很值得留意,前面介绍过,文中将dense特征和embedding特征拼接后作为Cross层和Deep层的共同输入。这对于Deep层是合理的,但我们知道人工交叉特征基本是对原始sparse特征进行叉乘,那为何不直接用原始sparse特征作为Cross的输入呢?联系这里介绍的Cross设计,每层layer的节点数都与Cross的输入维度一致的,**直接使用大规模高维的sparse特征作为输入,会导致极大地增加Cross的参数量**。当然,可以畅想一下,其实直接拿原始sparse特征喂给Cross层,才是论文真正宣称的“省去人工叉乘”的更完美实现,但是现实条件不太允许。所以将高维sparse特征转化为低维的embedding,再喂给Cross,实则是一种**trade-off**的可行选择。
|
||||
|
||||
看下DNN与Cross Network的参数量对比: <br><br>初始输入$x_0$维度是$d$, Deep和Cross层数分别为$L_{cross}$和$L_{deep}$, 为便于分析,设Deep每层神经元个数为$m$则两部分参数量:
|
||||
$$
|
||||
\text { Cross: } d * L_{\text {cross }} * 2 \quad V S \quad \text { Deep: }(d * m+m)+\left(m^{2}+m\right) *\left(L_{\text {deep }}-1\right)
|
||||
$$
|
||||
可以看到Cross的参数量随$d$增大仅呈“线性增长”!相比于Deep部分,对整体模型的复杂度影响不大,这得益于Cross的特殊网络设计,对于模型在业界落地并实际上线来说,这是一个相当诱人的特点。Deep那部分参数计算其实是第一层单算$m(d+1)$, 接下来的$L-1$层,每层都是$m$, 再加上$b$个个数,所以$m(m+1)$。
|
||||
|
||||
好了, Cross的好处啥的都分析完了, 下面得分析点不好的地方了,否则就没法引出这次的主角了。作者直接说:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210505195118178.png#pic_center" alt="image-20210308142624189" style="zoom: 60%;" />
|
||||
</div>
|
||||
|
||||
每一层学习到的是$\boldsymbol{x}_0$的标量倍,这是啥意思。 这里有一个理论:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/202105051955220.png#pic_center" alt="image-20210308142624189" style="zoom: 60%;" />
|
||||
</div>
|
||||
|
||||
这里作者用数学归纳法进行了证明。
|
||||
|
||||
当$k=1$的时候
|
||||
$$
|
||||
\begin{aligned}
|
||||
\mathbf{x}_{1} &=\mathbf{x}_{0}\left(\mathbf{x}_{0}^{T} \mathbf{w}_{1}\right)+\mathbf{x}_{0} \\
|
||||
&=\mathbf{x}_{0}\left(\mathbf{x}_{0}^{T} \mathbf{w}_{1}+1\right) \\
|
||||
&=\alpha^{1} \mathbf{x}_{0}
|
||||
\end{aligned}
|
||||
$$
|
||||
这里的$\alpha^{1}=\mathbf{x}_{0}^{T} \mathbf{w}_{1}+1$是$x_0$的一个线性回归, $x_1$是$x_0$的标量倍成立。 假设当$k=i$的时候也成立,那么$k=i+1$的时候:
|
||||
$$
|
||||
\begin{aligned}
|
||||
\mathbf{x}_{i+1} &=\mathbf{x}_{0} \mathbf{x}_{i}^{T} \mathbf{w}_{i+1}+\mathbf{x}_{i} \\
|
||||
&=\mathbf{x}_{0}\left(\left(\alpha^{i} \mathbf{x}_{0}\right)^{T} \mathbf{w}_{i+1}\right)+\alpha^{i} \mathbf{x}_{0} \\
|
||||
&=\alpha^{i+1} \mathbf{x}_{0}
|
||||
\end{aligned}
|
||||
$$
|
||||
其中$\alpha^{i+1}=\alpha^{i}\left(\mathbf{x}_{0}^{T} \mathbf{w}_{i+1}+1\right)$, 即$x_{i+1}$依然是$x_0$的标量倍。
|
||||
|
||||
所以作者认为Cross Network有两个缺点:
|
||||
1. 由于每个隐藏层是$x_0$的标量倍,所以CrossNet的输出受到了特定形式的限制
|
||||
2. CrossNet的特征交互是bit-wise的方式(这个经过上面举例子应该是显然了),这种方式embedding向量的各个元素也会互相影响,这样在泛化能力上可能受到限制,并且也**意识不到Field Vector的概念**, **这其实违背了我们特征之间相互交叉的初衷**。因为我们想让模型学习的是特征与特征之间的交互或者是相关性,从embedding的角度,,那么自然的特征与特征之间的交互信息应该是embedding与embedding的交互信息。 但是**bit-wise的交互上,已经意识不到embedding的概念了**。由于最细粒度是bit(embedding的具体元素),所以这样的交互既包括了不同embedding不同元素之间的交互,也包括了同一embedding不同元素的交互。本质上其实发生了改变。 **这也是作者为啥强调CIN网络是vector-wise的原因**。而FM,恰好是以向量为最细粒度学习相关性。
|
||||
|
||||
好了, 如果真正理解了Cross Network,以及上面存在的两个问题,理解xDeepFM的动机就不难了,**xDeepFM的动机,正是将FM的vector-wise的思想引入到了Cross部分**。
|
||||
|
||||
下面主角登场了:
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210505201840211.png#pic_center" alt="image-20210308142624189" style="zoom: 60%;" />
|
||||
</div>
|
||||
|
||||
## xDeepFM模型的理论以及论文细节
|
||||
了解了xDeepFM的动机,再强调下xDeepFM的核心,就是提出的一个新的Cross Network(CIN),这个是基于DCN的Cross Network,但是有上面那几点好处。下面的逻辑打算是这样,首先先整体看下xDeepFM的模型架构,由于我们已经知道了这里其实就是用一个CIN网络代替了DCN的Cross Network,那么这里面除了这个网络,其他的我们都熟悉。 然后我们再重点看看CIN网络到底在干个什么样的事情,然后再看看CIN与FM等有啥关系,最后分析下这个新网络的时间复杂度等问题。
|
||||
### xDeepFM的架构剖析
|
||||
首先,我们先看下xDeepFM的架构
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/2021050520373226.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" alt="image-20210308142624189" style="zoom: 60%;" />
|
||||
</div>
|
||||
|
||||
这个网络结构名副其实,依然是采用了W&D架构,DNN负责Deep端,学习特征之间的隐性高阶交互, 而CIN网络负责wide端,学习特征之间的显性高阶交互,这样显隐性高阶交互就在这个模型里面体现的淋漓尽致了。不过这里的线性层单拿出来了。
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210505204057446.png#pic_center" alt="image-20210308142624189" style="zoom: 60%;" />
|
||||
</div>
|
||||
|
||||
最终的计算公式如下:
|
||||
$$
|
||||
\hat{y}=\sigma\left(\mathbf{w}_{\text {linear }}^{T} \mathbf{a}+\mathbf{w}_{d n n}^{T} \mathbf{x}_{d n n}^{k}+\mathbf{w}_{\text {cin }}^{T} \mathbf{p}^{+}+b\right)
|
||||
$$
|
||||
这里的$\mathbf{a}$表示原始的特征,$\mathbf{a}_{dnn}^k$表示的是DNN的输出, $\mathbf{p}^+$表示的是CIN的输出。最终的损失依然是交叉熵损失,这里也是做一个点击率预测的问题:
|
||||
$$
|
||||
\mathcal{L}=-\frac{1}{N} \sum_{i=1}^{N} y_{i} \log \hat{y}_{i}+\left(1-y_{i}\right) \log \left(1-\hat{y}_{i}\right)
|
||||
$$
|
||||
最终的目标函数加了正则化:
|
||||
$$
|
||||
\mathcal{J}=\mathcal{L}+\lambda_{*}\|\Theta\|
|
||||
$$
|
||||
|
||||
### CIN网络的细节(重头戏)
|
||||
这里尝试剖析下本篇论文的主角CIN网络,全称Compressed Interaction Network。这个东西说白了其实也是一个网络,并不是什么高大上的东西,和Cross Network一样,也是一层一层,每一层都是基于一个固定的公式进行的计算,那个公式长这样:
|
||||
$$
|
||||
\mathbf{X}_{h, *}^{k}=\sum_{i=1}^{H_{k-1}} \sum_{j=1}^{m} \mathbf{W}_{i j}^{k, h}\left(\mathbf{X}_{i, *}^{k-1} \circ \mathbf{X}_{j, *}^{0}\right)
|
||||
$$
|
||||
这个公式第一眼看过来,肯定更是懵逼,这是写的个啥玩意?如果我再把CIN的三个核心图放上来:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/2021050520530391.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" alt="image-20210308142624189" style="zoom: 60%;" />
|
||||
</div>
|
||||
|
||||
上面其实就是CIN网络的精髓了,也是它具体的运算过程,只不过直接上图的话,会有些抽象,难以理解,也不符合我整理论文的习惯。下面,我们就一一进行剖析, 先从上面这个公式开始。但在这之前,需要先约定一些符号。要不然不知道代表啥意思。
|
||||
1. $\mathbf{X}^{0} \in \mathbb{R}^{m \times D}$: 这个就是我们的输入,也就是embedding层的输出,可以理解为各个embedding的堆叠而成的矩阵,假设有$m$个特征,embedding的维度是$D$维,那么这样就得到了这样的矩阵, $m$行$D$列。$\mathbf{X}_{i, *}^{0}=\mathbf{e}_{i}$, 这个表示的是第$i$个特征的embedding向量$e_i$。所以上标在这里表示的是网络的层数,输入可以看做第0层,而下标表示的第几行的embedding向量,这个清楚了。
|
||||
2. $\mathbf{X}^{k} \in \mathbb{R}^{H_{k} \times D}$: 这个表示的是CIN网络第$k$层的输出,和上面这个一样,也是一个矩阵,每一行是一个embedding向量,每一列代表一个embedding维度。这里的$H_k$表示的是第$k$层特征的数量,也可以理解为神经元个数。那么显然,这个$\mathbf{X}^{k}$就是$H_k$个$D$为向量堆叠而成的矩阵,维度也显然了。$\mathbf{X}_{h, *}^{k}$代表的就是第$k$层第$h$个特征向量了。
|
||||
|
||||
所以上面的那个公式:
|
||||
$$
|
||||
\mathbf{X}_{h, *}^{k}=\sum_{i=1}^{H_{k-1}} \sum_{j=1}^{m} \mathbf{W}_{i j}^{k, h}\left(\mathbf{X}_{i, *}^{k-1} \circ \mathbf{X}_{j, *}^{0}\right)
|
||||
$$
|
||||
其实就是计算第$k$层第$h$个特征向量, 这里的$1 \leq h \leq H_{k}, \mathbf{W}^{k, h} \in \mathbb{R}^{H_{k-1} \times m}$是第$h$个特征向量的参数矩阵。 $\circ$表示的哈达玛积,也就是向量之间对应维度元素相乘(不相加了)。$\left\langle a_{1}, a_{2}, a_{3}\right\rangle \circ\left\langle b_{1}, b_{2}, b_{3}\right\rangle=\left\langle a_{1} b_{1}, a_{2} b_{2}, a_{3} b_{3}\right\rangle$。通过这个公式也能看到$\mathbf{X}^k$是通过$\mathbf{X}^{k-1}$和$\mathbf{X}^0$计算得来的,也就是说特征的显性交互阶数会虽然网络层数的加深而增加。
|
||||
|
||||
那么这个公式到底表示的啥意思呢? 是具体怎么计算的呢?我们往前计算一层就知道了,这里令$k=1$,也就是尝试计算第一层里面的第$h$个向量, 那么上面公式就变成了:
|
||||
|
||||
$$
|
||||
\mathbf{X}_{h, *}^{1}=\sum_{i=1}^{H_{0}} \sum_{j=1}^{m} \mathbf{W}_{i j}^{1, h}\left(\mathbf{X}_{i, *}^{0} \circ \mathbf{X}_{j, *}^{0}\right)
|
||||
$$
|
||||
这里的$\mathbf{W}^{1, h} \in \mathbb{R}^{H_{0} \times m}$。这个能看懂吗? 首先这个$\mathbf{W}$矩阵是$H_0$行$m$列, 而前面那两个累加正好也是$H_0$行$m$列的参数。$m$代表的是输入特征的个数, $H_0$代表的是第0层($k-1$层)的神经元的个数, 这个也是$m$。这个应该好理解,输入层就是第0层。所以这其实就是一个$m\times m$的矩阵。那么后面这个运算到底是怎么算的呢? 首先对于第$i$个特征向量, 要依次和其他的$m$个特征向量做哈达玛积操作,当然也乘以对应位置的权重,求和。对于每个$i$特征向量,都重复这样的操作,最终求和得到一个$D$维的向量,这个就是$\mathbf{X}_{h, *}^{1}$。好吧,这么说。我觉得应该也没有啥感觉,画一下就了然了,现在可以先不用管论文里面是怎么说的,先跟着这个思路走,只要理解了这个公式是怎么计算的,论文里面的那三个图就会非常清晰了。灵魂画手:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210505215026537.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" alt="image-20210308142624189" style="zoom: 60%;" />
|
||||
</div>
|
||||
|
||||
这就是上面那个公式的具体过程了,图实在是太难看了, 但应该能说明这个详细的过程了。这样只要给定一个$\mathbf{W}^{1,h}$之后,就能算出一个相应的$\mathbf{X}^1_{h,*}$来,这样第一层的$H_1$个神经元按照这样的步骤就能够都计算出来了。 后面的计算过程其实是同理,无非就是输入是前一层的输出以及$\mathbf{X}_0$罢了,而这时候,第一个矩阵特征数就不一定是$m$了,而是一个$H_{k-1}$行$D$列的矩阵了。这里的$\mathbf{W}^{k,h}$就是上面写的$H_{k-1}$行$m$列了。
|
||||
|
||||
这个过程明白了之后,再看论文后面的内容就相对容易了,首先
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210505215629371.png#pic_center" alt="image-20210308142624189" style="zoom: 60%;" />
|
||||
</div>
|
||||
|
||||
CIN里面能看到RNN的身影,也就是当前层的隐藏单元的计算要依赖于上一层以及当前的输入层,只不过这里的当前输入每个时间步都是$\mathbf{X}_0$。 同时这里也能看到,CIN的计算是vector-wise级别的,也就是向量之间的哈达玛积的操作,并没有涉及到具体向量里面的位交叉。
|
||||
|
||||
下面我们再从CNN的角度去看这个计算过程。其实还是和上面一样的计算过程,只不过是换了个角度看而已,所以上面那个只要能理解,下面CNN也容易理解了。首先,这里引入了一个tensor张量$\mathbf{Z}^{k+1}$表示的是$\mathbf{X}^k$和$\mathbf{X}^0$的外积,那么这个东西是啥呢? 上面加权求和前的那个矩阵,是一个三维的张量。
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210505221222945.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" alt="image-20210308142624189" style="zoom: 60%;" />
|
||||
</div>
|
||||
|
||||
这个可以看成是一个三维的图片,$H_{k-1}$高,$m$宽,$D$个通道。而$\mathbf{W}^{k,h}$的大小是$H_{k-1}\times m$的, 这个就相当于一个过滤器,用这个过滤器对输入的图片如果**逐通道进行卷积**,就会最终得到一个$D$维的向量,而这个其实就是$\mathbf{X}^{k}_{h,*}$,也就是一张特征图(每个通道过滤器是共享的)。 第$k$层其实有$H_k$个这样的过滤器,所以最后得到的是一个$H_k\times D$的矩阵。这样,在第$k$个隐藏层,就把了$H_{k-1}\times m\times D$的三维张量通过逐通道卷积的方式,压缩成了一个$H_k\times D$的矩阵($H_k$张特征图), 这就是第$k$层的输出$\mathbf{X}^k$。 而这也就是“compressed"的由来。这时候再看这两个图就非常舒服了:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210505222742858.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" alt="image-20210308142624189" style="zoom: 60%;" />
|
||||
</div>
|
||||
|
||||
通过这样的一个CIN网络,就很容易的实现了特征的显性高阶交互,并且是vector-wise级别的,那么最终的输出层是啥呢? 通过上面的分析,首先我们了解了对于第$k$层输出的某个特征向量,其实是综合了输入里面各个embedding向量显性高阶交互的信息(第$k$层其实学习的输入embedding$k+1$阶交互信息),这个看第一层那个输出就能看出来。第$k$层的每个特征向量其实都能学习到这样的信息,那么如果把这些向量在从$D$维度上进行加和,也就是$\mathbf{X}^k$,这是个$H_k\times D$的,我们沿着D这个维度加和,又会得到一个$H_k$的向量,公式如下:
|
||||
$$
|
||||
p_{i}^{k}=\sum_{j=1}^{D} \mathbf{X}_{i, j}^{k}
|
||||
$$
|
||||
每一层,都会得到一个这样的向量,那么把所有的向量拼接到一块,其实就是CIN网络的输出了。之所以,这里要把中间结果都与输出层相连,就是因为CIN与Cross不同的一点是,在第$k$层,CIN只包含$k+1$阶的组合特征,而Cross是能包含从1阶-$k+1$阶的组合特征的,所以为了让模型学习到从1阶到所有阶的组合特征,CIN这里需要把中间层的结果与输出层建立连接。
|
||||
|
||||
这也就是第三个图表示的含义:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210505223705748.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" alt="image-20210308142624189" style="zoom: 60%;" />
|
||||
</div>
|
||||
|
||||
这样, 就得到了最终CIN的输出$\mathbf{p}^+$了:
|
||||
|
||||
$$
|
||||
\mathbf{p}^{+}=\left[\mathbf{p}^{1}, \mathbf{p}^{2}, \ldots, \mathbf{p}^{T}\right] \in \mathbb{R} \sum_{i=1}^{T} H_{i}
|
||||
$$
|
||||
后面那个维度的意思,就是说每一层是的向量维度是$H_i$维, 最后是所有时间步的维度之和。 CIN网络的计算过程的细节就是这些了。
|
||||
|
||||
### CIN网络的其他角度分析
|
||||
#### 设计意图分析
|
||||
CIN和DCN层的设计动机是相似的,Cross层的input也是前一层与输入层,这么做的原因就是可以实现: **有限高阶交互,自动叉乘和参数共享**。
|
||||
|
||||
但是CIN与Cross的有些地方是不一样的:
|
||||
1. Cross是bit-wise级别的, 而CIN是vector-wise级别的
|
||||
2. 在第$l$层,Cross包含从1阶-$l+1$阶的所有组合特征, 而CIN只包含$l+1$阶的组合特征。 相应的,Cross在输出层输出全部结果, 而CIN在每层都输出中间结果。 而之所以会造成这两者的不同, 就是因为Cross层计算公式中除了与CIN一样包含"上一层与输入层的×乘"外,会再额外加了个"+输入层"。**这是两种涵盖所有阶特征交互的不同策略,CIN和Cross也可以使用对方的策略**。
|
||||
|
||||
#### 时间和空间复杂度分析
|
||||
1. 空间复杂度
|
||||
假设CIN和DNN每层神经元个数是$H$,网络深度为$T$。 那么CIN的参数空间复杂度$O(mTH^2)$。 这个我们先捋捋是怎么算的哈, 首先对于CIN,第$k$层的每个神经元都会对应着一个$H_{k-1}\times m$的参数矩阵$\mathbf{W}^{k,h}$, 那么第$k$层$H$个神经元的话,那就是$H \times H_{k-1} \times m$个参数,这里假设的是每层都有$H$个神经元,那么就是$O(H^2\times m)$,这是一层。 而网络深度一共$T$层的话,那就是$H \times H_{k-1} \times m\times T$的规模。 但别忘了,输出层还有参数, 由于输出层的参数会和输出向量的维度相对应,而输出向量的维度又和每一层神经单元个数相对应, 所以CIN的网络参数一共是$\sum_{k=1}^{T} H_{k} \times\left(1+H_{k-1} \times m\right)$, 而换成大O表示的话,其实就是上面那个了。当然,CIN还可以对$\mathbf{W}$进行$L$阶矩阵分解,使得空间复杂度再降低。 <br><br>再看DNN,第一层是$m\times D\times H_1$, 中间层$H_k\times H_{k-1}$,T-1层,这是一个$O(mDH+TH^2)$的空间复杂度,并且参数量会随着$D$的增加而增加。 <br><br>所以空间上来说,CIN会有优势。
|
||||
2. 时间复杂度
|
||||
对于CIN, 我们计算某一层的某个特征向量的时候,需要前面的$H_{k-1}$个向量与输入的$m$个向量两两哈达玛积的操作,这个过程花费的时间$O(Hm)$, 而哈达玛积完事之后,有需要拿个过滤器在D维度上逐通道卷积,这时候得到了$\mathbf{Z}^{k+1}$,花费时间$O(HmD)$。 这只是某个特征向量, $k$层一共$H$个向量, 那么花费时间$O(H^2mD)$, 而一共$T$层,所以最终时间为$O(mH^2TD)$<br><br>对于普通的DNN,花费时间$O(mHD+H^2T)$<br><br>**所以时间复杂度会是CIN的一大痛点**。
|
||||
|
||||
#### 多项式逼近
|
||||
这地方没怎么看懂,大体写写吧, 通过对问题进行简化,即假设CIN中不同层的feature map的数量全部一致,均为fields的数量$m$,并且用`[m]`表示小于等于m的正整数。CIN中的第一层的第$h$个feature map表示为$x_h^1 \in \mathbb{R}^D$,即
|
||||
$$
|
||||
\boldsymbol{x}_{\boldsymbol{h}}^{1}=\sum_{i \in[m], j \in[m]} \boldsymbol{W}_{i, j}^{1, h}\left(x_{i}^{0} \circ x_{j}^{0}\right)
|
||||
$$
|
||||
因此, 在第一层中通过$O(m^2)$个参数来建模成对的特征交互关系,相似的,第二层的第$h$个特征图表示为:
|
||||
$$
|
||||
\begin{array}{c}
|
||||
\boldsymbol{x}_{h}^{2}=\sum_{i \in[m], j \in[m]} \boldsymbol{W}_{i, j}^{2, h}\left(x_{i}^{1} \circ x_{j}^{0}\right) \\
|
||||
=\sum_{i \in[m], j \in[m]] \in[m], k \in[m]} \boldsymbol{W}_{i, j}^{2, h} \boldsymbol{W}_{l, k}^{1, h}\left(x_{j}^{0} \circ x_{k}^{0} \circ x_{l}^{0}\right)
|
||||
\end{array}
|
||||
$$
|
||||
由于第二个$\mathbf{W}$矩阵在前面一层计算好了,所以第二层的feature map也是只用了$O(m^2)$个参数就建模出了3阶特征交互关系。
|
||||
|
||||
我们知道一个经典的$k$阶多项式一般是需要$O(m^k)$个参数的,而我们展示了CIN在一系列feature map中只需要$O(k m^2)$个参数就可以近似此类多项式。而且paper使用了归纳假设的方法证明了一下,也就是后面那两个公式。具体的没咋看懂证明,不整理了。但得知道两个结论:
|
||||
1. 对于CIN来讲, 第$k$层只包含$k+1$阶特征间的显性特征交互
|
||||
2. CIN的一系列特征图只需要$O(km^2)$个参数就可以近似此类多项式
|
||||
|
||||
#### xDeepFM与其他模型的关系
|
||||
1. 对于xDeepFM,将CIN模块的层数设置为1,feature map数量也为1时,其实就是DeepFM的结构,因此DeepFM是xDeepFM的特殊形式,而xDeepFM是DeepFM的一般形式;
|
||||
2. 在1中的基础上,当我们再将xDeepFM中的DNN去除,并对feature map使用一个常数1形式的 `sum filter`,那么xDeepFM就退化成了FM形式了。
|
||||
|
||||
一般这种模型的改进,是基于之前模型进行的,也就是简化之后,会得到原来的模型,这样最差的结果,模型效果还是原来的,而不应该会比原来模型的表现差,这样的改进才更有说服力。
|
||||
|
||||
所以,既然提到了FM,再考虑下面两个问题理解下CIN设计的合理性。
|
||||
1. 每层通过sum pooling对vector的元素加和输出,这么做的意义或者合理性? 这个就是为了退化成FM做准备,如果CIN只有1层, 只有$m$个vector,即 $H_1=m$ ,且加和的权重矩阵恒等于1,即$W^1=1$ ,那么sum pooling的输出结果,就是一系列的两两向量内积之和,即标准的FM(不考虑一阶与偏置)。
|
||||
2. 除了第一层,中间层的基于Vector高阶组合有什么物理意义? 回顾FM,虽然是二阶的,但可以扩展到多阶,例如考虑三阶FM,是对三个嵌入向量做哈达玛积乘再对得到的vector做sum, CIN基于vector-wise的高阶组合再sum pooling与之类似,这也是模型名字"eXtreme Deep Factorization Machine(xDeepFM)"的由来。
|
||||
### 论文的其他重要细节
|
||||
#### 实验部分
|
||||
这一块就是后面实验了,这里作者依然是抛出了三个问题,并通过实验进行了解答。
|
||||
1. CIN如何学习高阶特征交互
|
||||
通过提出的交叉网络,这里单独证明了这个结构要比CrossNet,DNN模块和FM模块要好
|
||||
2. 推荐系统中,是否需要显性和隐性的高阶特征交互都存在?
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/2021050609553410.png#pic_center" alt="image-20210308142624189" style="zoom: 60%;" />
|
||||
</div>
|
||||
|
||||
3. 超参对于xDeepFM的影响
|
||||
1. 网络的深度: 不用太深, CIN网络层数大于3,就不太好了,容易过拟合
|
||||
2. 每一层神经网络的单元数: 100是比较合适的一个数值
|
||||
3. 激活函数: CIN这里不用加任何的非线性激活函数,用恒等函数$f(x)=x$效果最好
|
||||
|
||||
|
||||
这里用了三个数据集
|
||||
1. 公开数据集 Criteo 与 微软数据集 BingNews
|
||||
2. DianPing 从大众点评网整理的相关数据,收集6个月的user check-in 餐厅poi的记录,从check-in餐厅周围3km内,按照poi受欢迎度抽取餐厅poi作为负例。根据user属性、poi属性,以及user之前3家check-in的poi,预测用户check-in一家给定poi的概率。
|
||||
|
||||
评估指标用了两个AUC和Logloss, 这两个是从不同的角度去评估模型。
|
||||
1. AUC: AUC度量一个正的实例比一个随机选择的负的实例排名更高的概率。它只考虑预测实例的顺序,对类的不平衡问题不敏感.
|
||||
2. LogLoss(交叉熵损失): 真实分数与预测分数的距离
|
||||
|
||||
作者说:
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/2021050609492960.png#pic_center" alt="image-20210308142624189" style="zoom: 60%;" />
|
||||
</div>
|
||||
|
||||
#### 相关工作部分
|
||||
这里作者又梳理了之前的模型,这里就再梳理一遍了
|
||||
|
||||
1. 经典推荐系统
|
||||
1. 非因子分解模型: 主要介绍了两类,一类是常见的线性模型,例如LR with FTRL,这一块很多工作是在交互特征的特征工程方面;另一类是提升决策树模型的研究(GBDT+LR)
|
||||
2. 因子分解模型: MF模型, FM模型,以及在FM模型基础上的贝叶斯模型
|
||||
|
||||
2. 深度学习模型
|
||||
1. 学习高阶交互特征: 论文中提到的DeepCross, FNN,PNN,DCN, NFM, W&D, DeepFM,
|
||||
2. 学习精心的表征学习:这块常见的深度学习模型不是focus在学习高阶特征交互关系。比如NCF,ACF,DIN等。
|
||||
|
||||
推荐系统数据特点: 稀疏,类别连续特征混合,高维。
|
||||
|
||||
关于未来两个方向:
|
||||
1. CIN的sum pooling这里, 后面可以考虑DIN的那种思路,根据当前候选商品与embedding的关联进行注意力权重的添加
|
||||
2. CIN的时间复杂度还是比较高的,后面在GPU集群上使用分布式的方式来训练模型。
|
||||
|
||||
|
||||
## xDeepFM模型的代码复现及重要结构解释
|
||||
### xDeepFM的整体代码逻辑
|
||||
下面看下xDeepFM模型的具体实现, 这样可以从更细节的角度去了解这个模型, 这里我依然是参考的deepctr的代码风格,这种函数式模型编程更清晰一些,当然由于时间原因,我这里目前只完成了一个tf2版本的(pytorch版本的后面有时间会补上)。 这里先看下xDeepFM的全貌:
|
||||
|
||||
```python
|
||||
def xDeepFM(linear_feature_columns, dnn_feature_columns, cin_size=[128, 128]):
|
||||
# 构建输入层,即所有特征对应的Input()层,这里使用字典的形式返回,方便后续构建模型
|
||||
dense_input_dict, sparse_input_dict = build_input_layers(linear_feature_columns+dnn_feature_columns)
|
||||
|
||||
# 构建模型的输入层,模型的输入层不能是字典的形式,应该将字典的形式转换成列表的形式
|
||||
# 注意:这里实际的输入预Input层对应,是通过模型输入时候的字典数据的key与对应name的Input层
|
||||
input_layers = list(dense_input_dict.values()) + list(sparse_input_dict.values())
|
||||
|
||||
# 线性部分的计算逻辑 -- linear
|
||||
linear_logits = get_linear_logits(dense_input_dict, sparse_input_dict, linear_feature_columns)
|
||||
|
||||
# 构建维度为k的embedding层,这里使用字典的形式返回,方便后面搭建模型
|
||||
# 线性层和dnn层统一的embedding层
|
||||
embedding_layer_dict = build_embedding_layers(linear_feature_columns+dnn_feature_columns, sparse_input_dict, is_linear=False)
|
||||
|
||||
# DNN侧的计算逻辑 -- Deep
|
||||
# 将dnn_feature_columns里面的连续特征筛选出来,并把相应的Input层拼接到一块
|
||||
dnn_dense_feature_columns = list(filter(lambda x: isinstance(x, DenseFeat), dnn_feature_columns)) if dnn_feature_columns else []
|
||||
dnn_dense_feature_columns = [fc.name for fc in dnn_dense_feature_columns]
|
||||
dnn_concat_dense_inputs = Concatenate(axis=1)([dense_input_dict[col] for col in dnn_dense_feature_columns])
|
||||
|
||||
# 将dnn_feature_columns里面的离散特征筛选出来,相应的embedding层拼接到一块
|
||||
dnn_sparse_kd_embed = concat_embedding_list(dnn_feature_columns, sparse_input_dict, embedding_layer_dict, flatten=True)
|
||||
dnn_concat_sparse_kd_embed = Concatenate(axis=1)(dnn_sparse_kd_embed)
|
||||
|
||||
# DNN层的输入和输出
|
||||
dnn_input = Concatenate(axis=1)([dnn_concat_dense_inputs, dnn_concat_sparse_kd_embed])
|
||||
dnn_out = get_dnn_output(dnn_input)
|
||||
dnn_logits = Dense(1)(dnn_out)
|
||||
|
||||
# CIN侧的计算逻辑, 这里使用的DNN feature里面的sparse部分,这里不要flatten
|
||||
exFM_sparse_kd_embed = concat_embedding_list(dnn_feature_columns, sparse_input_dict, embedding_layer_dict, flatten=False)
|
||||
exFM_input = Concatenate(axis=1)(exFM_sparse_kd_embed)
|
||||
exFM_out = CIN(cin_size=cin_size)(exFM_input)
|
||||
exFM_logits = Dense(1)(exFM_out)
|
||||
|
||||
# 三边的结果stack
|
||||
stack_output = Add()([linear_logits, dnn_logits, exFM_logits])
|
||||
|
||||
# 输出层
|
||||
output_layer = Dense(1, activation='sigmoid')(stack_output)
|
||||
|
||||
model = Model(input_layers, output_layer)
|
||||
|
||||
return model
|
||||
```
|
||||
这种风格最好的一点,就是很容易从宏观上把握模型的整体逻辑。 首先,接收的输入是linear_feature_columns和dnn_feature_columns, 这两个是深度和宽度两侧的特征,具体选取要结合着场景来。 接下来,就是为这些特征建立相应的Input层,这里要分成连续特征和离散的特征,因为后面的处理方式不同, 连续特征的话可以直接拼了, 而离散特征的话,需要过一个embedding层转成低维稠密,这就是第一行代码干的事情了。
|
||||
|
||||
接下来, 计算线性部分,从上面xDeepFM的结构里面可以看出, 是分三路走的,线性,CIN和DNN路, 所以`get_linear_logits`就是线性这部分的计算结果,完成的是$w_1x_1+w_2x_2..w_kx_k+b$, 这里面依然是连续和离散的不太一样,对于连续特征,直接过一个全连接就实现了这个操作,而离散特征,这里依然过一个embedding,不过这个维度是1,目的是转成了一个连续数值(这个相当于离散特征对应的w值),这样后面进行总的加和操作即可。
|
||||
|
||||
接下来是另外两路,DNN这路也比较简单, dnn_feature_columns里面的离散特征过embedding,和连续特征拼接起来,然后过DNN即可。 CIN这路使用的是dnn_feature_columns里面的离散embedding特征,进行显性高阶交叉,这里的输入是`[None, field_num, embedding_dim]`的维度。这个也好理解,每个特征embedding之后,拼起来即可,注意`flatten=False`了。 这个输入,过CIN网络得到输出。
|
||||
|
||||
这样三路输出都得到,然后进行了一个加和,再连接一个Dense映射到最终输出。这就是整体的逻辑了,关于每个部分的具体细节,可以看代码。 下面主要是看看CIN这个网络是怎么实现的,因为其他的在之前的模型里面也基本是类似的操作,比如前面DIEN,DSIN版本,并且我后面项目里面补充了DCN的deepctr风格版,这个和那个超级像,唯一不同的就是把CrossNet换成了CIN,所以这个如果感觉看不大懂,可以先看那个网络代码。下面说CIN。
|
||||
### CIN网络的代码实现细节
|
||||
再具体代码实现, 我们先简单捋一下CIN网络的实现过程,这里的输入是`[None, field_num embed_dim]`的维度,在CIN里面,我们知道接下来的话,就是每一层会有$H_k$个神经元, 而每个神经元的计算要根据上面的那个计算公式,也就是$X_0$要和前面一层的输出两两embedding,加权求和再求和的方式。 而从CNN的角度来看,这个过程可以是这样,对于每一层的计算,先$X_0$和$X_k$进行外积运算(相当于两两embedding),然后采用$H_k$个过滤器对前面的结果逐通道卷积就能得到每一层的$X_k$了。 最后的输出是每一层的$X_k$拼接起来,然后在embedding维度上的求和。 所以依据这个思路,就能得到下面的实现代码:
|
||||
|
||||
```python
|
||||
class CIN(Layer):
|
||||
def __init__(self, cin_size, l2_reg=1e-4):
|
||||
"""
|
||||
:param: cin_size: A list. [H_1, H_2, ....H_T], a list of number of layers
|
||||
"""
|
||||
super(CIN, self).__init__()
|
||||
self.cin_size = cin_size
|
||||
self.l2_reg = l2_reg
|
||||
|
||||
def build(self, input_shape):
|
||||
# input_shape [None, field_nums, embedding_dim]
|
||||
self.field_nums = input_shape[1]
|
||||
|
||||
# CIN 的每一层大小,这里加入第0层,也就是输入层H_0
|
||||
self.field_nums = [self.field_nums] + self.cin_size
|
||||
|
||||
# 过滤器
|
||||
self.cin_W = {
|
||||
'CIN_W_' + str(i): self.add_weight(
|
||||
name='CIN_W_' + str(i),
|
||||
shape = (1, self.field_nums[0] * self.field_nums[i], self.field_nums[i+1]), # 这个大小要理解
|
||||
initializer='random_uniform',
|
||||
regularizer=l2(self.l2_reg),
|
||||
trainable=True
|
||||
)
|
||||
for i in range(len(self.field_nums)-1)
|
||||
}
|
||||
|
||||
super(CIN, self).build(input_shape)
|
||||
|
||||
def call(self, inputs):
|
||||
# inputs [None, field_num, embed_dim]
|
||||
embed_dim = inputs.shape[-1]
|
||||
hidden_layers_results = [inputs]
|
||||
|
||||
# 从embedding的维度把张量一个个的切开,这个为了后面逐通道进行卷积,算起来好算
|
||||
# 这个结果是个list, list长度是embed_dim, 每个元素维度是[None, field_nums[0], 1] field_nums[0]即输入的特征个数
|
||||
# 即把输入的[None, field_num, embed_dim],切成了embed_dim个[None, field_nums[0], 1]的张量
|
||||
split_X_0 = tf.split(hidden_layers_results[0], embed_dim, 2)
|
||||
|
||||
for idx, size in enumerate(self.cin_size):
|
||||
# 这个操作和上面是同理的,也是为了逐通道卷积的时候更加方便,分割的是当一层的输入Xk-1
|
||||
split_X_K = tf.split(hidden_layers_results[-1], embed_dim, 2) # embed_dim个[None, field_nums[i], 1] feild_nums[i] 当前隐藏层单元数量
|
||||
|
||||
# 外积的运算
|
||||
out_product_res_m = tf.matmul(split_X_0, split_X_K, transpose_b=True) # [embed_dim, None, field_nums[0], field_nums[i]]
|
||||
out_product_res_o = tf.reshape(out_product_res_m, shape=[embed_dim, -1, self.field_nums[0]*self.field_nums[idx]]) # 后两维合并起来
|
||||
out_product_res = tf.transpose(out_product_res_o, perm=[1, 0, 2]) # [None, dim, field_nums[0]*field_nums[i]]
|
||||
|
||||
# 卷积运算
|
||||
# 这个理解的时候每个样本相当于1张通道为1的照片 dim为宽度, field_nums[0]*field_nums[i]为长度
|
||||
# 这时候的卷积核大小是field_nums[0]*field_nums[i]的, 这样一个卷积核的卷积操作相当于在dim上进行滑动,每一次滑动会得到一个数
|
||||
# 这样一个卷积核之后,会得到dim个数,即得到了[None, dim, 1]的张量, 这个即当前层某个神经元的输出
|
||||
# 当前层一共有field_nums[i+1]个神经元, 也就是field_nums[i+1]个卷积核,最终的这个输出维度[None, dim, field_nums[i+1]]
|
||||
cur_layer_out = tf.nn.conv1d(input=out_product_res, filters=self.cin_W['CIN_W_'+str(idx)], stride=1, padding='VALID')
|
||||
|
||||
cur_layer_out = tf.transpose(cur_layer_out, perm=[0, 2, 1]) # [None, field_num[i+1], dim]
|
||||
|
||||
hidden_layers_results.append(cur_layer_out)
|
||||
|
||||
# 最后CIN的结果,要取每个中间层的输出,这里不要第0层的了
|
||||
final_result = hidden_layers_results[1:] # 这个的维度T个[None, field_num[i], dim] T 是CIN的网络层数
|
||||
|
||||
# 接下来在第一维度上拼起来
|
||||
result = tf.concat(final_result, axis=1) # [None, H1+H2+...HT, dim]
|
||||
# 接下来, dim维度上加和,并把第三个维度1干掉
|
||||
result = tf.reduce_sum(result, axis=-1, keepdims=False) # [None, H1+H2+..HT]
|
||||
|
||||
return result
|
||||
```
|
||||
这里主要是解释四点:
|
||||
1. 每一层的W的维度,是一个`[1, self.field_nums[0]*self.field_nums[i], self.field_nums[i+1]`的,首先,得明白这个`self.field_nums`存储的是每一层的神经单元个数,这里包括了输入层,也就是第0层。那么每一层的每个神经元计算都会有一个$W^{k,h}$, 这个的大小是$[H_{k-1},m]$维的,而第$K$层一共$H_k$个神经元,所以总的维度就是$[H_{k-1},m,H_k]$, 这和上面这个是一个意思,只不过前面扩展了维度1而已。
|
||||
2. 具体实现的时候,这里为了更方便计算,采用了切片的思路,也就是从embedding的维度把张量切开,这样外积的计算就会变得更加的简单。
|
||||
3. 具体卷积运算的时候,这里采用的是Conv1d,1维卷积对应的是一张张高度为1的图片(理解的时候可这么理解),输入维度是`[None, in_width, in_channels]`的形式,而对应这里的数据是`[None, dim, field_nums[0]*field_nums[i]]`, 而这里的过滤器大小是`[1, field_nums[0]*field_nums[i], field_nums[i+1]`, 这样进行卷积的话,最后一个维度是卷积核的数量。是沿着dim这个维度卷积,得到的是`[None, dim, field_nums[i+1]]`的张量,这个就是第$i+1$层的输出了。和我画的
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210505221222945.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" alt="image-20210308142624189" style="zoom: 60%;" />
|
||||
</div>
|
||||
|
||||
这个不同的是它把前面这个矩形Flatten了,得到了一个$[D,H_{k-1}\times m]$的二维矩阵,然后用$[1,H_{k-1}\times m]$的卷积核沿着D这个维度进行Conv1D, 这样就直接得到了一个D维向量, 而$H_k$个卷积核,就得到了$H_k\times D$的矩阵了。
|
||||
4. 每一层的输出$X_k$先加入到列表里面,然后在$H_i$的维度上拼接,再从$D$这个维度上求和,这样就得到了CIN的最终输出。
|
||||
|
||||
关于CIN的代码细节解释到这里啦,剩下的可以看后面链接里面的代码了。
|
||||
|
||||
## 总结
|
||||
这篇文章主要是介绍了又一个新的模型xDeepFM, 这个模型的改进焦点依然是特征之间的交互信息,xDeepFM的核心就是提出了一个新的CIN结构(这个是重点,面试的时候也喜欢问),将基于Field的vecotr-wise思想引入到了Cross Network中,并保留了Cross高阶交互,自动叉乘,参数共享等优势,模型结构上保留了DeepFM的广深结构。主要有三大优势:
|
||||
1. CIN可以学习高效的学习有界的高阶特征;
|
||||
2. xDeepFM模型可以同时显示和隐式的学习高阶交互特征;
|
||||
3. 以vector-wise方式而不是bit-wise方式学习特征交互关系。
|
||||
|
||||
如果说DeepFM只是“Deep & FM”,那么xDeepFm就真正做到了”Deep” Factorization Machine。当然,xDeepFM的时间复杂度比较高,会是工业落地的主要瓶颈,后面需要进行一些优化操作。
|
||||
|
||||
这篇论文整体上还是非常清晰的,实验做的也非常丰富,语言描述上也非常地道,建议读读原文呀。
|
||||
|
||||
**参考**:
|
||||
* [xDeepFM原论文-建议读一下,这个真的超级不错](https://arxiv.org/abs/1803.05170)
|
||||
* [xDeepFM:名副其实的 ”Deep” Factorization Machine](https://zhuanlan.zhihu.com/p/57162373)
|
||||
* [深度CTR之xDeepFM:融合了显式和隐式特征交互关系的深度模型推荐系统](https://blog.csdn.net/oppo62258801/article/details/104236828)
|
||||
* [揭秘 Deep & Cross : 如何自动构造高阶交叉特征](https://zhuanlan.zhihu.com/p/55234968)
|
||||
* [一文读懂xDeepFM](https://zhuanlan.zhihu.com/p/110076629)
|
||||
* [推荐系统 - xDeepFM架构详解](https://blog.csdn.net/maqunfi/article/details/99664119)
|
||||
|
||||
177
2023旧版内容/4.人工智能/ch02/ch2.2/ch2.2.4/DIEN.md
Normal file
177
2023旧版内容/4.人工智能/ch02/ch2.2/ch2.2.4/DIEN.md
Normal file
@@ -0,0 +1,177 @@
|
||||
# DIEN
|
||||
## DIEN提出的动机
|
||||
在推荐场景,用户无需输入搜索关键词来表达意图,这种情况下捕捉用户兴趣并考虑兴趣的动态变化将是提升模型效果的关键。以Wide&Deep为代表的深度模型更多的是考虑不同field特征之间的相互作用,未关注用户兴趣。
|
||||
|
||||
DIN模型考虑了用户兴趣,并且强调用户兴趣是多样的,该模型使用注意力机制来捕捉和**target item**的相关的兴趣,这样以来用户的兴趣就会随着目标商品自适应的改变。但是大多该类模型包括DIN在内,直接将用户的行为当做用户的兴趣(因为DIN模型只是在行为序列上做了简单的特征处理),但是用户潜在兴趣一般很难直接通过用户的行为直接表示,大多模型都没有挖掘用户行为背后真实的兴趣,捕捉用户兴趣的动态变化对用户兴趣的表示非常重要。DIEN相比于之前的模型,即对用户的兴趣进行建模,又对建模出来的用户兴趣继续建模得到用户的兴趣变化过程。
|
||||
|
||||
## DIEN模型原理
|
||||
<div align=center>
|
||||
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片image-20210218155901144.png" alt="image-20210218155901144" style="zoom:50%;" />
|
||||
</div>
|
||||
|
||||
模型的输入可以分成两大部分,一部分是用户的行为序列(这部分会通过兴趣提取层及兴趣演化层转换成与用户当前兴趣相关的embedding),另一部分就是除了用户行为以外的其他所有特征,如Target id, Coontext Feature, UserProfile Feature,这些特征都转化成embedding的类型然后concat在一起(形成一个大的embedding)作为非行为相关的特征(这里可能也会存在一些非id类特征,应该可以直接进行concat)。最后DNN输入的部分由行为序列embedding和非行为特征embedding(多个特征concat到一起之后形成的一个大的向量)组成,将两者concat之后输入到DNN中。
|
||||
|
||||
所以DIEN模型的重点就是如何将用户的行为序列转换成与用户兴趣相关的向量,在DIN中是直接通过与target item计算序列中每个元素的注意力分数,然后加权求和得到最终的兴趣表示向量。在DIEN中使用了两层结构来建模用户兴趣相关的向量。
|
||||
|
||||
### Interest Exterator Layer
|
||||
|
||||
兴趣抽取层的输入原本是一个id序列(按照点击时间的先后顺序形成的一个序列),通过Embedding层将其转化成一个embedding序列。然后使用GRU模块对兴趣进行抽取,GRU的输入是embedding层之后得到的embedding序列。对于GRU模块不是很了解的可以看一下[动手学深度学习中GRU相关的内容](https://zh.d2l.ai/chapter_recurrent-neural-networks/gru.html)
|
||||
|
||||
作者并没有直接完全使用原始的GRU来提取用户的兴趣,而是引入了一个辅助函数来指导用户兴趣的提取。作者认为如果直接使用GRU提取用户的兴趣,只能得到用户行为之间的依赖关系,不能有效的表示用户的兴趣。因为是用户的兴趣导致了用户的点击,用户的最后一次点击与用户点击之前的兴趣相关性就很强,但是直接使用行为序列训练GRU的话,只有用户最后一次点击的物品(也就是label,在这里可以认为是Target Ad), 那么最多就是能够捕捉到用户最后一次点击时的兴趣,而最后一次的兴趣又和前面点击过的物品在兴趣上是相关的,而前面点击的物品中并没有target item进行监督。**所以作者提出的辅助损失就是为了让行为序列中的每一个时刻都有一个target item进行监督训练,也就是使用下一个行为来监督兴趣状态的学习**
|
||||
|
||||
**辅助损失**
|
||||
首先需要明确的就是辅助损失是计算哪两个量的损失。计算的是用户每个时刻的兴趣表示(GRU每个时刻输出的隐藏状态形成的序列)与用户当前时刻实际点击的物品表示(输入的embedding序列)之间的损失,相当于是行为序列中的第t+1个物品与用户第t时刻的兴趣表示之间的损失**(为什么这里用户第t时刻的兴趣与第t+1时刻的真实点击做损失呢?我的理解是,只有知道了用户第t+1真实点击的商品,才能更好的确定用户第t时刻的兴趣)。**
|
||||
|
||||
<div align=center>
|
||||
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片image-20210218163742638.png" alt="image-20210218163742638" style="zoom:50%;" />
|
||||
</div>
|
||||
|
||||
当然,如果只计算用户点击物品与其点击前一次的兴趣之间的损失,只能认为是正样本之间的损失,那么用户第t时刻的兴趣其实还有很多其他的未点击的商品,这些未点击的商品就是负样本,负样本一般通过从用户点击序列中采样得到,这样一来辅助损失中就包含了用户某个时刻下的兴趣及与该时刻兴趣相关的正负物品。所以最终的损失函数表示如下。
|
||||
|
||||
<div align=center>
|
||||
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片image-20210218162447125.png" alt="image-20210218162447125" style="zoom: 25%;" />
|
||||
</div>
|
||||
其中$h_t^i$表示的是用户$i$第$t$时刻的隐藏状态,可以表示用户第$t$时刻的兴趣向量,$e_b^i,\hat{e_b^i}$分别表示的是正负样本,$e_b^i[t+1]$表示的是用户$i$第$t+1$时刻点击的物品向量。
|
||||
|
||||
辅助损失会加到最终的目标损失(ctr损失)中一起进行优化,并且通过$\alpha$参数来平衡点击率和兴趣的关系
|
||||
$$
|
||||
L = L_{target} + \alpha L_{aux}
|
||||
$$
|
||||
|
||||
**引入辅助函数的函数有:**
|
||||
|
||||
- 辅助loss可以帮助GRU的隐状态更好地表示用户兴趣。
|
||||
|
||||
- RNN在长序列建模场景下梯度传播可能并不能很好的影响到序列开始部分,如果在序列的每个部分都引入一个辅助的监督信号,则可一定程度降低优化难度。
|
||||
|
||||
- 辅助loss可以给embedding层的学习带来更多语义信息,学习到item对应的更好的embedding。
|
||||
|
||||
### Interest Evolving Layer
|
||||
将用户的行为序列通过GRU+辅助损失建模之后,对用户行为序列中的兴趣进行了提取并表达成了向量的形式(GRU每个时刻输出的隐藏状态)。而用户的兴趣会因为外部环境或内部认知随着时间变化,特点如下:
|
||||
|
||||
- **兴趣是多样化的,可能发生漂移**。兴趣漂移对行为的影响是用户可能在一段时间内对各种书籍感兴趣,而在另一段时间却需要衣服
|
||||
|
||||
- 虽然兴趣可能会相互影响,但是**每一种兴趣都有自己的发展过程**,例如书和衣服的发展过程几乎是独立的。**而我们只关注与target item相关的演进过程。**
|
||||
|
||||
由于用户的兴趣是多样的,但是用户的每一种兴趣都有自己的发展过程,即使兴趣发生漂移我们可以只考虑用户与target item(广告或者商品)相关的兴趣演化过程,这样就不用考虑用户多样化的兴趣的问题了,而如何只获取与target item相关的信息,作者使用了与DIN模型中提取与target item相同的方法,来计算用户历史兴趣与target item之间的相似度,即这里也使用了DIN中介绍的局部激活单元(就是下图中的Attention模块)。
|
||||
|
||||
<div align=center>
|
||||
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片image-20210218180755462.png" alt="image-20210218180755462" style="zoom:70%;" />
|
||||
</div>
|
||||
|
||||
当得到了用户历史兴趣序列及兴趣序列与target item之间的相关性(注意力分数)之后,就需要再次对注意力序列进行建模得到用户注意力的演化过程,进一步表示用户最终的兴趣向量。此时的序列数据等同于有了一个序列及序列中每个向量的注意力权重,下面就是考虑如何使用这个注意力权重来一起优化序列建模的结果了。作者提出了三种注意力结合的GRU模型快:
|
||||
|
||||
1. **AIGRU:** 将注意力分数直接与输入的序列进行相乘,也就是权重越大的向量对应的值也越大, 其中$i_t^{'}, h_t, a_t$分别表示用户$i$在兴趣演化过程使用的GRU的第t时刻的输入,$h_t$表示的是兴趣抽取层第t时刻的输出,$a_t$表示的是$h_t$的注意力分数,这种方式的弊端是即使是零输入也会改变GRU的隐藏状态,所以相对较少的兴趣值也会影响兴趣的学习进化(根据GRU门的更新公式就可以知道,下一个隐藏状态的计算会用到上一个隐藏状态的信息,所以即使当前输入为0,最终隐藏状态也不会直接等于0,所以即使兴趣较少,也会影响到最终兴趣的演化)。
|
||||
$$
|
||||
i_t^{'} = h_t * a_t
|
||||
$$
|
||||
|
||||
2. **AGRU:** 将注意力分数直接作为GRU模块中,更新门的值,则重置门对应的值表示为$1-a_t$, 所以最终隐藏状态的更新公式表示为:其中$\hat{h_t^{'}}$表示的是候选隐藏状态。但是这种方式的弊端是弱化了兴趣之间的相关性,因为最终兴趣的更新前后是没关系的,只取决于输入的注意力分数
|
||||
$$
|
||||
h_t^{'} = (1-a_t)h_{t-1}^{'} + a_t * \tilde{h_t^{'}}
|
||||
$$
|
||||
|
||||
3. **AUGRU:** 将注意力分数作为更新门的权重,这样既兼顾了注意力分数很低时的状态更新值,也利用了兴趣之间的相关性,最终的表达式如下:
|
||||
$$
|
||||
\begin{align}
|
||||
& \tilde{u_t^{'}} = a_t * u_t \\
|
||||
& h_t^{'} = (1-\tilde{u_t^{'}})h_{t-1}^{'} + \tilde{u_t^{'}} * \tilde{h_t^{'}}
|
||||
\end{align}
|
||||
$$
|
||||
|
||||
**建模兴趣演化过程的好处:**
|
||||
- 追踪用户的interest可以使我们学习final interest的表达时包含更多的历史信息
|
||||
- 可以根据interest的变化趋势更好地进行CTR预测
|
||||
|
||||
## 代码实现
|
||||
下面我们看下DIN的代码复现,这里主要是给大家说一下这个模型的设计逻辑,参考了deepctr的函数API的编程风格, 具体的代码以及示例大家可以去参考后面的GitHub,里面已经给出了详细的注释, 这里主要分析模型的逻辑这块。关于函数API的编程式风格,我们还给出了一份文档, 大家可以先看这个,再看后面的代码部分,会更加舒服些。下面开始:
|
||||
|
||||
这里主要和大家说一下DIN模型的总体运行逻辑,这样可以让大家从宏观的层面去把握模型的编写过程。该模型所使用的数据集是movielens数据集, 具体介绍可以参考后面的GitHub。 因为上面反复强调了DIN的应用场景,需要基于用户的历史行为数据, 所以在这个数据集中会有用户过去对电影评分的一系列行为。这在之前的数据集中往往是看不到的。 大家可以导入数据之后自行查看这种行为特征(hist_behavior)。另外还有一点需要说明的是这种历史行为是序列性质的特征, 并且**不同的用户这种历史行为特征长度会不一样**, 但是我们的神经网络是要求序列等长的,所以这种情况我们一般会按照最长的序列进行padding的操作(不够长的填0), 而到具体层上进行运算的时候,会用mask掩码的方式标记出这些填充的位置,好保证计算的准确性。 在我们给出的代码中,大家会在AttentionPoolingLayer层的前向传播中看到这种操作。下面开始说编写逻辑:
|
||||
|
||||
首先, DIN模型的输入特征大致上分为了三类: Dense(连续型), Sparse(离散型), VarlenSparse(变长离散型),也就是指的上面的历史行为数据。而不同的类型特征也就决定了后面处理的方式会不同:
|
||||
|
||||
* Dense型特征:由于是数值型了,这里为每个这样的特征建立Input层接收这种输入, 然后拼接起来先放着,等离散的那边处理好之后,和离散的拼接起来进DNN
|
||||
* Sparse型特征,为离散型特征建立Input层接收输入,然后需要先通过embedding层转成低维稠密向量,然后拼接起来放着,等变长离散那边处理好之后, 一块拼起来进DNN, 但是这里面要注意有个特征的embedding向量还得拿出来用,就是候选商品的embedding向量,这个还得和后面的计算相关性,对历史行为序列加权。
|
||||
* VarlenSparse型特征:这个一般指的用户的历史行为特征,变长数据, 首先会进行padding操作成等长, 然后建立Input层接收输入,然后通过embedding层得到各自历史行为的embedding向量, 拿着这些向量与上面的候选商品embedding向量进入AttentionPoolingLayer去对这些历史行为特征加权合并,最后得到输出。
|
||||
|
||||
通过上面的三种处理, 就得到了处理好的连续特征,离散特征和变长离散特征, 接下来把这三种特征拼接,进DNN网络,得到最后的输出结果即可。所以有了这个解释, 就可以放DIN模型的代码全貌了,大家可以感受下我上面解释的:
|
||||
|
||||
```python
|
||||
def DIEN(feature_columns, behavior_feature_list, behavior_seq_feature_list, neg_seq_feature_list, use_neg_sample=False, alpha=1.0):
|
||||
# 构建输入层
|
||||
input_layer_dict = build_input_layers(feature_columns)
|
||||
|
||||
# 将Input层转化为列表的形式作为model的输入
|
||||
input_layers = list(input_layer_dict.values()) # 各个输入层
|
||||
user_behavior_length = input_layer_dict["hist_len"]
|
||||
|
||||
# 筛选出特征中的sparse_fea, dense_fea, varlen_fea
|
||||
sparse_feature_columns = list(filter(lambda x: isinstance(x, SparseFeat), feature_columns)) if feature_columns else []
|
||||
dense_feature_columns = list(filter(lambda x: isinstance(x, DenseFeat), feature_columns)) if feature_columns else []
|
||||
varlen_sparse_feature_columns = list(filter(lambda x: isinstance(x, VarLenSparseFeat), feature_columns)) if feature_columns else []
|
||||
|
||||
# 获取dense
|
||||
dnn_dense_input = []
|
||||
for fc in dense_feature_columns:
|
||||
dnn_dense_input.append(input_layer_dict[fc.name])
|
||||
|
||||
# 将所有的dense特征拼接
|
||||
dnn_dense_input = concat_input_list(dnn_dense_input)
|
||||
|
||||
# 构建embedding字典
|
||||
embedding_layer_dict = build_embedding_layers(feature_columns, input_layer_dict)
|
||||
|
||||
# 因为这里最终需要将embedding拼接后直接输入到全连接层(Dense)中, 所以需要Flatten
|
||||
dnn_sparse_embed_input = concat_embedding_list(sparse_feature_columns, input_layer_dict, embedding_layer_dict, flatten=True)
|
||||
# 将所有sparse特征的embedding进行拼接
|
||||
dnn_sparse_input = concat_input_list(dnn_sparse_embed_input)
|
||||
|
||||
# 获取当前的行为特征(movie)的embedding,这里有可能有多个行为产生了行为序列,所以需要使用列表将其放在一起
|
||||
query_embed_list = embedding_lookup(behavior_feature_list, input_layer_dict, embedding_layer_dict)
|
||||
# 获取行为序列(movie_id序列, hist_movie_id) 对应的embedding,这里有可能有多个行为产生了行为序列,所以需要使用列表将其放在一起
|
||||
keys_embed_list = embedding_lookup(behavior_seq_feature_list, input_layer_dict, embedding_layer_dict)
|
||||
# 把q,k的embedding拼在一块
|
||||
query_emb, keys_emb = concat_input_list(query_embed_list), concat_input_list(keys_embed_list)
|
||||
|
||||
# 采样的负行为
|
||||
neg_uiseq_embed_list = embedding_lookup(neg_seq_feature_list, input_layer_dict, embedding_layer_dict)
|
||||
neg_concat_behavior = concat_input_list(neg_uiseq_embed_list)
|
||||
|
||||
# 兴趣进化层的计算过程
|
||||
dnn_seq_input, aux_loss = interest_evolution(keys_emb, query_emb, user_behavior_length, neg_concat_behavior, gru_type="AUGRU")
|
||||
|
||||
# 后面的全连接层
|
||||
deep_input_embed = Concatenate()([dnn_dense_input, dnn_sparse_input, dnn_seq_input])
|
||||
|
||||
# 获取最终dnn的logits
|
||||
dnn_logits = get_dnn_logits(deep_input_embed, activation='prelu')
|
||||
model = Model(input_layers, dnn_logits)
|
||||
|
||||
# 加兴趣提取层的损失 这个比例可调
|
||||
if use_neg_sample:
|
||||
model.add_loss(alpha * aux_loss)
|
||||
|
||||
# 所有变量需要初始化
|
||||
tf.compat.v1.keras.backend.get_session().run(tf.compat.v1.global_variables_initializer())
|
||||
return model
|
||||
```
|
||||
|
||||
关于每一块的细节,这里就不解释了,在我们给出的GitHub代码中,我们已经加了非常详细的注释,大家看那个应该很容易看明白, 为了方便大家的阅读,我们这里还给大家画了一个整体的模型架构图,帮助大家更好的了解每一块以及前向传播。(画的图不是很规范,先将就看一下,后面我们会统一在优化一下这个手工图)。
|
||||
|
||||
下面是一个通过keras画的模型结构图,为了更好的显示,数值特征和类别特征都只是选择了一小部分,画图的代码也在github中(看不清的话可以自己用代码生成之后使用其他的软件打开看)。
|
||||
|
||||
> 下面这个图失效了
|
||||
<div align=center>
|
||||
此处无图
|
||||
</div>
|
||||
|
||||
## 思考
|
||||
1. 对于知乎上大佬们对DIEN的探讨,你有什么看法呢?[也评Deep Interest Evolution Network](https://zhuanlan.zhihu.com/p/54838663)
|
||||
|
||||
|
||||
**参考资料**
|
||||
- [deepctr](https://github.com/shenweichen/DeepCTR)
|
||||
- [原论文](https://arxiv.org/pdf/1809.03672.pdf)
|
||||
- [论文阅读-阿里DIEN深度兴趣进化网络之总体解读](https://mp.weixin.qq.com/s/IlVZCVtDco3hWuvnsUmekg)
|
||||
- [也评Deep Interest Evolution Network](https://zhuanlan.zhihu.com/p/54838663)
|
||||
|
||||
177
2023旧版内容/4.人工智能/ch02/ch2.2/ch2.2.4/DIN.md
Normal file
177
2023旧版内容/4.人工智能/ch02/ch2.2/ch2.2.4/DIN.md
Normal file
@@ -0,0 +1,177 @@
|
||||
# DIN
|
||||
## 动机
|
||||
Deep Interest Network(DIIN)是2018年阿里巴巴提出来的模型, 该模型基于业务的观察,从实际应用的角度进行改进,相比于之前很多“学术风”的深度模型, 该模型更加具有业务气息。该模型的应用场景是阿里巴巴的电商广告推荐业务, 这样的场景下一般**会有大量的用户历史行为信息**, 这个其实是很关键的,因为DIN模型的创新点或者解决的问题就是使用了注意力机制来对用户的兴趣动态模拟, 而这个模拟过程存在的前提就是用户之前有大量的历史行为了,这样我们在预测某个商品广告用户是否点击的时候,就可以参考他之前购买过或者查看过的商品,这样就能猜测出用户的大致兴趣来,这样我们的推荐才能做的更加到位,所以这个模型的使用场景是**非常注重用户的历史行为特征(历史购买过的商品或者类别信息)**,也希望通过这一点,能够和前面的一些深度学习模型对比一下。
|
||||
|
||||
在个性化的电商广告推荐业务场景中,也正式由于用户留下了大量的历史交互行为,才更加看出了之前的深度学习模型(作者统称Embeding&MLP模型)的不足之处。如果学习了前面的各种深度学习模型,就会发现Embeding&MLP模型对于这种推荐任务一般有着差不多的固定处理套路,就是大量稀疏特征先经过embedding层, 转成低维稠密的,然后进行拼接,最后喂入到多层神经网络中去。
|
||||
|
||||
这些模型在这种个性化广告点击预测任务中存在的问题就是**无法表达用户广泛的兴趣**,因为这些模型在得到各个特征的embedding之后,就蛮力拼接了,然后就各种交叉等。这时候根本没有考虑之前用户历史行为商品具体是什么,究竟用户历史行为中的哪个会对当前的点击预测带来积极的作用。 而实际上,对于用户点不点击当前的商品广告,很大程度上是依赖于他的历史行为的,王喆老师举了个例子
|
||||
|
||||
>假设广告中的商品是键盘, 如果用户历史点击的商品中有化妆品, 包包,衣服, 洗面奶等商品, 那么大概率上该用户可能是对键盘不感兴趣的, 而如果用户历史行为中的商品有鼠标, 电脑,iPad,手机等, 那么大概率该用户对键盘是感兴趣的, 而如果用户历史商品中有鼠标, 化妆品, T-shirt和洗面奶, 鼠标这个商品embedding对预测“键盘”广告的点击率的重要程度应该大于后面的那三个。
|
||||
|
||||
这里也就是说如果是之前的那些深度学习模型,是没法很好的去表达出用户这广泛多样的兴趣的,如果想表达的准确些, 那么就得加大隐向量的维度,让每个特征的信息更加丰富, 那这样带来的问题就是计算量上去了,毕竟真实情景尤其是电商广告推荐的场景,特征维度的规模是非常大的。 并且根据上面的例子, 也**并不是用户所有的历史行为特征都会对某个商品广告点击预测起到作用**。所以对于当前某个商品广告的点击预测任务,没必要考虑之前所有的用户历史行为。
|
||||
|
||||
这样, DIN的动机就出来了,在业务的角度,我们应该自适应的去捕捉用户的兴趣变化,这样才能较为准确的实施广告推荐;而放到模型的角度, 我们应该**考虑到用户的历史行为商品与当前商品广告的一个关联性**,如果用户历史商品中很多与当前商品关联,那么说明该商品可能符合用户的品味,就把该广告推荐给他。而一谈到关联性的话, 我们就容易想到“注意力”的思想了, 所以为了更好的从用户的历史行为中学习到与当前商品广告的关联性,学习到用户的兴趣变化, 作者把注意力引入到了模型,设计了一个"local activation unit"结构,利用候选商品和历史问题商品之间的相关性计算出权重,这个就代表了对于当前商品广告的预测,用户历史行为的各个商品的重要程度大小, 而加入了注意力权重的深度学习网络,就是这次的主角DIN, 下面具体来看下该模型。
|
||||
|
||||
## DIN模型结构及原理
|
||||
在具体分析DIN模型之前, 我们还得先介绍两块小内容,一个是DIN模型的数据集和特征表示, 一个是上面提到的之前深度学习模型的基线模型, 有了这两个, 再看DIN模型,就感觉是水到渠成了。
|
||||
|
||||
### 特征表示
|
||||
工业上的CTR预测数据集一般都是`multi-group categorial form`的形式,就是类别型特征最为常见,这种数据集一般长这样:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210118190044920.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" style="zoom: 67%;" />
|
||||
</div>
|
||||
|
||||
这里的亮点就是框出来的那个特征,这个包含着丰富的用户兴趣信息。
|
||||
|
||||
对于特征编码,作者这里举了个例子:`[weekday=Friday, gender=Female, visited_cate_ids={Bag,Book}, ad_cate_id=Book]`, 这种情况我们知道一般是通过one-hot的形式对其编码, 转成系数的二值特征的形式。但是这里我们会发现一个`visted_cate_ids`, 也就是用户的历史商品列表, 对于某个用户来讲,这个值是个多值型的特征, 而且还要知道这个特征的长度不一样长,也就是用户购买的历史商品个数不一样多,这个显然。这个特征的话,我们一般是用到multi-hot编码,也就是可能不止1个1了,有哪个商品,对应位置就是1, 所以经过编码后的数据长下面这个样子:
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210118185933510.png" style="zoom:67%;" />
|
||||
</div>
|
||||
这个就是喂入模型的数据格式了,这里还要注意一点 就是上面的特征里面没有任何的交互组合,也就是没有做特征交叉。这个交互信息交给后面的神经网络去学习。
|
||||
|
||||
### 基线模型
|
||||
|
||||
这里的base 模型,就是上面提到过的Embedding&MLP的形式, 这个之所以要介绍,就是因为DIN网络的基准也是他,只不过在这个的基础上添加了一个新结构(注意力网络)来学习当前候选广告与用户历史行为特征的相关性,从而动态捕捉用户的兴趣。
|
||||
|
||||
基准模型的结构相对比较简单,我们前面也一直用这个基准, 分为三大模块:Embedding layer,Pooling & Concat layer和MLP, 结构如下:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210118191224464.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" style="zoom:80%;" />
|
||||
</div>
|
||||
|
||||
前面的大部分深度模型结构也是遵循着这个范式套路, 简介一下各个模块。
|
||||
|
||||
1. **Embedding layer**:这个层的作用是把高维稀疏的输入转成低维稠密向量, 每个离散特征下面都会对应着一个embedding词典, 维度是$D\times K$, 这里的$D$表示的是隐向量的维度, 而$K$表示的是当前离散特征的唯一取值个数, 这里为了好理解,这里举个例子说明,就比如上面的weekday特征:
|
||||
|
||||
> 假设某个用户的weekday特征就是周五,化成one-hot编码的时候,就是[0,0,0,0,1,0,0]表示,这里如果再假设隐向量维度是D, 那么这个特征对应的embedding词典是一个$D\times7$的一个矩阵(每一列代表一个embedding,7列正好7个embedding向量,对应周一到周日),那么该用户这个one-hot向量经过embedding层之后会得到一个$D\times1$的向量,也就是周五对应的那个embedding,怎么算的,其实就是$embedding矩阵* [0,0,0,0,1,0,0]^T$ 。其实也就是直接把embedding矩阵中one-hot向量为1的那个位置的embedding向量拿出来。 这样就得到了稀疏特征的稠密向量了。其他离散特征也是同理,只不过上面那个multi-hot编码的那个,会得到一个embedding向量的列表,因为他开始的那个multi-hot向量不止有一个是1,这样乘以embedding矩阵,就会得到一个列表了。通过这个层,上面的输入特征都可以拿到相应的稠密embedding向量了。
|
||||
|
||||
2. **pooling layer and Concat layer**: pooling层的作用是将用户的历史行为embedding这个最终变成一个定长的向量,因为每个用户历史购买的商品数是不一样的, 也就是每个用户multi-hot中1的个数不一致,这样经过embedding层,得到的用户历史行为embedding的个数不一样多,也就是上面的embedding列表$t_i$不一样长, 那么这样的话,每个用户的历史行为特征拼起来就不一样长了。 而后面如果加全连接网络的话,我们知道,他需要定长的特征输入。 所以往往用一个pooling layer先把用户历史行为embedding变成固定长度(统一长度),所以有了这个公式:
|
||||
$$
|
||||
e_i=pooling(e_{i1}, e_{i2}, ...e_{ik})
|
||||
$$
|
||||
这里的$e_{ij}$是用户历史行为的那些embedding。$e_i$就变成了定长的向量, 这里的$i$表示第$i$个历史特征组(是历史行为,比如历史的商品id,历史的商品类别id等), 这里的$k$表示对应历史特种组里面用户购买过的商品数量,也就是历史embedding的数量,看上面图里面的user behaviors系列,就是那个过程了。 Concat layer层的作用就是拼接了,就是把这所有的特征embedding向量,如果再有连续特征的话也算上,从特征维度拼接整合,作为MLP的输入。
|
||||
|
||||
3. **MLP**:这个就是普通的全连接,用了学习特征之间的各种交互。
|
||||
|
||||
4. **Loss**: 由于这里是点击率预测任务, 二分类的问题,所以这里的损失函数用的负的log对数似然:
|
||||
$$
|
||||
L=-\frac{1}{N} \sum_{(\boldsymbol{x}, y) \in \mathcal{S}}(y \log p(\boldsymbol{x})+(1-y) \log (1-p(\boldsymbol{x})))
|
||||
$$
|
||||
|
||||
这就是base 模型的全貌, 这里应该能看出这种模型的问题, 通过上面的图也能看出来, 用户的历史行为特征和当前的候选广告特征在全都拼起来给神经网络之前,是一点交互的过程都没有, 而拼起来之后给神经网络,虽然是有了交互了,但是原来的一些信息,比如,每个历史商品的信息会丢失了一部分,因为这个与当前候选广告商品交互的是池化后的历史特征embedding, 这个embedding是综合了所有的历史商品信息, 这个通过我们前面的分析,对于预测当前广告点击率,并不是所有历史商品都有用,综合所有的商品信息反而会增加一些噪声性的信息,可以联想上面举得那个键盘鼠标的例子,如果加上了各种洗面奶,衣服啥的反而会起到反作用。其次就是这样综合起来,已经没法再看出到底用户历史行为中的哪个商品与当前商品比较相关,也就是丢失了历史行为中各个商品对当前预测的重要性程度。最后一点就是如果所有用户浏览过的历史行为商品,最后都通过embedding和pooling转换成了固定长度的embedding,这样会限制模型学习用户的多样化兴趣。
|
||||
|
||||
那么改进这个问题的思路有哪些呢? 第一个就是加大embedding的维度,增加之前各个商品的表达能力,这样即使综合起来,embedding的表达能力也会加强, 能够蕴涵用户的兴趣信息,但是这个在大规模的真实推荐场景计算量超级大,不可取。 另外一个思路就是**在当前候选广告和用户的历史行为之间引入注意力的机制**,这样在预测当前广告是否点击的时候,让模型更关注于与当前广告相关的那些用户历史产品,也就是说**与当前商品更加相关的历史行为更能促进用户的点击行为**。 作者这里又举了之前的一个例子:
|
||||
> 想象一下,当一个年轻母亲访问电子商务网站时,她发现展示的新手袋很可爱,就点击它。让我们来分析一下点击行为的驱动力。<br><br>展示的广告通过软搜索这位年轻母亲的历史行为,发现她最近曾浏览过类似的商品,如大手提袋和皮包,从而击中了她的相关兴趣
|
||||
|
||||
第二个思路就是DIN的改进之处了。DIN通过给定一个候选广告,然后去注意与该广告相关的局部兴趣的表示来模拟此过程。 DIN不会通过使用同一向量来表达所有用户的不同兴趣,而是通过考虑历史行为的相关性来自适应地计算用户兴趣的表示向量(对于给的广告)。 该表示向量随不同广告而变化。下面看一下DIN模型。
|
||||
|
||||
### DIN模型架构
|
||||
|
||||
上面分析完了base模型的不足和改进思路之后,DIN模型的结构就呼之欲出了,首先,它依然是采用了基模型的结构,只不过是在这个的基础上加了一个注意力机制来学习用户兴趣与当前候选广告间的关联程度, 用论文里面的话是,引入了一个新的`local activation unit`, 这个东西用在了用户历史行为特征上面, **能够根据用户历史行为特征和当前广告的相关性给用户历史行为特征embedding进行加权**。我们先看一下它的结构,然后看一下这个加权公式。
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210118220015871.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" style="zoom: 80%;" />
|
||||
</div>
|
||||
|
||||
这里改进的地方已经框出来了,这里会发现相比于base model, 这里加了一个local activation unit, 这里面是一个前馈神经网络,输入是用户历史行为商品和当前的候选商品, 输出是它俩之间的相关性, 这个相关性相当于每个历史商品的权重,把这个权重与原来的历史行为embedding相乘求和就得到了用户的兴趣表示$\boldsymbol{v}_{U}(A)$, 这个东西的计算公式如下:
|
||||
$$
|
||||
\boldsymbol{v}_{U}(A)=f\left(\boldsymbol{v}_{A}, \boldsymbol{e}_{1}, \boldsymbol{e}_{2}, \ldots, \boldsymbol{e}_{H}\right)=\sum_{j=1}^{H} a\left(\boldsymbol{e}_{j}, \boldsymbol{v}_{A}\right) \boldsymbol{e}_{j}=\sum_{j=1}^{H} \boldsymbol{w}_{j} \boldsymbol{e}_{j}
|
||||
$$
|
||||
这里的$\{\boldsymbol{v}_{A}, \boldsymbol{e}_{1}, \boldsymbol{e}_{2}, \ldots, \boldsymbol{e}_{H}\}$是用户$U$的历史行为特征embedding, $v_{A}$表示的是候选广告$A$的embedding向量, $a(e_j, v_A)=w_j$表示的权重或者历史行为商品与当前广告$A$的相关性程度。$a(\cdot)$表示的上面那个前馈神经网络,也就是那个所谓的注意力机制, 当然,看图里的话,输入除了历史行为向量和候选广告向量外,还加了一个它俩的外积操作,作者说这里是有利于模型相关性建模的显性知识。
|
||||
|
||||
这里有一点需要特别注意,就是这里的权重加和不是1, 准确的说这里不是权重, 而是直接算的相关性的那种分数作为了权重,也就是平时的那种scores(softmax之前的那个值),这个是为了保留用户的兴趣强度。
|
||||
|
||||
## DIN实现
|
||||
|
||||
下面我们看下DIN的代码复现,这里主要是给大家说一下这个模型的设计逻辑,参考了deepctr的函数API的编程风格, 具体的代码以及示例大家可以去参考后面的GitHub,里面已经给出了详细的注释, 这里主要分析模型的逻辑这块。关于函数API的编程式风格,我们还给出了一份文档, 大家可以先看这个,再看后面的代码部分,会更加舒服些。下面开始:
|
||||
|
||||
这里主要和大家说一下DIN模型的总体运行逻辑,这样可以让大家从宏观的层面去把握模型的编写过程。该模型所使用的数据集是movielens数据集, 具体介绍可以参考后面的GitHub。 因为上面反复强调了DIN的应用场景,需要基于用户的历史行为数据, 所以在这个数据集中会有用户过去对电影评分的一系列行为。这在之前的数据集中往往是看不到的。 大家可以导入数据之后自行查看这种行为特征(hist_behavior)。另外还有一点需要说明的是这种历史行为是序列性质的特征, 并且**不同的用户这种历史行为特征长度会不一样**, 但是我们的神经网络是要求序列等长的,所以这种情况我们一般会按照最长的序列进行padding的操作(不够长的填0), 而到具体层上进行运算的时候,会用mask掩码的方式标记出这些填充的位置,好保证计算的准确性。 在我们给出的代码中,大家会在AttentionPoolingLayer层的前向传播中看到这种操作。下面开始说编写逻辑:
|
||||
|
||||
首先, DIN模型的输入特征大致上分为了三类: Dense(连续型), Sparse(离散型), VarlenSparse(变长离散型),也就是指的上面的历史行为数据。而不同的类型特征也就决定了后面处理的方式会不同:
|
||||
|
||||
* Dense型特征:由于是数值型了,这里为每个这样的特征建立Input层接收这种输入, 然后拼接起来先放着,等离散的那边处理好之后,和离散的拼接起来进DNN
|
||||
* Sparse型特征,为离散型特征建立Input层接收输入,然后需要先通过embedding层转成低维稠密向量,然后拼接起来放着,等变长离散那边处理好之后, 一块拼起来进DNN, 但是这里面要注意有个特征的embedding向量还得拿出来用,就是候选商品的embedding向量,这个还得和后面的计算相关性,对历史行为序列加权。
|
||||
* VarlenSparse型特征:这个一般指的用户的历史行为特征,变长数据, 首先会进行padding操作成等长, 然后建立Input层接收输入,然后通过embedding层得到各自历史行为的embedding向量, 拿着这些向量与上面的候选商品embedding向量进入AttentionPoolingLayer去对这些历史行为特征加权合并,最后得到输出。
|
||||
|
||||
通过上面的三种处理, 就得到了处理好的连续特征,离散特征和变长离散特征, 接下来把这三种特征拼接,进DNN网络,得到最后的输出结果即可。所以有了这个解释, 就可以放DIN模型的代码全貌了,大家可以感受下我上面解释的:
|
||||
|
||||
```python
|
||||
# DIN网络搭建
|
||||
def DIN(feature_columns, behavior_feature_list, behavior_seq_feature_list):
|
||||
"""
|
||||
这里搭建DIN网络,有了上面的各个模块,这里直接拼起来
|
||||
:param feature_columns: A list. 里面的每个元素是namedtuple(元组的一种扩展类型,同时支持序号和属性名访问组件)类型,表示的是数据的特征封装版
|
||||
:param behavior_feature_list: A list. 用户的候选行为列表
|
||||
:param behavior_seq_feature_list: A list. 用户的历史行为列表
|
||||
"""
|
||||
# 构建Input层并将Input层转成列表作为模型的输入
|
||||
input_layer_dict = build_input_layers(feature_columns)
|
||||
input_layers = list(input_layer_dict.values())
|
||||
|
||||
# 筛选出特征中的sparse和Dense特征, 后面要单独处理
|
||||
sparse_feature_columns = list(filter(lambda x: isinstance(x, SparseFeat), feature_columns))
|
||||
dense_feature_columns = list(filter(lambda x: isinstance(x, DenseFeat), feature_columns))
|
||||
|
||||
# 获取Dense Input
|
||||
dnn_dense_input = []
|
||||
for fc in dense_feature_columns:
|
||||
dnn_dense_input.append(input_layer_dict[fc.name])
|
||||
|
||||
# 将所有的dense特征拼接
|
||||
dnn_dense_input = concat_input_list(dnn_dense_input) # (None, dense_fea_nums)
|
||||
|
||||
# 构建embedding字典
|
||||
embedding_layer_dict = build_embedding_layers(feature_columns, input_layer_dict)
|
||||
|
||||
# 离散的这些特特征embedding之后,然后拼接,然后直接作为全连接层Dense的输入,所以需要进行Flatten
|
||||
dnn_sparse_embed_input = concat_embedding_list(sparse_feature_columns, input_layer_dict, embedding_layer_dict, flatten=True)
|
||||
|
||||
# 将所有的sparse特征embedding特征拼接
|
||||
dnn_sparse_input = concat_input_list(dnn_sparse_embed_input) # (None, sparse_fea_nums*embed_dim)
|
||||
|
||||
# 获取当前行为特征的embedding, 这里有可能有多个行为产生了行为列表,所以需要列表将其放在一起
|
||||
query_embed_list = embedding_lookup(behavior_feature_list, input_layer_dict, embedding_layer_dict)
|
||||
|
||||
# 获取历史行为的embedding, 这里有可能有多个行为产生了行为列表,所以需要列表将其放在一起
|
||||
keys_embed_list = embedding_lookup(behavior_seq_feature_list, input_layer_dict, embedding_layer_dict)
|
||||
# 使用注意力机制将历史行为的序列池化,得到用户的兴趣
|
||||
dnn_seq_input_list = []
|
||||
for i in range(len(keys_embed_list)):
|
||||
seq_embed = AttentionPoolingLayer()([query_embed_list[i], keys_embed_list[i]]) # (None, embed_dim)
|
||||
dnn_seq_input_list.append(seq_embed)
|
||||
|
||||
# 将多个行为序列的embedding进行拼接
|
||||
dnn_seq_input = concat_input_list(dnn_seq_input_list) # (None, hist_len*embed_dim)
|
||||
|
||||
# 将dense特征,sparse特征, 即通过注意力机制加权的序列特征拼接起来
|
||||
dnn_input = Concatenate(axis=1)([dnn_dense_input, dnn_sparse_input, dnn_seq_input]) # (None, dense_fea_num+sparse_fea_nums*embed_dim+hist_len*embed_dim)
|
||||
|
||||
# 获取最终的DNN的预测值
|
||||
dnn_logits = get_dnn_logits(dnn_input, activation='prelu')
|
||||
|
||||
model = Model(inputs=input_layers, outputs=dnn_logits)
|
||||
|
||||
return model
|
||||
```
|
||||
|
||||
关于每一块的细节,这里就不解释了,在我们给出的GitHub代码中,我们已经加了非常详细的注释,大家看那个应该很容易看明白, 为了方便大家的阅读,我们这里还给大家画了一个整体的模型架构图,帮助大家更好的了解每一块以及前向传播。(画的图不是很规范,先将就看一下,后面我们会统一在优化一下这个手工图)。
|
||||
|
||||
<div align=center>
|
||||
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片DIN_aaaa.png" alt="DIN_aaaa" style="zoom: 70%;" />
|
||||
</div>
|
||||
|
||||
下面是一个通过keras画的模型结构图,为了更好的显示,数值特征和类别特征都只是选择了一小部分,画图的代码也在github中。
|
||||
|
||||
<div align=center>
|
||||
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/图片din.png" alt="DIN_aaaa" style="zoom: 50%;" />
|
||||
</div>
|
||||
|
||||
## 思考
|
||||
DIN模型在工业上的应用还是比较广泛的, 大家可以自由去通过查资料看一下具体实践当中这个模型是怎么用的? 有什么问题?比如行为序列的制作是否合理, 如果时间间隔比较长的话应不应该分一下段? 再比如注意力机制那里能不能改成别的计算注意力的方式会好点?(我们也知道注意力机制的方式可不仅DNN这一种), 再比如注意力权重那里该不该加softmax? 这些其实都是可以值的思考探索的一些问题,根据实际的业务场景,大家也可以总结一些更加有意思的工业上应用该模型的技巧和tricks,欢迎一块讨论和分享。
|
||||
|
||||
**参考资料**
|
||||
* [DIN原论文](https://arxiv.org/pdf/1706.06978.pdf)
|
||||
* [deepctr](https://github.com/shenweichen/DeepCTR)
|
||||
* [AI上推荐 之 AFM与DIN模型(当推荐系统遇上了注意力机制)](https://blog.csdn.net/wuzhongqiang/article/details/109532346)
|
||||
* 王喆 - 《深度学习推荐系统》
|
||||
731
2023旧版内容/4.人工智能/ch02/ch2.2/ch2.2.4/DSIN.md
Normal file
731
2023旧版内容/4.人工智能/ch02/ch2.2/ch2.2.4/DSIN.md
Normal file
@@ -0,0 +1,731 @@
|
||||
## 写在前面
|
||||
DSIN全称是Deep Session Interest Network(深度会话兴趣网络), 重点在这个Session上,这个是在DIEN的基础上又进行的一次演化,这个模型的改进出发点依然是如何通过用户的历史点击行为,从里面更好的提取用户的兴趣以及兴趣的演化过程,这个模型就是从user历史行为信息挖掘方向上进行演化的。而提出的动机呢? 就是作者发现用户的行为序列的组成单位,其实应该是会话(按照用户的点击时间划分开的一段行为),每个会话里面的点击行为呢? 会高度相似,而会话与会话之间的行为,就不是那么相似了,但是像DIN,DIEN这两个模型,DIN的话,是直接忽略了行为之间的序列关系,使得对用户的兴趣建模或者演化不是很充分,而DIEN的话改进了DIN的序列关系的忽略缺点,但是忽视了行为序列的本质组成结构。所以阿里提出的DSIN模型就是从行为序列的组成结构会话的角度去进行用户兴趣的提取和演化过程的学习,在这个过程中用到了一些新的结构,比如Transformer中的多头注意力,比如双向LSTM结构,再比如前面的局部Attention结构。
|
||||
|
||||
|
||||
## DSIN模型的理论以及论文细节
|
||||
### DSIN的简介与进化动机
|
||||
DSIN模型全称叫做Deep Session Interest Network, 这个是阿里在2019年继DIEN之后的一个新模型, 这个模型依然是研究如何更好的从用户的历史行为中捕捉到用户的动态兴趣演化规律。而这个模型的改进动机呢? 就是作者认为之前的序列模型,比如DIEN等,忽视了序列的本质结构其实是由会话组成的:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210310143019924.png#pic_center" alt="在这里插入图片描述" style="zoom:90%;" />
|
||||
</div>
|
||||
|
||||
这是个啥意思呢? 其实举个例子就非常容易明白DIEN存在的问题了(DIN这里就不说了,这个存在的问题在DIEN那里说的挺详细了,这里看看DIEN有啥问题),上一篇文章中我们说DIEN为了能够更好的利用用户的历史行为信息,把序列模型引进了推荐系统,用来学习用户历史行为之间的关系, 用兴趣提取层来学习各个历史行为之间的关系,而为了更有针对性的模拟与目标广告相关的兴趣进化路径,又在兴趣提取层后面加了注意力机制和兴趣进化层网络。这样理论上就感觉挺完美的了啊。这里依然是把DIEN拿过来,也方便和后面的DSIN对比:<br>
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210221165854948.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" alt="在这里插入图片描述" style="zoom:90%;" />
|
||||
</div>
|
||||
|
||||
但这个模型存在个啥问题呢? **就是只关注了如何去改进网络,而忽略了用户历史行为序列本身的特点**, 其实我们仔细想想的话,用户过去可能有很多历史点击行为,比如`[item3, item45, item69, item21, .....]`, 这个按照用户的点击时间排好序了,既然我们说用户的兴趣是非常广泛且多变的,那么这一大串序列的商品中,往往出现的一个规律就是**在比较短的时间间隔内的商品往往会很相似,时间间隔长了之后,商品之间就会出现很大的差别**,这个是很容易理解的,一个用户在半个小时之内的浏览点击的几个商品的相似度和一个用户上午点击和晚上点击的商品的相似度很可能是不一样的。这其实就是作者说的`homogeneous`和`heterogeneous`。而DIEN模型呢? 它并没有考虑这个问题,而是会直接把这一大串行为序列放入GRU让它自己去学(当然我们其实可以人工考虑这个问题,然后如果发现序列很长的话我们也可以分成多个样本哈,当然这里不考虑这个问题),如果一大串序列一块让GRU学习的话,往往用户的行为快速改变和突然终止的序列会有很多噪声点,不利于模型的学习。
|
||||
|
||||
所以,作者这里就是从序列本身的特点出发, 把一个用户的行为序列分成了多个会话,所谓会话,其实就是按照时间间隔把序列分段,每一段的商品列表就是一个会话,那这时候,会话里面每个商品之间的相似度就比较大了,而会话与会话之间商品相似度就可能比较小。作者这里给了个例子:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210310144926564.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" alt="在这里插入图片描述" style="zoom:90%;" />
|
||||
</div>
|
||||
|
||||
这是某个用户过去的历史点击行为,然后按照30分钟的时间间隔进行的分段,分成了3段。这里就一下子看出上面说的那些是啥意思了吧。就像这个女生,前30分钟在看裤子,再过30分钟又停留在了化妆品,又过30分钟,又看衣服。这种现象是非常普遍的啊,反映了一个用户通常在某个会话里面会有非常单一的兴趣但是当过一段时间之后,兴趣就会突然的改变。这个时候如果再统一的考虑所有行为就不合理了呀。**这其实也是DSIN改进的动机了**, DSIN,这次的关键就是在S上。
|
||||
|
||||
那它要怎么改呢? 如果是我们的话应该怎么改呢? 那一定会说,这个简单啊,不是说DIEN没考虑序列本身的特点吗? 既然我们发现了上面用户点击行为的这种会话规律,那么我们把序列进行分段,然后再用DIEN不就完事了? 哈哈, 那当然可以呀, 如果想用DIEN的话确实可以这么玩, 但那样就没有新模型了啊,那不还是DIEN? 这样的改进思路是没法发顶会的哟哈哈。 下面分析下人家是怎么改进的。
|
||||
|
||||
简单的说是用了四步,这个也是DSIN模型的整体逻辑:
|
||||
|
||||
1. 首先, 分段这个是必须的了吧,也就是在用户行为序列输入到模型之前,要按照固定的时间间隔(比如30分钟)给他分开段,每一段里面的商品序列称为一个会话Session。 这个叫做**会话划分层**
|
||||
|
||||
2. 然后呢,就是学习商品时间的依赖关系或者序列关系,由于上面把一个整的行为序列划分成了多段,那么在这里就是每一段的商品时间的序列关系要进行学习,当然我们说可以用GRU, 不过这里作者用了**多头的注意力机制**,这个东西是在**多个角度研究一个会话里面各个商品的关联关系**, 相比GRU来讲,没有啥梯度消失,并且可以并行计算,比GRU可强大多了。这个叫做**会话兴趣提取层**
|
||||
3. 上面研究了会话内各个商品之间的关联关系,接下来就是研究会话与会话之间的关系了,虽然我们说各个会话之间的关联性貌似不太大,但是可别忘了会话可是能够表示一段时间内用户兴趣的, 所以研究会话与会话的关系其实就是在学习用户兴趣的演化规律,这里用了**双向的LSTM**,不仅看从现在到未来的兴趣演化,还能学习未来到现在的变化规律, 这个叫做**会话交互层**。
|
||||
4. 既然会话内各个商品之间的关系学到了,会话与会话之间的关系学到了,然后呢? 当然也是针对性的模拟与目标广告相关的兴趣进化路径了, 所以后面是**会话兴趣局部激活层**, 这个就是注意力机制, 每次关注与当前商品更相关的兴趣。
|
||||
|
||||
所以,我们细品一下,其实DSIN和思路和DIEN的思路是差不多的,无非就是用了一些新的结构,这样,我们就从宏观上感受了一波这个模型。接下来,研究架构细节了。 看看上面那几块到底是怎么玩的。
|
||||
|
||||
### DSIN的架构剖析
|
||||
这里在说DSIN之前,作者也是又复习了一下base model模型架构,这里我就不整理了,其实是和DIEN那里一模一样的,具体的可以参考我上一篇文章。直接看DSIN的架构:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210310151619214.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" alt="在这里插入图片描述" style="zoom:90%;" />
|
||||
</div>
|
||||
|
||||
这个模型第一印象又是挺吓人的。核心的就是上面剖析的那四块,这里也分别用不同颜色表示出来了。也及时右边的那几块,左边的那两块还是我们之前的套路,用户特征和商品特征的串联。这里主要研究右边那四块,作者在这里又强调了下DSIN的两个目的,而这两个目的就对应着本模型最核心的两个层(会话兴趣提取层和会话交互层):
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210310151921213.png#pic_center" alt="在这里插入图片描述" style="zoom:90%;" />
|
||||
</div>
|
||||
|
||||
#### Session Division Layer
|
||||
这一层是将用户的行为序列进行切分,首先将用户的点击行为按照时间排序,判断两个行为之间的时间间隔,如果前后间隔大于30min,就进行切分(划一刀), 当然30min不是定死的,具体跟着自己的业务场景来。
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210310152906432.png#pic_center" alt="在这里插入图片描述" style="zoom:90%;" />
|
||||
</div>
|
||||
|
||||
划分完了之后,我们就把一个行为序列$\mathbf{S}$转成了Sessions $\mathbf{Q}$,比如上面这个分成了4个会话,会分别用$\mathbf{Q_1}, \mathbf{Q_2}, \mathbf{Q_3}, \mathbf{Q_4}$表示。 第$k$个会话$\mathbf{Q_k}$中,又包含了$T$个行为,即
|
||||
$$
|
||||
\mathbf{Q}_{k}=\left[\mathbf{b}_{1} ; \ldots ; \mathbf{b}_{i} ; \ldots ; \mathbf{b}_{T}\right] \in \mathbb{R}^{T \times d_{\text {model }}}
|
||||
$$
|
||||
$\mathbf{b}_{i}$表示的是第$k$个会话里面的第$i$个点击行为(具体的item),这个东西是一个$d_{model}$维的embedding向量。所以$\mathbf{Q}_{k}$是一个$T \times d_{\text {model }}$维的。 而整个大$\mathbf{Q}$, 就是一个$K\times T \times d_{\text {model }}$维的矩阵。 这里的$K$指的是session的个数。 这样,就把这个给捋明白了。但要注意,这个层是在embedding层之后呀,也就是各个商品转成了embedding向量之后,我们再进行切割。
|
||||
|
||||
#### Session Interest Extractor Layer
|
||||
这个层是学习每个会话中各个行为之间的关系,之前也分析过,在同一个会话中的各个商品的相关性是非常大的。此外,作者这里还提到,用户的随意的那种点击行为会偏离用户当前会话兴趣的表达,所以**为了捕获同一会话中行为之间的内在关系,同时降低这些不相关行为的影响**,这里采用了multi-head self-attention。关于这个东西, 这里不会详细整理,可以参考我之前的文章。这里只给出两个最核心关键的图,有了这两个图,这里的知识就非常容易理解了哈哈。
|
||||
|
||||
第一个,就是Transformer的编码器的一小块:
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210310154530852.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" alt="在这里插入图片描述" style="zoom:90%;" />
|
||||
</div>
|
||||
|
||||
拿过来是为了更好的对比,看DSIN的结构的第二层,其实就是这个东西。 而这个东西的整体的计算过程,我在之前的文章中剖析好了:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20200220195348122.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_16,color_FFFFFF,t_70" alt="在这里插入图片描述" style="zoom:90%;" />
|
||||
</div>
|
||||
|
||||
有了上面这两张图,在解释这里就非常好说了。
|
||||
|
||||
这一块其实是分两步的,第一步叫做位置编码,而第二步就是self-attention计算关联。 同样,DSIN中也是这两步,只不过第一步里面的位置编码,作者在这里做了点改进,称为**Bias Encoding**。先看看这个是怎么做的。
|
||||
>这里先解释下为啥要进行位置编码或者Bias Encoding, 这是因为我们说self-attention机制是要去学习会话里面各个商品之间的关系的, 而商品我们知道是一个按照时间排好的小序列,由于后面的self-attention并没有循环神经网络的迭代运算,所以我们必须提供每个字的位置信息给后面的self-attention,这样后面self-attention的输出结果才能蕴含商品之间的顺序信息。
|
||||
|
||||
在Transformer中,对输入的序列会进行Positional Encoding。Positional Encoding对序列中每个物品,以及每个物品对应的Embedding的每个位置,进行了处理,如下:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210310155625208.png#pic_center" alt="在这里插入图片描述" style="zoom:90%;" />
|
||||
</div>
|
||||
|
||||
上式中$pos$指的是某个会话里面item位于第几个位置位置, 取值范围是$[0, max\_len]$, $i$指的是词向量的某个维度, 取值范围是$[0, embed \_ dim]$, 上面有$sin$和$cos$一组公式, 也就是对应着$embed \_ dim$维度的一组奇数和偶数的序号的维度, 例如$0, 1$一组, $2, 3$一组, 分别用上面的$sin$和$cos$函数做处理, 从而产生不同的周期性变化, 而位置嵌入在$embed \_ dim$维度上随着维度序号增大, 周期变化会越来越慢, 而产生一种包含位置信息的纹理, 位置嵌入函数的周期从$2 \pi$到$10000 * 2 \pi$变化, 而每一个位置在$embed \_ dim$维度上都会得到不同周期的$sin$和$cos$函数的取值组合, 从而产生独一的纹理位置信息, 模型从而学到位置之间的依赖关系和自然语言的时序特性。这个在这里说可能有些迷糊,具体可以去另一篇文章看细节,**总结起来,就是通过这个公式,可以让每个item在每个embedding维度上都有独特的位置信息。但注意,位置编码的矩阵和输入的维度是一样的,这样两者加起来之后就相当于原来的序列加上了位置信息** 。
|
||||
|
||||
而这里,作者并不是用的这种方式,这是因为在这里还需要考虑各个会话之间的位置信息,毕竟这里是多个会话,并且各个会话之间也是有位置顺序的呀,所以还需要对每个会话添加一个Positional Encoding, 在DSIN中,这种对位置的处理,称为Bias Encoding。
|
||||
|
||||
于是乎作者在这里提出了个$\mathbf{B E} \in \mathbb{R}^{K \times T \times d_{\text {model }}}$,会发现这个东西的维度和会话分割层得到的$\mathbf{Q}$的维度也是一样的啊,其实这个东西就是这里使用的位置编码。那么这个东西咋计算呢?
|
||||
$$
|
||||
\mathbf{B} \mathbf{E}_{(k, t, c)}=\mathbf{w}_{k}^{K}+\mathbf{w}_{t}^{T}+\mathbf{w}_{c}^{C}
|
||||
$$
|
||||
$\mathbf{B} \mathbf{E}_{(k, t, c)}$表示的是第$k$个会话中,第$t$个物品在第$c$维度这个位置上的偏置项(是一个数), 其中$\mathbf{w}^{K} \in \mathbb{R}^{K}$表示的会话层次上的偏置项(位置信息)。如果有$n$个样本的话,这个应该是$[n, K, 1, 1]$的矩阵, 后面两个维度表示的$T$和$emb \_dim$。$\mathbf{w}^{T} \in \mathbb{R}^{T}$这个是在会话里面时间位置层次上的偏置项(位置信息),这个应该是$[n, 1, T, 1]$的矩阵。$\mathbf{w}^{C} \in \mathbb{R}^{d_{\text {model }}}$这个是embedding维度层次上的偏置(位置信息), 这个应该是$[n, 1, 1, d_{model}]$的矩阵。 而上面的$\mathbf{w}_{k}^{K},\mathbf{w}_{t}^{T},\mathbf{w}_{c}^{C}$都是表示某个维度上的具体的数字,所以$\mathbf{B} \mathbf{E}_{(k, t, c)}$也是一个数。
|
||||
|
||||
所以$\mathbf{B} \mathbf{E}$就是一个$[n,K, T, d_{model}]$的矩阵(这里其实是借助了广播机制的),蕴含了每个会话,每个物品,每个embedding位置的位置信息,所以经过Bias编码之后,得到的结果如下:
|
||||
$$
|
||||
\mathbf{Q}=\mathbf{Q}+\mathbf{B} \mathbf{E}
|
||||
$$
|
||||
这个$\mathbf{Q}$的维度$[n,K, T, d_{model}]$, 当然这里我们先不考虑样本个数,所以是$[K, T, d_{model}]$。相比上面的transformer,这里会多出一个会话的维度来。
|
||||
|
||||
接下来,就是每个会话的序列都通过Transformer进行处理:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210310163256416.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" alt="在这里插入图片描述" style="zoom:90%;" />
|
||||
</div>
|
||||
|
||||
一定要注意,这里说的是每个会话,这里我特意把下面的$Q_1$框出来了,就是每个$Q_i$都会走这个自注意力机制,因为我们算的是某个会话当中各个物品之间的关系。这里的计算和Transformer的block的计算是一模一样的了, 我这里就拿一个会话来解释。
|
||||
|
||||
首先$Q_1$这是一个$T\times embed \_dim$的一个矩阵,这个就和上面transformer的那个是一模一样的了,细节的计算过程其实是一样的。
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20200220194509277.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_16,color_FFFFFF,t_70" alt="在这里插入图片描述" style="zoom:90%;" />
|
||||
</div>
|
||||
|
||||
这里在拿过更细的个图来解释,首先这个$Q_1$会过一个多头的注意力机制,这个东西干啥用呢? 原理这里不说,我们只要知道,这里的头其实是从某个角度去看各个物品之间的关系,而多头的意思就是从不同的角度去计算各个物品之间的关系, 比如各个物品在价格上啊,重量上啊,颜色上啊,时尚程度上啊等等这些不同方面的关系。然后就是看这个运算图,我们会发现self-attention的输出维度和输入维度也是一样的,但经过这个多头注意力的东西之后,**就能够得到当前的商品与其他商品在多个角度上的相关性**。怎么得到呢?
|
||||
>拿一个head来举例子:<br>
|
||||
>我们看看这个$QK^T$在表示啥意思:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20200220195022623.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" alt="在这里插入图片描述" style="zoom:90%;" />
|
||||
</div>
|
||||
|
||||
>假设当前会话有6个物品,embedding的维度是3的话,那么会看到这里一成,得到的结果中的每一行其实表示的是当前商品与其他商品之间的一个相似性大小(embedding内积的形式算的相似)。而沿着最后一个维度softmax归一化之后,得到的是个权重值。这是不是又想起我们的注意力机制来的啊,这个就叫做注意力矩阵,我们看看乘以V会是个啥
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20200220195243968.png#pic_center" alt="在这里插入图片描述" style="zoom:90%;" />
|
||||
</div>
|
||||
|
||||
>这时候,我们从注意力矩阵取出一行(和为1),然后依次点乘V的列,因为矩阵V的每一行代表着每一个字向量的数学表达,这样操作,**得到的正是注意力权重进行数学表达的加权线性组合,从而使每个物品向量都含有当前序列的所有物品向量的信息**。而多头,不过是含有多个角度的信息罢了,这就是Self-attention的魔力了。
|
||||
|
||||
|
||||
好了, 下面再看论文里面的描述就非常舒服了,如果令$\mathbf{Q}_{k}=\left[\mathbf{Q}_{k 1} ; \ldots ; \mathbf{Q}_{k h} ; \ldots ; \mathbf{Q}_{k H}\right]$, 这里面的$\mathbf{Q}_{k h} \in \mathbb{R}^{T \times d_{h}}$代表的就是多头里面的某一个头了,由于这多个头合起来的维度$d_{model}$维度,那么一个头就是$d_{h}=\frac{1}{h} d_{\text {model }}$, 这里必须要保证能整除才行。这里用了$h$个头。某个头$h$的计算为:
|
||||
$$
|
||||
\begin{aligned}
|
||||
\text { head }_{h} &=\text { Attention }\left(\mathbf{Q}_{k h} \mathbf{W}^{Q}, \mathbf{Q}_{k h} \mathbf{W}^{K}, \mathbf{Q}_{k h} \mathbf{W}^{V}\right) \\
|
||||
&=\operatorname{softmax}\left(\frac{\mathbf{Q}_{k h} \mathbf{W}^{Q} \mathbf{W}^{K^{T}} \mathbf{Q}_{k h}^{T}}{\sqrt{d_{m o d e l}}}\right) \mathbf{Q}_{k h} \mathbf{W}^{V}
|
||||
\end{aligned}
|
||||
$$
|
||||
这里是某一个头的计算过程, 这里的$\mathbf{W}^{Q}, \mathbf{W}^{K}, \mathbf{W}^{Q}$是要学习的参数,由于是一个头,维度应该是$\frac{1}{h} d_{\text {model }}\times \frac{1}{h} d_{\text {model }}$, 这样的话softmax那块算出来的是$T \times T$的矩阵, 而后面是一个$T \times \frac{1}{h} d_{\text {model }}$的矩阵,这时候得到的$head_h$是一个$T \times \frac{1}{h} d_{\text {model }}$的矩阵。 而$h$个头的话,正好是$T \times d_{\text {model }}$的维度,也就是我们最后的输出了。即下面这个计算:
|
||||
$$
|
||||
\mathbf{I}_{k}^{Q}=\operatorname{FFN}\left(\text { Concat }\left(\text { head }_{1}, \ldots, \text { head }_{H}\right) \mathbf{W}^{O}\right)
|
||||
$$
|
||||
这个是self-attention 的输出再过一个全连接网络得到的。如果是用残差网络的话,最后的结果依然是个$T \times d_{\text {model }}$的,也就是$\mathbf{I}_{k}^{Q}$的维度。这时候我们在$T$的维度上进行一个avg pooling的操作,就能够把每个session兴趣转成一个$embedding$维的向量了,即
|
||||
$$
|
||||
\mathbf{I}_{k}=\operatorname{Avg}\left(\mathbf{I}_{k}^{Q}\right)
|
||||
$$
|
||||
即这个$\mathbf{I}_{k}$是一个embedding维度的向量, 表示当前用户在第$k$会话的兴趣。这就是一个会话里面兴趣提取的全过程了,如果用我之前的神图总结的话就是:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20200220204538414.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70" alt="在这里插入图片描述" style="zoom:90%;" />
|
||||
</div>
|
||||
|
||||
不同点就是这里用了两个transformer块,开始用的是bias编码。
|
||||
|
||||
接下来就是不同的会话都走这样的一个Transformer网络,就会得到一个$K \times embed \_dim$的矩阵,代表的是某个用户在$K$个会话里面的兴趣信息, 这个就是会话兴趣提取层的结果了。 两个注意点:
|
||||
1. 这$K$个会话是走同一个Transformer网络的,也就是在自注意力机制中不同的会话之间权重共享
|
||||
2. 最后得到的这个矩阵,$K$这个维度上是有时间先后关系的,这为后面用LSTM学习这各个会话之间的兴趣向量奠定了基础。
|
||||
|
||||
#### Session Interest Interacting Layer
|
||||
感觉这篇文章最难的地方在上面这块,所以我用了些篇幅,而下面这些就好说了,因为和之前的东西对上了又。 首先这个会话兴趣交互层
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210310172547359.png#pic_center" alt="在这里插入图片描述" style="zoom:90%;" />
|
||||
</div>
|
||||
|
||||
作者这里就是想通过一个双向的LSTM来学习下会话兴趣之间的关系, 从而增加用户兴趣的丰富度,或许还能学习到演化规律。
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/img_convert/8aa8363c1efa5101e578658515df7eba.png#pic_center" alt="在这里插入图片描述" style="zoom:90%;" />
|
||||
</div>
|
||||
|
||||
双向的LSTM这个,这里就不介绍了,关于LSTM之前我也总结过了,无非双向的话,就是先从头到尾计算,在从尾到头回来。所以这里每个时刻隐藏状态的输出计算公式为:
|
||||
$$
|
||||
\mathbf{H}_{t}=\overrightarrow{\mathbf{h}_{f t}} \oplus \overleftarrow{\mathbf{h}_{b t}}
|
||||
$$
|
||||
这是一个$[1,\#hidden\_units]$的维度。相加的两项分别是前向传播和反向传播对应的t时刻的hidden state,这里得到的隐藏层状态$H_t$, 我们可以认为是混合了上下文信息的会话兴趣。
|
||||
#### Session Interest Activating Layer
|
||||
用户的会话兴趣与目标物品越相近,那么应该赋予更大的权重,这里依然使用注意力机制来刻画这种相关性,根据结构图也能看出,这里是用了两波注意力计算:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210310173413453.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" alt="在这里插入图片描述" style="zoom:90%;" />
|
||||
</div>
|
||||
|
||||
由于这里的这种局部Attention机制,DIN和DIEN里都见识过了, 这里就不详细解释了, 简单看下公式就可以啦。
|
||||
1. 会话兴趣提取层
|
||||
$$
|
||||
\begin{aligned}
|
||||
a_{k}^{I} &=\frac{\left.\exp \left(\mathbf{I}_{k} \mathbf{W}^{I} \mathbf{X}^{I}\right)\right)}{\sum_{k}^{K} \exp \left(\mathbf{I}_{k} \mathbf{W}^{I} \mathbf{X}^{I}\right)} \\
|
||||
\mathbf{U}^{I} &=\sum_{k}^{K} a_{k}^{I} \mathbf{I}_{k}
|
||||
\end{aligned}
|
||||
$$
|
||||
这里$X^I$是候选商品的embedding向量, 是$[embed \_dim,1]$的维度, $I_k$是$[1, embed \_dim]$的,而$W^I$是一个$[embed \_dim, embed \_dim]$, 所以这样能算出个分数,表示当前会话兴趣与候选商品之间的相似性程度。 而最终的$U^I$是各个会话兴趣向量的加权线性组合, 维度是$[1, embed \_dim]$。
|
||||
2. 会话兴趣交互层
|
||||
同样,混合了上下文信息的会话兴趣,也进行同样的处理:
|
||||
$$
|
||||
\begin{aligned}
|
||||
a_{k}^{H} &=\frac{\left.\exp \left(\mathbf{H}_{k} \mathbf{W}^{H} \mathbf{X}^{I}\right)\right)}{\sum_{k}^{K} \exp \left(\mathbf{H}_{k} \mathbf{W}^{H} \mathbf{X}^{I}\right)} \\
|
||||
\mathbf{U}^{H} &=\sum_{k}^{K} a_{k}^{H} \mathbf{H}_{k}
|
||||
\end{aligned}
|
||||
$$
|
||||
这里$X^I$是候选商品的embedding向量, 是$[embed \_dim,1]$的维度, $H_k$是$[1, \# hidden \_units]$的,而$W^I$是一个$[ \# hidden \_units, embed \_dim]$, 所以这样能算出个分数,当然实际实现,这里都是过神经网络的,表示混合了上下文信息的当前会话兴趣与候选商品之间的相似性程度。 而最终的$U^H$是各个混合了上下文信息的会话兴趣向量的加权线性组合, 维度是$[1, \# hidden \_units]$。
|
||||
|
||||
|
||||
#### Output Layer
|
||||
这个就很简单了,上面的用户行为特征, 物品行为特征以及求出的会话兴趣特征进行拼接,然后过一个DNN网络,就可以得到输出了。
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210310174905503.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" alt="在这里插入图片描述" style="zoom:90%;" />
|
||||
</div>
|
||||
|
||||
损失这里依然用的交叉熵损失:
|
||||
$$
|
||||
L=-\frac{1}{N} \sum_{(x, y) \in \mathbb{D}}(y \log p(x)+(1-y) \log (1-p(x)))
|
||||
$$
|
||||
这里的$x$表示的是$\left[\mathbf{X}^{U}, \mathbf{X}^{I}, \mathbf{S}\right]$,分布表示用户特征,物品特征和会话兴趣特征。
|
||||
|
||||
到这里DSIN模型就解释完毕了。
|
||||
|
||||
### 论文的其他细节
|
||||
这里的其他细节,后面就是实验部分了,用的数据集是一个广告数据集一个推荐数据集, 对比了几个比较经典的模型Youtubetnet, W&D, DIN, DIEN, 用了RNN的DIN等。并做了一波消融实验,验证了偏置编码的有效性, 会话兴趣抽取层和会话交互兴趣抽取层的有效性。 最后可视化的self-attention和Action Unit的图比较有意思:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210310175643572.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" alt="在这里插入图片描述" style="zoom:90%;" />
|
||||
</div>
|
||||
|
||||
好了,下面就是DSIN的代码细节了。
|
||||
## DSIN的代码复现细节
|
||||
下面就是DSIN的代码部分,这里我依然是借鉴了Deepctr进行的简化版本的复现, 这次复现代码会非常多,因为想借着这个机会学习一波Transformer,具体的还是参考我后面的GitHub。 下面开始:
|
||||
|
||||
### 数据处理
|
||||
首先, 这里使用的数据集还是movielens数据集,延续的DIEN那里的,没来得及尝试其他,这里说下数据处理部分和DIEN不一样的地方。最大的区别就是这里的用户历史行为上的处理, 之前的是一个历史行为序列列表,这里得需要把这个列表分解成几个会话的形式, 由于每个用户的会话还不一定一样长,所以这里还需要进行填充。具体的数据格式如下:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210312161159945.png" alt="在这里插入图片描述" style="zoom:90%;" />
|
||||
</div>
|
||||
|
||||
就是把之前的hist_id序列改成了5个session。其他的特征那里没有变化。 而特征封装那里,需要把这5个会话封装起来,同时还得记录**每个用户的有效会话个数以及每个会话里面商品的有效个数, 这个在后面计算里面是有用的,因为目前是padding成了一样长,后面要根据这个个数进行mask, 所以这里有两波mask要做**
|
||||
|
||||
```python
|
||||
feature_columns = [SparseFeat('user_id', max(samples_data["user_id"])+1, embedding_dim=8),
|
||||
SparseFeat('gender', max(samples_data["gender"])+1, embedding_dim=8),
|
||||
SparseFeat('age', max(samples_data["age"])+1, embedding_dim=8),
|
||||
SparseFeat('movie_id', max(samples_data["movie_id"])+1, embedding_dim=8),
|
||||
SparseFeat('movie_type_id', max(samples_data["movie_type_id"])+1, embedding_dim=8),
|
||||
DenseFeat('hist_len', 1)]
|
||||
|
||||
feature_columns += [VarLenSparseFeat('sess1', vocabulary_size=max(samples_data["movie_id"])+1, embedding_dim=8, maxlen=10, length_name='seq_length1'),
|
||||
VarLenSparseFeat('sess2', vocabulary_size=max(samples_data["movie_id"])+1, embedding_dim=8, maxlen=10, length_name='seq_length2'),
|
||||
VarLenSparseFeat('sess3', vocabulary_size=max(samples_data["movie_id"])+1, embedding_dim=8, maxlen=10, length_name='seq_length3'),
|
||||
VarLenSparseFeat('sess4', vocabulary_size=max(samples_data["movie_id"])+1, embedding_dim=8, maxlen=10, length_name='seq_length4'),
|
||||
VarLenSparseFeat('sess5', vocabulary_size=max(samples_data["movie_id"])+1, embedding_dim=8, maxlen=10, length_name='seq_length5'),
|
||||
]
|
||||
feature_columns += ['sess_length']
|
||||
```
|
||||
封装代码变成了上面这个样子, 之所以放这里, 我是想说明一个问题,也是我这次才刚刚发觉的,就是这块封装特征的代码是用于建立模型用的, 也就是不用管有没有数据集,只要基于这个feature_columns就能把模型建立出来。 而这里面有几个重要的细节要梳理下:
|
||||
1. 上面的那一块特征是常规的离散和连续特征,封装起来即可,这个会对应的建立Input层接收后面的数据输入
|
||||
2. 第二块的变长离散特征, 注意后面的`seq_length`,这个东西的作用是标记每个用户在每个会话里面有效商品的真实长度,所以这5个会话建Input层的时候,不仅给前面的sess建立Input,还会给length_name建立Input层来接收每个用户每个会话里面商品的真实长度信息, 这样在后面创建mask的时候才有效。也就是**没有具体数据之前网络就能创建mask信息才行**。这个我是遇到了坑的,之前又忽略了这个`seq_length`, 想着直接用上面的真实数据算出长度来给网络不就行? 其实不行,因为我们算出来的长度mask给网络的时候,那个样本数已经确定了,这时候会出bug的到后面。 因为真实训练的时候,batch_size是我们自己指定。并且这个思路的话是网络依赖于数据才能建立出来,显然是不合理的。所以一定要切记,**先用`seq_length`在这里占坑,作为一个Input层, 然后过embedding,后面基于传进的序列长度和填充的最大长度用 `tf.sequence_mask`就能建立了**。
|
||||
3. 最后的`sess_length`, 这个标记每个用户的有效会话个数,后面在会话兴趣与当前候选商品算注意力的时候,也得进行mask操作,所以这里和上面这个原理是一样的,**必须先用sess_length在这里占坑,创建一个Input层**。
|
||||
4. 对应关系, 既然我们这里封装的时候是这样封装的,这样就会根据上面的建立出不同的Input层,这时候我们具体用X训练的时候,**一定要注意数据对应,也就是特征必须够且能对应起来,这里是通过名字对应的**, 看下面的X:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210312163227832.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" alt="在这里插入图片描述" style="zoom:90%;" />
|
||||
</div>
|
||||
|
||||
真实数据要和Input层接收进行对应好。
|
||||
|
||||
好了,关于数据处理就说这几个细节,感觉mask的那个处理非常需要注意。具体的看代码就可以啦。下面重头戏,剖析模型。
|
||||
|
||||
### DSIN模型全貌
|
||||
有了Deepctr的这种代码风格,使得建立模型会从宏观上看起来非常清晰,简单说下逻辑, 先建立输入层,由于输入的特征有三大类(离散,连续和变长离散),所以分别建立Input层,然后离散特征还得建立embedding层。下面三大类特征就有了不同的走向:
|
||||
1. 连续特征: 这类特征拼先拼接到一块,然后等待最后往DNN里面输入
|
||||
2. 普通离散特征: 这块从输入 -> embedding -> 拼接到一块,等待DNN里面输入
|
||||
3. 用户的会话特征: 这块从输入 -> embedding -> 会话兴趣分割层(`sess_interest_division`) -> 会话兴趣提取层(`sess_interest_extractor`) -> 会话兴趣交互层(`BiLSTM`) -> 会话兴趣激活层( `AttentionPoolingLayer`) -> 得到两个兴趣性特征
|
||||
|
||||
把上面的连续特征,离散特征和兴趣特征拼接起来,然后过DNN得到输出即可。就是这么个逻辑了,具体代码如下:
|
||||
|
||||
```python
|
||||
def DSIN(feature_columns, sess_feature_list, sess_max_count=5, bias_encoding=True, singlehead_emb_size=1,
|
||||
att_head_nums=8, dnn_hidden_units=(200, 80)):
|
||||
"""
|
||||
建立DSIN网络
|
||||
:param feature_columns: A list, 每个特征的封装 nametuple形式
|
||||
:param behavior_feature_list: A list, 行为特征名称
|
||||
:param sess_max_count: 会话的个数
|
||||
:param bias_encoding: 是否偏置编码
|
||||
:singlehead_emb_size: 每个头的注意力的维度,注意这个和头数的乘积必须等于输入的embedding的维度
|
||||
:att_head_nums: 头的个数
|
||||
:dnn_hidden_units: 这个是全连接网络的神经元个数
|
||||
"""
|
||||
|
||||
# 检查下embedding设置的是否合法,因为这里有了多头注意力机制之后,我们要保证我们的embedding维度 = att_head_nums * att_embedding_size
|
||||
hist_emb_size = sum(
|
||||
map(lambda fc: fc.embedding_dim, filter(lambda fc: fc.name in sess_feature_list, [feature for feature in feature_columns if not isinstance(feature, str)]))
|
||||
)
|
||||
if singlehead_emb_size * att_head_nums != hist_emb_size:
|
||||
raise ValueError(
|
||||
"hist_emb_size must equal to singlehead_emb_size * att_head_nums ,got %d != %d *%d" % (
|
||||
hist_emb_size, singlehead_emb_size, att_head_nums))
|
||||
|
||||
# 建立输入层
|
||||
input_layer_dict = build_input_layers(feature_columns)
|
||||
# 将Input层转化为列表的形式作为model的输入
|
||||
input_layers = list(input_layer_dict.values()) # 各个输入层
|
||||
input_keys = list(input_layer_dict.keys()) # 各个列名
|
||||
user_sess_seq_len = [input_layer_dict['seq_length'+str(i+1)] for i in range(sess_max_count)]
|
||||
user_sess_len = input_layer_dict['sess_length']
|
||||
|
||||
# 筛选出特征中的sparse_fra, dense_fea, varlen_fea
|
||||
sparse_feature_columns = list(filter(lambda x: isinstance(x, SparseFeat), feature_columns)) if feature_columns else []
|
||||
dense_feature_columns = list(filter(lambda x: isinstance(x, DenseFeat), feature_columns)) if feature_columns else []
|
||||
varlen_sparse_feature_columns = list(filter(lambda x: isinstance(x, VarLenSparseFeat), feature_columns)) if feature_columns else []
|
||||
|
||||
# 获取dense
|
||||
dnn_dense_input = []
|
||||
for fc in dense_feature_columns:
|
||||
dnn_dense_input.append(input_layer_dict[fc.name])
|
||||
# 将所有的dense特征拼接
|
||||
dnn_dense_input = concat_input_list(dnn_dense_input)
|
||||
|
||||
# 构建embedding词典
|
||||
embedding_layer_dict = build_embedding_layers(feature_columns, input_layer_dict)
|
||||
|
||||
# 因为这里最终需要将embedding拼接后直接输入到全连接层(Dense)中, 所以需要Flatten
|
||||
dnn_sparse_embed_input = concat_embedding_list(sparse_feature_columns, input_layer_dict, embedding_layer_dict, flatten=True)
|
||||
# 将所有sparse特征的embedding进行拼接
|
||||
dnn_sparse_input = concat_input_list(dnn_sparse_embed_input)
|
||||
# dnn_dense_input和dnn_sparse_input这样就不用管了,等待后面的拼接就完事, 下面主要是会话行为兴趣的提取
|
||||
|
||||
|
||||
# 首先获取当前的行为特征(movie)的embedding,这里有可能有多个行为产生了行为序列,所以需要使用列表将其放在一起
|
||||
# 这个东西最后求局域Attention的时候使用,也就是选择与当前候选物品最相关的会话兴趣
|
||||
query_embed_list = embedding_lookup(sess_feature_list, input_layer_dict, embedding_layer_dict)
|
||||
query_emb = concat_input_list(query_embed_list)
|
||||
|
||||
# 下面就是开始会话行为的处理了,四个层来: 会话分割层,会话兴趣提取层,会话兴趣交互层和局部Attention层,下面一一来做
|
||||
|
||||
# 首先这里是找到会话行为中的特征列的输入层, 其实用input_layer_dict也行
|
||||
user_behavior_input_dict = {}
|
||||
for idx in range(sess_max_count):
|
||||
sess_input = OrderedDict()
|
||||
for i, feat in enumerate(sess_feature_list): # 我这里只有一个movie_id
|
||||
sess_input[feat] = input_layer_dict["sess" + str(idx+1)]
|
||||
|
||||
user_behavior_input_dict['sess'+str(idx+1)] = sess_input # 这里其实是获取那五个会话的输入层
|
||||
|
||||
# 会话兴趣分割层: 拿到每个会话里面各个商品的embedding,并且进行偏置编码,得到transformer的输入
|
||||
transformer_input = sess_interest_division(embedding_layer_dict, user_behavior_input_dict,
|
||||
sparse_feature_columns, sess_feature_list,
|
||||
sess_max_count, bias_encoding=bias_encoding)
|
||||
# 这个transformer_input是个列表,里面的每个元素代表一个会话,维度是(None, max_seq_len, embed_dim)
|
||||
|
||||
# 会话兴趣提取层: 每个会话过transformer,从多个角度得到里面各个商品之间的相关性(交互)
|
||||
self_attention = Transformer(singlehead_emb_size, att_head_nums, dropout_rate=0, use_layer_norm=True,
|
||||
use_positional_encoding=(not bias_encoding), blinding=False)
|
||||
sess_fea = sess_interest_extractor(transformer_input, sess_max_count, self_attention, user_sess_seq_len)
|
||||
# 这里的输出sess_fea是个矩阵,维度(None, sess_max_cout, embed_dim), 这个东西后期要和当前的候选商品求Attention进行sess维度上的加权
|
||||
|
||||
# 会话兴趣交互层 上面的transformer结果,过双向的LSTM
|
||||
lstm_output = BiLSTM(hist_emb_size, layers=2, res_layers=0, dropout_rate=0.2)(sess_fea)
|
||||
# 这个lstm_output是个矩阵,维度是(None, sess_max_count, hidden_units_num)
|
||||
|
||||
# 会话兴趣激活层 这里就是计算两波注意力
|
||||
interest_attention = AttentionPoolingLayer(user_sess_len)([query_emb, sess_fea])
|
||||
lstm_attention = AttentionPoolingLayer(user_sess_len)([query_emb, lstm_output])
|
||||
# 上面这两个的维度分别是(None, embed_size), (None, hidden_units_num) 这里embed_size=hidden_units_num
|
||||
|
||||
# 下面就是把dnn_sense_input, dnn_sparse_input, interest_attention, lstm_attention拼接起来
|
||||
deep_input = Concatenate(axis=-1)([dnn_dense_input, dnn_sparse_input, interest_attention, lstm_attention])
|
||||
|
||||
# 全连接接网络, 获取最终的dnn_logits
|
||||
dnn_logits = get_dnn_logits(deep_input, activation='prelu')
|
||||
|
||||
model = Model(input_layers, dnn_logits)
|
||||
|
||||
# 所有变量需要初始化
|
||||
tf.compat.v1.keras.backend.get_session().run(tf.compat.v1.global_variables_initializer())
|
||||
|
||||
return model
|
||||
```
|
||||
下面开始解释每块的细节实现。
|
||||
|
||||
### 会话兴趣分割层(sess_interest_division)
|
||||
这里面接收的输入是一个每个用户的会话列表, 比如上面那5个会话的时候,每个会话里面是有若干个商品的,当然还不仅仅是有商品id,还有可能有类别id这种。 而这个函数干的事情就是遍历这5个会话,然后对于每个会话,要根据商品id拿到每个会话的商品embedding(有类别id的话也会拿到类别id然后拼起来), 所以每个会话会得到一个`(None, seq_len, embed_dim)`的一个矩阵,而最后的输出就是5个会话的矩阵放到一个列表里返回来。也就是上面的`transformer_input`, 作为transformer的输入。 这里面的一个细节,就是偏置编码。 如果需要偏置编码的话,要在这里面进行。偏置编码的过程
|
||||
|
||||
```python
|
||||
class BiasEncoding(Layer):
|
||||
"""位置编码"""
|
||||
def __init__(self, sess_max_count, seed=1024):
|
||||
super(BiasEncoding, self).__init__()
|
||||
self.sess_max_count = sess_max_count
|
||||
self.seed = seed
|
||||
|
||||
def build(self, input_shape):
|
||||
# 在该层创建一个可训练的权重 input_shape [None, sess_max_count, max_seq_len, embed_dim]
|
||||
if self.sess_max_count == 1:
|
||||
embed_size = input_shape[2]
|
||||
seq_len_max = input_shape[1]
|
||||
else:
|
||||
embed_size = input_shape[0][2]
|
||||
seq_len_max = input_shape[0][1]
|
||||
# 声明那三个位置偏置编码矩阵
|
||||
self.sess_bias_embedding = self.add_weight('sess_bias_encoding', shape=(self.sess_max_count, 1, 1),
|
||||
initializer=tf.keras.initializers.TruncatedNormal(mean=0.0, stddev=0.0001, seed=self.seed)) # 截断产生正太随机数
|
||||
self.seq_bias_embedding = self.add_weight('seq_bias_encoding', shape=(1, seq_len_max, 1),
|
||||
initializer=tf.keras.initializers.TruncatedNormal(mean=0.0, stddev=0.0001, seed=self.seed))
|
||||
self.embed_bias_embedding = self.add_weight('embed_beas_encoding', shape=(1, 1, embed_size),
|
||||
initializer=tf.keras.initializers.TruncatedNormal(mean=0.0, stddev=0.0001, seed=self.seed))
|
||||
super(BiasEncoding, self).build(input_shape)
|
||||
|
||||
def call(self, inputs, mask=None):
|
||||
"""
|
||||
:param inputs: A list 长度是会话数量,每个元素表示一个会话矩阵,维度是[None, max_seq_len, embed_dim]
|
||||
"""
|
||||
bias_encoding_out = []
|
||||
for i in range(self.sess_max_count):
|
||||
bias_encoding_out.append(
|
||||
inputs[i] + self.embed_bias_embedding + self.seq_bias_embedding + self.sess_bias_embedding[i] # 这里会广播
|
||||
)
|
||||
return bias_encoding_out
|
||||
```
|
||||
这里的核心就是build里面的那三个偏置矩阵,对应论文里面的$\mathbf{w}_{k}^{K},\mathbf{w}_{t}^{T},\mathbf{w}_{c}^{C}$, 这里之所以放到build里面建立,是为了让这些参数可学习, 而前向传播里面就是论文里面的公式加就完事,这里面会用到广播机制。
|
||||
|
||||
### 会话兴趣提取层(sess_interest_extractor)
|
||||
这里面就是复现了大名鼎鼎的Transformer了, 这也是我第一次看transformer的代码,果真与之前的理论分析还是有很多不一样的点,下面得一一梳理一下,Transformer是非常重要的。
|
||||
|
||||
首先是位置编码, 代码如下:
|
||||
|
||||
```python
|
||||
def positional_encoding(inputs, pos_embedding_trainable=True,scale=True):
|
||||
"""
|
||||
inputs: (None, max_seq_len, embed_dim)
|
||||
"""
|
||||
_, T, num_units = inputs.get_shape().as_list() # [None, max_seq_len, embed_dim]
|
||||
|
||||
position_ind = tf.expand_dims(tf.range(T), 0) # [1, max_seq_len]
|
||||
|
||||
# First part of the PE function: sin and cos argument
|
||||
position_enc = np.array([
|
||||
[pos / np.power(1000, 2. * i / num_units) for i in range(num_units)] for pos in range(T)
|
||||
])
|
||||
|
||||
# Second part, apply the cosine to even columns and sin to odds. # 这个操作秀
|
||||
position_enc[:, 0::2] = np.sin(position_enc[:, 0::2]) # dim 2i
|
||||
position_enc[:, 1::2] = np.cos(position_enc[:, 1::2]) # dim 2i+1
|
||||
|
||||
# 转成张量
|
||||
if pos_embedding_trainable:
|
||||
lookup_table = K.variable(position_enc, dtype=tf.float32)
|
||||
|
||||
outputs = tf.nn.embedding_lookup(lookup_table, position_ind)
|
||||
if scale:
|
||||
outputs = outputs * num_units ** 0.5
|
||||
return outputs + inputs
|
||||
```
|
||||
这一块的话没有啥好说的东西感觉,这个就是在按照论文里面的公式sin, cos变换, 这里面比较秀的操作感觉就是dim2i和dim 2i+1的赋值了。
|
||||
|
||||
接下来,LayerNormalization, 这个也是按照论文里面的公式实现的代码,求均值和方差的维度都是embedding:
|
||||
|
||||
```python
|
||||
class LayerNormalization(Layer):
|
||||
def __init__(self, axis=-1, eps=1e-9, center=True, scale=True):
|
||||
super(LayerNormalization, self).__init__()
|
||||
self.axis = axis
|
||||
self.eps = eps
|
||||
self.center = center
|
||||
self.scale = scale
|
||||
def build(self, input_shape):
|
||||
"""
|
||||
input_shape: [None, max_seq_len, singlehead_emb_dim*head_num]
|
||||
"""
|
||||
self.gamma = self.add_weight(name='gamma', shape=input_shape[-1:], # [1, max_seq_len, singlehead_emb_dim*head_num]
|
||||
initializer=tf.keras.initializers.Ones(), trainable=True)
|
||||
self.beta = self.add_weight(name='beta', shape=input_shape[-1:],
|
||||
initializer=tf.keras.initializers.Zeros(), trainable=True) # [1, max_seq_len, singlehead_emb_dim*head_num]
|
||||
super(LayerNormalization, self).build(input_shape)
|
||||
|
||||
def call(self, inputs):
|
||||
"""
|
||||
[None, max_seq_len, singlehead_emb_dim*head_num]
|
||||
"""
|
||||
mean = K.mean(inputs, axis=self.axis, keepdims=True) # embed_dim维度上求均值
|
||||
variance = K.mean(K.square(inputs-mean), axis=-1, keepdims=True) # embed_dim维度求方差
|
||||
std = K.sqrt(variance + self.eps)
|
||||
outputs = (inputs - mean) / std
|
||||
|
||||
if self.scale:
|
||||
outputs *= self.gamma
|
||||
if self.center:
|
||||
outputs += self.beta
|
||||
return outputs
|
||||
```
|
||||
下面就是伟大的Transformer网络,下面我先把整体代码放上来,然后解释一些和我之前见到过的一样的地方,也是通过看具体代码学习到的点:
|
||||
|
||||
```python
|
||||
class Transformer(Layer):
|
||||
"""Transformer网络"""
|
||||
def __init__(self, singlehead_emb_size=1, att_head_nums=8, dropout_rate=0.0, use_positional_encoding=False,use_res=True,
|
||||
use_feed_forword=True, use_layer_norm=False, blinding=False, seed=1024):
|
||||
super(Transformer, self).__init__()
|
||||
self.singlehead_emb_size = singlehead_emb_size
|
||||
self.att_head_nums = att_head_nums
|
||||
self.num_units = self.singlehead_emb_size * self.att_head_nums
|
||||
self.use_res = use_res
|
||||
self.use_feed_forword = use_feed_forword
|
||||
self.dropout_rate = dropout_rate
|
||||
self.use_positional_encoding = use_positional_encoding
|
||||
self.use_layer_norm = use_layer_norm
|
||||
self.blinding = blinding # 如果为True的话表明进行attention的时候未来的units都被屏蔽, 解码器的时候用
|
||||
self.seed = seed
|
||||
|
||||
# 这里需要为该层自定义可训练的参数矩阵 WQ, WK, WV
|
||||
def build(self, input_shape):
|
||||
# input_shape: [None, max_seq_len, embed_dim]
|
||||
embedding_size= int(input_shape[0][-1])
|
||||
# 检查合法性
|
||||
if self.num_units != embedding_size:
|
||||
raise ValueError(
|
||||
"att_embedding_size * head_num must equal the last dimension size of inputs,got %d * %d != %d" % (
|
||||
self.singlehead_emb_size, att_head_nums, embedding_size))
|
||||
self.seq_len_max = int(input_shape[0][-2])
|
||||
# 定义三个矩阵
|
||||
self.W_Query = self.add_weight(name='query', shape=[embedding_size, self.singlehead_emb_size*self.att_head_nums],
|
||||
dtype=tf.float32,initializer=tf.keras.initializers.TruncatedNormal(seed=self.seed))
|
||||
self.W_Key = self.add_weight(name='key', shape=[embedding_size, self.singlehead_emb_size*self.att_head_nums],
|
||||
dtype=tf.float32,initializer=tf.keras.initializers.TruncatedNormal(seed=self.seed+1))
|
||||
self.W_Value = self.add_weight(name='value', shape=[embedding_size, self.singlehead_emb_size*self.att_head_nums],
|
||||
dtype=tf.float32,initializer=tf.keras.initializers.TruncatedNormal(seed=self.seed+2))
|
||||
# 用神经网络的话,加两层训练参数
|
||||
if self.use_feed_forword:
|
||||
self.fw1 = self.add_weight('fw1', shape=[self.num_units, 4 * self.num_units], dtype=tf.float32,
|
||||
initializer=tf.keras.initializers.glorot_uniform(seed=self.seed))
|
||||
self.fw2 = self.add_weight('fw2', shape=[4 * self.num_units, self.num_units], dtype=tf.float32,
|
||||
initializer=tf.keras.initializers.glorot_uniform(seed=self.seed+1))
|
||||
self.dropout = tf.keras.layers.Dropout(self.dropout_rate)
|
||||
self.ln = LayerNormalization()
|
||||
super(Transformer, self).build(input_shape)
|
||||
|
||||
def call(self, inputs, mask=None, training=None):
|
||||
"""
|
||||
:param inputs: [当前会话sessi, 当前会话sessi] 维度 (None, max_seq_len, embed_dim)
|
||||
:param mask: 当前会话mask 这是个1维数组, 维度是(None, ), 表示每个样本在当前会话里面的行为序列长度
|
||||
"""
|
||||
# q和k其实是一样的矩阵
|
||||
queries, keys = inputs
|
||||
query_masks, key_masks = mask, mask
|
||||
|
||||
# 这里需要对Q和K进行mask操作,
|
||||
# key masking目的是让key值的unit为0的key对应的attention score极小,这样加权计算value时相当于对结果不产生影响
|
||||
# Query Masking 要屏蔽的是被0所填充的内容。
|
||||
query_masks = tf.sequence_mask(query_masks, self.seq_len_max, dtype=tf.float32) # (None, 1, seq_len_max)
|
||||
key_masks = tf.sequence_mask(key_masks, self.seq_len_max, dtype=tf.float32) # (None, 1, seq_len_max), 注意key_masks开始是(None,1)
|
||||
key_masks = key_masks[:, 0, :] # 所以上面会多出个1维度来, 这里去掉才行,(None, seq_len_max)
|
||||
query_masks = query_masks[:, 0, :] # 这个同理
|
||||
|
||||
# 是否位置编码
|
||||
if self.use_positional_encoding:
|
||||
queries = positional_encoding(queries)
|
||||
keys = positional_encoding(queries)
|
||||
|
||||
# tensordot 是矩阵乘,好处是当两个矩阵维度不同的时候,只要指定axes也可以乘
|
||||
# 这里表示的是queries的-1维度与W_Query的0维度相乘
|
||||
# (None, max_seq_len, embedding_size) * [embedding_size, singlehead_emb_size*head_num]
|
||||
querys = tf.tensordot(queries, self.W_Query, axes=(-1, 0)) # [None, max_seq_len_q, singlehead_emb_size*head_num]
|
||||
|
||||
keys = tf.tensordot(keys, self.W_Key, axes=(-1, 0)) # [None, max_seq_len_k, singlehead_emb_size*head_num]
|
||||
values = tf.tensordot(keys, self.W_Value, axes=(-1, 0)) # [None, max_seq_len_k, singlehead_emb_size*head_num]
|
||||
|
||||
# tf.split切分张量 这里从头那里切分成head_num个张量, 然后从0维拼接
|
||||
querys = tf.concat(tf.split(querys, self.att_head_nums, axis=2), axis=0) # [head_num*None, max_seq_len_q, singlehead_emb_size]
|
||||
keys = tf.concat(tf.split(keys, self.att_head_nums, axis=2), axis=0) # [head_num*None, max_seq_len_k, singlehead_emb_size]
|
||||
values = tf.concat(tf.split(values, self.att_head_nums, axis=2), axis=0) # [head_num*None, max_seq_len_k, singlehead_emb_size]
|
||||
|
||||
# Q*K keys后两维转置然后再乘 [head_num*None, max_seq_len_q, max_seq_len_k]
|
||||
outputs = tf.matmul(querys, keys, transpose_b=True)
|
||||
outputs = outputs / (keys.get_shape().as_list()[-1] ** 0.5)
|
||||
|
||||
# 从0维度上复制head_num次
|
||||
key_masks = tf.tile(key_masks, [self.att_head_nums, 1]) # [head_num*None, max_seq_len_k]
|
||||
key_masks = tf.tile(tf.expand_dims(key_masks, 1), [1, tf.shape(queries)[1], 1]) # [head_num*None, max_seq_len_q,max_seq_len_k]
|
||||
paddings = tf.ones_like(outputs) * (-2**32+1)
|
||||
|
||||
outputs = tf.where(tf.equal(key_masks, 1), outputs, paddings) # 被填充的部分赋予极小的权重
|
||||
|
||||
# 标识是否屏蔽未来序列的信息(解码器self attention的时候不能看到自己之后的哪些信息)
|
||||
# 这里通过下三角矩阵的方式进行,依此表示预测第一个词,第二个词,第三个词...
|
||||
if self.blinding:
|
||||
diag_vals = tf.ones_like(outputs[0, :, :]) # (T_q, T_k)
|
||||
tril = tf.linalg.LinearOperatorLowerTriangular(diag_vals).to_dense() # (T_q, T_k) 这是个下三角矩阵
|
||||
masks = tf.tile(tf.expand_dims(tril, 0), [tf.shape(outputs)[0], 1, 1]) # (h*N, T_q, T_k)
|
||||
|
||||
paddings = tf.ones_like(masks) * (-2 ** 32 + 1)
|
||||
outputs = tf.where(tf.equal(masks, 0), paddings, outputs) # (h*N, T_q, T_k)
|
||||
|
||||
outputs -= tf.reduce_max(outputs, axis=-1, keepdims=True)
|
||||
outputs = tf.nn.softmax(outputs, axis=-1) # 最后一个维度求softmax,换成权重
|
||||
|
||||
query_masks = tf.tile(query_masks, [self.att_head_nums, 1]) # [head_num*None, max_seq_len_q]
|
||||
query_masks = tf.tile(tf.expand_dims(query_masks, -1), [1, 1, tf.shape(keys)[1]]) # [head_num*None, max_seq_len_q, max_seq_len_k]
|
||||
|
||||
outputs *= query_masks
|
||||
|
||||
# 权重矩阵过下dropout [head_num*None, max_seq_len_q, max_seq_len_k]
|
||||
outputs = self.dropout(outputs, training=training)
|
||||
|
||||
# weighted sum [head_num*None, max_seq_len_q, max_seq_len_k] * # [head_num*None, max_seq_len_k, singlehead_emb_size]
|
||||
result = tf.matmul(outputs, values) # [head_num*None, max_seq_len_q, singlehead_emb_size]
|
||||
# 换回去了
|
||||
result = tf.concat(tf.split(result, self.att_head_nums, axis=0), axis=2) # [None, max_seq_len_q, head_num*singlehead_emb_size]
|
||||
|
||||
if self.use_res: # 残差连接
|
||||
result += queries
|
||||
if self.use_layer_norm:
|
||||
result = self.ln(result)
|
||||
if self.use_feed_forword: # [None, max_seq_len_q, head_num*singlehead_emb_size] 与 [num_units, self.num_units]
|
||||
fw1 = tf.nn.relu(tf.tensordot(result, self.fw1, axes=[-1, 0])) # [None, max_seq_len_q, 4*num_units]
|
||||
fw1 = self.dropout(fw1, training=training)
|
||||
fw2 = tf.tensordot(fw1, self.fw2, axes=[-1, 0]) # [None, max_seq_len_q, num_units] 这个num_units其实就等于head_num*singlehead_emb_size
|
||||
|
||||
if self.use_res:
|
||||
result += fw2
|
||||
if self.use_layer_norm:
|
||||
result = self.ln(result)
|
||||
|
||||
return tf.reduce_mean(result, axis=1, keepdims=True) # [None, 1, head_num*singleh]
|
||||
```
|
||||
这里面的整体逻辑, 首先在build里面会构建3个矩阵`WQ, WK, WV`,在这里定义依然是为了这些参数可训练, 而出乎我意料的是残差网络的参数w也是这里定义, 之前还以为这个是单独写出来,后面看了前向传播的逻辑时候明白了。
|
||||
|
||||
前向传播的逻辑和我之前画的图上差不多,不一样的细节是这里的具体实现上, 就是这里的**把多个头分开,采用堆叠的方式进行计算(堆叠到第一个维度上去了)**。这个是我之前忽略的一个问题, 只有这样才能使得每个头与每个头之间的自注意力运算是独立不影响的。如果不这么做的话,最后得到的结果会含有当前单词在这个头和另一个单词在另一个头上的关联,这是不合理的。**这是看了源码之后才发现的细节**。
|
||||
|
||||
另外就是mask操作这里,Q和K都需要进行mask操作,因为我们接受的输入序列是经过填充的,这里必须通过指明长度在具体计算的时候进行遮盖,否则softmax那里算的时候会有影响,因为e的0次方是1,所以这里需要找到序列里面填充的那些地方,给他一个超级大的负数,这样e的负无穷接近0,才能没有影响。但之前不知道这里的细节,这次看发现是Q和K都进行mask操作,且目的还不一样。
|
||||
|
||||
第三个细节,就是对未来序列的屏蔽,这个在这里是用不到的,这个是Transformer的解码器用的一个操作,就是在解码的时候,我们不能让当前的序列看到自己之后的信息。这里也需要进行mask遮盖住后面的。而具体实现,竟然使用了一个下三角矩阵, 这个东西的感觉是这样:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/20210312171321963.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" alt="在这里插入图片描述" style="zoom:90%;" />
|
||||
</div>
|
||||
|
||||
解码的时候,只能看到自己及以前的相关性,然后加权,这个学到了哈哈。
|
||||
|
||||
transformer这里接收的是会话兴趣分割层传下来的兴趣列表,返回的是个矩阵,维度是`(None, sess_nums, embed_dim)`, 因为这里每个会话都要过Transformer, 输入的维度是(None, seq_len, embed_dim), 而经过transformer之后,本来输出的维度也是这个,但是最后返回的时候在seq_len的维度上求了个平均。所以每个会话得到的输出是(None, 1, embed_dim), 相当于兴趣综合了下。而5个会话,就会得到5个这样的结果,然后再会话维度上拼接,就是上面的这个矩阵结果了,这个东西作为双向LSTM的输入。
|
||||
|
||||
### 会话兴趣交互层(BiLSTM)
|
||||
这里主要是值得记录下多层双向LSTM的实现过程, 用下面的这种方式非常的灵活:
|
||||
|
||||
```python
|
||||
class BiLSTM(Layer):
|
||||
def __init__(self, units, layers=2, res_layers=0, dropout_rate=0.2, merge_mode='ave'):
|
||||
super(BiLSTM, self).__init__()
|
||||
self.units = units
|
||||
self.layers = layers
|
||||
self.res_layers = res_layers
|
||||
self.dropout_rate = dropout_rate
|
||||
self.merge_mode = merge_mode
|
||||
|
||||
# 这里要构建正向的LSTM和反向的LSTM, 因为我们是要两者的计算结果最后加和,所以这里需要分别计算
|
||||
def build(self, input_shape):
|
||||
"""
|
||||
input_shape: (None, sess_max_count, embed_dim)
|
||||
"""
|
||||
self.fw_lstm = []
|
||||
self.bw_lstm = []
|
||||
for _ in range(self.layers):
|
||||
self.fw_lstm.append(
|
||||
LSTM(self.units, dropout=self.dropout_rate, bias_initializer='ones', return_sequences=True, unroll=True)
|
||||
)
|
||||
# go_backwards 如果为真,则反向处理输入序列并返回相反的序列
|
||||
# unroll 布尔(默认错误)。如果为真,则网络将展开,否则使用符号循环。展开可以提高RNN的速度,尽管它往往会占用更多的内存。展开只适用于较短的序列。
|
||||
self.bw_lstm.append(
|
||||
LSTM(self.units, dropout=self.dropout_rate, bias_initializer='ones', return_sequences=True, go_backwards=True, unroll=True)
|
||||
)
|
||||
super(BiLSTM, self).build(input_shape)
|
||||
|
||||
def call(self, inputs):
|
||||
|
||||
input_fw = inputs
|
||||
input_bw = inputs
|
||||
for i in range(self.layers):
|
||||
output_fw = self.fw_lstm[i](input_fw)
|
||||
output_bw = self.bw_lstm[i](input_bw)
|
||||
output_bw = Lambda(lambda x: K.reverse(x, 1), mask=lambda inputs, mask:mask)(output_bw)
|
||||
|
||||
if i >= self.layers - self.res_layers:
|
||||
output_fw += input_fw
|
||||
output_bw += input_bw
|
||||
|
||||
input_fw = output_fw
|
||||
input_bw = output_bw
|
||||
|
||||
if self.merge_mode == "fw":
|
||||
output = output_fw
|
||||
elif self.merge_mode == "bw":
|
||||
output = output_bw
|
||||
elif self.merge_mode == 'concat':
|
||||
output = K.concatenate([output_fw, output_bw])
|
||||
elif self.merge_mode == 'sum':
|
||||
output = output_fw + output_bw
|
||||
elif self.merge_mode == 'ave':
|
||||
output = (output_fw + output_bw) / 2
|
||||
elif self.merge_mode == 'mul':
|
||||
output = output_fw * output_bw
|
||||
elif self.merge_mode is None:
|
||||
output = [output_fw, output_bw]
|
||||
|
||||
return output
|
||||
```
|
||||
这里这个操作是比较骚的,以后建立双向LSTM就用这个模板了,具体也不用解释,并且这里之所以说灵活,是因为最后前向LSTM的结果和反向LSTM的结果都能单独的拿到,且可以任意的两者运算。 我记得Keras里面应该是也有直接的函数实现双向LSTM的,但依然感觉不如这种灵活。 这个层数自己定,单元自己定看,最后结果形式自己定,太帅了简直。 关于LSTM,可以看[官方文档](https://tensorflow.google.cn/versions/r2.0/api_docs/python/tf/keras/layers/LSTM)
|
||||
|
||||
这个的输入是`(None, sess_nums, embed_dim)`, 输出是`(None, sess_nums, hidden_units_num)`。
|
||||
|
||||
### 会话兴趣局部激活
|
||||
这里就是局部Attention的操作了,这个在这里就不解释了,和之前的DIEN,DIN的操作就一样了, 代码也不放在这里了,剩下的代码都看后面的GitHub链接吧, 这里我只记录下我觉得后面做别的项目会有用的代码哈哈。
|
||||
|
||||
## 总结
|
||||
DSIN的核心创新点就是把用户的历史行为按照时间间隔进行切分,以会话为单位进行学习, 而学习的方式首先是会话之内的行为自学习,然后是会话之间的交互学习,最后是与当前候选商品相关的兴趣演进,总体上还是挺清晰的。
|
||||
|
||||
具体的实际使用场景依然是有丰富的用户历史行为序列才可以,而会话之间的划分间隔,也得依据具体业务场景。 具体的使用可以调deepctr的包。
|
||||
|
||||
**参考资料**:
|
||||
* [DSIN原论文](https://arxiv.org/abs/1905.06482)
|
||||
* [自然语言处理之Attention大详解(Attention is all you need)](https://blog.csdn.net/wuzhongqiang/article/details/104414239?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522161512240816780357259240%2522%252C%2522scm%2522%253A%252220140713.130102334.pc%255Fblog.%2522%257D&request_id=161512240816780357259240&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~blog~first_rank_v1~rank_blog_v1-1-104414239.pc_v1_rank_blog_v1&utm_term=Attention+is+all)
|
||||
* [推荐系统遇上深度学习(四十五)-探秘阿里之深度会话兴趣网络](https://www.jianshu.com/p/82ccb10f9ede)
|
||||
* [深度兴趣网络模型探索——DIN+DIEN+DSIN](https://blog.csdn.net/baymax_007/article/details/91130374)
|
||||
* [Transformer解读](https://www.cnblogs.com/flightless/p/12005895.html)
|
||||
* [Welcome to DeepCTR’s documentation!](https://deepctr-doc.readthedocs.io/en/latest/)
|
||||
129
2023旧版内容/4.人工智能/ch02/ch2.2/ch2.2.5/2.2.5.0.md
Normal file
129
2023旧版内容/4.人工智能/ch02/ch2.2/ch2.2.5/2.2.5.0.md
Normal file
@@ -0,0 +1,129 @@
|
||||
## 背景与动机
|
||||
|
||||
在推荐系统的精排模块,多任务学习的模型结构已成业界的主流,获得了广阔的应用。多任务学习(multi-task learning),本质上是希望使用一个模型完成多个任务的建模。在推荐系统中,多任务学习一般即指多目标学习(multi-label learning),不同目标输入相同的feature进行联合训练,是迁移学习的一种。他们之间的关系如图:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://pic1.zhimg.com/80/v2-130d040474f34095ec6d8c81133da538_1440w.jpg" style="zoom:60%;" />
|
||||
</div>
|
||||
|
||||
下面我们先讨论三个问题
|
||||
|
||||
**一、为什么要用多任务学习?**
|
||||
|
||||
(1)很多业界推荐的业务,天然就是一个多目标的建模场景,需要多目标共同优化。以微信视频号推荐为例,打开一个视频,如图,首页上除了由于视频自动播放带来的“播放时长”、“完播率”(用户播放时长占视频长度的比例)目标之外,还有大量的互动标签,例如“点击好友头像”、“进入主页”、“关注”、“收藏”、“分享”、“点赞”、“评论”等。究竟哪一个标签最符合推荐系统的建模目标呢?
|
||||
|
||||
<div align=center>
|
||||
<img src="https://pic4.zhimg.com/80/v2-c18fc1ec65e308ee2e1477d7868007db_1440w.jpg" style="zoom:30%;" />
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
如果要用一个词来概括所有各式各样的推荐系统的终极目标,那就是“用户满意度”,但我们无法找到一个显示的指标量化用户满意度。业界一般使用“DAU”、“用户日均使用时长”、“留存率”来作为客观的间接的“用户满意度”(或者说算法工程师绩效)评价指标。而这些指标都是难以通过单一目标建模的,以使用时长为例,长视频播放长度天然大于短视频。所幸的是,虽然没有显式的用户满意度评价指标,但是现在的app都存在类似上述视频号推荐场景的丰富具体的隐式反馈。但这些独立的隐式反馈也存在一些挑战:
|
||||
|
||||
- 目标偏差:点赞、分享表达的满意度可能比播放要高
|
||||
- 物品偏差:不同视频的播放时长体现的满意度不一样,有的视频可能哄骗用户看到尾部(类似新闻推荐中的标题党)
|
||||
- 用户偏差:有的用户表达满意喜欢用点赞,有的用户可能喜欢用收藏
|
||||
|
||||
因此我们需要使用多任务学习模型针对多个目标进行预测,并在线上融合多目标的预测结果进行排序。多任务学习也不能直接表达用户满意度,但是可以最大限度利用能得到的用户反馈信息进行充分的表征学习,并且可建模业务之间的关系,从而高效协同学习具体任务。
|
||||
|
||||
(2)工程便利,不用针对不同的任务训练不同的模型。一般推荐系统中排序模块延时需求在40ms左右,如果分别对每个任务单独训练一个模型,难以满足需求。出于控制成本的目的,需要将部分模型进行合并。合并之后,能更高效的利用训练资源和进行模型的迭代升级。
|
||||
|
||||
**二、为什么多任务学习有效?**
|
||||
|
||||
当把业务独立建模变成多任务联合建模之后,有可能带来四种结果:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://pic1.zhimg.com/80/v2-44927ccdd6caf9685d3d9d5367af98dc_1440w.jpg" style="zoom:60%;" />
|
||||
</div>
|
||||
|
||||
多任务学习的优势在于通过部分参数共享,联合训练,能在保证“还不错”的前提下,实现多目标共同提升。原因有以下几种:
|
||||
|
||||
- 任务互助:对于某个任务难学到的特征,可通过其他任务学习
|
||||
- 隐式数据增强:不同任务有不同的噪声,一起学习可抵消部分噪声
|
||||
- 学到通用表达,提高泛化能力:模型学到的是对所有任务都偏好的权重,有助于推广到未来的新任务
|
||||
- 正则化:对于一个任务而言,其他任务的学习对该任务有正则化效果
|
||||
|
||||
**三、多任务学习都在研究什么问题**?
|
||||
|
||||
如上所述,多任务的核心优势在于通过不同任务的网络参数共享,实现1+1>2的提升,因此多任务学习的一大主流研究方向便是如何设计有效的网络结构。多个label的引入自然带来了多个loss,那么如何在联合训练中共同优化多个loss则是关键问题。
|
||||
|
||||
- 网络结构设计:主要研究哪些参数共享、在什么位置共享、如何共享。这一方向我们认为可以分为两大类,第一类是在设计网络结构时,考虑目标间的显式关系(例如淘宝中,点击之后才有购买行为发生),以阿里提出的ESMM为代表;另一类是目标间没有显示关系(例如短视频中的收藏与分享),在设计模型时不考虑label之间的量化关系,以谷歌提出的MMOE为代表。
|
||||
- 多loss的优化策略:主要解决loss数值有大有小、学习速度有快有慢、更新方向时而相反的问题。最经典的两个工作有UWL(Uncertainty Weight):通过自动学习任务的uncertainty,给uncertainty大的任务小权重,uncertainty小的任务大权重;GradNorm:结合任务梯度的二范数和loss下降梯度,引入带权重的损失函数Gradient Loss,并通过梯度下降更新该权重。
|
||||
|
||||
## loss加权融合
|
||||
|
||||
一种最简单的实现多任务学习的方式是对不同任务的loss进行加权。例如谷歌的Youtube DNN论文中提到的一种加权交叉熵:
|
||||
$$
|
||||
\text { Weighted CE Loss }=-\sum_{i}\left[T_{i} y_{i} \log p_{i}+\left(1-y_{i}\right) \log \left(1-p_{i}\right)\right]
|
||||
$$
|
||||
其中![[公式]](https://www.zhihu.com/equation?tex=T_i) 为观看时长。在原始训练数据中,正样本是视频展示后用户点击了该视频,负样本则是展示后未点击,这个一个标准的CTR预估问题。该loss通过改变训练样本的权重,让所有负样本的权重都为 1,而正样本的权重为点击后的视频观看时长 ![[公式]](https://www.zhihu.com/equation?tex=T_i) 。作者认为按点击率排序会倾向于把诱惑用户点击(用户未必真感兴趣)的视频排前面,而观看时长能更好地反映出用户对视频的兴趣,通过重新设计loss使得该模型在保证主目标点击的同时,将视频观看时长转化为样本的权重,达到优化平均观看时长的效果。
|
||||
|
||||
另一种更为简单粗暴的加权方式是人工手动调整权重,例如 0.3\*L(点击)+0.7*L\*(视频完播)
|
||||
|
||||
这种loss加权的方式优点如下:
|
||||
|
||||
- 模型简单,仅在训练时通过梯度乘以样本权重实现对其它目标的加权
|
||||
- 模型上线简单,和base完全相同,不需要额外开销
|
||||
|
||||
缺点:
|
||||
|
||||
- 本质上并不是多目标建模,而是将不同的目标转化为同一个目标。样本的加权权重需要根据AB测试才能确定。
|
||||
|
||||
## Shared-Bottom
|
||||
|
||||
最早的多任务学习模型是底层共享结构(Shared-Bottom),如图所示。
|
||||
|
||||
通过共享底层模块,学习任务间通用的特征表征,再往上针对每一个任务设置一个Tower网络,每个Tower网络的参数由自身对应的任务目标进行学习。Shared Bottom可以根据自身数据特点,使用MLP、DeepFM、DCN、DIN等,Tower网络一般使用简单的MLP。
|
||||
|
||||
代码如下,共享特征embedding,共享底层DNN网络,任务输出层独立,loss直接使用多个任务的loss值之和。
|
||||
|
||||
```python
|
||||
def Shared_Bottom(dnn_feature_columns, num_tasks=None, task_types=None, task_names=None,
|
||||
bottom_dnn_units=[128, 128], tower_dnn_units_lists=[[64,32], [64,32]],
|
||||
l2_reg_embedding=0.00001, l2_reg_dnn=0, seed=1024,dnn_dropout=0,
|
||||
dnn_activation='relu', dnn_use_bn=False):
|
||||
|
||||
features = build_input_features(dnn_feature_columns)
|
||||
inputs_list = list(features.values())
|
||||
|
||||
sparse_embedding_list, dense_value_list = input_from_feature_columns(features, dnn_feature_columns, l2_reg_embedding,seed)
|
||||
#共享输入特征
|
||||
dnn_input = combined_dnn_input(sparse_embedding_list, dense_value_list)
|
||||
#共享底层网络
|
||||
shared_bottom_output = DNN(bottom_dnn_units, dnn_activation, l2_reg_dnn, dnn_dropout, dnn_use_bn, seed=seed)(dnn_input)
|
||||
#任务输出层
|
||||
tasks_output = []
|
||||
for task_type, task_name, tower_dnn in zip(task_types, task_names, tower_dnn_units_lists):
|
||||
tower_output = DNN(tower_dnn, dnn_activation, l2_reg_dnn, dnn_dropout, dnn_use_bn, seed=seed, name='tower_'+task_name)(shared_bottom_output)
|
||||
|
||||
logit = tf.keras.layers.Dense(1, use_bias=False, activation=None)(tower_output)
|
||||
output = PredictionLayer(task_type, name=task_name)(logit)
|
||||
tasks_output.append(output)
|
||||
|
||||
model = tf.keras.models.Model(inputs=inputs_list, outputs=tasks_output)
|
||||
return model
|
||||
```
|
||||
|
||||
优点:
|
||||
|
||||
- 浅层参数共享,互相补充学习,任务相关性越高,模型loss优化效果越明显,也可以加速训练。
|
||||
|
||||
缺点:
|
||||
|
||||
- 任务不相关甚至优化目标相反时(例如新闻的点击与阅读时长),可能会带来负收益,多个任务性能一起下降。
|
||||
|
||||
一般把Shared-Bottom的结构称作“参数硬共享”,多任务学习网络结构设计的发展方向便是如何设计更灵活的共享机制,从而实现“参数软共享”。
|
||||
|
||||
|
||||
|
||||
参考资料:
|
||||
|
||||
[https://developer.aliyun.com/article/793252](https://developer.aliyun.com/article/793252)
|
||||
|
||||
https://zhuanlan.zhihu.com/p/291406172
|
||||
|
||||
Gradnorm: Gradient normalization for adaptive loss balancing in deep multitask networks (ICML'2018)
|
||||
|
||||
UWL: Multi-task learning using uncertainty to weigh losses for scene geometry and semantics (CVPR'2018)
|
||||
|
||||
YoutubeDNN: Deep neural networks for youtube recommendations (RecSys'2016)
|
||||
162
2023旧版内容/4.人工智能/ch02/ch2.2/ch2.2.5/ESMM.md
Normal file
162
2023旧版内容/4.人工智能/ch02/ch2.2/ch2.2.5/ESMM.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# ESMM
|
||||
|
||||
不同的目标由于业务逻辑,有显式的依赖关系,例如**曝光→点击→转化**。用户必然是在商品曝光界面中,先点击了商品,才有可能购买转化。阿里提出了ESMM(Entire Space Multi-Task Model)网络,显式建模具有依赖关系的任务联合训练。该模型虽然为多任务学习模型,但本质上是以CVR为主任务,引入CTR和CTCVR作为辅助任务,解决CVR预估的挑战。
|
||||
|
||||
## 背景与动机
|
||||
|
||||
传统的CVR预估问题存在着两个主要的问题:**样本选择偏差**和**稀疏数据**。下图的白色背景是曝光数据,灰色背景是点击行为数据,黑色背景是购买行为数据。传统CVR预估使用的训练样本仅为灰色和黑色的数据。
|
||||
<div align=center>
|
||||
<img src="https://pic4.zhimg.com/80/v2-2f0df0f6933dd8405c478fcce91f7b6f_1440w.jpg" alt="img" style="zoom:33%;" />
|
||||
</div>
|
||||
|
||||
这会导致两个问题:
|
||||
- 样本选择偏差(sample selection bias,SSB):如图所示,CVR模型的正负样本集合={点击后未转化的负样本+点击后转化的正样本},但是线上预测的时候是样本一旦曝光,就需要预测出CVR和CTR以排序,样本集合={曝光的样本}。构建的训练样本集相当于是从一个与真实分布不一致的分布中采样得到的,这一定程度上违背了机器学习中训练数据和测试数据独立同分布的假设。
|
||||
- 训练数据稀疏(data sparsity,DS):点击样本只占整个曝光样本的很小一部分,而转化样本又只占点击样本的很小一部分。如果只用点击后的数据训练CVR模型,可用的样本将极其稀疏。
|
||||
|
||||
## 解决方案
|
||||
|
||||
阿里妈妈团队提出ESMM,借鉴多任务学习的思路,引入两个辅助任务CTR、CTCVR(已点击然后转化),同时消除以上两个问题。
|
||||
|
||||
三个预测任务如下:
|
||||
|
||||
- **pCTR**:p(click=1 | impression);
|
||||
- **pCVR**: p(conversion=1 | click=1,impression);
|
||||
- **pCTCVR**: p(conversion=1, click=1 | impression) = p(click=1 | impression) * p(conversion=1 | click=1, impression);
|
||||
|
||||
> 注意:其中只有CTR和CVR的label都同时为1时,CTCVR的label才是正样本1。如果出现CTR=0,CVR=1的样本,则为不合法样本,需删除。
|
||||
> pCTCVR是指,当用户已经点击的前提下,用户会购买的概率;pCVR是指如果用户点击了,会购买的概率。
|
||||
|
||||
三个任务之间的关系为:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://pic1.zhimg.com/80/v2-7bbeb8767db5d6a157852c8cd4221548_1440w.jpg" alt="img" style="zoom: 50%;" />
|
||||
</div>
|
||||
|
||||
其中x表示曝光,y表示点击,z表示转化。针对这三个任务,设计了如图所示的模型结构:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://pic1.zhimg.com/80/v2-6d8189bfe378dc4bf6f0db2ba0255eac_1440w.jpg" alt="img" style="zoom:50%;" />
|
||||
</div>
|
||||
|
||||
|
||||
如图,主任务和辅助任务共享特征,不同任务输出层使用不同的网络,将cvr的预测值*ctr的预测值作为ctcvr任务的预测值,利用ctcvr和ctr的label构造损失函数:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://pic3.zhimg.com/80/v2-0098ab4556a8c67a1c12322ea3f89606_1440w.jpg" alt="img" style="zoom: 33%;" />
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
该架构具有两大特点,分别给出上述两个问题的解决方案:
|
||||
|
||||
- 帮助CVR模型在完整样本空间建模(即曝光空间X)。
|
||||
|
||||
<div align=center>
|
||||
<img src="https://pic1.zhimg.com/80/v2-0b0c6dc7d4c38fa422a2876b7c4cc638_1440w.jpg" alt="img" style="zoom:33%;" />
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
从公式中可以看出,pCVR 可以由pCTR 和pCTCVR推导出。从原理上来说,相当于分别单独训练两个模型拟合出pCTR 和pCTCVR,再通过pCTCVR 除以pCTR 得到最终的拟合目标pCVR 。在训练过程中,模型只需要预测pCTCVR和pCTR,利用两种相加组成的联合loss更新参数。pCVR 只是一个中间变量。而pCTCVR和pCTR的数据是在完整样本空间中提取的,从而相当于pCVR也是在整个曝光样本空间中建模。
|
||||
|
||||
- 提供特征表达的迁移学习(embedding层共享)。CVR和CTR任务的两个子网络共享embedding层,网络的embedding层把大规模稀疏的输入数据映射到低维的表示向量,该层的参数占了整个网络参数的绝大部分,需要大量的训练样本才能充分学习得到。由于CTR任务的训练样本量要大大超过CVR任务的训练样本量,ESMM模型中特征表示共享的机制能够使得CVR子任务也能够从只有展现没有点击的样本中学习,从而能够极大地有利于缓解训练数据稀疏性问题。
|
||||
|
||||
模型训练完成后,可以同时预测cvr、ctr、ctcvr三个指标,线上根据实际需求进行融合或者只采用此模型得到的cvr预估值。
|
||||
|
||||
## 总结与拓展
|
||||
|
||||
可以思考以下几个问题
|
||||
|
||||
1. 能不能将乘法换成除法?
|
||||
即分别训练CTR和CTCVR模型,两者相除得到pCVR。论文提供了消融实验的结果,表中的DIVISION模型,比起BASE模型直接建模CTCVRR和CVR,有显著提高,但低于ESMM。原因是pCTR 通常很小,除以一个很小的浮点数容易引起数值不稳定问题。
|
||||
|
||||
<div align=center>
|
||||
<img src="https://pic3.zhimg.com/80/v2-c0b2c860bd63a680d27c911c2e1ba8a2_1440w.jpg" alt="img" style="zoom:53%;" />
|
||||
</div>
|
||||
|
||||
2. 网络结构优化,Tower模型更换?两个塔不一致?
|
||||
原论文中的子任务独立的Tower网络是纯MLP模型,事实上业界在使用过程中一般会采用更为先进的模型(例如DeepFM、DIN等),两个塔也完全可以根据自身特点设置不一样的模型。这也是ESMM框架的优势,子网络可以任意替换,非常容易与其他学习模型集成。
|
||||
|
||||
3. 比loss直接相加更好的方式?
|
||||
原论文是将两个loss直接相加,还可以引入动态加权的学习机制。
|
||||
|
||||
4. 更长的序列依赖建模?
|
||||
有些业务的依赖关系不止有曝光-点击-转化三层,后续的改进模型提出了更深层次的任务依赖关系建模。
|
||||
|
||||
阿里的ESMM2: 在点击到购买之前,用户还有可能产生加入购物车(Cart)、加入心愿单(Wish)等行为。
|
||||
|
||||
<div align=center>
|
||||
<img src="https://pic2.zhimg.com/80/v2-4f9f5508412086315f85d1b7fda733e9_1440w.jpg" alt="img" style="zoom:53%;" />
|
||||
</div>
|
||||
|
||||
相较于直接学习 click->buy (稀疏度约2.6%),可以通过Action路径将目标分解,以Cart为例:click->cart (稀疏 度为10%),cart->buy(稀疏度为12%),通过分解路径,建立多任务学习模型来分步求解CVR模型,缓解稀疏问题,该模型同样也引入了特征共享机制。
|
||||
|
||||
美团的[AITM](https://zhuanlan.zhihu.com/p/508876139/[https://cloud.tencent.com/developer/article/1868117](https://cloud.tencent.com/developer/article/1868117)):信用卡业务中,用户转化通常是一个**曝光->点击->申请->核卡->激活**的过程,具有5层的链路。
|
||||
|
||||
<div align=center>
|
||||
<img src="https://pic4.zhimg.com/80/v2-0ecf42e999795511f40ac6cd7b85eccf_1440w.jpg" alt="img" style="zoom:50%;" />
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
美团提出了一种自适应信息迁移多任务(**Adaptive Information Transfer Multi-task,AITM**)框架,该框架通过自适应信息迁移(AIT)模块对用户多步转化之间的序列依赖进行建模。AIT模块可以自适应地学习在不同的转化阶段需要迁移什么和迁移多少信息。
|
||||
|
||||
总结:
|
||||
|
||||
ESMM首创了利用用户行为序列数据在完整样本空间建模,并提出利用学习CTR和CTCVR的辅助任务,迂回学习CVR,避免了传统CVR模型经常遭遇的样本选择偏差和训练数据稀疏的问题,取得了显著的效果。
|
||||
|
||||
## 代码实践
|
||||
|
||||
与Shared-Bottom同样的共享底层机制,之后两个独立的Tower网络,分别输出CVR和CTR,计算loss时只利用CTR与CTCVR的loss。CVR Tower完成自身网络更新,CTR Tower同时完成自身网络和Embedding参数更新。在评估模型性能时,重点是评估主任务CVR的auc。
|
||||
|
||||
```python
|
||||
def ESSM(dnn_feature_columns, task_type='binary', task_names=['ctr', 'ctcvr'],
|
||||
tower_dnn_units_lists=[[128, 128],[128, 128]], l2_reg_embedding=0.00001, l2_reg_dnn=0,
|
||||
seed=1024, dnn_dropout=0,dnn_activation='relu', dnn_use_bn=False):
|
||||
|
||||
features = build_input_features(dnn_feature_columns)
|
||||
inputs_list = list(features.values())
|
||||
|
||||
sparse_embedding_list, dense_value_list = input_from_feature_columns(features, dnn_feature_columns, l2_reg_embedding,seed)
|
||||
|
||||
dnn_input = combined_dnn_input(sparse_embedding_list, dense_value_list)
|
||||
|
||||
ctr_output = DNN(tower_dnn_units_lists[0], dnn_activation, l2_reg_dnn, dnn_dropout, dnn_use_bn, seed=seed)(dnn_input)
|
||||
cvr_output = DNN(tower_dnn_units_lists[1], dnn_activation, l2_reg_dnn, dnn_dropout, dnn_use_bn, seed=seed)(dnn_input)
|
||||
|
||||
ctr_logit = tf.keras.layers.Dense(1, use_bias=False, activation=None)(ctr_output)
|
||||
cvr_logit = tf.keras.layers.Dense(1, use_bias=False, activation=None)(cvr_output)
|
||||
|
||||
ctr_pred = PredictionLayer(task_type, name=task_names[0])(ctr_logit)
|
||||
cvr_pred = PredictionLayer(task_type)(cvr_logit)
|
||||
|
||||
ctcvr_pred = tf.keras.layers.Multiply(name=task_names[1])([ctr_pred, cvr_pred])#CTCVR = CTR * CVR
|
||||
|
||||
model = tf.keras.models.Model(inputs=inputs_list, outputs=[ctr_pred, cvr_pred, ctcvr_pred])
|
||||
return model
|
||||
```
|
||||
|
||||
测试数据集:
|
||||
|
||||
adult:[https://archive.ics.uci.edu/ml/datasets/census+income](https://archive.ics.uci.edu/dataset/20/census+income)
|
||||
|
||||
将里面两个特征转为label,完成两个任务的预测:
|
||||
|
||||
- 任务1预测该用户收入是否大于50K,
|
||||
- 任务2预测该用户的婚姻是否未婚。
|
||||
|
||||
以上两个任务均为二分类任务,使用交叉熵作为损失函数。在ESMM框架下,我们把任务1作为CTR任务,任务2作为CVR任务,两者label相乘得到CTCVR任务的标签。
|
||||
|
||||
除ESSM之外,之后的MMOE、PLE模型都使用本数据集做测试。
|
||||
|
||||
> 注意上述代码,并未实现论文模型图中提到的field element-wise +模块。该模块实现较为简单,即分别把用户、商品相关特征的embedding求和再拼接,然后输入Tower网络。我们使用数据不具有该属性,暂未区分。
|
||||
|
||||
参考资料:
|
||||
|
||||
https://www.zhihu.com/question/475787809
|
||||
|
||||
https://zhuanlan.zhihu.com/p/37562283
|
||||
|
||||
美团:[https://cloud.tencent.com/developer/article/1868117](https://cloud.tencent.com/developer/article/1868117)
|
||||
|
||||
Entire Space Multi-Task Model: An Effective Approach for Estimating Post-Click Conversion Rate (SIGIR'2018)
|
||||
178
2023旧版内容/4.人工智能/ch02/ch2.2/ch2.2.5/MMOE.md
Normal file
178
2023旧版内容/4.人工智能/ch02/ch2.2/ch2.2.5/MMOE.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# MMOE
|
||||
|
||||
## 写在前面
|
||||
|
||||
MMOE是2018年谷歌提出的,全称是Multi-gate Mixture-of-Experts, 对于多个优化任务,引入了多个专家进行不同的决策和组合,最终完成多目标的预测。解决的是硬共享里面如果多个任务相似性不是很强,底层的embedding学习反而相互影响,最终都学不好的痛点。
|
||||
|
||||
本篇文章首先是先了解下Hard-parameter sharing以及存在的问题,然后引出MMOE,对理论部分进行整理,最后是参考deepctr简单复现。
|
||||
|
||||
## 背景与动机
|
||||
|
||||
推荐系统中,即使同一个场景,常常也不只有一个业务目标。 在Youtube的视频推荐中,推荐排序任务不仅需要考虑到用户点击率,完播率,也需要考虑到一些满意度指标,例如,对视频是否喜欢,用户观看后对视频的评分;在淘宝的信息流商品推荐中,需要考虑到点击率,也需要考虑转化率;而在一些内容场景中,需要考虑到点击和互动、关注、停留时长等指标。
|
||||
|
||||
模型中,如果采用一个网络同时完成多个任务,就可以把这样的网络模型称为多任务模型, 这种模型能在不同任务之间学习共性以及差异性,能够提高建模的质量以及效率。 常见的多任务模型的设计范式大致可以分为三大类:
|
||||
* hard parameter sharing 方法: 这是非常经典的一种方式,底层是共享的隐藏层,学习各个任务的共同模式,上层用一些特定的全连接层学习特定任务模式。
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/ed10df1df313413daf2a6a6174ef4f8c.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBA57-75rua55qE5bCPQOW8ug==,size_1,color_FFFFFF,t_70,g_se,x_16#pic_center" alt="在这里插入图片描述" style="zoom:70%;" />
|
||||
</div>
|
||||
这种方法目前用的也有,比如美团的猜你喜欢,知乎推荐的Ranking等, 这种方法最大的优势是Task越多, 单任务更加不可能过拟合,即可以减少任务之间过拟合的风险。 但是劣势也非常明显,就是底层强制的shared layers难以学习到适用于所有任务的有效表达。 **尤其是任务之间存在冲突的时候**。MMOE中给出了实验结论,当两个任务相关性没那么好(比如排序中的点击率与互动,点击与停留时长),此时这种结果会遭受训练困境,毕竟所有任务底层用的是同一组参数。
|
||||
* soft parameter sharing: 硬的不行,那就来软的,这个范式对应的结果从`MOE->MMOE->PLE`等。 即底层不是使用共享的一个shared bottom,而是有多个tower, 称为多个专家,然后往往再有一个gating networks在多任务学习时,给不同的tower分配不同的权重,那么这样对于不同的任务,可以允许使用底层不同的专家组合去进行预测,相较于上面所有任务共享底层,这个方式显得更加灵活
|
||||
* 任务序列依赖关系建模:这种适合于不同任务之间有一定的序列依赖关系。比如电商场景里面的ctr和cvr,其中cvr这个行为只有在点击之后才会发生。所以这种依赖关系如果能加以利用,可以解决任务预估中的样本选择偏差(SSB)和数据稀疏性(DS)问题
|
||||
* 样本选择偏差: 后一阶段的模型基于上一阶段采样后的样本子集训练,但最终在全样本空间进行推理,带来严重泛化性问题
|
||||
* 样本稀疏: 后一阶段的模型训练样本远小于前一阶段任务
|
||||
|
||||
<br>ESSM是一种较为通用的任务序列依赖关系建模的方法,除此之外,阿里的DBMTL,ESSM2等工作都属于这一个范式。 这个范式可能后面会进行整理,本篇文章不过多赘述。
|
||||
|
||||
通过上面的描述,能大体上对多任务模型方面的几种常用建模范式有了解,然后也知道了hard parameter sharing存在的一些问题,即不能很好的权衡特定任务的目标与任务之间的冲突关系。而这也就是MMOE模型提出的一个动机所在了, 那么下面的关键就是MMOE模型是怎么建模任务之间的关系的,又是怎么能使得特定任务与任务关系保持平衡的?
|
||||
|
||||
带着这两个问题,下面看下MMOE的细节。
|
||||
|
||||
## MMOE模型的理论及论文细节
|
||||
MMOE模型结构图如下。
|
||||
|
||||
<div align=center>
|
||||
<img src="https://img-blog.csdnimg.cn/29c5624f2c8a46c097f097af7dbf4b45.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBA57-75rua55qE5bCPQOW8ug==,size_2,color_FFFFFF,t_70,g_se,x_16#pic_center" alt="在这里插入图片描述" style="zoom:70%;" />
|
||||
</div>
|
||||
|
||||
这其实是一个演进的过程,首先hard parameter sharing这个就不用过多描述了, 下面主要是看MOE模型以及MMOE模型。
|
||||
|
||||
### 混合专家模型
|
||||
我们知道共享的这种模型结构,会遭受任务之间冲突而导致可能无法很好的收敛,从而无法学习到任务之间的共同模式。这个结构也可以看成是多个任务共用了一个专家。
|
||||
|
||||
先抛开任务关系, 我们发现一个专家在多任务学习上的表达能力很有限,于是乎,尝试引入多个专家,这就慢慢的演化出了混合专家模型。 公式表达如下:
|
||||
$$
|
||||
y=\sum_{i=1}^{n} g(x)_{i} f_{i}(x)
|
||||
$$
|
||||
这里的$y$表示的是多个专家的汇总输出,接下来这个东西要过特定的任务塔去得到特定任务的输出。 这里还加了一个门控网络机制,就是一个注意力网络, 来学习各个专家的重要性权重$\sum_{i=1}^{n} g(x)_{i}=1$。$f_i(x)$就是每个专家的输出, 而$g(x)_i$就是每个专家对应的权重。 虽然感觉这个东西,无非就是在单个专家的基础上多引入了几个全连接网络,然后又给这几个全连接网络加权,但是在我看来,这里面至少蕴含了好几个厉害的思路:
|
||||
1. 模型集成思想: 这个东西很像bagging的思路,即训练多个模型进行决策,这个决策的有效性显然要比单独一个模型来的靠谱一点,不管是从泛化能力,表达能力,学习能力上,应该都强于一个模型
|
||||
2. 注意力思想: 为了增加灵活性, 为不同的模型还学习了重要性权重,这可能考虑到了在学习任务的共性模式上, 不同的模型学习的模式不同,那么聚合的时候,显然不能按照相同的重要度聚合,所以为各个专家学习权重,默认了不同专家的决策地位不一样。这个思想目前不过也非常普遍了。
|
||||
3. multi-head机制: 从另一个角度看, 多个专家其实代表了多个不同head, 而不同的head代表了不同的非线性空间,之所以说表达能力增强了,是因为把输入特征映射到了不同的空间中去学习任务之间的共性模式。可以理解成从多个角度去捕捉任务之间的共性特征模式。
|
||||
|
||||
MOE使用了多个混合专家增加了各种表达能力,但是, 一个门控并不是很灵活,因为这所有的任务,最终只能选定一组专家组合,即这个专家组合是在多个任务上综合衡量的结果,并没有针对性了。 如果这些任务都比较相似,那就相当于用这一组专家组合确实可以应对这多个任务,学习到多个相似任务的共性。 但如果任务之间差的很大,这种单门控控制的方式就不行了,因为此时底层的多个专家学习到的特征模式相差可能会很大,毕竟任务不同,而单门控机制选择专家组合的时候,肯定是选择出那些有利于大多数任务的专家, 而对于某些特殊任务,可能学习的一塌糊涂。
|
||||
|
||||
所以,这种方式的缺口很明显,这样,也更能理解为啥提出多门控控制的专家混合模型了。
|
||||
|
||||
### MMOE结构
|
||||
Multi-gate Mixture-of-Experts(MMOE)的魅力就在于在OMOE的基础上,对于每个任务都会涉及一个门控网络,这样,对于每个特定的任务,都能有一组对应的专家组合去进行预测。更关键的时候,参数量还不会增加太多。公式如下:
|
||||
|
||||
$$
|
||||
y_{k}=h^{k}\left(f^{k}(x)\right),
|
||||
$$
|
||||
where $f^{k}(x)=\sum_{i=1}^{n} g^{k}(x)_{i} f_{i}(x)$. 这里的$k$表示任务的个数。 每个门控网络是一个注意力网络:
|
||||
$$
|
||||
g^{k}(x)=\operatorname{softmax}\left(W_{g k} x\right)
|
||||
$$
|
||||
$W_{g k} \in \mathbb{R}^{n \times d}$表示权重矩阵, $n$是专家的个数, $d$是特征的维度。
|
||||
|
||||
上面的公式这里不用过多解释。
|
||||
|
||||
这个改造看似很简单,只是在OMOE上额外多加了几个门控网络,但是却起到了杠杆般的效果,我这里分享下我的理解。
|
||||
* 首先,就刚才分析的OMOE的问题,在专家组合选取上单门控会产生限制,此时如果多个任务产生了冲突,这种结构就无法进行很好的权衡。 而MMOE就不一样了。MMOE是针对每个任务都单独有个门控选择专家组合,那么即使任务冲突了,也能根据不同的门控进行调整,选择出对当前任务有帮助的专家组合。所以,我觉得单门控做到了**针对所有任务在专家选择上的解耦**,而多门控做到了**针对各个任务在专家组合选择上的解耦**。
|
||||
* 多门控机制能够建模任务之间的关系了。如果各个任务都冲突, 那么此时有多门控的帮助, 此时让每个任务独享一个专家,如果任务之间能聚成几个相似的类,那么这几类之间应该对应的不同的专家组合,那么门控机制也可以选择出来。如果所有任务都相似,那这几个门控网络学习到的权重也会相似,所以这种机制把任务的无关,部分相关和全相关进行了一种统一。
|
||||
* 灵活的参数共享, 这个我们可以和hard模式或者是针对每个任务单独建模的模型对比,对于hard模式,所有任务共享底层参数,而每个任务单独建模,是所有任务单独有一套参数,算是共享和不共享的两个极端,对于都共享的极端,害怕任务冲突,而对于一点都不共享的极端,无法利用迁移学习的优势,模型之间没法互享信息,互为补充,容易遭受过拟合的困境,另外还会增加计算量和参数量。 而MMOE处于两者的中间,既兼顾了如果有相似任务,那就参数共享,模式共享,互为补充,如果没有相似任务,那就独立学习,互不影响。 又把这两种极端给进行了统一。
|
||||
* 训练时能快速收敛,这是因为相似的任务对于特定的专家组合训练都会产生贡献,这样进行一轮epoch,相当于单独任务训练时的多轮epoch。
|
||||
|
||||
OK, 到这里就把MMOE的故事整理完了,模型结构本身并不是很复杂,非常符合"大道至简"原理,简单且实用。
|
||||
|
||||
|
||||
那么, 为什么多任务学习为什么是有效的呢? 这里整理一个看到比较不错的答案:
|
||||
>多任务学习有效的原因是引入了归纳偏置,两个效果:
|
||||
> - 互相促进: 可以把多任务模型之间的关系看作是互相 先验知识,也称为归纳迁移,有了对模型的先验假设,可以更好提升模型的效果。解决数据稀疏性其实本身也是迁移学习的一个特性,多任务学习中也同样会体现
|
||||
> - 泛化作用:不同模型学到的表征不同,可能A模型学到的是B模型所没有学好的,B模型也有其自身的特点,而这一点很可能A学不好,这样一来模型健壮性更强
|
||||
|
||||
## MMOE模型的简单复现之多任务预测
|
||||
### 模型概貌
|
||||
这里是MMOE模型的简单复现,参考的deepctr。
|
||||
|
||||
由于MMOE模型不是很复杂,所以这里就可以直接上代码,然后简单解释:
|
||||
|
||||
```python
|
||||
def MMOE(dnn_feature_columns, num_experts=3, expert_dnn_hidden_units=(256, 128), tower_dnn_hidden_units=(64,),
|
||||
gate_dnn_hidden_units=(), l2_reg_embedding=0.00001, l2_reg_dnn=0, dnn_dropout=0, dnn_activation='relu',
|
||||
dnn_use_bn=False, task_types=('binary', 'binary'), task_names=('ctr', 'ctcvr')):
|
||||
|
||||
num_tasks = len(task_names)
|
||||
|
||||
# 构建Input层并将Input层转成列表作为模型的输入
|
||||
input_layer_dict = build_input_layers(dnn_feature_columns)
|
||||
input_layers = list(input_layer_dict.values())
|
||||
|
||||
# 筛选出特征中的sparse和Dense特征, 后面要单独处理
|
||||
sparse_feature_columns = list(filter(lambda x: isinstance(x, SparseFeat), dnn_feature_columns))
|
||||
dense_feature_columns = list(filter(lambda x: isinstance(x, DenseFeat), dnn_feature_columns))
|
||||
|
||||
# 获取Dense Input
|
||||
dnn_dense_input = []
|
||||
for fc in dense_feature_columns:
|
||||
dnn_dense_input.append(input_layer_dict[fc.name])
|
||||
|
||||
# 构建embedding字典
|
||||
embedding_layer_dict = build_embedding_layers(dnn_feature_columns)
|
||||
# 离散的这些特特征embedding之后,然后拼接,然后直接作为全连接层Dense的输入,所以需要进行Flatten
|
||||
dnn_sparse_embed_input = concat_embedding_list(sparse_feature_columns, input_layer_dict, embedding_layer_dict, flatten=False)
|
||||
|
||||
# 把连续特征和离散特征合并起来
|
||||
dnn_input = combined_dnn_input(dnn_sparse_embed_input, dnn_dense_input)
|
||||
|
||||
# 建立专家层
|
||||
expert_outputs = []
|
||||
for i in range(num_experts):
|
||||
expert_network = DNN(expert_dnn_hidden_units, dnn_activation, l2_reg_dnn, dnn_dropout, dnn_use_bn, seed=2022, name='expert_'+str(i))(dnn_input)
|
||||
expert_outputs.append(expert_network)
|
||||
|
||||
expert_concat = Lambda(lambda x: tf.stack(x, axis=1))(expert_outputs)
|
||||
|
||||
# 建立多门控机制层
|
||||
mmoe_outputs = []
|
||||
for i in range(num_tasks): # num_tasks=num_gates
|
||||
# 建立门控层
|
||||
gate_input = DNN(gate_dnn_hidden_units, dnn_activation, l2_reg_dnn, dnn_dropout, dnn_use_bn, seed=2022, name='gate_'+task_names[i])(dnn_input)
|
||||
gate_out = Dense(num_experts, use_bias=False, activation='softmax', name='gate_softmax_'+task_names[i])(gate_input)
|
||||
gate_out = Lambda(lambda x: tf.expand_dims(x, axis=-1))(gate_out)
|
||||
|
||||
# gate multiply the expert
|
||||
gate_mul_expert = Lambda(lambda x: reduce_sum(x[0] * x[1], axis=1, keep_dims=False), name='gate_mul_expert_'+task_names[i])([expert_concat, gate_out])
|
||||
|
||||
mmoe_outputs.append(gate_mul_expert)
|
||||
|
||||
# 每个任务独立的tower
|
||||
task_outputs = []
|
||||
for task_type, task_name, mmoe_out in zip(task_types, task_names, mmoe_outputs):
|
||||
# 建立tower
|
||||
tower_output = DNN(tower_dnn_hidden_units, dnn_activation, l2_reg_dnn, dnn_dropout, dnn_use_bn, seed=2022, name='tower_'+task_name)(mmoe_out)
|
||||
logit = Dense(1, use_bias=False, activation=None)(tower_output)
|
||||
output = PredictionLayer(task_type, name=task_name)(logit)
|
||||
task_outputs.append(output)
|
||||
|
||||
model = Model(inputs=input_layers, outputs=task_outputs)
|
||||
return model
|
||||
```
|
||||
这个其实比较简单, 首先是传入封装好的dnn_features_columns, 这个是
|
||||
|
||||
```python
|
||||
dnn_features_columns = [SparseFeat(feat, feature_max_idx[feat], embedding_dim=4) for feat in sparse_features] \
|
||||
+ [DenseFeat(feat, 1) for feat in dense_features]
|
||||
```
|
||||
就是数据集先根据特征类别分成离散型特征和连续型特征,然后通过sparseFeat或者DenseFeat进行封装起来,组成的一个列表。
|
||||
|
||||
传入之后, 首先为这所有的特征列建立Input层,然后选择出离散特征和连续特征来,连续特征直接拼接即可, 而离散特征需要过embedding层得到连续型输入。把这个输入与连续特征拼接起来,就得到了送入专家的输入。
|
||||
|
||||
接下来,建立MMOE的多个专家, 这里的专家直接就是DNN,当然这个可以替换,比如MOSE里面就用了LSTM,这样的搭建模型方式非常灵活,替换起来非常简单。 把输入过多个专家得到的专家的输出,这里放到了列表里面。
|
||||
|
||||
接下来,建立多个门控网络,由于MMOE里面是每个任务会有一个单独的门控进行控制,所以这里的门控网络个数和任务数相同,门控网络也是DNN,接收输入,得到专家个输出作为每个专家的权重,把每个专家的输出加权组合得到门控网络最终的输出,放到列表中,这里的列表长度和task_num对应。
|
||||
|
||||
接下来, 为每个任务建立tower,学习特定的feature信息。同样也是DNN,接收的输入是上面列表的输出,每个任务的门控输出输入到各自的tower里面,得到最终的输出即可。 最终的输出也是个列表,对应的每个任务最终的网络输出值。
|
||||
|
||||
这就是整个MMOE网络的搭建逻辑。
|
||||
|
||||
|
||||
**参考资料**:
|
||||
* [MMOE论文](https://dl.acm.org/doi/pdf/10.1145/3219819.3220007)
|
||||
* [Recommending What Video to Watch Next: A Multitask
|
||||
Ranking System](https://dl.acm.org/doi/pdf/10.1145/3298689.3346997)
|
||||
* [Multitask Mixture of Sequential Experts for User Activity Streams](https://research.google/pubs/pub49274/)
|
||||
* [推荐系统中的多目标学习](https://zhuanlan.zhihu.com/p/183760759)
|
||||
* [推荐精排模型之多目标](https://zhuanlan.zhihu.com/p/221738556)
|
||||
* [Youtube视频推荐中应用MMOE模型](http://t.zoukankan.com/Lee-yl-p-13274642.html)
|
||||
* [多任务学习论文导读:Recommending What Video to Watch Next-A Multitask Ranking System](https://blog.csdn.net/fanzitao/article/details/104525843/)
|
||||
* [多任务模型之MoSE](https://zhuanlan.zhihu.com/p/161628342)
|
||||
389
2023旧版内容/4.人工智能/ch02/ch2.2/ch2.2.5/PLE.md
Normal file
389
2023旧版内容/4.人工智能/ch02/ch2.2/ch2.2.5/PLE.md
Normal file
@@ -0,0 +1,389 @@
|
||||
# PLE
|
||||
|
||||
**PLE**(Progressive Layered Extraction)模型由腾讯PCG团队在2020年提出,主要为了解决跷跷板问题,该论文获得了RecSys'2020的最佳长论文(Best Lone Paper Award)。
|
||||
|
||||
## 背景与动机
|
||||
|
||||
文章首先提出多任务学习中不可避免的两个缺点:
|
||||
|
||||
- 负迁移(Negative Transfer):针对相关性较差的任务,使用shared-bottom这种硬参数共享的机制会出现负迁移现象,不同任务之间存在冲突时,会导致模型无法有效进行参数的学习,不如对多个任务单独训练。
|
||||
- 跷跷板现象(Seesaw Phenomenon):针对相关性较为复杂的场景,通常不可避免出现跷跷板现象。多任务学习模式下,往往能够提升一部分任务的效果,但同时需要牺牲其他任务的效果。即使通过MMOE这种方式减轻负迁移现象,跷跷板问题仍然广泛存在。
|
||||
|
||||
在腾讯视频推荐场景下,有两个核心建模任务:
|
||||
|
||||
- VCR(View Completion Ratio):播放完成率,播放时间占视频时长的比例,回归任务
|
||||
- VTR(View Through Rate) :有效播放率,播放时间是否超过某个阈值,分类任务
|
||||
|
||||
这两个任务之间的关系是复杂的,在应用以往的多任务模型中发现,要想提升VTR准确率,则VCR准确率会下降,反之亦然。
|
||||
|
||||
上一小节提到的MMOE网络存在如下几个缺点
|
||||
|
||||
- MMOE中所有的Expert是被所有任务所共享,这可能无法捕捉到任务之间更复杂的关系,从而给部分任务带来一定的噪声。
|
||||
- 在复杂任务机制下,MMOE不同专家在不同任务的权重学的差不多
|
||||
- 不同的Expert之间没有交互,联合优化的效果有所折扣
|
||||
|
||||
## 解决方案
|
||||
|
||||
为了解决跷跷板现象,以及优化MMOE模型,PLE在网络结构设计上提出两大改进:
|
||||
|
||||
**一、CGC**(Customized Gate Control) 定制门控
|
||||
|
||||
PLE将共享的部分和每个任务特定的部分**显式的分开**,强化任务自身独立特性。把MMOE中提出的Expert分成两种,任务特定task-specific和任务共享task-shared。保证expert“各有所得”,更好的降低了弱相关性任务之间参数共享带来的问题。
|
||||
|
||||
网络结构如图所示,同样的特征输入分别送往三类不同的专家模型(任务A专家、任务B专家、任务共享专家),再通过门控机制加权聚合之后输入各自的Tower网络。门控网络,把原始数据和expert网络输出共同作为输入,通过单层全连接网络+softmax激活函数,得到分配给expert的加权权重,与attention机制类型。
|
||||
|
||||
|
||||
|
||||
<div align=center>
|
||||
<img src="https://pic3.zhimg.com/80/v2-c92975f7c21cc568a13cd9447adc757a_1440w.jpg" style="zoom:40%;" />
|
||||
</div>
|
||||
|
||||
任务A有 ![[公式]](https://www.zhihu.com/equation?tex=m_A) 个expert,任务B有 ![[公式]](https://www.zhihu.com/equation?tex=m_B) 个expert,另外还有 ![[公式]](https://www.zhihu.com/equation?tex=m_S) 个任务A、B共享的Expert。这样对Expert做一个显式的分割,可以让task-specific expert只受自己任务梯度的影响,不会受到其他任务的干扰(每个任务保底有一个独立的网络模型),而只有task-shared expert才受多个任务的混合梯度影响。
|
||||
|
||||
MMOE则是将所有Expert一视同仁,都加权输入到每一个任务的Tower,其中任务之间的关系完全交由gate自身进行学习。虽然MMOE提出的门控机制理论上可以捕捉到任务之间的关系,比如任务A可能与任务B确实无关,则MMOE中gate可以学到,让个别专家对于任务A的权重趋近于0,近似得到PLE中提出的task-specific expert。如果说MMOE是希望让expert网络可以对不同的任务各有所得,则PLE是保证让expert网络各有所得。
|
||||
|
||||
二、**PLE** (progressive layered extraction) 分层萃取
|
||||
|
||||
PLE就是上述CGC网络的多层纵向叠加,以获得更加丰富的表征能力。在分层的机制下,Gate设计成两种类型,使得不同类型Expert信息融合交互。task-share gate融合所有Expert信息,task-specific gate只融合specific expert和share expert。模型结构如图:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://pic2.zhimg.com/80/v2-ff3b4aff3511e6e56a3b509f244c5ab1_1440w.jpg" style="zoom:40%;" />
|
||||
</div>
|
||||
|
||||
将任务A、任务B和shared expert的输出输入到下一层,下一层的gate是以这三个上一层输出的结果作为门控的输入,而不是用原始input特征作为输入。这使得gate同时融合task-shares expert和task-specific expert的信息,论文实验中证明这种不同类型expert信息的交叉,可以带来更好的效果。
|
||||
|
||||
三、多任务loss联合优化
|
||||
|
||||
该论文专门讨论了loss设计的问题。在传统的多任务学习模型中,多任务的loss一般为
|
||||
|
||||
<div align=center>
|
||||
<img src="https://pic4.zhimg.com/80/v2-ec1a0ae2a4001fea296662a9a5a1942b_1440w.jpg" style="zoom:33%;" />
|
||||
</div>
|
||||
|
||||
其中K是指任务数, ![[公式]](https://www.zhihu.com/equation?tex=w_k) 是每个任务各自对应的权重。这种loss存在两个关键问题:
|
||||
|
||||
- 不同任务之间的样本空间不一致:在视频推荐场景中,目标之间的依赖关系如图,曝光→播放→点击→(分享、评论),不同任务有不同的样本空间。
|
||||
|
||||
<div align=center>
|
||||
<img src="https://pic3.zhimg.com/80/v2-bdf39ef6fcaf000924294cb010642fce_1440w.jpg" style="zoom:63%;" />
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
PLE将训练样本空间作为全部任务样本空间的并集,在分别针对每个任务算loss时,只考虑该任务的样本的空 间,一般需对这种数据集会附带一个样本空间标签。loss公式如下:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://pic2.zhimg.com/80/v2-8defd1e5d1ba896bb2d18bdb1db4e3cd_1440w.jpg" style="zoom:40%;" />
|
||||
</div>
|
||||
|
||||
|
||||
其中, ![[公式]](https://www.zhihu.com/equation?tex=%5Cdelta_%7Bk%7D%5E%7Bi%7D+%5Cin%5C%7B0%2C1%5C%7D%2C+%5Cdelta_%7Bk%7D%5E%7Bi%7D+) 表示样本i是否处于任务k的样本空间。
|
||||
|
||||
- 不同任务各自独立的权重设定:PLE提出了一种加权的规则,它的思想是随着迭代次数的增加,任务的权重应当不断衰减。它为每个任务设定一个初始权重 ![[公式]](https://www.zhihu.com/equation?tex=w_%7Bk%2C0%7D) ,再按该公式进行更新:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://pic1.zhimg.com/80/v2-2fbd23599bd2cd62222607e76cb975ec_1440w.jpg" style="zoom:40%;" />
|
||||
</div>
|
||||
|
||||
## 实验
|
||||
|
||||
该论文的一大特点是提供了极其丰富的实验,首先是在自身大规模数据集上的离线实验。
|
||||
|
||||
第一组实验是两个关系复杂的任务VTR(回归)与VCR(分类),如表1,实验结果证明PLE可以实现多任务共赢,而其他的硬共享或者软共享机制,则会导致部分任务受损。
|
||||
|
||||
<div align=center>
|
||||
<img src="https://pic1.zhimg.com/80/v2-4a190a8a3bcd810fbe1e810171ddc25c_1440w.jpg" alt="img" style="zoom: 33%;" />
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
第二组实验是两个关系简单清晰的任务,CTR与VCR,都是分类任务,且CTR→VCR存在任务依赖关系,如表2,这种多任务下,基本上所有参数共享的模型都能得到性能的提升,而PLE的提升效果最为明显。
|
||||
|
||||
<div align=center>
|
||||
<img src="https://pic4.zhimg.com/80/v2-29baaf461d29a4eff32e7ea324ef7f77_1440w.jpg" alt="img" style="zoom: 50%;" />
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
第三组实验则是线上的A/B Test,上面两组离线实验中,其实PLE相比于其他baseline模型,无论是回归任务的mse,还是分类任务的auc,提升都不是特别显著。在推荐场景中,评估模型性能的最佳利器还是线上的A/B Test。作者在pcg视频推荐的场景中,将部分用户随机划分到不同的实验组中,用PLE模型预估VTR和VCR,进行四周的实验。如表3所示,线上评估指标(总播放完成视频数量和总播放时间)均得到了较为显著的提升,而硬参数共享模型则带对两个指标都带来显著的下降。
|
||||
|
||||
<div align=center>
|
||||
<img src="https://pic3.zhimg.com/80/v2-d6daf1d58fa5edd9fa96aefd254f71ee_1440w.jpg" alt="img" style="zoom: 50%;" />
|
||||
</div>
|
||||
|
||||
第四组实验中,作者引入了更多的任务,验证PLE分层结构的必要性。如表4,随着任务数量的增加,PLE对比CGC的优势更加显著。
|
||||
|
||||
<div align=center>
|
||||
<img src="https://pic4.zhimg.com/80/v2-0b13558bc7e95f601c60a26deaff9acf_1440w.jpg" alt="img" style="zoom:50%;" />
|
||||
</div>
|
||||
|
||||
文中也设计实验,单独对MMOE和CGC的专家利用率进行对比分析,为了实现方便和公平,每个expert都是一个一层网络,每个expert module都只有一个expert,每一层只有3个expert。如图所示,柱子的高度和竖直短线分别表示expert权重的均值和方差。
|
||||
|
||||
<div align=center>
|
||||
<img src="https://pic4.zhimg.com/80/v2-557473be41f7f6fa5efc1ff17e21bab7_1440w.jpg" alt="img" style="zoom:50%;" />
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
可以看到,无论是 MMoE 还是 ML-MMoE,不同任务在三个 Expert 上的权重都是接近的,但对于 CGC & PLE 来说,不同任务在共享 Expert 上的权重是有较大差异的。PLE针对不同的任务,能够有效利用共享 Expert 和独有 Expert 的信息,解释了为什么其能够达到比 MMoE 更好的训练结果。CGC理论上是MMOE的子集,该实验表明,现实中MMOE很难收敛成这个CGC的样子,所以PLE模型就显式的规定了CGC这样的结构。
|
||||
|
||||
## 总结与拓展
|
||||
|
||||
总结:
|
||||
|
||||
CGC在结构上设计的分化,实现了专家功能的分化,而PLE则是通过分层叠加,使得不同专家的信息进行融合。整个结构的设计,是为了让多任务学习模型,不仅可以学习到各自任务独有的表征,还能学习不同任务共享的表征。
|
||||
|
||||
论文中也对大多数的MTL模型进行了抽象,总结如下图:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://pic2.zhimg.com/80/v2-d607cd8e14d4a0fadb4dbef06dc2ffa9_1440w.jpg" alt="img" style="zoom:50%;" />
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
不同的MTL模型即不同的参数共享机制,CGC的结构最为灵活。
|
||||
|
||||
可以思考下以下几个问题:
|
||||
|
||||
1. 多任务模型线上如何打分融合?
|
||||
在论文中,作者分享了腾讯视频的一种线上打分机制
|
||||
|
||||
<div align=center>
|
||||
<img src="https://pic2.zhimg.com/80/v2-9a412b82d45877287df2429fc89afac5_1440w.jpg" alt="img" style="zoom:33%;" />
|
||||
</div>
|
||||
|
||||
每个目标的预估值有一个固定的权重,通过乘法进行融合,并在最后未来排除视频自身时长的影响,使用 $ f(videolen)$对视频时长进行了非线性变化。其实在业界的案例中,也基本是依赖乘法或者加法进行融合,爱奇艺曾经公开分享过他们使用过的打分方法:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://pic1.zhimg.com/80/v2-661030ad194ae2059eace0804ef0f774_1440w.jpg" alt="img" style="zoom: 67%;" />
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
在业务目标较少时,通过加法方式融合新增目标可以短期内快速获得收益。但是随着目标增多,加法融合会 逐步弱化各字母表的重要性影响,而乘法融合则具有一定的模板独立性,乘法机制更加灵活,效益更好。融 合的权重超参一般在线上通过A/B test调试。
|
||||
|
||||
2. 专家的参数如何设置?
|
||||
PLE模型存在的超参数较多,其中专家和门控网络都有两种类型。一般来说,task-specific expert每个任务1-2个,shared expert个数在任务个数的1倍以上。原论文中的gate网络即单层FC,可以适当增加,调试。
|
||||
|
||||
3. ESMM、MMOE、PLE模型如何选择?
|
||||
|
||||
- 个人经验,无论任务之间是否有依赖关系,皆可以优先尝试CGC。而多层CGC(即PLE)未必比CGC效果好,且在相同参数规模小,CGC普遍好于MMOE。对于相关性特别差的多任务,CGC相对MMOE而言有多个专有expert兜底。
|
||||
|
||||
- 对于典型的label存在路径依赖的多任务,例如CTR与CVR,可尝试ESMM。
|
||||
|
||||
- 而在业界的实践案例中,更多的是两种范式的模型进行融合。例如美团在其搜索多业务排序场景上提出的模型:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://pic2.zhimg.com/80/v2-af16e969a0149aef9c2a1291de5c65d5_1440w.jpg" alt="img" style="zoom:50%;" />
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
总框架是ESMM的架构,以建模下单(CVR)为主任务,CTR和CTCVR为辅助任务。在底层的模块中,则使用了CGC模块,提取多任务模式下的特征表达信息。
|
||||
|
||||
4. 不同Tower能否输入不同的特征?不同的expert使用不同的特征?不同的门控使用不同的特征?
|
||||
MMOE、PLE原论文中介绍的模型均是使用同样的原始特征输入各个不同的expert,也输入给第一层的gate。最顶层的Tower网络中则均是由一个gate融合所有expert输出作为输入。在实践中,可以根据业务需求进行调整。
|
||||
|
||||
- 例如上图中美团提出的模型,在CTR的tower下,设置了五个子塔:闪购子网络、买菜子网络、外卖子网络、优选子网络和团好货子网络,并且对不同的子塔有额外输入不同的特征。
|
||||
对于底层输入给expert的特征,美团提出通过增加一个自适应的特征选择门,使得选出的特征对不同的业务权重不同。例如“配送时间”这个特征对闪购业务比较重要,但对于团好货影响不是很大。模型结构如图:
|
||||
|
||||
<div align=center>
|
||||
<img src="https://pic2.zhimg.com/80/v2-2e2370794bbd69ded636a248d8c36255_1440w.jpg" alt="img" style="zoom:50%;" />
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
特征选择门与控制expert信息融合的gate类似,由一层FC和softmax组成,输出是特征维度的权重。对于每一个特征通过该门都得到一个权重向量,权重向量点乘原始特征的embedding作为expert的输入。
|
||||
|
||||
5. 多任务loss更高效的融合机制
|
||||
|
||||
推荐首先尝试两种简单实用的方法,GrandNorm和UWL,具体实现细节查看下文所附的参考资料。
|
||||
|
||||
- UWL(Uncertainty Weight):通过自动学习任务的uncertainty,给uncertainty大的任务小权重,uncertainty小的任务大权重;
|
||||
- GradNorm:结合任务梯度的二范数和loss下降梯度,引入带权重的损失函数Gradient Loss,并通过梯度下降更新该权重。
|
||||
|
||||
## 代码实践
|
||||
|
||||
主要是分两个层级,在PLE的层级下,由于PLE是分层,上一层是输出是下一层的输入,代码逻辑为:
|
||||
|
||||
```python
|
||||
# build Progressive Layered Extraction
|
||||
ple_inputs = [dnn_input] * (num_tasks + 1) # [task1, task2, ... taskn, shared task]
|
||||
ple_outputs = []
|
||||
for i in range(num_levels):
|
||||
if i == num_levels - 1: # the last level
|
||||
ple_outputs = cgc_net(inputs=ple_inputs, level_name='level_' + str(i) + '_', is_last=True)
|
||||
else:
|
||||
ple_outputs = cgc_net(inputs=ple_inputs, level_name='level_' + str(i) + '_', is_last=False)
|
||||
ple_inputs = ple_outputs
|
||||
```
|
||||
|
||||
其中cgc_net函数则对应论文中提出的CGC模块,我们把expert分成两类,task-specific和task-shared,为了方便索引,expert list中expert的排列顺序为[task1-expert1, task1-expert2,...task2-expert1, task2-expert2,...shared expert 1... ],则可以通过双重循环创建专家网络:
|
||||
|
||||
```python
|
||||
for i in range(num_tasks): #任务个数
|
||||
for j in range(specific_expert_num): #每个任务对应的task-specific专家个数
|
||||
pass
|
||||
```
|
||||
|
||||
注意门控网络也分为两种类型,task-specific gate的输入是每个任务对应的expert的输出和共享expert的输出,我们同样把共享expert的输出放在最后,方便索引
|
||||
|
||||
```python
|
||||
for i in range(num_tasks):
|
||||
# concat task-specific expert and task-shared expert
|
||||
cur_expert_num = specific_expert_num + shared_expert_num
|
||||
# task_specific + task_shared
|
||||
cur_experts = specific_expert_outputs[
|
||||
i * specific_expert_num:(i + 1) * specific_expert_num] + shared_expert_outputs
|
||||
```
|
||||
|
||||
在最后一层中,由于CGC模块的输出需要分别输入给不同任务各自的Tower模块,所以不需要创建task-shared gate。完整代码如下
|
||||
|
||||
```python
|
||||
def PLE(dnn_feature_columns, shared_expert_num=1, specific_expert_num=1, num_levels=2,
|
||||
expert_dnn_hidden_units=(256,), tower_dnn_hidden_units=(64,), gate_dnn_hidden_units=(),
|
||||
l2_reg_embedding=0.00001,
|
||||
l2_reg_dnn=0, seed=1024, dnn_dropout=0, dnn_activation='relu', dnn_use_bn=False,
|
||||
task_types=('binary', 'binary'), task_names=('ctr', 'ctcvr')):
|
||||
"""Instantiates the multi level of Customized Gate Control of Progressive Layered Extraction architecture.
|
||||
:param dnn_feature_columns: An iterable containing all the features used by deep part of the model.
|
||||
:param shared_expert_num: integer, number of task-shared experts.
|
||||
:param specific_expert_num: integer, number of task-specific experts.
|
||||
:param num_levels: integer, number of CGC levels.
|
||||
:param expert_dnn_hidden_units: list,list of positive integer or empty list, the layer number and units in each layer of expert DNN.
|
||||
:param tower_dnn_hidden_units: list,list of positive integer or empty list, the layer number and units in each layer of task-specific DNN.
|
||||
:param gate_dnn_hidden_units: list,list of positive integer or empty list, the layer number and units in each layer of gate DNN.
|
||||
:param l2_reg_embedding: float. L2 regularizer strength applied to embedding vector.
|
||||
:param l2_reg_dnn: float. L2 regularizer strength applied to DNN.
|
||||
:param seed: integer ,to use as random seed.
|
||||
:param dnn_dropout: float in [0,1), the probability we will drop out a given DNN coordinate.
|
||||
:param dnn_activation: Activation function to use in DNN.
|
||||
:param dnn_use_bn: bool. Whether use BatchNormalization before activation or not in DNN.
|
||||
:param task_types: list of str, indicating the loss of each tasks, ``"binary"`` for binary logloss, ``"regression"`` for regression loss. e.g. ['binary', 'regression']
|
||||
:param task_names: list of str, indicating the predict target of each tasks
|
||||
:return: a Keras model instance.
|
||||
"""
|
||||
num_tasks = len(task_names)
|
||||
if num_tasks <= 1:
|
||||
raise ValueError("num_tasks must be greater than 1")
|
||||
|
||||
if len(task_types) != num_tasks:
|
||||
raise ValueError("num_tasks must be equal to the length of task_types")
|
||||
|
||||
for task_type in task_types:
|
||||
if task_type not in ['binary', 'regression']:
|
||||
raise ValueError("task must be binary or regression, {} is illegal".format(task_type))
|
||||
|
||||
features = build_input_features(dnn_feature_columns)
|
||||
|
||||
inputs_list = list(features.values())
|
||||
|
||||
sparse_embedding_list, dense_value_list = input_from_feature_columns(features, dnn_feature_columns,
|
||||
l2_reg_embedding, seed)
|
||||
dnn_input = combined_dnn_input(sparse_embedding_list, dense_value_list)
|
||||
|
||||
# single Extraction Layer
|
||||
def cgc_net(inputs, level_name, is_last=False):
|
||||
# inputs: [task1, task2, ... taskn, shared task]
|
||||
specific_expert_outputs = []
|
||||
# build task-specific expert layer
|
||||
for i in range(num_tasks):
|
||||
for j in range(specific_expert_num):
|
||||
expert_network = DNN(expert_dnn_hidden_units, dnn_activation, l2_reg_dnn, dnn_dropout, dnn_use_bn,
|
||||
seed=seed,
|
||||
name=level_name + 'task_' + task_names[i] + '_expert_specific_' + str(j))(
|
||||
inputs[i])
|
||||
specific_expert_outputs.append(expert_network)
|
||||
|
||||
# build task-shared expert layer
|
||||
shared_expert_outputs = []
|
||||
for k in range(shared_expert_num):
|
||||
expert_network = DNN(expert_dnn_hidden_units, dnn_activation, l2_reg_dnn, dnn_dropout, dnn_use_bn,
|
||||
seed=seed,
|
||||
name=level_name + 'expert_shared_' + str(k))(inputs[-1])
|
||||
shared_expert_outputs.append(expert_network)
|
||||
|
||||
# task_specific gate (count = num_tasks)
|
||||
cgc_outs = []
|
||||
for i in range(num_tasks):
|
||||
# concat task-specific expert and task-shared expert
|
||||
cur_expert_num = specific_expert_num + shared_expert_num
|
||||
# task_specific + task_shared
|
||||
cur_experts = specific_expert_outputs[
|
||||
i * specific_expert_num:(i + 1) * specific_expert_num] + shared_expert_outputs
|
||||
|
||||
expert_concat = tf.keras.layers.Lambda(lambda x: tf.stack(x, axis=1))(cur_experts)
|
||||
|
||||
# build gate layers
|
||||
gate_input = DNN(gate_dnn_hidden_units, dnn_activation, l2_reg_dnn, dnn_dropout, dnn_use_bn,
|
||||
seed=seed,
|
||||
name=level_name + 'gate_specific_' + task_names[i])(
|
||||
inputs[i]) # gate[i] for task input[i]
|
||||
gate_out = tf.keras.layers.Dense(cur_expert_num, use_bias=False, activation='softmax',
|
||||
name=level_name + 'gate_softmax_specific_' + task_names[i])(gate_input)
|
||||
gate_out = tf.keras.layers.Lambda(lambda x: tf.expand_dims(x, axis=-1))(gate_out)
|
||||
|
||||
# gate multiply the expert
|
||||
gate_mul_expert = tf.keras.layers.Lambda(lambda x: reduce_sum(x[0] * x[1], axis=1, keep_dims=False),
|
||||
name=level_name + 'gate_mul_expert_specific_' + task_names[i])(
|
||||
[expert_concat, gate_out])
|
||||
cgc_outs.append(gate_mul_expert)
|
||||
|
||||
# task_shared gate, if the level not in last, add one shared gate
|
||||
if not is_last:
|
||||
cur_expert_num = num_tasks * specific_expert_num + shared_expert_num
|
||||
cur_experts = specific_expert_outputs + shared_expert_outputs # all the expert include task-specific expert and task-shared expert
|
||||
|
||||
expert_concat = tf.keras.layers.Lambda(lambda x: tf.stack(x, axis=1))(cur_experts)
|
||||
|
||||
# build gate layers
|
||||
gate_input = DNN(gate_dnn_hidden_units, dnn_activation, l2_reg_dnn, dnn_dropout, dnn_use_bn,
|
||||
seed=seed,
|
||||
name=level_name + 'gate_shared')(inputs[-1]) # gate for shared task input
|
||||
|
||||
gate_out = tf.keras.layers.Dense(cur_expert_num, use_bias=False, activation='softmax',
|
||||
name=level_name + 'gate_softmax_shared')(gate_input)
|
||||
gate_out = tf.keras.layers.Lambda(lambda x: tf.expand_dims(x, axis=-1))(gate_out)
|
||||
|
||||
# gate multiply the expert
|
||||
gate_mul_expert = tf.keras.layers.Lambda(lambda x: reduce_sum(x[0] * x[1], axis=1, keep_dims=False),
|
||||
name=level_name + 'gate_mul_expert_shared')(
|
||||
[expert_concat, gate_out])
|
||||
|
||||
cgc_outs.append(gate_mul_expert)
|
||||
return cgc_outs
|
||||
|
||||
# build Progressive Layered Extraction
|
||||
ple_inputs = [dnn_input] * (num_tasks + 1) # [task1, task2, ... taskn, shared task]
|
||||
ple_outputs = []
|
||||
for i in range(num_levels):
|
||||
if i == num_levels - 1: # the last level
|
||||
ple_outputs = cgc_net(inputs=ple_inputs, level_name='level_' + str(i) + '_', is_last=True)
|
||||
else:
|
||||
ple_outputs = cgc_net(inputs=ple_inputs, level_name='level_' + str(i) + '_', is_last=False)
|
||||
ple_inputs = ple_outputs
|
||||
|
||||
task_outs = []
|
||||
for task_type, task_name, ple_out in zip(task_types, task_names, ple_outputs):
|
||||
# build tower layer
|
||||
tower_output = DNN(tower_dnn_hidden_units, dnn_activation, l2_reg_dnn, dnn_dropout, dnn_use_bn, seed=seed,
|
||||
name='tower_' + task_name)(ple_out)
|
||||
logit = tf.keras.layers.Dense(1, use_bias=False, activation=None)(tower_output)
|
||||
output = PredictionLayer(task_type, name=task_name)(logit)
|
||||
task_outs.append(output)
|
||||
|
||||
model = tf.keras.models.Model(inputs=inputs_list, outputs=task_outs)
|
||||
return model
|
||||
```
|
||||
|
||||
参考资料
|
||||
|
||||
Progressive Layered Extraction (PLE): A Novel Multi-Task Learning (MTL) Model for Personalized Recommendations (RecSys'2020)
|
||||
|
||||
https://zhuanlan.zhihu.com/p/291406172
|
||||
|
||||
爱奇艺:[https://www.6aiq.com/article/1624916831286](https://www.6aiq.com/article/1624916831286)
|
||||
|
||||
美团:[https://mp.weixin.qq.com/s/WBwvfqOTDKCwGgoaGoSs6Q](https://mp.weixin.qq.com/s/WBwvfqOTDKCwGgoaGoSs6Q)
|
||||
|
||||
多任务loss优化:[https://blog.csdn.net/wuzhongqi](https://blog.csdn.net/wuzhongqi)
|
||||
Reference in New Issue
Block a user