ML训练中的数据泄露会导致离线分数虚高但线上效果骤降,文章详解六种常见泄露构造及检测方法。
数据泄露:最常见的静默失败
泄露是指训练特征中包含了在真实预测时刻本不应该获取的信息。它从不抛出错误,总会提升离线分数,这也是为什么验证时达到 0.94 AUC 的模型在生产环境中只有 0.61 的表现。以下是几乎所有泄露来源的六种构造形式。
测试是对每个特征和每个流程步骤提出的单一问题:这个值能否仅凭存在于预测时刻的数据计算出来?如果答案需要任何限定条件,那就是泄露。
在所有建模错误中,有两点使它格外危险。它使分数朝着令人满意的方向移动,所以没人会去调查。对于正常工程流程所运行的所有检查来说,它都是不可见的——代码正确、测试通过、指标计算无误。唯一错误的是数字的含义。
# LEAKS
from sklearn.preprocessing import StandardScaler
X_scaled = StandardScaler().fit_transform(X) # <-- sees everything
X_tr, X_te, y_tr, y_te = train_test_split(X_scaled, y)
# also leaks: imputers, PCA, quantile transforms, TF-IDF vocabularies,
# discretisers -- anything with a .fit()
标准化器的均值和标准差是在测试行上计算的,因此每条训练行都是用来自测试集的信息进行标准化的。在大数据集上影响很小,在小数据集上影响很大——而对于用全局中位数填充缺失值的填充器来说,在任何数据集上影响都很大。
修复方案是结构性的,而非靠纪律约束:将每个拟合后的转换放入 Pipeline,永远不要在一个 Pipeline 外部调用 fit 或 fit_transform。
# CORRECT
from sklearn.pipeline import Pipeline
pipe = Pipeline([
("impute", SimpleImputer(strategy="median")),
("scale", StandardScaler()),
("clf", LogisticRegression()),
])
pipe.fit(X_tr, y_tr) # each step fits on train folds only
cross_val_score(pipe, X, y, cv=5) # and refits inside every fold
这是最隐蔽的一类,因为列名看起来人畜无害,数据也是真实的。一个值被写入记录,是因为它是你预测事件的附带结果。
-- LEAKS: predicting churn
SELECT
c.customer_id,
c.plan_tier, -- updated to 'cancelled' on churn
c.account_closed_reason, -- non-null only for churners
c.final_invoice_amount, -- exists only if they left
x.churned
FROM customers c
JOIN churn_labels x USING (customer_id)
account_closed_reason 一看便知。plan_tier 不是,因为它是合法特征,只是当前值恰好编码了结果。final_invoice_amount 是危险的那种:一个看似合理的金额特征,对于留下来的用户为空,因此模型学到"非空意味着流失",并报告 0.99 AUC。
同一种形式的更多实例:预测工单是否被升级时的 days_to_resolution;预测转化时在结账时应用了折扣的 discount_applied;人类分析师在自动评分案例之后设置的 risk_band 列。
修复方案是带有效时间戳的特征历史记录和时点连接,使每个特征都携带预测时刻的原始值——时点正确性是那篇文章的全部主题。在不存在历史记录的地方,必须删除该列;无法重建被覆盖的内容。
# LEAKS
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.2,
shuffle=True, random_state=0)
# on transactions spanning 2024-2026: trains on March, tests on February
模型是在它已经训练过的时期上进行评估的。它见过那周运作的欺诈团伙、那个月开展的促销活动、那天改变行为的故障。 当模型被问及下周时,这些都不会可用。
这不是小的乐观主义。任何具有时间自相关的特征——大多数特征都是如此——让模型进行插值而非外推,而插值比生产环境提出的问题要简单得多。
# CORRECT
cut = df.event_time.quantile(0.8)
train, test = df[df.event_time < cut], df[df.event_time >= cut]
# for cross-validation, expanding windows rather than folds
from sklearn.model_selection import TimeSeriesSplit
for tr, va in TimeSeriesSplit(n_splits=5).split(X):
...
注意,时间分割会报告更差的分数,而更差的分数才是真实的。滚动原点回测是这个想法的完整实现。
# LEAKS: one row per user per month, 12 rows per user
X_tr, X_te = train_test_split(panel, test_size=0.2) # splits rows
用户 4471 的 1 月到 8 月在训练集中,9 月到 12 月在测试集中。模型学会了用户 4471——他们的基准消费、设备、他们特有的模式——然后在测试时识别出他们。分数衡量的是对个人的记忆,对模型从未见过的用户没有任何预测能力,而生产环境只有这种用户。
同样的失败也出现在近似重复行中:同一产品以略有不同的文本列出两次、出现在两个集合中的文档、有两个账户 ID 的客户。按内容去重,而非按主键去重。
# CORRECT
from sklearn.model_selection import GroupShuffleSplit, GroupKFold
gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=0)
tr, te = next(gss.split(X, y, groups=panel.user_id))
# and inside cross-validation
for tr, va in GroupKFold(n_splits=5).split(X, y, groups=panel.user_id):
...
当数据既有分组又有时序——客户面板总是如此——你需要两者结合:按时间分割,并确认没有实体以重要方式跨越边界。
# LEAKS: each row's own label is inside its own feature
df["city_conversion_rate"] = (
df.groupby("city")["converted"].transform("mean")
)
# the same failure, less obviously:
df["user_avg_target"] = df.groupby("user_id")["y"].transform("mean")
df["is_above_segment_median"] = (
df["y"] > df.groupby("segment")["y"].transform("median")
)
对于一个有三条数据的城市,该特征的三分之一是行自身的结果。对于高基数列——用户 ID、产品 ID、邮编——组往往只有一行,特征就是标签。这产生了近乎完美的训练分数和一个毫无价值的模型,这是 Kaggle 技巧破坏生产项目最常见的方式。
修复方案是折叠外编码:计算每行的编码值时排除它自己所在折叠,对于小组向全局均值做平滑处理。
# CORRECT
from sklearn.model_selection import KFold
import numpy as np
def oof_target_encode(df, col, target, n_splits=5, smoothing=20):
prior = df[target].mean()
out = np.full(len(df), prior, dtype=float)
for tr, va in KFold(n_splits=n_splits, shuffle=True,
random_state=0).split(df):
stats = df.iloc[tr].groupby(col)[target].agg(["mean", "count"])
# shrink small groups towards the prior
enc = ((stats["mean"] * stats["count"] + prior * smoothing)
/ (stats["count"] + smoothing))
out[va] = df.iloc[va][col].map(enc).fillna(prior).to_numpy()
return out
Scikit-learn 也提供了 TargetEncoder,它在内部执行交叉拟合;在 Pipeline 中使用它,使折叠规则由框架强制执行,而非由下一个编辑笔记本的人决定。
# LEAKS
from sklearn.feature_selection import SelectKBest, f_classif
X_sel = SelectKBest(f_classif, k=50).fit_transform(X, y) # sees all labels
scores = cross_val_score(model, X_sel, y, cv=5) # meaningless
选择器根据与标签的关联度对 5000 列进行排名——包括测试标签——并保留在整个数据集上看起来最好的 50 个。每个后续的交叉验证分数都是在已知验证折叠知识的特征上计算的。在纯噪声的宽数据集上,这个过程可靠地产生远高于 0.5 的交叉验证 AUC。
同样的情况也适用于超参数搜索:根据测试集进行调参,即使只是看了十几次并选择了有效的参数,也是实验者造成的泄露。这就是为什么存在训练、验证和测试的三向分割,以及为什么应该只看一次测试集。
# CORRECT
pipe = Pipeline([
("select", SelectKBest(f_classif, k=50)),
("clf", GradientBoostingClassifier()),
])
scores = cross_val_score(pipe, X, y, cv=5) # selection refits per fold
# and for tuning, nest it
from sklearn.model_selection import GridSearchCV, cross_val_score
inner = GridSearchCV(pipe, param_grid, cv=5)
cross_val_score(inner, X, y, cv=5) # honest generalisation estimate
标签和特征窗口重叠。特征取自第 1-90 天,标签定义在第 60-150 天。重叠意味着部分结果在特征窗口内。修复窗口定义,而非模型——流失页面中的三窗口布局是一般形式。
行顺序和标识符。按类别排序的导出文件,或者因为正样本批量插入而与标签相关联的自增 ID。删除标识符列,除非你能为每一个辩护,并在查看前打乱顺序。
如果第一个模型达到远高于领域合理支持的 AUC,应该优先考虑泄露而非天赋。欺诈 0.99、流失 0.97、违约 0.95——在庆祝前先调查。
运行排列重要性。如果一个特征几乎携带了所有信号,在做其他任何事情之前先阅读它的定义和来源表。泄露会集中。
真正的模型会优雅地降级,因为信号是分散的。泄露的模型会崩溃到接近随机,因为里面从来只有一样东西。
在某个日期之前训练所有数据,之后测试。这与随机分割分数之间的巨大差距是泄露可用的最清晰单一指标。
特征文档中写一列,说明该值相对于预测何时变得已知。在任何代码运行之前填写它能捕获第 2 种模式,这是清单上最便宜的管控手段。
在线上运行模型但不据此行动,记录它实际收到的特征,并将其分布与训练时比较。在这里幸存下来的泄露表现为系统性不同或根本缺失的特征。
Churn Prediction End to End
Feature Stores and the Problem They Solve
Tabular Data Is Still Where Most ML Money Is