qinghuan
← 回到知识探索

AI 直觉 / 动手实验

检索给了同分,为什么调换两行就把NDCG从1变成了三分之一?

A与B分数一样,相关度却是3和1。只看第一条时,任意破同分会改变评价;均匀平均并列排列可得到稳定口径。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 先固定gain、折扣和截断位置
  4. 同分时,平均的是所有允许的并列顺序
  5. 不用枚举所有排列,也能算出同分组的贡献
  6. 运行完整小例子,再与独立公式核对
  7. 这是一种明确口径,不是所有线上排序的替代品
  8. 记住这三点
  9. 别把例子用过头
  10. 自己试一小步
  11. 查原始资料
开始前,会这些就够了
遇到不熟悉的词,先看这 2 个解释
理想折扣累计增益
把当前候选按真实相关度排到最好时,在同一k和折扣下能得到的分数。
同分排列平均
模型分数并列时,把组内各种允许顺序等权看待,计算评价的平均值。
打开完整术语小抄 ↗

同一查询有A、B、C三条候选,真实相关度分别为3、1、0。模型给出的分数是0.8、0.8、0.1。A和B并列,模型没有说明谁必须排在谁前面。

如果只评第一条,先放A可得到NDCG=1,先放B则得到1/3。模型分数没有变化,指标却变了。本篇固定线性gain=相关度,并把同分排列均匀平均,区分“模型给出的顺序信息”和“额外的破同分规则”。

把例子带到自己的电脑上

下载完整练习包

需要 Python 3.10 或更新版本,无第三方依赖;实测 Windows / Python 3.14.0,Python 3.10 仅做语法兼容检查。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • demo.py:完整可运行源码
  • README.md:运行步骤与练习
  • observed.stdout.txt:本次实际输出
  • verification.json:源码和运行验证记录
  • SHA256SUMS.txt:文件摘要

在这个文件夹打开 PowerShell,逐行运行

python --version
python -B demo.py

解压后,在含 demo.py 的目录打开终端。命令分别回车执行。程序实现同分组平均公式,并独立枚举三条候选所有符合分数顺序的排列。624组相关度、分数与k组合全部对照;没有运行scikit-learn,也没有调用检索模型。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

模型给了同分,评估应偷偷偏向谁?

正在准备实验控件…

A与B默认同分。交换它们的输入先后,观察任意破同分的结果会变,平均后的结果保持不变。

输入先后:A(相关度3,分数0.8) → B(相关度1,分数0.8) → C(相关度0,分数0.1)。

同一k下的理想DCG
3.000000
按输入顺序破同分:NDCG
1.000000
均匀平均同分排列:NDCG
0.666667

当前任意顺序是 A → B → C。A、B的平均相关度是2.00;即使k只切进同分组的一部分,也要考虑整组可能占据该位置。

只评一个查询的三条候选,线性gain=相关度,不使用2^相关度−1;位置折扣为1/log₂(rank+1)。IDCG使用同一k。对精确同分组均匀平均排列;任意顺序版本明确用输入先后破同分,不模拟某个库的排序细节。没有调用检索模型或scikit-learn。

先固定gain、折扣和截断位置

本文采用DCG@k=Σ[r=1到k] gain_r/log₂(r+1),gain直接等于相关度。站内《单独测量检索召回与排序质量》的深读示例采用2^rel−1;本篇按scikit-learn文档改用线性gain,不能混用两套数值。第一名折扣为1,第二名折扣为1/log₂3。

理想DCG,也叫IDCG,使用同一批候选的真实相关度从大到小排列,并采用相同k和折扣。NDCG=DCG/IDCG。本例k=1时,最好的第一条相关度为3,所以IDCG@1=3。

若任意把A放在前面,DCG@1=3,归一化得到1;若把B放在前面,DCG@1=1,得到1/3。这里没有改变候选集合、相关度或k。

同分时,平均的是所有允许的并列顺序

分数仍要求A、B都排在C之前,但允许A→B→C与B→A→C两种顺序。把这两种排列等权对待,NDCG@1的平均就是(1+1/3)/2=2/3。

也可以先算同分组的平均gain:(3+1)/2=2。这个组可能占据第一名,因此第一名的期望gain为2,再除以相同IDCG=3,仍得到2/3。

关键是k只切入同分组的一部分时,不能先任意留下前k条,再只对留下的条目平均。组外那条同分结果也有机会进入截断位置;先删掉它就已经偷偷决定了顺序。

不用枚举所有排列,也能算出同分组的贡献

若一个同分组覆盖若干排名,在均匀排列下,每个组内候选占据每个组内位置的机会相同。因此该组对DCG@k的期望贡献,等于组内平均gain乘以落在前k名内的折扣之和。

这个公式对整个组都在k之前、整个组都在k之后,以及k从组中间截断都适用。IDCG只依赖真实相关度与固定k,不随同分组的排列改变,所以可以先求平均DCG,再除以IDCG。

将本例k改为2,同分平均NDCG约为0.898354。因为较大的gain有时落到折扣权重更小的第二名,结果不会因为两条都被纳入就自动等于1。

原始论文的公式适用于先选定的gain函数。如果改用2^rel−1,要先逐项计算gain再平均:相关度[3,1]对应gain[7,1],平均gain为4;不能先把相关度平均成2,再算2²−1=3。每套gain仍需使用它自己的IDCG。本页交互和下载源码继续采用前文固定的线性gain。

运行完整小例子,再与独立公式核对

程序实现同分组平均公式,并独立枚举三条候选所有符合分数顺序的排列。624组相关度、分数与k组合全部对照;没有运行scikit-learn,也没有调用检索模型。

本轮实际使用 Windows / Python 3.14.0 运行。保存为 demo.py,执行 python demo.py;只用标准库。检查使用显式 AssertionError,python -O 不会删除这些检查。

展开收起代码 · 小实验python · 74 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""Linear-gain NDCG with exact ties; independent permutation enumeration."""
from itertools import permutations, product
from math import isclose, isfinite, log2


def require(condition, message):
    if not condition:
        raise AssertionError(message)


def ndcg(relevance, scores, k):
    if not isinstance(relevance, (list, tuple)) or not isinstance(scores, (list, tuple)):
        raise ValueError("paired sequences required")
    if not 2 <= len(relevance) <= 7 or len(scores) != len(relevance):
        raise ValueError("need 2 to 7 paired entries")
    if type(k) is not int or not 1 <= k <= len(relevance):
        raise ValueError("integer k within the list required")
    if any(type(r) not in (int, float) or not isfinite(r) or not 0 <= r <= 10 for r in relevance):
        raise ValueError("nonnegative finite relevance at most 10 required")
    if not any(relevance) or any(type(s) not in (int, float) or not isfinite(s) for s in scores):
        raise ValueError("positive ideal gain and finite scores required")
    order = sorted(range(len(scores)), key=lambda i: (-scores[i], i))
    discount = lambda rank: 1 / log2(rank + 2)
    ideal = sum(gain * discount(rank) for rank, gain in enumerate(sorted(relevance, reverse=True)[:k]))
    arbitrary = sum(relevance[i] * discount(rank) for rank, i in enumerate(order[:k]))
    averaged, start = 0.0, 0
    while start < len(order):
        end = start + 1
        while end < len(order) and scores[order[end]] == scores[order[start]]:
            end += 1
        mean_gain = sum(relevance[i] for i in order[start:end]) / (end - start)
        averaged += mean_gain * sum(discount(rank) for rank in range(start, min(end, k)))
        start = end
    return arbitrary / ideal, averaged / ideal


def permutation_oracle(relevance, scores, k):
    orders = list(permutations(range(len(scores))))
    dcg = lambda order: sum(relevance[i] / log2(rank + 2) for rank, i in enumerate(order[:k]))
    ideal = max(map(dcg, orders))
    allowed = [order for order in orders if all(scores[order[i]] >= scores[order[i + 1]]
                                               for i in range(len(order) - 1))]
    return sum(map(dcg, allowed)) / len(allowed) / ideal


def main():
    for relevance, scores, k in [([3, 1, 0], [.8, .8, .1], 1),
                                 ([1, 3, 0], [.8, .8, .1], 1),
                                 ([3, 1, 0], [.8, .8, .1], 2),
                                 ([3, 1, 0], [.9, .8, .1], 1)]:
        arbitrary, averaged = ndcg(relevance, scores, k)
        print(f"relevance={relevance}; scores={scores}; k={k}; input_tiebreak={arbitrary:.6f}; tie_average={averaged:.6f}")
    count = 0
    for relevance in product((0, 1, 3), repeat=3):
        if not any(relevance):
            continue
        for scores in product((0, 1), repeat=3):
            for k in (1, 2, 3):
                require(isclose(ndcg(relevance, scores, k)[1], permutation_oracle(relevance, scores, k),
                                abs_tol=1e-12), "all tied permutations oracle")
                count += 1
    require(count == 624, "case count")
    for invalid in [([0, 0], [1, 1], 1), ([3, -1], [1, 1], 1), ([3, 1], [1, 1], True)]:
        try:
            ndcg(*invalid)
        except ValueError:
            pass
        else:
            raise AssertionError("invalid input accepted")
    print(f"PASS: {count} cases match every permitted permutation; invalid cases rejected")


if __name__ == '__main__':
    main()

怎样运行:在独立练习文件夹中,依次执行下载区的命令。2026-10-26 使用 Python 3.10 或更新版本,无第三方依赖;实测 Windows / Python 3.14.0,Python 3.10 仅做语法兼容检查 复跑这份源码并核对完整输出。程序实现同分组平均公式,并独立枚举三条候选所有符合分数顺序的排列。624组相关度、分数与k组合全部对照;没有运行scikit-learn,也没有调用检索模型。

运行后对照结果
relevance=[3, 1, 0]; scores=[0.8, 0.8, 0.1]; k=1; input_tiebreak=1.000000; tie_average=0.666667
relevance=[1, 3, 0]; scores=[0.8, 0.8, 0.1]; k=1; input_tiebreak=0.333333; tie_average=0.666667
relevance=[3, 1, 0]; scores=[0.8, 0.8, 0.1]; k=2; input_tiebreak=1.000000; tie_average=0.898354
relevance=[3, 1, 0]; scores=[0.9, 0.8, 0.1]; k=1; input_tiebreak=1.000000; tie_average=1.000000
PASS: 624 cases match every permitted permutation; invalid cases rejected

这是一种明确口径,不是所有线上排序的替代品

scikit-learn文档说明,默认会平均同分;ignore_ties=True是假设没有同分以加快计算,并不是“有同分时自动采用同样口径”。本页的任意顺序版本明确按输入先后破同分,不声称复现这个选项的具体排序细节。

若实际产品另有确定的次级排序规则,例如同分时按更新时间排序,评估该产品最终展示顺序也是另一项合理任务。此时需要把这个规则写进评价定义,不能把它和“只评价模型分数、均匀处理同分”悄悄混在一起。

本例只处理非负相关度,且至少一个相关度为正,使IDCG>0;全零相关度时比值会遇到0/0,需要另设约定,本程序直接拒绝。只把数值完全相等视为同分,不把“很接近”自动合并;没有随机抽样,也没有判断真实相关度标注质量。

记住这三点

  • 并列分数没有提供组内先后,任意破同分可能改变指标。
  • 对同分组求期望时,截断处也必须考虑整组成员。
  • 固定候选集合、gain、折扣、k与同分规则后,NDCG才有可比口径。

别把例子用过头

  • 把同分组截到k条以后才平均,遗漏原本可能进入前k名的候选。
  • 一处用线性gain,另一处用2^rel−1,却直接比较分数。
  • 把ignore_ties理解成无条件安全开关,或把本页输入顺序当作库的固定内部规则。

自己试一小步

三个候选全部同分时,NDCG@1应如何计算?

  1. 保持相关度3、1、0,把三条模型分数设成相同。
  2. 先枚举六种顺序,分别计算DCG@1,再求平均。
  3. 改成k=2,用组内平均gain乘折扣和独立核对。

做到这些,就算完成

  • IDCG使用同一候选集合和同一k。
  • 所有允许排列等权,不依据真实相关度偏向某个排列。
  • 若改变gain定义或同分策略,重新计算,不沿用旧数字。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • ndcg_score — scikit-learn 1.9 (新标签页)↗scikit-learn · 英文

    NDCG的理想分数归一化、k、ignore_ties假设与默认同分平均的例子。

    2026-10-26 实际打开正文并阅读:定义、k与ignore_ties参数、同分Examples。

  • dcg_score — scikit-learn 1.9 (新标签页)↗scikit-learn · 英文

    直接使用真实分数作为gain并做对数折扣,以及同分组平均贡献。

    2026-10-26 实际打开正文并阅读:定义、log_base与ignore_ties参数、同分Examples。

  • _ranking.py — scikit-learn 1.9.1 固定版本源码 (新标签页)↗scikit-learn 官方仓库 · 英文

    同分组平均gain乘对应位置折扣和;k之后折扣置零;理想DCG使用相同k。本文实现独立编写。

    2026-10-26 实际打开正文并阅读:_dcg_sample_scores、_tie_averaged_dcg及_ndcg_sample_scores函数。

  • McSherry 与 Najork:同分检索评价的解析平均(ECIR 2008) (新标签页)↗Microsoft Research / Frank McSherry 与 Marc Najork · 英文

    第2.6节说明同分组平均gain与位置折扣和的乘积,以及理想分数不随预测同分排列改变。

    2026-10-26 阅读8页PDF的文本提取,重点第1至3页的问题设定和第7页第2.6节。本机直接下载该PDF返回403;若打不开,可先读同列的scikit-learn文档与固定版本源码。没有运行作者的C#实现或复验论文性能实验。