我在一个学习项目里写过一个电影推荐系统: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 用的是余弦相似度,按用户已评分电影加权(略去了生成"推荐理由"的几行):
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-CF | 0.327 | 0.222 | 0.417 |
| 随机推荐 | 0.286 | 0.111 | 0.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-CF | 0.326 | 0.254 | 0.437 |
| User-CF | 0.272 | 0.183 | 0.352 |
| 热门榜 | 0.306 | 0.211 | 0.408 |
| 随机推荐 | 0.311 | 0.197 | 0.437 |
| 按类型均分 | 0.579 | 0.437 | 0.648 |
几个结论:
- Item-CF 比随机只高 0.015,远小于换种子带来的抖动。
- User-CF(Pearson 相关取 15 个邻居)平均下来比随机还低。
- 按类型均分能到 0.579,说明数据里是有明确信号的,只是协同过滤没抓到。
为什么协同过滤抓不到
原因一:原始评分的余弦相似度区分不出类型
我把所有电影两两之间的相似度算出来,按"同类型""不同类型"分组取平均:
| 相似度 | 同类型 | 不同类型 |
|---|---|---|
| 原始评分余弦(项目里的写法) | 0.267 | 0.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.359 | 0.601 |
| Item-CF(调整余弦) | 0.424 | 0.590 |
| User-CF | 0.383 | 0.465 |
| 热门榜 | 0.308 | 0.372 |
| 随机推荐 | 0.268 | 0.269 |
| 按类型均分 | 0.569 | 0.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) 从同一个用户的高分电影里抽,列表顺序变了,抽到的就不是同一部;推荐结果里同分电影的先后也会变。
项目里的大屏接口每次请求都重新从数据库加载,不受影响;但如果像我这样在一个脚本里连续评估多种方法,每次都要在评分数据的副本上跑,否则方法之间根本不是在同一组"考题"上比较。
以后做推荐类项目,离线评估我会默认加的东西
- 随机推荐基线,并且写出理论值
K / 候选集大小。这是判断 Hit@K 有没有意义的第一步。 - 热门榜基线。很多数据集上热门榜是很难打败的,算法打不过热门榜就没必要上。
- 多个随机种子,报均值和最低/最高(或标准差)。参与评估的用户少于几百人时尤其重要。
- 小目录别只看 Hit@10。 几十部电影推 10 部,区分度很低。可以同时看 Hit@3、Hit@5,或者 MRR 这类考虑排名位置的指标。
- 评分数据先去掉用户偏置再算相似度(调整余弦或 Pearson),原始评分余弦更适合只有 0/1 的隐式反馈。
- 说清楚数据来源。 模拟数据要写明生成规则,并检查它是否保留了真实数据里最重要的那种信号。
小结
页面上那个 0.306 本身没有算错,错在我没有拿任何东西和它比。推荐算法的离线指标只有放在基线旁边才有意义:先问随机能到多少、热门榜能到多少,再看算法高出多少,高出的部分是不是比换个种子的抖动还大。
上面的数字都是用项目里的 item_cf、user_cf 原函数加一个评估脚本跑出来的,没有改项目代码。项目代码在下面这个仓库的 03-movie-recommender 目录(仓库仅供学习参考,请勿直接作为作业或毕设提交)。