推荐系统遇上深度学习 (三)--DeepFM 模型理论和实践


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

原文发布于微信公众号 - 小小挖掘机(wAIsjwj)
原文发表时间:2018-04-15

1、背景

特征组合的挑战

对于一个基于 CTR 预估的推荐系统,最重要的是学习到用户点击行为背后隐含的特征组合。在不同的推荐场景中,低阶组合特征或者高阶组合特征可能都会对最终的 CTR 产生影响。

之前介绍的因子分解机 (Factorization Machines, FM) 通过对于每一维特征的隐变量内积来提取特征组合。最终的结果也非常好。但是,虽然理论上来讲 FM 可以对高阶特征组合进行建模,但实际上因为计算复杂度的原因一般都只用到了二阶特征组合。

那么对于高阶的特征组合来说,我们很自然的想法,通过多层的神经网络即 DNN 去解决。

DNN 的局限

下面的图片来自于张俊林教授在 AI 大会上所使用的 PPT。

我们之前也介绍过了,对于离散特征的处理,我们使用的是将特征转换成为 one-hot 的形式,但是将 One-hot 类型的特征输入到 DNN 中,会导致网络参数太多:

如何解决这个问题呢,类似于 FFM 中的思想,将特征分为不同的 field:

再加两层的全链接层,让 Dense Vector 进行组合,那么高阶特征的组合就出来了

但是低阶和高阶特征组合隐含地体现在隐藏层中,如果我们希望把低阶特征组合单独建模,然后融合高阶特征组合。

即将 DNN 与 FM 进行一个合理的融合:

二者的融合总的来说有两种形式,一是串行结构,二是并行结构

而我们今天要讲到的 DeepFM,就是并行结构中的一种典型代表。

2、DeepFM 模型

我们先来看一下 DeepFM 的模型结构:

DeepFM 包含两部分:神经网络部分与因子分解机部分,分别负责低阶特征的提取和高阶特征的提取。这两部分共享同样的输入。DeepFM 的预测结果可以写为:

FM 部分

FM 部分的详细结构如下:

FM 部分是一个因子分解机。关于因子分解机可以参阅文章 [Rendle, 2010] Steffen Rendle. Factorization machines. In ICDM, 2010.。因为引入了隐变量的原因,对于几乎不出现或者很少出现的隐变量,FM 也可以很好的学习。

FM 的输出公式为:

深度部分

深度部分是一个前馈神经网络。与图像或者语音这类输入不同,图像语音的输入一般是连续而且密集的,然而用于 CTR 的输入一般是及其稀疏的。因此需要重新设计网络结构。具体实现中为,在第一层隐含层之前,引入一个嵌入层来完成将输入向量压缩到低维稠密向量。

嵌入层 (embedding layer) 的结构如上图所示。当前网络结构有两个有趣的特性,1)尽管不同 field 的输入长度不同,但是 embedding 之后向量的长度均为 K。2)在 FM 里得到的隐变量 Vik 现在作为了嵌入层网络的权重。

这里的第二点如何理解呢,假设我们的 k=5,首先,对于输入的一条记录,同一个 field 只有一个位置是 1,那么在由输入得到 dense vector 的过程中,输入层只有一个神经元起作用,得到的 dense vector 其实就是输入层到 embedding 层该神经元相连的五条线的权重,即 vi1,vi2,vi3,vi4,vi5。这五个值组合起来就是我们在 FM 中所提到的 Vi。在 FM 部分和 DNN 部分,这一块是共享权重的,对同一个特征来说,得到的 Vi 是相同的。

有关模型具体如何操作,我们可以通过代码来进一步加深认识。

3、相关知识

我们先来讲两个代码中会用到的相关知识吧,代码是参考的 github 上星数最多的 DeepFM 实现代码。

Gini Normalization

代码中将 CTR 预估问题设定为一个二分类问题,绘制了 Gini Normalization 来评价不同模型的效果。这个是什么东西,不太懂,百度了很多,发现了一个比较通俗易懂的介绍。



假设我们有下面两组结果,分别表示预测值和实际值:

predictions = [0.9, 0.3, 0.8, 0.75, 0.65, 0.6, 0.78, 0.7, 0.05, 0.4, 0.4, 0.05, 0.5, 0.1, 0.1]
actual = [1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0]

然后我们将预测值按照从小到大排列,并根据索引序对实际值进行排序:

Sorted Actual Values [0, 0, 0, 0, 1, 0, 0, 0, 1, 1, 0, 1, 0, 1, 1]

然后,我们可以画出如下的图片:

接下来我们将数据 Normalization 到 0,1 之间。并画出 45 度线。

橙色区域的面积,就是我们得到的 Normalization 的 Gini 系数。

这里,由于我们是将预测概率从小到大排的,所以我们希望实际值中的 0 尽可能出现在前面,因此 Normalization 的 Gini 系数越大,分类效果越好。

embedding_lookup

在 tensorflow 中有个 embedding_lookup 函数,我们可以直接根据一个序号来得到一个词或者一个特征的 embedding 值,那么他内部其实是包含一个网络结构的,如下图所示:

假设我们想要找到 2 的 embedding 值,这个值其实是输入层第二个神经元与 embedding 层连线的权重值。

之前有大佬跟我探讨 word2vec 输入的问题,现在也算是有个比较明确的答案,输入其实就是 one-hot Embedding,而 word2vec 要学习的是 new Embedding。

4、代码解析

好,一贯的风格,先来介绍几个地址:
原代码地址:https://github.com/ChenglongChen/tensorflow-DeepFM
本文代码地址:https://github.com/princewen/tensorflow_practice/tree/master/Basic-DeepFM-model
数据下载地址:https://www.kaggle.com/c/porto-seguro-safe-driver-prediction

好了,话不多说,我们来看看代码目录吧,接下来,我们将主要对网络的构建进行介绍,而对数据的处理,流程的控制部分,相信大家根据代码就可以看懂。

项目结构

项目结构如下:

其实还应该有一个存放 data 的路径。config.py 保存了我们模型的一些配置。DataReader 对数据进行处理,得到模型可以使用的输入。DeepFM 是我们构建的模型。main 是项目的入口。metrics 是计算 normalized gini 系数的代码。

模型输入

模型的输入主要有下面几个部分:

self.feat_index = tf.placeholder(tf.int32,
                                 shape=[None,None],
                                 name='feat_index')
self.feat_value = tf.placeholder(tf.float32,
                               shape=[None,None],
                               name='feat_value')

self.label = tf.placeholder(tf.float32,shape=[None,1],name='label')
self.dropout_keep_fm = tf.placeholder(tf.float32,shape=[None],name='dropout_keep_fm')
self.dropout_keep_deep = tf.placeholder(tf.float32,shape=[None],name='dropout_deep_deep')

feat_index 是特征的一个序号,主要用于通过 embedding_lookup 选择我们的 embedding。feat_value 是对应的特征值,如果是离散特征的话,就是 1,如果不是离散特征的话,就保留原来的特征值。label 是实际值。还定义了两个 dropout 来防止过拟合。

权重构建

权重的设定主要有两部分,第一部分是从输入到 embedding 中的权重,其实也就是我们的 dense vector。另一部分就是深度神经网络每一层的权重。第二部分很好理解,我们主要来看看第一部分:

#embeddings
weights['feature_embeddings'] = tf.Variable(
    tf.random_normal([self.feature_size,self.embedding_size],0.0,0.01),
    name='feature_embeddings')
weights['feature_bias'] = tf.Variable(tf.random_normal([self.feature_size,1],0.0,1.0),name='feature_bias')

weights[‘feature_embeddings’] 存放的每一个值其实就是 FM 中的 vik,所以它是 N * F * K 的。其中 N 代表数据量的大小,F 代表 feture 的大小 (将离散特征转换成 one-hot 之后的特征总量),K 代表 dense vector 的大小。

weights[‘feature_bias’] 是 FM 中的一次项的权重。

Embedding part

这个部分很简单啦,是根据 feat_index 选择对应的 weights[‘feature_embeddings’] 中的 embedding 值,然后再与对应的 feat_value 相乘就可以了:

# model
self.embeddings = tf.nn.embedding_lookup(self.weights['feature_embeddings'],self.feat_index) # N * F * K
feat_value = tf.reshape(self.feat_value,shape=[-1,self.field_size,1])
self.embeddings = tf.multiply(self.embeddings,feat_value)

FM part

首先来回顾一下我们之前对 FM 的化简公式,之前去今日头条面试还问到过公式的推导。

所以我们的二次项可以根据化简公式轻松的得到,再加上我们的一次项,FM 的 part 就算完了。同时更为方便的是,由于权重共享,我们这里可以直接用 Embedding part 计算出的 embeddings 来得到我们的二次项:

# first order term
self.y_first_order = tf.nn.embedding_lookup(self.weights['feature_bias'],self.feat_index)
self.y_first_order = tf.reduce_sum(tf.multiply(self.y_first_order,feat_value),2)
self.y_first_order = tf.nn.dropout(self.y_first_order,self.dropout_keep_fm[0])

# second order term
# sum-square-part
self.summed_features_emb = tf.reduce_sum(self.embeddings,1) # None * k
self.summed_features_emb_square = tf.square(self.summed_features_emb) # None * K

# squre-sum-part
self.squared_features_emb = tf.square(self.embeddings)
self.squared_sum_features_emb = tf.reduce_sum(self.squared_features_emb, 1)  # None * K

#second order
self.y_second_order = 0.5 * tf.subtract(self.summed_features_emb_square,self.squared_sum_features_emb)
self.y_second_order = tf.nn.dropout(self.y_second_order,self.dropout_keep_fm[1])

DNN part

DNNpart 的话,就是将 Embedding part 的输出再经过几层全链接层:

# Deep component
self.y_deep = tf.reshape(self.embeddings,shape=[-1,self.field_size * self.embedding_size])
self.y_deep = tf.nn.dropout(self.y_deep,self.dropout_keep_deep[0])

for i in range(0,len(self.deep_layers)):
    self.y_deep = tf.add(tf.matmul(self.y_deep,self.weights["layer_%d" %i]), self.weights["bias_%d"%I])
    self.y_deep = self.deep_layers_activation(self.y_deep)
    self.y_deep = tf.nn.dropout(self.y_deep,self.dropout_keep_deep[i+1])

最后,我们要将 DNN 和 FM 两部分的输出进行结合:

concat_input = tf.concat([self.y_first_order, self.y_second_order, self.y_deep], axis=1)

损失及优化器

我们可以使用 logloss(如果定义为分类问题),或者 mse(如果定义为预测问题),以及多种的优化器去进行尝试,这些根据不同的参数设定得到:

# loss
if self.loss_type == "logloss":
    self.out = tf.nn.sigmoid(self.out)
    self.loss = tf.losses.log_loss(self.label, self.out)
elif self.loss_type == "mse":
    self.loss = tf.nn.l2_loss(tf.subtract(self.label, self.out))
# l2 regularization on weights
if self.l2_reg > 0:
    self.loss += tf.contrib.layers.l2_regularizer(
        self.l2_reg)(self.weights["concat_projection"])
    if self.use_deep:
        for i in range(len(self.deep_layers)):
            self.loss += tf.contrib.layers.l2_regularizer(
                self.l2_reg)(self.weights["layer_%d" % I])


if self.optimizer_type == "adam":
    self.optimizer = tf.train.AdamOptimizer(learning_rate=self.learning_rate, beta1=0.9, beta2=0.999,
                                            epsilon=1e-8).minimize(self.loss)
elif self.optimizer_type == "adagrad":
    self.optimizer = tf.train.AdagradOptimizer(learning_rate=self.learning_rate,
                                               initial_accumulator_value=1e-8).minimize(self.loss)
elif self.optimizer_type == "gd":
    self.optimizer = tf.train.GradientDescentOptimizer(learning_rate=self.learning_rate).minimize(self.loss)
elif self.optimizer_type == "momentum":
    self.optimizer = tf.train.MomentumOptimizer(learning_rate=self.learning_rate, momentum=0.95).minimize(
        self.loss)
		

模型效果

前面提到了,我们用 logloss 作为损失函数去进行模型的参数更新,但是代码中输出了模型的 Normalization 的 Gini 值来进行模型评价,我们可以对比一下 (记住,Gini 值越大越好呦):

好啦,本文只是提供一个引子,有关 DeepFM 更多的知识大家可以更多的进行学习呦。

参考资料

1、http://www.360doc.com/content/17/0315/10/10408243_637001469.shtml
2、https://blog.csdn.net/u010665216/article/details/78528261


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