一个分数是没有意义的
上一章处理的是「流程被污染了」。这一章假设你的流程完全正确,然后问一个更朴素的问题:你报出来的那个 95.91%,误差有多大?本机把同一份数据、同一个模型跑了 200 遍,只换了随机划分的种子。最低 93.57%,最高 100.00%。如果你只跑一遍,你会得到这个区间里的某一个数,然后把它写进报告里——就好像它是一个确定的事实。
569 个样本的数据集,train_test_split(test_size=0.3),逻辑回归。你把 random_state 从 0 换到 199,跑 200 次。
问:200 个准确率,最高和最低差多少?
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 折里每一折轮流当验证集。但它不能替代那个最后才看的测试集——如果你根据交叉验证的结果反复调整方案(换模型、换特征、换预处理),那份交叉验证的分数同样会变得乐观。
还有一个词:StratifiedKFold 的 stratified(分层)指「每一折里各类别的比例和整体一致」。分类任务应该永远用它——不然在不平衡数据上,可能某一折里正例一个都没有。
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 里面,不是反过来。
正确答案是 C:6.43 个百分点,从 93.57% 到 100.00%。
A 「数据和模型都没变,怎么会差很多」——变的是哪些样本进了测试集。测试集只有 171 个样本,一个样本值 0.58 个百分点,6.43 的跨度不过是 11 个样本的差别。「同一份数据」这个说法在这里是个陷阱:每次划分给模型看到的其实是不同的训练集。 B 「约 2 个百分点」——这大约是标准差的量级(本机 1.09)。但 200 次抽样的极差会比标准差大好几倍,这是极值分布的性质:抽得越多,抽到极端值的机会越大。所以「我跑了几十次,最好的一次是 100%」这句话,本身就没有信息量。 D 「超过 20 个百分点」——在这份数据上不会。但如果样本更少(几十行)或者类别极不平衡,20 个百分点的跨度完全可能出现。这也是为什么小数据集上的模型比较尤其不可信——第 18 章那个 50 行的例子就是极端情形。cross_val_score 看标准差;比较方案时先问「差距有没有超过一个标准差」。
这一章的一句话
一个分数是一个随机变量的一次抽样,不是一个事实;在这份数据上,换个随机种子带来的波动(6.43 个百分点)比五个不同模型之间的差距还大——所以报告里该出现的是均值和标准差,不是那个你恰好跑出来的数。
下一章是卷 IV 的最后一章,处理最后一层伪装:就算流程干净、分数稳定,分数本身也可能在骗你。本机造了一份 1.42% 正例的数据:一个「永远预测多数类」的模型,准确率 98.58%,召回率 0——它一个正例都没抓到。另外,随机森林会告诉你最重要的那一列是一根纯噪声,两根噪声列合起来拿走了 75.4% 的重要性。