结合代码带你理解 DeepFM

阅读数:4047 2019 年 5 月 15 日

闲谈

众所周知,自从人工智能火了以后,大家现在全民 AI,连小学生中学生都在搞所谓的 AI。AI 的实现应该靠算法与硬件的结合,但是国内貌似搞算法的远超搞硬件的。现阶段来看,算法层面上,主要靠深度网络。我理解所谓的深度网络,就是用一系列的线性函数模拟复杂的非线性函数。举个简单例子,一个正弦函数,我们可以将他的作用域划分成一系列的小区间,将每个区间端点的函数值用直线连接起来。如果这些区间足够小,就可以用一系列的一次线性函数拟合这个正弦函数。神经网络中,每个神经元就可以模拟一个区间。所以理论上,一个两层的神经网络,就可以拟合任意一个复杂的函数。

最近工作中遇到了类似 ctr 点击的问题,所以看了一些这方面深度学习的方法。 ctr 最开始用逻辑回归算法,后来发展使用了 FM 算法。FM 算法中,解决了特征之间的 interaction 的问题。随后研究人员提出使用深度学习的方法来做 ctr 问题,神经网络可以解决的是高层特征的 interaction 问题,不仅仅解决了两阶 interaction 的问题。言归正传,下面我们开始介绍 DeepFM 这篇文章:

https://arxiv.org/pdf/1703.04247.pdf

DeepFM

相当于 Google 的 Wide&Deep ,这篇文章 wide 的部分是需要预先训练的 FM 算法,也就是说它不是一个 end-to-end 的方法。DeepFM 算法将 FM 算法作为一个训练的参数放到网络中,直接使用原始的特征输入,只需要告诉网络你的特征哪个是 numerical ,哪个是 categories 的特征。DeepFM 在 Wide&Deep 的基础上进行改进,成功解决了这两个问题,并做了一些改进,其优势 / 优点如下:

  • 不需要预训练 FM 得到隐向量;
  • 不需要人工特征工程;
  • 能同时学习低阶和高阶的组合特征;
  • FM 模块和 Deep 模块共享 Feature Embedding 部分,可以更快的训练,以及更精确的训练学习。

下面我们结合 kaggle 比赛的一个数据和代码进行分析。

1. 输入处理

首先,利用 pandas 读取数据,然后获得对应的特征和 target ,保存到对应的变量中。并且将 categories 的变量保存下来。

复制代码
# 加载数据
def _load_data():
# 读取 csv 文件
dfTrain = pd.read_csv(config.TRAIN_FILE)
dfTest = pd.read_csv(config.TEST_FILE)
def preprocess(df):
cols = [c for c in df.columns if c not in ["id", "target"]]
df["missing_feat"] = np.sum((df[cols] == -1).values, axis=1)
df["ps_car_13_x_ps_reg_03"] = df["ps_car_13"] * df["ps_reg_03"]
return df
dfTrain = preprocess(dfTrain)
dfTest = preprocess(dfTest)
cols = [c for c in dfTrain.columns if c not in ["id", "target"]]
cols = [c for c in cols if (not c in config.IGNORE_COLS)]# 只保留我们需要的列
X_train = dfTrain[cols].values
y_train = dfTrain["target"].values
X_test = dfTest[cols].values
ids_test = dfTest["id"].values
cat_features_indices = [i for i,c in enumerate(cols) if c in config.CATEGORICAL_COLS]
return dfTrain, dfTest, X_train, y_train, X_test, ids_test, cat_features_indices

2. 创建模型

复制代码
fd = FeatureDictionary(dfTrain=dfTrain, dfTest=dfTest,
numeric_cols=config.NUMERIC_COLS,
ignore_cols=config.IGNORE_COLS)

2.1 创建一个特征处理的字典

首先,创建一个特征处理的字典。在初始化方法中,传入第一步读取得到的训练集和测试集。然后生成字典,在生成字典中,循环遍历特征的每一列,如果当前的特征是数值型的,直接将特征作为键值,和目前对应的索引作为 value 存到字典中。如果当前的特征是 categories ,统计当前的特征总共有多少个不同的取值,这时候当前特征在字典的 value 就不是一个简单的索引了,value 也是一个字典,特征的每个取值作为 key,对应的索引作为 value,组成新的字典。总而言之,这里面主要是计算了特征的的维度,numerical 的特征只占一位,categories 的特征有多少个取值,就占多少位。

