本章节从推荐系统模型搭建基础和DeepCrossing原理讲解及实操两方面展开。
一、推荐系统模型搭建基础
1. Keras搭建模型
keras搭建模型主要有两种模式,一种是Sequential API,另外一种是Functional API。前者主要是通过层的有序堆叠形成一个模型,在大多数情况下可以快速的搭建一个模型,但是搭建的模型更适合简单的堆叠模型,对于复杂模型(多输入、多输出、共享层)的搭建就比较困难,所以后者函数式API可以更加灵活的搭建复杂网络,函数式API搭建模型是通过创建层的实例并将将层与层之间连接在一起,最后只需要指定模型的输入和输出就可以完成模型的搭建,不同层的实例可以表示不同的操作,搭建模型的时候只需要考虑层与层之间的关系,以及复杂层的搭建就可以很方便的搭建起一个复杂网络。
Sequential API Demo
import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers
# 定义一个3层的序列模型
model = keras.Sequential(
[
layers.Dense(2, activation="relu", name="layer1"),
layers.Dense(3, activation="relu", name="layer2"),
layers.Dense(4, name="layer3"),
]
)
# 定义数据
x = tf.random.normal((3, 4))
y = model(x)
Functional API Demo
# 定义输入层(可以看成数据层)
inputs = keras.Input(shape=(784,))
# 定义模型逻辑层(将输入数据进行转换)
x = layers.Dense(64, activation="relu")(inputs)
# 定义输出层,这里其实和其他的层没有区别,只不过是最后认定这一层作为输出层而已
outputs = layers.Dense(10)(x)
# 定义整个模型,通过制定模型的输入和输出,按照前面所说的构建模型的流程,产生最终的模型结构
model = Model(inputs=inputs,outputs=outputs)
注意:
layers.Dense(64, activation=“relu”)表示的是一个Dense层实例,括号中的参数就是创建Dense实例的参数,将inputs输入到layers.Dense(64, activation=“relu”)实例中,会自动的调用实例的__call__()方法,这样就把输入和层与层之间的逻辑给确定了。
所以函数API搭建模型的基本操作就是,将输入数据输入到层的实例中,层对象就会调用该层的call方法完成该层的计算并产生新的输出,接下来再将产生的新的输出输入到下一个层实例中产生新的输出,一直不断的构建层的实例并得到新的输出,进而构建一个复杂的模型。
二、DeepCrossing原理讲解及实操
深度学习模型中最经典的一种模型结构是Embedding+MLP。它不仅经典,还是后续实现其他深度学习模型的基础。
这里面的Embedding大家应该比较熟悉,而MLP是什么呢?它其实是 Multilayer perceptron,多层感知机的缩写。感知机是神经元的另外一种叫法,所以多层感知机就是多层神经网络。微软的DeepCrossing就是Embedding+MLP模型机构中的一种。
DeepCrossing应用场景
DeepCrossing模型应用场景是微软搜索引擎Bing中的搜索广告推荐, 用户在输入搜索词之后, 搜索引擎除了返回相关结果, 还返回与搜索词相关的广告,Deep Crossing的优化目标就是预测对于某一广告, 用户是否会点击,依然是点击率预测的一个问题。
DeepCrossing结构和原理
图1 经典的Embedding+MLP模型结构
可以看到该结构由Embedding Layer、Stacking Layer、Multiple Residual Units Layer、Scoring Layer组成。下面分别介绍各层作用。
Embedding Layer
Embedding 层就是为了把稀疏的 One-hot 向量转换成稠密的 Embedding 向量而设置的,我们需要注意的是,Embedding 层并不是全部连接起来的,而是每一个特征对应一个 Embedding 层,不同 Embedding 层之间互不干涉。
Embeding 层的结构就是 Word2vec 模型中从输入神经元到隐层神经元的部分(如图 2 红框内的部分)。参照下面的示意图,我们可以看到,这部分就是一个从输入层到隐层之间的全连接网络。
图2 Word2vec模型中Embedding层的部分
Stacking Layer
Stacking 层中文名是堆叠层,我们也经常叫它连接(Concatenate)层。它的作用比较简单,就是把不同的 Embedding 特征和数值型特征拼接在一起,形成新的包含全部特征的特征向量。
Multiple Residual Units Layer
MLP 层就是我们开头提到的多层神经网络层,在图 1 中指的是 Multiple Residual Units 层,中文叫多层残差网络。微软在实现 Deep Crossing 时针对特定的问题选择了残差神经元。
但事实上,神经元的选择有非常多种,以 Sigmoid 函数为激活函数的神经元,以及使用 tanh、ReLU 等其他激活函数的神经元。我们具体选择哪种是一个调参的问题,一般来说,ReLU 最经常使用在隐层神经元上,Sigmoid 则多使用在输出神经元,实践中也可以选择性地尝试其他神经元,根据效果作出最后的决定。
不管选择哪种神经元,MLP 层的特点是全连接,就是不同层的神经元两两之间都有连接。
Scoring Layer
这个作为输出层,为了拟合优化目标存在。 对于CTR预估二分类问题, Scoring往往采用逻辑回归,模型通过叠加多个残差块加深网络的深度,最后将结果转换成一个概率值输出。
用于点击率预估模型的损失函数就是对数损失函数:
其中表示真实的标签(点击或未点击),表示Scoring Layer输出的结果。但是在实际应用中,根据不同的需求可以灵活替换为其他目标函数。
实操tips
在正式进入DeepCrossing代码实操前,我们先了解下代码中的几个技巧。
1. 特征较多时构建多输入模型
将输入的数据转换成字典的形式,定义输入层的时候让输入层的name和字典中特征的key一致,就可以使得输入的数据和对应的Input层对应。方便后面搭建模型。
2. 特征表示的统一
看过DeepCTR源码的人可能就会知道,项目中输入分成三大类,分别是SparseFeat, DenseFeat, VarLenSparseFeat,并且使用类进行了封装,其中也考虑到效率的问题做了一些优化。
- SparseFeat: 稀疏特征的标记,一般是用来表示id类特征
- DenseFeat: 表示数值型特征,可以是一维的也可以是多维的
- VarLenSparseFeat: 可变长的id类特征,就是id序列特征
这三类特征在实际的推荐系统应用中包含了绝大多数的特征类型,可能会有一些其他的比如图像、视频等其它特征,虽然实际可能存在,但是我感觉如果要是用这些特征就需要将其转换成向量的形式去使用,也就是DenseFeat多维度的情况。统一特征后就可以用来更好的构建输入层了。
3. 通过特征标记构造输入层
在前面的函数式API构建模型最后说到过,可以使用字典的形式构建输入,最后只要将对应Input层的名字与字典中特征的key相对应就可以。在定义Input层的时候,除了name以外还有一个重要的属性就是shape
然而所有特征Input层的shape其实只有4种情况:
- 数值特征,1维的数值特征shape=(1, )
- 多维的数值特征shape=(dimension, )
- 类别特征,shape=(1,), 为什么类别特征的shape维度是1呢,因为输入的就是一个id,在类别型特征的Input后面还需要接一个Embedding层,将id转化成稠密的向量
- 可变长的序列特征,shape=(maxlen, 1), 序列的输入往往需要定义一个最大长度,这样不至于序列长度之间相差太大,这个最大长度可以是实际数据中的最大长度,也可以是根据经验定义的最大长度。需要注意的是,序列特征中的每个元素其实也是一个id类特征,在最后转换成Embedding的时候,不是一个Embedding向量,而是一个矩阵。
上面说了Input层的四种情况有什么用呢?
当特征维度特别多的时候,比如成百上千维特征,如果没有这种标记的话,我们就需要挨个定义每个特征对应的Input层,当然有人可能会说可以提前分组然后再给不同的Input层,其实本质上是一样的。
4. Embedding层的注意点
在构建模型的时候Embedding相关的需要注意两点:
- Embedding层的参数问题
- Embedding层之间的拼接问题
上面在说了类别特征和可变长的序列特征,在这两个Input层之后都需要将其转化成Embedding向量或者Embedding矩阵,在keras中转化成Embedding向量和Embedding矩阵只是相差一个参数的问题。
- 如何在linear层引入onehot特征
如果要将类别型特征的onehot表示输入到linear层中,第一个想法就是直接把特征转换成onehot向量不就行了吗?的确是可以,但是我们知道在推荐场景中id类特征是一等公民,在实际的场景中如果将所有的特征都转换成onehot类型,维度很可能超出想象。这里有个更好的做法就是,给id类特征转换成一个一维的Embedding矩阵,只需要将这个Embedding保存下来,然后有id类特征输入直接在Embedding中进行查找,找到那个对应的值其实就是onehot向量已经乘完权重的值,因为onehot向量只有0和1,只有非零的才是有效的,而1乘以权重还是权重本身,所以这种方式来获取onehot向量中的非零元素的值,相比直接使用onehot向量乘以一个权重更好一些。
代码实操
1. 数据读取及特征预处理
# 读取数据
data = pd.read_csv('./data/criteo_sample.txt')
# 划分dense和sparse特征
columns = data.columns.values
dense_features = [feat for feat in columns if 'I' in feat]
sparse_features = [feat for feat in columns if 'C' in feat]
# 简单的数据预处理
train_data = data_process(data, dense_features, sparse_features)
train_data['label'] = data['label']
# 将特征做标记
dnn_feature_columns = [SparseFeat(feat, vocabulary_size=data[feat].nunique(),embedding_dim=4)
for feat in sparse_features] + [DenseFeat(feat, 1,)
for feat in dense_features]
定义特征标记
from collections import namedtuple
# 使用具名元组定义特征标记
SparseFeat = namedtuple('SparseFeat', ['name', 'vocabulary_size', 'embedding_dim'])
DenseFeat = namedtuple('DenseFeat', ['name', 'dimension'])
VarLenSparseFeat = namedtuple('VarLenSparseFeat', ['name', 'vocabulary_size', 'embedding_dim', 'maxlen'])
2. 构建DeepCrossing模型
# 构建DeepCrossing模型
history = DeepCrossing(dnn_feature_columns)
DeepCrossing函数
def DeepCrossing(dnn_feature_columns):
# 构建输入层,即所有特征对应的Input()层,这里使用字典的形式返回,方便后续构建模型
dense_input_dict, sparse_input_dict = build_input_layers(dnn_feature_columns)
# 构建模型的输入层,模型的输入层不能是字典的形式,应该将字典的形式转换成列表的形式
# 注意:这里实际的输入与Input()层的对应,是通过模型输入时候的字典数据的key与对应name的Input层
input_layers = list(dense_input_dict.values()) + list(sparse_input_dict.values())
# 构建维度为k的embedding层,这里使用字典的形式返回,方便后面搭建模型
embedding_layer_dict = build_embedding_layers(dnn_feature_columns, sparse_input_dict, is_linear=False)
#将所有的dense特征拼接到一起
dense_dnn_list = list(dense_input_dict.values())
dense_dnn_inputs = Concatenate(axis=1)(dense_dnn_list) # B x n (n表示数值特征的数量)
# 因为需要将其与dense特征拼接到一起所以需要Flatten,不进行Flatten的Embedding层输出的维度为:Bx1xdim
sparse_dnn_list = concat_embedding_list(dnn_feature_columns, sparse_input_dict, embedding_layer_dict, flatten=True)
sparse_dnn_inputs = Concatenate(axis=1)(sparse_dnn_list) # B x m*dim (n表示类别特征的数量,dim表示embedding的维度)
# 将dense特征和Sparse特征拼接到一起
dnn_inputs = Concatenate(axis=1)([dense_dnn_inputs, sparse_dnn_inputs]) # B x (n + m*dim)
# 输入到dnn中,需要提前定义需要几个残差块
output_layer = get_dnn_logits(dnn_inputs, block_nums=3)
model = Model(input_layers, output_layer)
return model
build_input_layers函数
用于构建输入层,用字典的形式返回dense和sparse数据。
def build_input_layers(feature_columns):
"""
构建输入层
param feature_columns: 数据集中的所有特征对应的特征标记之
"""
# 构建Input层字典,并以dense和sparse两类字典的形式返回
dense_input_dict, sparse_input_dict = {}, {}
for fc in feature_columns:
if isinstance(fc, SparseFeat):
sparse_input_dict[fc.name] = Input(shape=(1, ), name=fc.name)
elif isinstance(fc, DenseFeat):
dense_input_dict[fc.name] = Input(shape=(fc.dimension, ), name=fc.name)
return dense_input_dict, sparse_input_dict
build_embedding_layers函数
用于创建sparse特征的embeddin。
def build_embedding_layers(feature_columns, input_layers_dict, is_linear):
# 定义一个embedding层对应的字典
embedding_layers_dict = dict()
# 将特征中的sparse特征筛选出来
sparse_feature_columns = list(filter(lambda x: isinstance(x, SparseFeat), feature_columns)) if feature_columns else []
# 如果是用于线性部分的embedding层,其维度为1,否则维度就是自己定义的embedding维度
if is_linear:
for fc in sparse_feature_columns:
embedding_layers_dict[fc.name] = Embedding(fc.vocabulary_size + 1, 1, name='1d_emb_' + fc.name)
else:
for fc in sparse_feature_columns:
embedding_layers_dict[fc.name] = Embedding(fc.vocabulary_size + 1, fc.embedding_dim, name='kd_emb_' + fc.name)
return embedding_layers_dict
concat_embedding_list函数
用于将所有的sparse特征embedding拼接
def concat_embedding_list(feature_columns, input_layer_dict, embedding_layer_dict, flatten=False):
# 将sparse特征筛选出来
sparse_feature_columns = list(filter(lambda x: isinstance(x, SparseFeat), feature_columns))
embedding_list = []
for fc in sparse_feature_columns:
_input = input_layer_dict[fc.name] # 获取输入层
_embed = embedding_layer_dict[fc.name] # B x 1 x dim 获取对应的embedding层
embed = _embed(_input) # B x dim 将input层输入到embedding层中
# 是否需要flatten, 如果embedding列表最终是直接输入到Dense层中,需要进行Flatten,否则不需要
if flatten:
embed = Flatten()(embed)
embedding_list.append(embed)
return embedding_list
get_dnn_logits函数
用于将dnn的输出转化成logits
# block_nums表示DNN残差块的数量
def get_dnn_logits(dnn_inputs, block_nums=3):
dnn_out = dnn_inputs
for i in range(block_nums):
dnn_out = ResidualBlock(64)(dnn_out)
# 将dnn的输出转化成logits
dnn_logits = Dense(1, activation='sigmoid')(dnn_out)
return dnn_logits
调用ResidualBlock类,运行call方法。
# DNN残差块的定义
class ResidualBlock(Layer):
def __init__(self, units): # units表示的是DNN隐藏层神经元数量
super(ResidualBlock, self).__init__()
self.units = units
def build(self, input_shape):
out_dim = input_shape[-1]
self.dnn1 = Dense(self.units, activation='relu')
self.dnn2 = Dense(out_dim, activation='relu') # 保证输入的维度和输出的维度一致才能进行残差连接
def call(self, inputs):
x = inputs
x = self.dnn1(x)
x = self.dnn2(x)
x = Activation('relu')(x + inputs) # 残差操作
return x
3. 查看参数概况,定义损失函数、优化函数、评估指标,训练模型
history.summary()
history.compile(optimizer="adam",
loss="binary_crossentropy",
metrics=["binary_crossentropy", tf.keras.metrics.AUC(name='auc')])
# 将输入数据转化成字典的形式输入
train_model_input = {name: data[name] for name in dense_features + sparse_features}
# 模型训练
history.fit(train_model_input, train_data['label'].values,
batch_size=64, epochs=5, validation_split=0.2, )
代码放至DeepCrossing