如何使用Python Suprise庫構建基於記憶的推薦系統

手把手教你用Python的Surprise庫實現一個kNN風格的推薦引擎,從數據準備到預測全部搞定。

*本文最初發佈於towards data science博客,***經原作者授權由InfoQ中文站翻譯並分享。

啊,我們的現代生活舒適卻又令人痛苦:下圖的紙杯蛋糕看上去都很誘人,可我們又不能全都嘗一口,那麼應該喫哪一個呢?無論使用哪種平臺,你的選項往往都是無窮無盡的;但是作爲消費者,你的資源卻是有限的。不用擔心,推薦系統可以助你一臂之力!

在推薦系統中,我們有一組用戶和一組項目。對於給定的用戶,我們希望過濾出用戶可能喜歡的一個項目子集(評分高、購買過、觀看過等,具體取決於問題的類型)。推薦系統無處不在,自身業務基於內容的傑出科技企業,如Netflix、Amazon和Facebook等,都非常依賴複雜的推薦系統來提升其產品的消費量。

在本文所討論的這個項目中,我選擇的是boardgamegeek評選出的前100大遊戲(截至2020年3月31日),使用了從這家網站上收集的230萬個人用戶評分數據。我主要使用的是Surprise(https://surprise.readthedocs.io/en/stable/index.html),這是一個專注於推薦系統的Python scikit庫,其結構與scikit-learn非常相似。

在本文中我們將討論基於記憶的模型。我們會介紹如何導入和準備數據,要使用哪些相似度指標,如何實現三個內置的kNN模型,如何應用模型驗證,最後是如何做出預測。關於該項目的詳細信息,請查看我的GitHub存儲庫

我們在本文中介紹的工作大致對應於文
02_modelling_neighbours.ipynb中的代碼。

推薦系統

先快速介紹一下推薦系統的各個種類,之後我們就可以探討最近鄰模型了。

你可以採用兩種主要的推薦路徑:

  • 基於內容的過濾模型,它基於商品的描述和用戶的歷史偏好,我們不需要其他用戶的意見即可做出推薦。

示例:用戶喜歡Vlaada Chvátil設計的三款遊戲,因此我們會推薦他設計的第四款遊戲。

  • 協作過濾模型,它試圖通過不同用戶有着類似評價/都擁有的項目來發現項目/用戶之間的相似性。
  • 示例:用戶喜歡Caverna,根據我們對人羣的分析,我們知道那些喜歡Caverna並瞭解Feast for Odin的用戶也更容易喜歡後者,因此我們會向用戶推薦FfO。

在這個項目中我們將使用協作過濾模型。在協作過濾模型中,兩種最著名的獨特方法分別是:

  • 基於記憶的模型,會根據用戶-項目的評分對計算用戶/項目之間的相似度。
  • 基於模型的模型(不可思議的名稱),使用某種機器學習算法來估計評分。一個典型的例子是用戶-項目評級矩陣的奇異值分解。

在本文中,我們將重點介紹基於記憶的模型。也就是說,在推薦系統中我們選擇了協作過濾,而在協作過濾方法中我們選擇了基於記憶的模型。

數據導入

首先,我們需要安裝Surprise軟件包:

pip install scikit-surprise

完成後,你需要一個數據集,其中包含三個變量:用戶ID、項目ID和評分。這很重要,請勿嘗試以用戶-項目評分矩陣格式來傳遞評分。首先,數據有3列,且行數等於評分的總數。

如果你只是想練習一下,請隨意使用我在GitHub上的數據集。我自己將它們放在了一個3列的csv文件中,但是你也可以使用其他數據結構,或者直接從pandas DataFrame加載。

爲了導入數據,你需要從庫中獲取以下類:

from surprise import Dataset, Reader

然後定義file_path(顯然要更改爲你的文件路徑):

file_path = './data_input/games_100_summary_w_testuser.csv'

最後,我們創建一個具有以下屬性的Reader對象:

  • line_format:確保順序與你的文件匹配。

  • sep:如果我們使用的是csv,這是一個逗號。

  • rating_scale:具有最低和最高可能範圍的一個元組(tuple)。正確設置這個參數是很重要的,否則部分數據將被忽略。例如,如果你使用的是二進制數據,那麼要表示用戶喜歡/不喜歡這個項目,你可以輸入(0,1)。

reader = Reader(
    line_format='user item rating', sep=',', rating_scale = (1,10)
    )

要導入數據,請使用load_from_file方法:

data = Dataset.load_from_file(file_path, reader=reader)

這樣就可以了,你應該讓你的數據使用surprise可以支持的格式!你現在可以將數據想象成一個稀疏矩陣,其中用戶/項目是行/列,而各個評分是該矩陣中的元素。大多數cell可能爲空,但這完全沒問題。在我使用的數據中,我的評分有230萬,用戶約爲23萬,這意味着每位用戶平均對100款遊戲中的10款做出了評價,因此矩陣中有90%的cell爲空。

數據準備

這裏surprise就開始派上用場了,它的工作流程與scikit-learn中的分類器模型是不一樣的。在scikit-learn的模型中你有一個大的矩陣,你可以根據自己的需要將其拆分爲訓練/驗證/測試集,做交叉驗證,因爲它們本質上仍是相同類型的數據。但在surprise中有三種不同的數據類,每種都有自己獨特的用法:

  • Dataset:可以直接或通過交叉驗證迭代器拆分爲訓練集和測試集。後者意味着如果你在交叉驗證中將一個Dataset作爲參數傳遞,它將創建許多訓練-測試拆分。
  • Trainset:在模型的fit方法中用作參數。
  • Testset:在模型的test方法中用作參數。

在我看來,surprise是一個文檔相對完善的庫,但它仍有一些奇怪之處。例如,一個Dataset對象有一個方法construct_testset,但是除了在舊版本的文檔頁面中能找到這一代碼外,文檔並沒有解釋它的作用,也沒說它應該用什麼參數。

我堅持在項目中使用有完善文檔說明的方法。我們正在爲兩種不同的方法做準備,在以下各節中將進一步說明這些方法的目的。

我們將使用來自model_selection包的以下內容:

from surprise.model_selection import train_test_split

首先,我們將數據分爲trainset和testset,test_size設置爲20%:

trainset, testset = train_test_split(data, test_size=0.2)

再說一次,它與分類器/迴歸模型的工作機制略有不同:testset包含隨機選擇的用戶/項目評分,而不是完整的用戶/項目。一位用戶可能有10個評分,現在隨機選擇其中3個評分進入testset,而不是用於擬合模型。我第一次使用時覺得這樣的機制很奇怪,但是不完全刪去某些用戶也是有道理的。

第二種方法是使用完整的數據並交叉驗證以備測試。在這種情況下,我們可以通過build_full_trainset方法使用所有評分來構建一個Trainset對象:

trainsetfull = data.build_full_trainset()

你可以使用n_users和n_items方法獲取項目數/用戶數(trainsetfull是相同的方法,因爲它們是同一類型的對象):

print('Number of users: ', trainset.n_users, '\n')
print('Number of items: ', trainset.n_items, '\n')

當Surprise創建一個Trainset或Testset對象時,它將獲取raw_id(你在導入的文件中使用的id),並將它們轉換爲所謂的inner_id(基本上是一系列從0開始的整數)。你可能需要追溯到原始名稱。以這些項目爲例(你可以對用戶執行相同的方法,只需在代碼中將iid換成uid即可),可以使用all_items方法來獲取inner_iid的列表。要將原始ID轉換爲內部ID,可以使用to_inner_iid方法,使用to_raw_iid可以轉換回去。

下面是關於如何保存內部項目ID和原始項目ID的列表的示例:

trainset_iids=list(trainset.all_items())
iid_converter=lambdax:trainset.to_raw_iid(x)
trainset_raw_iids=list(map(iid_converter,trainset_iids))

到這裏,我們的數據準備工作就結束了,接下來是時候瞭解一些模型參數了!

模型參數

當我們使用kNN—類型推薦器算法時,可以調整兩個超參數:k參數(是的,與模型類型名稱相同的k)和相似度選項。

k參數非常簡單,機制和它在通用的k-nearest近鄰模型中類似:它是我們希望算法考慮的相似項目的上限。例如,如果用戶爲20個遊戲打分,但我們將k設置爲10,則當我們估計新遊戲的評分時,只會考慮20個遊戲中最接近新遊戲的10個遊戲。你也可以設置min_k,如果用戶沒有足夠的評分,則將使用全局平均值進行估計。默認情況下k爲1。

我們在上一段中提到了彼此接近的項目,但是我們如何確定這個距離呢?第二個超參數(相似度選項)定義了計算它的方式。

首先讓我們看一下sim_option配置。這個參數是一個字典,具有以下鍵:

  • shrinkage:不需要基本的kNN模型,只在KNNBaseline模型中出現。

  • user_based:基本上,當你要估計相似度時有兩種路徑。你可以計算每個項目與其他項目的相似程度,也可以計算用戶間的相似程度。對於我的項目而言,考慮到我有100個項目和23萬個用戶,我使用False。

  • min_support:最小公共點數,低於它時相似度設置爲0。示例:如果min_support爲10,並且有兩個遊戲,只有9個用戶對它們都打了分,那麼無論評分如何,兩個遊戲的相似度均爲0。我沒有在我的項目中做這種實驗,考慮到數據範圍它應該沒什麼影響,因此我使用默認值1。

  • name:公式的類型,將在後文進一步討論。

所有相似度函數都會向特定(i,j)項目對返回0到1之間的數字。1表示評分完全一致,0表示兩個項目之間沒有任何聯繫。在公式中,rᵤᵢ是用戶u對項目i給予的評分,μᵢ是項目i的平均評分,而Uᵢⱼ是對項目i和j都打了分的用戶集合。下面是surprise相似性模塊(https://surprise.readthedocs.io/en/v1.1.0/similarities.html)中的三個相似度指標:

cosine:

MSD:

其中msd(i,j)爲:

pearson:


這些選項並沒有優劣之分,但我很少看到有示例使用MSD,而且在我的數據中pearson和cosine的性能確實好得多。可以看到,pearson公式基本上是cosine公式的均值中心形式。

關於如何定義sim_option參數的示例:

my_sim_option = {
    'name':'MSD', 'user_based':False, min_support = 1
    }

現在我們做好了所有準備工作,終於可以訓練一些模型了。

KNN模型

基本的KNN模型 在surprise中有三種變體(我們在本文中不考慮第四種,即KNNBaseline)。它們定義了rᵤᵢ(也就是用戶u對項目i的打分)在預測中是如何估計出來的。下面的公式主要使用我們在上一節中討論過的符號,其中有兩個是新的:σᵢ是項目i的標準差,Nᵤᵏ(i)是用戶u打分的項目中,和u對項目i的打分最接近的最多k個項目。

公式如下:

KNNBasic:

估計的評分基本上是用戶對相似項目評分的加權平均值,由相似度加權。

KNNWithMeans:

使用項目的平均評分調整KNNBasic公式。

KNNWithZScore:

更進一步,還根據評分的標準差進行調整。

在下面的示例中,我們使用三個my_參數擬合KNNWithMeans模型。根據我的經驗,如果你的項目的平均評分不一樣,那麼幾乎就不會選擇使用KNNBasic。你可以根據需要自由更改這些參數,並且所有三個模型都使用完全相同的參數。你可以在下面的代碼中將KNNWithMeans更改爲KNNBasic或KNNWithZScore,運行起來都是一樣的。

from surprise import KNNWithMeans
my_k = 15
my_min_k = 5
my_sim_option = {
    'name':'pearson', 'user_based':False, 
    }
algo = KNNWithMeans(
    k = my_k, min_k = my_min_k, sim_option = my_sim_option
    )
algo.fit(trainset)

這樣,我們的模型就擬合了。從技術上講,這裏發生的事情是模型算出了相似度矩陣,如果你需要的話還有均值/標準差。
你可以使用sim方法請求相似度矩陣,如下所示:

algo.sim()

它將是一個numpy數組格式。除非你想自己做某種預測,否則應該不需要這個矩陣。

測試

訓練模型後,就該測試了吧?性能指標保存在surprise的準確度模塊(https://surprise.readthedocs.io/en/stable/accuracy.html)中。這裏有四個指標(RMSE、FCP、MAE、MSE),但是據我所知,行業標準是均方根誤差(RMSE),因此我們只使用這個指標。下面是我們最終的數學公式:

這個分數大致會告訴你估計的平均評分與實際的平均評分之間的差距。要獲得測試分數,你要做的就是使用已經擬合的算法上的測試方法創建一個predictions對象:

from surprise import accuracy
predictions = algo.test(testset)
accuracy.rmse(predictions)

假設根據我的數據,測試數據的RMSE得分爲1.2891。這意味着估計的平均評分是實際評分的1.2891倍(或相反),分數範圍是1到10。這個分數不算好也不算差。

交叉驗證

在前兩節中,我們採用了非常直接的方法:我們保留測試數據,訓練模型,然後測試其性能。但是,如果你要跑很多次,則最好使用交叉驗證來測試模型的性能和判斷模型是否過擬合。

如前所述,surprise中測試和驗證的機制有所不同。你只能對原始Dataset對象進行交叉驗證,而不能爲最終測試留出單獨的測試部分,至少我找不到相應的方法。所以我的流程基本上是這樣的:

  • 對具有不同參數的多種模型類型進行交叉驗證,
  • 選出平均測試RMSE得分最低的配置,
  • 在整個Dataset上訓練這個模型,
  • 用它來預測。

我們討論一下cross_validate方法的幾個參數:

在下一部分中,我們將交叉驗證的結果保存在result變量中:

from surprise.model_selection import cross_validate
results = cross_validate(
    algo = algo, data = data, measures=['RMSE'], 
    cv=5, return_train_measures=True
    )

請注意,運行這個操作可能需要幾分鐘時間,測試需要一段時間,而交叉驗證則需要執行5次。
完成後,你可以深入研究result變量以分析性能。例如,要獲得平均測試RMSE分數:

results['test_rmse'].mean()

自然,你會花一段時間研究,然後嘗試不同的模型,並嘗試儘可能降低RMSE得分。等你對性能感到滿意,並創建了讓自己滿意的algo模型後,就可以在整個數據集上訓練算法了。這個步驟是必要的,因爲正如我提到的那樣,你無法根據交叉驗證做出預測。與上面針對非完整訓練集使用的代碼相同:

algo.fit(trainsetfull)

下一步,我們開始討論預測!

預測

終於到這一步了,我們做整個項目就是爲了這一刻,對吧?這裏要注意的是,surprise有兩點可能和你期望的不一樣:

  • 只能對已經在數據集中的用戶進行預測。這也是爲什麼我認爲在流程結束時在整個數據集上訓練模型纔有意義的原因所在。

  • 你不能調用一次就從模型中獲得輸出列表。你可以請求一個特定用戶對某個特定項目的估計評分結果。但這裏有一種解決方法,我們稍後會再討論。

要做一次預測,你可以使用原始ID,因此要獲取TestUser1(用戶在數據中至少具有min_k個其他評分)對ID爲161936的遊戲的評分估計,你需要使用訓練好的算法上的predict方法:

algo.predict(uid = 'TestUser1', iid = '161936')

predict方法將返回如下字典:

Prediction(uid='TestUser1', iid='161936', r_ui=None, est=6.647051644687803, details={'actual_k': 4, 'was_impossible': False})

r_ui爲None,因爲用戶對這個項目沒有實際評分。我們感興趣的是est項目,也就是估計的評分,這裏估計的評分爲6.647。
到這裏都很不錯,但是我們如何爲一位用戶獲取前N條推薦呢?你可以在這篇文檔(https://surprise.readthedocs.io/en/stable/FAQ.html)中找到一個詳細的解決方案,這裏不會細談,只講一下基本步驟:

  • 在trainsetfull上訓練模型。
  • 使用build_anti_testset方法創建一個“anti testset”。這基本上是我們原始數據集的補集。因此,如果用戶對100款遊戲中的15款進行了評分,我們的testset將包含該用戶未評分的85款遊戲。
  • 使用test方法在anti_testset上運行預測(結果與predict方法有類似的結構)。通過此步驟,我們爲數據中缺少的所有用戶-項目評分對提供了評分估計。
  • 對每個用戶的估計評分進行排序,列出N個具有最高估計評分的項目。

總結

我覺得應該將我們討論的內容放在一起總結一下。我們在下面的代碼中採用的方法是交叉驗證路線,因此我們使用交叉驗證測試性能,然後將模型擬合到整個數據集。

請注意,你很可能不會止步於一次交叉驗證,而應嘗試其他模型,直到找到最佳的選項。你可能還希望簡化上一節中得到的前N條推薦。

from surprise import Dataset, Reader
from surprise.model_selection import train_test_split, cross_validate
from surprise import KNNWithMeans
from surprise import accuracy

# Step 1 - Data Import & Preparation

file_path = './data_input/games_100_summary_w_testusers.csv'
reader = Reader(
    line_format='user item rating', sep=',', rating_scale = (1,10)
    )
data = Dataset.load_from_file(file_path, reader=reader)

trainsetfull = data.build_full_trainset()
print('Number of users: ', trainsetfull.n_users, '\n')
print('Number of items: ', trainsetfull.n_items, '\n')

# Step 2 - Cross-Validation

my_k = 15
my_min_k = 5
my_sim_option = {
    'name':'pearson', 'user_based':False
    }

algo = KNNWithMeans(
    k = my_k, min_k = my_min_k, 
    sim_options = my_sim_option, verbose = True
    )
    
results = cross_validate(
    algo = algo, data = data, measures=['RMSE'], 
    cv=5, return_train_measures=True
    )
    
print(results['test_rmse'].mean())

# Step 3 - Model Fitting

algo.fit(trainsetfull)

# Step 4 - Prediction

algo.predict(uid = 'TestUser1', iid = '161936')

下一步工作

當你使用surprise工作時還有其他許多選項,我打算在以後的文章中具體探討。

很容易想到的下一步工作是使用SVD和SVDpp方法探索基於模型的方法。它們使用矩陣分解來估計評分。另外你可能已經注意到,在這個場景中我沒有使用GridSearchCV進行超參數調整。考慮到我們只有幾個參數,我發現使用cross_validate就足夠了;但是當涉及更復雜的模型時,你肯定要使用GridSearchCV。

另一個值得探索的領域是預測。有時,你只想對某些用戶評分運行模型,而無需將其集成到基礎數據庫中。例如,我從boardgamegeek收集了數據,當我只是想快速向某人展示該模型時,我不希望這些評分與“官方”評分混在一起。爲一個用戶重新運行整個模型也有些浪費了。現在,對於我們討論的三種KNN模型而言,完全有可能僅根據相似性矩陣、均值和標準差進行預測。我將在以後的文章中專門介紹這個流程,或者你可以在GitHub中查看recomm_func.py腳本。

參考鏈接:

https://en.wikipedia.org/wiki/Collaborative_filtering

https://surprise.readthedocs.io/en/stable/index.html

原文鏈接:https://towardsdatascience.com/how-to-build-a-memory-based-recommendation-system-using-python-surprise-55f3257b2cf4

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