This commit is contained in:
2024-08-10 19:46:55 +08:00
commit 2233526534
798 changed files with 35282 additions and 0 deletions

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://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)