打开电商网站,首页已经替你摆好了可能想买的东西,视频网站不停往下刷,总能撞见合胃口的下一条,这些背后大多跑着一套推荐系统。协同过滤是推荐系统里最经典的一种思路,它不靠人工写规则,也不先搞清楚商品到底是什么,只盯着人和人、物和物之间的相对关系做推荐。它能流行这么多年,靠的是一个很朴素的观察,过去口味相近的人,往后大概率还会相近,被同一拨人一起买、一起打分的东西,彼此也多半是同一类。
接下来,我们把协同过滤的两条路线,基于用户和基于物品,彻底拆开讲清楚,说清“相似的人能互相推荐”这个假设到底成立在什么地方,相似度具体怎么算,再拿一张只有四个用户、五个物品的小评分表,亲手把一个缺失评分算出来一遍。代码部分保留完整的 Python 实现,看完就能跑。读这篇之前,你需要会用 Python 和 pandas,知道相关性、余弦这类距离度量大概是做什么的,没接触过也不影响看懂思路。
1. 核心假设
我们先把协同过滤到底在赌什么讲明白,后面两条路线才好理解。它的出发点是一张稀疏的用户对物品的评分表,每个用户只给少数物品打过星,表里大片格子是空的,推荐要做的就是把这些空格子猜出来,猜得高的就优先推给他。
为什么“相似”管用呢?基于用户的思路赌的是人以群分。如果小王过去买过、打过星的东西,和另外一批人高度重合,那批人喜欢而小王还没见过的东西,小王多半也会喜欢。基于物品的思路赌的是另一面,物以类聚。如果两件物品总被同一拨人打出相近的分,说明这两件东西在大家眼里是一类,喜欢其中一件的人,大概率也会喜欢另一件。两条路线用到的原料是同一张评分表,只是一个按行找人、一个按列找物,后面我们会拿同一张表各算一遍。
相似度怎么衡量呢?本篇用的是皮尔逊相关系数,它衡量两组评分的涨跌是否同步,范围在负 1 到 1 之间,越接近 1 说明两个人或两件东西的口味越合拍,接近负 1 说明正好相反。它有个好处,先减去各自的平均分再算相关,把有人爱打高分、有人爱打低分这种“手松手紧”的差异消掉,所以特别适合带评分的数据。
2. 评分矩阵
光说概念容易飘,下面我们造一张小表,后面两节就在这张表上动手算。表里是四个用户对五个物品打的一到五分,问号是我们要预测的格子:
| 用户 | 物品 A | 物品 B | 物品 C | 物品 D | 物品 E |
|---|---|---|---|---|---|
| U1 | 4 | 3 | 5 | ? | 2 |
| U2 | 5 | 3 | 5 | 5 | 2 |
| U3 | 3 | 4 | 2 | 3 | 5 |
| U4 | 4 | 1 | 5 | 4 | 1 |
我们要猜的就是 U1 会给 D 打几分。这个分一旦算出来,谁分高就先把谁排在 U1 的推荐列表前面,一次推荐就完成了。表里其他格子都是已知评分,算相似度时只拿两个人都打过分的物品来比。
3. 用户相似
我们先按“人以群分”这条路线来。要猜 U1 对 D 的评分,先在 A、B、C、E 这几个大家都打过分的物品上,量出 U1 和 U2、U3、U4 各自有多像。只有两人共同打过分的物品才纳入计算,谁没打过就跳过谁。算出来三张相似度是这样的:
| 与 U1 的相似度 | U2 | U3 | U4 |
|---|---|---|---|
| 皮尔逊相关系数 | 0.95 | -1.00 | 0.94 |
U2 和 U4 跟 U1 的评分几乎同步,U3 正好反过来,U1 喜欢的东西 U3 都讨厌。按相似度从高到低取最像的前两个邻居,就是 U2 和 U4。预测的办法是把这两个人对 D 的评分,按他们和 U1 的相似度加权,再除以相似度之和,公式写成:
r 估计等于 sim(U1,U2) 乘 r(U2,D) 加上 sim(U1,U4) 乘 r(U4,D),再除以 sim(U1,U2) 加 sim(U1,U4)。把数字代进去,分子是 0.95 乘 5 加 0.94 乘 4,等于 8.51,分母是 0.95 加 0.94,等于 1.89,相除约等于 4.50。
这个结果说明,U1 虽然没见过 D,但两个跟他口味最合拍的人都给了 4 分和 5 分,加权之后大约 4.5 分,D 值得推给他。U3 也评了 D 是 3 分,但他跟 U1 口味完全相反,这一票就不采纳。这正是“相似的人能互相推荐”落地的样子,不是随便找个人参考,而是先挑合拍的,再按合拍程度加权。
4. 物品相似
同一个问号,我们换“物以类聚”这条路线再算一遍。方法是把评分表的看法转过来,从物品之间找关系。要猜 U1 对 D 的评分,先看 D 和 A、B、C、E 里哪些物品是“被同一拨人打出相近的分”的。物品相似度要在都评过分的用户身上算,这里 U2、U3、U4 都评了 D,算出来是这样:
| 与 D 的相似度 | 物品 A | 物品 B | 物品 C | 物品 E |
|---|---|---|---|---|
| 皮尔逊相关系数 | 1.00 | -0.33 | 0.87 | -0.72 |
和 D 最像的前两个物品是 A 和 C,B、E 都跟 D 反着来。预测时用的是 U1 自己对这两个相似物品的评分,也就是 U1 给 A 打了 4、给 C 打了 5,按物品相似度加权。r 估计等于 sim(D,A) 乘 r(U1,A) 加 sim(D,C) 乘 r(U1,C),再除以两个相似度之和,代进去是 1.00 乘 4 加 0.87 乘 5,等于 8.35,除以 1.87,约等于 4.47。
两条路线算出来都在 4.5 分上下,这不是巧合。它们用的本来就是同一张矩阵,基于用户按行找邻居,基于物品按列找邻居,只是换了个看法,结论自然互相印证。实际选哪条路要看场景,用户数量相对少、用户口味稳定时用基于用户,能直接告诉小王“和你差不多的人还看了什么”。物品数量固定、用户量大时用基于物品,因为物品之间的相似度可以离线提前算好,上线直接查表,电商首页那种大规模推荐大多走这条路。
5. 代码实现
手算走通了原理,真实场景是五个用户对八个物品评分,缺失值也不止一个,下面这段代码把上面的过程批量跑出来。一共四步,先造数据,再算相似度,然后分别用两条路线把缺失值补上。
5.1 准备数据
先生成一张五行八列的随机评分表,再随机挖掉四个格子当缺失值,存成 csv。np.random.seed 固定了随机种子,每次跑出来的数据都一样,方便对照。表里的 np.nan 就代表待估计的空位。
import pandas as pd
import numpy as np
def test():
# 数据表示 5 个用户,对 8 个物品的评分
np.random.seed(6)
hisrory = np.random.randint(0, 8, size=[5, 8]).astype(object)
# 随机在分数中构造缺失值
size = 4
i = np.random.randint(0, hisrory.shape[0], size=size)
j = np.random.randint(0, hisrory.shape[1], size=size)
hisrory[i, j] = np.nan
# 转换类型
index = ['user' + str(i + 1) for i in range(hisrory.shape[0])]
colum = ['item' + str(i + 1) for i in range(hisrory.shape[1])]
hisrory = pd.DataFrame(hisrory, index=index, columns=colum)
# 数据存储
hisrory.to_csv('data.csv')
if __name__ == '__main__':
test()
5.2 计算相似度
有了评分表,下一步就是算相似度。pandas 的 corr 方法直接支持皮尔逊系数。按物品求相关就对整张表直接 corr,按用户求相关就先把表转置再 corr,本质就是在同一组数字上换了个方向看。
import pandas as pd
import numpy as np
# 物品相似度: 皮尔逊相关系数
def test01():
history = pd.read_csv('data.csv', index_col=0)
correlation = history.corr(method="pearson")
print(correlation)
correlation.to_csv('item.csv')
# 用户相似度: 皮尔逊相关系数
def test02():
history = pd.read_csv('data.csv', index_col=0)
correlation = history.transpose().corr(method="pearson")
print(correlation)
correlation.to_csv('user.csv')
if __name__ == '__main__':
test01()
print('-' * 80)
test02()
5.3 用户预测
拿到用户相似度矩阵后,逐行扫描每个用户。发现哪个格子是空的,就在这个用户的相似度里排个序,挑最像的两个邻居,把他们对该物品的评分按相似度加权,除以相似度之和,得到估计分再填回空格里。这一步和手算里那道题是同一个动作,只是循环跑遍了所有空位。
def test01():
data = pd.read_csv('data.csv', index_col=0)
print(data)
user = pd.read_csv('user.csv', index_col=0)
print(user)
for i in range(data.shape[0]):
user_item = data.iloc[i]
user_mask = pd.isna(user_item)
# 判断是否存在缺失值
if np.any(user_mask):
# 将所有用户根据相关性进行降序排列
sorted_users = user.iloc[i].drop(user_item.name).sort_values(ascending=False)[:2]
# 获得相关性最强的用户
close_users = sorted_users.index
# 获得对应的相关性值
close_score = sorted_users.values
# 从 data 中获得相关性最强用户的对应缺失值列的分数
close_item = data.loc[close_users, user_mask].values
# 根据公式计算分子
numerator = np.sum(close_item * close_score[None].transpose(), axis=0)
# 计算根据公式分母
denominator = np.sum(close_score)
# 计算得到估计的缺失分数
estimate_score = np.round(numerator / denominator, decimals=2)
# 将估计分数填充到 data 数据中
data.loc[user_item.name, user_mask] = estimate_score
print(data)
data.to_csv('user_data.csv')
跑完之后,原来空着的四个格子都被填上了估计分,整张表不再有缺失值,结果存成 user_data.csv。
5.4 物品预测
物品路线几乎对称,区别在于挑邻居时挑的是物品相似度最高的两个,加权用的是当前用户对这两个相似物品的评分。这里要处理一个边界情况,如果某一列评分全都一样,这一列的标准差是零,皮尔逊相关系数算出来是 NaN,没法加权。代码里碰到这种情况就退一步,直接用这一列的平均分填上。
def test02():
data = pd.read_csv('data.csv', index_col=0)
print(data)
print('-' * 80)
item = pd.read_csv('item.csv', index_col=0)
print(item)
print('-' * 80)
for i in range(data.shape[0]):
user_item = data.iloc[i]
user_mask = pd.isna(user_item)
# 判断是否存在缺失值
if np.any(user_mask):
# 获得包含缺失值的物品
item_nans = user_item[user_item.isna()].index
for item_nan in item_nans:
# 获得该物品的所有相似度并排序
similarity = item.loc[item_nan].drop(item_nan).sort_values(ascending=False)
# 获得相似的前两个物品
similarity = similarity[:2]
# 判断相似度是否全为 NaN
if np.all(pd.isna(similarity)):
data.loc[user_item.name, item_nan] = data[item_nan].mean()
continue
# 获得最相似两个物品名称
simi_names = similarity.index
# 获得最相似两个物品相似度
simi_value = similarity.values
# 获得当前用户对相似产品的评分
simi_score = data.loc[user_item.name, simi_names]
# 根据公式计算分子
numerator = np.sum(simi_score * simi_value)
# 计算根据公式分母
denominator = np.sum(simi_value)
# 估计分数
estimate_score = np.round(numerator / denominator, decimals=2)
data.loc[user_item.name, item_nan] = estimate_score
print(data)
data.to_csv('item_data.csv')
跑完存成 item_data.csv,和用户路线的结果对照看,两种方法补出来的分数趋势基本一致,和手算时互相印证的结论是一回事。
6. 方法选择
代码里用的是皮尔逊相关系数,但相似度度量不止这一种,选哪个要看数据长什么样。四种常见做法对比如下:
| 相似度方法 | 怎么衡量 | 特点 |
|---|---|---|
| 皮尔逊相关系数 | 两组评分的涨跌是否同步 | 先减去各自均值,消除打分松紧差异,本篇使用 |
| 余弦相似度 | 两个评分向量的夹角 | 只看方向不看长短,适合数值偏大的数据 |
| 欧式距离 | 两条评分向量的直线距离 | 数值差直接相加,对打分尺度很敏感 |
| 杰卡德距离 | 共同打过分的物品占比 | 只看打没打过,不看具体分数 |
选择依据是数据形态。评分表里每个用户打分尺度不一样,有人习惯打 4、5 分,有人只打 1、2 分,皮尔逊先减去各自均值再算相关,把这种手松手紧消掉,所以显式评分常用它。电商点击、收藏这类只有 0 和 1 的隐式行为,没有具体分差可言,杰卡德或余弦更合适。
还有两个边界要心里有数。一是冷启动,新用户一个分没打,新物品一个人没评,都找不到邻居,协同过滤直接失效,得靠热门物品或者内容信息先兜住。二是数据稀疏,用户和物品都成千上万的时候,两两算相关根本算不过来,工程上会先粗筛一批候选再精排,这已经超出本篇范围,属于更后面的优化话题了。
我们回到那张评分表。协同过滤不去猜物品是什么,只看人和人、物和物在评分上的相对关系。基于用户找和自己口味合拍的人,拿他们对未知物品的分按相似度加权。基于物品的路线则找和未知物品被同一拨人相近打分的东西,拿自己对那些东西的分加权。皮尔逊相关系数负责量这种合拍程度,它把打分手松手紧的差异消掉,更适合显式评分数据。这套思路是现代推荐系统的地基,理解了它,再去看矩阵分解、深度学习推荐,你就知道后面那些方法想补的,正是它在冷启动和数据稀疏上的短板。




冀公网安备13050302001966号