天天看點

DIN 深度興趣網絡介紹以及源碼淺析

DIN 深度興趣網絡介紹以及源碼淺析

前言

繼續分析論文, 養成及時記錄的好習慣; 周末立了寫兩篇部落格的 Flag, 期望能夠完成 … ???? ???? ????

另外需要說明的是, 本篇文章并不打算詳細的解讀原文, 而是按照我的了解記錄文章最核心的觀點, 以及對相關代碼進行解讀, 最終的目标是以後我再翻看本文能夠快速回憶起文章最重要的内容. OK, 下面開始分析.

廣而告之

可以在微信中搜尋 “珍妮的算法之路” 或者 “world4458” 關注我的微信公衆号;另外可以看看知乎專欄 ​​PoorMemory-機器學習​​, 以後文章也會發在知乎專欄中;

Deep Interest Network (深度興趣網絡, DIN)

文章資訊

  • 論文标題: Deep Interest Network for Click-Through Rate Prediction
  • 論文位址:​​https://arxiv.org/abs/1706.06978​​
  • 代碼位址:​​https://github.com/zhougr1993/DeepInterestNetwork​​​, 另外作者在 README 中推薦看​​https://github.com/mouna99/dien​​ 中的實作.
  • 發表時間: 2017
  • 論文作者: Zhou Guorui, Song Chengru, Zhu, Xiaoqiang, Ma Xiao, Yan Yanghui, Dai Xingya, Zhu Han, Jin Junqi, Li Han, Gai Kun
  • 作者機關: Alibaba Group

核心觀點

文章首先介紹了現有的點選率 (CTR) 預估模型大都滿足相同的模式: 先将大量的稀疏類别特征 (Categorical Features) 通過 Embedding 技術映射到低維空間, 再将這些特征的低維表達按照特征的類别進行組合與變換 (文中采用 in a group-wise manner 來描述), 以形成固定長度的向量 (比如常用的 sum pooling / mean pooling), 最後将這些向量 concatenate 起來輸入到一個 MLP (Multi-Layer Perceptron) 中, 進而學習這些特征間的非線性關系.

然而這個模式存在一個問題. 舉個例子, 在電商場景下, 使用者興趣可以使用使用者的曆史行為來描述 (比如使用者通路過的商品, 店鋪或者類目), 然而如果按照現有的處理模式, 對于不同的候選廣告, 使用者的興趣始終被映射為同一個固定長度的向量來表示, 這極大的限制了模型的表達能力, 畢竟使用者的興趣是多樣的.

為了解決這個問題, 本文提出了 DIN 網絡, 對于不同的候選廣告, 考慮該廣告和使用者曆史行為的相關性, 以便自适應地學習使用者興趣的特征表達. 具體來說, 文章介紹了 local activation unit 子產品, 其基于 Attention 機制, 對使用者曆史行為進行權重來表示使用者興趣, 其中權重參數是通過候選廣告和曆史行為互動來進行學習的.

另外, 本文還介紹了 Mini-batch Aware Regularization 與 Dice 激活函數兩種技術, 以幫助訓練大型的網絡.

上面這幾段話是做個簡單的總結, 下面再詳細分析一下.

核心觀點解讀

觀察下圖的 Base Model, 是現有的大多數 CTR 模型采用的模式:

DIN 深度興趣網絡介紹以及源碼淺析

其中紅藍粉三色節點分别表示商品 ID (Goods ID), 店鋪 ID (Shop ID), 類目 ID (Cate ID) 三種稀疏特征, 其他的輸入特征, 使用白色節點表示 (比如左邊的使用者特征, 比如使用者 ID; 還有右邊的上下文特征, 比如廣告位之類的特征). 注意 Goods 1 ~ Goods N 用來描述使用者的曆史行為. 候選廣告 Candidate Ad 本身也是商品, 也具有 Goods / Shop / Cate ID 三種特征.

