模型迭代了两个月离线 AUC 从 0.782 爬到 0.795我信心满满地把模型推上了线。结果三天后大盘点击率跌了 1.8%人均停留掉了 6%。后来复盘才发现问题不在模型本身而在我们用来做决策的那几个推荐算法指标——离线评估用的负样本构造方式和线上真实曝光分布完全不是一回事AUC 涨的那点收益全被偏差吃掉了。这件事之后我养成了一个习惯每次模型迭代先坐下来把评估口径和指标含义重新过一遍再谈效果。今天就把我这些年攒下来的指标笔记整理出来涵盖 ACC、Precision、Recall、F1、FPR、TPR、ROC、AUC、MAP、MRR、HR、NDCG 这一整套常用的评估量。不管你是刚入行做召回、做粗排精排还是已经带团队在盯大盘这篇都能当个手册用——每个指标我会说清楚它衡量什么、什么时候会骗人、怎么算、算的时候踩过哪些坑。1. 先把指标地图画出来推荐系统到底在衡量什么1.1 三个层次的指标不能混着用推荐系统的指标其实分三层很多人一开始就搞混了。第一层是分类层代表是 ACC、Precision、Recall、F1、AUC、LogLoss。这一层把推荐问题当成用户会不会点的二分类问题衡量的是模型对单个物品的打分准不准。它的特点是只看单个样本的预测结果完全不关心物品之间的相对顺序。第二层是排序层代表是 HRK、MRR、MAP、NDCG。这一层衡量的是给定一个用户模型排出来的前 K 个物品质量如何。这里位置极其重要——第一个位置和第十个位置命中同一个物品业务价值差好几倍排序层指标必须把这个差异体现出来。第三层是业务层CTR、人均曝光数、停留时长、转化率、GMV。这是最终说话的指标但它受产品改版、运营活动、季节波动影响极大迭代周期长不适合用来快速验证模型。为什么要分清楚因为不同层次指标之间经常是矛盾的。分类层 AUC 涨了排序层 NDCG 可能跌排序层 NDCG 涨了业务层 CTR 也可能跌——因为 NDCG 里隐含的相关性定义未必等于用户真实偏好而且重排阶段还有去重、多样性、打散等约束。我一般这么用分类层指标用于训练阶段的快速迭代和 debug排序层指标用于模型选型业务层指标用于最终上线决策。三层都得看但看的时机不同。1.2 选指标之前先回答三个问题在写任何评估代码之前我会逼自己回答三个问题回答不出来就说明还没想清楚。第一个问题我评估的是哪个阶段召回阶段的候选集动辄几万用户真实交互只有几个评估时通常用全量候选或者固定数量的负采样精排阶段候选只有几百负样本更接近真实曝光分布。同样是 Recall召回阶段算出来的 0.3 和精排阶段的 0.3 完全不可比。第二个问题我的目标偏向多召回还是少打扰首页信息流的推荐用户耐心有限误推一个不相关的视频会直接影响体验这时候 Precision 权重要高而猜你喜欢这种入口用户本来就有探索意愿多推几个不相关的代价小Recall 更关键。这个偏向直接决定了你该用 F1 还是 F0.5该用什么阈值。第三个问题评估数据的时间和分布对不对用随机切分做时序推荐特征里混进了未来信息指标能虚高十几个点这种坑后面第 5 章会细说。这三个问题定下来后面的指标选择基本就顺理成章了。2. 从混淆矩阵出发ACC、Precision、Recall 与 F12.1 混淆矩阵是所有指标的祖宗分类层的所有指标追根溯源都是从一个 2×2 的混淆矩阵里抠出来的。设正类为用户点击/购买负类为未交互预测为正预测为负实际为正TP真阳FN假阴漏推实际为负FP假阳误推TN真阴围绕这四个格子Accuracy (TP TN) / (TP TN FP FN)Precision TP / (TP FP)Recall TP / (TP FN)FPR FP / (FP TN)TPR TP / (TP FN)注意最后两行TPR 在数值上完全等于 Recall只是在不同语境下的叫法。TPR/FPR 这套命名主要出现在 ROC 的讨论里搞混这一点的人不在少数。实际工程里还有个细节TP/FP 的判定依赖一个阈值。模型输出的是连续分数你要先定一个阈值把它切成 0/1才有混淆矩阵。阈值一变四个格子全变所有指标跟着变。这就是为什么我的 Precision 是 0.6这句话本身没有意义——你得说清楚阈值取了多少。2.2 Accuracy 为什么在推荐里基本不能看ACC 是最直观的指标也是最容易骗人的。推荐场景的正样本比例通常极低信息流点击率 5% 上下电商转化率常常不到 1%广告点击率甚至只有千分之几。假设一个 1000 条曝光的数据集只有 20 条真实点击。如果一个模型把所有样本都预测为负它的 Accuracy 是 980/1000 98%。这个98% 准确率的模型在线上是灾难因为它一个用户兴趣都没抓到。所以我在推荐场景里几乎不看 ACC。它只在正负样本接近均衡、且两类错误的代价大致相当的场景下才有一点点参考价值。非要用的话至少搭配一个基线对比全预测为负的 ACC 是多少你的模型比它高多少高不出几个点说明模型没学到东西。注意面试或者汇报里如果有人只报 ACC基本可以判断他没有真正做过推荐。2.3 Precision 与 Recall 的跷跷板这两个指标的关系讲道理不如举个例子。假设模型给 1000 个候选物品打分按分数从高到低排。现在我从头开始截取截取数量TPFPPrecisionRecall前 201280.600.40前 5020300.400.67前 10026740.260.87前 300302700.101.00看得非常清楚截得越多Recall 单调上升直至 1.0Precision 单调下降。这不是模型变差了而是评估口径变了。Precision 和 Recall 单独报一个都是耍流氓必须成对出现或者说清楚截断位置。在业务上怎么选我的经验是看用户的容错成本。召回层候选库几万几十万下游还有精排兜底宁滥勿缺偏向 Recall。这个阶段我们经常看 Recall500 甚至 Recall1000。精排层直接决定曝光用户一眼就能看到误推的代价高偏向 Precision。看 Precision10 这种小截断更贴近真实体验。消息推送、短信营销打扰成本极高一次推错可能直接导致卸载Precision 权重压到最高。2.4 F1 与它的几个变体要在 Precision 和 Recall 之间找一个平衡点最常用的就是 F1F1 2 × P × R / (P R)它的本质是 Precision 和 Recall 的调和平均而不是算术平均。这个选择很关键调和平均对较小值更敏感。P1.0、R0.1 时算术平均是 0.55 看着还行调和平均只有 0.18直接把偏科暴露出来。这正是我们想要的效果——推荐系统最怕的就是某一项低到不可用。如果业务上对某一侧有明确偏好用 F_betaF_beta (1 β²) × P × R / (β² × P R)规律是β 1 时 Recall 权重更大β 1 时 Precision 权重更大。常用的几个指标β偏向典型场景F0.50.5Precision推送、广告、首屏F11.0均衡通用评估F22.0Recall召回层、候选生成2.5 一个容易懵的现象Precision 和 Recall 数值相同意味着什么有次同事跑出 P R 0.75第一反应是模型很均衡。其实这个等式背后藏着一个很强的约束。P R 意味着 TP/(TPFP) TP/(TPFN)。两边交叉相乘TP×(TPFN) TP×(TPFP)只要 TP ≠ 0就能约掉 TC得到FN FP。也就是说漏掉的正样本数量恰好等于误判为正的负样本数量。这两个值在真实数据上完全相等纯属巧合的可能性极低。所以遇到 P ≈ R 时我会立刻去查三件事数据集是不是做了 1:1 正负样本均衡均衡采样之后如果模型输出又恰好在某个阈值上把预测正例的个数压得和真实正例数接近就很容易出现 FP FN。阈值是不是被反推出来的有些评估脚本会用让 P R来求阈值这个等值就是被构造出来的。样本量是不是很小几十个样本时FP 和 FN 凑巧相等并不稀奇。这个P R → FP FN的小推论是我在排查评估脚本 bug 时用得最顺手的一招。指标算得对不对先看它是否符合数学约束。3. ROC 与 AUC排序能力的度量3.1 TPR 与 FPR 的构造逻辑回到混淆矩阵从两个不同的分母出发能得到两个归一化后的量TPR真正率 TP / (TP FN)分母是全部真实正样本。它回答的是所有真正该被推的物品我抓到了多少。这个量还有个名字叫 Recall、灵敏度Sensitivity。FPR假正率 FP / (FP TN)分母是全部真实负样本。它回答的是所有本来不相关的物品我误判了多少。这两个量做分子分母的巧妙之处在于它们都把样本总量消掉了只保留了正类内部的召回和负类内部的误召。这样做的好处是即便正负样本比例从 1:1 变成 1:100TPR 和 FPR 的取值范围和解释都不变。这是 ROC 相对 Precision/Recall 的最大优势——对类别不平衡不敏感。3.2 ROC 曲线怎么画出来的ROC 的横轴是 FPR纵轴是 TPR。把阈值从 1.0 一路降到 0.0每降一点就得到一个 (FPR, TPR) 点连起来就是曲线。我拿一组 10 个样本手算一遍方便你完全吃透。真实标签和模型打分如下样本真实打分A10.90B10.80C00.75D10.68E00.55F10.50G00.42H10.35I00.20J00.10正样本 5 个负样本 5 个。按打分从高到低扫每遇到一个样本就更新计数阈值取在TPFPFPRTPR 0.90000.000.00 0.80100.000.20 0.75200.000.40 0.68210.200.40 0.55310.200.60 0.50320.400.60 0.42420.400.80 0.35430.600.80 0.20530.601.00 0.10540.801.00把这些点连起来曲线从 (0,0) 出发一路右下折线爬到 (1,1)。任何一条 ROC 曲线都必然经过 (0,0) 和 (1,1)阈值高到全部判负TPR 和 FPR 都是 0阈值低到全部判正两者都是 1。理解这一点就不会画出一条看起来不对的曲线了。3.3 AUC 到底在衡量什么AUC 就是 ROC 曲线下的面积。理解它有三种等价视角我自己在不同场合会用不同说法视角一几何面积。面积越大曲线越往左上角凸模型越好。随机猜的模型曲线是 (0,0) 到 (1,1) 的对角线AUC 0.5。视角二排序概率。随机抽一个正样本和一个负样本模型给正样本的打分高于负样本的概率就是 AUC。这个解释我最喜欢因为它把 AUC 和排序能力直接绑定了——AUC 本质上衡量的是模型把正样本排在负样本前面的能力跟具体阈值无关。视角三Wilcoxon-Mann-Whitney 统计量。这是一个非参数检验统计量的归一化版本和视角二在数学上等价。知道这点有实际用处小样本评估时 AUC 的显著性可以用 Mann-Whitney U 检验来算。AUC 的取值范围是 0 到 10.5 是随机水平。推荐场景的经验参考AUC 区间含义0.50 - 0.60基本没学到东西0.60 - 0.70有信号但弱0.70 - 0.80工业界主流区间0.80 - 0.90特征充分、任务清晰时能到 0.90高度怀疑数据泄漏最后一行不是开玩笑。我见过好几次 AUC 冲到 0.95 的情况一查全是特征泄漏——比如把用户是否点击的衍生特征、或者带时间戳的统计量直接喂进了模型。3.4 AUC 的失效场景AUC 不是万能的至少有三个场景要警惕。场景一极度不平衡且只关心头部的场景。广告场景负样本占 99.9%AUC 对 FPR 的每一点变化都被均摊到巨大的负样本集合上导致大量 FP 换来的 FPR 增量微乎其微AUC 看起来还是很漂亮。但业务上这些 FP 就是真金白银的浪费。这时候应该用PR 曲线下的面积AUPRC它对正类的表现更敏感。场景二只关心 Top-K 排序质量的场景。AUC 衡量的是全局排序而你线上只曝光前 20 个。模型把第 500 名和第 5000 名排反了AUC 会扣分但业务上毫无影响。反过来模型把第 1 名和第 5 名排反了业务影响巨大但 AUC 扣的分微不足道。AUC 是全局指标Top-K 是局部指标两者关注的区域不一样。所以精排模型我一般 AUC 和 NDCG 一起看。场景三分数需要校准的场景。AUC 只看排序不看分数绝对值。模型输出 0.9 还是 0.5 对 AUC 没有影响只要相对顺序不变。但如果下游要做预估 CTR 出价、做流量分配分数必须校准这时候要看 LogLoss、Calibration Curve、或者用 Platt Scaling / Isotonic Regression 做后处理。4. Top-K 排序指标HR、MRR、MAP、NDCG这一层才是推荐系统真正的主战场。前面说过它们全都关心位置。4.1 HRK最粗暴也最常用Hit Ratio也有地方叫 Hit Rate。定义极其简单给每个用户推荐 K 个物品如果用户真实交互过的物品里至少有一个出现在这 K 个里就算命中。HRK 命中用户数 / 总用户数它只回答有没有完全不关心命中在第几位。好处是计算极快、解释极清楚缺点是分辨率太低——把正确答案放在第 1 位和放在第 K 位HR 得分完全一样。HR 在召回阶段用得最多。原因很实在召回层的 K 通常是几百位置精度本来就没那么重要只要捞得到就行。HR500 从 0.62 提到 0.68对召回来说就是实打实的进步。4.2 MRR只奖励第一个命中的位置Mean Reciprocal Rank先看单个用户RR 1 / rank_first_hitrank_first_hit 是第一个命中的物品在推荐列表中的位置。命中在第 1 位RR 1第 2 位RR 0.5第 5 位RR 0.2一个都没命中RR 0。然后对所有用户求平均就是 MRR。MRR 的特点是只认第一个命中后面的命中全部忽略。这让它天然适合只有一个正确答案的场景——比如问答系统的答案推荐、导航的 POI 推荐、搜索的首次点击。放到信息流推荐里就要小心了一个用户可能连刷 20 条视频MRR 只感谢第一条命中的位置信息损失很大。这种场景就该用 MAP 或 NDCG。4.3 MAP把每个相关项都算进去MAPMean Average Precision分两步算。先算单个用户的 APAP (1 / R) × Σ (Pk × rel(k))R 是该用户真实相关物品的总数k 遍历推荐列表的每一个位置Pk 是截取前 k 个时的 Precisionrel(k) 是第 k 个物品是否相关0 或 1然后对所有用户的 AP 求平均就是 MAP。理解 AP 的关键是每命中一个相关物品就把它位置的 Precision 累加进去。也就是说排在越靠前的相关物品贡献越大因为位置靠前时 Pk 的分母小。这就把位置信息带进来了。如果多个相关物品的相关程度不同二值的 rel(k) 不够用那就该上 NDCG 了。4.4 NDCG带权重和位置折损的终极方案NDCGNormalized Discounted Cumulative Gain是我在面试里最常问的指标因为它能一次性暴露候选人对位置折损相关性分级归一化三个概念的理解程度。拆开看公式有三层第一层增益Gain。每个位置上的物品有一个相关性得分 rel。二值场景就是 0/1。第二层折损Discount。位置越靠后折损越狠。常用的折损因子是 1 / log2(i 1)i 从 1 开始数。所以第 1 位不折损除以 1第 2 位除以 1.585第 3 位除以 2第 4 位除以 2.32……DCG Σ (2^rel_i - 1) / log2(i 1)注意这里有两个版本。有的资料用 (2^rel - 1)有的直接用 rel。前者叫指数增益版对高相关物品的放大更明显后者叫线性增益版更温和。两者算出来的 NDCG 数值不同比较时一定要确认口径一致我在跨团队对齐指标时因为这个栽过跟头。第三层归一化Normalized。DCG 是绝对量列表长度不同、用户真实相关物品数不同DCG 没法横向比较。所以用理想排序的 DCGIDCG做分母NDCGK DCGK / IDCGKIDCGK 是把该用户所有相关物品按相关性从高到低排满前 K 位算出来的 DCG也就是这个用户理论上能达到的最优值。这样 NDCG 被归一到 [0, 1]跨用户可比。NDCG 的优势很明确支持多级相关性。比如 rel 可以定义为点击 1、点赞 2、收藏 3、下单 5。这样模型把下单物品排前面会比把点击物品排前面得到更高分特别适合多目标场景。4.5 四个指标放一起算一遍光看公式容易虚举一个完整的例子。假设某个用户的真实相关物品是 {i1, i2, i3}模型给出的推荐序列是位置物品rel1i902i113i704i215i506i31先算 HR6命中数 3只要有一个就算命中HR 1。HR3 的话前 3 位里有 i1HR 1。MRR第一个命中在位置 2RR 1/2 0.5。APR 3。逐个位置扫k2命中P2 1/2 0.5贡献 0.5k4命中P4 2/4 0.5贡献 0.5k6命中P6 3/6 0.5贡献 0.5AP (0.5 0.5 0.5) / 3 0.5NDCG6线性增益版DCG 0/1 1/1.585 0/2 1/2.322 0/2.585 1/2.807 0.631 0.431 0.356 1.418。理想排序是 i1、i2、i3 在前三位IDCG 1/1 1/1.585 1/2 1 0.631 0.5 2.131。NDCG 1.418 / 2.131 0.665。换个排序对比一下感受。如果模型排成 i1、i2、i3、i9、i7、i5那 AP 直接变成 1.0NDCG 也变成 1.0MRR 1.0。同样的物品集合只是顺序不同指标差了将近 50%。这就是排序层指标的威力也是它存在的全部理由。5. 离线评估的实验设计负采样、切分与显著性指标公式本身不难难的是实验设计。我见过太多指标算对但结论错了的案例根子都在数据构造上。5.1 负采样比例怎么定真实推荐场景里正负样本比例可能到 1:1000 甚至更悬殊。直接训练会遇到两个问题一是训练慢二是模型被负样本主导学到的都是大部分东西不该推这个废话。于是要负采样。但采多少我的经验训练阶段常用 1:1 到 1:10。召回模型双塔训练时batch 内负采样in-batch negative实际比例就等于 batch size比如 1024配合温度系数调整。评估阶段千万不要用采样后的数据算评估指标。采样会改变真实的正负比例Recall、Precision、AUC 全部失真。而且采样是随机的不同实验采样不同的负样本指标之间的差异可能全部来自采样噪声。这条我踩过坑。早期我们用 1:4 负采样做评估两个模型 AUC 差了 0.008兴冲冲上线结果是负采样随机种子不同带来的波动。后来改成评估用全量候选指标波动立刻收敛模型差异也就看得清了。如果实在要采样比如评估集太大我的做法是固定负样本集合所有模型共用或者对每个模型重复采样 N 次取均值和方差看置信区间是否重叠。5.2 时间切分与随机切分的分水岭时序场景下随机切分是自找麻烦。推荐系统里用户的历史行为、物品的统计特征、各种滑窗聚合量都带时间属性。随机切分会让未来的数据进入训练集模型在预测过去的样本时用到了未来的信息。结果就是测试集指标虚高上线就崩。正确做法是按时间切分用第 1 到第 20 天的数据训练第 21 天的数据验证第 22 天的数据测试。留出足够的 gap比如间隔一天避免特征窗口边界处的泄漏。还有一种更隐蔽的泄漏用户维度的泄漏。如果按样本随机切分同一个用户的行为可能同时出现在训练集和测试集。模型会记住这个用户的偏好测试指标虚高。严谨的做法是按用户切分训练集里的用户不出现在测试集里。当然如果业务本身就是老用户行为预测这也很常见那可以不隔离用户但要明确说明评估口径。我在实际项目里的默认配置是按时间切分 用户维度内切分同一用户的前 80% 行为训练后 20% 测试。这个组合既贴近线上时序又不会跨用户泄漏。5.3 别被 0.001 的涨幅骗了离线指标天生有波动。测试集换一批、随机种子换一个、负采样重跑一次指标抖动 0.002 到 0.005 是家常便饭。如果模型 A 的 AUC 是 0.7821模型 B 是 0.7835差 0.0014你敢说 B 更好吗我不敢。判断这类差异是否显著我常用两个办法办法一Bootstrap 置信区间。从测试集中有放回地抽 N 次N 一般取 1000每次算一遍指标得到指标的分布取 2.5% 和 97.5% 分位数作为 95% 置信区间。两个模型的置信区间如果大幅重叠说明差异不显著。办法二配对检验。因为两个模型是在同一批样本上评估的应该用配对检验而不是独立样本检验。对每个样本计算两个模型的指标差异看均值是否显著异于 0。AUC 的场景可以用 DeLong 检验排序指标可以用配对 t 检验或 Wilcoxon 符号秩检验。一条硬经验AUC 差异小于 0.002、NDCG10 差异小于 0.005在没有显著性检验支撑的前提下一律当噪声处理。6. 常见问题与排查实录6.1 指标异常速查表这套表是我自己攒的排查清单遇到异常先照着查一遍能解决七八成的问题。现象最可能的原因排查动作AUC 高得离谱0.95特征泄漏检查是否有时间穿越特征、ID 类特征、目标编码未做折外处理AUC ≈ 0.5标签没对齐或特征全为常量打印标签分布、检查 join key 是否错位Recall 异常高Precision 极低阈值过低或正负标签定义反了检查阈值设置和 label 编码顺序Precision RecallFP FN采样或阈值构造所致检查负采样比例与阈值求法NDCG 与 HR 结论相反相关性定义不同或位置分布差异大统一 rel 定义后重算离线涨线上跌分布偏移、位置偏差、特征延迟见 6.2不同团队指标对不上切分方式、log 底数、K 值、相关性口径不一致逐项对齐口径清单最后一行特别重要。我现在牵头做跨团队评估时一定会先开个会把下面这张口径清单对齐训练/验证/测试的切分方式时间 or 随机是否隔离用户负采样比例评估是否采样NDCG 的增益形式指数 or 线性、log 底数2 还是 e、位置从 0 还是 1 开始HR/NDCG 的 K 值取多少多级相关性的具体等级映射是否过滤用户已交互物品这几项里任何一项不一致指标就没法比。6.2 离线涨线上跌的排查路径这是我花时间最多的一类问题按顺序往下走第一站检查位置偏差。离线数据里的曝光本身就是旧模型排序的结果。模型在旧模型排出来的位置上学习学到的偏好带有旧模型的烙印。如果新模型和旧模型差异大离线评估会系统性高估新模型。缓解手段有用随机流量日志做评估集、引入位置作为特征、用 IPS逆倾向得分做加权。第二站检查特征延迟。离线特征能拿到完整的 T1 数据线上只能拿到实时数据。如果一个强特征在离线是事后统计的线上根本不存在离线指标自然虚高。我一般会做特征可用性对齐把离线特征表按线上可得时间做回溯截断重算指标。第三站检查用户分布漂移。训练集是老用户线上来了大量新用户冷启动问题会让效果打折。这时候要看分人群的指标拆解别只看全局平均。第四站检查重排约束。精排输出的列表还要过一遍去重、打散、多样性控制。离线评估算的是精排输出线上曝光的是重排输出中间隔着一层。这种情况必须把重排逻辑放进离线链路或者单独评估重排前后的一致性。6.3 NDCG 计算里的经典坑我在 code review 里见过太多 NDCG 实现问题挑几个高频的说说。坑一IDCG 的计算对象搞错。IDCG 应该用该用户所有真实相关物品排理想顺序来算而不是用模型给出的列表里的相关物品。如果模型列表里只召回了一个相关物品你用它算 IDCGNDCG 直接变成 1.0指标虚高。正确做法是从全量交互里取相关性最高的 K 个来构 IDCG。坑二K 值不足时的处理。如果某用户真实相关物品有 10 个但 K5IDCG 只能取前 5 个理想值。这时 NDCG5 的上限是 1.0前提是模型把这 5 个都召回并排对。但用户剩下的 5 个相关物品呢它们不影响 NDCG5。这是设计使然但解释的时候要说清楚。坑三相关性等级的映射不一致。有的团队用点击1、收藏2、购买3有的用点击1、购买5。不同映射下 NDCG 的绝对值不同只能同口径比较。坑四log 底数。用 log2 还是 lnNDCG 的绝对值会不同但归一化之后差异会被部分抵消。统一即可别混用。坑五位置从 1 还是 0 开始。折损因子是 1/log2(i1) 还是 1/log2(i2)位置编号差一位结果就不同。工程实现里统一用从 1 开始折损因子 1/log2(i1)最不容易出错。7. 一套可直接抄的指标计算代码前面讲的都是原理这一章给你能直接跑的实现。我用 Python NumPy 手写不依赖 sklearn 的排序指标sklearn 里没有 MAP、MRR、NDCG方便你直接嵌到自己的评估脚本里。7.1 分类指标与 AUCimport numpy as np from sklearn.metrics import roc_auc_score, precision_score, recall_score, f1_score def classification_report(y_true, y_score, threshold0.5): y_true np.asarray(y_true) y_score np.asarray(y_score) y_pred (y_score threshold).astype(int) tp int(((y_pred 1) (y_true 1)).sum()) fp int(((y_pred 1) (y_true 0)).sum()) fn int(((y_pred 0) (y_true 1)).sum()) tn int(((y_pred 0) (y_true 0)).sum()) eps 1e-12 precision tp / (tp fp eps) recall tp / (tp fn eps) f1 2 * precision * recall / (precision recall eps) fpr fp / (fp tn eps) tpr recall # TPR 与 Recall 在数值上完全一致 return { TP: tp, FP: fp, FN: fn, TN: tn, Precision: round(precision, 4), Recall: round(recall, 4), F1: round(f1, 4), FPR: round(fpr, 4), TPR: round(tpr, 4), AUC: round(roc_auc_score(y_true, y_score), 4), }这段代码有两个实用的小设计。一是 eps 防零除空测试集或者全预测为负时不至于崩二是把 TPR 直接写成 recall顺便提醒读者这俩是一个东西。AUC 我用 sklearn 的实现它内部用的是基于排序的算法复杂度 O(n log n)比拼阈值扫一遍快得多。如果你要自己手写参考 3.3 节的视角二统计所有正负样本对的排序一致率即可但要注意实现成 O(n log n)先排序再算每个正样本前面有多少负样本。7.2 排序指标HR、MRR、MAP、NDCGimport numpy as np def hr_at_k(ranked_list, ground_truth, k): ranked_list: 推荐序列有序; ground_truth: 真实相关物品集合 topk ranked_list[:k] return 1.0 if len(set(topk) set(ground_truth)) 0 else 0.0 def mrr_at_k(ranked_list, ground_truth, k): for idx, item in enumerate(ranked_list[:k], start1): if item in ground_truth: return 1.0 / idx return 0.0 def ap_at_k(ranked_list, ground_truth, k): hits, score 0, 0.0 for idx, item in enumerate(ranked_list[:k], start1): if item in ground_truth: hits 1 score hits / idx return score / min(len(ground_truth), k) if ground_truth else 0.0 def dcg_at_k(rels, k): rels: 按推荐顺序排列的相关性得分列表 rels np.asarray(rels[:k], dtypefloat) if rels.size 0: return 0.0 discounts np.log2(np.arange(2, rels.size 2)) return float(np.sum((np.power(2.0, rels) - 1.0) / discounts)) def ndcg_at_k(ranked_list, relevance_map, k): relevance_map: {item_id: relevance}未出现的物品相关性为 0 pred_rels [relevance_map.get(i, 0.0) for i in ranked_list] ideal_rels sorted(relevance_map.values(), reverseTrue) dcg dcg_at_k(pred_rels, k) idcg dcg_at_k(ideal_rels, k) return dcg / idcg if idcg 0 else 0.0 def evaluate_all(user2rank, user2truth, user2rel, k10): hr, mrr, ap, ndcg [], [], [], [] for u, ranked in user2rank.items(): truth user2truth[u] rel user2rel[u] hr.append(hr_at_k(ranked, truth, k)) mrr.append(mrr_at_k(ranked, truth, k)) ap.append(ap_at_k(ranked, truth, k)) ndcg.append(ndcg_at_k(ranked, rel, k)) return { fHR{k}: round(float(np.mean(hr)), 4), fMRR{k}: round(float(np.mean(mrr)), 4), fMAP{k}: round(float(np.mean(ap)), 4), fNDCG{k}: round(float(np.mean(ndcg)), 4), }几个实现上的注意点都是我自己踩出来的。关于 AP 的分母。我用了min(len(ground_truth), k)。标准 AP 的分母是相关物品总数 R但截断到 K 之后如果 R K模型最多只能命中 K 个此时用 R 当分母会让所有模型的 AP 都被压低且差异被压缩。用 min(R, K) 更贴近在这 K 个位置里做到了多少的语义。两种口径都存在关键是同一个项目里保持一致并且在报告里写明。关于 DCG 的位置编号。代码里np.arange(2, rels.size 2)生成的是 2, 3, 4, ...对应位置 1, 2, 3, ...折损因子正好是 1/log2(i1)。这个写法比手写循环清爽也不容易出差错。关于 IDCG。我用sorted(relevance_map.values(), reverseTrue)直接取所有相关性得分的降序排列。注意 relevance_map 里应该包含该用户全部真实交互物品的相关性不能只放推荐列表里出现的。这就是 6.3 节说的坑一用代码结构直接避免它比事后检查靠谱。关于评估效率。上面的写法是逐用户循环评估集在十万用户规模时大概要跑几分钟。如果要提速可以把用户维度的循环换成向量化或者用多进程。但我的建议是先把正确性验一遍再谈性能。我见过太多为了提速把 IDCG 算错的实现。8. 关于指标口径的两条硬规矩写到这里把我这些年总结的两条硬规矩分享一下都是被坑出来的。第一条任何指标数值必须带着口径一起报。NDCG10 是 0.42这句话本身没意义。你得说清楚线性增益还是指数增益、log 底数是 2、位置从 1 开始、IDCG 用全量交互构造、评估集是哪个时间段、是否做负采样。我现在的习惯是写一个eval_config.json把所有这些参数固化下来模型迭代时每个版本都带着这个配置文件一起存档。半年后回头看才能复现出同一个数。第二条永远同时报一个排序指标和一个业务指标。只看 NDCG 容易掉进局部最优的陷阱——模型学会了在小候选集里排得很好但召回能力没提升业务大盘纹丝不动。只看 CTR 又会被波动掩盖问题。我的做法是离线阶段看 NDCG10 Recall500 的组合上线阶段看 CTR 人均曝光数的组合两边都盯住出问题的时候才好定位是哪一层掉了链子。回到开头那个 AUC 涨了但线上跌了的案例。后来我们的定位结论是离线评估集用的是随机切分的全量样本线上却是严格时序的场景特征里有几个滑窗统计量在离线是事后的在线上是实时的值域分布差了一截。把评估集改成时间切分 特征可用性回溯之后那个模型的 AUC 只涨了 0.001我们也就没有上线避免了那次事故。指标这东西算得对只是及格用对口径才算过关。