簡介
本文要介紹的是Google于2016年提出的Wide&Deep模型,此模型的提出對業(yè)界產(chǎn)生了非常大的影響,不僅其本身成功地應(yīng)用在多家一線互聯(lián)網(wǎng)公司,而且其后續(xù)的改進工作也一直延續(xù)至今。Wide&Deep模型正如其名,分別包含了Wide部分和Deep部分。其中Wide部分的作用是讓模型具有較強的“記憶能力”(memorization);而Deep部分的作用是讓模型具有“泛化能力”(generalization)。正是這樣的設(shè)計,使得模型兼具了邏輯回歸和深度神經(jīng)網(wǎng)絡(luò)的優(yōu)點——能夠快速處理并記憶大量歷史行為特征,并具有強大的表達能力。原論文在這里。
背景知識
推薦系統(tǒng)可以被認為是一個搜索排名系統(tǒng),它的輸入是一組用戶以及上下文信息,輸出是物品的排序列表。對于一次查詢,推薦任務(wù)是從數(shù)據(jù)庫中找到相關(guān)的物品,然后根據(jù)具體的任務(wù)(比如點擊率預(yù)估或者購買預(yù)測)對這些物品進行排名,最后把結(jié)果呈現(xiàn)給用戶。與一般的搜索排名問題類似,推薦系統(tǒng)中的一項挑戰(zhàn)是同時實現(xiàn)記憶(memorization)和泛化(generalization),Wide&Deep模型正是為了解決這項挑戰(zhàn)而提出的。那么我們首先來理解下這兩個概念,記憶和泛化。
Memoriization(記憶能力)
下面是原論文中的描述:
Memorization can be loosely defined as learning the frequent co-occurrence of items or features and exploiting the correlation available in the historical data.
“記憶能力”可以被理解成模型直接學(xué)習(xí)并利用歷史數(shù)據(jù)中物品或者特征的"共現(xiàn)頻率"的能力。一般來說,協(xié)同過濾、邏輯回歸等簡單模型具有較強的“記憶能力”。由于這類模型結(jié)構(gòu)簡單,原始數(shù)據(jù)往往可以直接影響推薦結(jié)果,產(chǎn)生類似于"如果曾經(jīng)點擊過A,就推薦B"這類規(guī)則式的推薦,這相當(dāng)于模型直接記住了歷史數(shù)據(jù)的分布,并根據(jù)這些特點進行推薦。
Generalization(泛化能力)
下面是原論文中的描述:
Generalization, on the other hand, is based on transitivity of correlation and explores new feature combinations that have never or rarely occurred in the past.
“泛化能力”可以被理解為模型傳遞特征的相關(guān)性,以及發(fā)掘稀疏甚至從未出現(xiàn)過的稀有特征與最終標(biāo)簽相關(guān)性的能力。矩陣分解比協(xié)同過濾的泛化能力強,因為矩陣分解引入了隱向量這樣的結(jié)構(gòu),使得數(shù)據(jù)稀少的用戶或者物品也能生成隱向量,從而獲得有數(shù)據(jù)支撐的推薦得分,這就是非常典型的將全局?jǐn)?shù)據(jù)傳遞到稀疏物品上,從而提高泛化能力的例子。
下面以人為例來總結(jié)一下記憶和泛化能力,我們?nèi)祟惪梢杂^察日常事件并且記在腦子中,比如我們觀察到了“麻雀會飛”和“鴿子會飛”等自然事件。除此之外,我們還可以根據(jù)已有記憶進行總結(jié)并制定出相應(yīng)規(guī)則(比如“有翅膀的動物會飛”),并應(yīng)用到之前未見過的事物。當(dāng)然也有例外,比如"企鵝不會飛",因此我們也需要記住一些異常情況,來進一步完善之前制定出的規(guī)則。
Wide&Deep模型如何工作
假設(shè)我們現(xiàn)在要開發(fā)一款點餐app,用戶只需要輸入它想要的某種食物(query),點餐app就可以預(yù)測出用戶最喜歡的食物(item),并且呈現(xiàn)出來。如果用戶下單了app推薦的食物,那么得分為1,否則為0。
我們首先使用Wide模型來處理這個問題,Wide模型希望能夠記住對于一次query,究竟哪一個item與之最為匹配。這個模型預(yù)測一個消費概率,即對于一個特定的query和推薦的item,它被消費的概率有多大?舉個例子,這個模型學(xué)到了組合特征"AND(query='fried chicken', item='chicken and waffles')"成功率很高,即如果用戶搜索炸雞,app推薦炸雞和華夫餅,那么用戶消費的概率就很大。組合特征"AND(query='fried chicken', item='chicken fried rice')"卻并沒有得到用戶的青睞,盡管從名字上看,炸雞和雞肉炒飯相似度很高,但實際上這兩者完全是不同的口味。因此Wide模型是要記住用戶之前喜歡什么樣的item。下圖展示了Wide模型的“記憶過程”。

