如何用keras實現deepFM

  • 2019 年 10 月 5 日
  • 筆記

一些前面說明

  1. 實現基本完全基於文末列出的deepFM 原文(還有幾處或者更多地方可以優化,比如二次項多值輸入的處理,樣本編碼等等)
  2. 文末參考的文章用Keras實現一個DeepFM 是我們初期學習和搭建deepFM 的主要參考。然後下面我們的實現會比參考內容更簡單而且有一些處理上的差異。同時在我們的業務數據集上,下面我們自己的實現方式得到的測試 auc 大約都比按照上面文章的實現測試 auc 高約 0~0.01 左右。(當然這裡可能有各種原因導致的差異,並不能說下面的實現是絕對優於參考文章的)
  3. 下面的內容完全是個人行為,有錯漏希望多指教

實現這個 deepFM 需要掌握的內容

  • Keras 的使用,包括如果使用 Sequential 搭建模型,以及如何使用函數式 API 搭建較簡單模型
  • Dense, Embedding, Reshape, Concatenate, Add, Substract, Lambda 這幾個 Layer 的使用方式
  • 自定義簡單的 Layer
  • FM 的基本原理
  • 另外一些零散又沒法繞過的內容(優化器,激活函數,損失函數,正則化),幸運的是這些內容大部分框架幫我們處理好了,我們暫時只需要調參即可,甚至不會調參的化,copy一下別人的配置個人覺得也無傷大雅(畢竟我也只會copy)。這裡的參數只要不是太過分,參數變化對對模型結果應該起不到決定性作用。

DeepFM 簡述

deepFM 的發展史我們也不多介紹,目前我們也主要用於做 ctr cvr 預測。

deepFM 說起來結構還是比較簡單,包含了左邊的 FM 和右邊的 deep 部分,每個神經元進行了什麼操作也在圖中表示得很清楚。需要注意的是,圖中的連線有紅線和黑線的區別,紅線表示權重為 1,黑線表示有需要訓練的權重連線。

  • Addition 普通的線性加權相加,就是 w*x
  • Inner Product 內積操作,就是 FM 的二次項隱向量兩兩相乘的部分
  • Sigmoid 激活函數,即最後整合兩部分輸出合併進入 sigmoid 激活函數得到的輸出結果
  • Activation Function,這裡為激活函數用的就是線性整流器 relu 函數

FM(因式分解機) 簡述

這裡不著重描述 FM 是什麼,FM 由如下公式表示(只討論二階組合的情況)

同樣是線性公式,和 LR 的唯一區別,就在於後面的二次項,該二次項表示各個特徵交叉相乘,即相當於我們在機器學習中的組合特徵。

FM 的這部分能力,解決了 LR 只能對一階特徵做學習的局限性。

LR 如果要使用組合特徵,必須手動做特徵組合,這一步需要經驗。FM 的二次項可以自動對特徵做組合。

同時 FM 的公式可以化為如下,v 表示的就是對應的特徵 x 的隱向量。

上面的公式還能進一步轉換成

這個公式的優點在於,上一個公式要訓練組合權重 w,需要兩個組合特徵的樣本值同時有值才能使 w 得到訓練,但是組合特徵原本樣本就較少,這樣的訓練方式很難使權重 w 得到充分訓練。

通過因式分解機,可以使用一個長度為 k 的隱向量來表達每一個輸入的特徵值 x,標記為 v,並且通過兩個特徵的 v 值求內積,其結果可以等同於特徵交叉項的權重 w。

通過隱向量 v 表示特徵的方式好處是,交叉項不需要保證兩個特徵均有值才能使 v 得到訓練,每一個包含有值特徵 x 的樣本,都能使之對應的隱向量 v 得到訓練。

這裡圈一下重點:

  • LR 的升級版
  • 有個二階項(也可以有更高階項,但付出的計算代價也更大)
  • 通過引入隱向量,訓練時二階項組合特徵無需同時有值就可以得到訓練

樣本格式

樣本保存格式

每個訓練樣本都會有自己的保存格式,libsvm 或者 tfrecord 或者其他什麼形式。

我們的樣本格式為:

  1. 單值離散特徵而是直接輸入index
  2. 多值離散特徵也是輸入 index,但是是輸入一串對應的 index 值,如 [5,9,11]
  3. 如果有沒有維表的字元串特徵,我們通過哈希轉換成某個範圍內的數字,這個轉換是確定的,比如 「hello」 恆轉換成 10,即變成了 1 情況里描述的單值離散特徵 (哈希是會出現一定概率碰撞的,這裡需要將維度冗餘大約10倍使碰撞率低於5%,目前這樣處理在我們的場景下模型效果無差異)
  4. 連續值直接輸入即可。(如果是較大的連續值,需在特徵工程部分先做歸一化,或者考慮先做離散化處理成離散值)

最後得到的樣本形如 1,5,10,3,6,0.5,0.4,100,[5,9,11]。這樣的話,線上的 TFserving 除了最後的 [5,9,11] 部分因為是變長,還是必須轉換成 one-hot 形式。其餘部分線上交給 embedding 層處理,就無需拼接 one-hot 向量輸入,節省輸入樣本長度。

特徵索引

當然如果保存後的樣本是上面些的 1,5,10,3,6,0.5,0.4,100,[5,9,11],我們還需要知道每個值是什麼特徵,維度是多少,以及訓練時如何轉換成可以使用的樣本。

所以需要有一行特徵索引和每一條樣本的每個值一一對應。假設可以用以下形式存儲索引。

1-age-100, 1-gender-3, 2-ads_weight-1, 3-game-50...  對應的每個表示是類型-特徵名-維度

左滑查看完整程式碼,下同

總之只要有一個對應方式,通過查詢索引找到特徵的資訊即可,我們後面的輸入樣本就需要根據這些資訊來轉換,並且餵給模型做訓練。

模型輸入

後續對於模型的輸入,我們根據不同特徵定義了對應不同的 Input。所以最後輸入的訓練格式要注意。訓練輸入應該長相如下,

train_x = [np.array([...]), np.array([...]), np.array([...])]label = np.array([0, 1, 0 ...])

實現 FM 部分

談到具體如何實現模型。下圖是 deepFM 網路的 FM 部分。

我們看到上圖有紅色的連線和黑色的連線

  • 第一層到第三層的黑色的連線部分就是原始輸入通過線性加權,得到模型的一次項。
  • 第二層到第三層的紅色連線則指的是原始特徵通過各自的隱向量來表達後,根據公式兩兩做內積,得到一堆內積結果
  • 最後第三層到第四層的一次項和二次項通過紅色連線相加,得到最後的 FM 輸出

按步驟實現,就是需要實現一次項和二次項兩部分,然後相加得到 FM 這部分的輸出。

一次項部分

FM 的一次項部分

這一部分思路很簡單

  1. 連續值,通過 dense(1) 得到 input*weight 的輸出。
  2. 單值離散特徵,通過 embedding(dim,1) 得到一維輸出, embedding(dim,1) 可以認為就是 input*weight 得到的一個輸出
  3. 多值離散特徵,通過 dense(1) 得到一個輸出值,比如 [1,5,7] 實際進入訓練時是 [0,1,0,0,0,1,0,1](假設最大特徵就是7)dense(1) 得到的就是對應 1 值乘以 weight並且相加得到的結果。
  4. 最後把上面1,2,3得到的單位輸出全部 Add 相加,得到的就是上述一次項結果。

上述過程可以簡單通過程式碼表達為

continuous = Input(shape=(1, ), name='single_continuous')single_discrete = Input(shape=(1, ), name='single_discrete')multi_discrete = Input(shape=(8, ), name='multi_discrete')continuous_dense = Dense(1)(continuous)single_embedding = Reshape([1])(Embedding(10, 1)(single_discrete))multi_dense = Dense(1)(multi_discrete)first_order_sum = Add()([continuous_dense, single_embedding, multi_dense])

二次項部分

FM 的二次項部分

等價於

這一部分主要做的事情,就是需要得到一個表示各 field 的隱向量,而且不管每個特徵 field 長度是多少,最後得到的隱向量長度都為 k(這裡 k 由開發者自己指定)。我們來分析一下如何處理這部分。

  1. 連續值,要得到 k 長度輸出,我們直接使用 dense(k)
  2. 單值離散特徵,同樣通過 Embedding(dim,k) 得到 k 長度輸出
  3. 多值離散特徵,使用 Dense(k) 得到 k 長度輸出(這一部分為了簡化,直接使用全連接層,包括後面的二次項操作)。

承接上面的程式碼,這一部分的程式碼表示為

continuous_k = Dense(3)(continuous)single_k = Reshape([3])(Embedding(10, 3)(single_discrete))multi_k = Dense(3)(multi_discrete)

