傳統推薦算法(二) 那個模型LFM (原名FunkSVD)

寫在前面

對奇異值分解數學原理有疑惑的,請閱讀:《傳統推薦算法(一)利用SVD進行推薦(3)6個層面透徹瞭解奇異值分解》
參考代碼:https://github.com/felipessalvatore/Recommender
重構代碼:https://github.com/wyl6/Recommender-Systems-Samples/tree/master/RecSys Traditional/MF/FunkSVD
參考代碼中將FunkSVD和NSVD寫在一起,看起來有scikit-learn代碼的風格,看起來很舒服。就參考了他的SVD那一部分代碼中練練手。

1.傳統SVD分解的缺陷

在《傳統推薦算法(一)(4)實戰中,我們講了利用SVD實現對物品的隱特徵降維,這是SVD在推薦中的一種應用。實際上還有一種應用,就是利用SVD對評分矩陣進行分解,然後利用降維後的U,S,V對矩陣進行還原,原先矩陣中空缺的部分就可以得到填充值。第一種應用中SVD就是作爲降維技術,優化相似度計算的過程;第二種利用這種矩陣分解技術分解後再填充,將填充的結果作爲預測值。

第一種缺點已經分析過了,本文重點看一下第二種應用的缺點,這種應用就是FunkSVD改進前的SVD推薦。

1.1 稀疏矩陣的缺失值問題

[2]中指出“奇異值分解作爲矩陣分解中最基本的方法,通常都是作用於完整的矩陣(即矩陣中不存在元素缺失的現象),即要求矩陣必須是稠密矩陣”。[3]中指出“這種方法存在一個致命的缺陷——奇異值分解要求矩陣是“稠密”的”。

注:這裏的“稠密”指的是矩陣不能有缺失元素(有缺失元素分解個錘子)。和下文中的稠密有所區別。

由於實際中大多數評分矩陣都會有缺失值,所以用這種SVD分解填充的話,第一步都會想辦法對缺失值做一個“粗糙的”填補。這個填補會造成問題。

1.2 稀疏矩陣的填補問題

歷史上對缺失值的研究有很多,對於一個沒有被打分的物品來說,到底是應該給它補一個 0 值,還是應該給它補一個平均值呢?由於在實際過程中,元素缺失值是非常多的,這就導致了早期的SVD不論通過以上哪種方法進行補全在實際的應用之中都是很難被接受的。

“粗糙的”填充會造成兩個明顯的問題[5]:第一,增加了數據量,增加了算法複雜度。第二,簡單粗暴的數據填充很容易造成數據失真。我們發現很多時候都是直接補0,得到一個非常稀疏的打分矩陣。這種補零的情況還會造成空間的浪費。

1.3 SVD計算複雜度問題

[1]中指出:

SVD 分解的複雜度比較高,假設對一個 ?×? 的矩陣進行分解,時間複雜度爲 ?(?2∗?+?∗?2),其實就是 ?(?3)。對於 m、n 比較小的情況,還是勉強可以接受的,但是在推薦場景的海量數據下,m 和 n 的值通常會比較大,可能是百萬級別上的數據,這個時候如果再進行 SVD 分解需要的計算代價就是很大的。

[4]中指出:

SVD計算代價很大,時間複雜度是3次方的,空間複雜度是2次方的。雖然有一些號稱並行的SVD算法,但據我所知,如果不能共享內存,SVD很難並行化。

1.4 SVD分解對內存的消耗問題

對於1萬個用戶,1000萬個物品的評分矩陣,我們簡單估計下它需要的內存。假設每個評分用4個字節表示,那麼SVD分解時,將這個完整的矩陣加載到內存中,大概需要372G內存。內存是否能容納是個大問題。

1.5 SVD的特徵表達問題

在在《傳統推薦算法(一)(4)實戰中,我們分析過這個,SVD分解方式比較單一,分解得到的特徵表示是否一定是用戶/物品的一種比較好的表示呢?從這個角度看,獲取的特徵的方式不夠靈活,那麼這樣無論是在第一種降維的應用,還是在第二種填充的應用中,都不能保證獲取好的結果。

