从五月份的xdeepFM到现在,已经有一个多月的时间没有与推荐模型相关的paper了,说好的"小步快跑"呢? 来到公司实习之后, 更一直抽不出时间来更新这个系列。不过,最近这段时间, 由于开始接触实际场景下的推荐小项目, 而实习生的话一般要从模型上手,所以这一周终于又有了时间开始玩推荐模型,并进行相应的魔改。 当然,来实习之后, 从前辈们的以及周围伙伴的交流中悟出了一个真理,在推荐系统甚至其他领域(CV,NLP)等,模型都只是工具而已,遇到问题解决问题的方法和策略才是王道, 那么我为啥还要坚持通过写博客的方式记录读过的模型论文,整理模型原理呢? 这个问题之前也没有仔细想过,之前整理模型的原因,是为了梳理推荐系统模型发展脉络, 方便入门推荐系统。 可能有人会说, 新模型paper每年那么多, 怎么可能一个个的读然后尝试? 感觉意义不大呀。emmm, 首先, 我现在的想法是肯定不会把每个模型都追一遍整理, 其次,就是通过我前面的一些模型积累发现, 很多模型的思路和原理给我带来的价值比模型本身要大很多(不妨跳去局部,从全局去把握模型的发展脉络和思路),并且很多东西,都是万变不离其宗, 各种组件之间的重复调用, 对于现在的我来说,整理模型并不是我最终的目的, 通过阅读paper,学习大佬们针对实际场景中遇到个问题,提出的解决方案才是我最想学的东西。 并且,阅读完了一篇paper之后,如果不整理下的话,心理会很不踏实,没法检验自己的掌握程度。
所以我后面整理模型,也不是乱整理, 一般是较为干净清爽的工业文章,读起来利落实在,还能解决自己的问题。 基于两个原则:
所以整理模型,一是爱好所在,另一个是也能把知识进行融会贯通下,检验自己的掌握水平, 这一块还得继续走着, 下面开始
今天整理的是2019年发表在RecSys(ACM组织的在推荐系统领域的专会)的一个模型叫做FiBiNET(Feature Importance and Bilinear feature Interaction),来自新浪微博张俊林老师的团队。 这个模型如果从模型演化的角度来看, 主要是在特征重要性以及特征之间交互上做出了探索, 记得在DIEN那篇里面我基于自己的感觉对CTR模型的发展做了一个总结梳理:
这样,这个模型的重点就get到啦。 所以这篇文章不像xDeepFM那个CIN那样复杂, 有点小清新的感觉,并且思路也非常的nice,之所以要先整理这篇文章,是因为我这边面临的一个场景是已经有了几个embedding向量特征, 我需要探索下这几个embedding向量的交互方式, 而FiBiNet里面正好先有个embedding的重要性选择, 又有embedding之间的双线性交互, 和我的需求非常契合。所以下面就一块来看看吧,依然是先整理与模型的相关细节,然后通过代码简单的复现下,看看里面两个重要的结构。
大纲如下:
Ok, let’s go!
FiBiNet的提出动机是因为在特征交互这一方面, 目前的ctr模型要么是简单的两两embedding内积(这里针对离散特征), 比如FM,FFM。 或者是两两embedding进行哈达玛积(NFM这种), 作者认为这两种交互方式还是过于简单, 另外像NFM这种,FM这种,也忽视了特征之间的重要性程度。
对于特征重要性,作者在论文中举得例子非常形象
the feature occupation is more important than the feature hobby when we predict a person’s income
所以要想让模型学习到更多的信息, 从作者的角度来看,首先是离散特征之间的交互必不可少,且需要更细粒度。第二个就是需要考虑不同特征对于预测目标的重要性程度,给不同的特征根据重要性程度进行加权。 写到这里, 如果看过之前的文章的话,这个是不是和某些模型有些像呀, 没错,AFM其实考虑了这一点, 不过那里是用了一个Attention网络对特征进行的加权, 这里采用了另一种思路而已,即SENET, 所以这里我们如果是考虑特征重要性程度的话, 就有了两种思路:
而考虑特征交互的话, 思路应该会更多:
所以,读论文, 这些思路感觉要比模型本身重要,而读论文还有一个有意思的事情,那就是我们既能了解思路,也能想一下,为啥这些方法会有效果呢? 我们自己能不能提出新的方法来呢? 如果读一篇paper,再顺便把后面的这些问题想通了, 那么这篇paper对于我们来说就发挥效用了, 后面就可以用拉马努金式方法训练自己的思维。
在前面的准备工作中,作者依然是带着我们梳理了整个推荐模型的演化过程, 我们也简单梳理下,就当回忆:
综上, 这几个网络里面最常用的还是属DeepFM了, 当然对于交互来讲,在我的任务上试过AFM和xDeepFM, 结果是AFM和DeepFM差不多持平, 而xDeepFM要比这俩好一些,但并不多,而考虑完了复杂性, 还是DeepFM或者AFM。
对于上面模型的问题,作者说了两点,第一个是大部分模型没有考虑特征重要性,也就是交互完事之后,没考虑对于预测目标来讲谁更重要,一视同仁。 第二个是目前的两两特征交互,大部分依然是内积或者哈达玛积, 作者认为还不是细粒度(fine-grained way)交互。
那么,作者是怎么针对这两个问题进行改进的呢? 为什么这么改进呢?
这里我们直接分析模型架构即可, 因为这个模型不是很复杂,也非常好理解前向传播的过程:
从模型架构上来看,如果把我框出来的两部分去掉, 这个基本上就退化成了最简单的推荐深度模型DeepCrossing,甚至还比不上那个(那个还用了残差网络)。不过,加上了两个框,效果可就不一样了。所以下面重点是剖析下这两个框的结构,其他的简单一过即可。
梳理细节之前, 先说下前向传播的过程。
首先,我们输入的特征有离散和连续,对于连续的特征,输入完了之后,先不用管,等待后面拼起来进DNN即可,这里也没有刻意处理连续特征。
对于离散特征,过embedding转成低维稠密,一般模型的话,这样完了之后,就去考虑embedding之间交互了。 而这个模型不是, 在得到离散特征的embedding之后,分成了两路
- 一路保持原样, 继续往后做两两之间embedding交互,不过这里的交互方式,不是简单的内积或者哈达玛积,而是采用了非线性函数,这个后面会提到。
- 另一路,过一个SENET Layer, 过完了之后得到的输出是和原来embedding有着相同维度的,这个SENET的理解方式和Attention网络差不多,也是根据embedding的重要性不同出来个权重乘到了上面。 这样得到了SENET-like Embedding,就是加权之后的embedding。 这时候再往上两两双线性交互。
两路embedding都两两交互完事, Flatten展平,和连续特征拼在一块过DNN输出。
所以,一梳理这个过程,就不是很复杂了吧,是不是感觉这一下子就能直接写代码啦哈哈。 不过代码这个东西是次要的,关键是这里面的SENET里面是怎么得到权重的? 为啥这个东西会有效? 双线性是如何做的? 这些才是我们需要重点关注的。
这个不多讲, 整理这个是为了后面统一符号。
假设我们有 f f f个离散特征,经过embedding层之后,会得到 E = [ e 1 , e 2 , ⋯ , e i , ⋯ , e f ] E=\left[e_{1}, e_{2}, \cdots, e_{i}, \cdots, e_{f}\right] E=[e1,e2,⋯,ei,⋯,ef], 其中 e i ∈ R k e_{i} \in R^{k} ei∈Rk,表示第 i i i个离散特征对应的embedding向量, k k k维。
这是第一个重点,首先这个网络接收的输入是上面的 E = [ e 1 , e 2 , ⋯ , e i , ⋯ , e f ] E=\left[e_{1}, e_{2}, \cdots, e_{i}, \cdots, e_{f}\right] E=[e1,e2,⋯,ei,⋯,ef], 网络的输出也是个同样大小的张量(None, f, k)
矩阵。 结构如下:
SENet由自动驾驶公司Momenta在2017年提出,在当时,是一种应用于图像处理的新型网络结构。它基于CNN结构,通过对特征通道间的相关性进行建模,对重要特征进行强化来提升模型准确率,本质上就是针对CNN中间层卷积核特征的Attention操作。ENet仍然是效果最好的图像处理网络结构之一。
SENet能否用到推荐系统?— 张俊林老师的知乎(链接在文末)
推荐领域里面的特征有个特点,就是海量稀疏,意思是大量长尾特征是低频的,而这些低频特征,去学一个靠谱的Embedding是基本没希望的,但是你又不能把低频的特征全抛掉,因为有一些又是有效的。既然这样,如果我们把SENet用在特征Embedding上,类似于做了个对特征的Attention,弱化那些不靠谱低频特征Embedding的负面影响,强化靠谱低频特征以及重要中高频特征的作用,从道理上是讲得通的
所以拿来用了再说, 把SENet放在Embedding层之上,通过SENet网络,动态地学习这些特征的重要性。对于每个特征学会一个特征权重,然后再把学习到的权重乘到对应特征的Embedding里,这样就可以动态学习特征权重,通过小权重抑制噪音或者无效低频特征,通过大权重放大重要特征影响的目的。在推荐系统里面, 结构长这个样子:
下面看下这个网络里面的具体计算过程, SENET主要分为三个步骤Squeeze, Excitation, Re-weight。
在Squeeze阶段,我们对每个特征的Embedding向量进行数据压缩与信息汇总,如下:
z i = F s q ( e i ) = 1 k ∑ t = 1 k e i ( t ) z_{i}=F_{s q}\left(e_{i}\right)=\frac{1}{k} \sum_{t=1}^{k} e_{i}^{(t)} zi=Fsq(ei)=k1t=1∑kei(t)
假设某个特征 v i v_i vi是 k k k维大小的 E m b e d d i n g Embedding Embedding,那么我们对 E m b e d d i n g Embedding Embedding里包含的 k k k维数字求均值,得到能够代表这个特征汇总信息的数值 z i z_i zi,也就是说,把第 i i i个特征的 E m b e d d i n g Embedding Embedding里的信息压缩到一个数值。原始版本的SENet,在这一步是对CNN的二维卷积核进行 M a x Max Max操作的,这里等于对某个特征Embedding元素求均值。我们试过,在推荐领域均值效果比 M a x Max Max效果好,这也很好理解,因为图像领域对卷积核元素求 M a x Max Max,等于找到最强的那个特征,而推荐领域的特征 E m b e d d i n g Embedding Embedding,每一位的数字都是有意义的,所以求均值能更好地保留和融合信息。通过Squeeze阶段,对于每个特征 v i v_i vi ,都压缩成了单个数值 z i z_i zi,假设特征Embedding层有 f f f个特征,就形成Squeeze向量 Z Z Z,向量大小 f f f。
Excitation阶段,这个阶段引入了中间层比较窄的两层MLP网络,作用在Squeeze阶段的输出向量 Z Z Z上,如下:
A = F e x ( Z ) = σ 2 ( W 2 σ 1 ( W 1 Z ) ) A=F_{e x}(Z)=\sigma_{2}\left(W_{2} \sigma_{1}\left(W_{1} Z\right)\right) A=Fex(Z)=σ2(W2σ1(W1Z))
σ \sigma σ非线性激活函数,一般 r e l u relu relu。本质上,这是在做特征的交叉,也就是说,每个特征以一个 B i t Bit Bit来表征,通过MLP来进行交互,通过交互,得出这么个结果:对于当前所有输入的特征,通过相互发生关联,来动态地判断哪些特征重要,哪些特征不重要。
其中,第一个MLP的作用是做特征交叉,第二个MLP的作用是为了保持输出的大小维度。因为假设Embedding层有 f f f个特征,那么我们需要保证输出 f f f个权重值,而第二个MLP就是起到将大小映射到 f f f个数值大小的作用。
这样,经过两层MLP映射,就会产生 f f f个权重数值,第 i i i个数值对应第 i i i个特征Embedding的权重 a i a_i ai 。
这个东西有没有感觉和自动编码器很像,虽然不是一样的作用, 但网络结构是一样的。这就是知识串联的功效哈哈。
瞬间是不是就把SENet这里的网络结构记住了哈哈。下面再分析下维度, SENet的输入是 E E E,这个是(None, f, k)
的维度, 通过Squeeze阶段,得到了(None, f)
的矩阵,这个也就相当于Layer L1的输入(当然这里没有下面的偏置哈),接下来过MLP1, 这里的 W 1 ∈ R f × f r , W 2 ∈ R f r × f W_{1} \in R^{f \times \frac{f}{r}}, W_{2} \in R^{\frac{f}{r} \times f} W1∈Rf×rf,W2∈Rrf×f, 这里的 r r r叫做reduction
ratio, f r \frac{f}{r} rf这个就是中间层神经元的个数, r r r表示了压缩的程度。
Re-Weight
我们把Excitation阶段得到的每个特征对应的权重 a i a_i ai,再乘回到特征对应的Embedding里,就完成了对特征重要性的加权操作。
V = F ReWeight ( A , E ) = [ a 1 ⋅ e 1 , ⋯ , a f ⋅ e f ] = [ v 1 , ⋯ , v f ] 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] V=FReWeight (A,E)=[a1⋅e1,⋯,af⋅ef]=[v1,⋯,vf]
a i ∈ R , e i ∈ R k a_{i} \in R, e_{i} \in R^{k} ai∈R,ei∈Rk, and v i ∈ R k v_{i} \in R^{k} vi∈Rk。 a i a_i ai数值大,说明SENet判断这个特征在当前输入组合里比较重要, a i a_i ai数值小,说明SENet判断这个特征在当前输入组合里没啥用。如果非线性函数用Relu,会发现大量特征的权重会被Relu搞成0,也就是说,其实很多特征是没啥用的。
这样,就可以将SENet引入推荐系统,用来对特征重要性进行动态判断。注意,所谓动态,指的是比如对于某个特征,在某个输入组合里可能是没用的,但是换一个输入组合,很可能是重要特征。它重要不重要,不是静态的,而是要根据当前输入,动态变化的。
这里正确的理解,算是一种特征重要性选择的思路, SENET和AFM的Attention网络是起着同样功效的一个网络。只不过那个是在特征交互之后进行特征交互重要性的选择,而这里是从embedding这里先压缩,再交互,再选择,去掉不太重要的特征。 考虑特征重要性上的两种考虑思路,难以说孰好孰坏,具体看应用场景。 不过如果分析下这个东西为啥会有效果, 就像张俊林老师提到的那样, 在Excitation阶段, 各个特征过了一个MLP进行了特征组合, 这样就真有可能过滤掉对于当前的交互不太重要的特征。 至于是不是, 那神经网络这东西就玄学了,让网络自己去学吧。
特征重要性选择完事, 接下来就是研究特征交互, 这里作者直接就列出了目前的两种常用交互以及双线性交互:
这个图其实非常了然了。以往模型用的交互, 内积的方式(FM,FFM)这种或者哈达玛积的方式(NFM,AFM)这种。
所谓的双线性,其实就是组合了内积和哈达玛积的操作,看上面的右图。就是在 v i v_i vi和 v j v_j vj之间先加一个 W W W矩阵, 这个 W W W矩阵的维度是 ( f , f ) (f,f) (f,f), v i , v j v_i, v_j vi,vj是 ( 1 , f ) (1,f) (1,f)的向量。 先让 v i v_i vi与 W W W内积,得到 ( 1 , f ) (1,f) (1,f)的向量,这时候先仔细体会下这个新向量的每个元素,相当于是原来向量 v i v_i vi在每个维度上的线性组合了。这时候再与 v j v_j vj进行哈达玛积得到结果。
这里我不由自主的考虑了下双线性的功效,也就是为啥作者会说双线性是细粒度,下面是我自己的看法哈。
当然思路是思路,双线性并不一定见得一定比哈达玛积有效, SENET也不一定就会比原始embedding要好,一定要辩证看问题
这里还有个厉害的地方在于这里的W有三种选择方式,也就是三种类型的双线性交互方式。
[0,1,2]
, 两两组合[0,1], [0,2], [1,2]
。 这里用到的域是0和1。不知道看到这里,这种操作有没有种似曾相识的感觉, 有没有想起FM和FFM, 反正我是不自觉的想起了哈哈,不知道为啥。总感觉FM的风格和上面的Field-All很像, 而FFM和下面的Field-Interaction很像。
我们的原始embedding和SKNET-like embedding都需要过这个层,那么得到的就是一个双线性两两组合的矩阵, 维度是 ( f ( f − 1 ) 2 , k ) (\frac{f(f-1)}{2}, k) (2f(f−1),k)的矩阵。
这个层的作用就是把目前得到的特征拼起来
c = F concat ( p , q ) = [ p 1 , ⋯ , p n , q 1 , ⋯ , q n ] = [ c 1 , ⋯ , c 2 n ] 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] c=Fconcat (p,q)=[p1,⋯,pn,q1,⋯,qn]=[c1,⋯,c2n]
这里他直拼了上面得到的两个离散特征通过各种交互之后的形式,如果是还有连续特征的话,也可以在这里拼起来,然后过DNN,不过这里其实还省略了一步操作就是Flatten,先展平再拼接。
这里就不多说了, DNN的话普通的全连接网络, 再捕捉一波高阶的隐性交互。
a ( l ) = σ ( W ( l ) a ( l − 1 ) + b ( l ) ) a^{(l)}=\sigma\left(W^{(l)} a^{(l-1)}+b^{(l)}\right) a(l)=σ(W(l)a(l−1)+b(l))
而输出层
y ^ = σ ( w 0 + ∑ i = 0 m w i x i + y d ) \hat{y}=\sigma\left(w_{0}+\sum_{i=0}^{m} w_{i} x_{i}+y_{d}\right) y^=σ(w0+i=0∑mwixi+yd)
分类问题损失函数:
loss = − 1 N ∑ i = 1 N ( y i log ( y ^ i ) + ( 1 − y i ) ∗ log ( 1 − y ^ i ) ) \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) loss=−N1i=1∑N(yilog(y^i)+(1−yi)∗log(1−y^i))
这里就不解释了。
实验部分,这里作者也是做了大量的实验来证明提出的模型比其他模型要好,这个就不说了。
竟然比xDeepFM都要好。
在模型评估指标上,用了AUC和Logloss,这个也是常用的指标,Logloss就是交叉熵损失, 反映了样本的平均偏差,经常作为模型的损失函数来做优化,可是,当训练数据正负样本不平衡时,比如我们经常会遇到正样本很少,负样本很多的情况,此时LogLoss会倾向于偏向负样本一方。 而AUC评估不会受很大影响,具体和AUC的计算原理有关。这个在这里就不解释了。
其次了解到的一个事情:
接下来,得整理下双线性与哈达玛积的组合类型,因为我们这个地方其实有两路embedding的, 一路是原始embedding, 一路是SKNet侧的embedding。而面临的组合方式,有双线性和哈达玛积两种。那么怎么组合会比较好呢? 作者做了实验。结论是,作者建议:
深度学习模型中,原始那边依然哈达玛,SE那边双线性, 可能更有效, 不过后面的代码实现里面,都用了双线性。
而具体,在双线性里面,那种W的原则有效呢? 这个视具体的数据集而定。
超参数选择,主要是embedding维度以及DNN层数, embedding维度这个10-50, 不同的数据集可能表现不一样, 但尽量不要超过50了。否则在DNN之前的特征维度会很大。
DNN层数,作者这里建议3层,而每一层神经单元个数,也是没有定数了。
这里竟然没有说 r r r的确定范围。 Deepctr里面默认是3。
对于实际应用的一些经验:
SE-FM 在实验数据效果略高于 FFM,优于FM,对于模型处于低阶的团队,升级FM、SE-FM成本比较低
deepSE-FM 效果优于DCN、XDeepFM 这类模型,相当于XDeepFM这种难上线的模型来说,很值得尝试,不过大概率怀疑是增加特征交叉的效果,特征改进比模型改进work起来更稳
实验中增加embeding 长度费力不讨好,效果增加不明显,如果只是增加长度不改变玩法边际效应递减,不增加长度增加emmbedding 交叉方式类似模型的ensemble更容易有效果
这里的话,参考deepctr修改的简化版本。
对于输入,就不详细的说了,在xDeepFM那里已经解释了, 首先网络的整体全貌:
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层,其他的和之前网络模块基本上一样。
这里的输入是[None, field_num embed_dim]
的维度,也就是离散特征的embedding, 拿到这个输入之后,三个步骤,得到的是一个[None, feild_num, embed_dim]
的同样维度的矩阵,只不过这里是SKNET-like embedding了。
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
三个步骤还是比较好理解的, 这里这种自定义层权重的方式需要学习下。
这里接收的输入同样是[None, field_num embed_dim]
的维度离散特征的embedding。 输出是来两两交互完毕的矩阵 [ N o n e , f ( f − 1 ) 2 , e m b e d _ d i m ] [None, \frac{f(f-1)}{2}, embed\_dim] [None,2f(f−1),embed_dim]
这里的双线性交互有三种形式,具体实现的话可以参考下面的代码,我加了注释, 后面两种用到了组合的方式, 感觉人家这种实现方式还是非常巧妙的。
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问给出相应的答案吗? 欢迎在下方留言呀。
还有一种读论文的厉害姿势,和张俊林老师学的,就是拉马努金式思维,就是读论文之前,看完题目之后, 不要看正文,先猜测作者在尝试解决什么样的问题,比如
看到特征重要性和双线性特征交互, 就大体上能猜测到这篇推荐论文讲的应该是和特征选择和特征交互相关的知识。 那么如果是我解决这两方面的话应该怎么解决呢?
这时候, 就可以读论文了,读完之后, 对比下人家提出的想法和自己的想法的区别,考虑下为啥会有这样的区别? 然后再就是上面的灵魂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要好些了。
参考:
整理这篇文章的同时, 也建立了一个GitHub项目, 准备后面把各种主流的推荐模型用复现一遍,并用通俗易懂的语言进行注释和逻辑整理, 今天的FiBiNet已经上传, 从DIEN开始,后面的模型复现代码不仅局限于pytorch了,而是主要参考deepctr的代码风格,写简化版的并进行解释了。前面的模型也会陆续补充上deepctr风格的代码, 感兴趣的可以看一下 ,star下我会更开心哈哈
筋斗云:https://github.com/zhongqiangwu960812/AI-RecommenderSystem
另外一个就是我们的fun-rec项目也渐渐成型啦,这是在学习推荐的路上和一群志同道合的伙伴一块沉淀下来的知识,专注于推荐系统知识的整理,分推荐系统基础,推荐系统进阶和推荐系统实战三个部分,目前也在慢慢的更新中,希望能帮助更多的小伙伴,同样也期待更多伙伴的加入,一块搞搞事情呀 。 具体地址: https://github.com/datawhalechina/fun-rec