复制代码
class FeatureDictionary(object):
def __init__(self, trainfile=None, testfile=None,
dfTrain=None, dfTest=None, numeric_cols=[], ignore_cols=[]):
assert not ((trainfile is None) and (dfTrain is None)), "trainfile or dfTrain at least one is set"
assert not ((trainfile is not None) and (dfTrain is not None)), "only one can be set"
assert not ((testfile is None) and (dfTest is None)), "testfile or dfTest at least one is set"
assert not ((testfile is not None) and (dfTest is not None)), "only one can be set"
self.trainfile = trainfile
self.testfile = testfile
self.dfTrain = dfTrain
self.dfTest = dfTest
self.numeric_cols = numeric_cols
self.ignore_cols = ignore_cols
# 根据特征的种类是 numerical 还是 categories 的类别 计算输入到网络里面的特征的长度
self.gen_feat_dict()
def gen_feat_dict(self):
if self.dfTrain is None:
dfTrain = pd.read_csv(self.trainfile)
else:
dfTrain = self.dfTrain
if self.dfTest is None:
dfTest = pd.read_csv(self.testfile)
else:
dfTest = self.dfTest
df = pd.concat([dfTrain, dfTest])
self.feat_dict = {}
tc = 0
# 通过下面的循环 计算输入到模型中特征的总的长度
for col in df.columns:
if col in self.ignore_cols:
continue
if col in self.numeric_cols:
# map to a single index
self.feat_dict[col] = tc
tc += 1
else:
us = df[col].unique()# 查看当前 categories 种类的特征有多少个唯一的值
self.feat_dict[col] = dict(zip(us, range(tc, len(us)+tc)))
tc += len(us)
self.feat_dim = tc

2.2 数据解析

复制代码
data_parser = DataParser(feat_dict=fd)
# 解析数据 Xi_train 存放的是特征对应的索引 Xv_train 存放的是特征的具体的值
Xi_train, Xv_train, y_train = data_parser.parse(df=dfTrain, has_label=True)
Xi_test, Xv_test, ids_test = data_parser.parse(df=dfTest)
在解析数据中,逐行处理每一条数据,dfi 记录了当前的特征在总的输入的特征中的索引。dfv 中记录的是具体的值,如果是 numerical 特征,存的是原始的值,如果是 categories 类型的,就存放 1。这个相当于进行了 one-hot 编码,在 dfi 存储了特征所在的索引。输入到网络中的特征的长度是 numerical 特征的个数 +categories 特征 one-hot 编码的长度。最终,Xi 和 Xv 是一个二维的 list,里面的每一个 list 是一行数据,Xi 存放的是特征所在的索引,Xv 存放的是具体的特征值。
复制代码
# 解析数据
class DataParser(object):
def __init__(self, feat_dict):
self.feat_dict = feat_dict
def parse(self, infile=None, df=None, has_label=False):
assert not ((infile is None) and (df is None)), "infile or df at least one is set"
assert not ((infile is not None) and (df is not None)), "only one can be set"
if infile is None:
dfi = df.copy()
else:
dfi = pd.read_csv(infile)
if has_label:
y = dfi["target"].values.tolist()
dfi.drop(["id", "target"], axis=1, inplace=True)
else:
ids = dfi["id"].values.tolist()
dfi.drop(["id"], axis=1, inplace=True)
# dfi for feature index
# dfv for feature value which can be either binary (1/0) or float (e.g., 10.24)
# dfi 记录的是特征所对应的索引 也就是输入样本在输入维度的第几个地方不等于 0 dfv 记录的是特征的具体的值
dfv = dfi.copy()
for col in dfi.columns:
if col in self.feat_dict.ignore_cols:
dfi.drop(col, axis=1, inplace=True)
dfv.drop(col, axis=1, inplace=True)
continue
if col in self.feat_dict.numeric_cols:
dfi[col] = self.feat_dict.feat_dict[col]
else:
dfi[col] = dfi[col].map(self.feat_dict.feat_dict[col])
dfv[col] = 1.
# list of list of feature indices of each sample in the dataset
Xi = dfi.values.tolist()
# list of list of feature values of each sample in the dataset
Xv = dfv.values.tolist()
if has_label:
return Xi, Xv, y
else:
return Xi, Xv, ids

2.3 准备 batch

复制代码
# 解析数据 Xi_train 存放的是特征对应的索引 Xv_train 存放的是特征的具体的值
Xi_train, Xv_train, y_train = data_parser.parse(df=dfTrain, has_label=True)
Xi_test, Xv_test, ids_test = data_parser.parse(df=dfTest)
#feature_size 记录特征的维度 field_size 记录了特征的个数
dfm_params["feature_size"] = fd.feat_dim
dfm_params["field_size"] = len(Xi_train[0])
y_train_meta = np.zeros((dfTrain.shape[0], 1), dtype=float)
y_test_meta = np.zeros((dfTest.shape[0], 1), dtype=float)
_get = lambda x, l: [x[i] for i in l]
gini_results_cv = np.zeros(len(folds), dtype=float)
gini_results_epoch_train = np.zeros((len(folds), dfm_params["epoch"]), dtype=float)
gini_results_epoch_valid = np.zeros((len(folds), dfm_params["epoch"]), dtype=float)
for i, (train_idx, valid_idx) in enumerate(folds):
Xi_train_, Xv_train_, y_train_ = _get(Xi_train, train_idx), _get(Xv_train, train_idx), _get(y_train, train_idx)
Xi_valid_, Xv_valid_, y_valid_ = _get(Xi_train, valid_idx), _get(Xv_train, valid_idx), _get(y_train, valid_idx)

2.4 构造 DeepFM 模型

  1. 在初始化方法中,先设置一些初始化的参数,比如特征的长度,总共有多少个原始的字段等。然后最重要的就是初始化图 self._init_graph() 方法。
  2. 在构造图的方法中,先定义了 6 个 placeholder ,每个大小的 None 代表的是 batch_size 的大小。
复制代码
self.feat_index = tf.placeholder(tf.int32, shape=[None, None],
name="feat_index") # None * F batch_size * field_size
self.feat_value = tf.placeholder(tf.float32, shape=[None, None],
name="feat_value") # None * F batch_size * field_size
self.label = tf.placeholder(tf.float32, shape=[None, 1], name="label") # None * 1
self.dropout_keep_fm = tf.placeholder(tf.float32, shape=[None], name="dropout_keep_fm")
self.dropout_keep_deep = tf.placeholder(tf.float32, shape=[None], name="dropout_keep_deep")
self.train_phase = tf.placeholder(tf.bool, name="train_phase")

在这之后,调用权重的初始化方法。将所有的权重放到一个字典中。feature_embeddings 本质上就是 FM 中的 latent vector 。对于每一个特征都建立一个隐特征向量。feature_bias 代表了 FM 中的 w 的权重。然后就是搭建深度图,输入到深度网络的大小为:特征的个数 * 每个隐特征向量的长度。根据每层的配置文件,生产相应的权重。对于输出层,根据不同的配置,生成不同的输出的大小。如果只是使用 FM 算法,那么

复制代码
# todo 初始化权重
def _initialize_weights(self):
weights = dict()
# embeddings
weights["feature_embeddings"] = tf.Variable(
tf.random_normal([self.feature_size, self.embedding_size], 0.0, 0.01),
name="feature_embeddings") # feature_size * K
weights["feature_bias"] = tf.Variable(
tf.random_uniform([self.feature_size, 1], 0.0, 1.0), name="feature_bias") # feature_size * 1
# deep layers
num_layer = len(self.deep_layers)
# 计算输入的大小
input_size = self.field_size * self.embedding_size
#todo ======================== 第一层的网络结构 =============================
glorot = np.sqrt(2.0 / (input_size + self.deep_layers[0]))
weights["layer_0"] = tf.Variable(
np.random.normal(loc=0, scale=glorot, size=(input_size, self.deep_layers[0])), dtype=np.float32)
weights["bias_0"] = tf.Variable(np.random.normal(loc=0, scale=glorot, size=(1, self.deep_layers[0])),
dtype=np.float32) # 1 * layers[0]
for i in range(1, num_layer):
glorot = np.sqrt(2.0 / (self.deep_layers[i-1] + self.deep_layers[i]))
weights["layer_%d" % i] = tf.Variable(
np.random.normal(loc=0, scale=glorot, size=(self.deep_layers[i-1], self.deep_layers[i])),
dtype=np.float32) # layers[i-1] * layers[i]
weights["bias_%d" % i] = tf.Variable(
np.random.normal(loc=0, scale=glorot, size=(1, self.deep_layers[i])),
dtype=np.float32) # 1 * layer[i]
# final concat projection layer
if self.use_fm and self.use_deep:
input_size = self.field_size + self.embedding_size + self.deep_layers[-1]
elif self.use_fm:
input_size = self.field_size + self.embedding_size
elif self.use_deep:
input_size = self.deep_layers[-1]
glorot = np.sqrt(2.0 / (input_size + 1))
weights["concat_projection"] = tf.Variable(
np.random.normal(loc=0, scale=glorot, size=(input_size, 1)),
dtype=np.float32) # layers[i-1]*layers[i]
weights["concat_bias"] = tf.Variable(tf.constant(0.01), dtype=np.float32)
return weights
  1. 根据每次输入的特征的索引,从隐特征向量中取出其对应的隐向量。将每一个特征对应的具体的值,和自己对应的隐向量相乘。如果是 numerical 的,就直接用对应的 value 乘以隐向量。如果是 categories 的特征,其对应的特征值是 1,相乘完还是原来的隐向量。最后,self.embeddings 存放的就是输入的样本的特征值和隐向量的乘积。大小为 batch_sizefield_sizeembedding_size
