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

一份纯随机的数据,我让它给出 98.9%

这是全书的招牌实验,也是整本书里我最想让你亲手跑一遍的二十行代码。数据是我当场造的:50 个样本,5000 列纯高斯噪声,标签靠抛硬币。这里面一点信号都没有,真值是 50%。然后我用一套完全标准的流程——按相关性挑特征、五折交叉验证、K 近邻分类器——让它报出 98.9% 的准确率。没有一行代码是错的。没有任何一步是我编的。

98.9% 对 51.1%真值 50%复刻 ESL 7.10.2

▷ 先猜一下

一份 50 行 × 5000 列的数据,全部是 np.random.normal 生成的噪声。标签是 25 个 0 和 25 个 1,随机打乱。特征和标签之间没有任何关系。

流程:先按「与标签的相关性」挑出最强的 100 列,然后用这 100 列做五折交叉验证。

问:交叉验证会报出多少准确率?

A 50% 左右。数据里没有信号,交叉验证会诚实地告诉你 B 60% 左右。会有一点点乐观偏差,但不严重 C 75% 左右。挑特征确实有影响,但交叉验证能挡住大部分 D 接近 100%。交叉验证完全挡不住

先把数据摊开:这里面真的什么都没有

rng = np.random.default_rng(seed)
X = rng.normal(size=(50, 5000))          # 25 万个格子,全是噪声
y = np.array([0] * 25 + [1] * 25)
rng.shuffle(y)                            # 标签和 X 毫无关系

先看一个数,它是整章的关键:在这 5000 列里,与标签相关性最强的那一列,相关系数有多大?

50 次独立模拟,每次 5000 列

  最大 |相关系数| 的平均值      0.522
  最大 |相关系数| 的最大值      0.652

0.522。在只有 50 个样本的情况下,一列纯噪声和一列纯噪声之间,相关系数的标准差大约是 1/√49 ≈ 0.14。你抽 5000 次,总会抽到几个 3.5 倍标准差之外的。

这就是全部的机关:「与标签高度相关」不等于「有信号」。在 5000 列里,纯靠运气就能挑出一批看起来相关性很强的列。

两段代码,差别只有一处

错的那段(这是无数论文和 Kaggle notebook 里的写法):

sel = TopCorr(k=100).fit(X, y)                    # ← 在【全部】数据上挑列
X_selected = sel.transform(X)
cross_val_score(KNeighborsClassifier(1), X_selected, y, cv=5)

对的那段:

pipe = Pipeline([("sel", TopCorr(k=100)),
                 ("clf", KNeighborsClassifier(1))])
cross_val_score(pipe, X, y, cv=5)                 # ← 挑列在每一折里重做

本机跑 50 次独立模拟,取平均:

做法交叉验证准确率错误率
先在全部数据上挑列,再交叉验证98.9%1.1%
挑列放进 Pipeline,每折重挑51.1%48.9%
真值50.0%50.0%

98.9% 对 51.1%。差 47.8 个百分点。而两段代码的差别,只是挑列这一步写在了 cross_val_score 的外面还是里面

◆ 主线

交叉验证保护的不是「模型」,是「从数据到答案的整条流水线」。

任何一步只要看过全部数据——挑特征、算均值、算分位数、做主成分分析、填缺失值、决定分箱边界——它就已经把测试集的信息带进了训练。交叉验证之后再切分,切开的只是最后一步。

所以判据不是「我做没做交叉验证」,是:「从原始数据到预测,每一步都在 Pipeline 里吗?」

为什么泄漏能大到 47.8 个百分点

把机制拆开看,它非常直白:

  1. 挑列时,我用了全部 50 个样本的标签去算相关性。
  2. 于是挑出来的 100 列,是「在这 50 个样本上,恰好和这组标签吻合得最好的 100 列」。
  3. 交叉验证把数据分成五折,拿 40 个训练、10 个测试。
  4. 但那 10 个测试样本的标签,早在第 1 步就参与了挑列。被挑中的列,天然就在这 10 个样本上也「对得上」。
  5. 1 近邻分类器在 100 列精心挑选过的维度上,几乎必然能找到「对的」邻居。

换句话说:测试集的标签,通过「哪些列被选中」这个渠道,泄漏进了训练过程。它没有直接被模型看到,但它影响了模型能看到什么——这就够了。

这个实验不是我编的。它复刻自 Hastie、Tibshirani、Friedman 的《统计学习基础》(ESL) 第 7.10.2 节,那本书是统计学习领域的标准教材。原文报告的交叉验证错误率是 3%,本机 50 次模拟的平均是 1.1%——同一个量级(差别来自筛选规则和分类器的细节)。这个例子在教材里已经站了二十年,而同样的错误至今每天都在发生。

但不是所有泄漏都这么严重

这一点同样重要,否则你会走向另一个极端:草木皆兵。同样是「用全量数据 fit」,换成标准化试试:

300 个样本、40 列(其中 5 列真有信号)
  全量数据先 StandardScaler,再交叉验证       78.33%
  StandardScaler 放进 Pipeline                78.33%
  差                                          0.00

一点差别都没有。为什么?因为标准化只用到每列的均值和标准差——这两个数是从 X 算的,完全没碰 y,而且 300 个样本算出来的均值和 240 个样本算出来的几乎一样。泄漏的信息量趋近于零。

把这两个实验放在一起,泄漏的严重程度就有了一把尺:

这一步用到 y 吗选择性强吗泄漏后果
按相关性挑特征5000 选 100,极强+47.8 个百分点
用目标值编码类别(target encoding)很大
用全量数据过采样(SMOTE)中等
用全量数据做 PCA不用小到中
标准化 / 归一化不用≈ 0

两条判据:这一步用到 y 吗?这一步的「选择性」有多强(从多少候选里挑出多少)?两个都是「是」的时候,泄漏会大到让整个实验作废。

不过工程上的结论仍然是全部放进 Pipeline——因为判断「这次泄漏严不严重」需要动脑子,而 Pipeline([...]) 不需要。把安全做成默认,比每次都判断一遍便宜。

还有一类泄漏,Pipeline 也救不了

Pipeline 解决的是「哪一步看了全部数据」。还有一类泄漏在更前面,出在特征本身的含义上:

  • 未来信息。用「用户最终是否流失」时期的数据去预测流失。典型症状:某个特征的重要性高得离谱,AUC 接近 1。
  • 目标的代理变量。预测「是否患病」,特征里有「用药记录」——用药是因为已经确诊了。
  • 分组泄漏。同一个病人的多张影像被随机分到了训练集和测试集。模型学会的是「认这个病人」,不是「认这个病」。解法是 GroupKFold,按病人分组切分。
  • 时间泄漏。时序数据用随机切分,等于拿未来预测过去。解法是 TimeSeriesSplit

这几类的共同症状是「分数好得不像话」。所以有一条经验:当你的模型表现远超预期时,第一反应应该是找泄漏,不是庆祝。在真实的业务数据上,AUC 突然从 0.75 跳到 0.95,绝大多数时候不是模型变聪明了。

✎ 术语正名

数据泄漏(data leakage):训练过程中用到了「预测时拿不到的信息」。注意这个定义的重点是「预测时拿不到」——它是一个关于时间和可得性的概念,不是关于数据质量的。

容易混的两个词:

  • 过拟合(overfitting):模型把训练集的噪声当成规律。交叉验证能测出来。
  • 泄漏(leakage):评估流程本身被污染了。交叉验证测不出来,因为交叉验证自己就是被污染的那个。

这个区别是这一章的要害:大家学到的「用交叉验证防止过拟合」是对的,但它给人一种错觉,好像做了交叉验证就安全了。而泄漏恰恰是那种能骗过交叉验证的东西。

⌨ 自己跑一遍

这是全书最值得亲手跑的二十行。跑一次要几十秒:

import numpy as np
from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.model_selection import cross_val_score, StratifiedKFold
from sklearn.neighbors import KNeighborsClassifier
from sklearn.pipeline import Pipeline

class TopCorr(BaseEstimator, TransformerMixin):
    """按 |皮尔逊相关| 挑 k 列 —— ESL 原文用的就是这个筛法"""
    def __init__(self, k=100):
        self.k = k
    def fit(self, X, y):
        yc = np.asarray(y, float) - np.mean(y)
        Xc = X - X.mean(0)
        denom = np.sqrt((Xc ** 2).sum(0)) * np.sqrt((yc ** 2).sum()) + 1e-300
        r = (Xc * yc[:, None]).sum(0) / denom
        self.idx_ = np.argsort(-np.abs(r))[:self.k]
        return self
    def transform(self, X):
        return X[:, self.idx_]

leak, ok = [], []
for rep in range(50):
    rng = np.random.default_rng(4000 + rep)
    X = rng.normal(size=(50, 5000))
    y = np.array([0] * 25 + [1] * 25); rng.shuffle(y)
    cv = StratifiedKFold(5, shuffle=True, random_state=rep)
    clf = KNeighborsClassifier(n_neighbors=1)

    sel = TopCorr(100).fit(X, y)                      # ← 错:先看全部数据
    leak.append(cross_val_score(clf, sel.transform(X), y, cv=cv).mean())

    pipe = Pipeline([("sel", TopCorr(100)), ("clf", clf)])   # ← 对
    ok.append(cross_val_score(pipe, X, y, cv=cv).mean())

print("先挑列再交叉验证  %.1f%%" % (np.mean(leak) * 100))    # 98.9%
print("挑列放进 Pipeline %.1f%%" % (np.mean(ok) * 100))      # 51.1%
print("真值               50.0%")

跑完之后改两个数感受一下:把列数 5000 改成 50(泄漏几乎消失),把样本数 50 改成 500(泄漏大幅减小)。「列多、样本少」是泄漏放大器。

python3 -c "import numpy as np;r=np.random.default_rng(0);X=r.normal(size=(50,5000));y=r.integers(0,2,50);print('最大 |r| = %.3f' % max(abs(np.corrcoef(X[:,i],y)[0,1]) for i in range(5000)))"

最后那行只要 numpy,十秒钟出结果——它就是整章的机关:纯噪声里挑出来的「最相关」那一列,相关系数能到 0.5 以上。

▸ 在现实里
  • 基因组学里的经典事故。基因表达数据天然是「几十个样本、几万个基因」——正是这一章那个形状。2000 年代有一批高影响力论文,报告的分类准确率极高,后来被发现无法复现,原因就是特征筛选在交叉验证之外做的。Baggerly 和 Coombes 2009 年那篇复核论文(关于杜克大学的化疗响应预测)直接导致了临床试验被叫停——这不是学术八卦,是真的有病人被卷进去了。
  • Kaggle 上「public LB 很高、private LB 崩了」的标准剧本。选手用全量数据(含测试集特征)做了目标编码或者特征选择,本地和公榜都很好看,私榜一揭晓掉到几百名开外。
  • 医学影像里的分组泄漏。同一个病人的 CT 切片被随机分进训练和测试集,模型学到的是「认这个人的解剖结构」。论文里报 0.99 的 AUC,换一家医院的数据就掉到 0.6。正确做法是按病人分组切分(GroupKFold)。
  • 推荐系统的时间泄漏。用随机切分评估推荐模型,等于允许模型「知道用户后来买了什么」再来预测他现在会点什么。线上 A/B 一跑,效果和离线指标对不上——这是推荐系统团队最常见的困惑之一。
✗ 这个直觉是错的

「我做了交叉验证,所以我的评估是可靠的。」

交叉验证保护的只有它包住的那部分。写在 cross_val_score 外面的每一步——挑特征、算均值、填缺失、降维、过采样——都在用全部数据,包括每一折的测试集。本章实测:这一个位置的差别,值 47.8 个百分点。

这个误解特别顽固,因为它看起来什么都做对了:分了折、算了平均、报了标准差。而且泄漏的方向永远是让分数变好看,所以没有任何东西会提醒你——你只会觉得自己的模型真不错。

三条可执行的纪律:(一)从原始数据到预测的每一步,都写进 Pipeline,然后把整个 Pipeline 交给 cross_val_score(二)看到远超预期的分数,先找泄漏。(三)数据有分组(病人、用户、门店)就用 GroupKFold,有时间顺序就用 TimeSeriesSplit——随机切分是最容易出错的默认。

◇ 揭晓

正确答案是 D接近 100%。本机 50 次模拟平均 98.9%,而真值是 50%

A 「交叉验证会诚实地告诉你」——只有当挑列这一步在交叉验证里面时它才诚实。放进 Pipeline 的版本给出 51.1%,离真值 50% 只差 1.1 个百分点,非常准。所以交叉验证本身没有问题,问题在于它包住了多少。 BC 「有偏差但不严重」——低估了「5000 选 100」这个筛选的威力。这里有一个决定量级的因素:候选越多、样本越少,泄漏越夸张。把 5000 列改成 50 列,泄漏几乎消失;把样本从 50 加到 500,泄漏大幅减小。而「列多样本少」正是基因组学、金融因子、文本特征的日常形状。 最后补一个数:这一章那两段代码,在真实的、有信号的数据上差距会小一些,但方向永远一致——泄漏只会让分数变好看,从来不会变难看。这就是它难以被发现的根本原因:没有人会去调查一个「表现超出预期」的模型。
⌗ 交接单
原始的 Xy一步预处理都还没做 一个把所有步骤(编码、缺失填充、缩放、降维、特征选择、模型)串起来的 Pipeline 对象。它自己也是一个估计器,可以整个交给 cross_val_score 没有任何东西会检查这一条。泄漏不会报错、不会警告,只会让分数变好看。这是全书最贵的一次静默失败——唯一的防线是「所有步骤都进 Pipeline」这条纪律。

这一章的一句话

交叉验证保护的不是模型,是它包住的那一段流程;任何一步只要在外面看过全部数据,测试集就已经泄漏了——而泄漏的方向永远是让分数变好看,所以它从不会被自己暴露。

下一章接着问一个更基本的问题:就算流程完全正确,一个分数值多少钱?本机把同一份数据、同一个模型跑了 200 次,只换 train_test_split 的随机种子。准确率从 93.57% 到 100.00%,跨度 6.43 个百分点。而这比第 16 章那五个模型之间的差距还大——也就是说,「挑准确率最高的模型」这个动作,挑的很可能只是运气。