論文背景
《Wide & Deep Learning for Recommender Systems 》這篇論文是Google於2016年發表在DLRS上的文章,該方法在Google Play的推薦業務中得到了成功的應用。
在推薦系統中,我們的主要挑戰之一就是同時解決Memorization和Generalization,也就是推薦系統的記憶能力和泛化能力。Memorization通過一系列人工的特徵叉乘(cross-product)來構造非線性特徵,捕捉稀疏特徵之間的高階相關性,能夠從歷史數據中學習到高頻共現的特徵組合。例如在CTR預估任務中利用手工構造的交叉組合特徵來使線性模型具有“記憶性”,這裏的記憶性是指“記憶”歷史數據中曾共同出現過的特徵對。Generalization為稀疏特徵學習低維的稠密嵌入來捕獲其中的特徵相關性,能夠利用特徵之間的傳遞性去探索歷史數據中從未出現過的特徵組合,學習到的embeddings本身具有一定的語義信息。
以上的描述可能比較抽象,類比到我們的大腦認識新事物的過程,起初老師,父母教導我們這個世界的規則,形成對這個世界最初的啓蒙,我們知道麻雀會飛,它有一對翅膀,喜鵲也可以飛,因為它也有一對翅膀。但是隨着認知的拓展我們又發現並不是有翅膀就可以飛,比如鴕鳥,到這裏我們認知的泛化能力產生了侷限,我們通過記憶來修正繁華的規則。
模型結構及原理
Wide Models部分
Wide是一個泛化的線性模型,
是我們要預測的結果,
是特徵,它是一個
維的向量
。
是
維的權重向量
,
是偏移量。特徵包含兩個部分,一個是原始數據中直接拿過來的數據,另一種是經過特徵轉化之後得到的特徵。最重要的一種特徵轉化方式是交叉組合,定義如下:
這裏表示的是第
個特徵的第
種轉化函數
的結果。對於這個特徵轉化結果來説,只有所有的項都為真,最終的結果才為1,否則是0。比如“
AND(gender=female,language=en)”這就是一個交叉特徵,只有當用户的性別為女並且使用的語言是英語時,這個特徵的結果才為1。通過這種方式,我們可以捕捉到特徵之間的交互。以及為線性模型加入非線性的特徵。
Deep Models部分
如上圖當中的右側部分,Deep Models是一個前饋神經網絡,它的輸入是一個稀疏的特徵,這個輸入會在神經網絡的第一層轉化為一個低維度的embedding,維度量級通常在到
之間,然後和一些原始的稠密特徵一起遞交給神經網絡訓練,這個模塊主要被設計用來處理一些類別特徵,比如性別,語言等。每一層的隱層計算方式如下:
其中是第
層的激活值,
是第
層的偏置,
是第
層的權重,
是激活函數。
Wide & Deep Models 聯合訓練
通過加權的方式將Wide部分和Deep部分合並在一起,最上面的輸出層是一個sigmoid層,或者是一個線性層,就是一個簡單的線性累加,文中稱為joint,論文中還降講到了聯合(joint)和集成(ensemble)的區別,集成是每個模型單獨訓練,再將模型的結果融合,相比於聯合訓練,集成的每個獨立的模型都得學的足夠好才有利於隨後的回合,因此模型的size也會更大。而在聯合訓練中,wide部分只需要做一小部分的特徵叉乘來彌補deep部分的不足,並不需要一個完整的Wide Models。在集成學習中,每個部分的參數是互不影響的,而在聯合學習中,它們的參數是一起訓練的。模型選取logistic損失函數,最後的預測輸出概率值。公式如下:
其中表示sigmoid函數,
表示叉乘特徵,
表示神經網絡最後一層激活值,
表示偏置。
論文中,作者通過梯度的反向傳播,使用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, )