Categories
程式開發

如何使用Python Suprise库构建基于记忆的推荐系统


手把手教你用Python的Surprise库实现一个kNN风格的推荐引擎,从数据准备到预测全部搞定。

*本文最初发布于towards data science博客,***经原作者授权由InfoQ中文站翻译并分享。

啊,我们的现代生活舒适却又令人痛苦:下图的纸杯蛋糕看上去都很诱人,可我们又不能全都尝一口,那么应该吃哪一个呢?无论使用哪种平台,你的选项往往都是无穷无尽的;但是作为消费者,你的资源却是有限的。不用担心,推荐系统可以助你一臂之力!

如何使用Python Suprise库构建基于记忆的推荐系统 1

在推荐系统中,我们有一组用户和一组项目。对于给定的用户,我们希望过滤出用户可能喜欢的一个项目子集(评分高、购买过、观看过等,具体取决于问题的类型)。推荐系统无处不在,自身业务基于内容的杰出科技企业,如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:

如何使用Python Suprise库构建基于记忆的推荐系统 2

MSD:

如何使用Python Suprise库构建基于记忆的推荐系统 3

其中msd(i,j)为:

如何使用Python Suprise库构建基于记忆的推荐系统 4

pearson:

如何使用Python Suprise库构建基于记忆的推荐系统 5
这些选项并没有优劣之分,但我很少看到有示例使用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:

如何使用Python Suprise库构建基于记忆的推荐系统 6

估计的评分基本上是用户对相似项目评分的加权平均值,由相似度加权。

KNNWithMeans:

如何使用Python Suprise库构建基于记忆的推荐系统 7

使用项目的平均评分调整KNNBasic公式。

KNNWithZScore:

如何使用Python Suprise库构建基于记忆的推荐系统 8

更进一步,还根据评分的标准差进行调整。

在下面的示例中,我们使用三个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),因此我们只使用这个指标。下面是我们最终的数学公式:

如何使用Python Suprise库构建基于记忆的推荐系统 9

这个分数大致会告诉你估计的平均评分与实际的平均评分之间的差距。要获得测试分数,你要做的就是使用已经拟合的算法上的测试方法创建一个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