阅读前思考
如果对于FM采用随机梯度下降SGD训练模型参数,请写出模型各个参数的梯度和FM参数训练的复杂度
对于下图(3-1)所示,根据你的理解Sparse Feature中的不同颜色节点分别表示什么意思
系列导读
- 深度学习推荐系统之deepcrossing简单介绍与代码实现
- 深度学习推荐系统之wide & deep介绍和代码实现
- 深度学习推荐系统之deepFM介绍和代码实现
- NFM(2017)结构与原理简介(代码)
介绍
对于CTR预估的模型来说,一个很重要的点就是学习用户行为对应的特征背后的潜在联系。虽然目前在这个领域已经取得了一些进展(截止2017年),但是目前的做法要么在低维或者高维的特征上存在很大的偏差,要么需要大量的专家级的特征工程。
哈工大和华为联合设计了一种新的模型DeepFM,从而找到了一种可能性,可以同时提升低维和高维的特征。它结合了FM和神经网络模型的长处,和Google最新的Wide & Deep模型的做法相比,取得了更大的进步,并且还免去了特征工程的部分。
论文原文详见:A Factorization-Machine based Neural Network for CTR Prediction
DeepFM模型本质
-
将Wide & Deep 部分的wide部分由 人工特征工程+LR 转换为FM模型,避开了人工特征工程;
-
FM模型与deep part共享feature embedding。
为什么要用FM代替线性部分(wide)呢?
因为线性模型有个致命的缺点:无法提取高阶的组合特征。 FM通过隐向量latent vector做内积来表示组合特征,从理论上解决了低阶和高阶组合特征提取的问题。但是实际应用中受限于计算复杂度,一般也就只考虑到2阶交叉特征。
各模型间的对比
-
随着DNN在图像、语音、NLP等领域取得突破,人们意识到DNN在特征表示上的天然优势。相继提出了使用CNN或RNN来做CTR预估的模型,但是
CNN模型缺点:偏向于学习相邻特征的组合特征。 RNN模型缺点:比较适用于有序列(时序)关系的数据。
-
FNN (Factorization-machine supported Neural Network) 的提出,应该算是一次非常不错的尝试:先使用预先训练好的FM,得到隐向量,然后作为DNN的输入来训练模型。缺点在于:受限于FM预训练的效果。
-
PNN (Product-based Neural Network),PNN为了捕获高阶组合特征,在embedding layer和first hidden layer之间增加了一个product layer。根据product layer使用内积、外积、混合分别衍生出IPNN, OPNN, PNN*三种类型。
无论是FNN还是PNN,他们都有一个绕不过去的缺点:对于低阶的组合特征,学习到的比较少。而前面我们说过,低阶特征对于CTR也是非常重要的。
-
为了同时学习低阶和高阶组合特征,Google提出了Wide&Deep模型。它混合了一个线性模型(Wide part)和Deep模型(Deep part)。这两部分模型需要不同的输入,而Wide part部分的输入,依旧依赖人工特征工程。
DeepFM优势
上面这些模型普遍都存在两个问题:
-
偏向于提取低阶或者高阶的组合特征。
-
不能同时提取这两种类型的特征。
-
需要专业的领域知识来做特征工程。
DeepFM可以看做是从FM基础上衍生的算法,将Deep与FM相结合,用FM做特征间低阶组合,用Deep NN部分做特征间高阶组合,通过并行的方式组合两种方法,使得最终的架构具有以下特点。
-
不需要预训练 FM 得到隐向量;
-
不需要人工特征工程;
-
能同时学习低阶和高阶的组合特征;
-
FM 模块和 Deep 模块共享 Feature Embedding 部分,可以更快的训练,以及更精确的训练学习。
————————————————
DeepFM模型介绍
DeepFM的模型结构:
DeepFM包含两部分:神经网络部分与因子分解机部分,分别负责低阶特征的提取和高阶特征的提取。这两部分共享同样的输入。DeepFM的预测结果可以写为:
FM
详细内容参考FM模型部分的内容,下图是FM的一个结构图,从图中大致可以看出FM Layer是由一阶特征和二阶特征Concatenate到一起在经过一个Sigmoid得到logits(结合FM的公式一起看),所以在实现的时候需要单独考虑linear部分和FM交叉特征部分。
Deep
Deep架构图
Deep Module是为了学习高阶的特征组合,在上图中使用用全连接的方式将Dense Embedding输入到Hidden Layer,这里面Dense Embeddings就是为了解决DNN中的参数爆炸问题,这也是推荐模型中常用的处理方法。
Embedding层的输出是将所有id类特征对应的embedding向量concat到到一起输入到DNN中。其中$v_i$表示第i个field的embedding,m是field的数量。
上一层的输出作为下一层的输入,我们得到:
其中$\sigma$表示激活函数,$z, W, b $分别表示该层的输入、权重和偏置。
最后进入DNN部分输出使用sigmod激活函数进行激活:
代码实现:
完整数据集:crite
# coding=utf-8
# Author:Jo Choi
# Date:2021-03-21
# Email:cai_oo@sina.com.cn
# Blog: caioo0.github.io
'''
数据集:criteo_sample
------------------------------
运行结果:
----------------------------
ETA: 0s - loss: 0.6558 - binary_crossentropy: 0.6558 - auc: 0.569 - 0s 28ms/step - loss: 0.6664 - binary_crossentropy: 0.6664 -
auc: 0.6306 - val_loss: 0.7323 - val_binary_crossentropy: 0.7323 - val_auc: 0.6724
----------------------------
'''
import warnings
warnings.filterwarnings("ignore")
import itertools
import pandas as pd
import numpy as np
from tqdm import tqdm
from collections import namedtuple
import tensorflow as tf
from tensorflow.keras.layers import *
from tensorflow.keras.models import *
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import MinMaxScaler,LabelEncoder
from utils import SparseFeat, DenseFeat, VarLenSparseFeat
def data_process(data_df,dense_features,sparse_features):
"""
数据预处理,包括填充缺失值,数值处理,类别编码
:param data_df: Data_Frame格式的数据
:param dense_features: 数值特征名称列表
:param sparse_features: 离散特征名称列表
"""
#数值型特征缺失值填充0.0
data_df[dense_features] = data_df[dense_features].fillna(0.0)
for f in dense_features:
data_df[f] = data_df[f].apply(lambda x: np.log(x + 1) if x > -1 else -1)
#离散型特征缺失值填充-1
data_df[sparse_features] = data_df[sparse_features].fillna("-1")
for f in sparse_features:
#标准化
lbe = LabelEncoder()
data_df[f] = lbe.fit_transform(data_df[f])
#返回
return data_df[dense_features + sparse_features]
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
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
def get_linear_logits(dense_input_dict, sparse_input_dict, sparse_feature_columns):
# 将所有的dense特征的Input层,然后经过一个全连接层得到dense特征的logits
concat_dense_inputs = Concatenate(axis=1)(list(dense_input_dict.values()))
dense_logits_output = Dense(1)(concat_dense_inputs)
# 获取linear部分sparse特征的embedding层,这里使用embedding的原因是:
# 对于linear部分直接将特征进行onehot然后通过一个全连接层,当维度特别大的时候,计算比较慢
# 使用embedding层的好处就是可以通过查表的方式获取到哪些非零的元素对应的权重,然后在将这些权重相加,效率比较高
linear_embedding_layers = build_embedding_layers(sparse_feature_columns, sparse_input_dict, is_linear=True)
# 将一维的embedding拼接,注意这里需要使用一个Flatten层,使维度对应
sparse_1d_embed = []
for fc in sparse_feature_columns:
feat_input = sparse_input_dict[fc.name]
embed = Flatten()(linear_embedding_layers[fc.name](feat_input)) # B x 1
sparse_1d_embed.append(embed)
# embedding中查询得到的权重就是对应onehot向量中一个位置的权重,所以后面不用再接一个全连接了,本身一维的embedding就相当于全连接
# 只不过是这里的输入特征只有0和1,所以直接向非零元素对应的权重相加就等同于进行了全连接操作(非零元素部分乘的是1)
sparse_logits_output = Add()(sparse_1d_embed)
# 最终将dense特征和sparse特征对应的logits相加,得到最终linear的logits
linear_logits = Add()([dense_logits_output, sparse_logits_output])
return linear_logits
class FM_Layer(Layer):
def __init__(self):
super(FM_Layer, self).__init__()
def call(self, inputs):
"""
优化后的公式: 0.5 * 求和(和的平方 - 平方的和) =>> B x 1
"""
# B x n x K
concated_embeds_value = inputs
# B x 1 x k
square_of_sum = tf.square(tf.reduce_sum(concated_embeds_value, axis = 1,keepdims = True ))
sum_of_square = tf.reduce_sum(concated_embeds_value * concated_embeds_value, axis = 1,keepdims = True)
cross_term = square_of_sum - sum_of_square
# B x 1
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)
def get_fm_logits(sparse_input_dict, sparse_feature_columns, dnn_embedding_layers):
# 讲特征中的squarse特征筛选出来
sqarse_feature_columns = list(filter(lambda x: isinstance(x, SparseFeat), sparse_feature_columns))
# 只考虑sparse的二阶交叉,将所有的embedding拼接到一起进行FM计算
# 因为类别型数据输入的只有0和1所以不需要考虑将隐向量与X相乘, 直接对隐向量进行操作即可
sparse_kd_embed = []
for fc in sparse_feature_columns:
feat_input = sparse_input_dict[fc.name]
_embed = dnn_embedding_layers[fc.name](feat_input) # B x 1 x k
sparse_kd_embed.append(_embed)
# 将所有sparse的embedding拼接起来,得到( n, k)的矩阵, 其中n为特征数,k为embedding大小
# B x n x k
concat_sparse_kd_embed = Concatenate(axis = 1)(sparse_kd_embed)
fm_cross_out = FM_Layer()(concat_sparse_kd_embed)
return fm_cross_out
def get_dnn_logits(sparse_input_dict, sparse_feature_columns, dnn_embedding_layers):
# 将特征中的sparse特征筛选出来
sparse_feature_columns = list(filter(lambda x: isinstance(x, SparseFeat), sparse_feature_columns))
# 将所有非零的sparse特征对应的embedding拼接到一起
sparse_kd_embed = []
for fc in sparse_feature_columns:
feat_input = sparse_input_dict[fc.name]
_embed = dnn_embedding_layers[fc.name](feat_input) # B x 1 x k
_embed = Flatten()(_embed) # B x k
sparse_kd_embed.append(_embed)
concat_sparse_kd_embed = Concatenate(axis=1)(sparse_kd_embed) # B x nk
# dnn层,这里的Dropout参数,Dense中的参数都可以自己设定,以及Dense的层数都可以自行设定
mlp_out = Dropout(0.5)(Dense(256, activation='relu')(concat_sparse_kd_embed))
mlp_out = Dropout(0.3)(Dense(256, activation='relu')(mlp_out))
mlp_out = Dropout(0.1)(Dense(256, activation='relu')(mlp_out))
dnn_out = Dense(1)(mlp_out)
return dnn_out
def DeepFM(linear_feature_columns, dnn_feature_columns):
# 构建输入层,即所有特征对应的Input()层,这里使用字典的形式返回,方便后续构建模型
dense_input_dict, sparse_input_dict = build_input_layers(linear_feature_columns + dnn_feature_columns)
# 将linear部分的特征中sparse特征筛选出来,后面用来做1维的embedding
linear_sparse_feature_columns = list(filter(lambda x: isinstance(x, SparseFeat), linear_feature_columns))
# 构建模型的输入层,模型的输入层不能是字典的形式,应该将字典的形式转换成列表的形式
# 注意:这里实际的输入与Input()层的对应,是通过模型输入时候的字典数据的key与对应name的Input层
input_layers = list(dense_input_dict.values()) + list(sparse_input_dict.values())
# linear_logits由两部分组成,分别是dense特征的logits和sparse特征的logits
linear_logits = get_linear_logits(dense_input_dict, sparse_input_dict, linear_sparse_feature_columns)
# 构建维度为k的embedding层,这里使用字典的形式返回,方便后面搭建模型
# embedding层用户构建FM交叉部分和DNN的输入部分
embedding_layers = build_embedding_layers(dnn_feature_columns, sparse_input_dict, is_linear=False)
# 将输入到dnn中的所有sparse特征筛选出来
dnn_sparse_feature_columns = list(filter(lambda x: isinstance(x, SparseFeat), dnn_feature_columns))
fm_logits = get_fm_logits(sparse_input_dict, dnn_sparse_feature_columns, embedding_layers) # 只考虑二阶项
# 将所有的Embedding都拼起来,一起输入到dnn中
dnn_logits = get_dnn_logits(sparse_input_dict, dnn_sparse_feature_columns, embedding_layers)
# 将linear,FM,dnn的logits相加作为最终的logits
output_logits = Add()([linear_logits, fm_logits, dnn_logits])
# 这里的激活函数使用sigmoid
output_layers = Activation("sigmoid")(output_logits)
model = Model(input_layers, output_layers)
return model
if __name__ == "__main__":
# 读取数据
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']
# 将特征分组,分成linear部分和dnn部分(根据实际场景进行选择),并将分组之后的特征做标记(使用DenseFeat, SparseFeat)
linear_feature_columns = [SparseFeat(feat, vocabulary_size=data[feat].nunique(),embedding_dim=4)
for i,feat in enumerate(sparse_features)] + [DenseFeat(feat, 1,)
for feat in dense_features]
dnn_feature_columns = [SparseFeat(feat, vocabulary_size=data[feat].nunique(),embedding_dim=4)
for i,feat in enumerate(sparse_features)] + [DenseFeat(feat, 1,)
for feat in dense_features]
# 构建DeepFM模型
history = DeepFM(linear_feature_columns, dnn_feature_columns)
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=8, validation_split=0.2, )
==================================================================================================
Total params: 170,125
Trainable params: 170,125
Non-trainable params: 0
__________________________________________________________________________________________________
Epoch 1/8
3/3 [==============================] - ETA: 0s - loss: 0.8385 - binary_crossentropy: 0.8385 - auc: 0.453 - 1s 332ms/step - loss: 1.0251 - binary_crossentropy: 1.0251 - auc: 0.5025 - val_loss: 1.1393 - val_binary_crossentropy: 1.1393 - val_auc: 0.6481
Epoch 2/8
3/3 [==============================] - ETA: 0s - loss: 0.9249 - binary_crossentropy: 0.9249 - auc: 0.445 - 0s 26ms/step - loss: 0.9782 - binary_crossentropy: 0.9782 - auc: 0.5065 - val_loss: 1.0822 - val_binary_crossentropy: 1.0822 - val_auc: 0.6368
Epoch 3/8
3/3 [==============================] - ETA: 0s - loss: 0.6185 - binary_crossentropy: 0.6185 - auc: 0.608 - 0s 28ms/step - loss: 0.9260 - binary_crossentropy: 0.9260 - auc: 0.5326 - val_loss: 1.0137 - val_binary_crossentropy: 1.0137 - val_auc: 0.6453
Epoch 4/8
3/3 [==============================] - ETA: 0s - loss: 0.7218 - binary_crossentropy: 0.7218 - auc: 0.606 - 0s 29ms/step - loss: 0.8665 - binary_crossentropy: 0.8665 - auc: 0.5416 - val_loss: 0.9309 - val_binary_crossentropy: 0.9309 - val_auc: 0.6467
Epoch 5/8
3/3 [==============================] - ETA: 0s - loss: 0.4631 - binary_crossentropy: 0.4631 - auc: 0.700 - 0s 24ms/step - loss: 0.7977 - binary_crossentropy: 0.7977 - auc: 0.5513 - val_loss: 0.8414 - val_binary_crossentropy: 0.8414 - val_auc: 0.6496
Epoch 6/8
3/3 [==============================] - ETA: 0s - loss: 0.9847 - binary_crossentropy: 0.9847 - auc: 0.415 - 0s 23ms/step - loss: 0.7581 - binary_crossentropy: 0.7581 - auc: 0.5584 - val_loss: 0.7629 - val_binary_crossentropy: 0.7629 - val_auc: 0.6581
Epoch 7/8
3/3 [==============================] - ETA: 0s - loss: 0.7810 - binary_crossentropy: 0.7810 - auc: 0.591 - 0s 23ms/step - loss: 0.6955 - binary_crossentropy: 0.6955 - auc: 0.5933 - val_loss: 0.7338 - val_binary_crossentropy: 0.7338 - val_auc: 0.6681
Epoch 8/8
3/3 [==============================] - ETA: 0s - loss: 0.6558 - binary_crossentropy: 0.6558 - auc: 0.569 - 0s 28ms/step - loss: 0.6664 - binary_crossentropy: 0.6664 - auc: 0.6306 - val_loss: 0.7323 - val_binary_crossentropy: 0.7323 - val_auc: 0.6724
思考题解答
1. 如果对于FM采用随机梯度下降SGD训练模型参数,请写出模型各个参数的梯度和FM参数训练的复杂度?
2. 对于下图(3-1)所示,根据你的理解Sparse Feature中的不同颜色节点分别表示什么意思?
解答:
一个圆点是一个神经元 ,每个field只有一个神经元标记为黄色,可以记作1,其他记作0,field到embedding voctor的过程中只有黄色(1)这个是起作用的。可以理解为 embedding voctor (嵌入向量的具体值)是该神经元(标记为黄色)相连的5条线的权重。我也是这么理解的。