推荐系统遇上深度学习 (二十二)--DeepFM 升级版 XDeepFM 模型强势来袭!



转载请注明 AIQ - 最专业的机器学习大数据社区  http://www.6aiq.com

AIQ 机器学习大数据 知乎专栏 点击关注

今天我们要学习的模型是 xDeepFM 模型,论文地址为:https://arxiv.org/abs/1803.05170。
文中包含我个人的一些理解,如有不对的地方,欢迎大家指正!废话不多说,我们进入正题!

1、引言

对于预测性的系统来说,特征工程起到了至关重要的作用。特征工程中,挖掘交叉特征是至关重要的。交叉特征指的是两个或多个原始特征之间的交叉组合。例如,在新闻推荐场景中,一个三阶交叉特征为 AND(user_organization=msra,item_category=deeplearning,time=monday_morning), 它表示当前用户的工作单位为微软亚洲研究院,当前文章的类别是与深度学习相关的,并且推送时间是周一上午。

传统的推荐系统中,挖掘交叉特征主要依靠人工提取,这种做法主要有以下三种缺点:

1)重要的特征都是与应用场景息息相关的,针对每一种应用场景,工程师们都需要首先花费大量时间和精力深入了解数据的规律之后才能设计、提取出高效的高阶交叉特征,因此人力成本高昂;
2)原始数据中往往包含大量稀疏的特征,例如用户和物品的 ID,交叉特征的维度空间是原始特征维度的乘积,因此很容易带来维度灾难的问题;
3)人工提取的交叉特征无法泛化到未曾在训练样本中出现过的模式中。

因此自动学习特征间的交互关系是十分有意义的。目前大部分相关的研究工作是基于因子分解机的框架,利用多层全连接神经网络去自动学习特征间的高阶交互关系,例如 FNN、PNN 和 DeepFM 等。其缺点是模型学习出的是隐式的交互特征,其形式是未知的、不可控的;同时它们的特征交互是发生在元素级(bit-wise)而不是特征向量之间(vector-wise),这一点违背了因子分解机的初衷。来自 Google 的团队在 KDD 2017 AdKDD&TargetAD 研讨会上提出了 DCN 模型,旨在显式(explicitly)地学习高阶特征交互,其优点是模型非常轻巧高效,但缺点是最终模型的表现形式是一种很特殊的向量扩张,同时特征交互依旧是发生在元素级上。

我们用下图来回顾一下 DCN 的实现:

下面是我对文中提到的两个重要概念的理解:

bit-wise VS vector-wise
假设隐向量的维度为 3 维,如果两个特征 (对应的向量分别为(a1,b1,c1) 和(a2,b2,c2)的话)在进行交互时,交互的形式类似于 f(w1 * a1 * a2,w2 * b1 * b2 ,w3 * c1 * c2)的话,此时我们认为特征交互是发生在元素级(bit-wise)上。如果特征交互形式类似于 f(w * (a1 * a2 ,b1 * b2,c1 * c2))的话,那么我们认为特征交互是发生在特征向量级(vector-wise)。

explicitly VS implicitly
显式的特征交互和隐式的特征交互。以两个特征为例 xi 和 xj,在经过一系列变换后,我们可以表示成 wij * (xi * xj) 的形式,就可以认为是显式特征交互,否则的话,是隐式的特征交互。

微软亚洲研究院社会计算组提出了一种极深因子分解机模型(xDeepFM),不仅能同时以显式和隐式的方式自动学习高阶的特征交互,使特征交互发生在向量级,还兼具记忆与泛化的学习能力。

我们接下来就来看看 xDeepFM 这个模型是怎么做的吧!

2、xDeepFM 模型介绍

2.1 Compressed Interaction Network

为了实现自动学习显式的高阶特征交互,同时使得交互发生在向量级上,文中首先提出了一种新的名为压缩交互网络(Compressed Interaction Network,简称 CIN)的神经模型。在 CIN 中,隐向量是一个单元对象,因此我们将输入的原特征和神经网络中的隐层都分别组织成一个矩阵,记为 X0 和 Xk。CIN 中每一层的神经元都是根据前一层的隐层以及原特征向量推算而来,其计算公式如下:

