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 的关系:

召回离线评估指标(二) 排序位置类指标 Hit Rate@K Mean Reciprocal Rank 只看「有没有命中」 适合覆盖面评估 看「排在第几位」 适合首条命中评估 💡 实际建议:两个指标一起看 Hit Rate 保证「有」,MRR 保证「好」

3.6 小结

这一章我们聊了两个核心指标:

  • Hit Rate@K:简单粗暴,看召回列表里有没有用户想要的东西。适合评估召回层的「兜底能力」。
  • MRR:更精细,看第一个命中的位置。适合评估「精准命中」能力。

我个人建议,在离线评估阶段,至少同时看 Hit Rate@K 和 MRR。一个管广度,一个管精度。如果只能选一个,我会选 MRR——因为它对排序质量更敏感,能暴露更多模型问题。

一个小技巧:我在做模型迭代时,会固定 K=10 和 K=50 两个档位。K=10 看「精准召回」,K=50 看「召回覆盖面」。两个指标一起涨,才是真正的提升。