Fork me on GitHub

xDeepFM 算法理论与实践

阅读原文

前言

今天为大家带来的是 2018 年由中科大、北邮、微软联合推出的 xDeepFM(eXtreme Deep Factorization Machine)模型[1],该模型的主要贡献在于,基于 vector-wise 的模式提出了新的显式交叉高阶特征的方法,并且与 DCN [2]一样,能够构造有限阶交叉特征。虽然 xDeepFM 在名称上与 DeepFM [3]相似,但其主要对比的是 DCN 模型。

在进行模型分析与对比之前,首先为大家介绍一下什么是 vector-wise 模式。与 vector-wise 概念相对应的是 bit-wise,在最开始的 FM 模型当中,通过特征隐向量之间的点积来表征特征之间的交叉组合。特征交叉参与运算的最小单位为向量,且同一隐向量内的元素并不会有交叉乘积,这种方式称为 vector-wise。后续 FM 的衍生模型,尤其是引入 DNN 模块后,常见的做法是,将 embedding 之后的特征向量拼接到一起,然后送入后续的 DNN 结构模拟特征交叉的过程。这种方式与 vector-wise 的区别在于,各特征向量 concat 在一起成为一个向量,抹去了不同特征向量的概念,后续模块计算时,对于同一特征向量内的元素会有交互计算的现象出现,这种方式称为 bit-wise。将常见的 bit-wise 方式改为 vector-wise,使模型与 FM 思想更贴切,这也是 xDeepFM 的 Motivation 之一。这里需要提醒的是,vector-wise 的方式其实在之前介绍的 PNN、NFM、AFM 与 DeepFM 中都有使用,但是并没有单独拎出来说明清楚。

关于 xDeepFM 的另一个贡献点“有限阶数特征交叉”,所具备的优点之前也提到过。简单 DNN 结构虽然说能够隐式交叉特征,学习任意函数的表示,但是我们并不清楚其最大特征交叉阶数为多少,简单来说就是无法得知哪些特征交叉可以得到最佳效果,而显式指定阶数交叉特征能够在一定程度缓解这些问题。

在论文原文中,作者对 DCN 模型的 Cross Network 进行分析,并从数学层面证明了 Cross Network 最终学习到的是一种特定形式的表示,认为该结构不利于模型充分表征交叉特征。个人理解,Cross Network 之所以采用该形式,是因为其精简参数,在实现显式特征交叉的同时控制计算复杂度。二者孰优孰劣,仍需要在实际业务场景中对比试验方能判定。

分析

1. 模型结构

xDeepFM 的模型结构如下所示,下面将对模型的四个模块详细讲解。

### 1.1 Embedding Layer

这个部分无需多言,将离散特征进行 embedding,表示成低维稠密向量。

If the field is univalent, the feature embedding is used as the field embedding.
If the field is multivalent, the sum of feature embedding is used as the field embedding.

1.2 Compressed Interaction Network(CIN)

与 DCN 的 bit-wise fashion 不同的是,xDeepFM 的 CIN 中采用 vector-wise fashion 对特征向量进行交叉。将 embedding 矩阵表示为 [公式][公式][公式] 行表示第 [公式] 个 Field 对应的特征 embedding 向量,共有 [公式][公式] 维的 embeddding vector。

CIN 是多层输出结构,每层都会产出一个中间结果矩阵,将第 [公式] 层产出的矩阵表示为 [公式] ,令 [公式] ,其中 [公式] 表示第 [公式] 层特征向量数。

接下来,让我们详细的分析下计算过程,公式如下:

[公式]

其中, [公式] 表示 [公式] 中第 [公式] 行特征向量,同理, [公式] 表示 CIN 第 [公式] 层输出矩阵的第 [公式] 行特征向量,[公式] 表示原始特征矩阵 [公式][公式] 行特征向量。 [公式] 是参数矩阵, [公式] 是一个标量值,表示第 [公式] 行第 [公式] 列位置的参数 。运算 [公式] 表示哈达玛积,即向量对应元素相乘,结果仍为向量。

从计算原理上进行理解,公式(1)首先将上一层(第 [公式] 层)输出的特征矩阵 [公式] 的特征向量,与原始特征矩阵 [公式] 的特征向量,两两之间进行哈达玛积。因为上一层有 [公式] 个特征向量,原始特征矩阵有 [公式] 个特征向量,所以运算完成会得到 [公式] 个向量。将得到的全部向量结果根据参数 [公式] 进行加权求和,得到第 [公式] 层的结果 [公式]

