协同过滤(Collaborative Filtering)

协同过滤(Collaborative Filtering)

打开电商网站,首页已经替你摆好了可能想买的东西,视频网站不停往下刷,总能撞见合胃口的下一条,这些背后大多跑着一套推荐系统。协同过滤是推荐系统里最经典的一种思路,它不靠人工写规则,也不先搞清楚商品到底是什么,只盯着人和人、物和物之间的相对关系做推荐。它能流行这么多年,靠的是一个很朴素的观察,过去口味相近的人,往后大概率还会相近,被同一拨人一起买、一起打分的东西,彼此也多半是同一类。

接下来,我们把协同过滤的两条路线,基于用户和基于物品,彻底拆开讲清楚,说清“相似的人能互相推荐”这个假设到底成立在什么地方,相似度具体怎么算,再拿一张只有四个用户、五个物品的小评分表,亲手把一个缺失评分算出来一遍。代码部分保留完整的 Python 实现,看完就能跑。读这篇之前,你需要会用 Python 和 pandas,知道相关性、余弦这类距离度量大概是做什么的,没接触过也不影响看懂思路。

1. 核心假设

我们先把协同过滤到底在赌什么讲明白,后面两条路线才好理解。它的出发点是一张稀疏的用户对物品的评分表,每个用户只给少数物品打过星,表里大片格子是空的,推荐要做的就是把这些空格子猜出来,猜得高的就优先推给他。

为什么“相似”管用呢?基于用户的思路赌的是人以群分。如果小王过去买过、打过星的东西,和另外一批人高度重合,那批人喜欢而小王还没见过的东西,小王多半也会喜欢。基于物品的思路赌的是另一面,物以类聚。如果两件物品总被同一拨人打出相近的分,说明这两件东西在大家眼里是一类,喜欢其中一件的人,大概率也会喜欢另一件。两条路线用到的原料是同一张评分表,只是一个按行找人、一个按列找物,后面我们会拿同一张表各算一遍。

相似度怎么衡量呢?本篇用的是皮尔逊相关系数,它衡量两组评分的涨跌是否同步,范围在负 1 到 1 之间,越接近 1 说明两个人或两件东西的口味越合拍,接近负 1 说明正好相反。它有个好处,先减去各自的平均分再算相关,把有人爱打高分、有人爱打低分这种“手松手紧”的差异消掉,所以特别适合带评分的数据。

2. 评分矩阵

光说概念容易飘,下面我们造一张小表,后面两节就在这张表上动手算。表里是四个用户对五个物品打的一到五分,问号是我们要预测的格子:

用户物品 A物品 B物品 C物品 D物品 E
U1435?2
U253552
U334235
U441541

我们要猜的就是 U1 会给 D 打几分。这个分一旦算出来,谁分高就先把谁排在 U1 的推荐列表前面,一次推荐就完成了。表里其他格子都是已知评分,算相似度时只拿两个人都打过分的物品来比。

3. 用户相似

我们先按“人以群分”这条路线来。要猜 U1 对 D 的评分,先在 A、B、C、E 这几个大家都打过分的物品上,量出 U1 和 U2、U3、U4 各自有多像。只有两人共同打过分的物品才纳入计算,谁没打过就跳过谁。算出来三张相似度是这样的:

与 U1 的相似度U2U3U4
皮尔逊相关系数0.95-1.000.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.330.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 的隐式行为,没有具体分差可言,杰卡德或余弦更合适。

还有两个边界要心里有数。一是冷启动,新用户一个分没打,新物品一个人没评,都找不到邻居,协同过滤直接失效,得靠热门物品或者内容信息先兜住。二是数据稀疏,用户和物品都成千上万的时候,两两算相关根本算不过来,工程上会先粗筛一批候选再精排,这已经超出本篇范围,属于更后面的优化话题了。

我们回到那张评分表。协同过滤不去猜物品是什么,只看人和人、物和物在评分上的相对关系。基于用户找和自己口味合拍的人,拿他们对未知物品的分按相似度加权。基于物品的路线则找和未知物品被同一拨人相近打分的东西,拿自己对那些东西的分加权。皮尔逊相关系数负责量这种合拍程度,它把打分手松手紧的差异消掉,更适合显式评分数据。这套思路是现代推荐系统的地基,理解了它,再去看矩阵分解、深度学习推荐,你就知道后面那些方法想补的,正是它在冷启动和数据稀疏上的短板。