Wide-Deep论文解析与代码实现

论文背景

《Wide & Deep Learning for Recommender Systems 》这篇论文是Google于2016年发表在DLRS上的文章,该方法在Google Play的推荐业务中得到了成功的应用。

在推荐系统中,我们的主要挑战之一就是同时解决Memorization和Generalization,也就是推荐系统的记忆能力和泛化能力。Memorization通过一系列人工的特征叉乘(cross-product)来构造非线性特征,捕捉稀疏特征之间的高阶相关性,能够从历史数据中学习到高频共现的特征组合。例如在CTR预估任务中利用手工构造的交叉组合特征来使线性模型具有“记忆性”,这里的记忆性是指“记忆”历史数据中曾共同出现过的特征对。Generalization为稀疏特征学习低维的稠密嵌入来捕获其中的特征相关性,能够利用特征之间的传递性去探索历史数据中从未出现过的特征组合,学习到的embeddings本身具有一定的语义信息。

以上的描述可能比较抽象,类比到我们的大脑认识新事物的过程,起初老师,父母教导我们这个世界的规则,形成对这个世界最初的启蒙,我们知道麻雀会飞,它有一对翅膀,喜鹊也可以飞,因为它也有一对翅膀。但是随着认知的拓展我们又发现并不是有翅膀就可以飞,比如鸵鸟,到这里我们认知的泛化能力产生了局限,我们通过记忆来修正繁华的规则。

模型结构及原理

image-20210318170410345

Wide Models部分

image-20220915222635109

Wide是一个泛化的线性模型 y = w T x + b y=w^Tx+b y=wTx+b y y y是我们要预测的结果, x x x是特征,它是一个 d d d维的向量 x = [ x 1 , x 2 , … , x d ] x=[x_1,x_2,\dots,x_d] x=[x1,x2,,xd] w w w d d d维的权重向量 w = [ w 1 , w 2 , ⋯   , w d ] w=[w_1,w_2,\cdots,w_d] w=[w1,w2,,wd] b b b是偏移量。特征包含两个部分,一个是原始数据中直接拿过来的数据,另一种是经过特征转化之后得到的特征。最重要的一种特征转化方式是交叉组合,定义如下:
ϕ k ( x ) = ∏ i = 1 d x i c k i , c k i ∈ { 0 , 1 } \phi_k{(x)}=\prod^{d}_{i=1}{ {x_i}^{c_{ki} } },c_{ki}\in\{0,1\} ϕk(x)=i=1dxicki,cki{0,1}
这里 c k i c_{ki} cki表示的是第 i i i个特征的第 k k k种转化函数 ϕ k \phi_k ϕk的结果。对于这个特征转化结果来说,只有所有的项都为真,最终的结果才为1,否则是0。比如“AND(gender=female,language=en)”这就是一个交叉特征,只有当用户的性别为女并且使用的语言是英语时,这个特征的结果才为1。通过这种方式,我们可以捕捉到特征之间的交互。以及为线性模型加入非线性的特征。

Deep Models部分

image-20210318183236289

如上图当中的右侧部分,Deep Models是一个前馈神经网络,它的输入是一个稀疏的特征,这个输入会在神经网络的第一层转化为一个低维度的embedding,维度量级通常在 O ( 10 ) O(10) O(10) O ( 100 ) O(100) O(100)之间,然后和一些原始的稠密特征一起递交给神经网络训练,这个模块主要被设计用来处理一些类别特征,比如性别,语言等。每一层的隐层计算方式如下:
α l + 1 = f ( W l α l + b l ) \alpha^{l+1}=f(W^l\alpha^l+b^l) αl+1=f(Wlαl+bl)
其中 α l \alpha^l αl是第 l l l层的激活值, b l b^l bl是第 l l l层的偏置, W l W^l Wl是第 l l l层的权重, f f f是激活函数。

Wide & Deep Models 联合训练

image-20220915222843032

通过加权的方式将Wide部分和Deep部分合并在一起,最上面的输出层是一个sigmoid层,或者是一个线性层,就是一个简单的线性累加,文中称为joint,论文中还降讲到了联合(joint)和集成(ensemble)的区别,集成是每个模型单独训练,再将模型的结果融合,相比于联合训练,集成的每个独立的模型都得学的足够好才有利于随后的回合,因此模型的size也会更大。而在联合训练中,wide部分只需要做一小部分的特征叉乘来弥补deep部分的不足,并不需要一个完整的Wide Models。在集成学习中,每个部分的参数是互不影响的,而在联合学习中,它们的参数是一起训练的。模型选取logistic损失函数,最后的预测输出概率值。公式如下:
P ( Y = 1 ∣ x ) = δ ( w w i d e T [ x , ϕ ( x ) ] + w d e e p T a ( l f ) + b ) P(Y=1|x)=\delta(w_{wide}^T[x,\phi(x)] + w_{deep}^T a^{(lf)} + b) P(Y=1∣x)=δ(wwideT[x,ϕ(x)]+wdeepTa(lf)+b)
其中 σ \sigma σ表示sigmoid函数, ϕ ( x ) \phi(x) ϕ(x)表示叉乘特征, α ( l f ) \alpha^{(lf)} α(lf)表示神经网络最后一层激活值, b b b表示偏置。

论文中,作者通过梯度的反向传播,使用mini-batch stochastic optimization方法训练参数,并且对wide部分使用带L1正则的FTRL(Follow-the-regularized-leader)算法,对Deep Models部分使用AdaGrad算法。

思考题

在应用场景中,哪些特征适合放在Wide侧,哪些特征适合放在Deep侧,为什么?

显然的,直接的,有规律可循的特征适合放在Wide侧,对于一些受上下文影响较大的,简单的规律或许能够反映更大的上下文原因的特征适合放在Deep层。

为什么Wide部分要用L1 FTRL训练?

L1正则化比L2正则化更容易产生稀疏解,FTRL本身是一个稀疏性很好,精度也不错的随机梯度下降方法。L1 FTRL非常注重模型的稀疏性,会让Wide部分的大部分权重都为0,我们无需准备大量0权重特征,大大压缩了模型的权重,也压缩了特征向量的维度。

为什么Deep部分不特别考虑稀疏性的问题?

在Deep部分输入类别是数值类特征,或者是已经降维并稠密化的Embedding向量,因此不需要考虑特征稀疏问题。

代码实现

模型部分,特征的选择应该根据实际的业务场景选择哪些特征应该放在wide部分,哪些特征应该放在deep部分

def WideNDeep(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())

    # Wide&Deep模型论文中Wide部分使用的特征比较简单,并且得到的特征非常的稀疏,所以使用了FTRL优化Wide部分(这里没有实现FTRL)
    # 但是是根据他们业务进行选择的,我们这里将所有可能用到的特征都输入到Wide部分,具体的细节可以根据需求进行修改
    linear_logits = get_linear_logits(dense_input_dict, sparse_input_dict, linear_sparse_feature_columns)
    
    # 构建维度为k的embedding层,这里使用字典的形式返回,方便后面搭建模型
    embedding_layers = build_embedding_layers(dnn_feature_columns, sparse_input_dict, is_linear=False)

    dnn_sparse_feature_columns = list(filter(lambda x: isinstance(x, SparseFeat), dnn_feature_columns))

    # 在Wide&Deep模型中,deep部分的输入是将dense特征和embedding特征拼在一起输入到dnn中
    dnn_logits = get_dnn_logits(dense_input_dict, sparse_input_dict, dnn_sparse_feature_columns, embedding_layers)
    
    # 将linear,dnn的logits相加作为最终的logits
    output_logits = Add()([linear_logits, dnn_logits])

    # 这里的激活函数使用sigmoid
    output_layer = Activation("sigmoid")(output_logits)

    model = Model(input_layers, output_layer)
    return model

模块导入

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):
    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)
        
    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]

构建输入层并以dense和sparse两类字典的形式返回

def build_input_layers(feature_columns):
    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

构建Embedding层并以字典形式返回

def build_embedding_layers(feature_columns, input_layers_dict, is_linear):
    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

将所有的稀疏特征的embedding拼接

def concat_embedding_list(feature_columns, input_layer_dict, embedding_layer_dict, flatten=False):
    # 将sparse特征筛选出来
    sparse_feature_columns = list(filter(lambda x: isinstance(x, SparseFeat), feature_columns))

    embedding_list = []
    for fc in sparse_feature_columns:
        _input = input_layer_dict[fc.name] # 获取输入层 
        _embed = embedding_layer_dict[fc.name] # B x 1 x dim  获取对应的embedding层
        embed = _embed(_input) # B x dim  将input层输入到embedding层中

        # 是否需要flatten, 如果embedding列表最终是直接输入到Dense层中,需要进行Flatten,否则不需要
        if flatten:
            embed = Flatten()(embed)
        
        embedding_list.append(embed)
    
    return embedding_list 

构建深度神经网络

def get_dnn_logits(dense_input_dict, sparse_input_dict, sparse_feature_columns, dnn_embedding_layers):
    concat_dense_inputs = Concatenate(axis=1)(list(dense_input_dict.values())) # B x n1 (n1表示的是dense特征的维度) 

    sparse_kd_embed = concat_embedding_list(sparse_feature_columns, sparse_input_dict, dnn_embedding_layers, flatten=True)

    concat_sparse_kd_embed = Concatenate(axis=1)(sparse_kd_embed) # B x n2  (n2表示的是Sparse特征的维度)

    dnn_input = Concatenate(axis=1)([concat_dense_inputs, concat_sparse_kd_embed]) # B x (n2 + n1)

    # dnn层,这里的Dropout参数,Dense中的参数及Dense的层数都可以自己设定
    dnn_out = Dropout(0.5)(Dense(1024, activation='relu')(dnn_input))  
    dnn_out = Dropout(0.3)(Dense(512, activation='relu')(dnn_out))
    dnn_out = Dropout(0.1)(Dense(256, activation='relu')(dnn_out))

    dnn_logits = Dense(1)(dnn_out)

    return dnn_logits

main函数入口

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]

    # 构建WideNDeep模型
    history = WideNDeep(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=5, validation_split=0.2, )    

参考资料

  1. 推荐系统(一):Wide & Deep 源论文整理和思考
  2. 巨经典论文!详解推荐系统经典模型 Wide & Deep
  3. 详解 Wide & Deep 结构背后的动机
  4. Wide & Deep Learning for Recommender Systems
  5. 见微知著,你真的搞懂 Google 的 Wide&Deep 模型了吗?
  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

UPTOLIMIT

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值