让我们换个视角观察上述计算过程,[公式] 中每一行特征向量都是 [公式][公式] 特征交叉之后加权求和得到的。 [公式] 中任意两个特征向量的不同点在于,计算过程中的加权求和参数不同。所以可以将 [公式] 的计算拆成 2 个步骤:1)将 [公式][公式] 中的特征向量两两交叉计算 ,得到中间结果 [公式] ,其中包含 [公式][公式] 维特征向量;2)使用不同的加权参数 [公式][公式] 中每个特征向量进行加权求和,得到 [公式]

步骤 1)中,计算 [公式] 的过程可以看做是矩阵 [公式][公式] 的向量外积计算,如下图(a)所示,得到的[公式]

步骤 2)通过不同的参数矩阵 [公式][公式] 中的向量进行加权求和,这个过程类似于 CNN 中的卷积操作,不同的参数矩阵便是不同的卷积核,每个卷积核与 [公式] 进行一次卷积计算得到一个 [公式] 维的 feature map 向量,其中 [公式][公式] 的通道数。最终通过 [公式] 次不同的卷积之后,便得到 [公式] 。通过卷积操作对上层特征矩阵 [公式] 进行压缩编码,这也是 CIN 中“compress”的由来。计算过程如图(b)所示:

得到每层的输出之后,通过 sum pooling 操作将所有的特征矩阵进一步压缩为向量,作为最终的输出。

[公式] 中含有 [公式][公式] 维 feature map,对于每个 feature map 进行求和池化之后得到一个标量值 [公式] ,其中 [公式] ,所以 [公式] 产出长度为 [公式] 的向量 [公式] 。同理,对于 CIN 中的每一个中间层都产出一个向量,将 [公式] 层所有向量进行拼接,得到 [公式] 。最终 CIN 的输出为, [公式] ,其中 [公式] 是长度为 [公式] 的参数向量。

整体如下图所示:

从上述分析可以看出,xDeepFM 的 CIN 模块在 [公式] 层仅包含了 [公式] 阶交叉特征。对比 DCN 模型中,在 [公式] 层包含了从 1 到 [公式] 阶全部交叉特征。这两种方式殊途同归,都能够实现有限阶特征交叉。

1.2.1 复杂度分析

  1. 空间复杂度:

首先,第 [公式] 层有 [公式][公式] ,所以第 [公式] 层参数量为 [公式] ,假设 CIN 有 [公式] 层,那么共有[公式]个参数。最后将各层输出池化拼接,需要 [公式] 个参数进行加权求和。所以,共有 [公式] 个参数。

对比普通的 DNN,第一层参数量为 [公式] ,后续层参数为 [公式] ,最后将所有输出进行加权求和,需要参数为 [公式] 。所以全部参数量为 [公式]

不妨假设每一层的 [公式] ,那么 CIN 的空间复杂度为 [公式] ,普通 DNN 的空间复杂度为 [公式]

一般来说, [公式] (特征 Field 个数)与 [公式] (每层 feature map 数目)都是较小的数值,所以 [公式] 在一个可接受的范围之内。但如果在一些极端情况,[公式][公式] 都比较大的时候,可以使用矩阵 [公式] 阶分解的思想,将 [公式] 表示为两个小矩阵的乘积,[公式] ,其中 [公式][公式] ,并且 [公式] 同时 [公式] 。在这种情况下,假设 [公式] ,那么 [公式] 的参数为 [公式] 。所以 CIN 的空间复杂度从 [公式] 变为 [公式] ,而普通 DNN 的空间复杂度变为 [公式]

  1. 时间复杂度:

因为 CIN 中每一层都需要计算 [公式] ,其计算时间复杂度为 [公式] ,而后每层都有 [公式] 个 feature map,所以 [公式] 层的 CIN 计算时间复杂度为 [公式] 。容易推出,普通 DNN 的时间复杂度为 [公式]

CIN 的主要瓶颈在于时间复杂度高。

1.3 DNN

在 Embedding Layer 之后,除了连接 CIN 模块,同时并行的会接入到简单的多层感知机。与 Wide&Deep、DeepFM 类似,设置这个模块的目的在于,隐式交叉编码可以作为显式交叉(CIN)的补充,进一步的提高模型表征能力。

1.4 Linear part

这个部分可以是一个简单的 LR,将原始特征(未经过 Embedding)作为输入,表示为:[公式] ,其中的 [公式] 就是原始特征输入,[公式] 是激活函数。

1.5 模型组合

各个模块分析完毕,将 1.1~1.4 所有模块组合到一块,三个输出模块(Linear、CIN、DNN)统一到一起,作为模型的最终输出,公式如下:

[公式]

其中,[公式] 是原始特征,[公式] 是 DNN 模块的输出向量,[公式] 是 CIN 模块的输出向量,[公式] 分别是对应模块的可训练参数,[公式] 为全局偏置项,[公式] 为激活函数。

