Skip to content
后端

推荐系统的 Hit@10 是 0.306,随机推荐是 0.287:给协同过滤做离线评估时我补上的几条基线

电影推荐项目的留一法 Hit@10 为 0.306,加上随机基线才发现理论值 0.287。用多种子、热门榜和类型基线排查,原因是原始评分余弦区分不出类型、模拟数据选片均匀随机。

涛哥2026-10-267 分钟阅读

我在一个学习项目里写过一个电影推荐系统:Python 标准库 + SQLite,Item-CF 和 User-CF 两种协同过滤,数据大屏上还有一个"留一法 Hit@10"的离线评估指标,用首次启动生成的演示数据,页面上显示 0.306。

这个数字我一直没多想,觉得"每 10 次推荐有 3 次命中"听起来还行。前几天回头看这块代码,顺手算了一下随机推荐当对照,结果随机推荐的 Hit@10 理论值是 0.287。

也就是说,这个推荐算法和闭着眼睛随便推,在这份数据上几乎没区别。这篇记录一下我是怎么排查的,以及以后做推荐类项目的离线评估,我会默认加上哪几样东西。所有数字都是在项目代码上实际跑出来的,评估方法下面都会写清楚。

项目里的算法和评估 ​

数据是程序生成的模拟数据:48 部电影、8 个类型(每类 6 部),80 个模拟用户。每个用户随机偏好 2 个类型,随机挑 8~20 部电影打分,喜欢的类型分数围绕 4.3 波动,其他类型围绕 2.6,取整到 1~5。

Item-CF 用的是余弦相似度,按用户已评分电影加权(略去了生成"推荐理由"的几行):

python
def item_cf(R, uid, k=10):
    by_item = defaultdict(dict)
    for u, rs in R.items():
        for m, s in rs.items(): by_item[m][u] = s
    norm = {m: math.sqrt(sum(v * v for v in us.values())) for m, us in by_item.items()}
    def sim(a, b):
        common = by_item[a].keys() & by_item[b].keys()
        return sum(by_item[a][u] * by_item[b][u] for u in common) / (norm[a] * norm[b]) if common else 0
    mine = R.get(uid, {}); score = defaultdict(float)
    for m in by_item:
        if m in mine: continue
        for j, s in mine.items():
            score[m] += sim(m, j) * (s - 2.5)   # 低于 2.5 分的电影起反作用
    return sorted(score.items(), key=lambda x: -x[1])[:k]

评估是留一法:对每个用户,从他打过 4 分及以上的电影里随机藏起一部,用剩下的评分做推荐,看藏起来的那部在不在 Top 10 里。命中的用户数除以参与评估的用户数,就是 Hit@10。大屏上取的是前 40 个用户(其中 36 个有至少 2 部高分电影,参与评估),随机种子固定为 1。

逻辑本身没问题,问题出在我只看了这一个数字。

第一条基线:随机推荐,以及它的理论值 ​

先算一下随机推荐应该是多少。某个用户评过 n 部电影,藏起一部后,候选集是"没评过的 48 - n 部 + 被藏的那 1 部",随机抽 10 部,命中的概率是:

P = 10 / (48 - n + 1)

用户平均评了 13.5 部,候选集大约 35 部,所以随机推荐的期望 Hit@10 大约是 10/35 ≈ 0.29。按每个用户的实际 n 算平均,结果是 0.287。

这一步其实就已经能说明问题了:Hit@K 的绝对值没有意义,它和候选集大小强相关。 48 部电影里推 10 部,已经覆盖了约三成候选,"命中 30%"是随机就能做到的水平。如果是 MovieLens 那种几千部电影的数据,同样的 0.3 才算得上不错。

第二件事:36 个用户的指标,抖动有多大 ​

只换随机种子(也就是每个用户藏起来的是哪一部),其他什么都不改,跑 30 次:

方法(前 40 个用户,30 个种子)均值最低最高
Item-CF0.3270.2220.417
随机推荐0.2860.1110.500

同一个算法,换个种子就能在 0.222 到 0.417 之间变;随机推荐运气好的一次能到 0.500,比 Item-CF 最好的一次还高。参与评估的只有 36 人,一个人命中与否就是 2.8 个百分点。大屏上的 0.306 只是其中一次的结果,换个种子写进论文可能就是 0.417。

所以第二条规矩:样本小的时候,至少换多个种子跑,报均值和范围,不要报单次结果。

第三条基线:热门榜,以及一个"作弊"的上限 ​

再加两个对照:

  • 热门榜:项目里已经有,按贝叶斯平均排序 (均分 × 人数 + 3 × 10) / (人数 + 10),就是给每部电影先垫 10 个 3 分,避免只有一两个人打 5 分的电影冲到第一。评估时用藏掉一条后的数据重算。
  • 按类型均分:只看用户自己评过的电影,算出他对每个类型的平均分,把没看过的电影按类型均分排序。它用了电影的类型信息,算是一个最简单的基于内容的推荐,也可以看成"这份数据里信号有多强"的参考。

全部 81 个用户(71 人参与评估),30 个种子:

