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

一个分数是没有意义的

上一章处理的是「流程被污染了」。这一章假设你的流程完全正确,然后问一个更朴素的问题:你报出来的那个 95.91%,误差有多大?本机把同一份数据、同一个模型跑了 200 遍,只换了随机划分的种子。最低 93.57%,最高 100.00%。如果你只跑一遍,你会得到这个区间里的某一个数,然后把它写进报告里——就好像它是一个确定的事实。

93.57 到 100.00跨度 6.43一个样本值 0.58

▷ 先猜一下

569 个样本的数据集,train_test_split(test_size=0.3),逻辑回归。你把 random_state 从 0 换到 199,跑 200 次。

问:200 个准确率,最高和最低差多少?

A 不到 1 个百分点。数据没变、模型没变,怎么会差很多 B 约 2 个百分点 C 约 6.4 个百分点,从 93.57% 到 100.00% D 超过 20 个百分点

200 次划分,200 个分数

569 个样本,逻辑回归,200 次不同的随机划分

  最低      93.57%      (random_state = 22)
  最高     100.00%      (random_state = 125)
  均值      97.58%
  标准差     1.09
  跨度       6.43 个百分点

停下来想一想这两个数意味着什么。如果你写 random_state=125,你的报告会说「准确率 100%」;如果你写 random_state=22,你的报告会说「93.57%」。同一份数据、同一个模型、同一段代码——差别只有一个整数。

再对照第 16 章那张表:五个完全不同的模型(逻辑回归、K 近邻、决策树、随机森林、SVM),准确率跨度是 5.27 个百分点(90.64% 到 95.91%)。

◆ 主线

随机划分带来的波动(6.43),比五个不同模型之间的差距(5.27)还大。

所以「跑一遍、看看哪个模型分数高、就选它」这个动作,选出来的很可能只是「在这次划分上运气最好的那个」

一个分数不是一个事实,是一个随机变量的一次抽样。而报告里通常只写那次抽样,不写它的分布。

波动是从哪来的

算一笔账就明白了。569 个样本,30% 拿去测试,测试集只有 171 个样本。

171 个测试样本
  一个样本预测对错,准确率变化   100 / 171 = 0.58 个百分点

一个样本就值 0.58 个百分点。那 6.43 的跨度,不过是「11 个样本的差别」。换一次划分,某些难分的样本从训练集跑到测试集,分数就掉几个点——这不是模型的性质,是抽样的性质

这也解释了为什么「95.91% 比 95.32% 好」这句话通常没有意义:0.59 个百分点连一个样本都不到。报告里那个小数点后两位,全是噪声。

那该怎么报

交叉验证的价值正在这里——它不只是「防止过拟合」,更重要的是给你一个分布,而不是一个点

scores = cross_val_score(pipe, X, y, cv=StratifiedKFold(10, shuffle=True,
                                                        random_state=0))
print("%.2f%% ± %.2f" % (scores.mean() * 100, scores.std(ddof=1) * 100))
十折交叉验证的十个分数
  94.7  94.7  96.5  100.0  100.0  96.5  98.2  100.0  98.2  98.2

  均值   97.72%
  标准差  2.03

注意这十个折内部的波动(94.7 到 100.0)和上面那 200 次划分的波动是同一回事。报「97.72% ± 2.03」比报「95.91%」诚实得多,因为它把不确定性也报出来了。

几条实用的做法:

做法什么时候用
cross_val_score + 报均值和标准差默认做法。比单次划分贵 k 倍,但便宜得起
RepeatedStratifiedKFold数据少(几百行)时,重复几次交叉验证,波动估得更稳
cross_validate(带 return_train_score想同时看训练分数——训练远高于测试就是过拟合
单次 train_test_split数据非常多(几十万行以上)时才够稳

调参:一个更隐蔽的版本

现在把这一章和上一章接起来。如果你在同一个测试集上试了 20 组超参数,然后报告最好的那个分数——那个分数也是被污染的

道理和上一章一样:你在用测试集做选择。试的组合越多,选出来的那个「最好」就越可能只是运气。这是上一章那个 98.9% 的温和版本,机制完全相同。

正确的做法是三分,或者更省事的嵌套交叉验证

from sklearn.model_selection import GridSearchCV, cross_val_score

grid = GridSearchCV(pipe, {"clf__C": [0.01, 0.1, 1, 10]}, cv=5)

# 错:直接报 grid.best_score_ —— 它是在「用来选参数的那些折」上算的
# 对:把整个 GridSearchCV 当成一个估计器,再套一层交叉验证
scores = cross_val_score(grid, X, y, cv=5)      # 嵌套交叉验证
print("%.2f%% ± %.2f" % (scores.mean() * 100, scores.std(ddof=1) * 100))

内层的交叉验证负责选参数,外层的负责估性能。这两件事必须用不同的数据,否则你报的是「选择过程在自己身上的成绩」。

注意那个参数名 "clf__C"两个下划线,前面是 Pipeline 里那一步的名字,后面是它的参数名。这是 sklearn 的命名约定,让你能调 Pipeline 里任意一层的参数——包括预处理那些步骤。「连预处理的超参数也一起调」正是 Pipeline 的另一半价值。

✎ 术语正名

验证集测试集是两样东西,混用是这一章所有问题的源头:

  • 训练集:模型学参数用的。
  • 验证集:你选东西用的——选模型、选超参数、决定什么时候停。它会被「用坏」,因为你在拿它做选择。
  • 测试集:只在最后看一次,用来报告。看过第二次就不再是测试集了。

交叉验证做的事,是把「训练 + 验证」这一段自动化了:k 折里每一折轮流当验证集。但它不能替代那个最后才看的测试集——如果你根据交叉验证的结果反复调整方案(换模型、换特征、换预处理),那份交叉验证的分数同样会变得乐观。

还有一个词:StratifiedKFoldstratified(分层)指「每一折里各类别的比例和整体一致」。分类任务应该永远用它——不然在不平衡数据上,可能某一折里正例一个都没有。

⌨ 自己跑一遍

200 次划分,跑一分钟左右:

import numpy as np
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split, cross_val_score, StratifiedKFold
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
from sklearn.linear_model import LogisticRegression

X, y = load_breast_cancer(return_X_y=True)
accs = []
for seed in range(200):
    a, b, c, d = train_test_split(X, y, test_size=0.3, random_state=seed, stratify=y)
    m = make_pipeline(StandardScaler(), LogisticRegression(max_iter=5000)).fit(a, c)
    accs.append(m.score(b, d))
accs = np.array(accs)
print("最低 %.2f%%(seed=%d)  最高 %.2f%%(seed=%d)"
      % (accs.min()*100, accs.argmin(), accs.max()*100, accs.argmax()))
print("均值 %.2f%%  标准差 %.2f  跨度 %.2f"
      % (accs.mean()*100, accs.std(ddof=1)*100, (accs.max()-accs.min())*100))
print("一个测试样本值 %.2f 个百分点" % (100 / (len(y) - int(len(y)*0.7))))

然后对照交叉验证的报法:

cv = cross_val_score(make_pipeline(StandardScaler(), LogisticRegression(max_iter=5000)),
                     X, y, cv=StratifiedKFold(10, shuffle=True, random_state=0))
print([round(s*100, 1) for s in cv])
print("%.2f%% ± %.2f" % (cv.mean()*100, cv.std(ddof=1)*100))     # 97.72% ± 2.03

python3 -c "n=171;print('一个样本 = %.2f 个百分点' % (100/n))"

test_size 从 0.3 改成 0.5,跨度会变小(测试集大了);改成 0.1,跨度会明显变大。这一个旋钮就能让你直观感受「测试集大小 ↔ 分数稳定性」的关系。

▸ 在现实里
  • 论文里的「我们的方法比基线高 0.3%」。如果没报标准差、没做多次运行,这个结论多半站不住。机器学习会议这几年开始强制要求报告多次随机种子的均值和方差,正是因为这个问题太普遍。一个不报误差的提升,等于没报。
  • A/B 测试为什么要算样本量。同一件事的另一个面孔:要检测出 1% 的提升,需要多少样本?这一章那个「一个样本值 0.58 个百分点」,就是 A/B 测试里最小可检测效应的直觉版。样本量不够时,你测到的差异全是噪声。
  • Kaggle 的公榜与私榜。公榜只用一部分测试数据算分,所以榜首和第十名的差距常常在噪声范围内。比赛结束时的「榜单大洗牌」,本质就是这一章——很多人在用公榜做选择,等于把公榜当成了验证集。
  • 金融回测里的过拟合。试一万个策略,总有一个在历史数据上年化 30%。这在量化领域有个专门的名字叫「回测过拟合」,还有专门的修正方法(deflated Sharpe ratio)。试的次数越多,你选出来的「最好」越可能是运气。和调参是同一个问题。
✗ 这个直觉是错的

「我固定了 random_state,所以我的结果是可复现的、可靠的。」

可复现和可靠是两回事。固定种子保证「你和别人跑出同一个数」,不保证「这个数接近真实性能」。本机实测:换个种子,同一段代码给出的答案在 93.57% 到 100.00% 之间。

更糟的一个变体是「挑一个好看的种子」。这件事往往不是故意的:你跑了几次,发现某次结果特别好,就把那个种子留下了。这和上一章的泄漏是同一类错误——你在用测试集做选择。

三条纪律:(一)报均值和标准差,不报单个分数;(二)比较两个方案时,先看差距有没有超过一个标准差,没超过就说「看不出差别」;(三)调参和评估用不同的数据——GridSearchCV 套在 cross_val_score 里面,不是反过来。

◇ 揭晓

正确答案是 C6.43 个百分点,从 93.57% 到 100.00%。

A 「数据和模型都没变,怎么会差很多」——变的是哪些样本进了测试集。测试集只有 171 个样本,一个样本值 0.58 个百分点,6.43 的跨度不过是 11 个样本的差别。「同一份数据」这个说法在这里是个陷阱:每次划分给模型看到的其实是不同的训练集。 B 「约 2 个百分点」——这大约是标准差的量级(本机 1.09)。但 200 次抽样的极差会比标准差大好几倍,这是极值分布的性质:抽得越多,抽到极端值的机会越大。所以「我跑了几十次,最好的一次是 100%」这句话,本身就没有信息量。 D 「超过 20 个百分点」——在这份数据上不会。但如果样本更少(几十行)或者类别极不平衡,20 个百分点的跨度完全可能出现。这也是为什么小数据集上的模型比较尤其不可信——第 18 章那个 50 行的例子就是极端情形。
⌗ 交接单
一个 Pipeline + 全部数据。 一个分布(k 个折的分数),不是一个数。报告时写「均值 ± 标准差」。 没人。sklearn 会老老实实按你给的种子算出一个数,不会告诉你这个数有多不稳。自己检查:至少跑一次 cross_val_score 看标准差;比较方案时先问「差距有没有超过一个标准差」。

这一章的一句话

一个分数是一个随机变量的一次抽样,不是一个事实;在这份数据上,换个随机种子带来的波动(6.43 个百分点)比五个不同模型之间的差距还大——所以报告里该出现的是均值和标准差,不是那个你恰好跑出来的数。

下一章是卷 IV 的最后一章,处理最后一层伪装:就算流程干净、分数稳定,分数本身也可能在骗你。本机造了一份 1.42% 正例的数据:一个「永远预测多数类」的模型,准确率 98.58%,召回率 0——它一个正例都没抓到。另外,随机森林会告诉你最重要的那一列是一根纯噪声,两根噪声列合起来拿走了 75.4% 的重要性。