推荐系统系列(七):NFM理论与实践

推荐系统系列(七):NFM理论与实践

背景

传统FM模型虽然考虑了组合特征,但是其本质仍是一种线性模型,模型的表征能力终究是有限的。后续的Wide&Deep [3]、DeepCrossing [4]等模型在FM的基础上引入了DNN,试图加强模型的非线性能力,但这类模型对于参数较为敏感,训练难度较大。且在Wide&Deep等模型中,对于二阶交叉特征向量仅进行简单concatenation,然后送入后续DNN部分,将捕获高阶特征交叉信息的任务交给了DNN结构。这种简单拼接的方式,并没有将二阶交叉特征的信息完全表征出来,对于后续DNN来说,基于此结构学习更高阶交叉信息效率太低。

基于这些分析,新加坡国立大学于2017年提出Neural Factorization Machine(NFM)模型 [1]。同样是在FM的基础上引入DNN,利用非线性结构来学习更多数据信息。不同于Wide&Deep、DeepCrossing等模型,NFM使用Bi-Interaction Layer(Bi-linear interaction)结构来对二阶交叉信息进行处理,使交叉特征的信息能更好的被DNN结构学习,降低DNN学习更高阶交叉特征信息的难度。减轻DNN的负担,意味着不再需要更深的网络结构,从而模型参数量也减少了,模型训练更便捷。

分析

1. NFM 结构定义

NFM公式化定义如下:

\begin{aligned} \hat{y}_{NFM}(X)=w_0+\sum_{i=1}^{n}w_ix_i+f(X) \end{aligned} \qquad (1)

模型结构图如下所示,注意这个部分并未涵盖一阶项与偏置,完整的NFM是将三者涵盖其中的

​从结构图可以看出NFM共有四个部分,下面分别对于每个部分进行详细讲解。

1.1 Embedding Layer

embedding vector的计算与之前介绍的模型保持一致,可通过lookup table获取。最终输入特征向量是由输入特征值 x_i 与 embedding vector v_i 相乘得到,即 V_x=\{x_1v_1,\dots,x_nv_n\} ​ 。个人认为这个部分与以前的模型没有区别。

1.2 Bi-Interaction Layer

Bi-Interaction Layer是NFM的核心,其本质是一个pooling操作,将embedding vector集合归并为一个向量。

\begin{aligned} f_{BI}(V_x)=\sum_{i=1}^n\sum_{j=i+1}^nx_iv_i \odot x_jv_j \end{aligned} \qquad (2)
其中, \odot ​ 表示两个向量对应元素相乘,其结果为一个向量。所以,Bi-Interation Layer将embedding vectors 进行两两交叉 ​ \odot 运算,然后将所有向量进行对应元素求和,最终 f_{BI}(V_x) ​ 为pooling之后的一个向量。
公式(2)的计算时间复杂度为 O(kn^2) ​ ,​ k 为embedding vector维度,类似于FM,可以对公式(2)进一步改写为:
\begin{aligned} f_{BI}(V_x)=\frac{1}{2}\left[\left(\sum_{i=1}^nx_iv_i\right)^2-\sum_{i=1}^n\left(x_iv_i\right)^2\right] \end{aligned} \qquad (3)
改写之后的时间复杂度为 ​ O(kN_x) ,其中 ​ N_x 为输入特征 ​ X 的非零元素个数。Bi-Interaction Layer与FM中的二阶交叉项相比,没有引入额外的参数,同时也能以线性时间复杂度进行训练,这是非常好的性质。

1.3 Hidden Layer

DNN部分的定义如下:

\begin{aligned} z1={}&\sigma_1(W_1f_{BI}(V_x)+b_1) \\ z2={}&\sigma_2(W_2z_1+b_2) \\ &\dots \dots \\ z_L={}&\sigma_L(W_Lz_{L-1}+b_L) \\ \end{aligned}
其中 W_l,b_l ​ 分别表示参数矩阵与偏置向量, \sigma ​ 表示激活函数,可以取 ​ sigmoid,tanh,relu 等。关于隐藏层的结构,可以使用类似于FNN中的几种结构: tower,constant,diamond ​ 等。

1.4 Prediction Layer

最后一层隐藏层加上一个线性变换,作为结果输出,即: f(X)=h^Tz_L ​ 。

最终,公式(1)可以表示为公式(4)

\begin{aligned} \hat{y}_{NFM}(X)={}&w_0+\sum_{i=1}^{n}w_ix_i+f(X) \\ ={}&w_0+\sum_{i=1}^{n}w_ix_i \\ +&h^T\sigma_L(W_L(\dots \sigma_1(W_1f_{BI}(V_x)+b_1)\dots)+b_L) \end{aligned} \qquad (4)

由此也可以看出,如果将中间的隐藏层去掉,仅保留最终的prediction layer,同时将 h ​ 设定为全1的向量,那么NFM完全复现了FM,可以认为NFM就是FM的推广。

a trainable h can not improve the expressiveness of FM, since its impact on prediction can be absorbed into feature embeddings.[1]

h 不会增强FM的表征能力,因为该参数可以吸收到特征的embedding 向量中。换句话说,就算 ​ h 不是全为1的向量,我们也可将其视为FM的等价模型。
仔细观察公式(4),上述Figure2的的结构图仅对应公式中的 ​ f(X) 项。如果将全局偏置 w_0 ​ 与一阶项 \sum_{i=1}^nw_ix_i ​ 综合考虑,其实NFM的结构图与Wide&Deep极为相似,但NFM中的二阶项与DNN是 串联结构 NFM左侧同样可以看做是一个LR模型,但不同于Wide&Deep,NFM左侧的LR模型仅输入单特征,并没有将组合特征送入LR模型,所以也就无需进行额外的特征工程工作。

2. 过拟合风险

将模型复杂度提高,不可避免的会面临训练过拟合的问题,NFM作者使用了dropout与batch normalization技术来缓解过拟合问题。后续实验表明,结合使用这两种技术能够有效的避免过拟合风险。

2.1 dropout

为了防止过拟合,可以使用dropout技术。需要注意的是,dropout一般是用于Bi-Interaction Layer输出之后,对于后续的每一层隐层都可使用。[5]

2.2 batch normalization

为了加速模型的收敛,同时还可使用batch normalization技术。同样的,作用于Bi-Interaction的输出,以及后续的隐层。[5]

2.3 附加层相对顺序

同时使用dropout,与batch normalization技术,需要注意两者的使用顺序。在 Batch Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shift 一文中,作者提到 :

we would like to ensure that for any parameter values, the network always produces activations with the desired distribution.[2]

也就是说,需要为激活函数提供需要的数据分布。所以,应该在Bi-Interaction Layer之后接入batch normalization,然后直接进行dropout。需要注意的是,Bi-Interaction Layer是没有激活函数的。后续隐层需要进行batch normalization调整数据分布,然后再加上激活函数,最后使用dropout技术。

3. 性能分析

个人认为NFM论文中对于模型的消融分析(Ablation study)做得非常精彩,值得大家一起来学习。

作者围绕三个问题对NFM的表现能力进行分析,下面一一来进行讲解。

3.1 问题一

Q:Bi-Interaction pooling 能否有效捕获二阶交叉特征信息?dropout与batch normalization能否起作用?

首先将NFM去除隐层,得到NFM-0模型,此时的NFM-0与FM等价。以LR作为对比模型,在不同dropout比例、不同的L2正则化强度下,NFM-0的表现如下:

从上述实验可以看出,NFM-0比LR表现好很多,说明Bi-Interaction pooling能够捕获二阶交叉信息。dropout与L2正则对比可以说明,NFM-0更适合使用dropout技术。

下面对比试验,观察dropout是否能够有效缓解模型过拟合问题。

图4可以看出,dropout能够有效缓解模型过拟合问题。

接下来试验观察batch normalization的效果

从图5可以看出,batch normalization能够明显加速模型收敛。同时可以看出,结合dropout与batch normalization技术能够有效避免过拟合,提高模型泛化能力。

3.2 问题二

Q:NFM隐层能否有效的捕获更高阶交叉特征信息?

