序言:
在Task03中学习的DeepFM模型是将DNN与FM横向拼接,这次,我们换一个姿势,将DNN与FM纵向拼接,就得到了NFM模型。
于是,问题就来了:
- DNN模型与FM模型是怎么纵向拼接的呢?
- 纵向拼接相比于横向拼接有哪些优势呢?
这些问题我们将在接下来展开讨论。
1 模型原理
一切还得从FM模型开始说起。FM模型的特点就在于,它在线性模型的基础上加上了交叉项,使得模型能够自动学习二阶特征:
y
^
F
M
(
x
)
=
w
0
+
∑
i
=
1
n
w
i
x
i
+
∑
i
=
1
n
−
1
∑
j
=
i
+
1
n
w
i
j
x
i
x
j
\hat{y}_{FM}(x)=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^FM(x)=w0+i=1∑nwixi+i=1∑n−1j=i+1∑nwijxixj
那么,如果我们想要学习更高阶的特征呢?一个直接的思路就是用一个表达能力更强的函数
f
(
x
)
f(x)
f(x)来替换掉上式中的二阶项
∑
i
=
1
n
−
1
∑
j
=
i
+
1
n
w
i
j
x
i
x
j
\sum_{i=1}^{n-1}\sum_{j=i+1}^{n}w_{ij}x_ix_j
∑i=1n−1∑j=i+1nwijxixj。
那么,如何得到一个表达能力更强、能够学习高阶特征的函数
f
(
x
)
f(x)
f(x)呢?理所当然地,我们会想到神经网络,经过多层的深度交叉理论上可以学习到任何高阶的特征。
至此,模型的表达式就变成:
y
^
N
F
M
(
x
)
=
w
0
+
∑
i
=
1
n
w
i
x
i
+
f
(
x
)
\hat{y}_{NFM}(x)=w_0+\sum_{i=1}^{n}w_ix_i+f(x)
y^NFM(x)=w0+i=1∑nwixi+f(x)
仔细观察一下这个表达式,是不是有一种似曾相识的感觉?前两项是线性模型,最后一项是DNN模型,两者相加得到输出,这不就是Wide&Deep吗?这个模型似乎已经跟名字中的FM模型没有关系了。其实不然,作者并没有抛弃FM,而是将二阶交叉特征作为了DNN模型的输入,所以最终NFM模型的结构如下图:
绿色框内的是Embedding层,红色框内的是DNN模型,这些都还是熟悉的配方,而两者连接处的Bi-Interaction Pooling层就是NFM模型的核心所在,是FM模型与DNN模型得以在纵向上无缝衔接的关键。
1.1 Bi-Interaction Pooling层
作为Embedding与DNN之间的衔接层,Bi-Interaction Pooling的作用就是:将Embedding后得到向量进行二阶交叉,然后输入给DNN模型。
假设对于输入
x
x
x,其第i维特征为
x
i
x_i
xi,经过Embedding后得到向量
v
i
\mathbf{v}_i
vi,第j维特征为
x
j
x_j
xj,经过Embedding后得到向量
v
j
\mathbf{v}_j
vj,因为特征经过one-hot编码后向量中存在大量的0,只有取值非零的部分参与训练,因此第i维特征经过Embedding层的输出可以写为
x
i
v
i
x_i\mathbf{v}_i
xivi,同理第j维特征经过Embedding层的输出可以写为
x
j
v
j
x_j\mathbf{v}_j
xjvj。
将两特征进行交叉:
x
i
v
i
⊙
x
j
v
j
x_i\mathbf{v}_i\odot{x_j\mathbf{v}_j}
xivi⊙xjvj
其中,
⊙
\odot
⊙是将两向量的对应元素相乘,例如对于向量
a
=
[
a
1
,
a
2
,
a
3
]
\mathbf{a}=[a_1,a_2,a_3]
a=[a1,a2,a3]和向量
b
=
[
b
1
,
b
2
,
b
3
]
\mathbf{b}=[b_1,b_2,b_3]
b=[b1,b2,b3],
a
⊙
b
=
[
a
1
b
1
,
a
2
b
2
,
a
3
,
b
3
]
\mathbf{a}\odot{\mathbf{b}}=[a_1b_1,a_2b_2,a_3,b_3]
a⊙b=[a1b1,a2b2,a3,b3]。
将所有两两特征交叉得到的结果相加,就得到Bi-Interaction Pooling层的表达式:
f
B
I
=
∑
i
=
1
n
−
1
∑
j
=
i
+
1
n
x
i
v
i
⊙
x
j
v
j
f_{BI}=\sum_{i=1}^{n-1}\sum_{j=i+1}^{n}x_i\mathbf{v}_i\odot{x_j\mathbf{v}_j}
fBI=i=1∑n−1j=i+1∑nxivi⊙xjvj
参考FM对上式进行转化:
f
B
I
=
∑
i
=
1
n
−
1
∑
j
=
i
+
1
n
x
i
v
i
⊙
x
j
v
j
=
1
2
(
∑
i
=
1
n
∑
j
=
1
n
x
i
v
i
⊙
x
j
v
j
−
∑
i
=
1
n
x
i
v
i
⊙
x
i
v
i
)
=
1
2
[
(
∑
i
=
1
n
x
i
v
i
⊙
∑
j
=
1
n
x
j
v
j
)
−
∑
i
=
1
n
(
x
i
v
i
)
2
]
=
1
2
[
(
∑
i
=
1
n
x
i
v
i
)
2
−
∑
i
=
1
n
(
x
i
v
i
)
2
]
\begin{aligned} f_{BI}&=\sum_{i=1}^{n-1}\sum_{j=i+1}^{n}x_i\mathbf{v}_i\odot{x_j\mathbf{v}_j} \\ &=\frac{1}{2}(\sum_{i=1}^{n}\sum_{j=1}^{n}x_i\mathbf{v}_i\odot{x_j\mathbf{v}_j}-\sum_{i=1}^{n}x_i\mathbf{v}_i\odot{x_i\mathbf{v}_i}) \\ &=\frac{1}{2}[(\sum_{i=1}^{n}x_i\mathbf{v}_i\odot{\sum_{j=1}^{n}x_j\mathbf{v}_j})-\sum_{i=1}^{n}(x_i\mathbf{v}_i)^2] \\ &=\frac{1}{2}[(\sum_{i=1}^{n}x_i\mathbf{v}_i)^2-\sum_{i=1}^{n}(x_i\mathbf{v}_i)^2] \end{aligned}
fBI=i=1∑n−1j=i+1∑nxivi⊙xjvj=21(i=1∑nj=1∑nxivi⊙xjvj−i=1∑nxivi⊙xivi)=21[(i=1∑nxivi⊙j=1∑nxjvj)−i=1∑n(xivi)2]=21[(i=1∑nxivi)2−i=1∑n(xivi)2]
这就是Bi-Interaction层的最终计算公式。
1.2 为什么要将二阶交叉项作为DNN的输入
既然DNN能够通过多层交叉学习任意复杂的高阶特征,为什么要多此一举先获得二阶交叉特征再将其输入DNN中进行学习呢?
在实际应用中,完全依赖DNN去学习复杂的交叉特征并不容易得到好的结果,因为深层网络结构的优化往往是很困难的,容易出现例如梯度消失或爆炸、过拟合或欠拟合等诸多问题。通过FM预先学习二阶特征,就能够简化DNN的结构,使DNN部分的调优变得更容易。作者在论文中通过实验证明,NFM模型中的DNN部分只需要一至两个隐藏层就能获得最佳效果,增加隐藏层数反而会使模型效果变差。
至此,我们便可以回答序言中提出的第二个问题:纵向拼接相比于横向拼接有什么优势?相较于DeepFM模型采用的横向拼接方式,NFM的优势就在于以二阶交叉项作为DNN的输入,使得学习高阶特征更加容易。同时,NFM模型也没有抛弃低阶特征,NFM的表达式中仍然包含线性部分,虽然在结构图中没有表现出来,在实际的模型实现中我们需要加上线性模型。
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)
- 选择线性部分的特征和DNN部分的特征
这里取了所有特征作为线性部分和DNN部分的输入,在实际应用中可以根据需要选择合适的特征。
from collections import namedtuple
# 使用具名元组定义特征标记
# 类别型特征需要记录特征名称、类别数、Embedding的输出维度
SparseFeat = namedtuple('SparseFeat', ['name', 'vocabulary_size', 'embedding_dim'])
# 数值型特征需要记录特征名称、输入维度
DenseFeat = namedtuple('DenseFeat', ['name', 'dimension'])
# 构造Linear部分的特征
linear_feas = [SparseFeat(fea, data[fea].nunique(), 8) for i, fea in enumerate(sparse_feas)] + \
[DenseFeat(fea, 1) for fea in dense_feas]
# 构造DNN部分的特征
dnn_feas = [SparseFeat(fea, data[fea].nunique(), 8) for i, fea in enumerate(sparse_feas)] + \
[DenseFeat(fea, 1) for fea in dense_feas]
2.2 构建NFM模型
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层:DNN部分Embedding层输出维度为8;对于线性模型,令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 构建线性模型
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 构建Bi Interaction Pooling层
根据1.1节的推导,Bi Interaction Pooling层的输出计算公式为:
f
B
I
=
1
2
[
(
∑
i
=
1
n
x
i
v
i
)
2
−
∑
i
=
1
n
(
x
i
v
i
)
2
]
f_{BI}=\frac{1}{2}[(\sum_{i=1}^{n}x_i\mathbf{v}_i)^2-\sum_{i=1}^{n}(x_i\mathbf{v}_i)^2]
fBI=21[(i=1∑nxivi)2−i=1∑n(xivi)2]
其中
x
i
v
i
x_i\mathbf{v}_i
xivi就是Embedding层的输出,我们直接对其进行计算就可以。
- 首先构建Bi Interaction Pooling类
class BiInteractionPooling(Layer):
def __init__(self):
super(BiInteractionPooling, self).__init__()
def call(self, inputs):
concated_embeds_value = inputs
# 计算公式中的第一项:和的平方
square_of_sum = tf.square(tf.reduce_sum(concated_embeds_value, axis=1, keepdims=False))
# 计算公式中的第二项:平方的和
sum_of_square = tf.reduce_sum(concated_embeds_value ** 2, axis=1, keepdims=False)
# 计算交叉项
cross_term = 0.5 * (square_of_sum - sum_of_square)
return cross_term
def compute_output_shape(self, input_shape):
return (None, input_shape[2])
- 然后调用构建的类,获得Bi Interaction Pooling层的输出结果
def get_bi_interaction_pooling_output(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特征输入Bi Interaction Pooling层中进行计算
pooling_out = BiInteractionPooling()(concat_sparse_kd_embed)
return pooling_out
2.2.5 构建DNN模型
NFM模型的DNN部分通常采用一至两个隐藏层,这里我们构建一个两层网络:
def get_dnn_logits(pooling_output):
# 构建两层的DNN模型
dnn_out = Dropout(0.5)(Dense(256, activation='relu')(pooling_output))
dnn_out = Dropout(0.5)(Dense(256, activation='relu')(dnn_out))
dnn_logits = Dense(1)(dnn_out)
return dnn_logits
2.2.6 构建NFM模型
将上述各步骤结合起来,分两部分。一部分是线性模型,将所有输入特征输入线性模型中得到线性部分的输出。另一部分是FM与DNN模型的结合,将类别型特征经过Embedding层、Bi Interaction Pooling层后输入DNN模型得到该部分的输出。两部分输出相加后经过Sigmoid激活函数就可以得到CTR的预估值。
值得一提的是,作者对Bi-Interaction层的输出采用了Dropout来防止过拟合,同时采用了BatchNormalization来避免Embedding向量的更新导致输入层的分布变为隐藏层或输出层。经过实验表明,Dropout+BatchNormalization的组合虽然是有效的,但是会导致模型不稳定。
def NFM(linear_feas, dnn_feas):
# 构建模型的输入
# 构建模型的输入特征字典
dense_input_dict, sparse_input_dict = build_input_layers(linear_feas + dnn_feas)
# 筛选出Linear部分的sparse特征
linear_sparse_feas = list(filter(lambda x: isinstance(x, SparseFeat), linear_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, linear_sparse_feas)
# 构建Embedding层
embedding_layers = build_embedding_layers(dnn_feas, sparse_input_dict, is_linear=False)
# 筛选出DNN部分的sparse特征
dnn_sparse_feas = list(filter(lambda x: isinstance(x, SparseFeat), dnn_feas))
# 计算Bi Interaction Pooling层的输出
pooling_output = get_bi_interaction_pooling_output(sparse_input_dict, dnn_sparse_feas, embedding_layers)
pooling_output = Dropout(0.5)(pooling_output)
pooling_output = BatchNormalization()(pooling_output)
# 构建DNN模型
dnn_logits = get_dnn_logits(pooling_output)
# 将线性模型、DNN模型的学习结果相加
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.7 模型训练
# 构建模型
model = NFM(linear_feas, dnn_feas)
# 查看模型结构
model.summary()
# 设置学习率和优化器
learning_rate = 2e-4
adam = tf.optimizers.Adam(lr=learning_rate)
# 模型编译
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=128, epochs=20, 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()
训练验证曲线如下图所示:
可以看到,模型的训练验证曲线是波动的,这验证了作者的说法,即Dropout+BatchNormalization的组合确实会使模型更不稳定。
3 思考
问题一:NFM中的特征交叉与FM中的特征交叉有何异同
1.从原理上看
FM的特征交叉项表达式为:
∑
i
=
1
n
−
1
∑
j
=
i
+
1
n
w
i
j
x
i
x
j
\sum_{i=1}^{n-1}\sum_{j=i+1}^{n}w_{ij}x_ix_j
i=1∑n−1j=i+1∑nwijxixj
而NFM的特征交叉项表达式为:
∑
i
=
1
n
−
1
∑
j
=
i
+
1
n
x
i
v
i
⊙
x
j
v
j
\sum_{i=1}^{n-1}\sum_{j=i+1}^{n}x_i\mathbf{v}_i\odot{x_j\mathbf{v}_j}
i=1∑n−1j=i+1∑nxivi⊙xjvj
可以明显看到,FM的特征交叉项采用的是点乘,最终得到的是一个数值;而NFM的特征交叉项采用的是元素积,最终得到的仍然是一个与Embedding向量维度相同的向量。
二者采用不同的交叉方法的原因在于,FM通过学习交叉特征的权重,直接根据表达式获得一个输出值,而NFM在特征交叉后并不直接输出,而是要以交叉后的向量作为输入,继续通过DNN模型学习更高阶的特征。
2.从代码实现上看
FM的特征交叉项的代码实现为:
# 计算公式中的第一项:和的平方
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)
NFM的特征交叉项的代码实现为:
# 计算公式中的第一项:和的平方
square_of_sum = tf.square(tf.reduce_sum(concated_embeds_value, axis=1, keepdims=False))
# 计算公式中的第二项:平方的和
sum_of_square = tf.reduce_sum(concated_embeds_value ** 2, axis=1, keepdims=False)
# 计算交叉项
cross_term = 0.5 * (square_of_sum - sum_of_square)
可以看到,FM的代码实现在计算交叉项cross_term时多了一个求和的步骤,且在计算和的平方和平方的和时都将keepdims参数设置为了True,这样设置是为了便于交叉项的求和,得到的结果是一个 N × 1 N\times1 N×1的列向量;而NFM将keepdims参数设置为了False,得到的结果是一个 N × e m b d i m N\times{embdim} N×embdim的矩阵,其中N为输入数据条数,embdim为Embedding向量的输出维度。