手把手教你:数据泄漏的隐蔽来源,预处理做在了划分之前|AI工程师踩坑实录·第7篇

本文是《AI工程师踩坑实录》系列第 7 篇。验证集分数虚高,上线就崩,这个经典症状背后最常见的根因之一,是预处理(归一化、特征筛选、过采样)在数据划分之前就做完了。这一篇用一组严格控变量的真机实验,把泄漏的虚高水分量化出来。

本篇配套的对照实验脚本放在公Z号。

有个场景你可能经历过。

离线评估 0.97,上线实测 0.82。领导拿着离线报告问,那 15 个点去哪了。你翻遍代码,模型没变、数据没变、阈值没变。

分数不是丢了的,是从一开始就不存在。它的名字叫数据泄漏------你在训练阶段,提前偷看了本该留到考试才见的答案。

今天讲其中最隐蔽的一类,预处理做在了数据划分之前。

一、实验设计,两组只差一个时机

我用 digits 手写数字构造了一个不平衡数据集,多数类 0 到 6 各 175 张,少数类 7 到 9 各 12 张,用 SMOTE 过采样补齐少数类,同一个网络,同一个随机种子。

关键在设计对照组。两组的训练集基础完全一致(都是「训练集内部 SMOTE」的结果),唯一差异是,泄漏组额外混入 96 张合成样本,这些样本是测试集样本与其近邻插值出来的。

这个设计精确复刻了「全集 SMOTE 再划分」的真实行为。当你在全部数据上做过采样再划分,SMOTE 合成新样本时的插值父母,就包括后来分进测试集的那些样本。合成样本是测试样本的近亲,混进了训练集。

先看真实输出。

二、真实结果,虚高 4.2 个点,落差 15 个点

把四个数字摆在一起。

正确做法(先划分,训练集上过采样),离线宏 F1 0.926,线上新数据 0.786。

数据泄漏(全集过采样再划分),离线宏 F1 0.968,线上新数据 0.819。

三个读法。

第一,离线分数,泄漏组比正确组多报了 4.2 个点。这 4 个点不是模型变强了,是评估被污染了。

第二,看落差。两组从离线到线上都掉了 14 个点上下(这是不平衡数据加合成样本本身的泛化代价,另一码事)。但泄漏组承诺的起点是 0.968,它让所有人(包括你自己)相信模型值这个分数。

第三,最实际的读法。你拿着 0.968 的报告去立项、去承诺指标、去跟别的方案比稿,赢下了竞争。上线交付的是 0.819。泄漏偷走的不是分数,是你的决策依据。

三、机制:合成样本是测试集的近亲

把两条流水线画出来,出问题的只是步骤顺序。

SMOTE 的工作方式是,对每个少数类样本找近邻,在两者连线上插值生成新样本。它不管你将来打算怎么划分,谁在数据池里,谁就可能成为插值的父母。

全集上做过采样,测试集样本就在池子里。它参与的合成样本进了训练集,模型训练时等于拿着测试题的变体在做练习。评估时再遇到「原题」,分数自然虚高。

过采样只是这一类泄漏的代表。同族还有几个常见成员,归一化(scaler 在全集上 fit,测试集的均值方差混了进来)、特征筛选(在全集上算互信息再选特征,选出的特征偏向测试集)、目标编码(在全集上算类别统计,测试样本的标签直接渗进特征)。

四、有个反直觉的实验发现,如实报告

我在准备这篇文章时,先用归一化泄漏做了一版实验,StandardScaler 在全集上 fit 对比只在训练集 fit,结果两组都是 0.927,零差距。

如实报告这个结果,因为它是个重要知识。归一化泄漏在这份数据上拉不开差距,原因是像素分布太均匀(训练均值 4.89,测试均值 4.86),偷看到的统计量几乎没有信息量。

但这不代表归一化泄漏无害。换个分布偏移明显的场景(时序数据、按机构划分的医疗数据),同样的错误立刻兑现成分数水分。泄漏的危害大小取决于数据分布差异,而错误的性质不变。所以结论只有一个,正确顺序是无条件的,不是「看数据再定」。

五、防线,用 Pipeline 把顺序焊死

对付这类泄漏,最稳的办法不是靠自觉,是让工具替你管住顺序。

