4. 离线评估指标(三):NDCG@K 的详细推导与计算示例
前面我们聊了 Precision 和 Recall,它们有个共同的问题——把所有的相关结果都当成一样重要。但实际推荐场景里,用户对排在前面的东西明显更在意。你想想看,谁会翻到第 50 位去点一个商品?
NDCG@K 就是来解决这个问题的。它不光看你有没有召回相关结果,还看你把相关结果排在了什么位置。说白了,排得越靠前,得分越高。
4.1 从 CG 到 DCG:为什么要打折?
先看一个最简单的版本——CG(Cumulative Gain,累计增益)。
公式很简单:
CG@K = Σ rel_i (i = 1 到 K)
其中 rel_i 是第 i 个位置的相关性得分。比如相关是 1,不相关是 0。
但 CG 有个硬伤——它不关心顺序。你想想,[相关, 相关, 不相关] 和 [不相关, 相关, 相关] 的 CG 值是一样的。这显然不合理。
所以有了 DCG(Discounted Cumulative Gain)。核心思路就是:位置越靠后,增益打折越狠。
最常用的公式是:
DCG@K = Σ (2^rel_i - 1) / log₂(i + 1) (i = 1 到 K)
这里 log₂(i+1) 就是折扣因子。位置 1 的折扣是 log₂(2)=1,不打折;位置 2 是 log₂(3)≈1.58;位置 10 是 log₂(11)≈3.46,折扣力度就大多了。
为什么用 log₂ 而不是线性衰减?
我个人理解是,用户浏览行为的衰减本身就不是线性的。用户从第 1 位看到第 2 位的概率下降很快,但从第 20 位看到第 21 位的下降就没那么明显了。log 衰减更符合这个规律。
4.2 NDCG:让不同列表可以公平比较
DCG 虽然考虑了位置,但不同查询/用户的理想排序不一样。有的查询有 10 个相关结果,有的只有 2 个。直接比 DCG 不公平。
NDCG 的做法是:用当前 DCG 除以理想情况下的最大 DCG(IDCG)。
NDCG@K = DCG@K / IDCG@K
IDCG 就是把所有相关结果按相关性从高到低排到 K 个位置,算出来的 DCG。这样 NDCG 的值就在 0 到 1 之间,不同列表之间可以比较了。
我踩过的坑
曾经有个项目,我们直接用 NDCG 对比两个不同召回策略的效果。结果发现 A 策略的 NDCG 总是比 B 高一点,但线上 A/B 测试却是 B 更好。后来排查发现,A 策略召回的候选集本身就小,IDCG 分母小,NDCG 虚高。所以记住:NDCG 适合在相同候选集规模下比较,跨场景对比要小心。
4.3 完整计算示例
我们用一个具体例子走一遍流程。假设某次召回返回了 10 个结果,相关性标注如下:
| 位置 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
|---|---|---|---|---|---|---|---|---|---|---|
| 相关性 | 3 | 2 | 0 | 1 | 0 | 0 | 2 | 1 | 0 | 0 |
相关性等级:3=非常相关,2=比较相关,1=弱相关,0=不相关。
第一步:计算 DCG@10
逐位计算 (2^rel - 1) / log₂(i+1):
| 位置 i | rel_i | 2^rel - 1 | log₂(i+1) | 单项 DCG |
|---|---|---|---|---|
| 1 | 3 | 7 | 1.000 | 7.000 |
| 2 | 2 | 3 | 1.585 | 1.893 |
| 3 | 0 | 0 | 2.000 | 0.000 |
| 4 | 1 | 1 | 2.322 | 0.431 |
| 5 | 0 | 0 | 2.585 | 0.000 |
| 6 | 0 | 0 | 2.807 | 0.000 |
| 7 | 2 | 3 | 3.000 | 1.000 |
| 8 | 1 | 1 | 3.170 | 0.315 |
| 9 | 0 | 0 | 3.322 | 0.000 |
| 10 | 0 | 0 | 3.459 | 0.000 |
DCG@10 = 7.000 + 1.893 + 0.000 + 0.431 + 0.000 + 0.000 + 1.000 + 0.315 + 0.000 + 0.000 = 10.639
第二步:计算 IDCG@10
把所有相关结果按相关性从高到低排序:3, 2, 2, 1, 1(共 5 个相关结果)
| 理想位置 | rel | 2^rel - 1 | log₂(i+1) | 单项 IDCG |
|---|---|---|---|---|
| 1 | 3 | 7 | 1.000 | 7.000 |
| 2 | 2 | 3 | 1.585 | 1.893 |
| 3 | 2 | 3 | 2.000 | 1.500 |
| 4 | 1 | 1 | 2.322 | 0.431 |
| 5 | 1 | 1 | 2.585 | 0.387 |
IDCG@10 = 7.000 + 1.893 + 1.500 + 0.431 + 0.387 = 11.211
第三步:计算 NDCG@10
NDCG@10 = 10.639 / 11.211 = 0.949
这个值接近 1,说明排序质量相当不错。主要扣分点在于位置 4 的弱相关结果(理想情况下应该排在第 5 位之后),以及位置 7 的比较相关结果被推后了。
4.4 NDCG 的变体与使用场景
实际工程中,NDCG 有几个常见变体:
- NDCG@K:只看前 K 个位置。我一般用 NDCG@10 和 NDCG@20,分别对应首屏和前三屏的效果。
- NDCG(不截断):看整个排序列表。适合候选集规模固定的场景。
- 加权 NDCG:对不同位置赋予不同的权重,比如前 3 位权重加倍。这个在电商场景用得比较多。
注意:NDCG 的局限性
NDCG 假设相关性是独立的,但实际推荐中,结果之间可能有冗余。比如前 3 位都是同一品牌的手机,用户可能只点一个。这种情况下 NDCG 会高估效果。我建议配合 α-NDCG 或 ERR(Expected Reciprocal Rank) 一起使用,它们能处理结果多样性问题。
4.5 NDCG 计算流程总览
下面这张图总结了 NDCG@K 的完整计算流程:
4.6 工程实现要点
最后分享几个我在工程落地时总结的经验:
- 相关性等级要合理:不要搞太多等级,3-5 级就够了。等级太多标注成本高,而且不同标注员之间的一致性会变差。
- 注意 K 的选择:K 太小(比如 3)区分度不够,K 太大(比如 100)计算量大且用户根本看不到那么深。我一般用 10 和 20。
- 处理平局情况:当两个结果相关性相同时,它们在理想排序中的顺序不影响 IDCG。但实际代码实现时要注意排序稳定性。
- 批量计算优化:如果每天要算几百万个用户的 NDCG,建议预计算 log₂(i+1) 的查表,能省不少时间。
我的代码习惯
我一般把 NDCG 计算封装成一个函数,输入是 (scores, relevances, K),输出是 NDCG 值。这样在离线评估和在线监控时都能复用同一套逻辑,避免两边算出来的结果不一致。