方法均值最低最高
Item-CF0.3260.2540.437
User-CF0.2720.1830.352
热门榜0.3060.2110.408
随机推荐0.3110.1970.437
按类型均分0.5790.4370.648

几个结论:

  1. Item-CF 比随机只高 0.015,远小于换种子带来的抖动。
  2. User-CF(Pearson 相关取 15 个邻居)平均下来比随机还低。
  3. 按类型均分能到 0.579,说明数据里是有明确信号的,只是协同过滤没抓到。

为什么协同过滤抓不到 ​

原因一:原始评分的余弦相似度区分不出类型 ​

我把所有电影两两之间的相似度算出来,按"同类型""不同类型"分组取平均:

相似度同类型不同类型
原始评分余弦(项目里的写法)0.2670.250
先减去用户自己的平均分,再算余弦0.096-0.035

原始评分全是 1~5 的正数,余弦相似度里点积的每一项都是正的,两部电影只要有共同评分的人,相似度就会是正的,主要反映的是"有多少人同时评过",而不是"同一批人是不是都喜欢"。同类型和不同类型只差 0.017。

先减去每个用户自己的平均分(这就是常说的"调整余弦",Adjusted Cosine),喜欢的变正、不喜欢的变负,同类型和不同类型就明显分开了。

不过我把 Item-CF 换成调整余弦后,在原始数据上 Hit@10 是 0.315,和原来几乎一样。说明还有第二个原因。

原因二:太稀疏,而且"看哪部电影"本身没有信息 ​

每对电影平均只有 6.53 个共同评分的人,用 6 个人的打分估计两部电影像不像,噪声很大。

更关键的是模拟数据的生成方式:每个用户评哪些电影是均匀随机抽的,只有分数和偏好有关。而真实世界不是这样的:喜欢科幻的人会去看更多科幻片,"看了什么"本身就带着偏好。协同过滤在真实数据上很大一部分信号就来自这种共现关系,在我的模拟数据里,这部分信号被完全抹掉了。

为了验证这一点,我按同样的规则重新生成了两份数据,唯一区别是"看哪部电影"的方式:

方法(80 个用户,10 个种子)均匀随机选片更常看喜欢的类型(权重 3:1)
Item-CF(原始余弦)0.3590.601
Item-CF(调整余弦)0.4240.590
User-CF0.3830.465
热门榜0.3080.372
随机推荐0.2680.269
按类型均分0.5690.781

只改了选片方式,Item-CF 就从 0.359 涨到 0.601,是随机推荐的两倍多,算法一行没动。在均匀选片的数据上,调整余弦比原始余弦好一些(0.424 对 0.359),但离按类型均分的 0.569 还差得远;一旦选片带上偏好,两种余弦的差别就不大了,共现本身已经提供了大部分信号。

这件事让我意识到,模拟数据的生成方式会直接决定算法看起来好不好。如果做毕设或课程项目要用模拟数据,最好在文档里说清楚数据是怎么造的,并且让"看什么"和"打几分"都跟偏好相关,否则评估出来的数字既不能说明算法好,也不能说明算法差。

顺带一个评估脚本自己的坑 ​

写对照脚本时我还踩了一个小坑:同一个脚本跑两遍,数字居然不一样。原因是留一法用 R[u].pop(h) 藏起一条、评估完再 R[u][h] = s 放回去,Python 的 dict 会把放回的键排到最后。下一轮 rnd.choice(good) 从同一个用户的高分电影里抽,列表顺序变了,抽到的就不是同一部;推荐结果里同分电影的先后也会变。

项目里的大屏接口每次请求都重新从数据库加载,不受影响;但如果像我这样在一个脚本里连续评估多种方法,每次都要在评分数据的副本上跑,否则方法之间根本不是在同一组"考题"上比较。

以后做推荐类项目,离线评估我会默认加的东西 ​

  1. 随机推荐基线,并且写出理论值 K / 候选集大小。这是判断 Hit@K 有没有意义的第一步。
  2. 热门榜基线。很多数据集上热门榜是很难打败的,算法打不过热门榜就没必要上。
  3. 多个随机种子,报均值和最低/最高(或标准差)。参与评估的用户少于几百人时尤其重要。
  4. 小目录别只看 Hit@10。 几十部电影推 10 部,区分度很低。可以同时看 Hit@3、Hit@5,或者 MRR 这类考虑排名位置的指标。
  5. 评分数据先去掉用户偏置再算相似度(调整余弦或 Pearson),原始评分余弦更适合只有 0/1 的隐式反馈。
  6. 说清楚数据来源。 模拟数据要写明生成规则,并检查它是否保留了真实数据里最重要的那种信号。

小结 ​

页面上那个 0.306 本身没有算错,错在我没有拿任何东西和它比。推荐算法的离线指标只有放在基线旁边才有意义:先问随机能到多少、热门榜能到多少,再看算法高出多少,高出的部分是不是比换个种子的抖动还大。

上面的数字都是用项目里的 item_cf、user_cf 原函数加一个评估脚本跑出来的,没有改项目代码。项目代码在下面这个仓库的 03-movie-recommender 目录(仓库仅供学习参考,请勿直接作为作业或毕设提交)。

仓库:https://github.com/MsNan/dev-learning-projects

最后更新: