最近在學習推薦系統(Recommender System),跟大部分人一樣,我也是從《推薦系統實踐》學起,同時也想跟學機器學習模型時一樣使用幾個開源的python庫玩玩。於是找到了surprise,挺新的,代碼沒有sklearn那么臃腫,我能看的下去,於是就開始了自己不斷的挖坑。
這篇文章介紹基於SVD的矩陣分解推薦預測模型。一開始我還挺納悶,SVD不是降維的方法嘛?為什么可以用到推薦系統呢?研究后,實則異曲同工。
有關SVD推導可以看這篇文章:降維方法PCA與SVD的聯系與區別
了解推薦系統的人一定會知道協同過濾算法!
協同過濾算法主要分為兩類,一類是基於領域的方法(neighborhood methods),另一類是隱語義模型(latent factor models),后者一個最成功的實現就是矩陣分解(matrix factorization),矩陣分解我們這篇文章使用的方法就是SVD(奇異值分解)
提問❓:SVD在推薦系統中到底在什么位置呢?
舉手🙋♂️:推薦系統 -> 協同過濾算法 -> 隱語義模型 -> 矩陣分解 -> SVD
一、Singular Value Decomposition(SVD)
Guide
SVD的思想在推薦系統中用得很巧妙,我們借助下面這個表來理解:

上圖有三幅小圖,我們來按順序看(圖中的數字請不要糾結於具體數值)。左中圖每行代表一個人,每列代表一部電影。每一個數字對應一個人給一部電影的評分,如李四的給《我不是葯神》評分為5,給《變形金剛4》的評分為1。
假設我們的目的是給圖中的李四推薦電影,你只知道他對這三部電影的評分。這似乎很難,但是設想如果我們知道李四喜歡電影的類型,這樣是不是就更好推薦了! 我們的信息量有限,但是並不是少到離譜,所以能不能通過僅有的信息推測出李四喜歡電影的類型呢?
SVD就是做這個事的,繼續看右邊的兩個圖。我們現在定義兩個電影類型(features:劇情、動畫),右上圖的表(矩陣)給出了每個電影對應類型的程度(如:《我不是葯神》更屬於劇情片,不屬於動畫),右下圖的表中的數字給出了每個人喜歡每種電影類型的程度(李四更喜歡劇情片,不喜歡動畫)。
那么光給出這兩個表(矩陣)有什么用呢?我們把右上表中的第二列(col=《我不是葯神》)和第三列(col=《變形金剛4》)分別與右下表的第二列(col=李四)對應元素相乘后相加(即點乘),得到的結果分別是(55 + -40=25)和(-25 + 50=-10),這兩個分數的物理意義分別是李四喜歡《我不是葯神》和李四喜歡《變形金鋼4》的程度,可以看出李四更喜歡《我不是葯神》。那么我們把右上表的每列和右下表的每列各元素相乘后相加,是不是可以得到每個人喜歡每部片子的程度呢?這不就是我們左邊Rating matirx圖表示的意思嘛!
現在我們給一部新電影《環太平洋》,給定他的類型指標(劇情:-1,動畫:4),我們想知道李四喜歡這部片的程度。仍然依據上面的方法(即:李四喜歡劇情片的程度5✖️《環太平洋》是劇情片的程度-1 ➕李四喜歡動畫的程度0✖️《環太平洋》是動畫的程度4 = -5)。好吧,結果看來李四不喜歡這部片。
但是我們現在只有用戶評分的數據呀,相當於只有左邊的那個稀疏矩陣怎么辦?一個字,學!🎓
Math
我們把評分矩陣(Rating Matrix)計作V, \(V\in R^{n\times m}\),那么V的每一行\(V_{i}\)代表一個人的所有評分,每一列\(V_{j}\)代表某一部電影所有人的評分,\(V_{ij}\)代表某個人i對某部電影j的評分。對應電影推薦來說,V必定是稀疏的,因為電影數量(列的數目)是巨大的,V中必定有很多很多項為null。
我們接下來看這兩個矩陣U(Users Features Matrix )和M(Movie Features Matrix)。U為用戶對特征的偏好程度矩陣,M為電影對特征的擁有程度矩陣。\(U\in R^{f\times n}\)的每一行表示用戶,每一列表示一個特征,它們的值表示用戶與某一特征的相關性,值越大,表明特征越明顯。矩陣\(M\in R^{f\times m}\),的每一行表示電影,每一列表示電影與特征的關聯。
那么U和M怎么得到呢?🤔️
還記得SVD的公式嘛?\(V=UM^{T}\),其實公式右邊的中間還有個對角矩陣S,我們可以把他看成跟U相乘合並(The S matrix is left blended into the feature vectors, so only U and M remain)。其實,U和M這兩個矩陣是通過學習的方式得到的,而不是直接做矩陣分解。我們定義如下的損失函數:
\(E = \frac{1}{2}\sum_{i=1}^{n}\sum_{j=1}^{m}I_{ij}(V_{ij} - p(U_i,M_j))^2+\frac{k_u}{2}\sum_{i=1}^{n}\lVert U_i \rVert^2 + \frac{k_m}{2}\sum_{j=1}^{m}\lVert M_j \rVert^2 \tag{1}\)
這里的評價指標metric采用的是RMSE。其中\(p(U_i,M_j)\)代表用戶i對電影j的預測,最常用的預測函數p就是點乘,即 \(p(U_i,M_j)=U_{i}^{T}M_{j}\)。式(1)中的 \(I\in \{0,1\}^{n\times m}\),為一個指示器,指示相應位置是否有評分,有評分為1,沒有為0。等式右邊最后兩項是正則化項,防止過擬合,這里不過多展開。
到這里,已經變成了一個機器學習的常見問題了,即最小化損失函數,用得最多的優化方法就是梯度下降,當然還有很多梯度下降的變體。下圖是簡單的梯度下降算法📖。

其中,\(\mu\)為學習率learning rate。且:
\(-\nabla _{U} =-\frac{\partial E}{\partial U_i} = \sum_{j=1}^{M}I_{ij}((V_{ij}-p(U_i,M_j))M_j) - k_uU_i \tag{2}\)
\(-\nabla _{M} =-\frac{\partial E}{\partial M_j} = \sum_{i=1}^{n}I_{ij}((V_{ij}-p(U_i,M_j))U_i) - k_mM_j \tag{3}\)
Improvements(adding bias)
可是,無奈的是🤷♂️,我們考慮這樣一個事實:
有的人評價電影的時候喜歡打高分,比如我自己,在豆瓣上,就算很一般的電影我也會打3分左右,因為我貌似不是能做出打一分這種殘忍的事的人😂。而有的人很嚴格,跟我正好相反。對於不同電影來說,比如姜文的《邪不壓正》,我可能智商不高,實在覺得不好看,但是由於豆瓣評分頗高,為了掩飾我的看不懂,我還是打了4分。所以,分數這個事情有很強的主觀性。即:
typical collaborative filtering data exhibits large systematic tendencies for some users to give higher ratings than others, and for some items to receive higher ratings than others.
所以,如果僅僅用 \(p(U_i,M_j)=U_{i}^{T}M_{j}\) 進行評分的預測就不那么明智了。在這里,我們考慮根據個人的口味和電影的級別來給予評分過程加一個偏置,即:(這里換一種形式:\(\mathbf{q}_i^T\mathbf{p}_u\)與\(U_{i}^{T}M_{j}\)等價)
\(\hat{r}_{ui} = \mu + b_i + b_u + \mathbf{q}_i^T\mathbf{p}_u \tag{4}\)
\(\mu\): 訓練集中所有記錄的評分的全局平均數。在不同網站中,因為網站定位和銷售的物品不同,網站的整體評分分布也會顯示出一些差異。比如有些網站中的用戶就是喜歡打高分,而另一些網站的用戶就是喜歡打低分。而全局平均數可以表示網站本身對用戶評分的影響。
\(b_u\): 用戶偏置(user bias)項。這一項表示了用戶的評分習慣中和物品沒有關系的那種因素。比如有些用戶就是比較苛刻,對什么東西要求都很高,那么他的評分就會偏低,而有些用戶比較寬容,對什么東西都覺得不錯,那么他的評分就會偏高。
\(b_i\): 物品偏置(item bias)項。這一項表示了物品接受的評分中和用戶沒有什么關系的因素。比如有些物品本身質量就很高,因此獲得的評分相對都比較高,而有些物品本身質量很差,因此獲得的評分相對都會比較低。
有人就說了,這些偏置參數怎么定呢?難道我要預先把所有的數據集計算一遍?❌
才不需要呢,這些偏置參數也是通過學習而來,所以現在我們需要學習的矩陣參數變成了5個。😺
這個時候我們的損失函數變為:
\(E = \sum_{(u,i)\in \mathcal{k}}(r_{ui}-\mu - b_i - b_u - \mathbf{q}_i^T\mathbf{p}_u)^2 + \lambda (\lVert p_u \rVert^2 + \lVert q_i \rVert^2 + b_u^2 + b_i^2) \tag{5}\)
此時,我們的梯度下降也變成了這樣:(\(\gamma\)為學習率)
\(b_u\leftarrow b_u + \gamma (e_{ui} - \lambda b_u) \tag{6}\)
\(b_i \leftarrow b_i + \gamma (e_{ui} - \lambda b_i \tag{7})\)
\(p_u \leftarrow p_u + \gamma (e_{ui} \cdot q_i - \lambda p_u) \tag{8}\)
\(q_i \leftarrow q_i + \gamma (e_{ui} \cdot p_u - \lambda q_i) \tag{9}\)
說到這里,SVD算法的本質是幫我們找到(學習出)item中隱含的維度(features),這些隱含的維度可以是(類型,派別,國別,或者兩個組合,n種組合等等),SVD還可以找到(學習出)用戶對各個維度(features)的類別喜愛程度。我們要做的只是指定維度的數量(n_factors),SVD會自動幫我們干好接下來的工作。
二、surprise
其實,當我知道要使用一個新庫👖的時候,我是拒絕的。但是沒辦法,從頭寫代碼實在是力不從心,把庫中的代碼先變成自己的再說是吧。下面先介紹surprise庫的基本使用,然后重點分析里面的SVD算法。首先,import:
from surprise import SVD
from surprise import Dataset, Reader
from surprise.model_selection import cross_validate, train_test_split
1、初始化reader
制定評分范圍為1-5,數據格式為四個
reader = Reader(rating_scale=(1, 5), line_format='user item rating timestamp')
2、初始化Dataset
傳入的數據有四列。其實Reader和Dataset這兩個類可以這樣理解,前者為框架,定好各種參數,后者填入相應數據。
df_data = pd.read_csv('./data/ml-latest-small/ratings.csv', usecols=['userId','movieId','rating'])
data = Dataset.load_from_df(df_data, reader)
data
<surprise.dataset.DatasetAutoFolds at 0x1192bff60>
這里我們創建了一種新的類型 surprise.dataset,這已經不是pandas的dataframe,這樣做的目的只是方便我們更好使用surprise的各種API。里面的數據格式是這樣的:
elif df is not None:
self.df = df
self.raw_ratings = [(uid, iid, float(r) + self.reader.offset, None)
for (uid, iid, r) in self.df.itertuples(index=False)]
PS: 在查看源碼的過程后,我發現surprise的load_from_df無法把dataframe里面的timestamp列包含進去。可能這是個bug?可以issue一下。
3、拆分data
trainset, testset = train_test_split(data, test_size=0.2) ### 實際調用 .split.ShuffleSplit()
trainset
<surprise.trainset.Trainset at 0x1131e6160>
4、訓練模型
我們先來剖開surprise的SVD函數內部看看:
根據(4)式,定義p和q:
pu = rng.normal(self.init_mean, self.init_std_dev, (trainset.n_users, self.n_factors))
qi = rng.normal(self.init_mean, self.init_std_dev, (trainset.n_items, self.n_factors))
其中rng為np.random.RandomState(random_state),參數為隨機數種子。
定義后,pu為一個n✖️f矩陣,qi為一個m✖️f矩陣
for current_epoch in range(self.n_epochs):
for u, i, r in trainset.all_ratings():
# 計算(5)的第一項
dot = 0 # <q_i, p_u>
for f in range(self.n_factors):
dot += qi[i, f] * pu[u, f]
err = r - (global_mean + bu[u] + bi[i] + dot)
# update biases
if self.biased:
# 根據式(6)
bu[u] += lr_bu * (err - reg_bu * bu[u])
# 根據式(7)
bi[i] += lr_bi * (err - reg_bi * bi[i])
# update factors
for f in range(self.n_factors):
puf = pu[u, f]
qif = qi[i, f]
# 根據式(8)
pu[u, f] += lr_pu * (err * qif - reg_pu * puf)
# 根據式(9)
qi[i, f] += lr_qi * (err * puf - reg_qi * qif)
好,現在開始訓練模型,這里給定隱含的特征為30個。
model = SVD(n_factors=30)
model.fit(trainset)
<surprise.prediction_algorithms.matrix_factorization.SVD at 0x1138c1da0>
我們來看看模型生成的pu和qi矩陣,似乎很符合我們的要求。
print(model.pu.shape)
print(model.qi.shape)
(671, 30)
(8382, 30)
5、推薦
終於到推薦環節了,前面做的所有工作都是為了這一刻。 推薦也分為幾種類型。一種是預測某個人對某部電影的評分,另一種是推薦給某個人新的幾部電影。
這里我們預測userId為2對於movieId為14的評分:
model.predict(2,14)
Prediction(uid=2, iid=14, r_ui=None, est=3.3735354350374438, details={'was_impossible': False})
推薦電影可以用.get_neighbors()函數
Reference:
- https://surprise.readthedocs.io/en/stable/prediction_algorithms_package.html
- http://charleshm.github.io/2016/03/SVD-Recommendation-System/#fn:3
- https://juejin.im/post/5b1108bae51d4506c7666cac
- https://www.cnblogs.com/FengYan/archive/2012/05/06/2480664.html
文獻:
- Ma C C. A Guide to Singular Value Decomposition for Collaborative Filtering[J]. 2008.
- Koren Y, Bell R, Volinsky C. Matrix Factorization Techniques for Recommender Systems[J]. Computer, 2009, 42(8):30-37.
