1、動機
在CTR預估任務中利用手工構(gòu)造的交叉組合特征來使線性模型具有“記憶性”,使模型記住共線頻率較高的特征組合,往往也能達到一個不錯的baseline,且可解釋性強。但是這種方式有著較為明顯的缺點:
1.特征工程需要耗費很大的精力
2.模型是強行記住這些特征組合,對于未曾出現(xiàn)過的特征組合,權(quán)重系數(shù)為0,無法進行泛化
為了加強模型的泛化能力,研究者引入DNN結(jié)構(gòu),將高維稀疏特征編碼為低維稠密的Embedding vector,這種基于Embedding的方式能夠有效提高泛化能力。但是,基于Embedding的方式可能因為數(shù)據(jù)長尾分布,導致長尾的一些特征無法被充分學習,其對應的Embedding vector是不準確的,這便會造成模型泛化過度。
wide&deep模型就是圍繞記憶性和泛化性進行討論的,模型能夠從歷史數(shù)據(jù)中學習高頻共振的特征組合能力,稱為模型的Memorization。能夠利用特征之間的傳遞性去探索歷史數(shù)據(jù)中未出現(xiàn)過的特征組合,稱為模型的Generalization。wide&deep兼顧Memorization與Generalization并在Google Play store的場景中成功落地。
2、模型結(jié)構(gòu)及原理

如何理解Wide部分有利于增強模型的“記憶能力”,Deep部分有利于增強模型的“泛化能力”
- wide部分是一個廣義的線性模型,輸入的特征主要有兩部分組成,一部分是原始的部分特征,另一部分是原始特征的交叉特征(cross-product transformation),對于交互特征可以定義為:
是一個布爾變量,當?shù)趇個特征屬于第k個特征組合時,
的值為1,否則為0,
是第i個特征的值,大體意思就是兩個特征都同時為1這個新的特征才能為1,否則就是0,說白了就是一個特征組合。用原論文的例子舉例:
AND(user_installed_app=QQ, impression_app=WeChat),當特征user_installed_app=QQ,和特征impression_app=WeChat取值都為1的時候,組合特征AND(user_installed_app=QQ, impression_app=WeChat)的取值才為1,否則為0。
對于wide部分訓練時候使用的優(yōu)化器是帶正則的FTRL算法(Follow-the-regularized-leader),而L1 FTLR是非常注重模型稀疏性質(zhì)的,也就是說W&D模型采用L1 FTRL是想讓Wide部分變得更加的稀疏,即Wide部分的大部分參數(shù)都為0,這就大大壓縮了模型權(quán)重及特征向量的維度。Wide部分模型訓練完之后留下來的特征都是非常重要的,那么模型的“記憶能力”就可以理解為發(fā)現(xiàn)"直接的",“暴力的”,“顯然的”關(guān)聯(lián)規(guī)則的能力。例如Google W&D期望wide部分發(fā)現(xiàn)這樣的規(guī)則:用戶安裝了應用A,此時曝光應用B,用戶安裝應用B的概率大。
Wide部分與Deep部分的結(jié)合
W&D模型是將兩部分輸出的結(jié)果結(jié)合起來聯(lián)合訓練,將deep和wide部分的輸出重新使用一個邏輯回歸模型做最終的預測,輸出概率值。聯(lián)合訓練的數(shù)學形式如下:需要注意的是,因為Wide側(cè)的數(shù)據(jù)是高維稀疏的,所以作者使用了FTRL算法優(yōu)化,而Deep側(cè)使用的是 Adagrad。
3.代碼實現(xiàn)
Wide側(cè)記住的是歷史數(shù)據(jù)中那些常見、高頻的模式,是推薦系統(tǒng)中的“紅?!薄嶋H上,Wide側(cè)沒有發(fā)現(xiàn)新的模式,只是學習到這些模式之間的權(quán)重,做一些模式的篩選。正因為Wide側(cè)不能發(fā)現(xiàn)新模式,因此我們需要根據(jù)人工經(jīng)驗、業(yè)務背景,將我們認為有價值的、顯而易見的特征及特征組合,喂入Wide側(cè)
Deep側(cè)就是DNN,通過embedding的方式將categorical/id特征映射成稠密向量,讓DNN學習到這些特征之間的深層交叉,以增強擴展能力。
模型的實現(xiàn)與模型結(jié)構(gòu)類似由deep和wide兩部分組成,這兩部分結(jié)構(gòu)所需要的特征在上面已經(jīng)說過了,針對當前數(shù)據(jù)集實現(xiàn),我們在wide部分加入了所有可能的一階特征,包括數(shù)值特征和類別特征的onehot都加進去了,其實也可以加入一些與wide&deep原論文中類似交叉特征。只要能夠發(fā)現(xiàn)高頻、常見模式的特征都可以放在wide側(cè),對于Deep部分,在本數(shù)據(jù)中放入了數(shù)值特征和類別特征的embedding特征,實際應用也需要根據(jù)需求進行選擇。
# Wide&Deep 模型的wide部分及Deep部分的特征選擇,應該根據(jù)實際的業(yè)務場景去確定哪些特征應該放在Wide部分,哪些特征應該放在Deep部分
def WideNDeep(linear_feature_columns, dnn_feature_columns):
# 構(gòu)建輸入層,即所有特征對應的Input()層,這里使用字典的形式返回,方便后續(xù)構(gòu)建模型
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))
# 構(gòu)建模型的輸入層,模型的輸入層不能是字典的形式,應該將字典的形式轉(zhuǎn)換成列表的形式
# 注意:這里實際的輸入與Input()層的對應,是通過模型輸入時候的字典數(shù)據(jù)的key與對應name的Input層
input_layers = list(dense_input_dict.values()) + list(sparse_input_dict.values())
# Wide&Deep模型論文中Wide部分使用的特征比較簡單,并且得到的特征非常的稀疏,所以使用了FTRL優(yōu)化Wide部分(這里沒有實現(xiàn)FTRL)
# 但是是根據(jù)他們業(yè)務進行選擇的,我們這里將所有可能用到的特征都輸入到Wide部分,具體的細節(jié)可以根據(jù)需求進行修改
linear_logits = get_linear_logits(dense_input_dict, sparse_input_dict, linear_sparse_feature_columns)
# 構(gòu)建維度為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])
# 這里的激活函數(shù)使用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 nametuple
import tensorflow as tf
from tensorflow.keras.layers import *
from tensorflow.keras.model import *
from sklearn.model_selection import train_test_split
from skleran.preprocessing import MinMaxScaler,LabelEncoder
from utils import SparseFeat , DenseFeat , VarLenSparseFeat
#簡單處理特征,包括缺失值,數(shù)值處理,類別編碼
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:
lbe = LabelEncoder()
data_df[f] = lbe.fit_transform(data_df[f])
return data_df[dense_features + sparse_features]
def build_input_layers(feature_columns):
#構(gòu)建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 = 'ld_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層,然后經(jīng)過一個全連接層得到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層的好處就是可以通過查表的方式獲取到哪些非零的元素對應的權(quán)重,然后在將這些權(quán)重相加,效率比較高
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中查詢得到的權(quán)重就是對應onehot向量中一個位置的權(quán)重,所以后面不用再接一個全連接了,本身一維的embedding就相當于全連接
# 只不過是這里的輸入特征只有0和1,所以直接向非零元素對應的權(quán)重相加就等同于進行了全連接操作(非零元素部分乘的是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
# 將所有的sparse特征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 (n表示的是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 n2k (n2表示的是Sparse特征的維度)
dnn_input = Concatenate(axis=1)([concat_dense_inputs, concat_sparse_kd_embed]) # B x (n2k + n1)
# dnn層,這里的Dropout參數(shù),Dense中的參數(shù)及Dense的層數(shù)都可以自己設定
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
# Wide&Deep 模型的wide部分及Deep部分的特征選擇,應該根據(jù)實際的業(yè)務場景去確定哪些特征應該放在Wide部分,哪些特征應該放在Deep部分
def WideNDeep(linear_feature_columns, dnn_feature_columns):
# 構(gòu)建輸入層,即所有特征對應的Input()層,這里使用字典的形式返回,方便后續(xù)構(gòu)建模型
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))
# 構(gòu)建模型的輸入層,模型的輸入層不能是字典的形式,應該將字典的形式轉(zhuǎn)換成列表的形式
# 注意:這里實際的輸入與Input()層的對應,是通過模型輸入時候的字典數(shù)據(jù)的key與對應name的Input層
input_layers = list(dense_input_dict.values()) + list(sparse_input_dict.values())
# Wide&Deep模型論文中Wide部分使用的特征比較簡單,并且得到的特征非常的稀疏,所以使用了FTRL優(yōu)化Wide部分(這里沒有實現(xiàn)FTRL)
# 但是是根據(jù)他們業(yè)務進行選擇的,我們這里將所有可能用到的特征都輸入到Wide部分,具體的細節(jié)可以根據(jù)需求進行修改
linear_logits = get_linear_logits(dense_input_dict, sparse_input_dict, linear_sparse_feature_columns)
# 構(gòu)建維度為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])
# 這里的激活函數(shù)使用sigmoid
output_layer = Activation("sigmoid")(output_logits)
model = Model(input_layers, output_layer)
return model
if __name__ == "__main__":
# 讀取數(shù)據(jù)
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]
# 簡單的數(shù)據(jù)預處理
train_data = data_process(data, dense_features, sparse_features)
train_data['label'] = data['label']
# 將特征分組,分成linear部分和dnn部分(根據(jù)實際場景進行選擇),并將分組之后的特征做標記(使用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]
# 構(gòu)建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')])
# 將輸入數(shù)據(jù)轉(zhuǎn)化成字典的形式輸入
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, )