序言:
在Task02中,我们说到,Wide&Deep模型是Wide模型和Deep模型的结合,其中Wide部分采用线性模型,Deep部分采用DNN模型。其中Wide模型的输入特征是原始的数值型特征、Embedding处理后的类别型特征以及人工构造的交叉特征。因此,Wide模型必须要有人工参与,需要有经验的工程师做好特征工程,构造出实用的交叉特征。
然而,特征工程对工程师的要求很高,同时日益庞大的特征量也使得人工构造特征变得越来越困难。在实际应用中,我们希望能够尽量避免人工参与,构造端到端模型,让模型自动完成特征交叉和学习的任务。于是,DeepFM模型就应运而生。
DeepFM模型是对Wide&Deep模型的继承与改进,在保留Wide模型与Deep模型相结合的思路的同时,DeepFM模型将Wide部分的线性模型改为FM模型,能够在线性模型的基础上进一步学习二阶特征,提高了Wide部分模型的表达能力。
1 模型原理
1.1 FM部分
FM部分是一个FM模型,仍然是常规的处理思路,将稀疏特征做Embedding,然后输入FM层。
FM模型的表达式为:
y
=
w
0
+
∑
i
=
1
n
w
i
x
i
+
∑
i
=
1
n
−
1
∑
j
=
i
+
1
n
w
i
j
x
i
x
j
y=w_0+\sum_{i=1}^{n}w_ix_i+\sum_{i=1}^{n-1}\sum_{j=i+1}^{n}w_{ij}x_ix_j
y=w0+i=1∑nwixi+i=1∑n−1j=i+1∑nwijxixj
相较于线性模型,FM模型多了二阶交叉特征,具有更强的表达能力,但同时也额外引入了
n
2
2
\frac{n^2}{2}
2n2个需要训练的参数。如何减少参数呢?
我们将交叉项的系数用矩阵
W
n
×
n
W_{n\times{n}}
Wn×n表示,其组成元素为
w
i
j
w_{ij}
wij,由于
W
n
×
n
W_{n\times{n}}
Wn×n是对称矩阵,我们可以将其分解为
W
n
×
n
=
V
n
×
k
V
n
×
k
T
W_{n\times{n}}=V_{n\times{k}}V_{n\times{k}}^T
Wn×n=Vn×kVn×kT,于是,模型表达式就变为:
y
=
w
0
+
∑
i
=
1
n
w
i
x
i
+
∑
i
=
1
n
−
1
∑
j
=
i
+
1
n
<
V
i
,
V
j
>
x
i
x
j
y=w_0+\sum_{i=1}^{n}w_ix_i+\sum_{i=1}^{n-1}\sum_{j=i+1}^{n}<V_i,V_j>x_ix_j
y=w0+i=1∑nwixi+i=1∑n−1j=i+1∑n<Vi,Vj>xixj
其中:
∑
i
=
1
n
−
1
∑
j
=
i
+
1
n
<
V
i
,
V
j
>
x
i
x
j
=
1
2
(
∑
i
=
1
n
∑
j
=
1
n
<
V
i
,
V
j
>
x
i
x
j
−
∑
i
=
1
n
<
V
i
,
V
i
>
x
i
x
i
)
=
1
2
(
∑
i
=
1
n
∑
j
=
1
n
∑
f
=
1
k
v
i
f
v
j
f
x
i
x
j
−
∑
i
=
1
n
∑
f
=
1
k
v
i
f
v
i
f
x
i
x
i
)
=
1
2
∑
f
=
1
k
(
∑
i
=
1
n
v
i
f
x
i
∑
j
=
1
n
v
j
f
x
j
−
∑
i
=
1
n
v
i
f
v
i
f
x
i
x
i
)
=
1
2
∑
f
=
1
k
(
(
∑
i
=
1
n
v
i
f
x
i
)
2
−
∑
i
=
1
n
(
v
i
f
x
i
)
2
)
\begin{aligned} &\sum_{i=1}^{n-1}\sum_{j=i+1}^{n}<V_i,V_j>x_ix_j \\ &=\frac{1}{2}(\sum_{i=1}^{n}\sum_{j=1}^{n}<V_i,V_j>x_ix_j-\sum_{i=1}^{n}<V_i,V_i>x_ix_i) \\ &=\frac{1}{2}(\sum_{i=1}^{n}\sum_{j=1}^{n}\sum_{f=1}^{k}v_{if}v_{jf}x_ix_j-\sum_{i=1}^{n}\sum_{f=1}^{k}v_{if}v_{if}x_ix_i) \\ &=\frac{1}{2}\sum_{f=1}^{k}(\sum_{i=1}^{n}v_{if}x_i\sum_{j=1}^{n}v_{jf}x_j-\sum_{i=1}^{n}v_{if}v_{if}x_ix_i) \\ &=\frac{1}{2}\sum_{f=1}^{k}((\sum_{i=1}^{n}v_{if}x_i)^2-\sum_{i=1}^{n}(v_{if}x_i)^2) \end{aligned}
i=1∑n−1j=i+1∑n<Vi,Vj>xixj=21(i=1∑nj=1∑n<Vi,Vj>xixj−i=1∑n<Vi,Vi>xixi)=21(i=1∑nj=1∑nf=1∑kvifvjfxixj−i=1∑nf=1∑kvifvifxixi)=21f=1∑k(i=1∑nvifxij=1∑nvjfxj−i=1∑nvifvifxixi)=21f=1∑k((i=1∑nvifxi)2−i=1∑n(vifxi)2)
经过以上分解之后,我们所需要训练的参数就降为nk个,其中k是我们设置的隐变量个数。
1.2 Deep部分
Deep部分与Wide&Deep模型一样,采用的是DNN模型,将稀疏特征做Embedding之后通过三层DNN网络。
1.3 需要注意的几个要点
在本节开头的模型结构图中,有几个要点需要注意。
- FM模型与DNN模型共享Embedding层。
- 模型的输入只有类别型特征(Sparse Features),那么数值型特征(Dense Features)去哪儿了?这个问题我们在下一小节展开讨论。
- 为什么类别型特征(Sparse Features)中黄色节点与模型连接,灰色节点却没有连接,黄色节点与灰色节点分别表示什么?这个问题留待最后一节思考题中进行讨论。
1.4 关于上述要点2的讨论
PS:以下仅代表我个人不成熟的理解,如有错漏之处,还望批评指正。
在论文原文中,作者将每一条输入数据x看作由m个field组成,每个field其实就表示一个特征,因此,一个field可以表示数值型特征,也可以表示类别型特征,每一条输入数据就表示为
x
=
[
x
f
i
e
l
d
1
,
x
f
i
e
l
d
2
,
.
.
.
,
x
f
i
e
l
d
m
]
x=[x_{field1}, x_{field2}, ..., x_{fieldm}]
x=[xfield1,xfield2,...,xfieldm]。所有的类别型特征采用one-hot编码,数值型特征的值不变,将这个m个field拼接起来,即为输入数据x。因为经过one-hot编码后得到的向量是稀疏的,即是与数值型特征拼接之后,得到的输入数据x仍然是一个稀疏向量,作者将这样的输入数据x称为Sparse Features,而并没有去区分数值型特征和类别型特征。
举个例子,假设输入数据有三个特征f1、f2、f3,其中f1和f2是数值型特征,f3是类别型特征。按上述说法,f1就是field1,f2是field2,f3是field3,每一条输入数据就表示为
x
=
[
x
f
i
e
l
d
1
,
x
f
i
e
l
d
2
,
x
f
i
e
l
d
3
]
x=[x_{field1}, x_{field2}, x_{field3}]
x=[xfield1,xfield2,xfield3]。假设对于其中一条数据x1,其特征f1的值为4,特征f2的值为8,特征f3经过one-hot编码后得到的向量为[0,…,0,1,0,…,0],将所有特征拼接起来就得到该输入数据x1为
x
1
=
[
4
,
8
,
0
,
.
.
.
,
0
,
1
,
0
,
.
.
.
,
0
]
x_1=[4, 8, 0,...,0,1,0,...,0]
x1=[4,8,0,...,0,1,0,...,0],由这样的n条数据构成了模型的输入数据。也就是说,Sparse Features指的是所有的特征,而并非特指类别型特征。
需要指出的是,我们在接下来的模型实现中,只取了类别型特征输入FM模型和DNN模型中,并且在FM层的构建中只学习了二阶特征(即FM模型表达式中的二阶交叉项),然后将所有特征输入线性模型来学习所有的一阶特征,最后将这三部分的输出结果相加,通过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)
- 选择FM部分的特征和Deep部分的特征
这里取了所有的特征作为FM部分和DNN部分的输入,实际在后面的FM模型和DNN模型中会仅筛选出类别型特征作为输入。在具体应用中,需要仔细考虑将哪些特征输入FM部分,哪些特征输入Deep部分。
from collections import namedtuple
# 使用具名元组定义特征标记
# 类别型特征需要记录特征名称、类别数、Embedding的输出维度
SparseFeat = namedtuple('SparseFeat', ['name', 'vocabulary_size', 'embedding_dim'])
# 数值型特征需要记录特征名称、输入维度
DenseFeat = namedtuple('DenseFeat', ['name', 'dimension'])
# 构造FM部分的特征
fm_feas = [SparseFeat(fea, data[fea].nunique(), 4) for i, fea in enumerate(sparse_feas)] + \
[DenseFeat(fea, 1) for fea in dense_feas]
# 构造Deep部分的特征
dnn_feas = [SparseFeat(fea, data[fea].nunique(), 4) for i, fea in enumerate(sparse_feas)] + \
[DenseFeat(fea, 1) for fea in dense_feas]
2.2 构建DeepFM模型
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层。我们构造两个不同的Embedding层:FM部分和Deep部分共享Embedding层,Embedding层输出维度为4;对于线性模型,令Embedding的输出维度为1。
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&Deep模型中同样的方法来构建线性模型:
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 构建FM模型
先定义一个FM层,在keras中可以通过继承Layer类来实现。在这个FM层中我们要实现的就是根据FM模型表达式中交叉项的计算公式来计算FM层的输出。根据之前的推导,计算公式为:
y
=
1
2
∑
f
=
1
k
(
(
∑
i
n
v
i
f
x
i
)
2
−
∑
i
=
1
n
(
v
i
f
x
i
)
2
)
y=\frac{1}{2}\sum_{f=1}^{k}((\sum_{i}^{n}v_{if}x_i)^2-\sum_{i=1}^{n}(v_{if}x_i)^2)
y=21f=1∑k((i∑nvifxi)2−i=1∑n(vifxi)2)
实际上,Embedding层的作用就是将输入数据映射到k维,类别型特征经过Embedding层后得到的在f位置的输出就是权重
v
i
f
v_{if}
vif,而输入值
x
i
∈
0
,
1
x_i\in{0,1}
xi∈0,1,因此
v
i
f
x
i
v_{if}x_i
vifxi的值实际就是
v
i
f
v_{if}
vif,在计算时,我们只需要对Embedding层的输出结果进行计算就可以了。
# 构建FM层,计算FM层的输出
class FMLayer(Layer):
def __init__(self):
super(FMLayer, self).__init__()
def call(self, inputs):
concated_embeds_value = inputs
# 计算公式中的第一项:和的平方
square_of_sum = tf.square(tf.reduce_sum(concated_embeds_value, axis=1, keepdims=True))
# 计算公式中的第二项:平方的和
sum_of_square = tf.reduce_sum(concated_embeds_value ** 2, axis=1, keepdims=True)
# 计算交叉项
cross_term = square_of_sum - sum_of_square
cross_term = 0.5 * tf.reduce_sum(cross_term, axis=2, keepdims=False)
return cross_term
def compute_output_shape(self, input_shape):
return (None, 1)
然后构建FM模型:
def get_fm_logits(sparse_input_dict, sparse_feas, dnn_embedding_layers):
# 筛选出sparse特征
sparse_feas = list(filter(lambda x: isinstance(x, SparseFeat), sparse_feas))
# 将sparse特征输入对应的Embedding层
sparse_kd_embed = []
for fc in sparse_feas:
feat_input = sparse_input_dict[fc.name]
_embed = dnn_embedding_layers[fc.name](feat_input)
sparse_kd_embed.append(_embed)
# 将Embedding后的sparse特征进行拼接
concat_sparse_kd_embed = Concatenate(axis=1)(sparse_kd_embed)
# 将拼接后的sparse特征输入FM层中进行计算
fm_cross_out = FMLayer()(concat_sparse_kd_embed)
return fm_cross_out
2.2.5 构建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(sparse_input_dict, sparse_feature_columns, dnn_embedding_layers):
# 将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)
# # 构建三层的DNN模型
dnn_out = Dropout(0.5)(Dense(256, activation='relu')(concat_sparse_kd_embed))
dnn_out = Dropout(0.3)(Dense(256, 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.6 构建DeepFM模型
将上述各个步骤结合起来,分别训练FM模型、DNN模型和线性模型,最后将三者的结果相加,通过Sigmoid激活函数,就得到了DeepFM模型:
def deep_fm(fm_feas, dnn_feas):
# 构建模型的输入
# 构建模型的输入特征字典
dense_input_dict, sparse_input_dict = build_input_layers(fm_feas + dnn_feas)
# 筛选出FM部分的sparse特征
fm_sparse_feas = list(filter(lambda x: isinstance(x, SparseFeat), fm_feas))
# dense特征与sparse特征共同构成模型的输入
input_layers = list(dense_input_dict.values()) + list(sparse_input_dict.values())
# 构建线性模型
linear_logits = get_linear_logits(dense_input_dict, sparse_input_dict, fm_sparse_feas)
# 构建Embedding层
embedding_layers = build_embedding_layers(dnn_feas, sparse_input_dict, is_linear=False)
# 筛选出Deep部分的sparse特征
dnn_sparse_feas = list(filter(lambda x: isinstance(x, SparseFeat), dnn_feas))
# 构建FM模型
fm_logits = get_fm_logits(sparse_input_dict, dnn_sparse_feas, embedding_layers)
# 构建DNN模型
dnn_logits = get_dnn_logits(sparse_input_dict, dnn_sparse_feas, embedding_layers)
# 将线性模型、FM模型、DNN模型的学习结果相加
output_logits = Add()([linear_logits, fm_logits, dnn_logits])
# 将相加的结果输入Sigmoid激活函数得到输出的CTR值
output_layer = Activation("sigmoid")(output_logits)
model = Model(input_layers, output_layer)
return model
2.2.7 模型训练
# 构建模型
model = deep_fm(fm_feas, dnn_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.8 绘制训练验证曲线
# 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 思考
问题一:如果对FM采用随机梯度下降SGD训练模型参数,写出模型各个参数的梯度及FM参数训练的复杂度
根据之前的推导的公式对各个参数求导,我们就得到了各参数的梯度:
∂
∂
θ
y
(
x
)
=
{
1
θ
=
w
0
x
i
θ
=
w
i
,
i
∈
[
1
,
n
]
x
i
∑
j
=
1
n
v
j
f
x
j
−
v
i
f
x
i
2
θ
=
v
i
f
,
i
∈
[
1
,
n
]
,
f
∈
[
1
,
k
]
\frac{\partial}{\partial{\theta}}y(x)=\left\{ \begin{array}{rcl} 1 & & {\theta=w_0}\\ x_i & & {\theta=w_i,i\in[1,n]}\\ x_i\sum_{j=1}^{n}v_{jf}x_j-v_{if}x_i^2 & & {\theta=v_{if},i\in[1,n],f\in[1,k]}\\ \end{array} \right.
∂θ∂y(x)=⎩⎨⎧1xixi∑j=1nvjfxj−vifxi2θ=w0θ=wi,i∈[1,n]θ=vif,i∈[1,n],f∈[1,k]
FM模型参数训练的复杂度为O(nk)。
问题二:Sparse Features中不同颜色的节点表示什么意思
我们在第1节中有提到,在DeepFM模型的结构图中,Sparse Features中的黄色节点与模型连接,参与了训练,而灰色节点没有参与训练。这一点在论文中可以找到答案(参见下图中Embedding层的结构),黄色节点代表输入的稀疏向量中数值为1的点,灰色节点代表数值为0的点,因为数值0输入模型中学习得到的权重也仍然为零,所以可以看作是不参与训练。