推薦系統系列(六):Wide&Deep理論與實踐


背景

在CTR預估任務中,線性模型仍占有半壁江山。利用手工構造的交叉組合特征來使線性模型具有“記憶性”,使模型記住共現頻率較高的特征組合,往往也能達到一個不錯的baseline,且可解釋性強。但這種方式有着較為明顯的缺點:首先,特征工程需要耗費太多精力。其次,因為模型是強行記住這些組合特征的,所以對於未曾出現過的特征組合,權重系數為0,無法進行泛化。

為了加強模型的泛化能力,研究者引入了DNN結構,將高維稀疏特征編碼為低維稠密的Embedding vector,這種基於Embedding的方式能夠有效提高模型的泛化能力。但是,現實世界是沒有銀彈的。基於Embedding的方式可能因為數據長尾分布,導致長尾的一些特征值無法被充分學習,其對應的Embedding vector是不准確的,這便會造成模型泛化過度。

2016年,Google提出Wide&Deep模型,將線性模型與DNN很好的結合起來,在提高模型泛化能力的同時,兼顧模型的記憶性。Wide&Deep這種線性模型與DNN的並行連接模式,后來成為推薦領域的經典模式。今天與大家一起分享這篇paper,向經典學習。

分析

1. Motivation

在這篇論文中,主要圍繞模型的兩部分能力進行探討:Memorization與Generalization。原文定義如下 [1]:

Memorization can be loosely defined as learning the frequent co-occurrence of items or features and exploiting the correlation available in the historical data. 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.

模型能夠從歷史數據中學習到高頻共現的特征組合的能力,這是模型的Memorization。而Generalization代表模型能夠利用相關性的傳遞性去探索歷史數據中從未出現過的特征組合。

廣義線性模型能夠很好地解決Memorization的問題,但是在Generalization方面表現不足。基於Embedding的DNN模型在Generalization表現優異,但在數據分布較為長尾的情況下,對於長尾數據的處理能力較弱,容易造成過度泛化。

能否將二者進行結合,取彼之長補己之短?使得模型同時兼顧Memorization與Generalization。為此,作者提出二者兼備的Wide&Deep模型,並在Google Play store的場景中成功落地。

2. 模型結構

模型結構示意圖如下:

示意圖中最左邊便是模型的Wide部分,這個部分可以使用廣義線性模型來替代,如LR便是最簡單的一種。 由此可見,Wide&Deep是一類模型的統稱,將LR換成FM同樣也是一個Wide&Deep模型(與DeepFM的差異見后續博文)。模型的Deep部分是一個簡單的基於Embedding的全連接網絡,結構與FNN一致 [2]。

2.1 Wide part

這部分是一個廣義線性模型,即 \(y=W^T[X, \phi(X)]+b\) 。其中,\(X=[x_1, x_2, \dots,x_d]\)\(d\) 維特征向量。\(\phi(X)=[\phi_1(X),\phi_2(X),\dots,\phi_k(X)]\)\(k\) 維特征轉化函數向量。

最常用的特征轉換函數便是特征交叉函數,定義為 \(\phi_k(X)=\prod_{i=1}^dx_i^{c_{ki}}, c_{ki} \in \{0,1\}\) ,當且僅當 \(x_i\) 是第 \(k\) 個特征變換的一部分時,\(c_{ki}=1\) 。否則為0。

舉例來說,對於二值特征,一個特征交叉函數為 \(And(gender=female,language=en)\) ,這個函數中只涉及到特征 \(female\)\(en\) ,所以其他特征值對應的 \(c_{ki}=0\) ,即可忽略。當樣本中 \(female\)\(en\) 同時存在時,該特征交叉函數為1,否則為0。這種特征組合可以為模型引入非線性。

2.2 Deep part

Deep側是簡單的全連接網絡:\(a^{(l+1)}=f(W^{(l)}a^{(l)}+b^{(l)})\) ,其中 \(a^{(l)},b^{(l)},W^{(l)},f\) 分別代表第 \(l\) 層的輸入、偏置項、參數項與激活函數。

2.3 Output part

Wide與Deep側都准備完畢之后,對兩部分輸出進行簡單 加權求和 即可作為最終輸出。對於簡單二分類任務而言可以定義為:

\[\begin{aligned} P(Y=1|X)=\sigma(W_{wide}^T[X,\phi(X)]+W_{deep}^Ta^{(l_f)}+b) \end{aligned} \]

其中,\(W_{wide}^T[X,\phi(X)]\) 為Wide輸出結果,\(W_{deep}\) 為Deep側作用到最后一層激活函數輸出的參數,Deep側最后一層激活函數輸出結果為 \(a^{(l_f)}\)\(b\) 為全局偏置項,\(\sigma\)\(sigmoid\) 激活函數 。

將Wide與Deep側進行聯合訓練,需要注意的是,因為Wide側的數據是高維稀疏的,所以作者使用了 \(FTRL\) 算法優化,而Deep側使用的是 \(AdaGrad\)

3. 工程實現

Google使用的pipeline如下,共分為三個部分:Data Generation、Model Training與Model Serving。

3.1 Data Generation

本階段負責對數據進行預處理,供給到后續模型訓練階段。其中包括用戶數據收集、樣本構造。對於類別特征,首先過濾掉低頻特征,然后構造映射表,將類別字段映射為編號,即token化。對於連續特征可以根據其分布進行離散化,論文中采用的方式為等分位數分桶方式,然后再放縮至[0,1]區間。

3.2 Model Training

針對Google paly場景,作者構造了如下結構的Wide&Deep模型。在Deep側,連續特征處理完之后直接送入全連接層,對於類別特征首先輸入到Embedding層,然后再連接到全連接層,與連續特征向量拼接。在Wide側,作者僅使用了用戶歷史安裝記錄與當前候選app作為輸入。

作者采用這種“重Deep,輕Wide”的結構完全是根據應用場景的特點來的。Google play因為數據長尾分布,對於一些小眾的app在歷史數據中極少出現,其對應的Embedding學習不夠充分,需要通過Wide部分Memorization來保證最終預測的精度。

作者在訓練該模型時,使用了5000億條樣本(驚呆),這也說明了Wide&Deep並沒有那么容易訓練。為了避免每次從頭開始訓練,每次訓練都是先load上一次模型的得到的參數,然后再繼續訓練。有實驗說明,類似於FNN使用預訓練FM參數進行初始化可以加速Wide&Deep收斂。

3.3 Model Serving

在實際推薦場景,並不會對全量的樣本進行預測。而是針對召回階段返回的一小部分樣本進行打分預測,同時還會采用多線程並行預測,嚴格控制線上服務時延。

4. 實驗結果

作者在線上線下同時進行實驗,線上使用A/B test方式運行3周時間,對比收益結果如下。Wide&Deep線上線下都有提升,且提升效果顯著。

5. 優缺點分析

優點:

  • 簡單有效。結構簡單易於理解,效果優異。目前仍在工業界廣泛使用,也證明了該模型的有效性。

  • 結構新穎。使用不同於以往的線性模型與DNN串行連接的方式,而將線性模型與DNN並行連接,同時兼顧模型的Memorization與Generalization。

缺點:

  • Wide側的特征工程仍無法避免。

實踐

依舊使用 \(MovieLens100K dataset\) ,核心代碼如下。其中需要注意的是,針對Wide部分采用了 \(FTRL\) 優化器,Deep部分使用了 \(Adam\) 優化器。

class WideDeep(object):
    def __init__(self, vec_dim=None, field_lens=None, dnn_layers=None, wide_lr=None, l1_reg=None, deep_lr=None):
        self.vec_dim = vec_dim
        self.field_lens = field_lens
        self.field_num = len(field_lens)
        self.dnn_layers = dnn_layers
        self.wide_lr = wide_lr
        self.l1_reg = l1_reg
        self.deep_lr = deep_lr

        assert isinstance(dnn_layers, list) and dnn_layers[-1] == 1
        self._build_graph()

    def _build_graph(self):
        self.add_input()
        self.inference()

    def add_input(self):
        self.x = [tf.placeholder(tf.float32, name='input_x_%d'%i) for i in range(self.field_num)]
        self.y = tf.placeholder(tf.float32, shape=[None], name='input_y')
        self.is_train = tf.placeholder(tf.bool)

    def inference(self):
        with tf.variable_scope('wide_part'):
            w0 = tf.get_variable(name='bias', shape=[1], dtype=tf.float32)
            linear_w = [tf.get_variable(name='linear_w_%d'%i, shape=[self.field_lens[i]], dtype=tf.float32) for i in range(self.field_num)]
            wide_part = w0 + tf.reduce_sum(
                tf.concat([tf.reduce_sum(tf.multiply(self.x[i], linear_w[i]), axis=1, keep_dims=True) for i in range(self.field_num)], axis=1),
                axis=1, keep_dims=True) # (batch, 1)
        with tf.variable_scope('dnn_part'):
            emb = [tf.get_variable(name='emb_%d'%i, shape=[self.field_lens[i], self.vec_dim], dtype=tf.float32) for i in range(self.field_num)]
            emb_layer = tf.concat([tf.matmul(self.x[i], emb[i]) for i in range(self.field_num)], axis=1) # (batch, F*K)
            x = emb_layer
            in_node = self.field_num * self.vec_dim
            for i in range(len(self.dnn_layers)):
                out_node = self.dnn_layers[i]
                w = tf.get_variable(name='w_%d' % i, shape=[in_node, out_node], dtype=tf.float32)
                b = tf.get_variable(name='b_%d' % i, shape=[out_node], dtype=tf.float32)
                in_node = out_node
                if out_node != 1:
                    x = tf.nn.relu(tf.matmul(x, w) + b)
                else:
                    self.y_logits = wide_part + tf.matmul(x, w) + b

        self.y_hat = tf.nn.sigmoid(self.y_logits)
        self.pred_label = tf.cast(self.y_hat > 0.5, tf.int32)
        self.loss = -tf.reduce_mean(self.y*tf.log(self.y_hat+1e-8) + (1-self.y)*tf.log(1-self.y_hat+1e-8))

        # set optimizer
        self.global_step = tf.train.get_or_create_global_step()

        wide_part_vars = tf.get_collection(tf.GraphKeys.TRAINABLE_VARIABLES, scope='wide_part')
        dnn_part_vars = tf.get_collection(tf.GraphKeys.TRAINABLE_VARIABLES, scope='dnn_part')

        wide_part_optimizer = tf.train.FtrlOptimizer(learning_rate=self.wide_lr, l1_regularization_strength=self.l1_reg)
        wide_part_op = wide_part_optimizer.minimize(loss=self.loss, global_step=self.global_step, var_list=wide_part_vars)

        dnn_part_optimizer = tf.train.AdamOptimizer(learning_rate=self.deep_lr)
        # set global_step to None so only wide part solver gets passed in the global step;
        # otherwise, all the solvers will increase the global step
        dnn_part_op = dnn_part_optimizer.minimize(loss=self.loss, global_step=None, var_list=dnn_part_vars)

        self.train_op = tf.group(wide_part_op, dnn_part_op)

reference

[1] Cheng, Heng-Tze, et al. "Wide & deep learning for recommender systems." Proceedings of the 1st workshop on deep learning for recommender systems. ACM, 2016.

[2] Zhang, Weinan, Tianming Du, and Jun Wang. "Deep learning over multi-field categorical data." European conference on information retrieval. Springer, Cham, 2016.

[3] https://zhuanlan.zhihu.com/p/53361519

知識分享

個人知乎專欄:https://zhuanlan.zhihu.com/c_1164954275573858304

歡迎關注微信公眾號:SOTA Lab
專注知識分享,不定期更新計算機、金融類文章


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM