这两年我一直在和排序模型打交道,无论搜索还是推荐,离线评估都避不开一个指标:NDCG,也就是归一化折损累积增益(Normalized Discounted Cumulative Gain)。刚开始我只会在评测脚本里调一个现成函数,觉得它无非就是按位置打个折、再归一化一下,直到有一次我用Precision评估线上实验被狠狠教育了一顿,才真正理解这个指标背后每一步设计都有原因。
那一次我在做商品搜索排序,离线Precision@5从0.42提到0.50,模型顺利发版。结果AB实验点击率几乎没有变化,收藏率甚至还掉了一点。我拉出case对比才发现,模型确实把更多“相关”商品带进了前5,但用户最可能下单的那个爆款,被我从前3挤到了第5位。Precision这种只看集合不看位置的指标,完全抓不到这种退化。从那之后,凡是用到排序位置价值的评估,我都默认用NDCG,也把它的计算和实现吃透了。这篇文章把我手算、编码、踩坑的完整过程整理出来,适合正在做搜索排序、推荐召回或学习评估指标的同学参考。
1. 为什么我用NDCG评估排序效果:那些只看“有没有”的指标会骗人
1.1 一次让我改变评估方式的线上实验
先回到Precision和Recall这两个老朋友。Precision@5指的是前5个结果里相关结果占多少,Recall@5指的是前5个结果捞到了多少相关性总量的比例。它们有一个共同问题:对位置完全不敏感。
我举个简化但很典型的例子。假设两个排序模型都返回5个结果,人工标注只有“相关”和“不相关”两类:
- 模型A:相关,相关,不相关,不相关,不相关
- 模型B:不相关,不相关,相关,相关,不相关
这两个模型的Precision@5都是2/5=0.4,Recall@5(假设相关文档一共3个,包括一个没排进前5的)都为2/3,从这两个指标看完全一样。但在真实用户面前,模型A明显远好于模型B,因为用户大部分注意力集中在第一屏的前几个位置。把最相关的东西从第1位压到第3位、第4位,曝光和点击的损失可能翻倍。
在线指标里更明显。同一件商品放在搜索结果的第1位和第5位,CTR可能差出30%甚至更多。位置本身就是一种稀缺资源,排序系统的优化目标天然带着“位置权重”。所以评估排序质量时,只看“前K个里有没有”,会漏掉最关键的排序信息。
1.2 排序质量到底差在哪里
我们需要一个能量化“好结果排得多靠前”的指标。它至少应该满足三个要求:
- 对位置敏感,越靠前贡献越大。
- 能处理多档相关性,而不只是“相关/不相关”二分类。
- 不同查询之间能比较,不会因为查询本身的结果数量差异导致数值失真。
NDCG(归一化折损累积增益)正好把这三件事全做了。它的名字已经把三个动作说清楚了:累积增益算总量,折损体现位置衰减,归一化让不同查询有可比性。接下来我从最原始的CG开始,一层一层把这个指标“组装”出来。
2. NDCG的四层推导:CG、DCG、IDCG、NDCG到底在算什么
2.1 CG:第一层,只数答案不看位置
累计增益(Cumulative Gain,CG)是所有结果相关性分数的直接加和。假设一个查询返回了K个文档,每个文档有一个人工标注的相关性分rel_i,那么:
CG@K = rel_1 + rel_2 + ... + rel_K
它把“全部结果的相关性总量”算出来了,但完全不理会谁先谁后。把最好的文档放在末尾,CG也不会变。所以CG只适合作为数学起点,不适合当排序评估指标。
2.2 DCG:第二层,用折损给“排得靠后”扣分
折损累计增益(Discounted Cumulative Gain,DCG)在CG基础上引入了一个位置折损项:越靠后的结果,增益被除以一个更大的数。业界最常用的是log折损:
DCG@K = rel_1 + rel_2 / log2(3) + rel_3 / log2(4) + ... + rel_K / log2(K+1)
更紧凑的写法是:
DCG@K = Σ (rel_i / log2(i + 1)),i从1到K
为什么用log2而不是直接除以位置i?原因在于用户注意力的衰减不是线性的。头部几个位置差距很大,第1位和第2位差别明显,但随着位置后移,每往后一位的额外损失逐渐变小。log2的增长正好是“先陡后平”,模拟这种注意力衰减更贴近真实浏览行为。位置1折损项为log2(2)=1,结果原封不动;位置2折损为log2(3)≈1.585;位置3折损为2;位置10折损为log2(11)≈3.46。越往后每移动一位带来的相对分数变化越小。
此外还有一种增强版本,把相关性分数先做指数变换再加折损:
DCG@K = Σ ((2 ^ rel_i - 1) / log2(i + 1))
这个版本把“高相关性文档”的差距放大。相关性分从2变成3时,gain从3变成7,一下拉开4个单位的差距;而从1变成2时,gain从1变成3,只差2个单位。这背后是搜索/推荐场景中普遍存在的“赢家通吃”现象:用户往往只需要那个完美的结果,二三名半好半坏的显然不如直接给他最好的那个有价值。指数增益能把这种差异刻画得更清楚。
2.3 IDCG和NDCG:用“最好情况”做尺子
DCG的绝对值会受结果长度、标注尺度影响。同样是DCG=5,一个查询可能所有结果都打3分,另一个查询可能只有一两个结果相关,两者没法直接比。于是引入IDCG(Ideal DCG,理想DCG):把当前查询的所有结果按相关性从高到低排序,然后用这个理想顺序重新计算DCG。
IDCG@K = 理想排序下的DCG@K
NDCG就是实际DCG占理想DCG的比例:
NDCG@K = DCG@K / IDCG@K
因为实际排序在任何情况下都不可能比理想排序更好,所以NDCG的取值落在0到1之间。1表示当前model给出的排序正好和理想排序一致,越接近0表示排序质量越差。每个查询都用自己的IDCG做分母,相当于“同一把尺子量不同长度”,跨查询平均也就变得有意义。
3. 一份手算全过程:从标好相关性的列表算到NDCG数值
3.1 样例数据与公式约定
这一节我完整走一遍手算过程,用最简单的5个文档示例。假设某个搜索query返回了5篇文档,人工标注使用0到4的分级相关性分数,4代表完美匹配,0代表完全不相关。模型预测出的排序顺序对应的真实标签如下:
| 排序位置 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
| 预测排序后相关性rel | 2 | 3 | 1 | 0 | 2 |
注意,这里的3分文档被模型排到了第2位,而2分的文档反而占了第1位。理想情况我们当然希望3分排最前。下面用指数增益版本计算:
gain = 2 ^ rel - 1
折损系数 = log2(位置 + 1)
3.2 逐位计算当前排序的DCG
我按位置逐项拆开算,每一步都列出折损系数,方便你复核。
- 位置1:rel=2,gain=2^2-1=3,折损=log2(1+1)=1,贡献=3/1=3.000
- 位置2:rel=3,gain=2^3-1=7,折损=log2(2+1)=1.585,贡献=7/1.585=4.417
- 位置3:rel=1,gain=2^1-1=1,折损=log2(3+1)=2,贡献=1/2=0.500
- 位置4:rel=0,gain=2^0-1=0,折损=log2(4+1)=2.322,贡献=0
- 位置5:rel=2,gain=2^2-1=3,折损=log2(5+1)=2.585,贡献=3/2.585=1.161
把五项加起来:
DCG@5 = 3.000 + 4.417 + 0.500 + 0 + 1.161 = 9.077
3.3 构造理想排序并计算IDCG
把所有标注分从高到低排:
3,2,2,1,0
这就是理想顺序。重新计算IDCG:
- 位置1:rel=3,gain=7,折损=1,贡献=7.000
- 位置2:rel=2,gain=3,折损=1.585,贡献=1.893
- 位置3:rel=2,gain=3,折损=2,贡献=1.500
- 位置4:rel=1,gain=1,折损=2.322,贡献=0.431
- 位置5:rel=0,gain=0,折损=2.585,贡献=0
IDCG@5 = 7.000 + 1.893 + 1.500 + 0.431 = 10.824
当前排序的NDCG@5:
NDCG@5 = 9.077 / 10.824 ≈ 0.839
这个0.839不算差,但仍然损失了约16%的排序质量,主要损失是3分文档被放到了第2位而不是第1位,以及一个2分文档被压到了第5位。
3.4 三种排序的NDCG对照
为了让“排序质量差异”直观可见,我把同一个标注集合下三种排序都算一遍:
| 排序方案 | 位置1到5标签 | DCG@5 | IDCG@5 | NDCG@5 |
|---|---|---|---|---|
| 理想排序 | 3, 2, 2, 1, 0 | 10.824 | 10.824 | 1.000 |
| 当前模型排序 | 2, 3, 1, 0, 2 | 9.077 | 10.824 | 0.839 |
| 最差排序(完全倒序) | 0, 1, 2, 2, 3 | 6.132 | 10.824 | 0.567 |
最差排序的DCG计算值得展开看一眼:位置1是0分,贡献0;位置2是1分,贡献1/1.585≈0.631;位置3是2分,贡献3/2=1.5;位置4是2分,贡献3/2.322≈1.292;位置5是3分,贡献7/2.585≈2.709。合计6.132,NDCG为0.567。也就是说,哪怕完全排反,NDCG也不会降到0,因为后面的结果仍然有折损后的增益。这个性质是正常的,别被“归一到0-1”误导成“最差一定是0”。
还有一个容易混淆的细节:如果用线性增益公式DCG=Σ rel_i/log2(i+1),上面这个例子的NDCG@5会变成0.908而不是0.839。同一份数据、同一个排序,只因公式选型不同,数值就有明显差别。二值标注(只有0和1)时两种公式完全等价,因为2^1-1=1;但多级标注时差别就出来了。所以看论文或对比实验结果,一定要先确认对方用的是指数增益还是线性增益,否则对比的基准根本不在一个坐标系里。
4. 可以直接拿去用的Python实现:从零实现到处理边界情况
4.1 一份10行的numpy实现
手算只能做小样例,实际评估动辄几千上万条query,必须写成代码。我经常用下面这个版本,逻辑清晰又短,适合直接粘贴到评测脚本里:
import numpy as np def _dcg(rels, gain="exp"): if len(rels) == 0: return 0.0 rels = np.asarray(rels, dtype=float) if gain == "exp": gains = 2.0 ** rels - 1.0 else: gains = rels discounts = np.log2(np.arange(1, len(rels) + 1) + 1.0) return float(np.sum(gains / discounts)) def ndcg_at_k(y_true, y_score, k=10, gain="exp"): k = min(k, len(y_true)) if k <= 0: return 0.0 order = np.argsort(-np.asarray(y_score))[:k] pred_rels = np.asarray(y_true)[order] dcg = _dcg(pred_rels, gain) ideal_rels = np.sort(np.asarray(y_true))[::-1][:k] idcg = _dcg(ideal_rels, gain) if idcg == 0.0: return 0.0 return dcg / idcg这里有两个容易写错的地方。第一,discounts用np.arange(1, len+1)+1,等于从2递增,对应log2(2)=1、log2(3)=1.585,正好和“位置从1开始”的约定对齐。如果你从位置0开始,分母就变成log2(i+1)中的i从0起(即log2(1)=0,需要额外处理),开源实现里两种下标都有,务必统一。第二,argsort默认升序,取前K个最高分要加负号,写成np.argsort(-score)。我一开始漏了负号,差点把低分结果当成高分排到前面。
4.2 验证:跑出来和手算一致
用刚才的例子验证:
y_true = np.array([2, 3, 1, 0, 2]) y_score = np.array([0.9, 0.8, 0.7, 0.6, 0.5]) print(ndcg_at_k(y_true, y_score, k=5)) # 约 0.83866注意这里y_score是我随意造的,它只是定义“模型把文档排出什么顺序”,对应的顺序正好是[0, 1, 2, 3, 4],标签依次为[2, 3, 1, 0, 2],和手算例子完全一致,输出约0.839。学这一套时,强烈建议先用一个能手算的小例子跑通,再应用到自己的数据上,否则代码里的索引错误很难发现。
4.3 不得不处理的边界情况
上线评估时,真实数据远比教科书例子脏,我遇到过下面几类情况。
第一,所有标注都为0。此时IDCG=0,公式分母为0,直接返回0.0。别抛异常,也别给一个很大的数,否则后续分组平均会把整体指标拉坏。
第二,k大于结果列表长度。例如用户搜“空字符串”,推荐池里只有3个结果,你却要NDCG@10。实现里min(k, len)已经截断,但你要意识到:用NDCG@10去平均长短不一的query,短query等于被强制按全部长度计算,和长query的“前10个截断”口径不同。这个问题我在下一节详细说。
第三,评分并列(tie)。真实模型的分数经常出现大段0.0或0.99,比如未登录商品打分全为0。np.argsort默认是稳定排序吗?不一定,不同场景行为可能不同。如果并列严重,建议直接按相关性排序辅助打破平局,或者在评估脚本里固定使用一种稳定排序(np.argsort(kind="stable")),并在实验记录中写明。评估指标最怕“隐式随机”,今天结果573,明天重跑变成581,差就差在平局处理上。
第四,空列表。某个query一条结果都没返回时,NDCG直接定0。要不要剔除这类样本?我的习惯是单独统计“无结果率”,NDCG平均值保留它们,因为真实系统里无结果也是一种用户体验。
另外提一嘴现成工具。scikit-learn的sklearn.metrics.ndcg_score输入是二维矩阵,shape为(样本数, 文档数),而且默认按指数增益计算。pytrec_eval则是信息检索社区常用的TREC格式评测工具,支持bpref、ERR等其他指标。用它们没问题,但一定要先在小样例上确认增益模式和截断逻辑,再大规模跑数。
5. 实际使用中踩过的坑:这些细节在教科书里找不到
5.1 截断位置@K:不要随手填,先想清楚业务边界
NDCG@K里的K不是随便取的。搜索场景常用@5或@10,因为用户主要看前几屏;推荐流场景可能用@20甚至更长。关键是全链路要统一:你的排序模型只负责重排前50个候选,评估却用NDCG@100,那第60到第100位全是填充的低质结果,只会把分数拉低,并且不能反映真实切段后的效果。我习惯先统计线上曝光位置分布,选一个“覆盖80%有效曝光”的位置作为K,比跟风用K=10靠谱得多。
还要注意分组对齐问题。一个数据集里,不同query返回的候选数量经常不同(有的20个有的200个)。如果统一算NDCG@20,短query没有20个结果,长query截断到20个,两者口径已经不一致。业内常见的做法是:
- 如果业务关注头部,统一用NDCG@K,并保证所有query的截断长度都大于K。
- 如果业务关注全量排序质量,直接用无截断版本(k=len),但此时长query天然占便宜。
- 最稳妥的做法是把按query长度分层统计,分别看1-10个结果、11-50个结果、50以上这三组的NDCG,能快速发现模型在哪类请求上失效。
5.2 标注口径:0/1还是0-4,直接影响你的结论
NDCG依赖相关性标注。工业界常用0-4五级,学术标准如TREC用相关/不相关两类。0/1标注下指数增益和线性增益等价,模型只要把“相关”往前提就行;0-4标注下,指数增益会让“完美结果”的权重远大于“部分相关”,模型会更愿意把排序收益压在少数高质量item上。
这会导致一个微妙问题:如果你的标注一致性不够稳定(两个标注员一个给2分一个给3分),指数增益会放大标注噪声。我在一个项目里因为中间换了一批标注供应商,同样的模型NDCG从0.72掉到0.65,排查半天发现不是模型变了,是标注尺子变了。所以评估报告里必须固定标注指南,并在每次换标注团队后抽样做一致性检验,否则前后对比全是幻觉。
5.3 离线评估和模型训练目标的一致性
这是我最想强调的一点。如果你用NDCG来评估模型,却用普通pointwise回归/分类loss来训练,两者之间是脱节的。Pointwise方式优化的是“每个样本预测值接近标注”,它不关心文档之间的相对顺序;而NDCG衡量的是“按预测分数排序后,好结果是否更靠前”。训练目标与评估指标的不一致,会导致离线指标涨了但排序体验没变,甚至出现倒挂。
实践中的做法大致有三条路:
- 换用listwise损失,直接以排序质量为目标训练,比如LambdaRank、ListNet等。
- 用pairwise损失(如RankNet),把“正样本是否排在负样本前面”作为优化目标,间接匹配排序指标。
- 如果只能用pointwise结构,可以在预测层之后加一个排序后处理(比如另训一个轻量reranker),或者用learning rate、特征组合等方式弥补。
我自己做过一个对比实验:同样的特征和样本,pointwise模型NDCG@10=0.62,换LambdaRank后到了0.67。原因很清楚,前者把高相关性分压到所有样本的回归任务里,后者直接站在“位置折损”的角度优化。所以说,用NDCG做评估,就得认真考虑训练目标是否也长在“位置”上。
5.4 关于IDCG分布的一个小技巧
最后分享一个排查小技巧:跑完NDCG后,别只看平均值,把每条query的IDCG值打印出来看分布。如果大量query的IDCG都很低(比如在0到1之间),说明这些query的相关结果只有一两个,NDCG很容易算到0.9以上,整体均值虚高且区分度极差。这时候有两个方向:
- 提高标注尺度,让0-4之间的差异变大;
- 换更敏感的指标,比如ERR(Expected Reciprocal Rank)这类更强调绝对头部的度量。
反过来,如果IDCG普遍很高,NDCG均值却在0.5以下,说明模型排序能力确实弱,优先查特征、样本或训练目标,而不是继续调K和公式。
我实际做排序评估这两年的体会是,NDCG不是一个需要背公式的教条,而是一把能精确度量“结果排得多好”的尺子。把它的手算底子打牢,代码写好边界,业务口径统一,它就能稳定地帮你发现模型能力的变化。下次再遇到“离线涨了在线不涨”的怪事,不妨先跑到标注分布和IDCG分布里看一眼,答案往往就藏在指标的构造细节里。