sklearn 的 Pipeline 和 imblearn 的 Pipeline(支持采样器)会把「fit 只发生在训练折」这件事自动化。交叉验证时每一折的预处理都只在该折的训练部分上拟合,划分边界由框架保证。手写顺序的代码,每次重构每次都有机会犯错,Pipeline 写一次,顺序终身锁定。

三条纪律带走。

  1. 看到数据先划分,再做任何统计类处理,划分是第一行代码。
  2. 预处理逻辑全部塞进 Pipeline,别在 DataFrame 上手动 fit。
  3. 上线前用一份从未参与的 holdout 数据做最终评测,它是你唯一没偷看过的考卷。

六、常见问题 3 连

1. 怎么快速自查有没有泄漏?

一个粗暴但有效的办法。把测试集的标签全部随机打乱再评一次,如果分数没有掉到随机水平,说明模型在测试集上拿到了标签相关信息,基本可以断定有泄漏。再配合检查所有预处理的 fit 调用发生在哪一步,两分钟出结论。

2. 时序数据也是先划分吗?

原则一样但方向更严,时序数据必须按时间切,训练在前、测试在后,而且不能用未来数据做任何统计(归一化、编码、特征筛选都只允许用历史窗口)。shuffle 划分对时序是灾难级的泄漏。

3. 目标编码怎么用才不漏?

用带 leave-one-out 或者交叉拟合的版本(CatBoost 的 ordered encoding、sklearn 的 TargetEncoder 内置了拟合隔离)。核心机制都是,算每个样本的编码值时,把它自己的标签从统计池里剔除。手动在全集上 groupby 算均值再 merge 回来,是最常见的翻车写法。

七、本篇小结

  1. 预处理在划分之前做,测试集信息就混进了训练流程,离线分数虚高。
  2. 本机实测虚高 4.2 个点、离线到线上落差 15 个点,泄漏偷走的是决策依据。
  3. 防线是结构性的,先划分再处理,预处理进 Pipeline,最终评测用从未参与的 holdout。

本篇坑卡片

字段 内容
坑名 SMOTE 过采样做在数据划分之前,离线评估虚高
现象原文 离线宏 F1 0.968,上线新数据 0.819(落差 15 个点)
环境 Win11 + CPU,Python 3.12,sklearn 1.9.1,imbalanced-learn 0.14.2
复现步骤 digits 构造不平衡子集 → 控变量双组(唯一差异为 96 张测试集插值合成样本)→ 同模型训练 → 离线与线上双评测
根因 全集过采样时,合成样本的插值父母包含测试集样本,近亲样本混入训练集
解法 先划分再过采样;预处理进 imblearn/sklearn Pipeline;上线前用 holdout 终评
避坑建议 划分永远第一行;归一化泄漏在均匀数据上无差距但错误性质不变,正确顺序无条件执行
可信度 本机验证
版本时间戳 2026-11-05,sklearn 1.9.1 + imblearn 0.14.2
关联坑 第 3 篇标签错位(同为数据正确性赛道)

专栏导航

篇次 主题 状态
1 nvidia-smi 显示的 CUDA 版本到底是不是你装的那个 ✅
2 torch.cuda.is_available() 一直是 False,一条命令定位断点 ✅
3 标签和图像没对齐,模型一直在学噪声 ✅
4 loss 变 NaN,用 hook 逐层定位 ✅
5 DataLoader 卡死在第一个 batch,Windows 多进程的坑 ✅
6 装了两套 CUDA 还在串,PATH 与优先级的真相 ✅
7 数据泄漏:预处理做在了划分之前 ✅(本篇)
8 RAG 答一半开始编,检索阈值与拒答机制实操 排期中
9 随机种子都设了,结果还是不可复现 排期中
10 相似度全是 0.9:向量没归一化 排期中
11 开了混合精度就 NaN,GradScaler 到底在做什么 排期中
12 loss 不降反升,先别怀疑模型结构 排期中
13 训练降验证不降:先查数据再谈正则 排期中

下一篇预告,《RAG 答一半开始编:知识库里没有,模型硬答》。检索没命中,模型不说不知道,编一个像模像样的答案,比答错更危险。下一篇用一个本地小模型完整复现「有拒答」和「没拒答」的输出差异。


版权说明,本文实验数据与截图均来自本人真机复现,专栏读者可学习使用,禁止转售或整篇搬运。