xDeepFM 网络介绍与源码浅析
前言 (与主题无关, 可以忽略)
哈哈哈, 十月第一篇博客, 希望这个季度能更奋进一些~~~ 不想当咸鱼了… 🤣🤣🤣
广而告之
可以在微信中搜索 “珍妮的算法之路” 或者 “world4458” 关注我的微信公众号;另外可以看看知乎专栏 PoorMemory-机器学习, 以后文章也会发在知乎专栏中;
xDeepFM
文章信息
- 论文标题: xDeepFM: Combining Explicit and Implicit Feature Interactions for Recommender Systems
- 论文地址: https://arxiv.org/abs/1803.05170
- 代码地址: https://github.com/Leavingseason/xDeepFM, 此外, DeepCTR 也进行了实现: https://github.com/shenweichen/DeepCTR/blob/master/deepctr/models/xdeepfm.py
- 发表时间: 2018
- 论文作者: Jianxun Lian, Xiaohuan Zhou, Fuzheng Zhang, Zhongxia Chen, Xing Xie, Guangzhong Sun
- 作者单位: University of Science and Technology of China
核心观点
- xDeepFM (eXtreme Deep Factorization Machine) 的目的是为了处理特征交叉的问题, 可以视为是对 DCN 进行改进. 文章对 DCN 的特征交叉公式进行了观察和推导, 发现 Cross 在做特征交叉其形式受限于一个特殊的形式, 其交叉特征表示为一个 scalar α \alpha α 和输入特征 x 0 \bm{x}_0 x0 的乘积. 当然, 这里的 α \alpha α 是有 x 0 \bm{x}_0 x0 有关的, 此外注意到 Cross 层做特征交叉是在 bit-wise level 上进行的;
- xDeepFM 的改进是: 首先特征交叉是在 vector-wise 的层级上做的, 并且和 DCN 的 Cross 层一样, 本文介绍的 CIN (Compressed Interaction Network) 层对交叉特征进行显式地学习,这样就可以知晓特征的阶数;
- CIN 进行特征交叉的具体过程是: 对于第 k k k 层 CIN, 其使用第 k − 1 k - 1 k−1 层的 H k − 1 H_{k-1} Hk−1 个特征和原始的输入 X 0 X^0 X0 中的 m m m 个特征进行特征交叉, 生成 m × H k − 1 m\times H_{k-1} m×Hk−1 个交叉特征, 然后使用对这些特征进行加权求和 (每个交叉特征对应一个权重参数, 因此权重参数的个数为 m × H k − 1 m\times H_{k-1} m×Hk−1), 这样就得到了一个输出的交叉特征; 由于第 k k k 层使用 H k H_k Hk 个权重矩阵, 那么最后就可以得到 H k H_k Hk 个输出的交叉特征;
- xDeepFM 的输入到输出层的结果包含三部分, 分别对应着线性层, CIN 层以及 Deep 层的输出结果,其中线性层包含着低阶特征, CIN 层包含显式学习的高阶特征, 而 Deep 层包含隐式学习的高阶特征.
核心观点介绍
首先看 xDeepFM 的网络结构, 如下图所示:
可以看到, 其结构类似于 Wide & Deep 与 DCN 结构, 不过其主要包含三个部分, 分别为线性层, CIN 层以及 Deep 层, 设 a \bm{a} a 为 raw features, x d n n k \bm{x}_{dnn}^k xdnnk 为 DNN 的输出结果, p + \bm{p}^{+} p+ 为 CIN 层的输出结果, 那么 xDeepFM 的输出为:
y ^ = σ ( w linear T a + w d n n T x d n n k + w c i n T p + + b ) \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}_{c i n}^{T} \mathbf{p}^{+}+b\right) y^=σ(wlinearTa+wdnnTxdnnk+wcinTp++b)
可以认为 xDeepFM 是对 DCN 的改进, 其介绍了 CIN 层来替换 DCN 中的 Cross 层, 用于显式学习交叉特征.
高阶特征交叉 (High-order Interactions)
下面在介绍 CIN 层的原理之前, 先说明一下 Cross 层存在的问题, 这个是 xDeepFM 这篇 paper 讨论的一个重点. 为了方便讨论, 先引入一些必要的符号. 设原始的高维稀疏特征经 Embedding Layer 处理后, 映射为低维的稠密向量:
e = [ e 1 , e 2 , … , e m ] \mathbf{e}=\left[\mathbf{e}_{1}, \mathbf{e}_{2}, \ldots, \mathbf{e}_{m}\right] e=[e1,e2,…,em]
其中 m m m 表示 Field 的个数, e i ∈ R D \mathbf{e}_{i}\in\mathbb{R}^D ei∈RD 表示第 i i i 个 Field 所对应的 embedding. 因此对于一个样本来说, 它对应的 embedding 大小为 m × D m\times D m×D.
在讨论 DCN 中的 Cross 层之前, 先介绍两个概念:
- 隐式高阶特征交叉 (Implicit High-order Interactions): DNN 学习特征交叉的方式是隐式的, 因为DNN 最后表示的函数是任意的, 而且目前没有理论论证了 DNN 能学习的特征交叉的最大阶数.
(此处再插入对bit-wise
和vector-wise
的讨论: 此外, DNN 学习特征交叉是在 bit-wise level, 而 FM 学习特征交叉是在 vector-wise level. 所谓bit
, 在文章的 Introduction 这一节中有具体的描述, 即用来表示一个向量中的某个元素, 比如向量 v = [ a , b , c ] \bm{v} = \left[a, b, c\right] v=[a,b,c] 中, a a a 就可以称为 bit. DNN 对高阶特征交叉建模是以 bit-wise 的形式进行的, 这意味着在同一个 field embedding 中的各个元素之间也会相互影响, 而 vertor-wise 则不会出现这种情况, 其是对整个向量进行处理.) - 显式高阶特征交叉 (Explicit High-order Interactions): 即学习到的交叉特征其阶数是知晓的, 比如 FM 学习到的是二阶特征交叉. DCN 中 Cross 层的目的也是希望显式地对高阶特征交叉进行建模.
DCN 中 Cross 层通过下式对高阶特征交叉进行建模:
x k = x 0 x k − 1 T w k + b k + x k − 1 \mathbf{x}_{k}=\mathbf{x}_{0} \mathbf{x}_{k-1}^{T} \mathbf{w}_{k}+\mathbf{b}_{k}+\mathbf{x}_{k-1} xk=x0xk−1Twk+bk+xk−1
其中 w k , b k , x k ∈ R m D \mathbf{w}_{k}, \mathbf{b}_{k}, \mathbf{x}_{k} \in \mathbb{R}^{m D} wk,bk,xk∈RmD 分别为第 k k k 层的权重, bias 以及输出. 作者认为 Cross 层学习出来的是一类特殊的高阶特征交叉, 其表示为一个 scalar α \alpha α 和输入特征 x 0 \mathbf{x}_{0} x0 的乘积. 当然, 这里的 α \alpha α 是有 x 0 \mathbf{x}_{0} x0 有关的, 此外注意到 Cross 层做特征交叉是在 bit-wise level 上进行的; 具体推导如下:
当 k = 1 k=1 k=1 时, 可以得到:
x 1 = x 0 ( x 0 T w 1 ) + x 0 = x 0 ( x 0 T w 1 + 1 ) = α 1 x 0 \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} x1=x0(x0Tw1)+x0=x0(x0Tw1+1)=α1x0
其中 α 1 = x 0 T w 1 + 1 \alpha^{1} = \mathbf{x}_{0}^{T} \mathbf{w}_{1}+1 α1=x0Tw1+1 为一个 scalar, 因此 x 1 \mathbf{x}_{1} x1 可以表示为一个 scalar 和 x 0 \mathbf{x}_{0} x0 的乘积. 使用数学归纳法, 假设这一点对 k = i k = i k=i 时仍然成立, 那么当 k = i + 1 k = i + 1 k=i+1 时, 有:
x i + 1 = x 0 x i T w i + 1 + x i = x 0 ( ( α i x 0 ) T w i + 1 ) + α i x 0 = α i + 1 x 0 \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} xi+1=x0xiTwi+1+xi=x0((αix0)Twi+1)+αix0=αi+1x0
其中 α i + 1 = α i ( x 0 T w i + 1 + 1 ) \alpha^{i+1}=\alpha^{i}\left(\mathrm{x}_{0}^{T} \mathbf{w}_{i+1}+1\right) αi+1=αi(x0Twi+1+1) 是一个 scalar. 因此 x i + 1 \mathbf{x}_{i+1} xi+1 仍然是一个 scalar 和 x 0 \mathbf{x}_{0} x0 的乘积. 因此可以证明, Cross 层的输出确实满足一种特殊的形式, 即 x k \mathbf{x}_{k} xk 是一个 scalar 和 x 0 \mathbf{x}_{0} x0 的乘积.
Cross 层可以高效的对高阶交叉特征进行显式地学习, 但问题是其结果受限于一个特殊的形式, 并且特征交叉是在 bit-wise level 而不是 vector-wise level 上. (另外注意, 虽然 x k \mathbf{x}_{k} xk 可以表示为一个 scalar 和 x 0 \mathbf{x}_{0} x0 的乘积, 但并不意味着 x k \mathbf{x}_{k} xk 和 x 0 \mathbf{x}_{0} x0 是线性关系, 因为系数 α i \alpha^{i} αi 是跟 x 0 \mathbf{x}_{0} x0 有关的).
基于以上考虑, 本文提出 CIN 模块, 一种新的对特征进行显式交叉的方法, 并且特征交叉是在 vector-wise level 上进行的.
CIN (Compressed Interaction Network)
假设 Embedding Layer 的输出为 X 0 ∈ R m × D \mathbf{X}^0\in\mathbb{R}^{m\times D} X0∈Rm×D, 其中 m m m 表示 field 的个数, D D D 表示每个 field 所对应的 embedding 的大小, 第 i i i 行表示第 i i i 个 field 所对应的 embedding, 即 X i , ∗ 0 = e i \mathbf{X}^0_{i,\ast} = \mathbf{e}_i Xi,∗0=ei. CIN 的第 k k k 层输出也是一个矩阵 X k ∈ R H k × D \mathbf{X}^k\in\mathbb{R}^{H_k\times D} Xk∈RHk×D, 其中 H k H_k Hk 表示第 k k k 层的特征数量, 此外, 我们设置 H 0 = m H_0 = m H0=m. CIN 的计算过程可以用下图表示:
对于 CIN 中的每一层来说, 其输出 X k \mathbf{X}^k Xk 的计算方式如下:
X h , ∗ k = ∑ i = 1 H k − 1 ∑ j = 1 m W i j k , h ( X i , ∗ k − 1 ∘ X j , ∗ 0 ) \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) Xh,∗k=i=1∑Hk−1j=1∑mWijk,h(Xi,∗k−1∘Xj,∗0)
其中
1
≤
h
≤
H
k
1 \leq h \leq H_k
1≤h≤Hk,
W
k
,
h
∈
R
H
k
−
1
×
D
\mathbf{W}^{k, h}\in\mathbb{R}^{H_{k-1}\times D}
Wk,h∈RHk−1×D 表示第
h
h
h 个特征所对应的参数矩阵.
∘
\circ
∘ 表示哈达玛积 (Hadamard product), 比如对于向量
⟨
a
1
,
a
2
,
a
3
⟩
∘
⟨
b
1
,
b
2
,
b
3
⟩
=
⟨
a
1
b
1
,
a
2
b
2
,
a
3
b
3
⟩
\langle a_1, a_2, a_3\rangle\circ\langle b_1, b_2, b_3\rangle = \langle a_1b_1, a_2b_2, a_3b_3\rangle
⟨a1,a2,a3⟩∘⟨b1,b2,b3⟩=⟨a1b1,a2b2,a3b3⟩.
注意到
X
k
\mathbf{X}^{k}
Xk 是通过
X
k
−
1
\mathbf{X}^{k - 1}
Xk−1 和
X
0
\mathbf{X}^{0}
X0 进行交叉得到的, 而且交叉特征的学习是显式的, 其阶数会随着层数的增加而增大. 上式可以用上面图示中的 (a) 和 (b) 来形象地描述.
上图中的 ( c c c) 给出了 CIN 整体的结构, 假设 CIN 总共有 T T T 层, 在得到每层的输出结果 X k \mathbf{X}^k Xk ( k ∈ [ 1 , T ] k\in[1, T] k∈[1,T]) 后, 对结果中的每个 feature map 进行 sum pooling, 即:
p i k = ∑ j = 1 D X i , j k p_{i}^{k}=\sum_{j=1}^{D} \mathrm{X}_{i, j}^{k} pik=j=1∑DXi,jk
其中 i ∈ [ 1 , H k ] i\in[1, H_k] i∈[1,Hk]. 因此我们可以得到经过 pooling 后的 vector: p k = [ p 1 k , p 2 k , … , p H k k ] \mathbf{p}^{k}=\left[p_{1}^{k}, p_{2}^{k}, \ldots, p_{H_{k}}^{k}\right] pk=[p1k,p2k,…,pHkk], 其中 H k H_k Hk 表示第 k k k 层的特征个数. 之后将 CIN 中所有层的 pooling vector 进行 concatenation, 得到:
p + = [ p 1 , p 2 , … , p T ] ∈ R ∑ i = 1 T H i \mathbf{p}^{+}=\left[\mathbf{p}^{1}, \mathbf{p}^{2}, \ldots, \mathbf{p}^{T}\right] \in \mathbb{R}^{\sum_{i=1}^{T} H_{i}} p+=[p1,p2,…,pT]∈R∑i=1THi
即 p + \mathbf{p}^{+} p+ 为 CIN 层的输出结果.
最后再讨论下 CIN 层的参数个数, 对于第 k k k 层来说, 其权重参数个数为 H k × H k − 1 × m H_k\times H_{k - 1}\times m Hk×Hk−1×m, 那么总共有 ∑ k = 1 T H k × H k − 1 × m \sum\limits_{k=1}^{T} H_k\times H_{k - 1}\times m k=1∑THk×Hk−1×m 个参数 (论文中考虑了输出层的 ∑ k = 1 T H k \sum\limits_{k=1}^{T} H_k k=1∑THk 个参数, 所以总共为 ∑ k = 1 T H k × ( 1 + H k − 1 × m ) \sum\limits_{k=1}^{T} H_k\times \left(1 + H_{k - 1}\times m\right) k=1∑THk×(1+Hk−1×m) 个, 咱不考虑😁)
CIN 源码浅析
原作者在 https://github.com/Leavingseason/xDeepFM/blob/master/exdeepfm/src/CIN.py 中实现了 CIN, 由于代码有点多, 不太想看, 这里分析 DeepCTR 对于 CIN 的实现, 了解个大概就行, 要用到的时候再说 🤣 🤣 🤣
DeepCTR 在 https://github.com/shenweichen/DeepCTR/blob/master/deepctr/layers/interaction.py 中实现了 CIN
模块.
详细注释写在了代码中, 其中不太直观的地方有两处, 我写了很简单的测试用例, 可以用于后续的参考:
dot_result_m = tf.matmul(split_tensor0, split_tensor, transpose_b=True)
import tensorflow as tf
B = 2
D = 3
m = 2
H = 2 ## 理解为 H_{k-1}
a = tf.reshape(tf.range(B * D * m, dtype=tf.float32),
(B, m, D))
b = tf.split(a, D * [1], 2)
c = tf.matmul(b, b, transpose_b=True)
with tf.Session() as sess:
print(sess.run(tf.shape(c))) ## shape 为 [D, B, m, H_{k-1}]
curr_out = tf.nn.conv1d(dot_result, filters=self.filters[idx], stride=1, padding='VALID')
import tensorflow as tf
B = 2
D = 3
E = 4 ## 代表 m * H_{k-1}
F = 5 ## 代表 H_{k}
a = tf.reshape(tf.range(B * D * E, dtype=tf.float32),
(B, D, E))
b = tf.reshape(tf.range(1 * E * F, dtype=tf.float32),
(1, E, F))
curr_out = tf.nn.conv1d(
a, filters=b, stride=1, padding='VALID')
with tf.Session() as sess:
print(sess.run(tf.shape(curr_out))) ## 结果为 [B, D, H_{k}]
CIN 模块的代码如下:
class CIN(Layer):
"""Compressed Interaction Network used in xDeepFM.This implemention is
adapted from code that the author of the paper published on https://github.com/Leavingseason/xDeepFM.
Input shape
- 3D tensor with shape: ``(batch_size,field_size,embedding_size)``.
Output shape
- 2D tensor with shape: ``(batch_size, featuremap_num)`` ``featuremap_num = sum(self.layer_size[:-1]) // 2 + self.layer_size[-1]`` if ``split_half=True``,else ``sum(layer_size)`` .
Arguments
- **layer_size** : list of int.Feature maps in each layer.
- **activation** : activation function used on feature maps.
- **split_half** : bool.if set to False, half of the feature maps in each hidden will connect to output unit.
- **seed** : A Python integer to use as random seed.
References
- [Lian J, Zhou X, Zhang F, et al. xDeepFM: Combining Explicit and Implicit Feature Interactions for Recommender Systems[J]. arXiv preprint arXiv:1803.05170, 2018.] (https://arxiv.org/pdf/1803.05170.pdf)
"""
def __init__(self, layer_size=(128, 128), activation='relu', split_half=True, l2_reg=1e-5, seed=1024, **kwargs):
if len(layer_size) == 0:
raise ValueError(
"layer_size must be a list(tuple) of length greater than 1")
self.layer_size = layer_size
self.split_half = split_half
self.activation = activation
self.l2_reg = l2_reg
self.seed = seed
super(CIN, self).__init__(**kwargs)
def build(self, input_shape):
if len(input_shape) != 3:
raise ValueError(
"Unexpected inputs dimensions %d, expect to be 3 dimensions" % (len(input_shape)))
self.field_nums = [int(input_shape[1])]
self.filters = []
self.bias = []
for i, size in enumerate(self.layer_size):
## layer_size 对应着论文中的 H_{k}, 表示 CIN 每层中 feature map 的个数
## self.filters[i] 的 shape 为 [1, m * H_{k-1}, H_{k}]
self.filters.append(
self.add_weight(name='filter' + str(i),
shape=[1, self.field_nums[-1] * self.field_nums[0], size],
dtype=tf.float32, initializer=glorot_uniform(seed=self.seed + i),
regularizer=l2(self.l2_reg)))
## self.bias[i] 的 shape 为 [H_{k}]
self.bias.append(
self.add_weight(name='bias' + str(i),
shape=[size], dtype=tf.float32,
initializer=tf.keras.initializers.Zeros()))
if self.split_half:
if i != len(self.layer_size) - 1 and size % 2 > 0:
raise ValueError(
"layer_size must be even number except for the last layer when split_half=True")
self.field_nums.append(size // 2)
else:
self.field_nums.append(size)
self.activation_layers = [activation_layer(
self.activation) for _ in self.layer_size]
super(CIN, self).build(input_shape) # Be sure to call this somewhere!
def call(self, inputs, **kwargs):
## inputs 的 shape 为 [B, m, D], 其中 m 为 Field 的数量,
## D 为 embedding size, 我注释的符号尽量和论文中的一样
if K.ndim(inputs) != 3:
raise ValueError(
"Unexpected inputs dimensions %d, expect to be 3 dimensions" % (K.ndim(inputs)))
dim = int(inputs.get_shape()[-1]) # D
hidden_nn_layers = [inputs]
final_result = []
## split_tensor0 表示 list: [x1, x2, ..., xD], 其中 xi 的 shape 为 [B, m, 1]
split_tensor0 = tf.split(hidden_nn_layers[0], dim * [1], 2)
for idx, layer_size in enumerate(self.layer_size):
## split_tensor 表示 list: [t1, t2, ..., tH_{k-1}], 即有 H_{k-1} 个向量;
## 其中 ti 的 shape 为 [B, H_{k-1}, 1]
split_tensor = tf.split(hidden_nn_layers[-1], dim * [1], 2)
## dot_result_m 为一个 tensor, 其 shape 为 [D, B, m, H_{k-1}]
dot_result_m = tf.matmul(
split_tensor0, split_tensor, transpose_b=True)
## dot_result_o 的 shape 为 [D, B, m * H_{k-1}]
dot_result_o = tf.reshape(
dot_result_m, shape=[dim, -1, self.field_nums[0] * self.field_nums[idx]])
## dot_result 的 shape 为 [B, D, m * H_{k-1}]
dot_result = tf.transpose(dot_result_o, perm=[1, 0, 2])
## 牛掰啊, 还可以这样写, 精彩!
## self.filters[idx] 的 shape 为 [1, m * H_{k-1}, H_{k}]
## 因此 curr_out 的 shape 为 [B, D, H_{k}]
curr_out = tf.nn.conv1d(
dot_result, filters=self.filters[idx], stride=1, padding='VALID')
## self.bias[idx] 的 shape 为 [H_{k}]
## 因此 curr_out 的 shape 为 [B, D, H_{k}]
curr_out = tf.nn.bias_add(curr_out, self.bias[idx])
## curr_out 的 shape 为 [B, D, H_{k}]
curr_out = self.activation_layers[idx](curr_out)
## curr_out 的 shape 为 [B, H_{k}, D]
curr_out = tf.transpose(curr_out, perm=[0, 2, 1])
if self.split_half:
if idx != len(self.layer_size) - 1:
next_hidden, direct_connect = tf.split(
curr_out, 2 * [layer_size // 2], 1)
else:
direct_connect = curr_out
next_hidden = 0
else:
direct_connect = curr_out
next_hidden = curr_out
final_result.append(direct_connect)
hidden_nn_layers.append(next_hidden)
## 先假设不走 self.split_half 的逻辑, 此时 result 的
## shape 为 [B, sum(H_{k}), D] (k=1 -> T, T 为 CIN 的总层数)
result = tf.concat(final_result, axis=1)
## result 最终的 shape 为 [B, sum(H_{k})]
result = reduce_sum(result, -1, keep_dims=False)
return result
总结
这篇文章有点麻烦, 写博客花的时间有点久啊, 上午开始写, 左磨右磨终于写完了. 必须说一下, 我昨天把手机 B 站 APP 给卸载了, 感觉生活多出了很多时间… 🤣 🤣 🤣