之後使用 Embedding Layer 将這些類别特征轉化為 dense 的低維稠密向量, 其中對于使用者曆史行為特征, 之後使用 SUM Pooling 轉化為一個固定長度的向量, 該向量可以表示使用者的興趣; 這裡需要注意上一節 “核心觀點” 中提到的問題, 對于同一個使用者, 如果候選廣告 (Candidate Ad) 發生了變化, 使用者的興趣卻依然是同一個向量來表達, 顯然這限制了模型的表達能力, 畢竟使用者的興趣是豐富的/變化的.

為了解決上面的問題, 本文提出了深度興趣網絡 DIN, 如下圖:

DIN 深度興趣網絡介紹以及源碼淺析

注意該圖和 Base Model 的差異. DIN 介紹了被稱為 Local Activation Unit 的子產品, 它用來學習候選廣告和使用者曆史行為的關系, 并給出候選廣告和各個曆史行為的相關性程度 (即權重參數), 再對曆史行為序列進行權重求和, 最終得到使用者興趣的特征表達. 用數學公式來表達, 即為: 假設 為使用者 的曆史行為的 embedding list, 長度為 , 表示 Candidate Ad 的特征表達, 那麼給定廣告 , 使用者的興趣可以表示為:

其中 為前向傳播網絡 (本文中即為 Local Activation Unit, 觀察其接受兩個參數, 分别是使用者第 個曆史行為以及候選廣告的 embedding ), 其輸出結果, 即權重 .

可以從公式中觀察到, 即便對一組相同的使用者曆史行為序列, 如果候選廣告 發生了變化, 權重 也會發生變化, 使用者興趣的特征表示

文章核心觀點介紹完畢, 關于 Dice 激活函數和 Mini-batch Aware Regularization 後面也許會在代碼分析中介紹. (嗅到了 Flag 的味道~ ????????????)

DIN 源碼淺析

資料處理

詳見代碼: ​​https://github.com/zhougr1993/DeepInterestNetwork/blob/master/din/build_dataset.py​​

其核心邏輯代碼如下:

for reviewerID, hist in reviews_df.groupby('reviewerID'):
  pos_list = hist['asin'].tolist()
  def gen_neg():
    neg = pos_list[0]
    while neg in pos_list:
      neg = random.randint(0, item_count-1)
    return neg
  neg_list = [gen_neg() for i in range(len(pos_list))]

  for i in range(1, len(pos_list)):
    hist = pos_list[:i]
    if i != len(pos_list) - 1:
      train_set.append((reviewerID, hist, pos_list[i], 1))
      train_set.append((reviewerID, hist, neg_list[i], 0))
    else:
      label = (pos_list[i], neg_list[i])
      test_set.append((reviewerID, hist, label))      

(每當要畫圖的時候, 塵封已久的 iPad Pro 才能發揮它應該有的價值, 不愧為生産力工具 … ????, 沒有成為影碟機或者遊戲機, 不知道是幸運還是不幸~)

DIN 深度興趣網絡介紹以及源碼淺析

建構訓練集和測試集

代碼詳見: ​​https://github.com/zhougr1993/DeepInterestNetwork/blob/master/din/input.py​​

上一節對原始資料進行處理, 得到資料:

## 訓練資料
train_set.append((reviewerID, hist, pos_list[i], 1))
train_set.append((reviewerID, hist, neg_list[i], 0))

## 測試資料
label = (pos_list[i], neg_list[i])
test_set.append((reviewerID, hist, label))      

之後訓練集的制作使用 ​

​DataInput​

​ 實作, 其為一個疊代器:

class DataInput:
  ## ..... 不核心的代碼去掉
  def next(self):

    if self.i == self.epoch_size:
      raise StopIteration

    ts = self.data[self.i * self.batch_size : min((self.i+1) * self.batch_size,
                                                  len(self.data))]
    self.i += 1

    u, i, y, sl = [], [], [], []
    for t in ts:
      ## t 表示 (reviewerID, hist, pos_list[i]/neg_list[i], 1/0)
      ## 其中 t[1] = hist, 為使用者曆史行為序列
      u.append(t[0]) 
      i.append(t[2])
      y.append(t[3])
      sl.append(len(t[1]))
    max_sl = max(sl)

    hist_i = np.zeros([len(ts), max_sl], np.int64)

    k = 0
    for t in ts:
      for l in range(len(t[1])):
        hist_i[k][l] = t[1][l]
      k += 1

    return self.i, (u, i, y, hist_i, sl)      

​DataInput​

​​ 從 ​

​train_set​

​ 中讀取資料, 其中:

  • ​u​

    ​​ 儲存使用者的 User ID, 即代碼中​

    ​reviewID​

    ​​, 那麼​

    ​u​

    ​ 就是使用者 ID 序列
  • ​i​

    ​ 表示正樣本/負樣本,後面正負樣本我統一用目标節點 (target) 來描述, 即​

    ​i​

    ​ 為目标節點序列
  • ​y​

    ​​ 表示目标節點的 label, 取值為​

    ​1​

    ​​ 或​

    ​0​

    ​;
  • ​sl​

    ​​ 儲存使用者曆史行為序列的真實長度 (代碼中的​

    ​len(t[1])​

    ​​ 就是求曆史行為序列的長度),​

    ​max_sl​

    ​ 表示序列中的最大長度;

由于使用者曆史序列的長度是不固定的, 是以引入 ​

​hist_i​

​​, 其為一個矩陣, 将序列長度固定為 ​

​max_sl​

​​. 對于長度不足 ​

​max_sl​

​​ 的序列, 使用 ​

​0​

​​ 來進行填充 (注意 ​

​hist_i​

​ 使用 zero 矩陣來進行初始化的)

測試集的制作基本同理:

class DataInputTest:
  ## 忽略不核心的代碼
  def next(self):

    if self.i == self.epoch_size:
      raise StopIteration

    ts = self.data[self.i * self.batch_size : min((self.i+1) * self.batch_size,
                                                  len(self.data))]
    self.i += 1

    u, i, j, sl = [], [], [], []
    for t in ts:
      ## t 表示: (reviewerID, hist, label)
      ## 其中 label 為 (pos_list[i], neg_list[i])
      u.append(t[0])
      i.append(t[2][0])
      j.append(t[2][1])
      sl.append(len(t[1]))
    max_sl = max(sl)

    hist_i = np.zeros([len(ts), max_sl], np.int64)

    k = 0
    for t in ts:
      for l in range(len(t[1])):
        hist_i[k][l] = t[1][l]
      k += 1

    return self.i, (u, i, j, hist_i, sl)      

由于前面 ​

​test_set​

​​ 傳回的結果: ​

​(reviewerID, hist, label)​

​​, 其中 ​

​label​

​​ 表示 ​

​(pos_list[i], neg_list[i])​

​, 是以:

  • ​u​

    ​​ 儲存使用者的 User ID, 即代碼中​

    ​reviewID​

    ​​, 那麼​

    ​u​

    ​ 就是使用者 ID 序列
  • ​i​

    ​ 儲存正樣本
  • ​j​

    ​ 儲存負樣本
  • ​sl​

    ​​ 儲存使用者曆史行為序列的真實長度 (代碼中的​

    ​len(t[1])​

    ​​ 就是求曆史行為序列的長度),​

    ​max_sl​

    ​ 表示序列中的最大長度;
  • ​hist_i​

    ​​ 儲存使用者曆史行為序列, 長度不足​

    ​max_sl​

    ​​ 的序列後面用​

    ​0​

    ​ 填充.

模型建構

完整代碼見: ​​https://github.com/zhougr1993/DeepInterestNetwork/blob/master/din/model.py​​

注意看源碼的時候, 如果你在大腦中使用訓練集去将模型的流程過一遍, 可以忽略 Model 中關于 ​

