模型测试 95%,一上线掉到 60%?先检查这三处数据泄漏
模型在测试集上跑出 95% 的准确率,评审会上大家都挺满意。上线一周,实际效果掉到 60% 出头 —— 业务方问「你们不是说 95% 吗」,你回去翻代码,查了半天,最后发现问题不在模型,而在数据划分那几行。这种情况十有八九是 数据泄漏(Data Leakage):测试集的信息在训练阶段就被模型「看见」了。测试分数自然好看,但它测的不是真实水平,而是模型对「已经见过的数据」的记忆。更麻烦的是:数据泄漏不会报错。代码跑得通、指标还漂亮,你没有任何提示 —— 直到上线。这三个场景看着各不相同,根源却是同一个:划分数据时,没有把「训练阶段到底能看到哪些信息」想清楚。
场景一:先做了标准化 / 归一化,再划分数据集
这是最常见的一个 —— 写法上最顺理成章,也最不容易让人觉得有问题:
from sklearn.preprocessing import StandardScaler # 标准化工具:把每列换算成均值 0、标准差 1
from sklearn.model_selection import train_test_split # 切分工具:按比例划分训练集 / 测试集
scaler = StandardScaler() # ❌ 先造一个标准化器
X_scaled = scaler.fit_transform(X) # ❌ 问题在这一行:fit 会「从数据里学参数」(每列的均值和标准差),
# 而它的输入 X 是「全量数据」—— 测试集也被一起算进去了
X_train, X_test, y_train, y_test = train_test_split(X_scaled, y, test_size=0.3, random_state=42)
# ❌ 到这一步才切分:测试集的信息在上一行就漏进训练过程了
为什么错:fit_transform 要计算每一列的均值和标准差,而它算的时候用的是整份数据 —— 包括后面要当测试集的那 30%。也就是说,训练时用到的「列均值」,里面混进了测试集样本的贡献。测试集的信息顺着这个均值漏进了训练过程。正确写法:先划分,fit 只在训练集上做,测试集只 transform:
# ✅ 正确:第一步就是切分
X_train, X_test, y_train, y_test = train_test_split(
X, y, # X 是特征、y 是标签,两个一起切,保证样本和标签一一对应
test_size=0.3, # 测试集占 30%
random_state=42, # 固定随机种子:每次跑都得到同一套划分,两次实验才可比
)
scaler = StandardScaler() # 造一个标准化器
X_train = scaler.fit_transform(X_train) # 只在训练集上「学」参数(均值、标准差)
X_test = scaler.transform(X_test) # 测试集只套用训练集学到的参数,自己不参与计算
同一个道理适用于所有「从数据里学参数」的预处理:归一化、标准化、填充缺失值(SimpleImputer)、特征选择、PCA 降维、目标编码(用标签的统计量给类别特征编码 —— 这一类泄漏得最隐蔽,务必只在训练集上算)—— 凡是 fit 开头的,都只能在训练集上做。一句话记:训练集负责「学」,测试集只负责「被变换」。
场景二:按行划分,把同一个实体的多条记录分到了两边
这个场景在真实业务数据里特别常见,因为数据往往「一人多行」。比如一份就诊记录表,同一个患者有 5 次就诊记录。如果直接按行随机划分:
# ❌ 错误:按行随机划分
X_train, X_test, y_train, y_test = train_test_split(
X, y, # 特征和标签一起切(这一步本身的用法没错)
test_size=0.3, # 测试集占 30%
random_state=42, # 可复现 —— 但「按行切」这件事本身就是问题
)
# 同一个患者的多条记录,会被随机分到训练集和测试集两边
5 条记录很可能 3 条分到训练集、2 条分到测试集。而同一个患者的多次就诊记录在特征上高度相似(年龄、基础病、既往史几乎一样)——模型在训练时已经见过这个人的「画像」了,测试集里的那两条当然容易判对。 但这不代表模型对新患者有判断力。正确写法:按实体划分,保证同一个患者(或用户、设备、门店)只出现在一边:
# ✅ 正确:按患者 ID 划分
from sklearn.model_selection import GroupShuffleSplit # 支持「按组切」的划分器
gss = GroupShuffleSplit(
n_splits=1, # 只切一次(反复切分取平均是另一回事)
test_size=0.3, # 测试集占 30%
random_state=42, # 固定种子,结果可复现
)
train_idx, test_idx = next(gss.split(
X, y, # 特征和标签一起传进去
groups=df["患者ID"], # ★ 关键:告诉它「哪些行属于同一个患者」
))
# 同一个患者的所有记录,会整体落到同一边
X_train, X_test = X.iloc[train_idx], X.iloc[test_idx] # 按行号取出训练 / 测试特征
y_train, y_test = y.iloc[train_idx], y.iloc[test_idx] # 标签用同一套行号取,避免错位
GroupShuffleSplit 的 groups 参数就是干这个的:同一个 group 的样本要么全在训练集、要么全在测试集。(上面这段里 X 是 DataFrame,所以用 .iloc 按行号取;如果 X 是纯 numpy 数组,直接写 X[train_idx] 就行。)
要划分的「实体」是什么?看业务:风控按客户、推荐按用户、医疗按患者、设备预测按设备号。判断标准是「哪些行其实描述的是同一个对象」。
场景三:时间序列用未来数据训练,去预测过去
如果数据带时间属性,按行随机划分基本一定会出错:
# ❌ 错误:时间数据随机划分
X_train, X_test, y_train, y_test = train_test_split(
X, y, # 特征和标签一起切
test_size=0.3, # 测试集占 30%
random_state=42, # 固定种子(可复现 —— 但问题不在种子,在「随机」这件事)
)
# 「随机」意味着:6 月的数据可能进训练集、3 月的数据可能进测试集 —— 未来漏进了训练
随机划分会让未来的样本进入训练集,而测试集里留着过去的样本。模型在训练时已经「见过未来」,用它去预测过去,分数当然漂亮 —— 但上线后你手上永远只有历史数据,要预测的是真正的未来。正确写法:按时间切分,训练集取前面一段、测试集取后面一段:
# ✅ 正确:按时间切分(以 2025-01-01 为界)
train = df[df["日期"] < "2025-01-01"] # 训练集:分界点之前的全部历史数据
test = df[df["日期"] >= "2025-01-01"] # 测试集:分界点之后的(模拟「未来」)
更严谨一点的做法是滚动验证(TimeSeriesSplit):用 1–3 月训练、4 月验证,再用 1–4 月训练、5 月验证 —— 始终保证「用过去预测将来」。
三处泄漏其实是一回事
三个场景的形态不同 —— 一个是统计量漏了(场景一),一个是同一个实体的其他记录漏了(场景二),一个是未来的数据漏了(场景三)—— 但根源相同:
划分数据时,训练阶段拿到了它在真实场景里根本拿不到的信息。
落到代码上,对应三行:
| 预处理 | 所有 fit 都只在训练集上做,测试集只 transform |
| 实体 | 按实体(用户 / 患者 / 设备号)划分,而不是按行 |
| 时间 | 有时间属性时按时间切分,而不是随机切 |
为什么它比别的错误更难发现
数据泄漏不报错,也不影响代码运行 —— 它只是让模型选型、调参、要不要上线,全都建立在同一个假数字上,等发现时前面所有判断都得重来。而它的修正成本,往往只是把两行代码换个顺序。
反过来也成立:在类别不平衡的任务里,一个漂亮的准确率本身就是警报 —— 正样本只占 2% 时,一个「永远判负」的模型准确率也有 98%,但它一个正样本都抓不到。
在动手优化模型之前,先确认那个 95% 是不是真的。
网硕互联帮助中心






评论前必须登录!
注册