卷 IV · 唯一的队形CH 20深度 20/23

分数也会骗人

卷 IV 的最后一层伪装。前两章处理的是「流程被污染」和「分数不稳定」;这一章假设流程干净、分数稳定,然后指出分数本身可能在回答一个你没问的问题。两个实测:一个什么都不做的模型拿到 98.58% 的准确率;一台随机森林信誓旦旦地告诉你,最重要的那一列是一根纯噪声。

98.58% 的废物模型召回 0噪声拿走 75.4%

▷ 先猜一下

一份两万行的数据,正例只占 1.42%(比如信用卡欺诈、罕见病筛查)。你训练一个 DummyClassifier(strategy="most_frequent")——它的策略是「永远预测多数类」,也就是永远说「不是」

问:它的准确率是多少?

A 大约 50%。瞎猜的模型只能有一半对 B 大约 1.42%。它一个正例都没抓到 C 98.58%。它把 98.58% 的负例全部答对了 D 无法计算。它没有真正学习任何东西

伪装一:准确率

答案是 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(两者的调和平均)。
  • 还没定阈值 → 看 AUCPR 曲线下面积

而无论看什么,都先跑一个 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_置换重要性
噪声_连续50000.3997 ★最高0.0045
噪声_千类9950.35460.0041
真有用20.24560.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-AUCPR-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,而且在测试集上做。

◇ 揭晓

正确答案是 C98.58%。它把 98.58% 的负例全答对了,正例一个没抓到(召回率 0)。

A 「瞎猜只能有一半对」——「瞎猜」和「永远猜多数类」是两回事。DummyClassifier 有好几种策略:uniform(真的瞎猜)确实约 50%,most_frequent(永远猜多数类)是 98.58%,stratified(按类别比例随机)约 97.2%。选哪个当基线,取决于你想让基线代表什么。 B 「1.42%」——这是它的召回率的反面,或者说是「它答对的正例比例」。把准确率和召回率搞混,是这一章想解决的主要问题。记法:准确率的分母是全部样本,召回率的分母只有正例。 D 「没学习就没法算」——恰恰相反,DummyClassifier 存在的意义就是「不学习也能算,而且算出来常常高得吓人」。sklearn 专门提供这个类,就是为了逼你面对这件事。它应该是每个分类项目的第一行代码,不是最后一行。
⌗ 交接单
一个训练好的模型 + 一份没见过的数据。 一组指标,而且每个指标都要有一个「什么都不做能拿多少」的基线作对照;以及一个由业务成本决定(不是默认 0.5)的阈值。 没人。model.score() 会老老实实给你准确率,不会问你数据平不平衡;feature_importances_ 会给你一个数组,不会告诉你它偏爱高基数的列。这一层的检查全靠你自己。

这一章的一句话

指标回答的永远是它自己定义的那个问题,不是你心里那个问题:准确率问「多数类分对了多少」,feature_importances_ 问「哪一列提供了最多的切分机会」——而你想问的是「这模型有用吗」和「哪一列真的重要」。

卷 IV 到此结束。这条列已经走完了五层:一块字节、一个带名字的列、一个视觉通道、一个特征。

最后一卷回答两个问题。第一个是往下还能走多远:把同一份两百万行的数据存成 CSV 要 60 MB、读一列要 334 毫秒,存成 Parquet 只要 19.4 MB、读一列 3 毫秒——快 100 倍。而这个差别的原因,正是第 1 章那句话:谁跟谁挨着放。