Task04 深度学习推荐系统 NFM打卡

1. 简介

NFM(Neural Factorization Machines)是2017年由新加坡国立大学的何向南教授等人在SIGIR会议上提出的一个模型,传统的FM模型仅局限于线性表达和二阶交互, 无法胜任生活中各种具有复杂结构和规律性的真实数据, 针对FM的这点不足, 作者提出了一种将FM融合进DNN的策略,通过引进了一个特征交叉池化层的结构,使得FM与DNN进行了完美衔接,这样就组合了FM的建模低阶特征交互能力和DNN学习高阶特征交互和非线性的能力,形成了深度学习时代的神经FM模型(NFM)。

\hat{y}_{N F M}(\mathbf{x})=w_{0}+\sum_{i=1}^{n} w_{i} x_{i}+f(\mathbf{x})

我们对比FM, 就会发现变化的是第三项,前两项还是原来的, 因为我们说FM的一个问题,就是只能到二阶交叉, 且是线性模型, 这是他本身的一个局限性, 而如果想突破这个局限性, 就需要从他的公式本身下点功夫, 于是乎,作者在这里改进的思路就是用一个表达能力更强的函数来替代原FM中二阶隐向量内积的部分

而这个表达能力更强的函数呢, 我们很容易就可以想到神经网络来充当,因为神经网络理论上可以拟合任何复杂能力的函数,当然不是一个简单的DNN, 而是依然底层考虑了交叉,然后高层使用的DNN网络, 这个也就是我们最终的NFM网络了:

2. 模型结构

2.1 Input 和Embedding层

输入层的特征, 文章指定了稀疏离散特征居多, 这种特征我们也知道一般是先one-hot, 然后会通过embedding,处理成稠密低维的。 所以这两层还是和之前一样,假设$\mathbf{v}{\mathbf{i}} \in \mathbb{R}^{k}$为第$i$个特征的embedding向量, 那么$\mathcal{V}{x}=\left{x_{1} \mathbf{v}{1}, \ldots, x{n} \mathbf{v}_{n}\right}$表示的下一层的输入特征。这里带上了$x_i$是因为很多$x_i$转成了One-hot之后,出现很多为0的, 这里的${x_iv_i}$是$x_i$不等于0的那些特征向量。

2.2 Bi-Interaction Pooling layer

在Embedding层和神经网络之间加入了特征交叉池化层是本网络的核心创新了,正是因为这个结构,实现了FM与DNN的无缝连接, 组成了一个大的网络,且能够正常的反向传播。假设$\mathcal{V}_{x}$是所有特征embedding的集合, 那么在特征交叉池化层的操作:

f_{B I}\left(\mathcal{V}_{x}\right)=\sum_{i=1}^{n} \sum_{j=i+1}^{n} x_{i} \mathbf{v}_{i} \odot x_{j} \mathbf{v}_{j}

$\odot$表示两个向量的元素积操作,即两个向量对应维度相乘得到的元素积向量(可不是点乘呀),其中第$k$维的操作:

\left(v_{i} \odot v_{j}\right)_{k}=\boldsymbol{v}_{i k} \boldsymbol{v}_{j k}

Bi-Interaction层不需要额外的模型学习参数,更重要的是它在一个线性的时间内完成计算,和FM一致的,即时间复杂度为$O\left(k N_{x}\right)$,$N_x$为embedding向量的数量。参考FM,可以将上式转化为:

f_{B I}\left(\mathcal{V}_{x}\right)=\frac{1}{2}\left[\left(\sum_{i=1}^{n} x_{i} \mathbf{v}_{i}\right)^{2}-\sum_{i=1}^{n}\left(x_{i} \mathbf{v}_{i}\right)^{2}\right]

2.3 隐藏层

这一层就是全连接的神经网络, DNN在进行特征的高层非线性交互上有着天然的学习优势,公式如下:

\begin{aligned} \mathbf{z}_{1}=&\sigma_{1}\left(\mathbf{W}_{1} f_{B I} \left(\mathcal{V}_{x}\right)+\mathbf{b}_{1}\right) \\ \mathbf{z}_{2}=& \sigma_{2}\left(\mathbf{W}_{2} \mathbf{z}_{1}+\mathbf{b}_{2}\right) \\ \ldots \ldots \\ \mathbf{z}_{L}=& \sigma_{L}\left(\mathbf{W}_{L} \mathbf{z}_{L-1}+\mathbf{b}_{L}\right) \end{aligned}