其中点乘的部分计算如下:

我们来解释一下上面的过程,第 k 层隐层含有 H_k 条神经元向量。隐层的计算可以分成两个步骤:(1)根据前一层隐层的状态 Xk 和原特征矩阵 X0,计算出一个中间结果 Z^k+1,它是一个三维的张量,如下图所示:

在这个中间结果上,我们用 Hk+1 个尺寸为 m*Hk 的卷积核生成下一层隐层的状态,该过程如图 2 所示。这一操作与计算机视觉中最流行的卷积神经网络大体是一致的,唯一的区别在于卷积核的设计。CIN 中一个神经元相关的接受域是垂直于特征维度 D 的整个平面,而 CNN 中的接受域是当前神经元周围的局部小范围区域,因此 CIN 中经过卷积操作得到的特征图(Feature Map)是一个向量,而不是一个矩阵。

如果你觉得原文中的图不够清楚的话,希望下图可以帮助你理解整个过程:

CIN 的宏观框架可以总结为下图:

可以看出,它的特点是,最终学习出的特征交互的阶数是由网络的层数决定的,每一层隐层都通过一个池化操作连接到输出层,从而保证了输出单元可以见到不同阶数的特征交互模式。同时不难看出,CIN 的结构与循环神经网络 RNN 是很类似的,即每一层的状态是由前一层隐层的值与一个额外的输入数据计算所得。不同的是,CIN 中不同层的参数是不一样的,而在 RNN 中是相同的;RNN 中每次额外的输入数据是不一样的,而 CIN 中额外的输入数据是固定的,始终是 X^0。

可以看到,CIN 是通过(vector-wise)来学习特征之间的交互的,还有一个问题,就是它为什么是显式的进行学习?我们先从 X1 来开始看,X1 的第 h 个神经元向量可以表示成:

进一步,X^2 的第 h 个神经元向量可以表示成:

最后,第 k 层的第 h 个神经元向量可以表示成:

因此,我们能够通过上面的式子对特征交互的形式进行一个很好的表示,它是显式的学习特征交叉。

2.2 xDeepFM

将 CIN 与线性回归单元、全连接神经网络单元组合在一起,得到最终的模型并命名为极深因子分解机 xDeepFM,其结构如下图:

集成的 CIN 和 DNN 两个模块能够帮助模型同时以显式和隐式的方式学习高阶的特征交互,而集成的线性模块和深度神经模块也让模型兼具记忆与泛化的学习能力。值得一提的是,为了提高模型的通用性,xDeepFM 中不同的模块共享相同的输入数据。而在具体的应用场景下,不同的模块也可以接入各自不同的输入数据,例如,线性模块中依旧可以接入很多根据先验知识提取的交叉特征来提高记忆能力,而在 CIN 或者 DNN 中,为了减少模型的计算复杂度,可以只导入一部分稀疏的特征子集。

3、Tensorflow 充电

在介绍 xDeepFM 的代码之前,我们先来进行充电,学习几个 tf 的函数以及 xDeepFM 关键过程的实现。

首先我们要实现第一步:

如何将两个二维的矩阵,相乘得到一个三维的矩阵?我们首先来看一下 tf.split 函数的原理:

tf.split(
    value,
    num_or_size_splits,
    axis=0,
    num=None,
    name='split'
)

其中,value 传入的就是需要切割的张量,axis 是切割的维度,根据 num_or_size_splits 的不同形式,有两种切割方式:

  1. 如果 num_or_size_splits 传入的是一个整数,这个整数代表这个张量最后会被切成几个小张量。此时,传入 axis 的数值就代表切割哪个维度(从 0 开始计数)。调用 tf.split(my_tensor, 2,0) 返回两个 10 * 30 * 40 的小张量。
  2. 如果 num_or_size_splits 传入的是一个向量,那么向量有几个分量就分成几份,切割的维度还是由 axis 决定。比如调用 tf.split(my_tensor, [10, 5, 25], 2),则返回三个张量分别大小为 20 * 30 * 10、20 * 30 * 5、20 * 30 * 25。很显然,传入的这个向量各个分量加和必须等于 axis 所指示原张量维度的大小 (10 + 5 + 25 = 40)。

