Tensorflow实现DeepFM(代码分析)

  • Post author:
  • Post category:其他


参考:

源码:

https://github.com/ChenglongChen/tensorflow-DeepFM

原文下载:

https://arxiv.org/abs/1703.04247

参看原文我们可以发现,deepfm由两部分组成:FM、deep,两部分共享一套输入,下面的介绍也从这两个方面展开。

1.FM部分

fm部分我的上篇文章

https://blog.csdn.net/guanbai4146/article/details/80139806

已经介绍,这里简单提一下。

从上面公式(这里提一下
w_{i},w_{ij}
是两套不同的参数,对应于下面的weights[“feature_bias”]和weights[“feature_embeddings”])可以看到,原始fm由两部分组成,一阶原始特征+二阶特征组合+偏置项,强调的是特征组合的影响,有个点在于如何训练参数。

1.1 稀疏特征输入如何训练参数

首先,数值型特征可以直接输入模型(除必要的变换操作外),fm主要解决的是稀疏特征的问题,这里指的主要是类别型特征,比如性别。这类特征输入模型的时候需要做特征展开,也就是一列展开成两列了。具体可以参考上一篇博客的题外话。

这里要提一下这个点,因为这贯穿整个实现过程。这里性别就是一个field,而展开后的两列就是feature(下面会用特征域(field)和特征(feature)表示)。

1.2 FM结构

上图截自原文。

主要分四个部分:sparse feature层、dense embedding层、fm层、输出层,图中不同的符号和线段表示了不同的计算逻辑。

虽然在上一篇文章已经介绍了,但这里还需要提一下,fm的特点是引入了

隐向量的概念

假设有三个变量
x_{1},x_{2},x_{3}
,那么他们交叉就有三个组合
w_{12}x_{1}x_{2},w_{13}x_{1}x_{3},w_{23}x_{2}x_{3}
,其中
w
是组合特征域的权重参数,这里我直接用特征域来表述,方便理解。如果这个这三个特征域都是类别特征,很容易出现什么现象?

比如:
x_{1}=[1,0,0],x_{2}=[0,1,0]==>x_{1}*x_{2}=0
,向量內积为0那么使用梯度下降寻参的时候就没办法训练得到
w_{12}
的参数值,原本输入特征就非常稀疏,导致问题更加严峻。所以这里引入隐向量(latent vector)的概念解决稀疏性问题,

具体来说,令
w_{12}=v _{1} * v _{2}
(这里
v _{1},v _{2}
都是向量,维度为K,K的大小超参设定),那么即使
x _{1},x _{2}
正交
v _{1}
也可以在后面
x_{1} * x_{3}
中得到训练,极大缓解了数据稀疏带来的问题。

所以,这里的特征参数都会用一个K维的隐向量表示,了解了上述介绍后我们再看怎么实现(以下所有代码均来自源码DeepFM文件中,这里对每行代码做注释和个人理解的说明):

首先声明两个占位符,这些占位符会在训练的时候由数据填充

# None * F, None * 特征域大小,特征索引占位符
self.feat_index = tf.placeholder(tf.int32, shape=[None, None],name="feat_index")
# None * F, 特征值占位符(输入的样本数据)
self.feat_value = tf.placeholder(tf.float32, shape=[None, None],name="feat_value") 

这里重点说明这两个占位符,前面我们知道特征域(field)会被展开拉直成特征(features)集,比如现在有两个特征域,年龄和性别,会被展开成【年龄、男、女】,对应类别特征值用01表示,看下面例子

年龄 性别
25
26
24

那么这里field有2个【年龄、性别】,feature有三个值【年龄、男、女】,预处理部分会构建一个特征索引表【年龄:0,男:1,女:2】

先说feat_index数据形式,在预处理中会将原始样本值处理成索引,所以每个样本表的索引表就是:

年龄 性别
0 1
0 2
0 1

feat_value就是输入的样本值(类别特征数值化,对应的feature上取值设为1):

年龄
26 1
26 1
24 1

注意:这里每行样本都是2列(和域维度一样),虽然类别型特征对应的feature不同,比如第一行样本第二列表示男这个特征,而第二行第二列表示女这个特征。而他们的权重会通过feat_index的每行的索引去关联。

接下来介绍各层代码的开发。

1.2.1从sparse feature层到dense embedding层:

# None * F * K  样本数 * 特征域个数 * 隐向量长,最后得到的embeddings维度(none, none, 8)
self.embeddings = tf.nn.embedding_lookup(self.weights["feature_embeddings"], self.feat_index)
# feat_value:(?, 39, 1)输入特征值的维度
feat_value = tf.reshape(self.feat_value, shape=[-1, self.field_size, 1])
# (?, 39, 8) multiply两个矩阵的对应元素各自相乘(参数权重和对应的特征值相乘)w * x
self.embeddings = tf.multiply(self.embeddings, feat_value)

注:解释的时候都以一条样本输入说明,代码注释中的?和None都表示样本数

第一行解释:

self.weights["feature_embeddings"]是特征的权重参数,注意这里是特征的权重,参看之前那篇文章关于fm和ffm区别的叙述中,原始的fm模型对每个特征都是有一个特征权重的(注意是特征不是特征域)。具体定义如下
# feature_size:特征长度,embedding_size:隐向量长度
weights["feature_embeddings"] = tf.Variable(tf.random_normal([self.feature_size, self.embedding_size], 0.0, 0.01),name="feature_embeddings")
embedding_lookup(params, ids)表示按后面ids从params里面选择对应索引的值,也就是说从按照特征索引(feat_index)从特征权重集(weights[feature_embeddings])中选择对应的特征权重。仍然以上面的例子说明,

weights[feature_embeddings]就是【年龄(0)的权重,男(1)的权重,女(2)的权重】,以第一个样本索引【0,1】为例,就是选出了【年龄(0)的权重,男(1)的权重】

第二到三行解释:

第二行主要是二维向三维的转换,变成:样本数 * 特征域数 * 1,以一条样本为例就是上面的feat_value的第一行【25,0】

第三行是矩阵对应元素相乘,也就是每个特征权重乘以样本特征值,仍然以第一条样本为例就是【25 * 年龄的权重,0 * 男的权重】

从图中也能看出这两层之间也就是权重连接的关系,维度大小并没有变化。

1.2.2 FM层:

从结构图中我们也可以看到fm的数据源来自前面两层,sparse和dense都有数据输入。

从前文和公式我们也能知道,fm分为一阶项(
w_{i} * x_{i}
)和二阶项(
w_{ij} * x_i * x_j
)两部分,

先说一阶项:

# None * 特征域数 * 1
self.y_first_order = tf.nn.embedding_lookup(self.weights["feature_bias"], self.feat_index)
# None * 特征域数 axis=2最内层元素维度的加和
self.y_first_order = tf.reduce_sum(tf.multiply(self.y_first_order, feat_value), 2)
# None * 特征域数
# 第一层dropout
self.y_first_order = tf.nn.dropout(self.y_first_order, self.dropout_keep_fm[0])

第一行和上面类似,就是选择对应输入特征的权重值(
w_{i} * x_{i}
中的
w_{i}
),不做赘述,其中weights[“feature_bias”]的定义如下:

weights["feature_bias"] = tf.Variable(tf.random_uniform([self.feature_size, 1], 0.0, 1.0), name="feature_bias")

接下来第二行和第三行就是做对应的运算(
w_{i} * x_{i}
)和添加dropout层。

二阶项:

# sum_square part  和平方,None * K 就是w_1 * x_1... + w_n * x_n...
self.summed_features_emb = tf.reduce_sum(self.embeddings, 1)
self.summed_features_emb_square = tf.square(self.summed_features_emb)  # None * K  所有特征加和平方 (w_1 * x_1 + w_n * x_n...)^2

# square_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 (和平方-平方和) / 2对应元素相减,得到(x_1 * y_1 + ... + x_n * y_n)二阶特征交叉,二阶项本质是各向量交叉后求和的值,所以维度是K
self.y_second_order = 0.5 * tf.subtract(self.summed_features_emb_square, self.squared_sum_features_emb)  # None * K
self.y_second_order = tf.nn.dropout(self.y_second_order, self.dropout_keep_fm[1])  # None * K fm第二层的dropout

代码就是实现下面的计算逻辑:

