Revert "Delete 4.人工智能 directory"

This reverts commit d77a9b0c51.
This commit is contained in:
camera-2018
2023-07-22 22:04:47 +08:00
parent f6ed512d71
commit c43e32de1e
396 changed files with 19301 additions and 0 deletions

View 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类特征建一类) ADID类特征在CTR预估中是非常重要的特征直接将ADID作为feature进行建树不可行故考虑为每个ADID建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)

View 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://link.zhihu.com/?target=https%3A//arxiv.org/abs/1810.11921)
* [AutoInt基于Multi-Head Self-Attention构造高阶特征](https://zhuanlan.zhihu.com/p/60185134)

View 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&DCross部分表达能力更强 使得模型具备了更强的非线性学习能力。
## 代码实现
下面我们看下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)

View 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等问题比如可以采用MSEMean Square Errorloss 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)

View File

@@ -0,0 +1,451 @@
## 写在前面
FiBiNET(Feature Importance and Bilinear feature Interaction)是2019年发表在RecSys的一个模型来自新浪微博张俊林老师的团队。这个模型如果从模型演化的角度来看 主要是在特征重要性以及特征之间交互上做出了探索。所以如果想掌握FiBiNet的话需要掌握两大核心模块
* 模型的特征重要性选择 --- SENET网络
* 特征之间的交互 --- 双线性交叉层(组合了内积和哈达玛积)
## FiBiNet 我们先需要先了解这些
FiBiNet的提出动机是因为在特征交互这一方面 目前的ctr模型要么是简单的两两embedding内积(这里针对离散特征) 比如FMFFM。 或者是两两embedding进行哈达玛积(NFM这种) 作者认为这两种交互方式还是过于简单, 另外像NFM这种FM这种也忽视了特征之间的重要性程度。
对于特征重要性,作者在论文中举得例子非常形象
>the feature occupation is more important than the feature hobby when we predict a persons 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)

View 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)

View 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)

View 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的embeddingm是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)

View 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)

View 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)

View 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的优势所在。 比较有代表的模型PNNDeepCrossing模型等。 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模块的层数设置为1feature 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如何学习高阶特征交互
通过提出的交叉网络这里单独证明了这个结构要比CrossNetDNN模块和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, FNNPNNDCN, NFM, W&D, DeepFM,
2. 学习精心的表征学习:这块常见的深度学习模型不是focus在学习高阶特征交互关系。比如NCFACFDIN等。
推荐系统数据特点: 稀疏,类别连续特征混合,高维。
关于未来两个方向:
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这个网络是怎么实现的因为其他的在之前的模型里面也基本是类似的操作比如前面DIENDSIN版本并且我后面项目里面补充了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. 具体卷积运算的时候这里采用的是Conv1d1维卷积对应的是一张张高度为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)

View 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)

View 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 layerPooling & 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$的一个矩阵(每一列代表一个embedding7列正好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)
* 王喆 - 《深度学习推荐系统》

View File

@@ -0,0 +1,731 @@
## 写在前面
DSIN全称是Deep Session Interest Network(深度会话兴趣网络) 重点在这个Session上这个是在DIEN的基础上又进行的一次演化这个模型的改进出发点依然是如何通过用户的历史点击行为从里面更好的提取用户的兴趣以及兴趣的演化过程这个模型就是从user历史行为信息挖掘方向上进行演化的。而提出的动机呢 就是作者发现用户的行为序列的组成单位,其实应该是会话(按照用户的点击时间划分开的一段行为),每个会话里面的点击行为呢? 会高度相似而会话与会话之间的行为就不是那么相似了但是像DINDIEN这两个模型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的操作了这个在这里就不解释了和之前的DIENDIN的操作就一样了 代码也不放在这里了剩下的代码都看后面的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 DeepCTRs documentation!](https://deepctr-doc.readthedocs.io/en/latest/)

View 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数值有大有小、学习速度有快有慢、更新方向时而相反的问题。最经典的两个工作有UWLUncertainty 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://link.zhihu.com/?target=https%3A//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)

View 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 biasSSB如图所示CVR模型的正负样本集合={点击后未转化的负样本+点击后转化的正样本}但是线上预测的时候是样本一旦曝光就需要预测出CVR和CTR以排序样本集合={曝光的样本}。构建的训练样本集相当于是从一个与真实分布不一致的分布中采样得到的,这一定程度上违背了机器学习中训练数据和测试数据独立同分布的假设。
- 训练数据稀疏data sparsityDS点击样本只占整个曝光样本的很小一部分而转化样本又只占点击样本的很小一部分。如果只用点击后的数据训练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=0CVR=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-taskAITM**框架该框架通过自适应信息迁移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://link.zhihu.com/?target=https%3A//archive.ics.uci.edu/ml/datasets/census%2Bincome)
将里面两个特征转为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://link.zhihu.com/?target=https%3A//cloud.tencent.com/developer/article/1868117)
Entire Space Multi-Task Model: An Effective Approach for Estimating Post-Click Conversion Rate (SIGIR'2018)

View 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是一种较为通用的任务序列依赖关系建模的方法除此之外阿里的DBMTLESSM2等工作都属于这一个范式。 这个范式可能后面会进行整理,本篇文章不过多赘述。
通过上面的描述能大体上对多任务模型方面的几种常用建模范式有了解然后也知道了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)

View File

@@ -0,0 +1,390 @@
# 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具体实现细节查看下文所附的参考资料。
- UWLUncertainty 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://link.zhihu.com/?target=https%3A//www.6aiq.com/article/1624916831286)
美团:[https://mp.weixin.qq.com/s/WBwvfqOTDKCwGgoaGoSs6Q](https://link.zhihu.com/?target=https%3A//mp.weixin.qq.com/s/WBwvfqOTDKCwGgoaGoSs6Q)
多任务loss优化[https://blog.csdn.net/wuzhongqi](https://link.zhihu.com/?target=https%3A//blog.csdn.net/wuzhongqiang/article/details/124258128)