复制代码
self.embeddings = tf.nn.embedding_lookup(self.weights["feature_embeddings"],self.feat_index) # None * F * K 由于 one-hot 是 01 编码 所以取出的大小为 batch_size*field_size*embedding_size
feat_value = tf.reshape(self.feat_value, shape=[-1, self.field_size, 1])
self.embeddings = tf.multiply(self.embeddings, feat_value) # 两个矩阵中对应元素各自相乘 这个就是就是每个值和自己的隐向量的乘积
  1. 计算一阶项,从 self.weights[“feature_bias”] 取出对应的 w ,得到一阶项,大小为 batch_size*field_size。

二阶项的计算,也就是 FM 的计算,利用了的技巧。先将 embeddings 在 filed_size 的维度上求和,最后得到红框里面的项。

  1. 计算 deep 的项。将 self.embeddings(大小为 batch_sizeself.field_size * self.embedding_size) reshape 成 batch_size(self.field_size * self.embedding_size) 的大小,然后输入到网络里面进行计算。
  2. 最后将所有项 concat 起来,投影到一个值。如果是只要 FM ,不要 deep 的部分,则投影的大小为 filed_size+embedding_size 的大小。如果需要 deep 的部分,则大小再加上 deep 的部分。利用最后的全连接层,将特征映射到一个 scalar 。
  3. 最后一项就是定义损失和优化器。
复制代码
# loss 损失函数
if self.loss_type == "logloss":
self.out = tf.nn.sigmoid(self.out)
self.loss = tf.losses.log_loss(self.label, self.out)
elif self.loss_type == "mse":
self.loss = tf.nn.l2_loss(tf.subtract(self.label, self.out))
# l2 regularization on weights 正则化项目
if self.l2_reg > 0:
self.loss += tf.contrib.layers.l2_regularizer(
self.l2_reg)(self.weights["concat_projection"])
if self.use_deep:
for i in range(len(self.deep_layers)):
self.loss += tf.contrib.layers.l2_regularizer(
self.l2_reg)(self.weights["layer_%d"%i])
# optimizer
if self.optimizer_type == "adam":
self.optimizer = tf.train.AdamOptimizer(learning_rate=self.learning_rate, beta1=0.9, beta2=0.999,
epsilon=1e-8).minimize(self.loss)
elif self.optimizer_type == "adagrad":
self.optimizer = tf.train.AdagradOptimizer(learning_rate=self.learning_rate,
initial_accumulator_value=1e-8).minimize(self.loss)
elif self.optimizer_type == "gd":
self.optimizer = tf.train.GradientDescentOptimizer(learning_rate=self.learning_rate).minimize(self.loss)
elif self.optimizer_type == "momentum":
self.optimizer = tf.train.MomentumOptimizer(learning_rate=self.learning_rate, momentum=0.95).minimize(
self.loss)
elif self.optimizer_type == "yellowfin":
self.optimizer = YFOptimizer(learning_rate=self.learning_rate, momentum=0.0).minimize(
self.loss)

作者介绍:

王腾龙,滴滴算法工程师,中科院硕士,主要研究方向为机器学习与 Deepctr 。

本文来自 王腾龙 在 DataFun 社区的演讲,由 DataFun 编辑整理。

收藏

评论

微博

发表评论

注册/登录 InfoQ 发表评论