推荐模型DeepCrossing: 原理介绍与TensorFlow2.0实现
本文主要介绍了微软在2016年所公开的模型DeepCrossing,其将多层残差网络引入神经网络模型,以提供更为丰富的交叉特征的原理和实现部分。
DeepCrossing是在AutoRec之后,微软完整的将深度学习应用在推荐系统的模型。其应用场景是搜索推荐广告中,解决了特征工程,稀疏向量稠密化,多层神经网路的优化拟合等问题。所使用的特征在论文中描述为两个大类数值型(文中couting feature)和类别型。如下图
对于数值型特征可以直接拼接在Embedding向量之后,类别多的特征需要经过Embedding过程。要多说一句,数值的统计特征包括了过去广告点击率,这个在以后实际应用中设计特征可以考虑。
其优化目标就是广告的点击率,即CTR,click through rate。其效果可以看论文的实现对比部分。这里简单介绍,
- 与传统模型DSSM进行对比;
- 与线上生产环境的模型进行对比;
- counting feature的重要性对比。
2. 算法架构
网络架构解决的问题是:
- 离散特征过于稀疏的高维灾难问题;
- 特征交叉自动组合问题;
- 输出层中如何优化目标的设计问题。
网络架构图
总共包含Embedding,Stacking,Multiple ResidualUnits和Scoring 层。
下面根据网络结构图分别说明各个模块的作用。
Embedding层
本层主要作用是降维。使用的是一个单层神经网路,具有如下形式,
针对每个类别的特征都有一个Embedding操作,但是如果由于高维基数特征太大了,对于目标相关部分排序较低的进行衍生构造。也能降低Embedding部分的参数数量提高训练速度例如,CampaignID
十分巨大,但对于点击率排序后10000以外的使用衍生特征来处理,最后一个编号为10000,且添加衍生为将所有ID对应的历史点击率组合成10001维的稠密矩阵,各个元素分别为对应ID的历史CTR,最后一个元素为剩余ID的平均CTR。通过降维引入衍生特征的方式,可以有效的减少高基数特征带来的参数量剧增问题。
其中,每个特征的维度压缩到256维,如果小于256维则直接连接到Stacking层。
Stacking层
主要是将Embedding部分的各个特征的向量进行拼接,小于256维度或者数值型特征不需要Embedding的直接拼接(如Feature #2
)。
得到\(X^O=[X^O_0, X^O_1,…,X^O_k]\)的拼接向量。
Residual Layers
首先是残差单元结构为:
这个残差模块与ResNet的不同是没有使用卷积操作,而是ReLu与线性部分的前向传播加(element-wise add)上输入再经过ReLu得到输出。
作者通过各种类型各种大小的实验发现,DeepCrossing具有很好的鲁棒性,推测可能是因为残差结构能起到类似于正则的效果,残差结构能更敏感的捕获输入输出之间的信息差 ,引入特征的交叉和非线性。
残差网络解决的问题:
- 网络深度增加后,过拟合,通过残差网络的短路操作,起到正则化的作用,减少过拟合;
- 网络深度增加后,梯度消失,所以使用ReLu激活函数,且短路操作相当于将上上层的梯度传递到下层,收敛更快。
原结构使用了五个残差块,每个残差块的维度是512,512,256,128,64。
Scoring Layer
计算得分,即目标函数(objective function)的应用层。
二分类使用Sigmoid函数,多分类使用softmax函数。
3. 代码实现
基于TensorFlow2.0 和Keras API来实现模型结构。
根据上节每个模块,需要分别实现各个模型的结构,然后组合在一个即可。(原始论文的部分使用的CNTK实现且GPU加速,获得了效率的显著提高)
导包
import numpy as np
import pandas as pd
import tensorflow as tf
from tensorflow import keras
from sklearn.model_selection import train_test_split
import gc
Embedding模块
这里自己实现,不使用tf自带的embedding。
class EmbeddingBlock(keras.layers.Layer):
def __init__(self, emb_dim, input_shapes):
super(EmbeddingBlock, self).__init__()
self.input_shapes = input_shapes
self.listlayer = []
for shape in self.input_shapes:
self.listlayer.append(keras.layers.Dense(emb_dim, input_shape=(shape, ), activation=\'relu\'))
def call(self, X):
stacking = []
last_col = 0
for idx, shape in enumerate(self.input_shapes): # 离散值的onehot维度部分
stacking.append(self.listlayer[idx](X[:, last_col:last_col+shape]))
last_col += shape
stacking.append(X[:, last_col:]) # 连续值
X = tf.concat(stacking, axis=1)
return X
这里主要是将输入X的前一部分作为需要embedding的部分,后部分作为不需要embedding的部分,然后并行运算,并最后连接在一起。
定义残差层
这里分为两个模块分别定义,没有使用函数,而是直接继承Keras的API。
class Residual(keras.models.Model):
def __init__(self,hidden_units=None, feature_dim=None) -> None:
super(Residual, self).__init__()
self.relu_layer = keras.layers.Dense(units=hidden_units, input_shape=(feature_dim,), activation=\'relu\')
self.linear_layer = keras.layers.Dense(units=feature_dim, input_shape=(hidden_units,)) # 为了后续相加,要回归原来的维度
def call(self, X):
X1 = self.relu_layer(X)
X2 = self.linear_layer(X1)
y = keras.activations.relu(tf.add(X, X2)) # or tf.nn.relu, X+X2
return y
class ResidualLayer(keras.layers.Layer):
def __init__(self, units_list=None, feature_dim=None) -> None:
super(ResidualLayer, self).__init__()
self.listlayer = []
for unit in units_list:
self.listlayer.append(Residual(unit, feature_dim))
def call(self, X):
for layer in self.listlayer:
X = layer(X)
return X
串联整个模型DeepCrossing
class DeepCrossing(keras.models.Model):
def __init__(self, emb_dim, emb_shapes, residual_units, feature_dims) -> None:
super().__init__()
self.emb = EmbeddingBlock(emb_dim=emb_dim, input_shapes=emb_shapes)
self.stacking_dim = emb_dim*len(emb_shapes) + feature_dims - np.array(emb_shapes).sum()
self.residual_layer = ResidualLayer(residual_units, self.stacking_dim)
self.score_layer = keras.layers.Dense(units=1, input_shape=(self.stacking_dim,), activation=\'sigmoid\')
def call(self, X):
X = self.emb(X)
X = self.residual_layer(X)
X = self.score_layer(X)
return X
4. 数据验证
说个小插曲,使用的数据是MovieLens,在train_test_split的时候会有一个报错 大概是MemoryError的问题,因为使用的列比较多。后来就抽取了一千条数据来验证模型。估计使用迭代器和tf.data的生成器会比较好操作。
合并数据
rating = pd.read_csv(\'./ratings.dat\', sep=\'::\', names=[\'UserID\', \'MovieID\', \'Rating\', \'Timestamp\'])
user = pd.read_csv(\'./users.dat\', sep=\'::\', names=[\'UserID\', \'Gender\', \'Age\', \'Occupation\', \'ZipCode\'])
movie = pd.read_csv(\'./movies.dat\', sep=\'::\', names=[\'MovieID\', \'Title\', \'Genres\'])
data = pd.merge(left=rating, right=user, how=\'inner\', on=\'UserID\')
data = data.merge(movie, on=\'MovieID\')
构造标签
为了保证正负样本相对平衡,契合评分层的二分类模型,这里直接将3分以上的认为是正样本(也可以定义为多分类 使用softmax层作为评分层)。
data[\'label\'] = (data[\'Rating\'] > 3).astype(np.int)
处理数据
把电影名字的时间抽取出来
data[\'Year\'] = data[\'Title\'].apply(lambda x: x[-5:-1]).astype(int)
data[\'Title\'] = data[\'Title\'].apply(lambda x: x[:-7])
为了方便,不使用Title作为特征(否则使用Token然后Embedding处理也是很好的)。
统计各个特征数量,以便确定谁要Embedding层:
tmp = data.copy()
for col in [\'Gender\', \'Occupation\', \'ZipCode\', \'Title\', \'Genres\']:
print(col, tmp[col].unique().shape[0])
=============================================
Gender 2
Occupation 21
ZipCode 3439
Title 3664
Genres 301
oenhot处理并合并:
dummy_col = [\'ZipCode\', \'Genres\', \'Gender\', \'Occupation\']
tmp1 = pd.get_dummies(tmp[dummy_col],
prefix=dummy_col,
columns=dummy_col)
resDF = pd.concat([tmp1, tmp[[ \'Age\', \'Year\',\'Timestamp\',\'UserID\', \'MovieID\', \'label\']] ], axis=1)
构造Dataset
X = resDF.iloc[:1000,:-3]
y = resDF.iloc[:1000, -1]
num_or_size_splits = [int(y.shape[0]*0.9), int(y.shape[0]*0.1 + 0.5)]
num_or_size_splits # [900, 100]
X = tf.constant(X.values, dtype=tf.float32)
y = tf.constant(y.to_list(), dtype=tf.float32)
X_train, X_test = tf.split(X, num_or_size_splits, axis=0)
y_train, y_test = tf.split(y, num_or_size_splits, axis=0)
BATCH = 128
train_ds = tf.data.Dataset.from_tensor_slices((X_train, y_train)).batch(BATCH).shuffle(2).repeat()
test_ds = tf.data.Dataset.from_tensor_slices((X_test, y_test)).batch(32)
训练模型
net = DeepCrossing(emb_dim=128,
emb_shapes=[3439, 301],
residual_units=[256, 128, 64],
feature_dims=len(resDF.columns)-3)
net.compile(loss=\'binary_crossentropy\',
optimizer=keras.optimizers.Adam(lr=0.01),
metrics=[\'accuracy\'])
net.fit(train_ds, epochs=5, steps_per_epoch=X.shape[0]//BATCH)
Train for 7 steps
Epoch 1/5
7/7 [==============================] - 5s 690ms/step - loss: 160474554.2098 - accuracy: 0.6192
Epoch 2/5
7/7 [==============================] - 0s 7ms/step - loss: 30519627.1429 - accuracy: 0.7679
Epoch 3/5
7/7 [==============================] - 0s 8ms/step - loss: 6237030.3564 - accuracy: 0.8692
Epoch 4/5
7/7 [==============================] - 0s 7ms/step - loss: 6711226.7366 - accuracy: 0.7461
Epoch 5/5
7/7 [==============================] - 0s 7ms/step - loss: 3462408.0007 - accuracy: 0.7345
测试集验证:
loss, acc = net.evaluate(test_ds)
print(\'loss: \', loss, \' acc: \', acc)
=================================
loss: 1159263.28125 acc: 0.93
4. 小结
Deep Crossing模型没有引入现代流行的注意力机制,序列模型的特殊结构,但是相比FM,FFM模型只具备二阶特征交叉能力来说,这模型可以更深层次的交叉,且独立特征之外,没有人工设计的组合特征。