分数也会骗人
卷 IV 的最后一层伪装。前两章处理的是「流程被污染」和「分数不稳定」;这一章假设流程干净、分数稳定,然后指出分数本身可能在回答一个你没问的问题。两个实测:一个什么都不做的模型拿到 98.58% 的准确率;一台随机森林信誓旦旦地告诉你,最重要的那一列是一根纯噪声。
一份两万行的数据,正例只占 1.42%(比如信用卡欺诈、罕见病筛查)。你训练一个 DummyClassifier(strategy="most_frequent")——它的策略是「永远预测多数类」,也就是永远说「不是」。
问:它的准确率是多少?
伪装一:准确率
答案是 98.58%。因为 98.58% 的样本本来就是负例,而它把每一个都答对了。
| 模型 | 准确率 | 召回率 | 精确率 | AUC |
|---|---|---|---|---|
| 永远说「不是」 | 98.58% | 0.0% | — | 0.5 |
| 逻辑回归 | 99.03% | 31.8% | 100.0% | 0.7316 |
看第一列:99.03% 和 98.58%,差 0.45 个百分点。如果你只看准确率,会觉得这个模型基本没用——训了半天,只比什么都不做好了半个点。
再看第二列:0% 对 31.8%。真模型抓到了近三分之一的欺诈,而 dummy 一个都没抓到。准确率把这个巨大的差别压缩成了 0.45。
把混淆矩阵摊开就清楚了:
测试集 6000 行,其中正例 85 个
预测「不是」 预测「是」
真的不是 5915 0
真的是 58 27 ← 抓到 27 个,漏了 58 个
准确率 = (5915 + 27) / 6000 = 99.03%
↑ 分子里 5915 全是「说不是」贡献的
准确率的分母被多数类主导了。正例只有 85 个,占 1.42%——它们在准确率里的话语权也只有 1.42%。所以:
类别不平衡时,准确率回答的是「多数类分对了多少」,不是「模型有没有用」。
该看什么,取决于你怕哪种错:
- 怕漏(癌症筛查、欺诈检测)→ 看召回率:真的是的里面,我抓到了几个。
- 怕误报(垃圾邮件、自动封号)→ 看精确率:我报警的里面,几个是真的。
- 两个都在乎 → 看 F1(两者的调和平均)。
- 还没定阈值 → 看 AUC 或 PR 曲线下面积。
而无论看什么,都先跑一个 DummyClassifier 当基线——它告诉你「什么都不做」能拿多少分。
0.5 这个阈值,是你没做的一个决定
上面那个模型精确率 100%、召回率 31.8%,这个组合很不寻常——它极度保守:只有非常有把握时才报警,所以报的全对,但漏了三分之二。
为什么这么保守?因为 predict() 用的阈值是 0.5:预测概率大于 0.5 才判正例。而在 1.42% 正例的数据上,模型输出的概率大多很小,能超过 0.5 的寥寥无几。
0.5 不是一个中立的默认值,它是一个隐含的业务假设——假设漏报和误报的代价一样。真实业务里几乎从不如此。本机把阈值扫了一遍:
阈值 0.50 F1 = 0.4821 阈值 0.19 F1 = 0.5156 ★ 最优
同一个模型、同一份数据,什么都不重训,只改一个数字,F1 提高 7%。这是机器学习里性价比最高的一步优化,也是最常被跳过的一步。
proba = model.predict_proba(X_test)[:, 1] # ← 拿概率,不是拿 predict() pred = (proba >= 0.19).astype(int) # 阈值由业务决定
训练时优化 AUC(与阈值无关),上线前根据业务成本定阈值。这两件事应该分开做。
伪装二:特征重要性
第二个伪装更隐蔽,因为它不是分数,是「解释」。
本机造了一份三列的数据:
真有用:只有 0 和 1 两个取值,标签确实由它决定(带 25% 噪声)。噪声_连续:一列[0, 1)的随机数,5000 个不同取值,和标签毫无关系。噪声_千类:0 到 999 的随机整数,995 个不同取值,同样毫无关系。
训一个随机森林,问它哪一列最重要:
| 特征 | 取值个数 | feature_importances_ | 置换重要性 |
|---|---|---|---|
| 噪声_连续 | 5000 | 0.3997 ★最高 | 0.0045 |
| 噪声_千类 | 995 | 0.3546 | 0.0041 |
| 真有用 | 2 | 0.2456 | 0.1978 ★最高 |
随机森林的 feature_importances_ 说最重要的是一根纯噪声。两根噪声列合起来拿走了 75.4% 的重要性。而唯一有信息的那一列排最后。
为什么?因为 feature_importances_ 算的是基尼不纯度下降:这一列被用来切分时,平均降低了多少不确定性。而取值多的列有更多的切分点可以试——5000 个不同的值意味着 4999 个候选切点,总能找到一个在训练集上「碰巧」切得不错的。取值只有 2 个的列,只有 1 个切点可选。
这叫基数偏好(cardinality bias),是树模型这个重要性指标的已知缺陷。它偏爱:高基数的类别列(用户 ID、邮编)、连续变量。
正解是置换重要性(permutation importance):把某一列打乱,看模型在没见过的数据上掉多少分。掉得多,说明这列真的有用。
from sklearn.inspection import permutation_importance rf.fit(X_train, y_train) perm = permutation_importance(rf, X_test, y_test, n_repeats=20, random_state=0) # ^^^^^^^^^^^^^^ 关键:在测试集上做
结果对了:真有用 是 0.1978,两根噪声列掉到 0.0045 和 0.0041——差了 44 倍。
注意那个「在测试集上做」。在训练集上做置换重要性会得到和 feature_importances_ 类似的错误结论——因为模型在训练集上把噪声也记住了(本机上这个随机森林训练集准确率 100%,测试集只有 69.7%)。「有没有用」这个问题,只能在模型没见过的数据上问。这和第 18 章那条主线是同一件事。
四个指标,一次分清。设 TP/FP/FN 分别是「报对的正例/误报/漏报」:
- 准确率 accuracy = 全部答对 ÷ 全部。不平衡时无意义。
- 精确率 precision = TP ÷ (TP + FP)。我报警的里面,几个是真的。
- 召回率 recall = TP ÷ (TP + FN)。真的里面,我抓到几个。
- F1 = 精确率和召回率的调和平均。只在两者同等重要时才合理。
中文的「精确率」和「准确率」只差一个字,含义完全不同,是最容易说混的一对。记法:精确率的分母是「我报的」,召回率的分母是「真的有的」。
还有一对:ROC-AUC 和 PR-AUC。前者在极度不平衡时会偏乐观(因为它的横轴假正例率的分母是庞大的负例总数,几百个误报也只挪动一点点)。正例比例低于 5% 时,PR 曲线下面积(average_precision_score)通常更能反映真实体验。
那个 98.58% 的废物模型:
import numpy as np
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.dummy import DummyClassifier
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import (recall_score, precision_score, f1_score,
roc_auc_score, confusion_matrix)
X, y = make_classification(n_samples=20000, n_features=20, n_informative=6,
weights=[0.99, 0.01], flip_y=0.01, random_state=20)
Xa, Xz, ya, yz = train_test_split(X, y, test_size=0.3, random_state=0, stratify=y)
dummy = DummyClassifier(strategy="most_frequent").fit(Xa, ya)
real = make_pipeline(StandardScaler(), LogisticRegression(max_iter=5000)).fit(Xa, ya)
p = real.predict_proba(Xz)[:, 1]; pred = (p >= 0.5).astype(int)
print("dummy 准确率 %.2f%% 召回 %.1f%%"
% (dummy.score(Xz, yz)*100, recall_score(yz, dummy.predict(Xz), zero_division=0)*100))
print("真模型 准确率 %.2f%% 召回 %.1f%% 精确 %.1f%% AUC %.4f"
% ((pred == yz).mean()*100, recall_score(yz, pred)*100,
precision_score(yz, pred, zero_division=0)*100, roc_auc_score(yz, p)))
print(confusion_matrix(yz, pred))
best = max(((f1_score(yz, (p >= t).astype(int), zero_division=0), t)
for t in np.linspace(0.01, 0.99, 99)))
print("最优阈值 %.2f F1 %.4f (0.5 时 %.4f)"
% (best[1], best[0], f1_score(yz, pred, zero_division=0)))
那个「噪声最重要」:
import pandas as pd
from sklearn.ensemble import RandomForestClassifier
from sklearn.inspection import permutation_importance
rng = np.random.default_rng(202)
n = 5000
useful = rng.integers(0, 2, n)
X2 = pd.DataFrame({"真有用": useful,
"噪声_连续": rng.random(n),
"噪声_千类": rng.integers(0, 1000, n)})
y2 = (useful + (rng.random(n) < 0.25).astype(int)) % 2
Xf, Xg, yf, yg = train_test_split(X2, y2, test_size=0.3, random_state=0, stratify=y2)
rf = RandomForestClassifier(n_estimators=300, random_state=0).fit(Xf, yf)
print(dict(zip(X2.columns, rf.feature_importances_.round(4))))
perm = permutation_importance(rf, Xg, yg, n_repeats=20, random_state=0)
print(dict(zip(X2.columns, perm.importances_mean.round(4))))
python3 -c "print('正例 1.42%% → 永远说不是的准确率 %.2f%%' % (100-1.42))"
试着把 噪声_千类 的取值范围从 1000 改成 10,看它的重要性怎么掉——这一个旋钮就能证明「基数」才是那个重要性的真正来源。
- 医学筛查里的敏感性与特异性。医学界用的就是召回率(敏感性)和真负率(特异性),而且从来不单看准确率——因为疾病发生率天然就低。一个「所有人都健康」的模型在罕见病上准确率能到 99.9%。这个领域两百年前就明白了这件事。
- 推荐系统的线上线下不一致。离线 AUC 提升了,线上点击率没动。一个常见原因就是指标不匹配:AUC 衡量的是全局排序,而用户只看前十条。该看的是 NDCG@10、Recall@10 这类「只看头部」的指标。
- SHAP 值为什么流行起来。正因为
feature_importances_有这一章的缺陷。SHAP 基于博弈论的 Shapley 值,能给出每个样本上每个特征的贡献,而且不偏向高基数特征。不过它也不是万能的——相关特征之间的贡献分配仍然是个开放问题。 - 「这个模型说用户 ID 最重要」。这是基数偏好最经典的现场:用户 ID 有几百万个取值,在树模型里重要性总是排第一,但它没有任何泛化价值。看到这种结果,先查特征的基数,再查是不是有泄漏。
「模型准确率 99%,效果很好。而且它自己告诉了我哪些特征最重要,可以拿去做业务决策。」
两句话都可能是错的,而且错得方向相反。99% 的准确率在 1.42% 正例的数据上可能连「什么都不做」都不如——本机那个 dummy 就有 98.58%,而它一个正例都没抓到。正确的做法是先跑一个 DummyClassifier,把它的分数当地板。
而 feature_importances_ 偏爱取值多的列,本机上两根纯噪声拿走了 75.4% 的重要性。拿它去做业务决策(「我们要重点优化这个指标」),可能是在优化一根噪声。换成置换重要性,噪声掉到 0.004,真有用的那列是 0.198——差 44 倍。
三条:(一)报分数前先跑 dummy 基线;(二)不平衡数据看召回/精确/PR-AUC,不看准确率;(三)特征重要性一律用 permutation_importance,而且在测试集上做。
正确答案是 C:98.58%。它把 98.58% 的负例全答对了,正例一个没抓到(召回率 0)。
A 「瞎猜只能有一半对」——「瞎猜」和「永远猜多数类」是两回事。DummyClassifier 有好几种策略:uniform(真的瞎猜)确实约 50%,most_frequent(永远猜多数类)是 98.58%,stratified(按类别比例随机)约 97.2%。选哪个当基线,取决于你想让基线代表什么。
B 「1.42%」——这是它的召回率的反面,或者说是「它答对的正例比例」。把准确率和召回率搞混,是这一章想解决的主要问题。记法:准确率的分母是全部样本,召回率的分母只有正例。
D 「没学习就没法算」——恰恰相反,DummyClassifier 存在的意义就是「不学习也能算,而且算出来常常高得吓人」。sklearn 专门提供这个类,就是为了逼你面对这件事。它应该是每个分类项目的第一行代码,不是最后一行。
model.score() 会老老实实给你准确率,不会问你数据平不平衡;feature_importances_ 会给你一个数组,不会告诉你它偏爱高基数的列。这一层的检查全靠你自己。
这一章的一句话
指标回答的永远是它自己定义的那个问题,不是你心里那个问题:准确率问「多数类分对了多少」,feature_importances_ 问「哪一列提供了最多的切分机会」——而你想问的是「这模型有用吗」和「哪一列真的重要」。
卷 IV 到此结束。这条列已经走完了五层:一块字节、一个带名字的列、一个视觉通道、一个特征。
最后一卷回答两个问题。第一个是往下还能走多远:把同一份两百万行的数据存成 CSV 要 60 MB、读一列要 334 毫秒,存成 Parquet 只要 19.4 MB、读一列 3 毫秒——快 100 倍。而这个差别的原因,正是第 1 章那句话:谁跟谁挨着放。