相信推荐算法小白们应该没有不知道deepctr这个包的了吧,这个包由于它的易用、易扩展性在学术界、工业界以及"比赛界",都有大量的用户在使用,并且其中实现了非常多的经典的ctr模型,如果我们熟悉其代码风格的话,就可以很容易的熟悉经典ctr模型的实现原理。fun-rec开源项目中的代码也是参考deepctr的风格来实现的,为了让推荐小白们更容易理解deepctr的代码或者fun-rec中的代码,本文对deepctr中模型的构建逻辑进行一个总结。
在fun-rec交流社群里面发现很多问deepctr代码问题的时候,绝大部分情况都是对keras中函数式API的调用方式不是很熟悉,所以在介绍deepctr代码逻辑之前,会简单的介绍一下keras函数式API构建模型的基本思路。
如果对于Keras本身都不了解的建议去keras官网(最新的)、或者查找一些博客熟悉一下,下面主要介绍一下keras函数式API(Keras functional API)的使用方法以及它的特点。
keras构建模型通常有三种方法:
使用函数式API定义模型的基本步骤:
import numpy as np
import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers
# 定义模型的输入层
inputs = keras.Input(shape=(784,))
# 定义模型的中间层
# 这里就体现了函数式API了,将上一层的结果输入到当前层中
x= layers.Dense(64, activation="relu")(inputs)
# 定义模型的输出层
# 将中间层的结果输入到定义的输出层中
outputs = layers.Dense(10)(x)
# 构建模型
model = keras.Model(inputs=inputs, outputs=outputs, name="test_model")
# 查看一下模型层的结构及相关参数形状
model.summary()
# 可视化模型结构
keras.utils.plot_model(model, "my_first_model.png")
上面就是通过函数式API构建模型的基本思路,在构建完了模型之后,keras还可以使用统一的方式来训练、验证和测试,细节可以参考Keras官网上面的教程。
上面的对keras函数式API构建模型做了个简单的介绍,很容易看出,keras构建模型其实主要是通过构建输入和输出之间的关系来定义计算图的,而中间层计算图的构建又是直接将上一层的结果通过类似函数传参的方式传给下一层中,这样可以很容易实现多输入、输出,多分支,以及层的共享(只要让多个不同的输入传到同一个层中就行了),更多案例可以看一下参考资料中的keras教程。
deepctr在构建模型的时候,不仅是利用了keras这种函数式API的构建方式,还将推荐算法中常用的特征处理进行了统一,所以先简单总结一些推荐算法中常用的特征有哪些:
对于上述的三类特征,deepctr分别定义了三种特征标记的类:SparseFeat、VarLenSparseFeat和DenseFeat。这三个类是用来标记特征的,在构建模型的时候,可以通过特征的标记来构建不同的Input层和对应的Embedding层,和特征最靠近的网络层也就是这两个层了。下面对这三个类的几个主要参数来做个简单的介绍:
class SparseFeat(namedtuple('SparseFeat',
['name', # 特征的名称
'vocabulary_size', # id特征的词典大小
'embedding_dim', # id特征转化成向量的维度
'use_hash',
'vocabulary_path',
'dtype',
'embeddings_initializer', # embedding的初始化方式
'embedding_name',
'group_name', # 该特征所属的组
'trainable'])): # 该id类特征对应的Embedding层是否可训练
pass
class VarLenSparseFeat(namedtuple('VarLenSparseFeat',
['sparsefeat',
'maxlen', # id序列的最大长度
'combiner', # 序列特征聚合的方式(例如mean pooling)
'length_name', # 序列特征对应的有效长度的特征名(也是一个特征)
'weight_name', # 序列特征对应聚合权重的特征名(也是一个特征)
'weight_norm'])):
pass
class DenseFeat(namedtuple('DenseFeat',
['name', # 数值特征的名称
'dimension', # 数值特征的维度,注意区分是单个数值特征还是向量数值特征
'dtype', # 数值类型
'transform_fn'])): # 数值特征的转换方式,例如有些特征需要做log变换
有了上述三类特征标记的类,我们就可以通过给每个特征都设置好对应的标记参数,这样就可以对所有特征自动化的构建对应的Input层和Embedding层了。
deepctr中使用了build_input_features方法来构造Input层,从下面的方法中可以看出,传入的参数是feature_columns,这个就是我们提前根据特征定义好的特征标记数组,此外这个方法返回的是一个字典,key是特征名,value是特征对应的Input层,便于后续使用(例如将Input层输入到对应特征的Embedding层中)
def build_input_features(feature_columns, prefix=''):
input_features = OrderedDict()
for fc in feature_columns:
if isinstance(fc, SparseFeat):
input_features[fc.name] = Input(
shape=(1,), name=prefix + fc.name, dtype=fc.dtype)
elif isinstance(fc, DenseFeat):
input_features[fc.name] = Input(
shape=(fc.dimension,), name=prefix + fc.name, dtype=fc.dtype)
elif isinstance(fc, VarLenSparseFeat):
input_features[fc.name] = Input(shape=(fc.maxlen,), name=prefix + fc.name,
dtype=fc.dtype)
if fc.weight_name is not None:
input_features[fc.weight_name] = Input(shape=(fc.maxlen, 1), name=prefix + fc.weight_name,
dtype="float32")
if fc.length_name is not None:
input_features[fc.length_name] = Input((1,), name=prefix + fc.length_name, dtype='int32')
else:
raise TypeError("Invalid feature column type,got", type(fc))
return input_features
由于在推荐场景下绝大部分都会存在id类特征,所以在构造完了Input层之后,就是构造对应id特征的Embedding层了。对于id类特征,输入的其实是一个id编号,或者说一个数字,然后再将这个数字转换成一个embedding向量,这个过程分为两步
这两个步骤在deepctr中都放在了input_from_feature_columns方法中,先从大体上看一下这个方法的整体逻辑,如下:
def input_from_feature_columns(features, feature_columns, l2_reg, seed, prefix='', seq_mask_zero=True,
support_dense=True, support_group=False):
# 筛选出id类和id序列类特征
sparse_feature_columns = list(
filter(lambda x: isinstance(x, SparseFeat), feature_columns)) if feature_columns else []
varlen_sparse_feature_columns = list(
filter(lambda x: isinstance(x, VarLenSparseFeat), feature_columns)) if feature_columns else []
# 返回一个Embedding层的字典
embedding_matrix_dict = create_embedding_matrix(feature_columns, l2_reg, seed, prefix=prefix,
seq_mask_zero=seq_mask_zero)
# features是一个Input层的字典
# sparse_feature_columns表示的是id类特征
# embedding_lookup函数是用来从Embedding层中获取对应的结果,就是将对应特征的Input层输入到Embedding层中去
# 返回的仍然是一个dict, 此时返回的内容中,embedding层和input层已经连接到一起了,并且还将同一组的id类特征
# 放到了一个列表中,方便同组id类特征的处理
group_sparse_embedding_dict = embedding_lookup(embedding_matrix_dict, features, sparse_feature_columns)
# 获取dense类特征的所有Input层,在这里可以做一些dense特征的数学变换
# 最终返回的是一个Input层的列表
dense_value_list = get_dense_input(features, feature_columns)
if not support_dense and len(dense_value_list) > 0:
raise ValueError("DenseFeat is not supported in dnn_feature_columns")
# 将id序列的embedding与对应的Input层进行关联
sequence_embed_dict = varlen_embedding_lookup(embedding_matrix_dict, features, varlen_sparse_feature_columns)
# 将id序列embedding进行池化操作,返回一个池化厚的向量列表
group_varlen_sparse_embedding_dict = get_varlen_pooling_list(sequence_embed_dict, features,
varlen_sparse_feature_columns)
# 将字典中的所有层都进行合并
group_embedding_dict = mergeDict(group_sparse_embedding_dict, group_varlen_sparse_embedding_dict)
# 如果当前特征处理过程中没有用到分组的功能的话,就直接将字典中的所有层都转换成一个列表返回
if not support_group:
group_embedding_dict = list(chain.from_iterable(group_embedding_dict.values()))
# group_embedding_dict代表的是一个字典或者一个列表,这取决于当前特征是否支持分组
return group_embedding_dict, dense_value_list
其实在input_from_feature_columns方法中,除了上述的两个逻辑,还有其他的一些逻辑,先介绍一下上面的两个逻辑具体是怎么实现的:
根据id类特征的词表大小定义一个Embedding层:
def create_embedding_matrix(feature_columns, l2_reg, seed, prefix="", seq_mask_zero=True):
from . import feature_column as fc_lib
# 将feature_cloumns中的sparsefeat 和 varlen_sparsefeat 特征分开,方便分开创建Embedding层
sparse_feature_columns = list(
filter(lambda x: isinstance(x, fc_lib.SparseFeat), feature_columns)) if feature_columns else []
varlen_sparse_feature_columns = list(
filter(lambda x: isinstance(x, fc_lib.VarLenSparseFeat), feature_columns)) if feature_columns else []
# 创建Embedding层的字典
sparse_emb_dict = create_embedding_dict(sparse_feature_columns, varlen_sparse_feature_columns, seed,
l2_reg, prefix=prefix + 'sparse', seq_mask_zero=seq_mask_zero)
return sparse_emb_dict
# 其中create_embedding_dict逻辑如下
def create_embedding_dict(sparse_feature_columns, varlen_sparse_feature_columns, seed, l2_reg,
prefix='sparse_', seq_mask_zero=True):
sparse_embedding = {}
for feat in sparse_feature_columns:
emb = Embedding(feat.vocabulary_size, feat.embedding_dim,
embeddings_initializer=feat.embeddings_initializer,
embeddings_regularizer=l2(l2_reg),
name=prefix + '_emb_' + feat.embedding_name)
emb.trainable = feat.trainable
sparse_embedding[feat.embedding_name] = emb
# 对于id序列和id特征的Embedding层只有一个参数是不一样的,那就是mask_zero的设置,如果设置为True,
# 表示的是取出来的是一个embedding序列,如果长度不够最大长度的用0填充
if varlen_sparse_feature_columns and len(varlen_sparse_feature_columns) > 0:
for feat in varlen_sparse_feature_columns:
# if feat.name not in sparse_embedding:
emb = Embedding(feat.vocabulary_size, feat.embedding_dim,
embeddings_initializer=feat.embeddings_initializer,
embeddings_regularizer=l2(
l2_reg),
name=prefix + '_seq_emb_' + feat.name,
mask_zero=seq_mask_zero)
emb.trainable = feat.trainable
sparse_embedding[feat.embedding_name] = emb
return sparse_embedding
通过create_embedding_dict方法构造Embedding层, 具体的函数调用语句是:
group_sparse_embedding_dict = embedding_lookup(embedding_matrix_dict, features, sparse_feature_columns)
其中传入的参数:
从传入的参数,我们就能大概知道其核心实现逻辑了:根据特征名,拿到对应特征的Input层和Embedding层,然后将Input层输入到对应的Embedding层中(这也是函数式API构建模型的基本操作)
def embedding_lookup(sparse_embedding_dict, sparse_input_dict, sparse_feature_columns, return_feat_list=(),
mask_feat_list=(), to_list=False):
# 定义个字典,字典中value默认是一个list,这么做是为了将id类特征进行分组,同一个组的id特征都会在一个列表中,
# 方便后面同组的特征做处理
group_embedding_dict = defaultdict(list)
for fc in sparse_feature_columns:
feature_name = fc.name
embedding_name = fc.embedding_name
if (len(return_feat_list) == 0 or feature_name in return_feat_list):
# 判断当前特征是否需要被哈希,如果需要的话,就将Input层输入到Hash层,得到输出
if fc.use_hash:
lookup_idx = Hash(fc.vocabulary_size, mask_zero=(feature_name in mask_feat_list), vocabulary_path=fc.vocabulary_path)(
sparse_input_dict[feature_name])
else:
# 否则直接取出当前特征的Input层
lookup_idx = sparse_input_dict[feature_name]
group_embedding_dict[fc.group_name].append(sparse_embedding_dict[embedding_name](lookup_idx))
# 如果最后想返回一个列表的话,需要将字典中不同组的embedding都拆开拼接到一个list中
if to_list:
return list(chain.from_iterable(group_embedding_dict.values()))
return group_embedding_dict
对于id序列特征而言,和id类特征是类似的,也是先定义Embedding层然后将对应的Input层输入进来。只不过id序列类特征可能会多一步序列聚合的操作,细节可以去看源码。
从input_from_feature_columns方法中我们看到,除了对id类特征进行了一些处理意外,还有对DenseFeat有简单的处理,这里就简单说一下DenseFeat和SparseFeat在输入到DNN之前需要应该做的事情:
通过上述的介绍,其实特征处理部分的逻辑基本上就做完了,后面就是将上述得到的Input层,或者Embedding层输出的embedding向量,输入到DNN,特征交叉等模块中进一步构造模型。
为了更全局的看一下deepctr是如何构建模型的,下面以deepfm模型为例,介绍一下整体的逻辑:
def DeepFM(linear_feature_columns, dnn_feature_columns, fm_group=(DEFAULT_GROUP_NAME,), dnn_hidden_units=(256, 128, 64),
l2_reg_linear=0.00001, l2_reg_embedding=0.00001, l2_reg_dnn=0, seed=1024, dnn_dropout=0,
dnn_activation='relu', dnn_use_bn=False, task='binary'):
"""这里把参数的介绍注释删了"""
# feature是一个字典,key是feature_name, value表示的是对应的Input层
# feature_columns是一个列表,里面每个元素都是DenseFeat, SparseFeat或者VarLenSparseFeat中的实例,这个列表可以用来构建特征的Input层
features = build_input_features(
linear_feature_columns + dnn_feature_columns)
# inputs_list表示的是所有特征的Input层,这个列表可以直接放到keras中的Model里面作为输入参数
inputs_list = list(features.values())
linear_logit = get_linear_logit(features, linear_feature_columns, seed=seed, prefix='linear',
l2_reg=l2_reg_linear)
# 这个过程在上述的get_linear_logit里面已经重复过一遍了
group_embedding_dict, dense_value_list = input_from_feature_columns(features, dnn_feature_columns, l2_reg_embedding,
seed, support_group=True)
# 对于所有embedding特征进行单独处理
fm_logit = add_func([FM()(concat_func(v, axis=1))
for k, v in group_embedding_dict.items() if k in fm_group])
# 最后将特征交叉部分与linear部分的dnn拼接
dnn_input = combined_dnn_input(list(chain.from_iterable(
group_embedding_dict.values())), dense_value_list)
dnn_output = DNN(dnn_hidden_units, dnn_activation, l2_reg_dnn, dnn_dropout, dnn_use_bn, seed=seed)(dnn_input)
dnn_logit = tf.keras.layers.Dense(
1, use_bias=False, kernel_initializer=tf.keras.initializers.glorot_normal(seed=seed))(dnn_output)
final_logit = add_func([linear_logit, fm_logit, dnn_logit])
output = PredictionLayer(task)(final_logit)
model = tf.keras.models.Model(inputs=inputs_list, outputs=output)
return model
本文主要是介绍deepctr构建模型的核心思想,主要内容包括:
其实deepctr所有模型的构造思路都是一样的,只要先把一个模型的构建细节搞清楚了基本上其他模型的构建就能很容易理解,熟悉了deepctr中模型的构建思路,再花一点时间,就可以将上面所有的经典的ctr模型的原理都看一遍了,甚至感兴趣的自己还可以全部手写一遍!
为了更清楚的了解DeepCTR开源项目的整体结构,我还做了一个思维导图,公众号回复deepctr,可以获取思维导图的PDF版
参考资料: