【总结】DeepCTR如何构建模型

文章目录

  • 前言
  • keras函数式API构建模型
  • deepctr中的特征标记
  • Input层的构建
  • Embedding层的构建
  • 不同类型特征的处理方式
  • deepctr模型的构建
  • 总结

前言

相信推荐算法小白们应该没有不知道deepctr这个包的了吧,这个包由于它的易用、易扩展性在学术界、工业界以及"比赛界",都有大量的用户在使用,并且其中实现了非常多的经典的ctr模型,如果我们熟悉其代码风格的话,就可以很容易的熟悉经典ctr模型的实现原理。fun-rec开源项目中的代码也是参考deepctr的风格来实现的,为了让推荐小白们更容易理解deepctr的代码或者fun-rec中的代码,本文对deepctr中模型的构建逻辑进行一个总结。

keras函数式API构建模型

在fun-rec交流社群里面发现很多问deepctr代码问题的时候,绝大部分情况都是对keras中函数式API的调用方式不是很熟悉,所以在介绍deepctr代码逻辑之前,会简单的介绍一下keras函数式API构建模型的基本思路。

如果对于Keras本身都不了解的建议去keras官网(最新的)、或者查找一些博客熟悉一下,下面主要介绍一下keras函数式API(Keras functional API)的使用方法以及它的特点。

keras构建模型通常有三种方法:

  1. 通过tf.keras.Sequential,线性的构造一个模型。但是这种方式无法实现比较复杂的模型(例如多输入,多输出,层共享等)
  2. 通过函数式API,构建模型。这种方式可以很方便的构造多输入、输出、网络层的共享、多分支链接、循环链接等复杂模型

使用函数式API定义模型的基本步骤:

  1. 定义模型的输入(Input层)
  2. 定义模型的中间层(比如Embedding层,卷积层、RNN等)
  3. 定义模型的输出层(根据不同的任务确定不同的输出)
  4. 通过tf.keras.Model,将上面定义的模型输入层和输出层,来构建模型,tf.keras.Model会根据用户定义的Input层到输出层,之间的图结构关系,自动的生成一个计算图,这也就是我们定义的模型
  5. 定义完模型就是模型的编译、训练和验证了
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中的特征标记

deepctr在构建模型的时候,不仅是利用了keras这种函数式API的构建方式,还将推荐算法中常用的特征处理进行了统一,所以先简单总结一些推荐算法中常用的特征有哪些:

  1. id类特征,这是推荐算法中最喜欢使用的特征
  2. id序列特征,例如带有先后顺序的行为序列、没有先后顺序的multi-hot编码的id特征
  3. 数值特征,单个数值(例如用户的年龄、视频的时长、文章的字数等),数值向量(例如通过预训练模型抽取的图片、视频、文本的向量表示),对于单个数值特征有时候还会通过分桶的方式将其转化为离散的id类特征

对于上述的三类特征,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层了。

Input层的构建

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

Embedding层的构建

由于在推荐场景下绝大部分都会存在id类特征,所以在构造完了Input层之后,就是构造对应id特征的Embedding层了。对于id类特征,输入的其实是一个id编号,或者说一个数字,然后再将这个数字转换成一个embedding向量,这个过程分为两步

  1. 根据id类特征的词表大小定义一个Embedding层
  2. 根据id类特征的索引去这个Embedding层找那个找到这个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层

  1. 先将id类特征和id序列类特征分成两个列表
  2. 然后通过create_embedding_dict方法构造Embedding层
  3. 构建Embedding层的具体逻辑如下:
    1. 遍历所有的id类特征和id序列类特征(这里遍历的其实是特征标记,也就是前面介绍的feature_column)
    2. 根据每个id类特征的标记(vocabulary_size, embedding_dim,embeddings_initializer,embeddings_regularizer等)定义对应Embedding层的词典大小,向量维度、Embedding层的初始化方法、正则化参数,以及Embedding层的名字
    3. 将构造完的Embedding层通过字典的形式返回,key是特征名字,value是特征对应的Embedding层(这里和Input层返回的格式非常像,后面会用到)
    4. 注意:对于id类特征和id序列类特征,在构造Embedding层的时候,唯一的区别就是,id序列类特征创建Embedding的时候多了一个参数mask_zero=True,这个的作用可以参考keras官网上对它的介绍
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)

其中传入的参数:

  1. embedding_matrix_dict:这个是前面根据feature_column构造的Embedding层,是一个字典,其key是特征名,value是特征对应的Embedding层
  2. features:这个是前面根据feature_column构造的Input层,是一个字典,其key是特征名,value是对应的Input层
  3. sparse_feature_columns:这个是从整个特征标记数组中筛选出来的id类特征,因为id类特征和id序列特征Embedding的处理方式是不一样的,所以这里是将这两类特征分开处理的,但是从代码中可以发现,创建Embedding层是放在一起的

从传入的参数,我们就能大概知道其核心实现逻辑了:根据特征名,拿到对应特征的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之前需要应该做的事情:

  1. DenseFeat,只需要将每个特征根据提前设置好的特征标记参数,构建好对应的Input层,然就就可以将这些Input层拼起来输入到DNN层里面去了,所以不需要做太多的其他处理
  2. SparseFeat,需要将每个特征根据提前设置好的特征标记参数,构建好对应的Input层以及Embedding层,然后将每个特征的Input层输入到Embedding层,这样输入的一个id索引,就可以得到对应的embedding向量了,得到了id特征的embedding向量,就可以经过DNN层或者各类特征交叉层了
  3. VarLenSparseFeat,与SparseFeat类似,但是有一点区别。也是先得到对应的Input、Embedding层,然后将Input层输入到Embedding层,此时得到的就不是一个向量了,而是一个向量序列(维度:batch_size x seq_len x embedding_dim),有时候会将这个向量序列做简单的池化操作,例如mean pooling,得到一个聚合后的向量特征。此外,有时候还可以使用序列模型,例如LSTM将序列输入到LSTM模块中,得到一个新的序列,再进行聚合。

通过上述的介绍,其实特征处理部分的逻辑基本上就做完了,后面就是将上述得到的Input层,或者Embedding层输出的embedding向量,输入到DNN,特征交叉等模块中进一步构造模型。

deepctr模型的构建

为了更全局的看一下deepctr是如何构建模型的,下面以deepfm模型为例,介绍一下整体的逻辑:

  1. 构建Input层,调用build_input_features方法,返回一个Input层的字典
  2. 生成Input层列表,执行inputs_list = list(features.values()),用于传入tf.keras.models.Model构建模型
  3. 获取linear层的logtis,调用get_linear_logit方法,这个细节可以看一下源码,也比较好理解
  4. 获取id类特征的Embedding层的特征(已经将id类特征的Input层输入到对应的Embedding层中去了)以及数值类特征的Input层结果列表,用于后续进一步通过复杂的网络模块(DNN, FM等)构造模型
  5. 计算FM logits, 执行FM方法,输入的特征是上述得到的id类特征的embedding
  6. 计算DNN logtis,需要先将上述Dense特征的Input层的结果和id类特征的embedding结果(已经将id类特征的Input层输入到对应的Embedding层中去了)拼接,然后输入到DNN中,得到DNNlogits
  7. 计算模型的输出结果,将linear_logits, FM_logits以及DNN logits结果相加,然后在经过一个PredictionLayer层得到最终模型的输出结果,PredictionLayer层对于不同的任务(分类、回归)不一样
  8. 根据模型的输入和输出构建模型,调用model = tf.keras.models.Model(inputs=inputs_list, outputs=output)
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构建模型的核心思想,主要内容包括:

  1. 介绍keras函数式API构建模型的方式
  2. deepctr中特征的标记方法及对应的参数含义
  3. deepctr中如何构建Input层、Embedding层
  4. deepctr构建模型的整体逻辑

其实deepctr所有模型的构造思路都是一样的,只要先把一个模型的构建细节搞清楚了基本上其他模型的构建就能很容易理解,熟悉了deepctr中模型的构建思路,再花一点时间,就可以将上面所有的经典的ctr模型的原理都看一遍了,甚至感兴趣的自己还可以全部手写一遍!

为了更清楚的了解DeepCTR开源项目的整体结构,我还做了一个思维导图,公众号回复deepctr,可以获取思维导图的PDF版

【总结】DeepCTR如何构建模型_第1张图片

欢迎关注下方微信公众号,查看更多推荐算法专题文章!
【总结】DeepCTR如何构建模型_第2张图片

参考资料:

  • https://keras.io/guides/functional_api/
  • https://github.com/shenweichen/DeepCTR

你可能感兴趣的:(推荐系统,推荐算法)