好了,从实际需求出发,我们来体验一下,假设我们的 batch 为 2,embedding 的 size 是 3,field 数量为 4。我们先来生成两个这样的 tensor(假设 X^k 的 field 也是 4):

arr1 = tf.convert_to_tensor(np.arange(1,25).reshape(2,4,3),dtype=tf.int32)
arr2 = tf.convert_to_tensor(np.arange(1,25).reshape(2,4,3),dtype=tf.int32)

生成的矩阵如下:

在经过 CIN 的第一步之后,我们目标的矩阵大小应该是 2(batch) * 3(embedding Dimension) * 4(Xk 的 field 数 ) * 4(X0 的 field 数 )。如果只考虑 batch 中第一条数据的话,应该形成的是 1 * 3 * 4 * 4 的矩阵。忽略第 0 维,想像成一个长宽为 4,高为 3 的长方体,长方体横向切割,第一个横截面对应的数字应该如下:

那么想要做到这样的结果,我们首先按输入数据的 axis=2 进行 split:

split_arr1 = tf.split(arr1,[1,1,1],2)
split_arr2 = tf.split(arr2,[1,1,1],2)
print(split_arr1)
print(sess.run(split_arr1))
print(sess.run(split_arr2))

分割后的结果如下:

通过结果我们可以看到,我们现在对每一条数据,得到了 3 个 4 * 1 的 tensor,可以理解为此时的 tensor 大小为 3(embedding Dimension) * 2(batch) * 4(Xk 或 X0 的 field 数 ) * 1。

此时我们进行矩阵相乘:

res = tf.matmul(split_arr1,split_arr2,transpose_b=True)

这里我理解的,tensorflow 对 3 维及以上矩阵相乘时,矩阵相乘只发生在最后两维。也就是说,3 * 2 * 4 * 1 和 3 * 2 * 1 * 4 的矩阵相乘,最终的结果是 3 * 2 * 4 * 4。我们来看看结果:

可以看到,不仅矩阵的形状跟我们预想的一样,同时结果也跟我们预想的一样。

最后,我们只需要进行 transpose 操作,把 batch 转换到第 0 维就可以啦。

res = tf.transpose(res,perm=[1,0,2,3])

这样,CIN 中的第一步就大功告成了,明白了这一步如何用 tensorflow 实现,那么代码你也就能够顺其自然的看懂啦!

这一块完整的代码如下:

import tensorflow as tf
import numpy as np

arr1 = tf.convert_to_tensor(np.arange(1,25).reshape(2,4,3),dtype=tf.int32)
arr2 = tf.convert_to_tensor(np.arange(1,25).reshape(2,4,3),dtype=tf.int32)


with tf.Session() as sess:
    sess.run(tf.global_variables_initializer())
    split_arr1 = tf.split(arr1,[1,1,1],2)
    split_arr2 = tf.split(arr2,[1,1,1],2)
    print(split_arr1)
    print(sess.run(split_arr1))
    print(sess.run(split_arr2))
    res = tf.matmul(split_arr1,split_arr2,transpose_b=True)
    print(sess.run(res))
    res = tf.transpose(res,perm=[1,0,2,3])
    print(sess.run(res))
	

4、XDeepFM 的 TF 实现

本文的代码来自 github 地址:https://github.com/Leavingseason/xDeepFM
而我的 github 库中也偷偷把这里面的代码加进去啦:https://github.com/princewen/tensorflow_practice/tree/master/recommendation/Basic-XDeepFM-Demo

真的是写的非常好的一段代码,希望大家可以比着自己敲一敲,相信你会有所收获。

具体的代码细节我们不展开进行讨论,我们只说一下数据的问题吧:
1、代码中的数据按照 ffm 的格式存储,格式如下:filed:n th dimension:value, 即这个特征属于第几个 field,在所有特征全部按 one-hot 展开后的第几维 (而不是在这个 field 中是第几维) 以及对应的特征值。
2、代码中使用到的数据属于多值的离散特征。

关于代码实现细节,我们这里只说一下 CIN 的实现:

由于 X0 在每一层都有用到,所以我们先对 X0 进行一个处理:

nn_input = tf.reshape(nn_input, shape=[-1, int(field_num), hparams.dim])
split_tensor0 = tf.split(hidden_nn_layers[0], hparams.dim * [1], 2)

在计算 Xk 时,我们需要用到 Xk-1 的数据,代码中用 hidden_nn_layers 保存这些数据。对 Xk-1 进行和 X0 同样的处理:

split_tensor = tf.split(hidden_nn_layers[-1], hparams.dim * [1], 2)

接下来就是我们之前讲过的,对两个 split 之后的 tensor 进行相乘再转置的过程啦:

dot_result_m = tf.matmul(split_tensor0, split_tensor, transpose_b=True)
dot_result_o = tf.reshape(dot_result_m, shape=[hparams.dim, -1, field_nums[0]*field_nums[-1]])
dot_result = tf.transpose(dot_result_o, perm=[1, 0, 2])

接下来,我们需要进行 CIN 的第二步,先回顾一下:

这里我们用 1 维卷积实现,假设 X^K 的 field 的数量我们起名为 layer_size:

filters = tf.get_variable(name="f_"+str(idx),
                     shape=[1, field_nums[-1]*field_nums[0], layer_size],
                     dtype=tf.float32)

curr_out = tf.nn.conv1d(dot_result, filters=filters, stride=1, padding='VALID')

此时我们 curr_out 的大小就是 Batch * Embedding Size * Layer size,我们需要进行一下转置:

curr_out = tf.transpose(curr_out, perm=[0, 2, 1])

接下来就是最后一步,进行 sumpooling,如下图:

代码中有两种选择方式,direct 方式和非 direct 方式,direct 方式,直接把完整 curr_out 作为最后输出结果的一部分,同时把完整的 curr_out 作为计算下一个隐藏层向量的输入。非 direct 方式,把 curr_out 按照 layer_size 进行均分,前一半作为计算下一个隐藏层向量的输入,后一半作为最后输出结果的一部分。

if direct:
    hparams.logger.info("all direct connect")
    direct_connect = curr_out
    next_hidden = curr_out
    final_len += layer_size
    field_nums.append(int(layer_size))

else:
    hparams.logger.info("split connect")
    if idx != len(hparams.cross_layer_sizes) - 1:
        next_hidden, direct_connect = tf.split(curr_out, 2 * [int(layer_size / 2)], 1)
        final_len += int(layer_size / 2)
    else:
        direct_connect = curr_out
        next_hidden = 0
        final_len += layer_size
    field_nums.append(int(layer_size / 2))

final_result.append(direct_connect)
hidden_nn_layers.append(next_hidden)

最后 ,经过 sum_pooling 操作,再拼接一个输出层,我们就得到了 CIN 部分的输出:

result = tf.concat(final_result, axis=1)
result = tf.reduce_sum(result, -1)

hparams.logger.info("no residual network")
w_nn_output = tf.get_variable(name='w_nn_output',
                              shape=[final_len, 1],
                              dtype=tf.float32)
b_nn_output = tf.get_variable(name='b_nn_output',
                              shape=[1],
                              dtype=tf.float32,
                              initializer=tf.zeros_initializer())
self.layer_params.append(w_nn_output)
self.layer_params.append(b_nn_output)
exFM_out = tf.nn.xw_plus_b(result, w_nn_output, b_nn_output)

5、总结

我们今天介绍的 xDeepFM 模型,由 linear、DNN、CIN 三部分组成,其中 CIN 实现了自动学习显式的高阶特征交互,同时使得交互发生在向量级上。该模型在几个数据集上都取得了超过 DeepFM 模型的效果。

参考文献

1、论文:https://arxiv.org/abs/1803.05170
2、特征交互:一种极深因子分解机模型(xDeepFM):https://www.xianjichina.com/news/details_81731.html
3、https://blog.csdn.net/SangrealLilith/article/details/80272346
4、https://github.com/Leavingseason/xDeepFM


更多高质资源 尽在AIQ 机器学习大数据 知乎专栏 点击关注

转载请注明 AIQ - 最专业的机器学习大数据社区  http://www.6aiq.com