FM 算法介紹以及 libFM 源碼簡析

FM 算法介紹以及 libFM 源碼簡析

libFM 的大名如雷貫耳, 然而一直沒有機會看看它的具體實現; 週末查看了一下 FM 算法的原理以及源碼實現, 現在記錄下心得. 另外發現大佬很多, 像博主 zhiyong_will 寫了一系列的文章, 比如 機器學習算法實現解析——libFM之libFM的訓練過程之SGD的方法 來介紹 libFM 的源碼實現, 看後受益匪淺. 感覺以後要是看其他的源碼, 也應該這樣來寫文章, 提煉出框架中的核心功能, 將實現原理講透徹, 代碼應配合公式推導, 不需要太在意細枝末節. 另外, UML 圖之類的也應該畫一畫.

文章信息

S. Rendle. Factorization machines. In ICDM, pages995–1000, 2010.

主要內容

基本思路

介紹 FM 之前, 需要了解一下它被用來解決什麼問題. 在推薦/搜索/CTR預估等場景, 經常會用到 Categorical 特徵(即類別特徵), 由於單特徵的表達能力比較弱, 特徵交叉是必要的, 用來增強類別特徵的表達能力. 爲了將類別特徵轉換爲數值特徵, 我們常用 OneHot 編碼, 但由於類別特徵的種類以及取值可能衆多, 常導致編碼後的特徵空間大且稀疏. FM 模型就是用來解決稀疏數據下的特徵組合問題.

作爲對比, 考慮在線性模型中加入組合特徵, 模型可以表示爲:

y(x)=w0+i=1nwixi+i=1nj=i+1nwijxixj y(x) = w_0 + \sum_{i=1}^{n} w_ix_i + \sum_{i=1}^{n}\sum_{j=i+1}^{n}w_{ij}x_ix_j

其中 nn 表示樣本 xx 的特徵個數, xix_i 表示 xx 的第 ii 個特徵, 交叉特徵爲 xixjx_ix_j, 偏置爲 w0w_0, 一次項的權重爲 wiw_i, 二次項/交叉項的權重爲 wijw_{ij},

模型的參數的總個數爲:

1+n+Cn2=1+n+n(n1)2 1 + n + C_n^2 = 1 + n + \frac{n(n - 1)}{2}

由於一般樣本量大且稀疏, 二次項的權重 wijw_{ij} 會非常難優化, 因爲這需要足夠多的同時滿足 xix_ixjx_j 都非零的樣本; 爲了解決這個問題, FM (Factorization Machine) 引入矩陣分解的思路.

下圖來自美團技術團隊的 深入FFM原理與實踐

如何解決二次項參數的訓練問題呢?矩陣分解提供了一種解決思路。在model-based的協同過濾中,一個rating矩陣可以分解爲user矩陣和item矩陣,每個user和item都可以採用一個隱向量表示。比如在下圖中的例子中,我們把每個user表示成一個二維向量,同時把每個item表示成一個二維向量,兩個向量的點積就是矩陣中user對item的打分。

對於一個實對稱的正定矩陣 WW, 可以分解爲 W=VTVW = V^TV. FM 爲每一個特徵分配一個隱向量 viRkv_i\in\mathbb{R}^{k} (viv_iVV 的第 ii 列), 然後用兩個隱向量 viv_ivjv_j 的內積 vi,vj\langle v_i, v_j \rangle 來表示交叉項的權重參數 wijw_{ij}, 此時模型可以表示爲:

y(x)=w0+i=1nwixi+i=1nj=i+1nvi,vjxixj y(x) = w_0 + \sum_{i=1}^{n} w_ix_i + \sum_{i=1}^{n}\sum_{j=i+1}^{n}\langle v_i, v_j \rangle x_ix_j

由於每一個特徵都對應一個隱向量, VRk×nV\in\mathbb{R}^{k\times n}, 其參數個數爲 knkn (knk \ll n), 二次項的參數遠小於前面的 n(n1)2\frac{n(n - 1)}{2}. 還有一個好處是, 對於參數 viv_i 的訓練, 條件不再苛刻, 只要是包含 "xix_i 的所有非零組合" (存在某個 jij \neq i,使得 xixj0x_ix_j\neq 0) 都可以用來訓練隱向量 viv_i, 權重得到訓練的機會大大增加.

如果直接去計算上式, 二次項的計算複雜度爲 O(kn2)O(kn^2), FM 通過重新構建計算順序使得時間複雜度降低到線性時間 O(kn)O(kn), 具體推導如下:

i=1nj=i+1nvi,vjxixj=12i=1nj=1nvi,vjxixj12i=1nvi,vixixi=12(i=1nj=1nf=1kvi,fvj,fxixji=1nf=1kvi,fvi,fxixi)=12f=1k((i=1nvi,fxi)(j=1nvj,fxj)j=1nvj,f2xj2)=12f=1k((i=1nvi,fxi)2j=1nvj,f2xj2) \begin{aligned} & \sum_{i=1}^{n} \sum_{j=i+1}^{n}\left\langle\mathbf{v}_{i}, \mathbf{v}_{j}\right\rangle x_{i} x_{j} \\ =& \frac{1}{2} \sum_{i=1}^{n} \sum_{j=1}^{n}\left\langle\mathbf{v}_{i}, \mathbf{v}_{j}\right\rangle x_{i} x_{j}-\frac{1}{2} \sum_{i=1}^{n}\left\langle\mathbf{v}_{i}, \mathbf{v}_{i}\right\rangle x_{i} x_{i} \\ =& \frac{1}{2}\left(\sum_{i=1}^{n} \sum_{j=1}^{n} \sum_{f=1}^{k} v_{i, f} v_{j, f} x_{i} x_{j}-\sum_{i=1}^{n} \sum_{f=1}^{k} v_{i, f} v_{i, f} x_{i} x_{i}\right) \\ =& \frac{1}{2} \sum_{f=1}^{k}\left(\left(\sum_{i=1}^{n} v_{i, f}x_{i}\right)\left(\sum_{j=1}^{n} v_{j, f} x_{j}\right) - \sum_{j=1}^{n} v^2_{j, f} x^2_{j}\right) \\ =& \frac{1}{2} \sum_{f=1}^{k}\left(\left(\sum_{i=1}^{n} v_{i, f}x_{i}\right)^2 - \sum_{j=1}^{n} v^2_{j, f} x^2_{j}\right) \\ \end{aligned}

上式對 kknn 都只有線性的計算複雜度, 因此最終的時間複雜度爲 O(kn)O(kn). 推導中用到了公式:

(i=1nai)(j=1nbj)=i=1nj=1naibj \left(\sum_{i=1}^{n}a_i\right)\left(\sum_{j=1}^{n}b_j\right) = \sum_{i=1}^{n}\sum_{j=1}^{n}a_ib_j

libFM 在 src/fm_core/fm_model.h 中的 predict 方法中完成對預測值 y^\widehat{y} 的估計:

double fm_model::predict(sparse_row<FM_FLOAT>& x, DVector<double> &sum,
	DVector<double> &sum_sqr) {
  	  double result = 0;
	  if (k0) { // 處理偏置
	    result += w0;
	  }
	  if (k1) { // 處理一次項
	    for (uint i = 0; i < x.size; i++) {
	      assert(x.data[i].id < num_attribute);
	      result += w(x.data[i].id) * x.data[i].value;
	    }
	  }
	  for (int f = 0; f < num_factor; f++) { // 處理二次項
	    sum(f) = 0;
	    sum_sqr(f) = 0;
	    for (uint i = 0; i < x.size; i++) {
	      double d = v(f,x.data[i].id) * x.data[i].value;
	      sum(f) += d;
	      sum_sqr(f) += d*d;
	    }
	    result += 0.5 * (sum(f)*sum(f) - sum_sqr(f));
	  }
	  return result;
}

代碼分爲三個部分, 分別用來處理偏置 w0w_0, 一次項的參數 wiw_i, 以及二次項的參數 viv_i. 代碼中使用 sum(f) 來保存 i=1nvi,fxi\sum\limits_{i=1}^{n} v_{i, f}x_{i} 的結果, 使用 sum_sqr(f) 來保存 j=1nvj,f2xj2\sum\limits_{j=1}^{n} v^2_{j, f} x^2_{j} 的結果. 注意前者在後面的參數更新中會被使用到.

以上是 FM 的基本思路, 下面介紹 FM 的目標函數以及參數(w0,w,Vw_0, \bm{w}, V)的更新方法, 它們將結合 libFM 的實現來進行說明.

目標函數

FM 可以用來做迴歸 (Regression) 以及二分類 (Binary Classification) 任務, 其中迴歸的目標函數一般採用 MSE:

l=12ni=1n(yiy^i)2 l = \frac{1}{2n}\sum_{i=1}^{n}\left(y_i - \widehat{y}_i\right)^2

而分類任務一般用交叉熵, 但 libFM 中在實現時採用的 Log 損失函數, 參考 機器學習中的基本問題——log損失與交叉熵的等價性 , 瞭解到這兩個損失函數是等價的, 具體的推導方法如下:

首先 Log 損失的基本形式爲:

log(1+exp(yy^)),y{1,1} \log(1 + \exp(-y\cdot\widehat{y})), \quad y\in\{-1, 1\}

對樣本集 T={(x1,y1),,(xn,yn)}\mathcal{T} = \{(x_1, y_1), \ldots, (x_n, y_n)\}, 其 Log 損失爲:

1ni=1nlog(1+exp(yiy^i)) \frac{1}{n}\sum_{i=1}^{n}\log\left(1 + \exp(-y_i\cdot\widehat{y}_i)\right)

另一方面, Sigmoid 函數 (詳見 邏輯迴歸模型 Logistic Regression 詳細推導 (含 Numpy 與PyTorch 實現)) 被定義爲:

σ(x)=11+exp(x) \sigma(x) = \frac{1}{1 + \exp(-x)}

從上面公式可以推出兩點結論:

1+exp(x)=1σ(x)σ(x)=1σ(x) \Rightarrow 1 + \exp(-x) = \frac{1}{\sigma(x)} \\ \Rightarrow \sigma(x) = 1 - \sigma(-x)

因此 Log 損失可以進一步推導爲:

1ni=1nlog(1+exp(yiy^i))=1ni=1nlog(1σ(yiy^i))=1ni=1nlog(σ(yiy^i)) \begin{aligned} &\frac{1}{n}\sum_{i=1}^{n}\log\left(1 + \exp(-y_i\cdot\widehat{y}_i)\right) \\ &= \frac{1}{n}\sum_{i=1}^{n}\log\left(\frac{1}{\sigma\left(y_i\cdot\widehat{y}_i\right)}\right) \\ &= -\frac{1}{n}\sum_{i=1}^{n}\log\left(\sigma\left(y_i\cdot\widehat{y}_i\right)\right) \end{aligned}

上式爲 libFM 中用到的損失函數, 下面再介紹交叉熵. 交叉熵定義爲:

H(y,y^)=1ni=1nI{yi=1}logσ(y^i)+I{yi=1}log(1σ(y^i)) H(y, \widehat{y}) = -\frac{1}{n}\sum_{i=1}^{n}I\{y_i = 1\}\cdot\log\sigma(\widehat{y}_i) + I\{y_i = -1\}\cdot\log\left(1 - \sigma(\widehat{y}_i)\right)

由於 σ(x)=1σ(x)\sigma(x) = 1 - \sigma(-x), 交叉熵進一步變換爲:

H(y,y^)=1ni=1nI{yi=1}logσ(y^i)+I{yi=1}log(σ(y^i)) H(y, \widehat{y}) = -\frac{1}{n}\sum_{i=1}^{n}I\{y_i = 1\}\cdot\log\sigma(\widehat{y}_i) + I\{y_i = -1\}\cdot\log\left(\sigma(-\widehat{y}_i)\right)

又因爲:

I{y(i)=k}={0 if y(i)k1 if y(i)=k I\left\{y^{(i)}=k\right\}=\left\{ \begin{array}{ll} 0 & \text { if } y^{(i)} \neq k \\ 1 & \text { if } y^{(i)}=k \end{array}\right.

因此, 可以得到交叉熵爲:

H(y,y^)=1ni=1nlogσ(yiy^i) H(y, \widehat{y}) = -\frac{1}{n}\sum_{i=1}^{n}\log\sigma(y_i\cdot\widehat{y}_i)

該式和 Log 損失相同, 因此得到了 Log 損失和交叉熵等價的結論.

參數更新

這裏關注 libFM 實現的 SGD 算法, 由於每次只對一個樣本 (xi,yi)(x_i, y_i) 進行計算, 因此對於迴歸問題, 損失函數爲:

l=12(yiy^i)2 l = \frac{1}{2}\left(y_i - \widehat{y}_i\right)^2

其對參數的梯度爲:

lθ=(yiy^i)y^iθ \frac{\partial l}{\partial\theta} = -\left(y_i - \widehat{y}_i\right)\cdot\frac{\partial \widehat{y}_i}{\partial\theta}

對於迴歸問題, 損失函數爲 Log 損失 (詳見上一節):

l=log(σ(yiy^i)) l = -\log(\sigma(y_i\cdot\widehat{y}_i))

梯度更新的方法爲:

lθ=1σ(yiy^i)σ(yiy^i)yiy^iθ=1σ(yiy^i)(σ(yiy^i)(1σ(yiy^i)))yiy^iθ=(1σ(yiy^i))yiy^iθ=yi(1σ(yiy^i))y^iθ \begin{aligned} \frac{\partial l}{\partial\theta} &= -\frac{1}{\sigma(y_i\cdot\widehat{y}_i)}\cdot\sigma^\prime(y_i\cdot\widehat{y}_i)\cdot y_i \cdot \frac{\partial \widehat{y}_i}{\partial\theta} \\ &= -\frac{1}{\sigma(y_i\cdot\widehat{y}_i)}\cdot\left(\sigma(y_i\cdot\widehat{y}_i)\left(1 - \sigma(y_i\cdot\widehat{y}_i)\right)\right)\cdot y_i \cdot \frac{\partial \widehat{y}_i}{\partial\theta} \\ &= -\left(1 - \sigma(y_i\cdot\widehat{y}_i)\right)\cdot y_i \cdot \frac{\partial \widehat{y}_i}{\partial\theta} \\ &= -y_i\cdot \left(1 - \sigma(y_i\cdot\widehat{y}_i)\right)\cdot \frac{\partial \widehat{y}_i}{\partial\theta} \end{aligned}

推導中用到了 Sigmoid 函數求導的性質:

σ(x)=σ(x)(1σ(x)) \sigma^\prime(x) = \sigma(x)(1 - \sigma(x))

libFM 在 libfm/src/fm_learn_sgd_element.hlearn 方法中實現上面的更新步驟, 核心代碼如下 (無關代碼已刪去):

void fm_learn_sgd_element::learn(Data& train, Data& test) {
  fm_learn_sgd::learn(train, test);
  // SGD
  for (int i = 0; i < num_iter; i++) {
    double iteration_time = getusertime();
    for (train.data->begin(); !train.data->end(); train.data->next()) {
      double p = fm->predict(train.data->getRow(), sum, sum_sqr);
      double mult = 0;
      if (task == 0) { // 迴歸問題
        p = std::min(max_target, p);
        p = std::max(min_target, p);
        mult = -(train.target(train.data->getRowIndex())-p);
      } else if (task == 1) { // 分類問題
        mult = -train.target(train.data->getRowIndex())*(1.0-1.0/(1.0+exp(-train.target(train.data->getRowIndex())*p)));
      }
      SGD(train.data->getRow(), mult, sum);
    }
    // ......
  }
}

其中

double p = fm->predict(train.data->getRow(), sum, sum_sqr);

完成預估值的計算 (前面在闡述 “基本思路” 時已經介紹過了):

y^(x)=w0+i=1nwixi+i=1nj=i+1nvi,vjxixj=w0+i=1nwixi+12f=1k((i=1nvi,fxi)2j=1nvj,f2xj2) \begin{aligned} \widehat{y}(x) &= w_0 + \sum_{i=1}^{n} w_ix_i + \sum_{i=1}^{n}\sum_{j=i+1}^{n}\langle v_i, v_j \rangle x_ix_j \\ &= w_0 + \sum_{i=1}^{n} w_ix_i + \frac{1}{2} \sum_{f=1}^{k}\left(\left(\sum_{i=1}^{n} v_{i, f}x_{i}\right)^2 - \sum_{j=1}^{n} v^2_{j, f} x^2_{j}\right) \end{aligned}

p 即爲 y^i\widehat{y}_i, 代碼中使用 sum(f) 來保存 i=1nvi,fxi\sum\limits_{i=1}^{n} v_{i, f}x_{i} 的結果. 對迴歸問題來說, mult 表示 (yiy^i)-\left(y_i - \widehat{y}_i\right), 而對於分類問題, mult 表示 yi(1σ(yiy^i))-y_i\cdot\left(1 - \sigma(y_i\cdot\widehat{y}_i)\right).

之後的 SGD 函數, 真正實現對參數 (w0,w,V)(w_0, \bm{w}, V) 的更新, 在上面的推導中, 還需要求出 y^iθ\frac{\partial \widehat{y}_i}{\partial\theta} 的具體形式, 根據 θ\theta 的不同, 可以得到不同的梯度:

θy^i(x)={1, if θ is w0xi, if θ is wixij=1nvj,fxjvi,fxi2, if θ is vi,f \frac{\partial}{\partial \theta} \hat{y}_i(\mathbf{x})=\left\{ \begin{array}{ll} 1, & \text { if } \theta \text { is } w_{0} \\ x_{i}, & \text { if } \theta \text { is } w_{i} \\ x_{i} \sum\limits_{j=1}^{n} v_{j, f} x_{j}-v_{i, f} x_{i}^{2}, & \text { if } \theta \text { is } v_{i, f} \end{array}\right.

需要注意的是, 在前面計算 y^i(x)\widehat{y}_i(x) 時, 已經使用 sum(f) 保存了 j=1nvj,fxj\sum\limits_{j=1}^{n} v_{j, f} x_{j} 的結果, 這樣的話, 不論 θ\theta 爲何值, 梯度的計算複雜度均爲 O(1)O(1).

SGD 函數定義在 src/fm_core/fm_sgd.h 中, 具體爲:

void fm_SGD(fm_model* fm, const double& learn_rate, sparse_row<DATA_FLOAT> &x, 
	const double multiplier, DVector<double> &sum) {
	  if (fm->k0) {  // 更新偏置項
	    double& w0 = fm->w0;
	    w0 -= learn_rate * (multiplier + fm->reg0 * w0);
	  }
	  if (fm->k1) {  // 更新一次項
	    for (uint i = 0; i < x.size; i++) {
	      double& w = fm->w(x.data[i].id);
	      w -= learn_rate * (multiplier * x.data[i].value + fm->regw * w);
	    }
	  }
	  for (int f = 0; f < fm->num_factor; f++) { // 更新二次項
	    for (uint i = 0; i < x.size; i++) {
	      double& v = fm->v(f,x.data[i].id);
	      double grad = sum(f) * x.data[i].value - v * x.data[i].value * x.data[i].value;
	      v -= learn_rate * (multiplier * grad + fm->regv * v);
	    }
	  }
}

代碼中還考慮了對各個參數的正則項. 其中 multiplier 就是前面 mult, 對於迴歸問題, 表示 (yiy^i)-\left(y_i - \widehat{y}_i\right), 而對於分類問題, multiplier 表示 yi(1σ(yiy^i))-y_i\cdot\left(1 - \sigma(y_i\cdot\widehat{y}_i)\right). 另外, 在更新二次項時, sum(f) 表示 j=1nvj,fxj\sum\limits_{j=1}^{n} v_{j, f} x_{j}.

關於 libFM 就分析到這了~

參考資料

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章