【通俗易懂】手把手帶你實現DeepFM!

  • 2019 年 10 月 31 日
  • 筆記

勘誤:本文修正了原文中的兩處錯誤。感謝群友對文中錯誤的指正!小編以後也會更加細心的校對文章品質

1、模型輸入部分,label的shape應該是[None,1]

2、FM一次項的係數應該由feature_bias獲得。

可以說,DeepFM是目前最受歡迎的CTR預估模型之一,不僅是在交流群中被大家提及最多的,同時也是在面試中最多被提及的:

1、Deepfm的原理,DeepFM是一個模型還是代表了一類模型,DeepFM對FM做了什麼樣的改進,FM的公式如何化簡併求解梯度(滴滴) 2、FM、DeepFM介紹一下(貓眼) 3、DeepFm模型介紹一下(一點資訊) 4、DeepFM介紹下 & FM推導(一點資訊)

這次咱們一起來回顧一下DeepFM模型,以及手把手來實現一波!

1、DeepFM原理回顧

先來回顧一下DeepFM的模型結構:

DeepFM包含兩部分:因子分解機部分與神經網路部分,分別負責低階特徵的提取和高階特徵的提取。這兩部分共享同樣的嵌入層輸入。DeepFM的預測結果可以寫為:

嵌入層

嵌入層(embedding layer)的結構如上圖所示。通過嵌入層,儘管不同field的長度不同(不同離散變數的取值個數可能不同),但是embedding之後向量的長度均為K(我們提前設定好的embedding-size)。通過程式碼可以發現,在得到embedding之後,我們還將對應的特徵值乘到了embedding上,這主要是由於fm部分和dnn部分共享嵌入層作為輸入,而fm中的二次項如下:

FM部分

FM部分的詳細結構如下:

FM部分是一個因子分解機。關於因子分解機可以參閱文章[Rendle, 2010] Steffen Rendle. Factorization machines. In ICDM, 2010.。因為引入了隱變數的原因,對於幾乎不出現或者很少出現的隱變數,FM也可以很好的學習。

FM的輸出公式為:

深度部分

深度部分是一個多層前饋神經網路。我們這裡不再贅述。

有關模型具體如何操作,我們可以通過程式碼來進一步加深認識。

2、程式碼實現

2.1 Embedding介紹

DeepFM中,很重要的一項就是embedding操作,所以我們先來看看什麼是embedding,可以簡單的理解為,將一個特徵轉換為一個向量。在推薦系統當中,我們經常會遇到離散變數,如userid、itemid。對於離散變數,我們一般的做法是將其轉換為one-hot,但對於itemid這種離散變數,轉換成one-hot之後維度非常高,但裡面只有一個是1,其餘都為0。這種情況下,我們的通常做法就是將其轉換為embedding。

embedding的過程是什麼樣子的呢?它其實就是一層全連接的神經網路,如下圖所示:

假設一個離散變數共有5個取值,也就是說one-hot之後會變成5維,我們想將其轉換為embedding表示,其實就是接入了一層全連接神經網路。由於只有一個位置是1,其餘位置是0,因此得到的embedding就是與其相連的圖中紅線上的權重。

2.2 tf.nn.embedding_lookup函數介紹

在tf1.x中,我們使用embedding_lookup函數來實現embedding,程式碼如下:

# embedding  embedding = tf.constant(          [[0.21,0.41,0.51,0.11]],          [0.22,0.42,0.52,0.12],          [0.23,0.43,0.53,0.13],          [0.24,0.44,0.54,0.14]],dtype=tf.float32)    feature_batch = tf.constant([2,3,1,0])    get_embedding1 = tf.nn.embedding_lookup(embedding,feature_batch)

在embedding_lookup中,第一個參數相當於一個二維的詞表,並根據第二個參數中指定的索引,去詞表中尋找並返回對應的行。上面的過程為:

注意這裡的維度的變化,假設我們的feature_batch 是 1維的tensor,長度為4,而embedding的長度為4,那麼得到的結果是 4 * 4 的,同理,假設feature_batch是2 *4的,embedding_lookup後的結果是2 * 4 * 4。每一個索引返回embedding table中的一行,自然維度會+1。

上文說過,embedding層其實是一個全連接神經網路層,那麼其過程等價於:

可以得到下面的程式碼:

embedding = tf.constant(      [          [0.21,0.41,0.51,0.11],          [0.22,0.42,0.52,0.12],          [0.23,0.43,0.53,0.13],          [0.24,0.44,0.54,0.14]      ],dtype=tf.float32)    feature_batch = tf.constant([2,3,1,0])  feature_batch_one_hot = tf.one_hot(feature_batch,depth=4)  get_embedding2 = tf.matmul(feature_batch_one_hot,embedding)

二者是否一致呢?我們通過程式碼來驗證一下:

with tf.Session() as sess:      sess.run(tf.global_variables_initializer())      embedding1,embedding2 = sess.run([get_embedding1,get_embedding2])      print(embedding1)      print(embedding2)

得到的結果為:

二者得到的結果是一致的。

因此,使用embedding_lookup的話,我們不需要將數據轉換為one-hot形式,只需要傳入對應的feature的index即可。

2.3 數據處理

接下來進入程式碼實戰部分。

首先我們來看看數據處理部分,通過剛才的講解,想要給每一個特徵對應一個k維的embedding,如果我們使用embedding_lookup的話,需要得到連續變數或者離散變數對應的特徵索引feature index。聽起來好像有點抽象,咱們還是舉個簡單的例子:

這裡有三組特徵,或者說3個field的特徵,分別是性別、星期幾、職業。對應的特徵數量分別為2、7、2。我們總的特徵數量feature-size為2 + 7 + 2=11。如果轉換為one-hot的話,每一個取值都會對應一個特徵索引feature-index。

這樣,對於一個實例男/二/學生來說,將其轉換為對應的特徵索引即為0、3、9。在得到特徵索引之後,就可以通過embedding_lookup來獲取對應特徵的embedding。

當然,上面的例子中我們只展示了三個離散變數,對於連續變數,我們也會給它一個對應的特徵索引,如:

可以看到,此時共有5個field,一個連續特徵就對應一個field。

但是在FM的公式中,不光是embedding的內積,特徵取值也同樣需要。對於離散變數來說,特徵取值就是1,對於連續變數來說,特徵取值是其本身,因此,我們想要得到的數據格式如下:

定好了目標之後,咱們就開始實現程式碼。先看下對應的數據集:

import pandas as pd    TRAIN_FILE = "data/train.csv"  TEST_FILE = "data/test.csv"    NUMERIC_COLS = [      "ps_reg_01", "ps_reg_02", "ps_reg_03",      "ps_car_12", "ps_car_13", "ps_car_14", "ps_car_15"  ]    IGNORE_COLS = [      "id", "target",      "ps_calc_01", "ps_calc_02", "ps_calc_03", "ps_calc_04",      "ps_calc_05", "ps_calc_06", "ps_calc_07", "ps_calc_08",      "ps_calc_09", "ps_calc_10", "ps_calc_11", "ps_calc_12",      "ps_calc_13", "ps_calc_14",      "ps_calc_15_bin", "ps_calc_16_bin", "ps_calc_17_bin",      "ps_calc_18_bin", "ps_calc_19_bin", "ps_calc_20_bin"  ]    dfTrain = pd.read_csv(TRAIN_FILE)  dfTest = pd.read_csv(TEST_FILE)  print(dfTrain.head(10))

數據集如下:

我們定義了一些不考慮的變數列、一些連續變數列,剩下的就是離散變數列,接下來,想要得到一個feature-map。這個featrue-map定義了如何將變數的不同取值轉換為其對應的特徵索引feature-index。

df = pd.concat([dfTrain,dfTest])    feature_dict = {}  total_feature = 0  for col in df.columns:      if col in IGNORE_COLS:          continue      elif col in NUMERIC_COLS:          feature_dict[col] = total_feature          total_feature += 1      else:          unique_val = df[col].unique()          feature_dict[col] = dict(zip(unique_val,range(total_feature,len(unique_val) + total_feature)))          total_feature += len(unique_val)  print(total_feature)  print(feature_dict)

這裡,我們定義了total_feature來得到總的特徵數量,定義了feature_dict來得到變數取值到特徵索引的對應關係,結果如下:

可以看到,對於連續變數,直接是變數名到索引的映射,對於離散變數,內部會嵌套一個二級map,這個二級map定義了該離散變數的不同取值到索引的映射。

下一步,需要將訓練集和測試集轉換為兩個新的數組,分別是feature-index,將每一條數據轉換為對應的特徵索引,以及feature-value,將每一條數據轉換為對應的特徵值。

"""  對訓練集進行轉化  """  print(dfTrain.columns)  train_y = dfTrain[['target']].values.tolist()  dfTrain.drop(['target','id'],axis=1,inplace=True)  train_feature_index = dfTrain.copy()  train_feature_value = dfTrain.copy()    for col in train_feature_index.columns:      if col in IGNORE_COLS:          train_feature_index.drop(col,axis=1,inplace=True)          train_feature_value.drop(col,axis=1,inplace=True)          continue      elif col in NUMERIC_COLS:          train_feature_index[col] = feature_dict[col]      else:          train_feature_index[col] = train_feature_index[col].map(feature_dict[col])          train_feature_value[col] = 1      """  對測試集進行轉化  """  test_ids = dfTest['id'].values.tolist()  dfTest.drop(['id'],axis=1,inplace=True)    test_feature_index = dfTest.copy()  test_feature_value = dfTest.copy()    for col in test_feature_index.columns:      if col in IGNORE_COLS:          test_feature_index.drop(col,axis=1,inplace=True)          test_feature_value.drop(col,axis=1,inplace=True)          continue      elif col in NUMERIC_COLS:          test_feature_index[col] = feature_dict[col]      else:          test_feature_index[col] = test_feature_index[col].map(feature_dict[col])          test_feature_value[col] = 1

來看看此時的訓練集的特徵索引:

對應的特徵值:

此時,我們的訓練集和測試集就處理完畢了!

2.4 模型參數及輸入

接下來定義模型的一些參數,如學習率、embedding的大小、深度網路的參數、激活函數等等,還有兩個比較重要的參數,分別是feature的大小和field的大小:

"""模型參數"""  dfm_params = {      "use_fm":True,      "use_deep":True,      "embedding_size":8,      "dropout_fm":[1.0,1.0],      "deep_layers":[32,32],      "dropout_deep":[0.5,0.5,0.5],      "deep_layer_activation":tf.nn.relu,      "epoch":30,      "batch_size":1024,      "learning_rate":0.001,      "optimizer":"adam",      "batch_norm":1,      "batch_norm_decay":0.995,      "l2_reg":0.01,      "verbose":True,      "eval_metric":'gini_norm',      "random_seed":3  }  dfm_params['feature_size'] = total_feature  dfm_params['field_size'] = len(train_feature_index.columns)

而訓練模型的輸入有三個,分別是剛才轉換得到的特徵索引和特徵值,以及label:

"""開始建立模型"""  feat_index = tf.placeholder(tf.int32,shape=[None,None],name='feat_index')  feat_value = tf.placeholder(tf.float32,shape=[None,None],name='feat_value')    label = tf.placeholder(tf.float32,shape=[None,1],name='label')

比如剛才的兩個數據,對應的輸入為:

定義好輸入之後,再定義一下模型中所需要的weights:

"""建立weights"""  weights = dict()    #embeddings  weights['feature_embeddings'] = tf.Variable(      tf.random_normal([dfm_params['feature_size'],dfm_params['embedding_size']],0.0,0.01),      name='feature_embeddings')  weights['feature_bias'] = tf.Variable(tf.random_normal([dfm_params['feature_size'],1],0.0,1.0),name='feature_bias')      #deep layers  num_layer = len(dfm_params['deep_layers'])  input_size = dfm_params['field_size'] * dfm_params['embedding_size']  glorot = np.sqrt(2.0/(input_size + dfm_params['deep_layers'][0]))    weights['layer_0'] = tf.Variable(      np.random.normal(loc=0,scale=glorot,size=(input_size,dfm_params['deep_layers'][0])),dtype=np.float32  )  weights['bias_0'] = tf.Variable(      np.random.normal(loc=0,scale=glorot,size=(1,dfm_params['deep_layers'][0])),dtype=np.float32  )      for i in range(1,num_layer):      glorot = np.sqrt(2.0 / (dfm_params['deep_layers'][i - 1] + dfm_params['deep_layers'][I]))      weights["layer_%d" % i] = tf.Variable(          np.random.normal(loc=0, scale=glorot, size=(dfm_params['deep_layers'][i - 1], dfm_params['deep_layers'][i])),          dtype=np.float32)  # layers[i-1] * layers[I]      weights["bias_%d" % i] = tf.Variable(          np.random.normal(loc=0, scale=glorot, size=(1, dfm_params['deep_layers'][i])),          dtype=np.float32)  # 1 * layer[I]      # final concat projection layer    if dfm_params['use_fm'] and dfm_params['use_deep']:      input_size = dfm_params['field_size'] + dfm_params['embedding_size'] + dfm_params['deep_layers'][-1]  elif dfm_params['use_fm']:      input_size = dfm_params['field_size'] + dfm_params['embedding_size']  elif dfm_params['use_deep']:      input_size = dfm_params['deep_layers'][-1]    glorot = np.sqrt(2.0/(input_size + 1))  weights['concat_projection'] = tf.Variable(np.random.normal(loc=0,scale=glorot,size=(input_size,1)),dtype=np.float32)  weights['concat_bias'] = tf.Variable(tf.constant(0.01),dtype=np.float32)

介紹兩個比較重要的參數,weights['feature_embeddings']是每個特徵所對應的embedding,它的大小為feature-size * embedding-size,另一個參數是weights['feature_bias'] ,這個是FM部分計算時所用到的一次項的權重參數,可以理解為embedding-size為1的embedding table,它的大小為feature-size * 1。

假設weights['feature_embeddings']如下:

2.5 嵌入層

嵌入層,主要根據特徵索引得到對應特徵的embedding:

"""embedding"""  embeddings = tf.nn.embedding_lookup(weights['feature_embeddings'],feat_index)    reshaped_feat_value = tf.reshape(feat_value,shape=[-1,dfm_params['field_size'],1])    embeddings = tf.multiply(embeddings,reshaped_feat_value)

這裡注意的是,在得到對應的embedding之後,還乘上了對應的特徵值,這個主要是根據FM的公式得到的。過程表示如下:

2.6 FM部分

我們先來回顧一下FM的公式,以及二次項的化簡過程:

上面的式子中有部分同學曾經問我第一步是怎麼推導的,其實也不難,看下面的手寫過程(大夥可不要嫌棄字丑喲)

因此,一次項的計算如下,我們剛剛也說過了,通過weights['feature_bias']來得到一次項的權重係數:

fm_first_order = tf.nn.embedding_lookup(weights['feature_bias'],feat_index)  fm_first_order = tf.reduce_sum(tf.multiply(fm_first_order,reshaped_feat_value),2)

對於二次項,經過化簡之後有兩部分(暫不考慮最外層的求和),我們先用excel來形象展示一下兩部分,這有助於你對下面程式碼的理解。

第一部分過程如下:

第二部分的過程如下:

最後兩部分相減:

二次項部分的程式碼如下:

summed_features_emb = tf.reduce_sum(embeddings,1)  summed_features_emb_square = tf.square(summed_features_emb)    squared_features_emb = tf.square(embeddings)  squared_sum_features_emb = tf.reduce_sum(squared_features_emb,1)    fm_second_order = 0.5 * tf.subtract(summed_features_emb_square,squared_sum_features_emb)  

要注意這裡的fm_second_order是二維的tensor,大小為batch-size * embedding-size,也就是公式中最外層的一個求和還沒有進行,這也是程式碼中與FM公式有所出入的地方。我們後面再講。

2.7 Deep部分

Deep部分很簡單了,就是幾層全連接的神經網路:

"""deep part"""  y_deep = tf.reshape(embeddings,shape=[-1,dfm_params['field_size'] * dfm_params['embedding_size']])    for i in range(0,len(dfm_params['deep_layers'])):      y_deep = tf.add(tf.matmul(y_deep,weights["layer_%d" %i]), weights["bias_%d"%I])      y_deep = tf.nn.relu(y_deep)

2.8 輸出部分

最後的輸出部分,論文中的公式如下:

在我們的程式碼中如下:

"""final layer"""  if dfm_params['use_fm'] and dfm_params['use_deep']:      concat_input = tf.concat([fm_first_order,fm_second_order,y_deep],axis=1)  elif dfm_params['use_fm']:      concat_input = tf.concat([fm_first_order,fm_second_order],axis=1)  elif dfm_params['use_deep']:      concat_input = y_deep    out = tf.nn.sigmoid(tf.add(tf.matmul(concat_input,weights['concat_projection']),weights['concat_bias']))

看似有點出入,其實真的有點出入,不過無礙,咱們做兩點說明:

1)首先,這裡整體上和最終的公式是相似的,看下面的excel(由於最後一層只有一個神經元,矩陣相乘可以用對位相乘再求和代替):

2)這裡不同的地方就是,FM二次項化簡之後最外層不再是簡單的相加了,而是變成了加權求和(有點類似attention的意思),如果FM二次項部分對應的權重都是1,就是標準的FM了。

2.9 Loss and Optimizer

這一塊也不再多講,我們使用logloss來指導模型的訓練:

"""loss and optimizer"""  loss = tf.losses.log_loss(tf.reshape(label,(-1,1)), out)  optimizer = tf.train.AdamOptimizer(learning_rate=dfm_params['learning_rate'], beta1=0.9, beta2=0.999,                                                          epsilon=1e-8).minimize(loss)

至此,我們整個DeepFM模型的架構就搭起來了,接下來,我們簡單測試一下程式碼:

"""train"""  with tf.Session() as sess:      sess.run(tf.global_variables_initializer())      for i in range(100):          epoch_loss,_ = sess.run([loss,optimizer],feed_dict={feat_index:train_feature_index,                               feat_value:train_feature_value,                               label:train_y})          print("epoch %s,loss is %s" % (str(i),str(epoch_loss)))

調試成功:

好了,本次的手把手實現DeepFM教學就到這裡了,這個模型大家一定要掌握啊,推薦系統崗位校招必考!

程式碼鏈接:https://github.com/princewen/tensorflow_practice/blob/master/recommendation/Basic-DeepFM-model/DeepFM-StepByStep.ipynb

數據在git最外層的readme中。