从这个形式上来看,xDeepFM 既有低阶项又有高阶项,既有显式交叉又有隐式组合,并且是基于 vector-wise 级别的交叉,可谓是应有尽有,算是 FM 类模型的完备实现了。

2. 与 FM、DeepFM 的关系

假设输入的所有特征 Field 都是单值的,CIN 模块只有 1 层,并且其中的 feature map 只有 1 个,此时的 xDeepFM 模型基本等价于 DeepFM 模型。因为 CIN 模块只对所有的特征 embedding vector 进行两两哈达玛积,将得到的结果进行加权求和(DeepFM 是权重恒定为 1 进行求和),然后再对结果进行 sum pooling 作为输出。结合一阶项与 DNN 部分,模型基本等价于 DeepFM。

如果更进一步,将 DNN 部分去除,同时在加权求和部分固定权重为 1,那么此时 xDeepFM 与 FM 模型完全等价。

3. 性能分析

围绕三个方面进行性能试验

  1. CIN 模块进行高阶特征交叉是否有效?
  2. 对于推荐系统而言,同时结合显式高阶特征交叉与隐式高阶特征交叉,是否有必要?
  3. 网络结构的设置对于 xDeepFM 性能有何影响?

3.1 实验准备

数据集使用 CritDianpingBing News

评测指标使用 AUC 与 Logloss。其中 AUC 主要关注的是正负样本的相对顺序,并且对非平衡数据不敏感。而 Logloss 关注预测值与真实值之间的相关程度,在计算广告中因为涉及到广告竞价策略,一般使用 Logloss 比较多。

对比模型:LR、FM、DNN、PNN(IPNN 与 OPNN 择其优)、Wide&Deep、DCN 和 DeepFM。

参数设置:

learning rat:0.001,优化器:Adambatch siz:4096

对于 DNN、DCN、Wide&Deep、DeepFM 与 xDeepFM 使用 L2 正则,其中 [公式] 。对于 PNN 使用 dropout rate=0.5。

模型每层的默认节点数:(1)DNN Layer 节点为 400;(2)在 Crit 中,CIN Layer 节点 200。而 DianpingBing News 中,CIN Layer 节点数为 100;

在实验过程中,Field embedding 维度默认为 10。

3.2 问题一

所有对比模型都优于 FM,说明特征高阶交叉的必要性。但所有模型都低于 CIN,这也就凸显出了 CIN 结构(显式高阶交叉)的优越性,论证了 CIN 模块进行高阶特征交叉是有效的。

3.3 问题二

上述对比试验仅说明了 CIN 的有效性,但是在 xDeepFM 中结合了 CIN 与 DNN 两个部分,这种结合是否能带来提升呢?答案是肯定的,通过下表对比结果可以看出 xDeepFM 这种双形态结合的有效性。

3.4 问题三

这个环节主要探讨超参数带来的影响,1)隐藏层层数;2)每层的节点数;3)激活函数;在对比过程中,固定最佳的 DNN 参数,仅调节 CIN 模块参数。

xDeepFM 的表现可简单通过下图可得出结论,就不做过多剖析了。

实践

仍然使用 [公式] 作为数据集,核心代码如下。

参数含义:

vec_dim :代表 embedding vector 维度

field_lens :list 结构,其中每个元素代表对应 Field 有多少取值。例如 gender 有两个取值,那么其对应的元素为 2

cin_layer_num:cin network 层数

dnn_layers:list 结构,其中每个元素对应 DNN 部分节点数目

lr :学习率

class XDeepFM(object):
    def __init__(self, vec_dim=None, field_lens=None, cin_layer_num=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.feat_num = np.sum(field_lens)
        self.cin_layer_num = cin_layer_num
        self.dnn_layers = dnn_layers
        self.lr = lr
        self.dropout_rate = dropout_rate

        self._build_graph()

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

    def add_input(self):
        self.index = tf.placeholder(tf.int32, shape=[None, self.field_num], name='feat_index') # (batch, m)
        self.x = tf.placeholder(tf.float32, shape=[None, self.field_num], name='feat_value') # (batch, m)
        self.y = tf.placeholder(tf.float32, shape=[None], name='input_y')
        self.is_train = tf.placeholder(tf.bool)

    def cin_layer(self, x0_tensor, xk_tensor, xk_field_num, feature_map_num, name):
        with tf.variable_scope(name):
            x0 = tf.split(value=x0_tensor, num_or_size_splits=self.vec_dim*[1], axis=2)
            xk = tf.split(value=xk_tensor, num_or_size_splits=self.vec_dim*[1], axis=2)
            z_tensor = tf.matmul(x0, xk, transpose_b=True) # (D, batch, m, H_k)
            z_tensor = tf.reshape(z_tensor, shape=[self.vec_dim, -1, self.field_num*xk_field_num]) # (D, batch, m*H_k)
            z_tensor = tf.transpose(z_tensor, perm=[1, 0, 2]) # (batch, D, m*H_k)

            filters = tf.get_variable(name='filters', shape=[1, self.field_num*xk_field_num, feature_map_num], dtype=tf.float32)
            xk_1 = tf.nn.conv1d(z_tensor, filters=filters, stride=1, padding='VALID') # (batch, D, feature_map_num)
            xk_1 = tf.transpose(xk_1, perm=[0, 2, 1]) # (batch, feature_map_num, D)
            return xk_1

    def inference(self):
        with tf.variable_scope('first_order_part'):
            first_ord_w = tf.get_variable(name='first_ord_w', shape=[self.feat_num, 1], dtype=tf.float32)
            first_order = tf.nn.embedding_lookup(first_ord_w, self.index) # (batch, m, 1)
            first_order = tf.reduce_sum(tf.multiply(first_order, tf.expand_dims(self.x, axis=2)), axis=2) # (batch, m)

        with tf.variable_scope('emb_part'):
            embed_matrix = tf.get_variable(name='second_ord_v', shape=[self.feat_num, self.vec_dim], dtype=tf.float32)
            embed_v = tf.nn.embedding_lookup(embed_matrix, self.index) # (batch, m, D)

            embed_x = tf.multiply(tf.expand_dims(self.x, axis=2), embed_v)  # (batch, m, D)
            embed_x = tf.layers.dropout(embed_x, rate=self.dropout_rate, training=self.is_train)  # (batch, m, D)
            node_num = self.field_num * self.vec_dim
            embed_x = tf.reshape(embed_x, shape=[-1, node_num]) # (batch, node_num)

        with tf.variable_scope('cin_part'):
            cross_tensors = []
            x0_tensor = tf.reshape(embed_x, shape=[-1, self.field_num, self.vec_dim]) # (batch, m, D)
            cross_tensors.append(x0_tensor)
            field_nums = []
            field_nums.append(int(self.field_num))
            for i, layer_num in enumerate(self.cin_layer_num):
                xk_tensor = self.cin_layer(x0_tensor, cross_tensors[-1], field_nums[-1], layer_num, 'cin_layer_%d'%i)
                cross_tensors.append(xk_tensor)
                field_nums.append(layer_num)
            p_vec = [tf.reduce_sum(x, axis=2) for x in cross_tensors]
            cin = tf.concat(p_vec, axis=1)
            cin_lens = np.sum(field_nums)

        with tf.variable_scope('dnn_part'):
            dnn = embed_x
            in_num = node_num
            for i in range(len(self.dnn_layers)):
                out_num = self.dnn_layers[i]
                w = tf.get_variable(name='w_%d'%i, shape=[in_num, out_num], dtype=tf.float32)
                b = tf.get_variable(name='b_%d'%i, shape=[out_num], dtype=tf.float32)
                dnn = tf.matmul(dnn, w) + b
                dnn = tf.layers.dropout(tf.nn.relu(dnn), rate=self.dropout_rate, training=self.is_train)
                in_num = out_num

        with tf.variable_scope('output_part'):
            output = tf.concat([first_order, cin, dnn], axis=1)
            global_w = tf.get_variable(name='global_w', shape=[self.field_num+cin_lens+in_num, 1], dtype=tf.float32)
            global_b = tf.get_variable(name='global_b', shape=[1], dtype=tf.float32)
            self.y_logits = tf.matmul(output, global_w) + global_b

        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))
        self.train_op = tf.train.AdamOptimizer(self.lr).minimize(self.loss)

reference

[1] Lian, Jianxun, et al. "xdeepfm: Combining explicit and implicit feature interactions for recommender systems." Proceedings of the 24th ACM SIGKDD International Conference on Knowledge Discovery & Data Mining. ACM, 2018.

[2] Wang, R., Fu, B., Fu, G., & Wang, M. (2017, August). Deep & cross network for ad click predictions. In Proceedings of the ADKDD'17 (p. 12). ACM.

[3] Guo, Huifeng, et al. "DeepFM: a factorization-machine based neural network for CTR prediction." arXiv preprint arXiv:1703.04247 (2017).

[4] https://github.com/Leavingseason/xDeepFM

[5] https://zhuanlan.zhihu.com/p/57162373


本文地址:https://www.6aiq.com/article/1590447563105
本文版权归作者和AIQ共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出