最後得到如下圖的輸出,我們這裡假設 k=3,下列每一種類型的 3 位輸出,最後 concate 在一起,要作為後面說到的 deep 層的輸入。

再看一次上面二次項部分的公式,我們使用

其實相當於兩部分內容

這一部分是先相加後平方

這一部分是先平方後相加

從上一張圖我們看到一個資訊,每個 k=3 的時候,每個輸出節點,其實就相當於 Xi*Vil。

多值離散特徵的 k=3 的每個輸出其實等於 XiVil+XjVjl,因為他還是同一個 field 的多個特徵值,為了簡化,我們認為這個結果近似等於

所以不管是要先相加後平方,或者先平方後相加,最後等同於上面的 3 個 3維神經元相加起來,對應得到的數就等於求和部分。如下圖。

先相加後平方

所以這裡每個 k=3 的輸出,都是一個 Xi*Vil。先相加後平方的一項,利用 Lambda 層對每個元素做一次平方處理,接上面的程式碼得到

sum_square_layer = Lambda(lambda x: x**2)(Add()([continuous_k, single_k, multi_k]))

先平方後相加

跟上一步類似,我們得到

continuous_square = Lambda(lambda x:x**2)(continuous_k)single_square = Lambda(lambda x:x**2)(single_k)multi_square = Lambda(lambda x:x**2)(multi_k)square_sum_layer = Add()([continuous_square, single_square, multi_square])

二次項的最後輸出

最後結合上面兩部分,得到二次項的最後輸出為上面兩項相減,乘以二分之一後,再對 k=3 的三個值相加。

substract_layer = Lambda(lambda x:x*0.5)(Subtract()([sum_square_layer, square_sum_layer]))# 要實現單層的各個值相加,目前 Keras 似乎沒有這樣的操作# 可以通過自定義一個簡單層來簡單實現我們需要的功能class SumLayer(Layer):  def __init__(self, **kwargs):    super(SumLayer, self).__init__(**kwargs)  def call(self, inputs):    inputs = K.expand_dims(inputs)    return K.sum(inputs, axis=1)  def compute_output_shape(self, input_shape):    return tuple([input_shape[0], 1])# 最後相加 k 維輸出,結果等於second_order_sum = SumLayer()(substract_layer)

FM部分的最後輸出

我們再回顧一下要注意到最開始的deepFM論文中的原圖,FM 部分最後連接到outpu units的 FM 部分,是紅色的線,weight-1 connection。

也就是說,FM 部分最後相當於需要把一次項和二次項的輸出值相加得到一個單值輸出,然後再跟 deep 部分的輸出相加,進入 sigmoid 激活函數。所以 FM 部分我們最後的輸出為

fm_output = Add()([first_order_sum, second_order_sum])

實現 deep 部分

[ deep部分 ]

deep 部分全是黑色連線,所以實現很簡單,只需要把上面 FM 部分的二次項 k 維輸出 concate 後作為輸入,然後進入幾層全連接層,最後得到的單值輸出和 FM 部分的單值輸出 concate,再經過一次 Dense(1),進入 sigmoid 函數即可。

可以直接看程式碼如何實現這部分。

deep_input = Concatenate()([continuous_k, single_k, multi_k])deep_layer_0 = Dropout(0.5)(Dense(64, activation='relu')(deep_input))deep_layer_1 = Dropout(0.5)(Dense(64, activation='relu')(deep_layer_0))deep_layer_2 = Dropout(0.5)(Dense(64, activation='relu')(deep_layer_1))deep_output = Dropout(0.5)(Dense(1, activation='relu')(deep_layer_2))

最後輸出部分

concat_layer = Concatenate()([fm_output, deep_output])y = Dense(1, activation='sigmoid')(concat_layer)

到此模型的程式碼就完成了,剩餘的就是樣本的處理,以及各自如何把樣本喂入模型的程式碼。這些程式碼應該是根據各自業務,各自樣本格式需要做對應處理。

deepFM 結果對比

根據上面的方法實現了模型之後,我們用自己的業務的幾份數據集做了離線測試。

  1. 完整deepFM模型
  2. 只有deep的模型,deep部分的輸入是特徵簡單embedding等處理後的輸入,即上面的[continuous_dense, single_embedding, multi_dense] concat後的輸入
  3. 只有 FM 的模型,即把 deep 部分去掉,最後進入 Dense(1) 的只有fm_output

