3. 离线评估指标(二):Hit Rate@K 与 MRR
上一章我们聊了 Precision 和 Recall,这两个指标虽然基础,但在召回场景下有个硬伤——它们要求你给每个用户标注「到底哪些 item 是相关的」。说实话,这在工业界几乎不可能做到。用户没点过的 item 就一定不相关吗?不一定。所以我们需要另一类指标:基于排序位置的评估方法。
这一章我重点讲两个:Hit Rate@K 和 Mean Reciprocal Rank (MRR)。这两个指标不需要全量标注,只需要知道用户真正交互过的 item 是否出现在召回列表里就行。我个人习惯把它们叫做「弱标注指标」——因为只需要正样本,不需要负样本。
核心区别一句话:Hit Rate@K 只看「有没有」,MRR 还看「排第几」。
3.1 Hit Rate@K:召回列表里有没有用户想要的东西?
Hit Rate@K 的定义非常直观:对于每个用户,我们取他实际交互过的 item 集合(比如点击过的商品、看过的视频),然后检查这些 item 是否出现在模型召回的 Top-K 列表里。只要至少有一个命中,就算一次 Hit。
公式长这样:
Hit Rate@K = (命中用户数) / (总用户数)
举个例子。假设我们有 100 个用户,每个用户都有一些历史点击记录。模型给每个用户召回 10 个 item(K=10)。如果其中 80 个用户的点击记录里至少有一个出现在召回列表中,那么 Hit Rate@10 = 80%。
嗯,这里要注意一个细节:测试集里的 item 不能出现在训练集里。我在项目中遇到过这个问题——有次离线评估 Hit Rate 高达 95%,上线后效果却很差。排查了半天才发现,测试集里很多 item 在训练阶段已经被模型「见过」了。说白了,模型是在「回忆」而不是「预测」。
避坑指南:我曾经因为时间窗口划分不当,导致 Hit Rate 虚高。建议严格按照时间顺序切分训练/测试集,测试集用用户最新的交互行为,训练集用历史行为。这样才符合线上「预测未来」的场景。
3.2 Mean Reciprocal Rank (MRR):第一个命中的位置有多靠前?
Hit Rate 有个明显的缺陷:它只关心「有没有命中」,不关心「命中的位置」。你想想看,用户真正想要的东西排在第一位,和排在第十位,体验能一样吗?
MRR 就是来解决这个问题的。它计算每个用户第一个命中 item 在召回列表中的位置倒数,然后对所有用户取平均。
公式:
MRR = (1/N) * Σ (1 / rank_i)
其中 rank_i 是第 i 个用户第一个命中 item 的位置。如果没命中,rank_i 取无穷大,该项贡献为 0。
举个例子:
| 用户 | 召回列表(Top-5) | 命中位置 | Reciprocal Rank |
|---|---|---|---|
| 用户 A | [item3, item7, item1, item9, item5] | 第 1 位(item3) | 1/1 = 1.0 |
| 用户 B | [item2, item8, item4, item6, item10] | 第 3 位(item4) | 1/3 ≈ 0.333 |
| 用户 C | [item1, item5, item9, item3, item7] | 无命中 | 0 |
MRR = (1.0 + 0.333 + 0) / 3 ≈ 0.444
你发现没有?MRR 对排名非常敏感。排第 1 得 1 分,排第 2 得 0.5 分,排第 3 得 0.33 分……衰减很快。这意味着 MRR 会强烈惩罚那些把用户想要的东西排在后面的模型。
注意:MRR 只考虑第一个命中 item。如果用户有多个感兴趣的 item,MRR 会忽略后面的命中。这种情况下可以考虑用 MAP(Mean Average Precision)或者 NDCG,我们后面会讲到。
3.3 两个指标怎么选?
我个人的经验是:
- Hit Rate@K 适合评估「召回覆盖面」。比如新闻推荐,用户可能对多个话题感兴趣,只要召回列表里有他喜欢的就行,位置不是最关键。
- MRR 适合评估「首条命中能力」。比如搜索场景,用户通常只关心第一个结果。你想想看,在搜索引擎里输入关键词,大部分人只看第一页第一条。
实际项目中,我通常两个指标一起看。举个例子:
| 模型 | Hit Rate@10 | MRR | 解读 |
|---|---|---|---|
| 模型 A | 85% | 0.32 | 覆盖面不错,但首条命中差 |
| 模型 B | 72% | 0.58 | 覆盖面稍弱,但首条很准 |
模型 A 适合「猜你喜欢」这类泛推荐场景,模型 B 更适合搜索或意图明确的场景。具体选哪个,取决于你的业务目标。
3.4 代码实现:从零开始算一遍
理论说完了,我们动手写代码。我习惯用 Python 实现,逻辑清晰,方便调试。
import numpy as np
def hit_rate_at_k(rec_list, true_items, k):
"""
rec_list: 模型召回的 item 列表(已排序)
true_items: 用户实际交互的 item 集合
k: 截断位置
"""
top_k = rec_list[:k]
# 只要有一个命中就算 hit
if any(item in true_items for item in top_k):
return 1
return 0
def mrr(rec_list, true_items, k):
"""
计算单个用户的 Reciprocal Rank
"""
top_k = rec_list[:k]
for idx, item in enumerate(top_k):
if item in true_items:
return 1.0 / (idx + 1)
return 0.0
# 模拟数据
users = {
'user1': (['A', 'B', 'C', 'D', 'E'], {'C', 'F'}),
'user2': (['X', 'Y', 'Z', 'W', 'V'], {'W', 'Z'}),
'user3': (['M', 'N', 'O', 'P', 'Q'], {'R', 'S'}),
}
K = 3
hit_scores = []
mrr_scores = []
for user, (rec_list, true_items) in users.items():
hit_scores.append(hit_rate_at_k(rec_list, true_items, K))
mrr_scores.append(mrr(rec_list, true_items, K))
print(f"Hit Rate@{K}: {np.mean(hit_scores):.2%}")
print(f"MRR: {np.mean(mrr_scores):.4f}")
输出结果:
Hit Rate@3: 66.67%
MRR: 0.4167
你看,代码其实很简单。但我在实际项目中踩过一个坑:true_items 要去重。如果用户对同一个 item 点击了多次,集合里只保留一个就够了。否则重复计算会稀释 MRR 的区分度。
3.5 本章知识体系
下面这张图帮你理清 Hit Rate@K 和 MRR 的关系:
3.6 小结
这一章我们聊了两个核心指标:
- Hit Rate@K:简单粗暴,看召回列表里有没有用户想要的东西。适合评估召回层的「兜底能力」。
- MRR:更精细,看第一个命中的位置。适合评估「精准命中」能力。
我个人建议,在离线评估阶段,至少同时看 Hit Rate@K 和 MRR。一个管广度,一个管精度。如果只能选一个,我会选 MRR——因为它对排序质量更敏感,能暴露更多模型问题。
一个小技巧:我在做模型迭代时,会固定 K=10 和 K=50 两个档位。K=10 看「精准召回」,K=50 看「召回覆盖面」。两个指标一起涨,才是真正的提升。