對于組合特征"AND(query='fried chicken', item='chicken fried rice')",Wide模型會降低此組合特征的權(quán)重,而增大組合特征"AND(query='fried chicken', item='chicken and waffles')"的權(quán)重。 過了一段時間,用戶對app的推薦內(nèi)容感到疲倦,他們希望app能夠推薦一些符合他們口味,但同時又能帶來新鮮感的食物。因此我們選擇Deep模型來解決這個問題,Deep模型會對每個query和item都生成低維的稠密embedding向量,并且在embedding空間中來查找彼此比較接近的ietm。舉個例子,你會發(fā)現(xiàn)搜索炸雞的用戶一般也不會介意再吃個漢堡。下圖展示了Deep模型示意圖,可以看到在Embedding空間中,炸雞和漢堡彼此距離比較近。

但是Deep模型也有它自身的問題,就是泛化過度,即給用戶推薦了不太相關(guān)的物品。通過查詢歷史數(shù)據(jù),我們發(fā)現(xiàn)實際上存在兩種不同的query-item關(guān)系。
第一種是精準(zhǔn)查詢,用戶輸入了非常精準(zhǔn)的食物描述,比如“冰脫脂牛奶拿鐵咖啡”,我們不能因為它與“熱全脂拉鐵咖啡”在Embedding空間中比較相近就推薦給用戶。
第二種是寬泛查詢,比如用戶輸入了類似"海鮮"或者“意大利食物”這樣的關(guān)鍵字,根據(jù)這種具有寬泛意義的關(guān)鍵詞可以找到非常多相關(guān)的item。

如上圖所示,對于兩個稀疏特征query="fried chicken" 以及 item="chicken fried rice",我們同時丟入Wide模型(左邊)和Deep模型(右邊)進行訓(xùn)練。這樣模型就兼具了記憶和泛化的能力,從而可以達到更好的推薦效果。
Wide&Deep模型
因此在這里引入本文的主角,Wide&Deep模型,如下圖:
上圖左邊是Wide模型,右邊是Deep模型,中間便是Wide&Deep模型了,下面分別來介紹一下:
Wide部分
Wide模型其實就是一個簡單的廣義線性模型,公式定義如下:

其中
這里需要介紹一下叉乘特征,叉乘特征是通過特定的變換函數(shù)對特征進行組合得來的,其中論文使用的是交叉積變換函數(shù),其定義如下:

其中
Deep部分
Deep模型其實就是一個前饋神經(jīng)網(wǎng)絡(luò),網(wǎng)絡(luò)會對一些稀疏特征(如ID類特征)學(xué)習(xí)一個低維的稠密Embedding向量,維度通常在O(10)~O(100)之間,然后與一些原始稠密特征一起作為網(wǎng)絡(luò)的輸入,依次通過若干隱層進行前向傳播,每一個隱層都執(zhí)行以下計算:

其中
Wide&Deep聯(lián)合訓(xùn)練
論文特意強調(diào)了Wide模型和Deep模型是聯(lián)合(Joint)訓(xùn)練的,與集成(Ensemble)是不同的,集成訓(xùn)練是每個模型單獨訓(xùn)練,再將模型結(jié)果匯總。因此每個模型都會學(xué)的足夠好的時候才會進行匯總,故每個模型相對較大。而對于Wide&Deep的聯(lián)合訓(xùn)練而言,Wide部分只是為了補償Deep部分缺失的記憶能力,它只需要使用一小部分的叉乘特征,故相對較小。
Wide&Deep模型采用的Logistic Loss函數(shù),模型的預(yù)測值定義如下:

其中
關(guān)于模型訓(xùn)練,論文對Wide部分使用了FTRL算法并且加上了L1正則化,對于Deep部分使用了AdaGrad算法。
實驗
作者將Wide&Deep模型運用到了Google Play商店中,當(dāng)一個用戶訪問Google Play,會生成一個包含用戶和上下文信息的query,推薦系統(tǒng)的精排模型會對于候選池中召回的一系列apps(即item)進行打分,按打分生成app的排序列表返回給用戶。


實驗細節(jié)如下:
- 訓(xùn)練樣本約5000億
- Categorical 特征(sparse)會有一個過濾閾值,即至少在訓(xùn)練集中出現(xiàn)m次才會被加入
- Continuous 特征(dense)通過CDF被歸一化到 [0,1] 之間
- Categorical 特征映射到32維embeddings,和原始Continuous特征共1200維作為神經(jīng)網(wǎng)絡(luò)的輸入
- Wide部分只用了一組特征叉乘,即已安裝的應(yīng)用和曝光應(yīng)用
- 線上模型更新時,通過“熱啟動”重訓(xùn)練,即使用上次的embeddings和模型參數(shù)初始化
通過3周的線上A/B實驗,實驗結(jié)果如下,其中Acquisition表示下載。Wide部分的輸入僅僅是已安裝應(yīng)用和曝光應(yīng)用兩類特征,其中已安裝應(yīng)用代表用戶的歷史行為,而曝光應(yīng)用代表當(dāng)前的待推薦應(yīng)用。選擇這兩類特征的原因是充分發(fā)揮Wide部分“記憶能力”強的優(yōu)勢。

可以看到,經(jīng)過3周的實驗之后,Wide&Deep模型使Google Play應(yīng)用商店主頁上的app下載量提升了3.9%。
代碼實踐
網(wǎng)絡(luò)模型部分分別實現(xiàn)了Wide和Deep模型,然后拼接起來實現(xiàn)了Wide&Deep模型,模型部分代碼如下:
import torch
import torch.nn as nn
class Wide(nn.Module):
def __init__(self, input_dim):
super(Wide, self).__init__()
# hand-crafted cross-product features
self.linear = nn.Linear(in_features=input_dim, out_features=1)
def forward(self, x):
return self.linear(x)
class Deep(nn.Module):
def __init__(self, config, hidden_layers):
super(Deep, self).__init__()
self.dnn = nn.ModuleList([nn.Linear(layer[0], layer[1]) for layer in list(zip(hidden_layers[:-1], hidden_layers[1:]))])
self.dropout = nn.Dropout(p=config['deep_dropout'])
def forward(self, x):
for layer in self.dnn:
x = layer(x)
# 如果輸出層大小是1的話,這里再使用了個ReLU激活函數(shù),可能導(dǎo)致輸出全變成0,即造成了梯度消失,導(dǎo)致Loss不收斂
x = torch.relu(x)
x = self.dropout(x)
return x
class WideDeep(nn.Module):
def __init__(self, config, dense_features_cols, sparse_features_cols):
super(WideDeep, self).__init__()
self._config = config
# 稠密特征的數(shù)量
self._num_of_dense_feature = dense_features_cols.__len__()
# 稠密特征
self.sparse_features_cols = sparse_features_cols
self.embedding_layers = nn.ModuleList([
# 根據(jù)稀疏特征的個數(shù)創(chuàng)建對應(yīng)個數(shù)的Embedding層,Embedding輸入大小是稀疏特征的類別總數(shù),輸出稠密向量的維度由config文件配置
nn.Embedding(num_embeddings = num_feat, embedding_dim=config['embed_dim'])
for num_feat in self.sparse_features_cols
])
# Deep hidden layers
self._deep_hidden_layers = config['hidden_layers']
self._deep_hidden_layers.insert(0, self._num_of_dense_feature + config['embed_dim'] * len(self.sparse_features_cols))
self._wide = Wide(self._num_of_dense_feature)
self._deep = Deep(config, self._deep_hidden_layers)
# 之前直接將這個final_layer加入到了Deep模塊里面,想著反正輸出都是1,結(jié)果沒注意到Deep沒經(jīng)過一個Linear層都會經(jīng)過Relu激活函數(shù),如果
# 最后輸出層大小是1的話,再經(jīng)過ReLU之后,很可能變?yōu)榱?,造成梯度消失問題,導(dǎo)致Loss怎么樣都降不下來。
self._final_linear = nn.Linear(self._deep_hidden_layers[-1], 1)
def forward(self, x):
# 先區(qū)分出稀疏特征和稠密特征,這里是按照列來劃分的,即所有的行都要進行篩選
dense_input, sparse_inputs = x[:, :self._num_of_dense_feature], x[:, self._num_of_dense_feature:]
sparse_inputs = sparse_inputs.long()
sparse_embeds = [self.embedding_layers[i](sparse_inputs[:, i]) for i in range(sparse_inputs.shape[1])]
sparse_embeds = torch.cat(sparse_embeds, axis=-1)
# Deep模塊的輸入是稠密特征和稀疏特征經(jīng)過Embedding產(chǎn)生的稠密特征的
deep_input = torch.cat([sparse_embeds, dense_input], axis=-1)
wide_out = self._wide(dense_input)
deep_out = self._deep(deep_input)
deep_out = self._final_linear(deep_out)
assert (wide_out.shape == deep_out.shape)
outputs = torch.sigmoid(0.5 * (wide_out + deep_out))
return outputs
def saveModel(self):
torch.save(self.state_dict(), self._config['model_name'])
def loadModel(self, map_location):
state_dict = torch.load(self._config['model_name'], map_location=map_location)
self.load_state_dict(state_dict, strict=False)
數(shù)據(jù)集方面采用的是criteo數(shù)據(jù)集的一個很小的子集,僅僅是為了測試模型功能。測試數(shù)據(jù)并沒有標(biāo)簽,因此模型訓(xùn)練好了之后,直接對測試集進行點擊率預(yù)估,輸出的結(jié)果是由0,1組成的向量,代表點擊與否,部分結(jié)果如下:
后記
此網(wǎng)絡(luò)模型代碼也是借鑒網(wǎng)上的,具體鏈接記不得了。自己在調(diào)試的時候,遇到一個很棘手的問題,就是模型代碼設(shè)計好了之后,在訓(xùn)練時,無論怎樣調(diào)整學(xué)習(xí)率等超參數(shù),損失就是降不下來。自己也搜了很多相關(guān)的資料,有很多說的是數(shù)據(jù)集本身有問題,學(xué)習(xí)率過小,損失函數(shù)不對等等。結(jié)果折騰了半天依然沒找到原因。最后通過一步一步對比自己的代碼,發(fā)現(xiàn)WideDeep模型最后是會將Wide和Deep模型各自的兩個輸出Tensor相加的,這兩個Tensor大小都是1。而自己當(dāng)時覺得為了方便,將輸出為1的Linear層直接放到了Deep層里面,其實這樣做并沒有什么問題,但是由于Deep模型進行前向計算時,每次都會經(jīng)過ReLU激活函數(shù),當(dāng)Deep模型最后的輸出大小為1的時候,再經(jīng)過ReLU,很可能導(dǎo)致輸出直接變?yōu)?,即造成了梯度消失的問題。因此這樣無論怎么訓(xùn)練,梯度都無法傳遞到網(wǎng)絡(luò)的淺層部分,導(dǎo)致模型參數(shù)無法更新,Loss無法降低。通過這次代碼調(diào)試,自己也算是學(xué)到一些技巧了,以后在編寫代碼的時候要盡可能注意不要犯這樣的錯誤。
完整代碼見https://github.com/HeartbreakSurvivor/RsAlgorithms/blob/main/Test/widedeep_test.py.