十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

NDCG排序评估指标详解:公式、Python实现与推荐系统实战避坑指南

NDCG排序评估指标详解:公式、Python实现与推荐系统实战避坑指南 做推荐系统的朋友应该都有这种体会模型离线指标刷得飞高线上一旦不怎么涨第一个被怀疑的就是评估方式。我在做推荐系统排序模型的那段时间最常被问到的问题就是到底用什么指标才能比较真实地反映排序质量如果你也用 Python 做离线评估NDCG 一定绕不开。NDCG 全称是 Normalized Discounted Cumulative Gain中文一般叫归一化折损累计增益是推荐系统里头最常用的排序评价指标之一。这篇博客我就结合自己的实操经验把 NDCG 评价指标从公式到 Python 实现完整拆一遍顺便把那些容易踩的坑也一并说清楚。适合刚接触推荐系统评估的同学也适合已经写了不少评估代码但总觉得哪里不对的工程师。1. NDCG是什么为什么推荐系统离不开它1.1 排序模型评估的痛点只看命中不够推荐系统本质上是个排序问题。用户来了之后系统把候选物品按照模型预估的点击率、转化率或者综合分排个序再把排在前面的东西展示出去。于是我们评价推荐效果时光看“有没有推荐对”是不够的更要看“对的东西是不是排在前面”。举个例子有一个用户系统帮他找到了 10 个他可能喜欢的内容。如果这 10 个内容全部排在第二屏、第三屏用户根本看不见那系统做得再好也没有意义。传统分类指标比如精确率 Precision、召回率 Recall它们只关心你预测出来的集合中有多少是对的完全不关心对的东西在列表里的位置。可推荐系统的收益大头恰恰来自头部位置第一屏能不能命中直接决定了用户是否愿意继续刷下去。NDCG 就是为了解决这个问题设计的。它把“相关程度”和“排名位置”同时纳入计算给排名靠前的命中更高的分数。模型把相关物品排得越靠前NDCG 就越高排得越靠后即使最终都找出来了分数也会明显下降。这个性质让它特别适合推荐系统里“前几名决定体验”的场景。1.2 DCG、IDCG、NDCG 公式拆解我们先从最朴素的累计增益 CG 说起。假设模型给一个用户返回了一个长度为 n 的排序列表列表中第 i 个位置i 从 1 开始的物品真实相关度是 rel_i那么CG rel_1 rel_2 ... rel_n这个指标太粗糙了。它把所有位置的贡献一视同仁排在第 1 名和第 1000 名只要相关度一样分数就一样。实际场景不可能这样用户基本只会看前面几屏。于是有了折损累计增益 DCGDCG sum_{i1}^{n} rel_i / log2(i 1)这里分母的 log2(i 1) 就是位置衰减。i1 时衰减最小归一化后是 1i 越大分母越大贡献越小。这个逻辑很像搜索场景排在搜索结果第一页第一条的结果和排在第 5 页第 10 条的结果被用户点击到的概率天差地别。所以越靠后的位置越要打折。实际工程里也有用 log2(i1)、ln(i1) 或者直接除以 i 的核心思想都是“位置越深价值越低”差异不会特别大关键是衰减梯度要符合业务。还有一个常见变体是 DCG sum (2^rel_i - 1) / log2(i 1)。这个形式用指数放大了高相关度物品的收益。如果你的相关度是人写的评分比如 1 到 5 分用这个公式会更合适高分的价值会被放大。如果相关度只是 0/1 标签两种形式算出来的排序结论基本一致用哪个都行。我这篇博客后面的例子里统一用 rel_i / log2(i 1)因为好解释、手算方便。接下来是 IDCG。IDCG 是“理想情况下的 DCG”也就是把当前用户的真实相关度分数按从高到低排列得到一个理论上最优的排序再计算这个最优排序下的 DCG。IDCG 在同一个用户、同一个候选集下是一个固定值。最后NDCG DCG / IDCG这个归一化非常关键。不同用户的相关物品数量可能完全不一样有人可能只点了 1 个有人点了 50 个。直接用 DCG 比较两个用户没有意义因为分母规模差太多。NDCG 把每个用户的得分压到 0 到 1 之间1 代表模型排序和理想排序完全一致0 代表没有一个相关物品被排进来。这样跨用户求平均才合理。1.3 和 Precision、Recall、MAP 比一比为了更清楚 NDCG 的优势我列个表把常见指标放在一起对比指标核心关注点不擅长的地方PrecisionKTop K 里命中的比例不关心 Top K 内部顺序RecallK召回了多少相关物品不关心排序也没有位置折损MAP相关物品出现的位置只支持二元相关无法利用相关度强弱NDCG排名位置和相关度强弱需要定义 K对相关度分数取值敏感MAP 其实已经考虑了位置但它只能处理 0/1 相关。真实推荐场景里点击、收藏、评论、完播这些行为带来的相关度强度完全不同一个被收藏又看完了的视频和一个只被滑到的视频价值不应该一样。NDCG 天然可以接受连续分数这一点在做多目标推荐时很有用。我自己的习惯是同时上报 Recall20 和 NDCG10。Recall 负责看模型有没有能力把相关物品“捞”出来NDCG 负责看排得够不够好。如果 Recall 很高但 NDCG 很低说明模型要么把相关物品排到太后面了要么相关度估计出了问题。如果 NDCG 很高但 Recall 很低则可能是模型只在少数几个强信号上过拟合整体覆盖率不行。两个指标一起看定位问题会快很多。2. NDCG计算细节与Python实现从手算到批量2.1 手算一个NDCG弄清每个分母从哪来先来一次手算把公式彻底搞明白。假设某个用户一共有 5 个候选物品真实相关度分数分别是item_a3item_b1item_c0item_d2item_e1模型给出的预测排序结果是item_d - item_a - item_c - item_b - item_e也就是按模型预测的排序真实相关度数组是[2, 3, 0, 1, 1]那么 DCG5 2/log2(2) 3/log2(3) 0/log2(4) 1/log2(5) 1/log2(6)。算一下第1位2 / 1 2第2位3 / 1.585 1.893第3位0 / 2 0第4位1 / 2.322 0.431第5位1 / 2.585 0.387DCG 约等于 4.711。接下来算 IDCG。理想排序是把真实相关度从高到低排列也就是 [3, 2, 1, 1, 0]。IDCG 3/1 2/log2(3) 1/log2(4) 1/log2(5) 0。第1位3 / 1 3第2位2 / 1.585 1.262第3位1 / 2 0.5第4位1 / 2.322 0.431第5位0IDCG 约等于 5.193。所以 NDCG5 4.711 / 5.193 0.907。这个 0.907 已经很高了因为模型虽然没有把相关度最高的 item_a 放到第一位但是把相关度次高的 item_d 放到了第一位整体排序和理想排序非常接近。如果模型把 item_c相关度为0放到第一位NDCG 会立刻掉到很低这才符合我们对排序质量的直觉。2.2 最简Python实现DCG、IDCG、NDCG理解了公式以后Python 实现就很简单了。下面这份代码我平时会直接拿来当工具函数用清楚、好改。import math def dcg_at_k(relevance, k): relevance relevance[:k] if not relevance: return 0.0 return sum(rel / math.log2(idx 1) for idx, rel in enumerate(relevance, start1)) def idcg_at_k(relevance, k): ideal sorted(relevance, reverseTrue) return dcg_at_k(ideal, k) def ndcg_at_k(relevance, k): idcg idcg_at_k(relevance, k) if idcg 0: return 0.0 return dcg_at_k(relevance, k) / idcg这里唯一要注意的是 enumerate 的起始位置。enumerate(relevance, start1)会让第一个位置 idx1然后分母写成math.log2(idx 1)这样第 1 位分母是 log2(2)1第 2 位分母是 log2(3)和公式完全对应。如果写成了math.log2(idx)或者math.log2(idx - 1)结果都会错。用上面那个例子验证一下rel [2, 3, 0, 1, 1] print(dcg_at_k(rel, 5)) # 4.711... print(idcg_at_k(rel, 5)) # 5.192... print(ndcg_at_k(rel, 5)) # 0.907...这里输入的rel不是原始物品的任意相关度顺序而是“按照模型预测得分从高到低排序后对应的真实相关度序列”。这一步经常有人搞反后面实战部分我会再强调。2.3 批量化实现用NumPy处理全量用户真实评估场景里不可能每个用户都 for 循环手算数量一多就慢。所以实际工程里我一般用 NumPy 做批量计算。import numpy as np def ndcg_batch(true_rel, pred_scores, k10): true_rel: (n_users, n_items) 每个用户对每个候选物品的真实相关度 pred_scores: (n_users, n_items) 模型预测得分 if true_rel.shape ! pred_scores.shape: raise ValueError(true_rel 和 pred_scores 的 shape 必须一致) n_users, n_items true_rel.shape k min(k, n_items) # 每个用户按预测分从高到低排序取前 k 个位置的下标 ranked_idx np.argsort(-pred_scores, axis1)[:, :k] row_index np.arange(n_users)[:, None] gains true_rel[row_index, ranked_idx] # 位置折损rank 从 1 开始分母是 log2(rank1) denom np.log2(np.arange(2, k 2)) dcg np.sum(gains / denom, axis1) # 理想排序直接把真实相关度按从高到低排取前 k 个 ideal_gains np.sort(true_rel, axis1)[:, ::-1][:, :k] idcg np.sum(ideal_gains / denom, axis1) ndcg np.zeros(n_users, dtypefloat) mask idcg 0 ndcg[mask] dcg[mask] / idcg[mask] return ndcg.mean()这段代码背后的思路是每行是一个用户每个用户都有一组候选 item 的真实相关度同时模型对这些候选 item 预测了一个分数。argsort(-pred_scores, axis1)按预测分降序排列拿到每个用户的排序下标再去true_rel里取对应位置的真实相关度形成gains。后面的ndcg.mean()就是所有用户的平均 NDCG。这里denom np.log2(np.arange(2, k 2))对应的是 rank 1 到 k 的分母 log2(2)、log2(3)、...、log2(k1)千万不能写成np.arange(1, k 1)否则 rank 1 的分母会变成 log2(1)0整个数组出现无穷大。如果你不想自己维护这些细节也可以直接用 scikit-learnfrom sklearn.metrics import ndcg_score # y_true: (n_samples, n_items) # y_score: (n_samples, n_items) mean_ndcg ndcg_score(y_true, y_score, k10)但要特别注意版本差异。sklearn 不同版本对ndcg_score内部的增益函数定义不完全一样有的默认用指数增益2^rel - 1有的用线性增益。如果要保证实验口径完全可控我还是推荐自己实现一份或者在使用前先读一读当前版本的源码。3. 实战把NDCG接入推荐模型离线评估3.1 评估数据怎么构造测试集与候选集计算 NDCG 之前最麻烦的不是公式而是数据格式。很多人第一次写评估脚本直接拿测试集里“用户 正样本”算一遍就结束了这其实是有问题的。NDCG 需要有候选集你不能只告诉模型“这个用户喜欢 item1”你得同时给模型一些“用户可能不喜欢或者不相关”的干扰项让模型去排序。我一般这样构造离线评估数据把时间轴切开前 30 天做训练后面 7 天做测试防止时间穿越。测试期每个用户选出若干正样本比如点击、完播、加购物品各抽一些。对每个正样本随机采样一批负样本负样本数量可以按业务来常见的比例是 1 条正样本配 10 到 100 条负样本。把正样本和负样本混在一起交给模型打分得到每个物品的预测分。按预测分排序得到该用户的一个候选排序列表。评估时这个排序列表后面要跟一个真实相关度序列。也就是说用户测试集里如果有一个物品真实相关度是 3另一个是 0那排序后得到的就是类似[3, 0, 0, 1, 0]这样的一条向量。这个向量就是ndcg_at_k函数的输入。还需要提醒一点如果一次实验里每个用户只有一个正样本剩余全是负样本那么 NDCGK 实际上只和一个数字有关——正样本在预测列表里的排名。因为 IDCG 此时恒等于 1NDCG 就等于 1 / log2(rank 1)。这种情况下 NDCG 能看但信息量不大建议再配合 RecallK 一起看。3.2 完整评估脚本与结果解读假设我们有一份测试数据表test_data字段是user_id, item_id, label模型打分表score_data字段是user_id, item_id, score。下面这个脚本可以直接用import numpy as np import pandas as pd def evaluate_ndcg(test_data, score_data, k10): df test_data.merge(score_data, on[user_id, item_id]) df df.sort_values([user_id, score], ascending[True, False]) ndcg_values [] for user_id, group in df.groupby(user_id): rel group[label].values[:k] ideal np.sort(rel)[::-1][:k] n len(rel) denom np.log2(np.arange(2, n 2)) dcg np.sum(rel / denom) idcg np.sum(ideal / denom) ndcg_values.append(dcg / idcg if idcg 0 else 0.0) return np.mean(ndcg_values) # 使用示例 ndcg evaluate_ndcg(test_data, score_data, k10) print(fNDCG10: {ndcg:.4f})这段代码核心逻辑就三步合并数据、按用户分组排序、计算每个用户的 NDCG 后取平均。如果样本量特别大groupby会很慢我一般会先用cumcount()给每个用户内部的物品编上排名然后只保留排名小于等于 k 的行再把这部分数据转成 NumPy 操作速度能提升好几倍。算出 NDCG 之后建议不要只看一个数字。我一般会把模型 A 和模型 B 的结果放到一起模型Recall20NDCG10基线模型0.7310.512新模型0.7560.547如果新模型 NDCG 一致提升同时 Recall 也持平或上涨这种情况下通常可以比较放心地上线。如果出现 NDCG 上涨但 Recall 下跌要警惕模型只是把已有相关物品排得更靠前却漏掉了很多长尾相关物品整体召回能力反而下降了。3.3 K值选择和多用户平均的正确姿势K 值到底取多少第一原则是看产品形态。如果你的推荐位每屏 5 个重点就放在 NDCG5首页信息流每屏能露出 10 个到 20 个可以用 NDCG10 或 NDCG20搜索场景常常看 NDCG3 或 NDCG5因为用户基本只看前几个结果。K 值过大会稀释头部位置差异。比如 NDCG50可能排在第 1 名和第 30 名的差距在分母折损下已经不明显了模型只要整体差不多分数就差不多。但很多业务里第 1 名和第 30 名的商业价值差了不止一个量级。所以不要盲目追求大 K。还有一个非常隐蔽的坑K 值截断的位置到底是在“预测排序后”截断还是在“计算理想排序前”也截断正确的做法是先对全部候选计算真实相关度序列取预测排序的前 K 个作为 DCG 输入IDCG 则基于全部候选的真实相关度排序后取前 K 个。如果你先把预测结果截断成 top K再在这个截断后的集合里计算 IDCG是有问题的。比如某个用户的相关物品集中在第 30 名之后你只截断了前 10 名那这些相关物品直接被排除掉了IDCG 会偏低NDCG 反而不合理地虚高。多用户平均时也有讲究。直接对每个用户的 NDCG 求算术平均是最常见的做法。但这个平均每个用户的权重一样如果测试用户里有大量低活跃用户结果会被他们主导。更稳妥的做法是同时算一个“按用户活跃度加权”的版本或者按用户冷热分层之后分别看 NDCG。做推荐实验时活跃用户和非活跃用户的排序质量往往差异巨大混在一起评估经常掩盖问题。4. NDCG实战中那些坑以及排查心法4.1 相关分数粒度不要盲目套用公式计算 NDCG 时rel_i 到底填什么直接决定指标的含义。最常见的三种做法0/1 标签点击了就是 1没点击就是 0。简单但把所有相关行为等同看待。行为等级曝光、点击、收藏、分享、购买分别映射成 1、2、3、4、5。能区分行为强度。连续值比如用播放时长除以某个基准值或者用 GMV 金额归一化。适合商业目标明确的场景。理论上 NDCG 支持所有这些但要注意增益函数的选择。如果用 2^rel - 1 这个形式每个等级的差距会被指数放大。rel 从 4 到 5 带来的增益变化远大于 rel 从 1 到 2。如果你的 rel 是行为等级这个放大有可能是故意的也有可能是过度放大需要结合业务判断。最怕的是自己没意识到用的是哪种公式拿和别人不同的口径去对比结果两个实验根本没对齐。我自己在视频推荐场景中踩过坑用播放时长归一化后的连续 rel 做 NDCG结果模型老是倾向于把长视频推到前面因为长视频的 rel 天然就大。后来改成“完播率相关分数 指数增益”才缓解。所以相关分数怎么构造一定要回到评测目标本身。4.2 除零、截断、列表不足K的边界情况写评估脚本时最容易翻车的其实是小边界。我列一个常见问题速查表情况现象处理方式某个用户候选不足 K 个分母长度不够按实际长度计算不要强行补 0某个用户没有任何相关物品IDCG0NDCG 设为 0或直接跳过该用户某个用户全部物品都相关IDCGDCGNDCG1预测得分全一样排序退化成随机序先检查模型是否异常相关度全是 0DCG0IDCG0同上NDCG 置 0除零问题永远排在第一位。很多自己实现的 NDCG 代码在 IDCG0 时会报错或者产生 NaN后面mean()一算就全部变 NaN整个实验报告就废了。我在ndcg_at_k里特意加了if idcg 0: return 0.0就是为了防这个。候选不足 K 的问题也很常见。比如用户只有一个候选rel[1]denom 应该就是log2(2)算出来 NDCG1。如果你不去动态调整分母长度而是硬套一个固定长度的np.arange(2, k2)就会数组长度不匹配不是报错就是算错。列表不足 K 时更新k min(k, n_items)是必须的。还有一个容易忽略的点模型输出的分数如果有很多 NaN 或者重复值argsort(-pred_scores)的结果可能不稳定。建议在评估前清洗一遍确保每条 user-item 的分数都是有效数值。重复值多的时候不同批次跑出来的排序会有一点点随机性NDCG 也会跟着轻微抖动。4.3 稀疏场景NDCG抖动大怎么办冷启动用户、新上线的物品、由负采样构造的评估集这些场景里每个用户的正样本非常少NDCG 的波动会大得离谱。比如某个用户只有一个正样本模型稍微调整一下打分正样本排名从第 2 变成第 5NDCG 可能从 0.63 掉到 0.21单看一个用户毫无意义。我一般用两种办法解决第一种是按用户分层做平均。把用户按活跃度分成高、中、低三档分别计算每档的平均 NDCG。这样能看到模型改进是均匀受益还是只在高活跃用户上受益。冷启动问题上重点盯低活跃档位。第二种是给 NDCG 算置信区间。最简单的就是 Bootstrap 重采样import numpy as np # ndcg_scores 是每个用户算出来的 NDCGshape (n_users,) rng np.random.default_rng(42) boot_means [] for _ in range(1000): sample rng.integers(0, len(ndcg_scores), sizelen(ndcg_scores)) boot_means.append(ndcg_scores[sample].mean()) lower np.percentile(boot_means, 2.5) upper np.percentile(boot_means, 97.5) print(fNDCG10 {np.mean(ndcg_scores):.4f}, 95% CI: [{lower:.4f}, {upper:.4f}])如果两个模型的置信区间重叠很多即使平均值高了一点点也不要急着下结论。我在实际实验里经常看到新模型 NDCG 平均提升 0.002但置信区间宽达 0.01这种差异基本就是随机噪声。加了 Bootstrap 之后就不会被日常实验里的微小波动骗了。4.4 离线NDCG和线上指标为什么对不上这个问题几乎每个团队都会遇到。离线 NDCG 明明涨了线上点击率、时长、留存却没动静。原因通常藏在评估链路和数据偏差里。最典型的是采样偏差。离线评估用的用户行为数据本身是“被曝光过的数据”也就是说用户只看到了系统推荐的那些 item没有看到的 item 用户根本没有机会点击。用这种数据构造正负样本模型会学到“系统当时展示出来的 item 更容易被点击”这层偏差而不是真正的用户偏好。NDCG 在这样偏的测试集上再准也只是相对排序质量的提升线上完全可能不涨。第二个常见原因是离线评估没有完整复现线上排序环境。线上有实时特征、上下文信息、多样性控制、去重规则这些在离线评估里常常被忽略。NDCG 只能度量你送进去的那批候选的排序质量度量不了整个推荐链路的问题。所以我现在看到一份实验报告不会单看 NDCG 数字大小而是先确认评估数据怎么生成的、候选集怎么采样的、K 取多少、相关度怎么定义。这几个条件不同NDCG 之间根本没有可比性。与其问“为什么离线 NDCG 涨了线上不涨”不如先问“离线评估流程到底是不是在模拟线上真实排序任务”。最后再分享一个我坚持了很久的习惯每次实验评估我会同时输出整体 NDCG10、活跃用户 NDCG10、冷启动用户 NDCG10 这三行数字。如果整体涨了但冷启动掉得厉害这个模型我是不敢直接上的如果三个数字都涨哪怕幅度不大我也会更愿意去做线上小流量实验。评估指标的意义不是写进文档里的一个平均数而是帮我们快速判断模型“到底变好在哪里、变差在哪里”。NDCG 的 Python 实现本身不复杂想清楚数据口径和细节它就能帮你在各种排序模型之间做靠谱的判断。
返回列表