
上周有个刚转推荐方向的朋友发来一张评估截图问我“我的 AUC 都 0.91 了为什么上线以后 CTR 只涨了 0.2 个点”我让他把全套指标打出来结果是这样的Precision 0.38、Recall 0.11、F1 0.17、NDCG10 只有 0.23。这四个数字摆在一起问题其实已经写在脸上了——模型只是学会了给“容易区分的负样本”打低分真到了 Top-N 列表里排在前面的东西并不比随机好多少。顺手说个真实的搜索体验我最早查 F1 指标的时候搜索引擎给我推的全是“怎么关闭 f1 自动打开帮助”“f1 快捷键被音量占用了”“键盘的 f1 到 f12 键没有反应”查 precision 的时候出来的是“mac precision touchpad 驱动”和某数据库的“can not decrease precision”报错。同一个词在不同圈子里指完全不同的东西这件事本身就说明指标这个词被用得太随意了。所以这篇不打算照着教科书目录念一遍我想把 ACC、Precision、Recall、F1、FPR、TPR、ROC、AUC、MAP、MRR、HR、NDCG 这一串指标按“它们在推荐系统里到底衡量什么、什么时候会骗你、代码怎么写、怎么核对”这条线串起来讲适合刚上手推荐评估的同学也适合那些“指标都会算但总觉得哪里不对”的从业者。1. 先分清楚推荐系统里跑着两套完全不同的指标语言很多人第一次做推荐评估会犯一个根本性错误把 CTR 预估模型的指标和 Top-N 推荐列表的指标混在一个表格里比大小。这两套东西的目标函数不一样量纲不一样甚至连“样本”的定义都不一样混着看只会把自己绕晕。1.1 混淆矩阵是这一堆指标的公共地基不管后面公式多花哨ACC、Precision、Recall、F1、FPR、TPR、ROC、AUC 全部是从同一个 2x2 矩阵里长出来的。推荐场景下的四格含义需要重新对齐一遍否则很容易算错符号通用含义推荐场景的对应解释TP预测正实际正推给用户且用户真的产生了正向行为点击/转化/完播FP预测正实际负推给用户但用户毫无反应甚至点了不喜欢FN预测负实际正用户本来会喜欢但系统没推给他TN预测负实际负没推给用户用户也确实不需要看起来平平无奇但推荐系统里 TN 这一格的性质和传统分类任务完全不同这一点后面会专门讲。这里先记住一件事所有阈值型指标本质上都是在回答“我把哪些东西判成了正例”而阈值一动四格数字全变指标也就全变。1.2 推荐场景的 TN 是一个天文数字假设你的物品库有 100 万件商品一次曝光只展示 20 件。那么对于某一个用户来说剩下 99.998 万件没被展示的物品理论上全都可以算作 TN。这个量级意味着什么意味着 TN 在总数里占了 99.998%任何把 TN 放进分母的指标——最典型的就是 ACC——都会被这个巨大的数字彻底稀释。我见过一个同学兴奋地说“我的模型准确率 99.8%”我让他把模型换成“全部预测为负”再跑一遍结果还是 99.8%。这不是模型好这是指标失效。后面第 2 节会把这个坑拆开讲透因为它是最容易在汇报里被质疑、也最容易在面试里被追问的点。1.3 分类指标管“点”排序指标管“序”如果非要给这两套语言划一条清晰的分界线我的划分方式是分类/概率类指标ACC、Precision、Recall、F1、FPR、TPR、ROC、AUC回答的是“这个样本会不会被正例化”它不关心位置。你给 100 个候选打分AUC 只看你能不能把正例排在负例前面至于排第一还是排第一百它完全不区分。排序/列表类指标HR、MRR、MAP、NDCG回答的是“排在最前面的这几个够不够好”位置是第一等公民越靠前权重越高。CTR/CVR 预估模型、召回模型的粗排阶段主要看第一套精排和重排、最终展示的 Top-N 列表主要看第二套。分不清这条线就会出现“AUC 涨了但 NDCG 没动”这种让人抓狂的情况——不是模型没变化而是你用的尺子量错了维度。2. ACC、Precision、Recall、F1在不平衡数据上怎么用才不翻车这一组是最基础的四个也是最容易被误用的四个。核心矛盾只有一个推荐数据极度不平衡而不同指标对不平衡的敏感度天差地别。2.1 ACC 为什么在推荐里几乎等于废指标ACC 的公式是 (TP TN) / (TP FP FN TN)。放到推荐场景里算一笔账总样本 100 万1 个用户 × 100 万物品真实正例 30 个这个用户确实会互动的物品模型全部预测为负此时 TP 0FP 0FN 30TN 999970。ACC 999970 / 1000000 99.997%。一个什么都不推的模型准确率高达 99.997%。这就是所谓的“准确率悖论”——负样本越多ACC 越没有区分度它衡量的其实是“负类占比”不是模型能力。那什么时候 ACC 能用只有一个条件正负样本比例大致均衡比如在采样之后的训练集上做快速 sanity check。但即便这样我也不推荐把它当主指标因为它对“把正例排在哪”毫无感知。我的习惯是ACC 只在代码调试阶段打一次确认数据管道没接反比如标签是不是搞反了正式评估一律不用。2.2 Precision 和 Recall 的天然对立关系Precision TP / (TP FP)衡量的是“我推出去的东西里有多少是对的”Recall TP / (TP FN)衡量的是“用户真正想要的东西里我捞回来多少”。这两者天生打架而且推荐场景会把这种对立放大到极端。举两个极端策略策略 A只推 1 个物品而且是我 99% 确定用户会点的那一个。此时 Precision 可以做到 0.9 以上但 Recall 可能只有 0.03——因为用户三十个潜在兴趣里我只覆盖了一个。策略 B把 100 万个物品全推给用户。此时 Recall 1.0完美但 Precision 掉到 0.00003等于没有推荐。所以单独报 Precision 或者单独报 Recall 都是耍流氓。而且这里有个非常隐蔽的坑Precision 的取值高度依赖“每个用户推荐了多少个”。你的列表长度从 10 改成 20Precision 大概率会下降多推的那 10 个质量更差但召回会上升。所以做单元对比时必须锁死 K否则数字没有可比性。2.3 F1 的推导过程和 β 版本该怎么选F1 是 Precision 和 Recall 的调和平均F1 2PR / (P R)。很多人只记住这个形式不知道它展开后长什么样F1 2PR / (P R) 2 · [TP/(TPFP)] · [TP/(TPFN)] / ([TP/(TPFP)] [TP/(TPFN)]) 2TP / (2TP FP FN)看到最后这个形式就明白了F1 其实只关心 TP、FP、FN 三个数压根不涉及 TN。这正是它在不平衡数据上比 ACC 靠谱的根本原因——那个天文数字的 TN 被彻底排除在公式之外了。不过 F1 也有自己的偏心它认为 Precision 和 Recall 同等重要。真实业务里几乎不存在这种情况。推荐首页坑位有限Precision 更值钱做召回层的时候Recall 更值钱。所以千万不要被“F1 更高就是更好的模型”这句话骗了。更灵活的是 Fβ 版本Fβ (1 β²) · PR / (β² · P R)β 的取值有一个非常实用的记忆技巧β² 就是 Precision 和 Recall 在公式里的权重比。β 0.5 时β² 0.25意味着 Precision 的权重是 Recall 的 4 倍适合“宁可少推不能推错”的场景β 2 时召回权重是精准的 4 倍适合“宁可推错不能漏掉”的场景。我做过的一个内容社区项目里因为负反馈点了不喜欢代价很高我们直接把目标调成 F0.5 来汇报比 F1 更能反映业务真实偏好。2.4 一个经常被问到的现象Precision 和 Recall 数值完全一样热词里这个提问出现频率很高。数学上P R 的充要条件非常简单TP / (TP FP) TP / (TP FN) ⟹ TP FP TP FN ⟹FP FN也就是说只要假阳性和假阴性的数量相等两个指标就必然相等。等价地看FP TP FN TP即“预测为正的样本数 真实正样本数”。只要模型预测出来的正例数量和真实正例数量恰好相等Precision 就等于 Recall这时候 F1 的数值也等于二者。这解释了一个常见现象当你把阈值调到让“预测正例数 ≈ 真实正例数”的时候P 和 R 会精确相等。这不是巧合是数学必然。但它不意味着模型好——模型完全可以把正负判反只要数量对得上P 和 R 照样相等且很低。看懂这一层下次再有人跟你说“我的 P 和 R 一样是不是过拟合了”你就能直接指出这跟过拟合没关系是阈值位置的问题。顺便提一下 F1 曲线的用法。把阈值从 0 扫到 1每个阈值算一个 F1画出来的曲线就是 F1 随阈值的变化。曲线的峰值对应的阈值是“让 F1 最大化”的阈值但它不一定是最优的业务阈值。因为 F1 假设正负例等权而线上业务里 FP 和 FN 的代价往往差着数量级直接拿 F1 峰值当线上阈值是很典型的“指标正确但决策错误”。3. FPR、TPR、ROC、AUC把排序能力从阈值里解放出来上一节所有指标的共同毛病是必须有阈值才能算。但训练出来的模型输出的是一个连续分数阈值的选择是个独立的决策问题。ROC 和 AUC 的价值就在于它们把阈值这个变量彻底消掉了。3.1 阈值是怎么把好模型算成坏指标的先说清楚 FPR 和 TPRTPR真阳性率 TP / (TP FN)注意这个式子就是 Recall两个名字指的是同一个东西。FPR假阳性率 FP / (FP TN)分母里出现了 TN。这里有个非常容易搞错的地方推荐场景里 TN 巨大所以 FPR 天然就很小。一个模型哪怕把 10000 个不相关物品推成了正例只要物品总量是百万级FPR 也就是 1% 左右。所以 FPR 在推荐系统里是个偏乐观的指标看到 0.01 别高兴太早那只是因为分母太大。举一个具体的例子说明阈值有多关键假设模型对某个用户打出的分数里真正的正例分数集中在 0.3 到 0.5负例集中在 0.1 到 0.35。如果你把阈值定在 0.8想保守一点那所有正例全被判负Recall 0你的模型在指标表上就是一个废物。阈值定在 0.5Recall 上来了Precision 又下去了。所以每换一次阈值整套数字都要重算——这就是为什么工程上大家更愿意用 AUC。3.2 ROC 曲线的画法和 AUC 的概率含义ROC 曲线的做法很机械把所有样本按模型分数从低到高排一遍把每一个可能的分割点都当成一次阈值每个阈值算出一对 (FPR, TPR)在坐标系里描点连成线。横轴 FPR纵轴 TPR起点是 (0,0)阈值高到全判负终点是 (1,1)阈值低到全判正。AUC 就是这条曲线下的面积取值 [0, 1]。0.5 意味着跟随机猜一样1.0 是完美排序。但比公式更值得记住的是 AUC 的概率解释随机取一个真实正样本和一个真实负样本模型给正样本的分数高于负样本分数的概率就等于 AUC。这个解释在实际排查问题的时候极其有用——它告诉你 AUC 衡量的是“成对比较的正确率”和绝对分数、和具体阈值都没关系。这也解释了为什么 AUC 对正负样本比例不敏感你正样本从 100 个变成 10000 个只要“正例普遍比负例分高”这个性质不变AUC 基本不动。3.3 负采样会悄悄改变你的 AUC让数字失去可比性这是我在实际项目里踩过最深的坑之一。早期做双塔召回训练的时候为了省内存每个正例配 4 个随机负样本。跑出来的 AUC 是 0.86看着还行。后来全量评估对全部物品打分排序一跑AUC 掉到 0.62。我当时第一反应是模型有问题排查了半天才意识到是两个 AUC 根本不是同一个东西。原理很简单。全量评估时负样本池里包含大量“用户压根不可能点”的极端容易负例比如给一个宝妈推荐显卡这些样本让模型的排序看起来很漂亮一旦做随机负采样负样本的难度分布虽然期望不变但方差、样本组成和评估协议都变了。如果用的是“按曝光采样”或者“困难负样本挖掘”那 AUC 会明显往下掉因为剩下的全是难区分的样本。我后来立的规矩是团队内部所有 AUC 报告必须标注两件事——负样本是怎么来的、总数是多少。不标注的 AUC一律不认。另外采样后的 AUC 只能和同样采样方式的 AUC 比跨协议比较毫无意义。3.4 AUC 什么时候会骗你AUC 不是万能的至少有三个场景要警惕分数大量并列。很多模型输出经过量化或者取整之后会有大量相同分数。标准 AUC 对并列的处理是算 0.5 个正确如果并列比例很高比如 30% 的样本分数完全一样AUC 的解释力会大幅下降。这时候你应该去看 PR 曲线或者直接看 NDCG。位置无关。AUC 只关心“正例是不是排在负例前面”不关心排多前。你把所有正例都排在第 500 位、把所有负例排在第 501 位之后AUC 照样是 1.0。可用户只看前 20 个这个模型在线上一文不值。这就是“AUC 高但线上没效果”的典型病因。样本分布漂移。AUC 是对当前测试集分布的描述。用户兴趣、物品池、竞品环境一变历史 AUC 就没有预测力了。所以 AUC 的绝对值不重要重要的是它在同一份测试集上的相对排序。4. MAP、MRR、HR、NDCG真正衡量 Top-N 列表的四个主力前面两个章节的指标都在讨论“这一堆样本判得对不对”但推荐系统的最终产品形态是一个有序列表。这一节四个指标才是日常提单、比模型、写周报时最常用的。4.1 HRK 和 MRR简单但各自只回答一个问题HRKHit Ratio的定义朴素到没有任何解释空间Top-K 列表里只要命中至少一个相关物品这个用户记为 1否则记为 0最后对所有用户求平均。所以 HR10 0.72 的意思是72% 的用户在推荐的前 10 个里至少看到了一个他真正感兴趣的东西。它的好处是极其直观业务方一听就懂坏处是信息损失严重——命中 1 个和命中 10 个HR 都记 1。而且它对 K 极其敏感HR5 和 HR20 能差出一倍所以任何 HR 报告都必须写清楚 K 是多少。MRRMean Reciprocal Rank换个角度找第一个命中的位置取倒数。第 1 位命中得 1 分第 3 位得 0.333第 10 位得 0.1一个都没命中得 0。它特别适合只有一个正确答案的任务比如“猜你想搜的下一个关键词”“导航目的地预测”“问答系统的唯一答案”。如果一个用户有 30 个兴趣点用 MRR 就有点浪费——你只看了最靠前的那个命中位置后面的全扔了。4.2 MAPK把每一次命中都按位置折算APAverage Precision的做法是遍历 Top-K 列表每遇到一个相关物品就把当前位置的 Precision 记下来最后取平均。写成公式是 AP (1/R) · Σ Pi · rel(i)其中 Pi 是前 i 个位置的准确率rel(i) 表示第 i 个位置是否相关R 是相关物品总数。MAP 就是所有用户 AP 的平均。举个例子直观感受一下。假设 Top-5 的相关性序列是 [1, 0, 1, 1, 0]第 1 位就是相关P1 1/1 1.0第 3 位相关P3 2/3 0.667第 4 位相关P4 3/4 0.75AP (1.0 0.667 0.75) / 3 0.806对比另一个序列 [0, 1, 1, 0, 1]同样命中 3 个P2 1/2 0.5P3 2/3 0.667P5 3/5 0.6AP (0.5 0.667 0.6) / 3 0.589命中数量完全相同AP 差了 0.2 以上差距全部来自位置。这就是 MAP 比 HR 信息量大的地方。MAPK 有一个分歧点必须提前和团队约定好不然两边的数字永远对不上分母 R 到底是“整个测试集里的相关物品总数”还是“Top-K 截断内的相关物品数”前者是标准定义AP 的上限永远小于 1后者是很多开源库的实现AP 上限可以到 1。我见过两个同事为“为什么你的 MAP 是 0.45 我的是 0.61”吵了半小时最后发现只是这一处分母定义不一样。我的建议是统一用截断内的相关数min(K, 相关总数)作为分母这样数字更好看也更好跨团队沟通但一定要在文档里写死。4.3 NDCGK工业界排序模型最认的指标NDCG 是这一组里唯一考虑了“相关程度分级”的指标。前面三个都假设“相关就是 1不相关就是 0”但真实推荐里“用户点进去看完并且点赞”和“用户点进去 3 秒退出”显然不是一回事。它的构造分三步。第一步算 DCGDCGK Σ (2^rel_i - 1) / log2(i 1)这里的 (2^rel - 1) 叫增益函数指的是第 i 个位置的相关性带来的收益。用指数形式而不是直接 rel是为了让高相关物品的收益被显著放大。分母 log2(i1) 是折损因子位置越靠后同样的增益被除得越小。第 1 位不折损log2(2) 1第 2 位除以 1.585第 3 位除以 2第 10 位除以 3.46。第二步把同一批物品按相关性从高到低排一遍算出 IDCGK也就是理论上能取得的最好成绩。第三步NDCGK DCGK / IDCGK。除以 IDCG 的意义在于归一化有的用户相关物品多、有的少不归一化就没法跨用户平均。实际用的时候有两个细节决定了你的 NDCG 有没有意义其一rel 的分级标准必须固定。常见的做法是纯二值相关 1不相关 0也有的用 0/1/2/3/4 五级。二值版本的 NDCG 会退化得比较接近 MAP 的排序逻辑而分级版本才能真正体现“强相关压过弱相关”。做内容推荐时我用的是三级点击 完播 2仅点击 1曝光未点 0。其二列表长度不足 K 时的处理方式。如果某个用户只推了 8 个物品但 K 10剩下两个位置算 0 还是不参与两种做法的结果完全不同。我的做法是把 IDCG 也只算实际长度即用 min(K, len(list)) 来截断避免因为“列表短”被惩罚一次。4.4 四个排序指标的适用边界对照指标是否看位置是否支持分级适合的场景主要短板HRK不看不支持快速看“有没有命中”召回效果初判命中数量多少不影响分数MRR只看第一个命中不支持唯一答案类任务搜索、导航、问答丢弃第一个命中之后的所有信息MAPK看逐位置累积不支持多相关物品的通用排序评估分母定义容易踩坑NDCGK看对数折损支持精排/重排模型主力指标分级相关性表达计算复杂rel 定义影响大我的实际做法是召回层看 RecallK 和 HRK精排层看 NDCGK冷启动和长尾单独拉一份分组 NDCGMRR 只在少数唯一答案的模块里用。5. 工程落地一套能跑通的指标实现与数据协议公式看懂了不代表能算对。这一节把评估协议和代码实现都摊开方便直接抄。5.1 先定评估协议再谈指标数值推荐系统里有三种主流评估协议选哪种直接决定了你的数字长什么样全量排序Full Ranking对每个用户给全部物品打分取 Top-K。这是最接近线上真实情形的方式但计算开销极大百万级物品池基本只能离线批处理跑或者用近似最近邻做加速。同一份数据上全量排序的数字通常最低。采样评估Sampled Ranking每个用户取 1 个正样本 99 个随机负样本组成 100 个候选在这 100 个里排序取 Top-10。这是不少经典论文采用的协议好处是快坏处是数字明显偏高。留一法Leave-One-Out按时间排序把用户最后一次交互留作测试集之前的所有交互作训练集。适合序列建模注意它天然假设用户的下一次行为和他的历史偏好一致。最要命的问题是三种协议的结果不能横向比。我见过有人拿论文里的“采样协议 NDCG10 0.68”来对标自己全量排序的“NDCG10 0.21”然后怀疑人生。这不是模型差距是尺子不一样。5.2 手写指标实现先从最基础的混淆矩阵和分类指标开始这段代码可以直接跑注释里标了每个指标的数学来源import numpy as np def confusion_counts(y_true, y_pred): y_true np.asarray(y_true).astype(int) y_pred np.asarray(y_pred).astype(int) tp int(np.sum((y_true 1) (y_pred 1))) fp int(np.sum((y_true 0) (y_pred 1))) fn int(np.sum((y_true 1) (y_pred 0))) tn int(np.sum((y_true 0) (y_pred 0))) return tp, fp, fn, tn def classification_metrics(y_true, y_pred): tp, fp, fn, tn confusion_counts(y_true, y_pred) acc (tp tn) / max(tp fp fn tn, 1) precision tp / (tp fp) if tp fp else 0.0 recall tp / (tp fn) if tp fn else 0.0 f1 2 * precision * recall / (precision recall) if precision recall else 0.0 fpr fp / (fp tn) if fp tn else 0.0 tpr recall return { acc: round(acc, 4), precision: round(precision, 4), recall: round(recall, 4), f1: round(f1, 4), fpr: round(fpr, 4), tpr: round(tpr, 4), }注意几个防御性写法。分母全部做了零判断因为推荐场景里“某个用户一个正例都没有”是常态直接除会抛 NaN 一路污染整个平均值。tpr我直接赋值recall就是为了在代码里明确告诉团队这两个是同一个东西避免有人以为是两个不同指标。AUC 的实现有两种思路。数据量小的时候直接做两两比较最直观也最贴合 AUC 的概率定义数据量大正负样本乘积超过几百万就必须换成基于秩的方法否则内存和时间都会爆def auc_score(y_true, y_score): y_true np.asarray(y_true) y_score np.asarray(y_score, dtypefloat) pos y_score[y_true 1] neg y_score[y_true 0] if pos.size 0 or neg.size 0: return float(nan) if pos.size * neg.size 4_000_000: gt (pos[:, None] neg[None, :]).sum() eq (pos[:, None] neg[None, :]).sum() return float((gt 0.5 * eq) / (pos.size * neg.size)) # 大数据量走秩统计法先排序再算平均秩 order np.argsort(y_score, kindmergesort) sorted_score y_score[order] ranks np.empty(len(y_score), dtypefloat) ranks[order] np.arange(1, len(y_score) 1) i 0 while i len(sorted_score): j i while j 1 len(sorted_score) and sorted_score[j 1] sorted_score[i]: j 1 if j i: avg_rank (i 1 j 1) / 2.0 ranks[order[i:j 1]] avg_rank i j 1 n_pos int((y_true 1).sum()) n_neg int((y_true 0).sum()) sum_pos_rank ranks[y_true 1].sum() return float((sum_pos_rank - n_pos * (n_pos 1) / 2.0) / (n_pos * n_neg))rank 方法那段有个容易忽略的细节np.argsort用mergesort是为了保证稳定排序而并列分数必须取平均秩。如果直接给并列分数按顺序分配不同秩AUC 会被系统性地算高或算低取决于并列样本里正负例的先后顺序。这个 bug 在测试里根本发现不了只有在分数分布高度集中时才暴露。然后是四个排序指标def dcg_at_k(rels, k): rels np.asarray(rels, dtypefloat)[:k] gains np.power(2.0, rels) - 1.0 discounts 1.0 / np.log2(np.arange(2, rels.size 2)) return float(np.sum(gains * discounts)) def ndcg_at_k(rels, k): rels np.asarray(rels, dtypefloat)[:k] if rels.size 0: return 0.0 ideal np.sort(rels)[::-1] # 理想排序相关性降序 idcg dcg_at_k(ideal, k) return dcg_at_k(rels, k) / idcg if idcg 0 else 0.0 def hit_ratio_at_k(rels, k): return 1.0 if np.any(np.asarray(rels)[:k] 0) else 0.0 def reciprocal_rank(rels): for idx, r in enumerate(np.asarray(rels), start1): if r 0: return 1.0 / idx return 0.0 def average_precision_at_k(rels, k, total_relNone): rels np.asarray(rels, dtypefloat)[:k] if rels.size 0: return 0.0 if total_rel is None: # 默认按截断内相关数归一 total_rel max(int((rels 0).sum()), 1) hits, ap 0, 0.0 for idx, r in enumerate(rels, start1): if r 0: hits 1 ap hits / idx return ap / total_relaverage_precision_at_k里的total_rel参数就是前面说的那个分歧点。默认走“截断内相关数”如果你要严格对标某个论文的 MAP 定义就把全量相关数传进来。两种算法写在一个函数里、用一个参数控制好处是团队里谁都能看清楚差异在哪不会各写各的。用的时候把用户维度聚合一下就行def mean_metric(per_user_ranks, metric_fn, k): scores [metric_fn(r, k) for r in per_user_ranks if len(r) 0] return float(np.mean(scores)) if scores else 0.05.3 和 sklearn 对照一次确认自己没算错手写实现最大的风险是“自己算错还看不出来”。我的习惯是每次写完先用 sklearn 对一遍from sklearn.metrics import ( accuracy_score, precision_score, recall_score, f1_score, roc_auc_score ) y_true [1, 1, 0, 1, 0, 0, 1, 0, 0, 0] y_pred [1, 0, 0, 1, 1, 0, 1, 0, 0, 0] y_score [0.9, 0.4, 0.2, 0.8, 0.6, 0.1, 0.7, 0.3, 0.15, 0.05] print(classification_metrics(y_true, y_pred)) print(sklearn ::, round(accuracy_score(y_true, y_pred), 4), round(precision_score(y_true, y_pred), 4), round(recall_score(y_true, y_pred), 4), round(f1_score(y_true, y_pred), 4), round(roc_auc_score(y_true, y_score), 4))这里必须提醒一个隐蔽的默认值差异sklearn.metrics.precision_score和f1_score默认的zero_division处理方式在不同版本之间改过当模型对某个类别完全没有预测时有的版本给 0有的给警告加 0早期版本甚至会直接报错。生产代码里我一般显式传zero_division0避免上线之后因为某个批次数据全是负例而炸掉。另外还有一个常被忽略的点sklearn 的roc_auc_score处理多分类时需要指定multi_class和average参数二分类场景直接用就行。如果你做的是多任务同时预估点击和转化记得每个任务单独算 AUC不要混在一起。5.4 测试集构造中三个必踩的坑指标算对了数据构造错了照样白干。这三个坑我几乎在每个新人身上都见过坑一时间泄露。用随机切分把数据分成训练集和测试集结果测试集里某条交互的时间早于训练集里的交互。模型在训练时“看到了未来”离线指标虚高得离谱。正确做法是一律按时间切测试集的时间戳必须全部晚于训练集。坑二已交互物品没过滤。评估时把用户历史上已经看过、买过、点过的物品又推给他还判为命中。这会让 HR 和 NDCG 凭空涨一大截但线上这些物品根本不会被展示。评估前必须做一遍历史交互过滤这是硬性步骤。坑三热门物品霸榜带来的虚高。如果一个用户的测试集里有 10 个物品其中 8 个是全站爆款那模型只要无脑推热门NDCG 就很漂亮。这时候要做流行度分组评估把物品按曝光量分桶分别算 NDCG才能看出模型在长尾上的真实能力。我的经验是如果整体 NDCG 涨了但长尾桶 NDCG 没动甚至跌了这个涨幅基本是假的上线大概率没效果。6. 我在实际项目里踩出来的指标使用经验公式和代码都是死的真正难的是判断“这个数字变了意味着什么”。这一节聊几个只有做过线上项目才会有的体会。6.1 离线涨、线上跌八成是这几个原因回到开头那个朋友的问题AUC 0.91 但线上没效果。拆开看无非是这几种情况。第一种是指标和业务目标错位。AUC 衡量的是全局排序能力但业务只关心前 10 个坑位。模型把大量正例从第 200 名提升到第 150 名AUC 会涨但 Top-10 一个没变。这种情况的解法是放弃 AUC 作为主指标改用 NDCG10 或者 Hit5把评估范围收窄到真正决定业务的那一段。第二种是离线样本和线上流量的分布差异。离线训练数据里用户曝光过的物品才进样本线上却有大量冷启动物品。模型在离线见过的物品上表现很好一到冷启动就抓瞎。解法是离线评估时做一次冷启动物品分组的单独评估。第三种是位置偏置。离线数据里的点击是“在原来那个排序位置下产生的点击”模型学到的可能是“位置高所以点击多”而不是“内容好所以点击多”。直接拿这种数据训出来的模型做离线上线对比指标会虚高。业界的常规做法是加位置特征并做位置纠偏或者做无偏评估把位置作为特征输入评估时固定成同一个值。6.2 平均值会骗人冷启动和长尾必须分组看所有指标的默认计算方式都是对所有用户求平均。这个平均值掩盖了两类最关键的用户新用户几乎没有历史行为和长尾兴趣用户偏好和大众不一样。我做过一次对比全站 NDCG10 是 0.34看起来还行。按用户活跃度分桶之后高频用户 0.41中频 0.33新用户 0.09。也就是说模型基本是给老用户在服务新用户几乎没有被有效推荐。这个问题在被平均值淹没的情况下是绝对看不出来的。所以我的标准动作是所有排序指标都至少输出三份——全量、按用户活跃度分组、按物品流行度分组。只报一个总平均值无论数字多漂亮我都会要求补分组。6.3 常用场景的指标选型速查场景主指标辅助指标备注召回模型初筛RecallK、HRK覆盖率、长尾命中率K 取线上候选集规模粗排/精排模型NDCGK、MAPKAUCNDCG 的 rel 定义要固定CTR/CVR 预估AUC、LogLossGAUC、PCOC一定要看分组 AUC唯一答案类任务MRR、Top-1 准确率HR3搜索、导航、问答类别极不平衡的二分类F0.5 或 F2、PR-AUCPrecision、Recall用 Fβ 表达业务偏好数据管道自检FPR、TPR混淆矩阵四格确认标签没接反有一点必须说清楚离线指标只用来做模型间的相对排序不用来预测线上绝对收益。NDCG 从 0.30 涨到 0.32你应该理解为“这个模型大概率比上一个好”而不是“线上 CTR 会涨 6.7%”。任何试图用离线指标线性外推线上收益的做法最后都会被打脸。6.4 从离线指标走向线上指标的最后一步离线指标修得再精致也只是上线前的一道门槛。真正要过的是在线指标而且在线指标的结构和离线完全不同离线是每个用户一个分数取平均线上是全局流量池里 CTR、人均曝光、人均点击、次日留存、人均停留时长这些数字的组合。我的经验是提前把两套指标做一次映射验证。具体做法是拿历史上已经全量上线过的 A/B 实验数据回看当时离线 NDCG 的涨跌和线上 CTR 的涨跌是否方向一致。如果过去 10 次实验里 8 次方向一致那离线指标就有参考价值如果只有 4 次一致说明你的离线评估协议和线上场景差得太远得先修协议再谈模型。这个校验过程不复杂但很少有人认真做。做完之后你会发现之前纠结的很多“指标怎么算”的问题其实是在纠结一个根本不反映线上效果的尺子该标多长。最后分享一个小细节是我踩过之后才养成的习惯每次提交模型评估报告前我会强制自己把混淆矩阵的四个原始计数打出来看一眼。因为它没法被任何公式修饰是唯一能让你一眼看出“数据是不是接反了、正例是不是少得离谱、评测集是不是被污染了”的东西。我至少有过三次是在看到 TP 0 或者正例占比 95% 的瞬间才发现整份报告从数据源头就是错的。指标再多也顶不上那四个裸数字诚实。