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 的完整计算流程:

NDCG@K 计算流程 输入:排序列表 + 相关性标注 步骤1:计算 DCG@K DCG@K = Σ (2^rel_i - 1) / log₂(i+1) 步骤2:计算 IDCG@K 按相关性降序排列,计算理想 DCG 步骤3:计算 NDCG@K NDCG@K = DCG@K / IDCG@K 输出:NDCG@K ∈ [0, 1] 越接近 1 说明排序越接近理想排序,推荐质量越高

4.6 工程实现要点

最后分享几个我在工程落地时总结的经验:

  • 相关性等级要合理:不要搞太多等级,3-5 级就够了。等级太多标注成本高,而且不同标注员之间的一致性会变差。
  • 注意 K 的选择:K 太小(比如 3)区分度不够,K 太大(比如 100)计算量大且用户根本看不到那么深。我一般用 10 和 20。
  • 处理平局情况:当两个结果相关性相同时,它们在理想排序中的顺序不影响 IDCG。但实际代码实现时要注意排序稳定性。
  • 批量计算优化:如果每天要算几百万个用户的 NDCG,建议预计算 log₂(i+1) 的查表,能省不少时间。

我的代码习惯

我一般把 NDCG 计算封装成一个函数,输入是 (scores, relevances, K),输出是 NDCG 值。这样在离线评估和在线监控时都能复用同一套逻辑,避免两边算出来的结果不一致。