NFM使用一层隐层,同时将维度与Bi-Interaction的输出维度保持一致,对比NFM-0、FM(二者其实是等价的)有巨大提升。说明,NFM中的DNN部分能够学习更高阶交叉特征信息,帮助模型提效。

同时,对比不同的激活函数发现,引入非线性激活函数比线性函数(identity)表现更加优异。这也说明了非线性结构(DNN)的必要性。

除此之外,作者还对隐层结构进行实验对比。首先比较了不同隐层带来的效果,如table2所示,NFM-1是最佳状态,继续叠加隐层反而效果会有所下降。说明NFM的Bi-Interaction能充分的捕获二阶交叉特征信息,所以只需要接入较浅的隐层结构就能捕获更高阶的信息,从而取得不错的效果。

We think the reason is because the Bi-Interaction layer has encoded informative second-order feature interactions, and based on which, a simple non-linear function is sufficient to capture higher-order interactions. [1]

为了进一步验证Bi-Interaction捕获信息的高效性,作者将Bi-Interaction替换为concatenation。实验结果发现,随着隐层结构越来越深,效果也会越来越好,但是最佳效果仍不如NFM-1。

因为Wide&Deep、DeepCrossing使用FM预训练参数初始化时能够有更好的表现,为了更进一步展示NFM的优越性,作者还考察了NFM参数是否对于预训练敏感。

将FM训练得到的参数作为NFM的初始化参数,对比预训练模式与随机初始化模式的效果。从图7可以看出,预训练只能起到加速收敛的作用,对于最终的结果影响不大。而NFM对于参数初始化不敏感,更容易进行训练优化。

3.3 问题三

Q:NFM与目前(2017年)最好的模型(Wide&Deep、DeepCrossing)相比如何呢?

NFM使用一层隐层,激活函数为relu,与对比模型保持一致,dropout设置为0.5。Wide&Deep、DeepCrossing使用原论文中最佳参数。

通过实验选择最佳的embedding 维度,如图8所示,除了DeepCrossing,其他模型的表现基本一致,所以最终选择128、256作为对比参数。

从最终的对比结果Table3来看,NFM的确表现最佳。除此之外也能够发现一些其他有意思的现象。

HOFM(使用了更高阶交叉特征的FM)与FM对比可以发现,更高阶的特征交叉能够带来提升,但HOFM采用线性方式拟合高阶特征,这种做法带来的提升效果有限,且模型参数更多。对比HOFM与NFM可以看出,使用非线性方式拟合高阶特征才能带来巨大提升效果,且NFM所引入的参数远远低于HOFM。

Wide&Deep与DeepCrossing在使用预训练的参数进行初始化之后,能够取得更好的效果。DeepCrossing使用了10层网络结构,但是最终的效果却不及FM,这说明DeepCrossing难以训练优化,网络并不是越深越好。

总而言之,NFM结构简单参数少,训练速度快(Bi-Interaction Layer的复杂度为 ​ ),效果棒!简直是居家旅行必备模型。

实践

NFM核心代码如下:

class NFM(object):
    def __init__(self, vec_dim=None, field_lens=None, dnn_layers=None, lr=None, dropout_rate=None):
        self.vec_dim = vec_dim
        self.field_lens = field_lens
        self.field_num = len(field_lens)
        self.dnn_layers = dnn_layers
        self.lr = lr
        self.dropout_rate = dropout_rate
        assert isinstance(dnn_layers, list) and dnn_layers[-1] == 1
        self._build_graph()

    def _build_graph(self):
        self.add_input()
        self.inference()

    def add_input(self):
        self.x = [tf.placeholder(tf.float32, name='input_x_%d'%i) for i in range(self.field_num)]
        self.y = tf.placeholder(tf.float32, shape=[None], name='input_y')
        self.is_train = tf.placeholder(tf.bool)

    def inference(self):
        with tf.variable_scope('linear_part'):
            w0 = tf.get_variable(name='bias', shape=[1], dtype=tf.float32)
            linear_w = [tf.get_variable(name='linear_w_%d'%i, shape=[self.field_lens[i]], dtype=tf.float32) for i in range(self.field_num)]
            linear_part = w0 + tf.reduce_sum(
                tf.concat([tf.reduce_sum(tf.multiply(self.x[i], linear_w[i]), axis=1, keep_dims=True) for i in range(self.field_num)], axis=1),
                axis=1, keep_dims=True) # (batch, 1)
        with tf.variable_scope('emb_part'):
            emb = [tf.get_variable(name='emb_%d'%i, shape=[self.field_lens[i], self.vec_dim], dtype=tf.float32) for i in range(self.field_num)]
            emb_layer = tf.concat([tf.matmul(self.x[i], emb[i]) for i in range(self.field_num)], axis=1) # (batch, F*K)
            emb_layer = tf.reshape(emb_layer, shape=(-1, self.field_num, self.vec_dim)) # (batch, F, K)
        with tf.variable_scope('bi_interaction_part'):
            sum_square_part = tf.square(tf.reduce_sum(emb_layer, axis=1)) # (batch, K)
            square_sum_part = tf.reduce_sum(tf.square(emb_layer), axis=1) # (batch, K)
            nfm = 0.5 * (sum_square_part - square_sum_part)
            nfm = tf.layers.batch_normalization(nfm, training=self.is_train, name='bi_interaction_bn')
            nfm = tf.layers.dropout(nfm, rate=self.dropout_rate, training=self.is_train)
        with tf.variable_scope('dnn_part'):
            in_node = self.vec_dim
            for i in range(len(self.dnn_layers)-1):
                out_node = self.dnn_layers[i]
                w = tf.get_variable(name='w_%d'%i, shape=[in_node, out_node], dtype=tf.float32)
                b = tf.get_variable(name='b_%d'%i, shape=[out_node], dtype=tf.float32)
                in_node = out_node
                nfm = tf.matmul(nfm, w) + b
                nfm = tf.layers.batch_normalization(nfm, training=self.is_train, name='bn_%d'%i)
                nfm = tf.nn.relu(nfm)
                nfm = tf.layers.dropout(nfm, rate=self.dropout_rate, training=self.is_train)
            h = tf.get_variable(name='h', shape=[in_node, 1], dtype=tf.float32)
            nfm = tf.matmul(nfm, h) # (batch, 1)

        self.y_logits = linear_part + nfm
        self.y_hat = tf.nn.sigmoid(self.y_logits)
        self.pred_label = tf.cast(self.y_hat > 0.5, tf.int32)
        self.loss = -tf.reduce_mean(self.y*tf.log(self.y_hat+1e-8) + (1-self.y)*tf.log(1-self.y_hat+1e-8))
        reg_variables = tf.get_collection(tf.GraphKeys.REGULARIZATION_LOSSES)
        if len(reg_variables) > 0:
            self.loss += tf.add_n(reg_variables)
        update_op = tf.get_collection(tf.GraphKeys.UPDATE_OPS)
        with tf.control_dependencies(update_op):
            self.train_op = tf.train.AdamOptimizer(self.lr).minimize(self.loss)

reference

[1] He, Xiangnan, and Tat-Seng Chua. "Neural factorization machines for sparse predictive analytics." Proceedings of the 40th International ACM SIGIR conference on Research and Development in Information Retrieval. ACM, 2017.

[2] Ioffe, Sergey, and Christian Szegedy. "Batch normalization: Accelerating deep network training by reducing internal covariate shift." arXiv preprint arXiv:1502.03167 (2015).

[3] Cheng, Heng-Tze, et al. "Wide & deep learning for recommender systems." Proceedings of the 1st workshop on deep learning for recommender systems. ACM, 2016.

[4] Shan, Ying, et al. "Deep crossing: Web-scale modeling without manually crafted combinatorial features." Proceedings of the 22nd ACM SIGKDD international conference on knowledge discovery and data mining. ACM, 2016.

[5] github.com/hexiangnan/n


欢迎关注公众号:SOTA Lab

专注知识分享,不定期更新计算机、金融相关文章~

编辑于 2020-02-06 21:21