所以,用SVD進行評分矩陣填充時,並不會採用正統的SVD分解,而是採用一種靈活的版本,請見1.6。

1.6稀疏矩陣的SVD分解問題

由於奇異值分解的計算過程較慢,所以在實際使用SVD分解進行填充預測時,使用的是“僞SVD”分解(圖片來自孟一凡大佬的截圖):
1753035041.jpg

這裏的U和V是用戶和物品的隱向量,Σ是對角陣,通過最小化平方誤差的解析方法來求解。最小化平方誤差通常用最小二乘法(ALS)或者梯度下降。由於正統的SVD分解出正交矩陣會比較慢,所以在實際操作中只取SVD精髓,訓練出用戶和物品的隱向量U和V即可,不要求U和V正交。

我們可以發現,U,Σ,V中有許多參數需要確定,如果矩陣過於稀疏,顯然模型的效果不會理想。如果想要保證模型效果,就必須保證訓練數據充分,要求矩陣不能稀疏,畢竟模型參數太多了。孟一凡大佬的話說:

“如果數據很稀疏,模型能利用的信息是有限的,算法填充矩陣的能力也是有限的,所以稠密。”

那這就尷尬了,實際中的評分矩陣稠密了還需要填充嗎,還需要預測嗎?這種改進後的“僞SVD”依然不夠給力。

2.隱語義模型LFM

2.1 大名鼎鼎的LFM

之前我們分析過,SVD分解後:

https://raw.githubusercontent.com/wyl6/wyl6.github.io/master/imgs_for_blogs/recommender_traditional/MF/SVD/1.bmp

其實可以簡化爲:

https://raw.githubusercontent.com/wyl6/wyl6.github.io/master/imgs_for_blogs/recommender_traditional/MF/SVD/3.bmp

在此基礎上,我們從“僞SVD”中,可以觀察到:
圖片來自[6]

倘若將奇異值融合到ui和vi和中去,就可以將矩陣分解轉化爲一個參數估計問題[6]:
20190623225630.png

這樣得到的用戶和物品隱特徵矩陣雖然不一定具備經典奇異值分解的正交矩陣性質,但是用來作爲用戶和物品的特徵表達(隱因子)也足夠了。

並且這隱含着機器學習的常用方法論:只要隱因子在估計已知評分時足夠準,那麼輔以一些正則化保證泛化能力之後它在未知評分上的預測也能滿足精度要求[6]。

我們可以發現,相比剛纔的“僞SVD”,這個模型也可以獲得用戶和物品的特徵表達,並且將原來的奇異值融合進了特徵中,特徵包含了更多的信息;同時參數數量大大下降,相同的訓練數據,這個模型顯然可以擁有更好的性能。這個模型就是大名鼎鼎的LFM模型。

2006年,FunkSVDSimon Funk 在博客上公佈了一個算法(Funk-SVD),這個算法後來被Netflix Prize 的冠軍 Koren 稱爲 Latent Factor Model(LFM)。[7]

LFM通過以下公式來計算用戶u對物品i的評分:

Screenshot from 2019-06-22 11-10-50.png

我們可以把puk看成是用戶u對第k個隱類的興趣,把qik看成是物品i和第k個隱類的相關性。

2.2 損失函數及訓練

我們藉助[7]中內容簡要看一下。SVD損失函數爲:

20190624094234.png

可以通過交替最小二乘法或者梯度下降法來訓練模型。比如用梯度下降法,那麼損失函數偏導爲:
20190624094746.png

參數沿最速下降方向前進時,遞推公式爲:
20190624094846.png

3.FunkSVD及其變種

FunkSVD的變種很多,簡單整理一下,供大家參考。Matrix Factorization(MF)可以看成是LFM帶起來的一種解決推薦問題的思路,有的人直接叫LFM爲MF模型。但是現在矩陣分解技術很多,不建議這樣叫,不然別人可能不知道你說的是矩陣分解這一類技術還是LFM這個模型。