这里的$\sigma_i$是第$i$层的激活函数,可不要理解成sigmoid激活函数。

2.4 预测层

这个就是最后一层的结果直接过一个隐藏层,但注意由于这里是回归问题,没有加sigmoid激活:

f(\mathbf{x})=\mathbf{h}^{T} \mathbf{z}_{L}

所以, NFM模型的前向传播过程总结如下:

\begin{aligned} \hat{y}_{N F M}(\mathbf{x}) &=w_{0}+\sum_{i=1}^{n} w_{i} x_{i} \\ &+\mathbf{h}^{T} \sigma_{L}\left(\mathbf{W}_{L}\left(\ldots \sigma_{1}\left(\mathbf{W}_{1} f_{B I}\left(\mathcal{V}_{x}\right)+\mathbf{b}_{1}\right) \ldots\right)+\mathbf{b}_{L}\right) \end{aligned}

3. 代码实现

数据集的特征会分为dense特征(连续)和sparse特征(离散), 所以模型的输入层接收这两种输入。但是我们这里把输入分成了linear input和dnn input两种情况,而每种情况都有可能包含上面这两种输入。因为我们后面的模型逻辑会分这两部分走,这里有个细节要注意,就是光看上面那个NFM模型的话,是没有看到它线性特征处理的那部分的,也就是FM的前半部分公式那里图里面是没有的。但是这里我们要加上。

\hat{y}_{N F M}(\mathbf{x})=w_{0}+\sum_{i=1}^{n} w_{i} x_{i}+f(\mathbf{x})

所以模型的逻辑我们分成了两大部分,这里我分别给大家解释下每一块做了什么事情:

  1. linear part: 这部分是有关于线性计算,也就是FM的前半部分$w1x1+w2x2…wnxn+b$的计算。对于这一块的计算,我们用了一个get_linear_logits函数实现,后面再说,总之通过这个函数,我们就可以实现上面这个公式的计算过程,得到linear的输出
  2. dnn part: 这部分是后面交叉特征的那部分计算,FM的最后那部分公式f(x)。 这一块主要是针对离散的特征,首先过embedding, 然后过特征交叉池化层,这个计算我们用了get_bi_interaction_pooling_output函数实现, 得到输出之后又过了DNN网络,最后得到dnn的输出

模型的最后输出结果,就是把这两个部分的输出结果加和(当然也可以加权),再过一个sigmoid得到。所以NFM的模型定义就出来了:

\begin{aligned}\hat{y}_{N F M}(\mathbf{x}) &=w_{0}+\sum_{i=1}^{n} w_{i} x_{i} \\&+\mathbf{h}^{T} \sigma_{L}\left(\mathbf{W}_{L}\left(\ldots \sigma_{1}\left(\mathbf{W}_{1} f_{B I}\left(\mathcal{V}_{x}\right)+\mathbf{b}_{1}\right) \ldots\right)+\mathbf{b}_{L}\right)\end{aligned}
class BiInteractionPooling(Layer):
    def __init__(self):
        super(BiInteractionPooling, self).__init__()

    def call(self, inputs):
        # 优化后的公式为: 0.5 * (和的平方-平方的和)  =>> B x k
        concated_embeds_value = inputs # B x n x k

        square_of_sum = tf.square(tf.reduce_sum(concated_embeds_value, axis=1, keepdims=False)) # B x k
        sum_of_square = tf.reduce_sum(concated_embeds_value * concated_embeds_value, axis=1, keepdims=False) # B x k
        cross_term = 0.5 * (square_of_sum - sum_of_square) # B x k

        return cross_term

    def compute_output_shape(self, input_shape):
        return (None, input_shape[2])

def NFM(linear_feature_columns, dnn_feature_columns):
    # 构建输入层,即所有特征对应的Input()层,这里使用字典的形式返回,方便后续构建模型
    dense_input_dict, sparse_input_dict = build_input_layers(linear_feature_columns + dnn_feature_columns)

    # 将linear部分的特征中sparse特征筛选出来,后面用来做1维的embedding
    linear_sparse_feature_columns = list(filter(lambda x: isinstance(x, SparseFeat), linear_feature_columns))

    # 构建模型的输入层,模型的输入层不能是字典的形式,应该将字典的形式转换成列表的形式
    # 注意:这里实际的输入与Input()层的对应,是通过模型输入时候的字典数据的key与对应name的Input层
    input_layers = list(dense_input_dict.values()) + list(sparse_input_dict.values())

    # linear_logits由两部分组成,分别是dense特征的logits和sparse特征的logits
    linear_logits = get_linear_logits(dense_input_dict, sparse_input_dict, linear_sparse_feature_columns)

    # 构建维度为k的embedding层,这里使用字典的形式返回,方便后面搭建模型
    # embedding层用户构建FM交叉部分和DNN的输入部分
    embedding_layers = build_embedding_layers(dnn_feature_columns, sparse_input_dict, is_linear=False)

    # 将输入到dnn中的sparse特征筛选出来
    dnn_sparse_feature_columns = list(filter(lambda x: isinstance(x, SparseFeat), dnn_feature_columns))

    pooling_output = get_bi_interaction_pooling_output(sparse_input_dict, dnn_sparse_feature_columns, embedding_layers) # B x (n(n-1)/2)
    
    # 论文中说到在池化之后加上了BN操作
    pooling_output = BatchNormalization()(pooling_output)

    dnn_logits = get_dnn_logits(pooling_output)
    
    # 将linear,dnn的logits相加作为最终的logits
    output_logits = Add()([linear_logits, dnn_logits])

    # 这里的激活函数使用sigmoid
    output_layers = Activation("sigmoid")(output_logits)

    model = Model(input_layers, output_layers)
    return model
Epoch 1/5
160/160 [==============================] - ETA: 0s - loss: 0.6945 - binary_crossentropy: 0.6945 - auc: 0.616 - ETA: 0s - loss: 0.6974 - binary_crossentropy: 0.6974 - auc: 0.631 - 0s 3ms/sample - loss: 0.6945 - binary_crossentropy: 0.6945 - auc: 0.6230
Epoch 2/5
160/160 [==============================] - ETA: 0s - loss: 0.6220 - binary_crossentropy: 0.6220 - auc: 0.647 - ETA: 0s - loss: 0.6666 - binary_crossentropy: 0.6666 - auc: 0.641 - 0s 2ms/sample - loss: 0.6496 - binary_crossentropy: 0.6496 - auc: 0.6383
Epoch 3/5
160/160 [==============================] - ETA: 0s - loss: 0.6023 - binary_crossentropy: 0.6023 - auc: 0.654 - ETA: 0s - loss: 0.5763 - binary_crossentropy: 0.5763 - auc: 0.691 - 0s 2ms/sample - loss: 0.5629 - binary_crossentropy: 0.5629 - auc: 0.6952
Epoch 4/5
160/160 [==============================] - ETA: 0s - loss: 0.4845 - binary_crossentropy: 0.4845 - auc: 0.742 - ETA: 0s - loss: 0.4688 - binary_crossentropy: 0.4688 - auc: 0.784 - 0s 2ms/sample - loss: 0.4840 - binary_crossentropy: 0.4840 - auc: 0.7650
Epoch 5/5
160/160 [==============================] - ETA: 0s - loss: 0.2860 - binary_crossentropy: 0.2860 - auc: 0.924 - ETA: 0s - loss: 0.4298 - binary_crossentropy: 0.4298 - auc: 0.822 - 0s 2ms/sample - loss: 0.4136 - binary_crossentropy: 0.4136 - auc: 0.8514

4. 思考题

  1. NFM中的特征交叉与FM中的特征交叉有何异同,分别从原理和代码实现上进行对比分析

NFM中不是两个隐向量的内积,而是元素积,也就是这一个交叉完了之后k个维度不求和,最后会得到一个$k$维向量,而FM那里内积的话最后得到一个数,在进行两两Embedding元素积之后,对交叉特征向量取和, 得到该层的输出向量, 很显然, 输出是一个$k$维的向量。

square_of_sum = tf.square(tf.reduce_sum(concated_embeds_value, axis=1, keepdims=True)) # B x 1 x k
sum_of_square = tf.reduce_sum(concated_embeds_value * concated_embeds_value, axis=1, keepdims=True) # B x1 xk

FM代码实现:
cross_term = square_of_sum - sum_of_square # B x 1 x k
cross_term = 0.5 * tf.reduce_sum(cross_term, axis=2, keepdims=False) # B x 1

NFM代码实现:
cross_term = 0.5 * (square_of_sum - sum_of_square) # B x k

5. 参考资料

  • Datawhale 2021年3月推荐系统组队学习资料