​self.j​

​ 的代碼, 它隻會在運作測試集的時候被用到, 這和 TF 的靜态圖特性有關.

class Model(object):

  def __init__(self, user_count, item_count, cate_count, cate_list, predict_batch_size, predict_ads_num):

    self.u = tf.placeholder(tf.int32, [None,]) # [B]
    self.i = tf.placeholder(tf.int32, [None,]) # [B]
    self.j = tf.placeholder(tf.int32, [None,]) # [B]  ## 讀代碼的時候可以先忽略關于 self.j 的部分
    self.y = tf.placeholder(tf.float32, [None,]) # [B]
    self.hist_i = tf.placeholder(tf.int32, [None, None]) # [B, T]
    self.sl = tf.placeholder(tf.int32, [None,]) # [B]
    self.lr = tf.placeholder(tf.float64, [])      

先來看如果是訓練集, 模型會傳入哪些參數:

  • ​self.u​

    ​: 使用者 ID 序列
  • ​self.i​

    ​: 目标節點序列 (目标節點就是正或負樣本)
  • ​self.y​

    ​​: 目标節點對應的 label 序列, 正樣本對應​

    ​1​

    ​​, 負樣本對應​

    ​0​

  • ​self.hist_i​

    ​​: 使用者曆史行為序列, 大小為​

    ​[B, T]​

  • ​self.sl​

    ​: 記錄使用者行為序列的真實長度
  • ​self.lr​

    ​: 學習速率

Embedding 擷取