算法 別名 內容
SVD traditional SVD 奇異值分解
FunkSVD LFM, basic MF, MF LFM
bias SVD bias MF LFM+偏置項
regularized SVD regularized MF LFM+正則項
PMF * LFM+正則項的概率版本
SVD++ * LFM+正則項+隱性反饋
NMF * 對隱向量非負限制,可用在bias SVD等不同模型上
NSVD * 學習兩個物品隱因子,而非學習用戶、物品隱因子
WRMF * 引入時間信息,可用在bias SVD等不同模型上

4.Tensorflow實現FunkSVD

FunkSVD可以說是一個上古戰神了,模型比較簡單,網上有不少用Python寫的,代碼都不長。我們看看tensorflow如何實現,首先看一下各變量的定義:

import numpy as np
import tensorflow as tf
import time
import os
from util import rmse, status_printer

def inference_svd(batch_user, batch_item, num_user, num_item, dim=5):
    
    with tf.name_scope('Declaring_variables'):
        
        ## obtain global bias, user bias, item bias
        w_bias_user = tf.get_variable('num_bias_user', shape=[num_user])
        w_bias_item = tf.get_variable('num_bias_item', shape=[num_item])
        bias_global = tf.get_variable('bias_global', shape=[])
        bias_user = tf.nn.embedding_lookup(w_bias_user, 
                                           batch_user, 
                                           name='batch_bias_user')
        bias_item = tf.nn.embedding_lookup(w_bias_item, 
                                           batch_item, 
                                           name='batch_bias_imte')
        
        ## obtain user embedding, item embedding
        initializer = tf.truncated_normal_initializer(stddev=0.02)
        w_user = tf.get_variable('num_embed_user', 
                                 shape=[num_user, dim], 
                                 initializer=initializer)
        w_item = tf.get_variable('num_embed_item', 
                                 shape=[num_item, dim], 
                                 initializer=initializer)
        embed_user = tf.nn.embedding_lookup(w_user, 
                                            batch_user, 
                                            name='batch_embed_user')
        embed_item = tf.nn.embedding_lookup(w_item, 
                                            batch_item, 
                                            name='batch_embed_item')

實際上這份代碼可以做更多擴展,不僅僅加入偏置項和正則項。然後我們看一下預測值:

        ## obtain r_ij = p_i*q_j
        infer = tf.reduce_sum(tf.multiply(embed_user, embed_item), 1) 
        
        ## obtain bias
        infer = tf.add(infer, bias_global)
        infer = tf.add(infer, bias_user)
        infer = tf.add(infer, bias_item, name='svd_inference')

然後看看正則項定義:

		## obtain regularizer
        l2_user = tf.sqrt(tf.nn.l2_loss(embed_user))
        l2_item = tf.sqrt(tf.nn.l2_loss(embed_item))
        l2_sum = tf.add(l2_user, l2_item)
        bias_user_sq = tf.square(bias_user)
        bias_item_sq = tf.square(bias_item)
        bias_sum = tf.add(bias_user_sq, bias_item_sq)
        regularizer = tf.add(l2_sum, bias_sum, name='svd_regularizer')
        
        regularizer = tf.add(tf.nn.l2_loss(embed_user), 
                             tf.nn.l2_loss(embed_item), 
                             name="svd_regularizer")

那麼損失函數就很清晰了:

def loss_function(infer, regularizer, batch_rate, reg):
    '''
    calculate loss = loss_l2+loss_regularizer
    '''
    loss_l2 = tf.square(tf.subtract(infer, batch_rate))
    reg = tf.constant(reg, dtype=tf.float32, name='reg')
    loss_regularizer = tf.multiply(regularizer, reg)
    loss = tf.add(loss_l2, loss_regularizer)
    return loss

訓練過程使用:

self.train_op = optimizer.minimize(self.loss, global_step=global_step)

就不多說了。這份代碼是我從一份代碼裏重構出來的,推薦大家閱讀一下。不推薦跟着手寫一遍,有點長,想手寫的建議找個python版本的,簡單好用。

等寫SVD++那一部分的時候,儘量找個短的,畢竟這些代碼只是幫助理解,真用的時候直接調成熟的包更好些。

參考

公衆號

更多精彩內容請移步公衆號:推薦算法工程師

感覺公衆號內容不錯點個關注唄

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