\sum _{i=1}^{n-1}\sum _{j=i+1}^n<v_{i},v_{j}>x_{i}x_{j} =...=0.5*\sum _{f=1}^{k}((\sum _{i=1}^{n}v_{i,f}x_{i})^2-(\sum _{i=1}^{n}v_{i,f}^{2}x_{i}^{2}))

2. Deep部分

模型结构如下:

原始的deepfm深度部分使用的就是普通的dnn结构,如下代码:

# None * (F*K) (样本数, 特征域长度 * 隐向量长度)
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]) # None * layer[i] * 1 (wx+b)
    if self.batch_norm:  # 对参数批正则化
        self.y_deep = self.batch_norm_layer(self.y_deep, train_phase=self.train_phase, scope_bn="bn_%d" %i) # None * layer[i] * 1
    self.y_deep = self.deep_layers_activation(self.y_deep)  # 激活层
    # dropout at each Deep layer 每层都有一个dropout参数
    self.y_deep = tf.nn.dropout(self.y_deep, self.dropout_keep_deep[1+i])

首先将dense层输出进行reshape维度变换(将特征域对应的向量拉直平铺),添加dropout层

深度结构一共两层,循环部分分别添加以下步骤:
w*x+b
、批正则化(可选)、激活层、dropout层,正常的dnn结构。

上面使用的权重参数(
w,b
),声明如下:

# num_layer:dnn层数
num_layer = len(self.deep_layers)
# 特征域个数 * 隐向量长度
input_size = self.field_size * self.embedding_size
# 标准差glorot设定参数的标准,标准差=sqrt(2/输入维度+输出维度)
glorot = np.sqrt(2.0 / (input_size + self.deep_layers[0]))
# layer_0是和dense层输出做计算,所以参数维度是[特征域个数*隐向量长]
weights["layer_0"] = tf.Variable(np.random.normal(loc=0, scale=glorot, size=(input_size, self.deep_layers[0])), dtype=np.float32)
weights["bias_0"] = tf.Variable(np.random.normal(loc=0, scale=glorot, size=(1, self.deep_layers[0])), dtype=np.float32)  # 1 * layers[0]

for i in range(1, num_layer):
    glorot = np.sqrt(2.0 / (self.deep_layers[i-1] + self.deep_layers[i]))
    # layer_1第2层 32*32, layers[i-1] * layers[i]
    weights["layer_%d" % i] = tf.Variable(np.random.normal(loc=0, scale=glorot, size=(self.deep_layers[i-1], self.deep_layers[i])), dtype=np.float32)
    # 1 * layer[i]
    weights["bias_%d" % i] = tf.Variable(np.random.normal(loc=0, scale=glorot, size=(1, self.deep_layers[i])), dtype=np.float32)

按空行分界,先说空行上半部分

deep_layer列表存储的是两层dnn的输出维度,glorot是参数初始化的一种理论,这里不赘述。

深度部分的输入就是dense embedding层的输出,第一层计算的时候进行了reshape拉直处理,所以参数
w
的输入大小(input_size)=特征域个数 * 隐向量长度,这里就是分别声明
w
权重和偏置项。

再说空行下半部分,这里声明的时候range是从1开始的,所以这里是从dnn的第二层开始声明对应的参数变量(
w,b
,每一层输出维度的超参存储在deep_layer中)。

以上,fm部分和dnn部分都已经解释清楚了,接下来就是收尾。

3.FM和Deep的组合收尾

先看下整体的结构图(以上结构图均截自原文):

最后这部分就是将fm和deep部分组合到一起,也就是低阶特征和高阶特征的组合,最后计算输出,先看下代码:

# fm的一阶项、二阶项、深度输出项拼接,变成 样本数 * 79
concat_input = tf.concat([self.y_first_order, self.y_second_order, self.y_deep], axis=1)
# 最后全连接层权重 * 输出 + 偏置
self.out = tf.add(tf.matmul(concat_input, self.weights["concat_projection"]), self.weights["concat_bias"])

第一行就是一阶项+二阶项(fm部分)、deep部分拼接(拉直铺平)

第二行就是最后的全连接层,计算逻辑就是
w*x+b
。至此,deepfm结构实现完毕。

4.总结

deepfm已经在工业界得到了普遍的应用,写这篇分享的目的也是希望自己能够从理论到实践有一个更深入的理解,不当之处还望多多指正。



版权声明:本文为guanbai4146原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。