### 使用者 embedding, 整個代碼沒有被用到 .....
user_emb_w = tf.get_variable("user_emb_w", [user_count, hidden_units])
### 目标節點對應的商品 embedding
item_emb_w = tf.get_variable("item_emb_w", [item_count, hidden_units // 2])
### 如果我沒有猜錯, 這應該是 bias
item_b = tf.get_variable("item_b", [item_count],
                         initializer=tf.constant_initializer(0.0))
### 類目 embedding
cate_emb_w = tf.get_variable("cate_emb_w", [cate_count, hidden_units // 2])
cate_list = tf.convert_to_tensor(cate_list, dtype=tf.int64)

## 從 cate_list 中擷取目标節點對應的類目 id
ic = tf.gather(cate_list, self.i)
## 将目标節點對應的商品 embedding 和類目 embedding 進行 concatenation
i_emb = tf.concat(values = [
    tf.nn.embedding_lookup(item_emb_w, self.i),
    tf.nn.embedding_lookup(cate_emb_w, ic),
    ], axis=1)
i_b = tf.gather(item_b, self.i)

## 注意 self.hist_i 儲存使用者的曆史行為序列, 大小為 [B, T], 是以在進行 embedding_lookup 時,
## 輸出大小為 [B, T, H/2]; 之後将 Goods 和 Cate 的 embedding 進行 concat, 得到
## [B, T, H] 大小. 注意到 tf.concat 中的 axis 參數值為 2
hc = tf.gather(cate_list, self.hist_i)
h_emb = tf.concat([
    tf.nn.embedding_lookup(item_emb_w, self.hist_i),
    tf.nn.embedding_lookup(cate_emb_w, hc),
    ], axis=2)      

上面的代碼主要是對使用者 embedding layer, 商品 embedding layer 以及類目 embedding layer 進行初始化, 然後在擷取一個 Batch 中目标節點對應的 embedding, 儲存在 ​

​i_emb​

​ 中, 它由商品 (Goods) 和類目 (Cate) embedding 進行 concatenation, 相當于完成了下圖中的步驟:

DIN 深度興趣網絡介紹以及源碼淺析

後面對 ​

​self.hist_i​

​​ 進行處理, 其儲存了使用者的曆史行為序列, 大小為 ​

​[B, T]​

​​, 是以在進行 embedding_lookup 時, 輸出大小為 ​

​[B, T, H/2]​

​​; 之後将 Goods 和 Cate 的 embedding 進行 concat, 得到 ​

​[B, T, H]​

​​ 大小. 注意到 ​

​tf.concat​

​​ 中的 ​

​axis​

​ 參數值為 2, 相當于完成下圖中的步驟:

DIN 深度興趣網絡介紹以及源碼淺析

Attention

經過上面的處理, 我們已經得到候選廣告對應的 embedding ​

​i_emb​

​​, 大小為 ​

​[B, H]​

​​, 以及使用者曆史行為對應的 embedding ​

​hist_i​

​​, 大小為 ​

​[B, T, H]​

​, 下面将它們輸入 Attention 層中, 自适應學習使用者興趣的表征.

### 注意原代碼中寫的是 hist_i =attention(i_emb, h_emb, self.sl)
### 為了友善說明, 傳回值用 u_emb_i 來表示, 即 Attention 之後傳回使用者的興趣表征;
### 源代碼中傳回的結果 hist_i 還經過了 BN 層, 當然這不是我們關心的重點
u_emb_i = attention(i_emb, h_emb, self.sl)      

其中 ​

​Attention​

​ 層的具體實作為:

def attention(queries, keys, keys_length):
  '''
    queries:     [B, H]
    keys:        [B, T, H]
    keys_length: [B], 儲存着使用者曆史行為序列的真實長度
  '''
  ## queries.get_shape().as_list()[-1] 就是 H,
  ## tf.shape(keys)[1] 結果就是 T
  queries_hidden_units = queries.get_shape().as_list()[-1]
  queries = tf.tile(queries, [1, tf.shape(keys)[1]]) ## [B, T * H], 想象成貼瓷磚
  ## queries 先 reshape 成和 keys 相同的大小: [B, T, H]
  queries = tf.reshape(queries, [-1, tf.shape(keys)[1], queries_hidden_units])
  
  ## Local Activation Unit 的輸入, 候選廣告 queries 對應的 emb 以及使用者曆史行為序列 keys
  ## 對應的 embed, 再加上它們之間的交叉特征, 進行 concat 後, 再輸入到一個 DNN 網絡中
  ## DNN 網絡的輸出節點為 1
  din_all = tf.concat([queries, keys, queries-keys, queries*keys], axis=-1)
  d_layer_1_all = tf.layers.dense(din_all, 80, activation=tf.nn.sigmoid, name='f1_att', reuse=tf.AUTO_REUSE)
  d_layer_2_all = tf.layers.dense(d_layer_1_all, 40, activation=tf.nn.sigmoid, name='f2_att', reuse=tf.AUTO_REUSE)
  d_layer_3_all = tf.layers.dense(d_layer_2_all, 1, activation=None, name='f3_att', reuse=tf.AUTO_REUSE)
  ## 上一層 d_layer_3_all 的 shape 為 [B, T, 1]
  ## 下一步 reshape 為 [B, 1, T], axis=2 這一維表示 T 個使用者行為序列分别對應的權重參數
  d_layer_3_all = tf.reshape(d_layer_3_all, [-1, 1, tf.shape(keys)[1]])
  outputs = d_layer_3_all  ## [B, 1, T]
  
  # Mask
  ## 由于一個 Batch 中的使用者行為序列不一定都相同, 其真實長度儲存在 keys_length 中
  ## 是以這裡要産生 masks 來選擇真正的曆史行為
  key_masks = tf.sequence_mask(keys_length, tf.shape(keys)[1])   # [B, T]
  key_masks = tf.expand_dims(key_masks, 1) # [B, 1, T]
  paddings = tf.ones_like(outputs) * (-2 ** 32 + 1)
  ## 選出真實的曆史行為, 而對于那些填充的結果, 适用 paddings 中的值來表示
  ## padddings 中使用巨大的負值, 後面計算 softmax 時, e^{x} 結果就約等于 0
  outputs = tf.where(key_masks, outputs, paddings)  # [B, 1, T]

  # Scale
  outputs = outputs / (keys.get_shape().as_list()[-1] ** 0.5)

  # Activation
  outputs = tf.nn.softmax(outputs)  # [B, 1, T]

  # Weighted sum
  ## outputs 的大小為 [B, 1, T], 表示每條曆史行為的權重,
  ## keys 為曆史行為序列, 大小為 [B, T, H];
  ## 兩者用矩陣乘法做, 得到的結果就是 [B, 1, H]
  outputs = tf.matmul(outputs, keys)  # [B, 1, H]

  return      

代碼中給了詳細的注釋. 其中, Local Attention Unit 的輸入除了候選廣告的 embedding 以及使用者曆史行為的 embedding, 還有它們之間的交叉特征:

## queries 傳入候選廣告的 embedding, keys 傳入使用者曆史行為的 embedding
## queries-keys, queries*keys 均為交叉特征
din_all = tf.concat([queries, keys, queries-keys, queries*keys], axis=-1)

outputs = DNN(din_all)  ## 簡略代碼, 用來說明問題      

之後 ​

​din_all​

​ 會輸入到 DNN, 以産生權重, 相當于完成下面的步驟:

DIN 深度興趣網絡介紹以及源碼淺析

之後進行的重要一步是 産生 Mask, 這是因為:

DIN 深度興趣網絡介紹以及源碼淺析

得到正确的權重 ​

​outputs​

​​ 以及使用者曆史行為序列 ​

​keys​

​, 再進行矩陣相乘得到使用者的興趣表征:

# Weighted sum
## outputs 的大小為 [B, 1, T], 表示每條曆史行為的權重,
## keys 為曆史行為序列, 大小為 [B, T, H];
## 兩者用矩陣乘法做, 得到的結果就是 [B, 1, H]
outputs = tf.matmul(outputs, keys)  # [B, 1, H]      

此外, 作者還定義了名為 ​

​attention_multi_items​

​ 的函數,

def attention_multi_items(queries, keys, keys_length):      

它的邏輯和上面的 ​

​attention​

​​ 完全一樣, 隻不過, 上面 ​

​attention​

​​ 函數處理的 query 隻有一個候選廣告, 但是這裡 ​

​attention_multi_items​

​​ 一次處理 ​

​N​

​ 個候選廣告.

全連接配接層

這個沒啥好說的, ​

​din_i​

​ 中:

  • ​u_emb_i​

    ​: 由 Attention 層得到的輸出結果, 表示使用者興趣;
  • ​i_emb​

    ​: 候選廣告對應的 embedding;
  • ​u_emb_i * i_emb​

    ​: 使用者興趣和候選廣告的交叉特征;
din_i = tf.concat([u_emb_i, i_emb, u_emb_i * i_emb], axis=-1)
din_i = tf.layers.batch_normalization(inputs=din_i, name='b1')
d_layer_1_i = tf.layers.dense(din_i, 80, activation=tf.nn.sigmoid, name='f1')
#if u want try dice change sigmoid to None and add dice layer like following two lines. You can also find model_dice.py in this folder.
# d_layer_1_i = tf.layers.dense(din_i, 80, activation=None, name='f1')
# d_layer_1_i = dice(d_layer_1_i, name='dice_1_i')
d_layer_2_i = tf.layers.dense(d_layer_1_i, 40, activation=tf.nn.sigmoid, name='f2')
# d_layer_2_i = tf.layers.dense(d_layer_1_i, 40, activation=None, name='f2')
# d_layer_2_i = dice(d_layer_2_i, name='dice_2_i')
d_layer_3_i = tf.layers.dense(d_layer_2_i, 1, activation=None, name='f3')      

如下圖: (作者的 dense 節點設定和圖中不完全相同)

DIN 深度興趣網絡介紹以及源碼淺析

總結

部落格寫到這裡, 終于結束了. 勉為其難的總結一下吧, 其實就兩個字: 真的累~

繼續閱讀