序言:
Wide&Deep模型是围绕模型的泛化与记忆能力提出的。
一方面,我们希望推荐系统模型具有较强的记忆能力,能够发现一些直白的、显而易见的关联信息。例如那个著名的啤酒与尿布的故事,人们发现年轻的父亲买尿布的时候总会顺带为自己购买啤酒,因此,我们就希望模型能够记住这种模式,在看到尿布的时候,就将其与啤酒关联起来。这种记忆能力可以通过简单的线性模型来实现。
另一方面,我们又希望模型具有较强的泛化能力,能够挖掘一些不直观的、难以察觉的信息。这就需要深度神经网络对输入的特征进行层层地交叉与组合。
那么,如果我们希望模型同时具有较强的记忆能力与较强的泛化能力,既能看到特征之间一些直接的关系,又能挖掘一些潜在不易察觉的信息呢?一个简单的方法就是将两个模型拼接起来,这就是Wide&Deep模型的构造思路。
1 模型原理
Wide&Deep模型的结构并不复杂,可以分为以下三个步骤:
- Wide部分
这一部分是简单的线性模型。由于输入特征中同时包含数值型特征和类别型特征,我们要先采用Embedding层对类别型特征稠密化,然后将处理后的类别型特征与数值型特征拼接,一同输入线性模型中。 - Deep部分
这一部分是一个DNN模型。同样将类别型特征输入Embedding层后于数值型特征拼接,然后共同输入包含三个Dense层的DNN网络。 - 拼接
将Wide部分的输出结果于Deep部分的输出结果加和,然后通过Sigmoid激活函数得到CTR的预估结果。
最终模型的结构如下图所示:
2 模型构建
2.1 构建数据集
采用criteo的部分数据,数据集包括39个特征,其中I1 ~ I13为数值型特征,C1 ~ C26为类别特征,目标是预测CTR。
- 构造训练集和验证集
分离出数值型特征和类别型特征,对数据做一些简单的预处理:
# 数值型特征
dense_feas = [col for col in data.columns if col[0] == 'I']
# 类别型特征
sparse_feas = [col for col in data.columns if col[0] == 'C']
# 数据预处理函数
def data_process(data, dense_feas, sparse_feas):
data[dense_feas] = data[dense_feas].fillna(0)
for fea in dense_feas:
data[fea] = data[fea].apply(lambda x: np.log(x+1) if x > -1 else -1)
data[sparse_feas] = data[sparse_feas].fillna('-1')
for fea in sparse_feas:
le = LabelEncoder()
data[fea] = le.fit_transform(data[fea])
return data[dense_feas + sparse_feas]
# 数据预处理,构造训练集和验证集
train_data = data_process(data, dense_feas, sparse_feas)
train_label = data['label']
X_train, X_valid, y_train, y_valid = train_test_split(train_data, train_label, test_size=0.2, random_state=42)
- 选择Wide部分的特征和Deep部分的特征
在具体的应用中,需要考虑将哪些特征输入Wide部分,哪些特征输入Deep部分。在这里,我们不进行选择,直接在Wide部分和Deep部分都使用所有的特征。
要注意的是,类别型特征要进行Embedding处理,所以我们可以采用具名元组来标记类别型特征和数值型特征,并保存它们各自的必要信息,方便之后在构造模型时使用。
from collections import namedtuple
# 使用具名元组定义特征标记
# 类别型特征需要记录特征名称、类别数、Embedding的输出维度
SparseFeat = namedtuple('SparseFeat', ['name', 'vocabulary_size', 'embedding_dim'])
# 数值型特征需要记录特征名称、输入维度
DenseFeat = namedtuple('DenseFeat', ['name', 'dimension'])
# 构造Wide部分的特征
wide_feas = [SparseFeat(fea, data[fea].nunique(), 4) for i, fea in enumerate(sparse_feas)] + \
[DenseFeat(fea, 1) for fea in dense_feas]
# 构造Deep部分的特征
deep_feas = [SparseFeat(fea, data[fea].nunique(), 4) for i, fea in enumerate(sparse_feas)] + \
[DenseFeat(fea, 1) for fea in dense_feas]
2.2 构建Wide&Deep模型
2.2.1 构建模型的输入
对输入的所有特征各构建一个Input层,并且将数值型特征的输入层和类别型特征的输入层按字典的形式返回:
def build_input_layers(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
2.2.2 构建Embedding层
对特征中的 每一个类别型特征构建一个Embedding层。在这里,我们对Wide部分和Deep部分构建了不同的Embedding层,对于Wide部分,我们令Embedding的输出维度为1,对于Deep部分,Embedding层输出维度为4。实际上,因为两部分使用的类别型特征是相同的,我们也可以让这两部分模型共用Embedding层。
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, name='1d_emb_' + fc.name)
else:
for fc in sparse_feature_columns:
embedding_layers_dict[fc.name] = Embedding(fc.vocabulary_size, fc.embedding_dim, name='kd_emb_' + fc.name)
return embedding_layers_dict
2.2.3 构建Wide部分的线性模型
在做好所有的准备工作后,我们就可以开始构建Wide部分的模型。这部分的线性模型构建分三步:
- 学习数值型数据
将数值型特征的输入字典dense_input_dict的所有值按列拼接后,通过一个全连接层得到一个1维的输出。
concat_dense_inputs = Concatenate(axis=1)(list(dense_input_dict.values()))
dense_logits_output = Dense(1)(concat_dense_inputs)
- 学习类别型数据
将类别型特征各自输入对应的Embedding层,由于Embedding层得到的权重就是将类别型特征的one-hot向量映射到该层各个输出维度的权重,我们将线性模型的Embedding层输出维度设置为了1,因此每个Embedding层的输出结果就相当于是这个特征进行全连接的权重,我们只需要把所有Embedding层的结果相加,就等同于进行了全连接操作。
# 构建Embedding层
linear_embedding_layers = build_embedding_layers(sparse_feature_columns, sparse_input_dict, is_linear=True)
# 将每个特征输入对应的Embedding层,并保存结果
sparse_1d_embed = []
for fc in sparse_feature_columns:
feat_input = sparse_input_dict[fc.name]
# 注意这里要用Flatten()层保证输出维度为Batchsize * 1
embed = Flatten()(linear_embedding_layers[fc.name](feat_input))
sparse_1d_embed.append(embed)
- 将以上输出结果相加
linear_logits = Add()([dense_logits_output, sparse_logits_output])
将以上各个步骤结合起来就得到完整的代码:
def get_linear_logits(dense_input_dict, sparse_input_dict, sparse_feature_columns):
# 学习dense特征
concat_dense_inputs = Concatenate(axis=1)(list(dense_input_dict.values()))
dense_logits_output = Dense(1)(concat_dense_inputs)
# 学习sparse特征
# 构建Embedding层
linear_embedding_layers = build_embedding_layers(sparse_feature_columns, sparse_input_dict, is_linear=True)
# 将每个特征输入对应的Embedding层,并保存结果
sparse_1d_embed = []
for fc in sparse_feature_columns:
feat_input = sparse_input_dict[fc.name]
# 注意这里要用Flatten()层保证输出维度为Batchsize * 1
embed = Flatten()(linear_embedding_layers[fc.name](feat_input))
sparse_1d_embed.append(embed)
sparse_logits_output = Add()(sparse_1d_embed)
# 将dense特征和sparse特征的输出结果相加
linear_logits = Add()([dense_logits_output, sparse_logits_output])
return linear_logits
2.2.4 构建Deep部分的DNN模型
为了方便构建DNN模型,我们定义一个函数,能够筛选出类别型特征,并将其输入Embedding层。
# 将所有的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
然后构建DNN模型:
def get_dnn_logits(dense_input_dict, sparse_input_dict, sparse_feature_columns, dnn_embedding_layers):
# 对dense特征进行拼接
concat_dense_inputs = Concatenate(axis=1)(list(dense_input_dict.values()))
# 将sparse特征输入Embedding层
sparse_kd_embed = concat_embedding_list(sparse_feature_columns, sparse_input_dict, dnn_embedding_layers, flatten=True)
# 将Embedding后的sparse特征进行拼接
concat_sparse_kd_embed = Concatenate(axis=1)(sparse_kd_embed)
# 将dense特征和Embedding后的sparse特征进行拼接作为DNN模型的输入
dnn_input = Concatenate(axis=1)([concat_dense_inputs, concat_sparse_kd_embed]) # B x (n2k + n1)
# 构建三层的DNN模型
dnn_out = Dropout(0.5)(Dense(1024, activation='relu')(dnn_input))
dnn_out = Dropout(0.3)(Dense(512, activation='relu')(dnn_out))
dnn_out = Dropout(0.1)(Dense(256, activation='relu')(dnn_out))
dnn_logits = Dense(1)(dnn_out)
return dnn_logits
2.2.5 构建Wide&Deep模型
将上述各个步骤结合起来,分别训练Wide部分的模型和Deep部分的模型,最后将二者的结果相加,通过Sigmoid激活函数,就得到了Wide&Deep模型:
def wide_deep(wide_feas, deep_feas):
# 构建模型的输入
# 构建模型的输入特征字典
dense_input_dict, sparse_input_dict = build_input_layers(wide_feas + deep_feas)
# 筛选出Wide部分的sparse特征
wide_sparse_feas = list(filter(lambda x: isinstance(x, SparseFeat), wide_feas))
# dense特征与sparse特征共同构成模型的输入
input_layers = list(dense_input_dict.values()) + list(sparse_input_dict.values())
# 构建Wide部分的线性模型
linear_logits = get_linear_logits(dense_input_dict, sparse_input_dict, wide_sparse_feas)
# 构建Deep部分的DNN模型
embedding_layers = build_embedding_layers(deep_feas, sparse_input_dict, is_linear=False)
deep_sparse_feas = list(filter(lambda x: isinstance(x, SparseFeat), deep_feas))
dnn_logits = get_dnn_logits(dense_input_dict, sparse_input_dict, deep_sparse_feas, embedding_layers)
# 将Wide部分的学习结果与Deep部分的学习结果相加
output_logits = Add()([linear_logits, dnn_logits])
# 将相加的结果输入Sigmoid激活函数得到输出的CTR值
output_layer = Activation("sigmoid")(output_logits)
model = Model(input_layers, output_layer)
return model
2.2.6 模型训练
# 构建模型
model = wide_deep(wide_feas, deep_feas)
# 查看模型结构
model.summary()
# 模型编译
model.compile(optimizer="adam",
loss="binary_crossentropy",
metrics=["binary_crossentropy", tf.keras.metrics.AUC(name='auc')])
# 由于构建模型的输入层时采用的是字典形式,所以要将训练集和验证集构造成字典形式
train_model_input = {name: X_train[name] for name in dense_feas + sparse_feas}
valid_model_input = {name: X_valid[name] for name in dense_feas + sparse_feas}
# 模型训练
history = model.fit(train_model_input, y_train, validation_data=(valid_model_input, y_valid), batch_size=64, epochs=5, verbose=2)
2.2.7 绘制训练验证曲线
# define the function
def training_vis(hist):
loss = hist.history['loss']
val_loss = hist.history['val_loss']
auc = hist.history['auc']
val_auc = hist.history['val_auc']
bce = hist.history['binary_crossentropy']
val_bce = hist.history['val_binary_crossentropy']
# make a figure
fig = plt.figure(figsize=(12,4))
# subplot loss
ax1 = fig.add_subplot(131)
ax1.plot(loss,label='train_loss')
ax1.plot(val_loss,label='val_loss')
ax1.set_xlabel('Epochs')
ax1.set_ylabel('Loss')
ax1.set_title('Loss on Training and Validation Data')
ax1.legend()
# subplot auc
ax2 = fig.add_subplot(132)
ax2.plot(auc,label='train_auc')
ax2.plot(val_auc,label='val_auc')
ax2.set_xlabel('Epochs')
ax2.set_ylabel('AUC')
ax2.set_title('AUC on Training and Validation Data')
ax2.legend()
plt.tight_layout()
# subplot binary_crossentropy
ax3 = fig.add_subplot(133)
ax3.plot(bce,label='train_binary_crossentropy')
ax3.plot(val_bce,label='val_binary_crossentropy')
ax3.set_xlabel('Epochs')
ax3.set_ylabel('Binary Crossentropy')
ax3.set_title('Binary Crossentropy on Training and Validation Data')
ax3.legend()
plt.tight_layout()
# 绘制训练过程的训练验证曲线
training_vis(history)
训练验证曲线如下图所示:
3 思考
问题一: 哪些特征适合放在Wide侧,哪些特征适合放在Deep侧
在Google团队发表的论文中,选择了用户安装的APP的Id和当前曝光的APP的Id的乘积作为Wide侧的输入,由此来发现用户安装的APP与当前曝光的APP的直接联系。
因此,在实际的推荐系统应用中,可以考虑将召回项目与用户的历史交互项目,以及其他可能与召回项目有直接关联的特征、人工构造的特征一起放在Wide侧。
对于Deep侧,我认为可以将所有原始特征与召回项目都输入Deep侧,通过神经网络对所有特征进行自动地交叉组合以发掘特征之间潜在的关联。
问题二: 为什么Wide部分要用L1 FTRL训练
FTRL本身是一个稀疏性很好的随机梯度下降方法,同时L1正则化也能够产生稀疏权值矩阵,因此在Wide侧采用L1 FTRL训练能够生成更加稀疏的权值矩阵。
虽然在上述模型构建中我们对Wide侧的类别型特征采用了Embedding,没有用L1 FTRL进行训练,在Google团队发表的论文原文中,是直接将两个Id的乘积作为输入进行训练的,这就导致Wide侧的输入矩阵大且稀疏,此时采用L1 FTRL进行训练就可以令大量的维度的权值为0,由此过滤掉大量稀疏的特征,加快模型的训练。
问题三: 为什么Deep部分不特别考虑稀疏性的问题
Deep部分的稀疏数据都经过了Embedding层进行稠密化,大大减少了模型的输入维度,而且DNN本身也并不适合学习稀疏数据,因此不需要特别考虑稀疏性的问题,可以采用深度学习中常用的优化方法进行训练。