1. 程式人生 > 其它 >Python實現協同過濾的教程

Python實現協同過濾的教程

協同過濾

在 使用者 ―― 物品(user -
item)的資料關係下很容易收集到一些偏好資訊(preference),比如評分。利用這些分散的偏好資訊,基於其背後可能存在的關聯性,來為使用者推薦物品的方法,便是協同過濾,或稱協作型過濾(collaborative
filtering)。

這種過濾演算法的有效性基礎在於:

使用者的偏好具有相似性,即使用者是可分類的。這種分類的特徵越明顯,推薦的準確率就越高
物品之間是存在關係的,即偏好某一物品的任何人,都很可能也同時偏好另一件物品

不同環境下這兩種理論的有效性也不同,應用時需做相應調整。如豆瓣上的文藝作品,使用者對其的偏好程度與使用者自身的品位關聯性較強;而對於電子商務網站來說,商品之間的內在聯絡對使用者的購買行為影響更為顯著。當用在推薦上,這兩種方向也被稱為基於使用者的和基於物品的。本文內容為基於使用者的。
影評推薦例項

本文主要內容為基於使用者偏好的相似性進行物品推薦,使用的資料集為 GroupLens Research 採集的一組從 20 世紀 90 年代末到 21
世紀初由 MovieLens 使用者提供的電影評分資料。資料中包含了約 6000 名使用者對約 4000 部電影的
100萬條評分,五分制。資料包可以從網上下載到,裡面包含了三個資料表――users、movies、ratings。因為本文的主題是基於使用者偏好的,所以只使用
ratings 這一個檔案。另兩個檔案裡分別包含使用者和電影的元資訊。

本文使用的資料分析包為 pandas,環境為 IPython,因此其實還預設攜帶了 Numpy 和 matplotlib。下面程式碼中的提示符看起來不是
IPython 環境是因為 Idle 的格式發在部落格上更好看一些。
資料規整

首先將評分資料從 ratings.dat 中讀出到一個 DataFrame 裡:

    >>> import pandas as pd
    >>> from pandas import Series,DataFrame
    >>> rnames = ['user_id','movie_id','rating','timestamp']
    >>> ratings = pd.read_table(r'ratings.dat',sep='::',header=None,names=rnames)
    >>> ratings[:3]
     user_id movie_id rating timestamp
    0  1  1193  5 978300760
    1  1  661  3 978302109
    2  1  914  3 978301968
     
    [3 rows x 4 columns]
    
    

ratings 表中對我們有用的僅是 user_id、movie_id 和 rating 這三列,因此我們將這三列取出,放到一個以 user
為行,movie 為列,rating 為值的表 data 裡面。(其實將 user 與 movie
的行列關係對調是更加科學的方法,但因為重跑一遍太麻煩了,這裡就沒改。)

    >>> data = ratings.pivot(index='user_id',columns='movie_id',values='rating')
    >>> data[:5]
    movie_id 1 2 3 4 5 6 
    user_id                  
    1   5 NaN NaN NaN NaN NaN ...
    2  NaN NaN NaN NaN NaN NaN ...
    3  NaN NaN NaN NaN NaN NaN ...
    4  NaN NaN NaN NaN NaN NaN ...
    5  NaN NaN NaN NaN NaN 2 ...
    

可以看到這個表相當得稀疏,填充率大約只有 5%,接下來要實現推薦的第一步是計算 user 之間的相關係數,DataFrame 物件有一個很親切的
.corr(method='pearson', min_periods=1) 方法,可以對所有列互相計算相關係數。method 預設為皮爾遜相關係數,這個
ok,我們就用這個。問題僅在於那個 min_periods
引數,這個引數的作用是設定計算相關係數時的最小樣本量,低於此值的一對列將不進行運算。這個值的取捨關係到相關係數計算的準確性,因此有必要先來確定一下這個引數。

相關係數是用於評價兩個變數間線性關係的一個值,取值範圍為 [-1, 1],-1代表負相關,0 代表不相關,1 代表正相關。其中 0~0.1
一般被認為是弱相關,0.1~0.4 為相關,0.4~1 為強相關。

min_periods 引數測定

測定這樣一個引數的基本方法為統計在 min_periods
取不同值時,相關係數的標準差大小,越小越好;但同時又要考慮到,我們的樣本空間十分稀疏,min_periods
定得太高會導致出來的結果集太小,所以只能選定一個折中的值。

這裡我們測定評分系統標準差的方法為:在 data
中挑選一對重疊評分最多的使用者,用他們之間的相關係數的標準差去對整體標準差做點估計。在此前提下對這一對使用者在不同樣本量下的相關係數進行統計,觀察其標準差變化。

首先,要找出重疊評分最多的一對使用者。我們新建一個以 user 為行列的方陣 foo,然後挨個填充不同使用者間重疊評分的個數:

    >>> foo = DataFrame(np.empty((len(data.index),len(data.index)),dtype=int),index=data.index,columns=data.index)
    >>> for i in foo.index:
      for j in foo.columns:
       foo.ix[i,j] = data.ix[i][data.ix[j].notnull()].dropna().count()
    

這段程式碼特別費時間,因為最後一行語句要執行 4000*4000 = 1600萬遍;(其中有一半是重複運算,因為 foo 這個方陣是對稱的)還有一個原因是
Python 的 GIL,使得其只能使用一個 CPU 執行緒。我在它執行了一個小時後,忍不住去測試了一下總時間,發現要三個多小時後就果斷 Ctrl + C
了,在算了一小半的 foo 中,我找到的最大值所對應的行列分別為 424 和 4169,這兩位使用者之間的重疊評分數為 998:

    >>> for i in foo.index:
      foo.ix[i,i]=0#先把對角線的值設為 0
     
    >>> ser = Series(np.zeros(len(foo.index)))
    >>> for i in foo.index:
      ser[i]=foo[i].max()#計算每行中的最大值
     
    >>> ser.idxmax()#返回 ser 的最大值所在的行號
    4169
     
    >>> ser[4169]#取得最大值
    998
     
    >>> foo[foo==998][4169].dropna()#取得另一個 user_id
    424  4169
    Name: user_id, dtype: float64
    

我們把 424 和 4169 的評分資料單獨拿出來,放到一個名為 test 的表裡,另外計算了一下這兩個使用者之間的相關係數為
0.456,還算不錯,另外通過柱狀圖瞭解一下他倆的評分分佈情況:

    >>> data.ix[4169].corr(data.ix[424])
    0.45663851303413217
    >>> test = data.reindex([424,4169],columns=data.ix[4169][data.ix[424].notnull()].dropna().index)
    >>> test
    movie_id 2  6  10 11 12 17 ...
    424    4  4  4  4  1  5 ...
    4169    3  4  4  4  2  5 ...
     
    >>> test.ix[424].value_counts(sort=False).plot(kind='bar')
    >>> test.ix[4169].value_counts(sort=False).plot(kind='bar')
    
    

對這倆使用者的相關係數統計,我們分別隨機抽取 20、50、100、200、500 和 998 個樣本值,各抽 20 次。並統計結果:

     >>> periods_test = DataFrame(np.zeros((20,7)),columns=[10,20,50,100,200,500,998])
    >>> for i in periods_test.index:
      for j in periods_test.columns:
       sample = test.reindex(columns=np.random.permutation(test.columns)[:j])
       periods_test.ix[i,j] = sample.iloc[0].corr(sample.iloc[1])
     
     
    >>> periods_test[:5]
      10  20  50  100  200  500  998
    0 -0.306719 0.709073 0.504374 0.376921 0.477140 0.426938 0.456639
    1 0.386658 0.607569 0.434761 0.471930 0.437222 0.430765 0.456639
    2 0.507415 0.585808 0.440619 0.634782 0.490574 0.436799 0.456639
    3 0.628112 0.628281 0.452331 0.380073 0.472045 0.444222 0.456639
    4 0.792533 0.641503 0.444989 0.499253 0.426420 0.441292 0.456639
     
    [5 rows x 7 columns]
    >>> periods_test.describe()
        10   20   50   100  200  500 #998略
    count 20.000000 20.000000 20.000000 20.000000 20.000000 20.000000 
    mean 0.346810 0.464726 0.458866 0.450155 0.467559 0.452448 
    std  0.398553 0.181743 0.103820 0.093663 0.036439 0.029758 
    min -0.444302 0.087370 0.192391 0.242112 0.412291 0.399875 
    25%  0.174531 0.320941 0.434744 0.375643 0.439228 0.435290 
    50%  0.487157 0.525217 0.476653 0.468850 0.472562 0.443772 
    75%  0.638685 0.616643 0.519827 0.500825 0.487389 0.465787 
    max  0.850963 0.709073 0.592040 0.634782 0.546001 0.513486 
     
    [8 rows x 7 columns]
    
    

從 std 這一行來看,理想的 min_periods 引數值應當為 200 左右。可能有人會覺得 200
太大了,這個推薦演算法對新使用者簡直沒意義。但是得說,隨便算出個有超大誤差的相關係數,然後拿去做不靠譜的推薦,又有什麼意義呢。
演算法檢驗

為了確認在 min_periods=200 下本推薦演算法的靠譜程度,最好還是先做個檢驗。具體方法為:在評價數大於 200 的使用者中隨機抽取 1000
位使用者,每人隨機提取一個評價另存到一個數組裡,並在資料表中刪除這個評價。然後基於閹割過的資料表計算被提取出的 1000
個評分的期望值,最後與真實評價陣列進行相關性比較,看結果如何。

    >>> check_size = 1000
    >>> check = {}
    >>> check_data = data.copy()#複製一份 data 用於檢驗,以免篡改原資料
    >>> check_data = check_data.ix[check_data.count(axis=1)>200]#濾除評價數小於200的使用者
    >>> for user in np.random.permutation(check_data.index):
      movie = np.random.permutation(check_data.ix[user].dropna().index)[0]
      check[(user,movie)] = check_data.ix[user,movie]
      check_data.ix[user,movie] = np.nan
      check_size -= 1
      if not check_size:
       break
     
     
    >>> corr = check_data.T.corr(min_periods=200)
    >>> corr_clean = corr.dropna(how='all')
    >>> corr_clean = corr_clean.dropna(axis=1,how='all')#刪除全空的行和列
    >>> check_ser = Series(check)#這裡是被提取出來的 1000 個真實評分
    >>> check_ser[:5]
    (15, 593)  4
    (23, 555)  3
    (33, 3363) 4
    (36, 2355) 5
    (53, 3605) 4
    dtype: float64
    

接下來要基於 corr_clean 給 check_ser 中的 1000 個 使用者-影片 對計算評分期望。計算方法為:對與使用者相關係數大於 0.1
的其他使用者評分進行加權平均,權值為相關係數:

    >>> result = Series(np.nan,index=check_ser.index)
    >>> for user,movie in result.index:#這個迴圈看著很亂,實際內容就是加權平均而已
      prediction = []
      if user in corr_clean.index:
       corr_set = corr_clean[user][corr_clean[user]>0.1].dropna()#僅限大於 0.1 的使用者
      else:continue
      for other in corr_set.index:
       if not np.isnan(data.ix[other,movie]) and other != user:#注意bool(np.nan)==True
        prediction.append((data.ix[other,movie],corr_set[other]))
      if prediction:
       result[(user,movie)] = sum([value*weight for value,weight in prediction])/sum([pair[1] for pair in prediction])
     
     
    >>> result.dropna(inplace=True)
    >>> len(result)#隨機抽取的 1000 個使用者中也有被 min_periods=200 刷掉的
    862
    >>> result[:5]
    (23, 555)  3.967617
    (33, 3363) 4.073205
    (36, 2355) 3.903497
    (53, 3605) 2.948003
    (62, 1488) 2.606582
    dtype: float64
    >>> result.corr(check_ser.reindex(result.index))
    0.436227437429696
    >>> (result-check_ser.reindex(result.index)).abs().describe()#推薦期望與實際評價之差的絕對值
    count 862.000000
    mean  0.785337
    std  0.605865
    min  0.000000
    25%  0.290384
    50%  0.686033
    75%  1.132256
    max  3.629720
    dtype: float64
    

862 的樣本量能達到 0.436 的相關係數,應該說結果還不錯。如果一開始沒有濾掉評價數小於 200 的使用者的話,那麼首先在計算 corr
時會明顯感覺時間變長,其次 result 中的樣本量會很小,大約 200+ 個。但因為樣本量變小的緣故,相關係數可以提升到 0.5~0.6 。

另外從期望與實際評價的差的絕對值的統計量上看,資料也比較理想。
實現推薦

在上面的檢驗,尤其是平均加權的部分做完後,推薦的實現就沒有什麼新東西了。

首先在原始未閹割的 data 資料上重做一份 corr 表:

    >>> corr = data.T.corr(min_periods=200)
    >>> corr_clean = corr.dropna(how='all')
    >>> corr_clean = corr_clean.dropna(axis=1,how='all')
    

我們在 corr_clean 中隨機挑選一位使用者為他做一個推薦列表:

    >>> lucky = np.random.permutation(corr_clean.index)[0]
    >>> gift = data.ix[lucky]
    >>> gift = gift[gift.isnull()]#現在 gift 是一個全空的序列
    

最後的任務就是填充這個 gift:

    >>> corr_lucky = corr_clean[lucky].drop(lucky)#lucky 與其他使用者的相關係數 Series,不包含 lucky 自身
    >>> corr_lucky = corr_lucky[corr_lucky>0.1].dropna()#篩選相關係數大於 0.1 的使用者
    >>> for movie in gift.index:#遍歷所有 lucky 沒看過的電影
      prediction = []
      for other in corr_lucky.index:#遍歷所有與 lucky 相關係數大於 0.1 的使用者
       if not np.isnan(data.ix[other,movie]):
        prediction.append((data.ix[other,movie],corr_clean[lucky][other]))
      if prediction:
       gift[movie] = sum([value*weight for value,weight in prediction])/sum([pair[1] for pair in prediction])
     
     
    >>> gift.dropna().order(ascending=False)#將 gift 的非空元素按降序排列
    movie_id
    3245  5.000000
    2930  5.000000
    2830  5.000000
    2569  5.000000
    1795  5.000000
    981   5.000000
    696   5.000000
    682   5.000000
    666   5.000000
    572   5.000000
    1420  5.000000
    3338  4.845331
    669   4.660464
    214   4.655798
    3410  4.624088
    ...
    2833  1
    2777  1
    2039  1
    1773  1
    1720  1
    1692  1
    1538  1
    1430  1
    1311  1
    1164  1
    843   1
    660   1
    634   1
    591   1
    56   1
    Name: 3945, Length: 2991, dtype: float64
    

補充

上面給出的示例都是些原型程式碼,有很多可優化的空間。比如 data 的行列轉換;比如測定 min_periods 時的方陣 foo 只需計算一半;比如有些
for 迴圈和相應運算可以用陣列物件方法來實現(方法版比使用者自己編寫的版本速度快很多);甚至肯定還有一些
bug。另外這個資料集的體積還不算太大,如果再增長一個數量級,那麼就有必要針對計算密集的部分(如 corr)做進一步優化了,可以使用多程序,或者
Cython/C 程式碼。(或者換更好的硬體)

雖然協同過濾是一種比較省事的推薦方法,但在某些場合下並不如利用元資訊推薦好用。協同過濾會遇到的兩個常見問題是

  1. 稀疏性問題――因使用者做出評價過少,導致算出的相關係數不準確
  2. 冷啟動問題――因物品獲得評價過少,導致無“權”進入推薦列表中

都是樣本量太少導致的。(上例中也使用了至少 200
的有效重疊評價數)因此在對於新使用者和新物品進行推薦時,使用一些更一般性的方法效果可能會更好。比如給新使用者推薦更多平均得分超高的電影;把新電影推薦給喜歡類似電影(如具有相同導演或演員)的人。後面這種做法需要維護一個物品分類表,這個表既可以是基於物品元資訊劃分的,也可是通過聚類得到的。