推荐模型DeepCrossing: 原理介绍与TensorFlow2.0实现

DeepCrossing是在AutoRec之后,微软完整的将深度学习应用在推荐系统的模型。其应用场景是搜索推荐广告中,解决了特征工程,稀疏向量稠密化,多层神经网路的优化拟合等问题。所使用的特征在论文中描述为两个大类数值型(文中couting feature)和类别型。如下图
image

对于数值型特征可以直接拼接在Embedding向量之后,类别多的特征需要经过Embedding过程。要多说一句,数值的统计特征包括了过去广告点击率,这个在以后实际应用中设计特征可以考虑。

其优化目标就是广告的点击率,即CTR,click through rate。其效果可以看论文的实现对比部分。这里简单介绍,

  1. 与传统模型DSSM进行对比;
  2. 与线上生产环境的模型进行对比;
  3. counting feature的重要性对比。

2. 算法架构

网络架构解决的问题是:

  • 离散特征过于稀疏的高维灾难问题;
  • 特征交叉自动组合问题;
  • 输出层中如何优化目标的设计问题。

网络架构图

image
总共包含Embedding,Stacking,Multiple ResidualUnits和Scoring 层
下面根据网络结构图分别说明各个模块的作用。

Embedding层

本层主要作用是降维。使用的是一个单层神经网路,具有如下形式,
image
针对每个类别的特征都有一个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

首先是残差单元结构为:
image

这个残差模块与ResNet的不同是没有使用卷积操作,而是ReLu与线性部分的前向传播加(element-wise add)上输入再经过ReLu得到输出。
image
作者通过各种类型各种大小的实验发现,DeepCrossing具有很好的鲁棒性,推测可能是因为残差结构能起到类似于正则的效果,残差结构能更敏感的捕获输入输出之间的信息差 ,引入特征的交叉和非线性。

残差网络解决的问题:

  • 网络深度增加后,过拟合,通过残差网络的短路操作,起到正则化的作用,减少过拟合;
  • 网络深度增加后,梯度消失,所以使用ReLu激活函数,且短路操作相当于将上上层的梯度传递到下层,收敛更快。

原结构使用了五个残差块,每个残差块的维度是512,512,256,128,64。

Scoring Layer

计算得分,即目标函数(objective function)的应用层。
image

二分类使用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模型只具备二阶特征交叉能力来说,这模型可以更深层次的交叉,且独立特征之外,没有人工设计的组合特征。

posted @ 2021-03-14 12:45  道之有道  阅读(1035)  评论(0编辑  收藏  举报