在目前我們個業務的幾份不同日期的數據集合上測試,得到的AUC 結果如下。

數據集

deepFM

FM

deep

A1

0.86661

0.86581

0.85166

A2

0.86125

0.86121

0.84789

A3

0.84842

0.84841

0.80581

A4

0.84170

0.84083

0.82848

有一個大致經驗,deepFM 在有效特徵更多,特徵工程處理更好,數據更乾淨,數據更有區分度的數據集上,得到的對比結果差異會更大。

反之,如果數據特徵和 label 本身的關聯性不高,數據本身無法很好的區分樣本時,對比結果差異會很小。

FM 有時候表現已經和 deepFM 幾乎無差別。猜測的原因是數據本身並不需要複雜規則就能得到很好的模型區分,所以比 FM 多出來的這部分 deep 能力顯得並不太重要。

同時因為 deep 部分明顯效果都不如前兩者,所以可能可以驗證上一步猜測。

預計需要的是更多在特徵工程上做優化,以及挖掘更多有效特徵。

最後附上程式碼demo

以上面的程式碼為例,附上完整的實現程式碼。這個demo是直接可運行的。

import numpy as npfrom keras.layers import *from keras.models import Modelfrom keras import backend as Kfrom keras import optimizersfrom keras.engine.topology import Layer# 樣本和標籤,這裡需要對應自己的樣本做處理train_x = [    np.array([0.5, 0.7, 0.9]),    np.array([2, 4, 6]),    np.array([[0, 1, 0, 0, 0, 1, 0, 1], [0, 1, 0, 0, 0, 1, 0, 1],              [0, 1, 0, 0, 0, 1, 0, 1]])]label = np.array([0, 1, 0])# 輸入定義continuous = Input(shape=(1, ), name='single_continuous')single_discrete = Input(shape=(1, ), name='single_discrete')multi_discrete = Input(shape=(8, ), name='multi_discrete')# FM 一次項部分continuous_dense = Dense(1)(continuous)single_embedding = Reshape([1])(Embedding(10, 1)(single_discrete))multi_dense = Dense(1)(multi_discrete)# 一次項求和first_order_sum = Add()([continuous_dense, single_embedding, multi_dense])# FM 二次項部分 k=3continuous_k = Dense(3)(continuous)single_k = Reshape([3])(Embedding(10, 3)(single_discrete))multi_k = Dense(3)(multi_discrete)# 先相加後平方sum_square_layer = Lambda(lambda x: x**2)(    Add()([continuous_k, single_k, multi_k]))# 先平方後相加continuous_square = Lambda(lambda x: x**2)(continuous_k)single_square = Lambda(lambda x: x**2)(single_k)multi_square = Lambda(lambda x: x**2)(multi_k)square_sum_layer = Add()([continuous_square, single_square, multi_square])substract_layer = Lambda(lambda x: x * 0.5)(    Subtract()([sum_square_layer, square_sum_layer]))# 定義求和層class SumLayer(Layer):  def __init__(self, **kwargs):    super(SumLayer, self).__init__(**kwargs)  def call(self, inputs):    inputs = K.expand_dims(inputs)    return K.sum(inputs, axis=1)  def compute_output_shape(self, input_shape):    return tuple([input_shape[0], 1])# 二次項求和second_order_sum = SumLayer()(substract_layer)# FM 部分輸出fm_output = Add()([first_order_sum, second_order_sum])# deep 部分deep_input = Concatenate()([continuous_k, single_k, multi_k])deep_layer_0 = Dropout(0.5)(Dense(64, activation='relu')(deep_input))deep_layer_1 = Dropout(0.5)(Dense(64, activation='relu')(deep_layer_0))deep_layer_2 = Dropout(0.5)(Dense(64, activation='relu')(deep_layer_1))deep_output = Dropout(0.5)(Dense(1, activation='relu')(deep_layer_2))concat_layer = Concatenate()([fm_output, deep_output])y = Dense(1, activation='sigmoid')(concat_layer)model = Model(inputs=[continuous, single_discrete, multi_discrete], outputs=y)Opt = optimizers.Adam(    lr=0.001, beta_1=0.9, beta_2=0.999, epsilon=None, decay=0.0, amsgrad=False)model.compile(    loss='binary_crossentropy',    optimizer=Opt,    metrics=['acc'])model.fit(    train_x,    label,    shuffle=True,    epochs=1,    verbose=1,    batch_size=1024,    validation_split=None)    

參考內容