AI 学习第 6 课:98% 准确率的分类器,一个正类都抓不到
第 5 课的验证集准确率 95.5%看着挺像回事。换个正类只占 2% 的数据集同一套指标当场失效一个什么都不学、全部判负的分类器准确率 98%。准确率在类别不平衡时基本没信息量。下面用 numpy 手算混淆矩阵、精确率、召回率、PR 曲线和 log loss再拆一个更隐蔽的坑模型只做一次平方变换排序没变、AUC 没变F1 却全变了。指标不是模型的属性是你问模型的问题。环境准备只用 numpy不装 sklearn。本机 Python 3.13.14 / Windows 10 / numpy 2.5.1。python -c import numpy, sys, platform; print(sys.version.split()[0], platform.platform()) pip install numpy2.5造一份 2% 正类的评分数据5000 条样本100 条正类。分数用两簇正态叠出来、重叠区很大模拟一个「能排点序但不够准」的模型。import numpy as np rng np.random.default_rng(2026) n_pos, n_neg 100, 4900 y np.r_[np.zeros(n_neg), np.ones(n_pos)] # 正类分数偏高但和负类大幅重叠故意不做一个完美分类器 score np.r_[ np.clip(rng.normal(0.16, 0.14, n_neg), 0.001, 0.999), np.clip(rng.normal(0.42, 0.20, n_pos), 0.001, 0.999), ] print(样本, len(y), 正类占比, round(float(y.mean()), 4))样本 5000 正类占比 0.02评估指标只吃「标签 分数」跟分数由什么模型算出来无关所以这里直接把分数当输入。准确率 98% 的模型一个正类都没抓到最省事的分类器是永远输出 0。把它和阈值 0.5 下的真实模型放在一起算def confusion(y, yhat): tp int(((yhat 1) (y 1)).sum()) fp int(((yhat 1) (y 0)).sum()) fn int(((yhat 0) (y 1)).sum()) tn int(((yhat 0) (y 0)).sum()) return tp, fp, fn, tn def report(name, y, yhat): tp, fp, fn, tn confusion(y, yhat) acc (tp tn) / len(y) prec tp / (tp fp) if tp fp else float(nan) rec tp / (tp fn) if tp fn else float(nan) f1 2 * prec * rec / (prec rec) if (tp fp) and (tp fn) else float(nan) print(f{name} acc{acc:.4f} precision{prec:.4f} recall{rec:.4f} f1{f1:.4f}) print(f tp{tp} fp{fp} fn{fn} tn{tn}) report(全部判负 :, y, np.zeros_like(y)) report(阈值 0.5 :, y, (score 0.5).astype(int))全部判负 : acc0.9800 precisionnan recall0.0000 f1nan tp0 fp0 fn100 tn4900 阈值 0.5 : acc0.9796 precision0.4875 recall0.3900 f10.4333 tp39 fp41 fn61 tn4859全判负是 0.9800真模型 0.9796加了模型准确率反而低 0.0004但它捞回 39 个正类全判负一个都没有。准确率把 4900 个「正确地什么都没做」算成了成绩对这种差距不敏感。precision和f1打 nan 不是程序错tp fp 0分母为零。scikit-learn 会发UndefinedMetricWarning并置 0意思一样——模型没有「预测为正」的能力。混淆矩阵四格精确率和召回率各问一件事阈值 0.5 那行拆开就是一张 2×2 表预测为正预测为负实际为正TP 39FN 61实际为负FP 41TN 4859精确率39 / (39 41) 0.4875报警 80 次41 次是误报一半白跑。召回率39 / (39 61) 0.39100 个真事只发现 39 个。F10.4333前两者的调和平均。F1 的局限公式只用 TP、FP、FNTN 那 4859 个样本没参与所以它也不知道「不报警时判断对不对」。把正常用户判成黑产这类误判代价高的场景F1 一样会骗人得回去看 FP 的绝对数量。阈值扫描同一个模型九种性格模型训练完概率就固定了阈值是上线时才定的。扫一遍看它怎么换脸hdr thr 判正数 tp fp precision recall f1 print(hdr) for thr in (0.05, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8): yhat (score thr).astype(int) tp, fp, fn, tn confusion(y, yhat) prec tp / max(tp fp, 1) rec tp / (tp fn) f1 2 * prec * rec / (prec rec) if prec rec else 0.0 print(f{thr:5.2f}{int(yhat.sum()):6d}{tp:6d}{fp:6d}{prec:11.4f}{rec:8.4f}{f1:8.4f})thr 判正数 tp fp precision recall f1 0.05 3919 100 3819 0.0255 1.0000 0.0498 0.10 3326 98 3228 0.0295 0.9800 0.0572 0.20 1993 89 1904 0.0447 0.8900 0.0850 0.30 834 71 763 0.0851 0.7100 0.1520 0.40 256 59 197 0.2305 0.5900 0.3315 0.50 80 39 41 0.4875 0.3900 0.4333 0.60 23 18 5 0.7826 0.1800 0.2927 0.70 11 11 0 1.0000 0.1100 0.1982 0.80 4 4 0 1.0000 0.0400 0.07690.05 时召回 100%代价 3819 个误报0.8 时精确率 100%只捞到 4 个。没有「最优阈值」只有按成本选的点误报成本乘 3819 与漏报成本乘 96哪边大往哪挪。F1 最大的 0.50.4333只是「两者同等重要」假设下的答案。ROC-AUC 0.873 和 PR-AUC 0.42差的不是模型是基线def pr_curve(y, s): order np.argsort(-s, kindmergesort) ys, ss y[order], s[order] tp, fp np.cumsum(ys), np.cumsum(1 - ys) keep np.r_[np.diff(ss) ! 0, True] # 同分只留最后一个阈值否则曲线有重复点 tp, fp tp[keep], fp[keep] rec np.r_[0.0, tp / ys.sum()] prec np.r_[1.0, tp / (tp fp)] r_out, p_out [rec[0]], [prec[0]] for r, p in zip(rec[1:], prec[1:]): if r r_out[-1]: # 同一召回率取最大精确率 p_out[-1] max(p_out[-1], p) else: r_out.append(r) p_out.append(p) return np.array(r_out), np.array(p_out) def roc_auc(y, s): # 用秩和算 AUC正样本排在负样本前面的概率并列记 0.5 order np.argsort(s, kindmergesort) ranks np.empty(len(s)) ss s[order] i 0 while i len(ss): j i while j 1 len(ss) and ss[j 1] ss[i]: j 1 ranks[order[i:j 1]] (i j) / 2 1 i j 1 n_pos y.sum() n_neg len(y) - n_pos return float((ranks[y 1].sum() - n_pos * (n_pos 1) / 2) / (n_pos * n_neg)) rec, prec pr_curve(y, score) print(PR 曲线点数, len(rec), AP , round(float(np.sum(np.diff(rec) * prec[1:])), 4)) print(ROC-AUC , round(roc_auc(y, score), 4))PR 曲线点数 101 AP 0.4208 ROC-AUC 0.873同一个模型AUC 0.873 听着「相当不错」AP 0.4208 听着「一半是误报」。差异来自基线ROC 的随机基线恒为 0.5因为 TPR 和 FPR 各按自己的类别归一化正类多少不影响PR 的随机基线就是正类占比 0.020.4208 相对它是 21 倍提升。不平衡数据只报 AUC 的问题就在这0.873 是真强还是只把排序做对了一点单看数字分不出来。报 AUC 时把正类占比一起写或者直接报 AP 与基线。AP 不是 PR 曲线下的面积很多人默认 PR-AUC 就是trapz(precision, recall)。拿 6 个样本试一下yt np.array([1, 0, 1, 0, 0, 1]) st np.array([0.9, 0.8, 0.6, 0.55, 0.4, 0.3]) rt, pt pr_curve(yt, st) print(recall , np.round(rt, 3)) print(precision, np.round(pt, 3)) print(阶梯 AP , round(float(np.sum(np.diff(rt) * pt[1:])), 4)) print(梯形面积 , round(float(np.trapezoid(pt, rt)), 4))recall [0. 0.333 0.667 1. ] precision [1. 1. 0.667 0.5 ] 阶梯 AP 0.7222 梯形面积 0.8056差 0.083方向是梯形偏高。梯形在两个相邻点之间画直线等于假设「召回涨一点、精确率线性下降」实际精确率只在命中或误报那一跳上下台阶中间是平的。样本越少、台阶越粗虚高越多这份 6 样本数据上虚高 11%换到 5000 样本那份只剩 0.005 左右很容易被忽略。scikit-learn 的average_precision_score用阶梯口径按召回增量给精确率加权不是梯形面积。版本上记一笔1.9.02026-06给average_precision_score加了 Array API 支持官方发布文章把它列进「拿到 GPU 支持」的指标1.9.12026-09修了 array API 打开时分类指标接受 pandas 标签出错的问题#34779。接口在变口径没变。log loss 管的是概率不是排序把分数平方一下再评估。平方是单调变换任何一对样本的先后顺序都不变def logloss(y, p): p np.clip(p, 1e-15, 1 - 1e-15) # 防止 log(0) return float(-np.mean(y * np.log(p) (1 - y) * np.log(1 - p))) print(原始 logloss , round(logloss(y, score), 4)) sq score ** 2 rec2, prec2 pr_curve(y, sq) print(平方 logloss , round(logloss(y, sq), 4)) print(平方 ROC-AUC , round(roc_auc(y, sq), 4), AP , round(float(np.sum(np.diff(rec2) * prec2[1:])), 4)) print(平方 阈值0.5 , confusion(y, (sq 0.5).astype(int)), int((sq 0.5).sum()))原始 logloss 0.2113 平方 logloss 0.0835 平方 ROC-AUC 0.873 AP 0.4208 平方 阈值0.5 (10, 0, 90, 4900) 10AUC 和 AP 一位没动log loss 从 0.2113 掉到 0.0835阈值 0.5 下的判正数量从 80 个缩到 10 个精确率变成 1.0、召回率只剩 0.10。一次变换把三件事分开了排序能力AUC、AP只看谁排在谁前面单调变换改不动概率质量log loss、Brier看预测概率离真实频率多远往基率压一压就变好决策效果阈值后的 P/R/F1由「概率刻度 阈值」共同决定概率缩水0.5 这条线就不是原来那条线。log loss 变好不代表模型变强可能只是概率被压平、更接近基率。反过来AP 很高但概率过度自信把 0.3 报成 0.99的模型 log loss 会很大风控定价、广告出价这类拿概率做乘法的场景直接废掉。第 5 课的交叉熵优化的是概率质量不是排序也不是 F1。踩坑np.trapz 在 numpy 2.5 上直接报错想给 PR 曲线算面积顺手写np.trapz(prec, rec)Traceback (most recent call last): File probe.py, line 68, in module trap float(np.trapz(prec_c, rec_c)) AttributeError: module numpy has no attribute trapz. Did you mean: trace?旧名字没了统一改成np.trapezoidarea float(np.trapezoid(prec, rec))危险之处在于它不是 warning 而是硬崩老代码里手算 AUC 面积、积分近似的地方升级 numpy 后直接挂掉而trapz这个名字还在无数博客里流通。小结同一份数据上准确率 0.98 的模型什么都没学到0.9796 的模型能捞回 39% 的正类AUC 0.873 配 AP 0.4208log loss 0.2113 在平方变换后降到 0.0835排序一点没变。指标不是精度高低是回答不同问题。给一条能直接用的规矩报分类结果时至少同时给三样——正类占比、混淆矩阵四格、你选的阈值。只报一个 accuracy 或 AUC 的结果没法复核。第 7 课进入交叉验证与学习曲线单次留出法为什么会给出互相矛盾的结论训练曲线和验证曲线的开口怎么区分欠拟合与过拟合。