开始前,会这些就够了
- 知道模型排序分数不等于人工相关度
- 能按给定顺序求和,并理解取前k条
- 先读 检索从三条加到十条,为什么还得重新评估?:先分清候选集合与截断位置k,固定评估对象。
- 先读 单独测量检索召回与排序质量:先读NDCG与理想排序;旧课使用2^rel−1增益,本篇线性gain的数值需单独计算。
遇到不熟悉的词,先看这 2 个解释
打开完整术语小抄 ↗同一查询有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 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
模型给了同分,评估应偷偷偏向谁?
正在准备实验控件…
只评一个查询的三条候选,线性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 不会删除这些检查。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""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应如何计算?
- 保持相关度3、1、0,把三条模型分数设成相同。
- 先枚举六种顺序,分别计算DCG@1,再求平均。
- 改成k=2,用组内平均gain乘折扣和独立核对。
做到这些,就算完成
- IDCG使用同一候选集合和同一k。
- 所有允许排列等权,不依据真实相关度偏向某个排列。
- 若改变gain定义或同分策略,重新计算,不沿用旧数字。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- ndcg_score — scikit-learn 1.9 (新标签页)↗
NDCG的理想分数归一化、k、
ignore_ties假设与默认同分平均的例子。 - dcg_score — scikit-learn 1.9 (新标签页)↗
直接使用真实分数作为gain并做对数折扣,以及同分组平均贡献。
- _ranking.py — scikit-learn 1.9.1 固定版本源码 (新标签页)↗
同分组平均gain乘对应位置折扣和;k之后折扣置零;理想DCG使用相同k。本文实现独立编写。
- McSherry 与 Najork:同分检索评价的解析平均(ECIR 2008) (新标签页)↗
第2.6节说明同分组平均gain与位置折扣和的乘积,以及理想分数不随预测同分排列改变。