文章参考来源:
- https://github.com/datawhalechina/team-learning-rs/blob/master/DeepRecommendationModel/NFM.md
- https://zhuanlan.zhihu.com/p/92293407
- https://www.jianshu.com/p/1d157d5146ad
1. 引言
在提出说明NFM之前,我们先来回顾一下FM的模型:
y
^
F
M
(
x
)
=
w
0
+
∑
i
=
1
N
w
i
x
i
+
∑
i
=
1
N
∑
j
=
i
+
1
N
v
i
T
v
j
⋅
x
i
x
j
\hat y_{FM}(x) = w_0 + \sum_{i=1}^Nw_ix_i +\sum_{i=1}^N\sum_{j=i+1}^{N}v_i^Tv_j \cdot x_ix_j
y^FM(x)=w0+i=1∑Nwixi+i=1∑Nj=i+1∑NviTvj⋅xixj
由公式可以看出,FM模型虽然考虑了组合特征,但最多只到二阶交叉特征,而且其本质仍是一种线性模型,模型的表征能力终究是有限的。如果想突破这个局限性,就需要从模型本身动手了。
NFM(Neural Factorization Machines)是2017年由新加坡国立大学的何向南教授等人在SIGIR会议上提出的一个模型,传统的FM模型仅局限于线性表达和二阶交互, 无法胜任生活中各种具有复杂结构和规律性的真实数据, 针对FM的这点不足, 作者提出了一种将FM融合进DNN的策略,通过引进了一个特征交叉池化层的结构,使得FM与DNN进行了完美衔接,这样就组合了FM的建模低阶特征交互能力和DNN学习高阶特征交互和非线性的能力。
2. 模型结构与原理
首先看一下NFM的公式:
y
^
N
F
M
(
x
)
=
w
0
+
∑
i
=
1
n
w
i
x
i
+
f
(
x
)
\hat{y}_{N F M}(\mathbf{x})=w_{0}+\sum_{i=1}^{n} w_{i} x_{i}+f(\mathbf{x})
y^NFM(x)=w0+i=1∑nwixi+f(x)
与FM相比,NFM仅仅在第三项发生了变化。FM的第三项为特征的两两组合,而NFM则换成了
f
(
x
)
f(x)
f(x),这是否意味着模型更简单了?仅从公式上来看,确实好像变得简单了一些,那么这个FM究竟是什么呢?前面说到,FM的局限性在于线性表达和仅到二阶的特征交互,而NFM又是由FM和DNN的组合,所以这里的
f
(
x
)
f(x)
f(x)由DNN来充当。理论上神经网络可以拟合任何复杂能力的函数, 当然不是一个简单的DNN, 而是依然底层考虑了交叉,然后高层使用的DNN网络, 这个也就是我们最终的NFM网络:
提到在线性模型里引入DNN,不免就会想到以往的类似模型,如Wide&Deep、DeepFM、DeepCrossing等。这些模型对于二阶交叉特征向量仅进行简单concatenation,然后送入后续DNN部分,将捕获高阶特征交叉信息的任务交给了DNN结构。这种简单拼接的方式,并没有将二阶交叉特征的信息完全表征出来,导致后期DNN的工作量很大,基于此结构学习更高阶交叉信息效率太低。而NFM使用Bi-Interaction Layer(Bi-linear interaction)结构来对二阶交叉信息进行处理,使交叉特征的信息能更好的被DNN结构学习,降低DNN学习更高阶交叉特征信息的难度。减轻DNN的负担,意味着不再需要更深的网络结构,从而模型参数量也减少了,模型训练更便捷。
Input 和Embedding层
Input和以往的很多输入一样,大多是one-hot稀疏向量,首先要通过embedding将其转化为低维,稠密向量。 这两层还是和之前的FM一样,利用FM对输入层特征进行提取,得到对应的交叉系数不为的交叉变量。最终输入特征向量是由输入特征
x
i
x_i
xi值与 embedding vector
v
i
v_i
vi相乘得到,即
V
x
=
{
x
1
v
1
,
…
,
x
n
v
n
}
\mathcal{V}_{x} = \{x_1 \mathbf{v}_1, \ldots, x_n \mathbf{v}_n\}
Vx={x1v1,…,xnvn}
Bi-Interaction Pooling layer
NFM最大的特点便是在Embedding层和神经网络之间加入了Bi-Interacction Pooling layer(特征交叉池化层),通过这个结构,实现了FM与DNN的无缝连接, 组成了一个大的网络,且能够正常的反向传播。假设
V
x
\mathcal{V}_{x}
Vx是所有特征embedding的集合, 那么在特征交叉池化层的操作:
f B I ( V x ) = ∑ i = 1 n ∑ j = i + 1 n x i v i ⊙ x j v j 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} fBI(Vx)=∑i=1nj=i+1∑nxivi⊙xjvj
⊙ \odot ⊙表示两个向量的元素积操作,即两个向量对应维度相乘得到的元素积向量,例如其中第 k k k维的操作: ( v i ⊙ v j ) k = v i k v j k \left(v_{i} \odot v_{j}\right){k}=\boldsymbol{v}_{i k} \boldsymbol{v}_{j k} (vi⊙vj)k=vikvjk
这便定义了在embedding空间特征的二阶交互,这个不仔细看会和感觉FM的最后一项很像,但是不一样,一定要注意这个地方不是两个隐向量的内积,而是元素积,也就是这一个交叉完了之后k个维度不求和,最后会得到一个k维向量,而FM最后得到一个数, 在进行两两Embedding元素积之后,对交叉特征向量取和, 得到该层的输出向量, 很显然, 输出是一个 k k k维的向量。
FM模型到这个位置就结束了,剩下的就是输出。而NFM在加入特征池化层之后, 把二阶交互的信息合并, 再接上一个DNN网络, 这样就能够增强FM的表达能力了。这里的DNN可以进行多阶且非线性,只要FM把二阶的学习好了,DNN这块学习来会更加容易,这就很好的解决了开始提出的FM的两个问题:①线性②特征交叉只到二阶
Bi-Interaction层不需要额外的模型学习参数,更重要的是它在一个线性的时间内完成计算,和FM一致的,即时间复杂度为 O ( k N x ) O\left(k N_{x}\right) O(kNx), N x N_x Nx为embedding向量的数量。参考FM,可以将上式转化为: f B I ( V x ) = 1 2 [ ( ∑ i = 1 n x i v i ) 2 − ∑ i = 1 n ( x i v i ) 2 ] 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] fBI(Vx)=21[(∑i=1nxivi)2−∑i=1n(xivi)2] 后面代码复现NFM就是用的这个公式直接计算,比较简便且清晰。
Hidden layers(隐藏层)
这一层就是全连接的神经网络, DNN在进行特征的高层非线性交互上有着天然的学习优势,公式如下:
z
1
=
σ
1
(
W
1
f
B
I
(
V
x
)
+
b
1
)
z
2
=
σ
2
(
W
2
z
1
+
b
2
)
…
…
z
L
=
σ
L
(
W
L
z
L
−
1
+
b
L
)
\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}
z1=σ1(W1fBI(Vx)+b1) z2=σ2(W2z1+b2) …… zL=σL(WLzL−1+bL) 这里的
σ
i
\sigma_i
σi是第
i
i
i层的激活函数,可不要理解成sigmoid激活函数。
预测层
这个就是最后一层的结果直接过一个隐藏层,但注意由于这里是回归问题,没有加sigmoid激活:
f
(
x
)
=
h
T
z
L
f(\mathbf{x})=\mathbf{h}^{T} \mathbf{z}_{L}
f(x)=hTzL
NFM模型的前向传播过程总结如下:
y
^
N
F
M
(
X
)
=
w
0
+
∑
i
=
1
n
w
i
x
i
+
f
(
X
)
=
w
0
+
∑
i
=
1
n
w
i
x
i
+
h
T
σ
L
(
W
L
(
…
σ
1
(
W
1
f
B
I
(
V
x
)
+
b
1
)
…
)
+
b
L
)
\begin{aligned} \hat{y}_{NFM}(\mathbf{X}) =w_0+\sum_{i=1}^{n}w_ix_i+f(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}
y^NFM(X)=w0+i=1∑nwixi+f(X)=w0+i=1∑nwixi+hTσL(WL(…σ1(W1fBI(Vx)+b1)…)+bL)
NFM相比较于其他模型的核心创新点是特征交叉池化层,基于它,实现了FM和DNN的无缝连接,使得DNN可以在底层就学习到包含更多信息的组合特征,这时候,就会减少DNN的很多负担,只需要很少的隐藏层就可以学习到高阶特征信息。NFM相比之前的DNN, 模型结构更浅,更简单,但是性能更好,训练和调参更容易。集合FM二阶交叉线性和DNN高阶交叉非线性的优势,非常适合处理稀疏数据的场景任务。
3. 代码实现
由NFM的公式可知,NFM的输入由两部分组成,分别为线性部分 ∑ i = 1 n w i x i \sum_{i=1}^nw_ix_i ∑i=1nwixi和非线性部分 f ( x ) f(x) f(x),而在NFM中,非线性部分是用DNN实现的,所以在代码部分的输入对应为linear_feature_colums和dnn_feature_colums
linear part: 这部分是有关于线性计算,也就是FM的前半部分 w 1 x 1 + w 2 x 2... w n x n + b w1x1+w2x2...wnxn+b w1x1+w2x2...wnxn+b的计算。对于这一块的计算,我们用了一个get_linear_logits函数实现,通过这个函数,我们就可以实现上面这个公式的计算过程,得到linear的输出。
dnn part: 这部分是后面交叉特征的那部分计算,FM的最后那部分公式f(x)。 这一块主要是针对离散的特征,首先过embedding, 然后过特征交叉池化层,这个计算我们用了get_bi_interaction_pooling_output函数实现, 得到输出之后又过了DNN网络,最后得到dnn的输出。
模型的最后输出结果,就是把这两个部分的输出结果加和(当然也可以加权),再过一个sigmoid得到。
def NFM(linear_feature_columns, dnn_feature_columns):
"""
: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的输入部分
# 可以发现DNN部分的输入除了dnn_feature_columns以外,还有sparse_input部分
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部分的结果相加
output_logits = Add()([linear_logits, dnn_logits])
#最后再过个sigmoid
output_layers = Activation("sigmoid")(output_logits)
#保存模型
model = Model(inputs=input_layers, outputs=output_layers)
return model