📑 本页目录(点开跳转)
14 · 数据泄漏的七种来源
⏱ 70 分钟 | ⭐⭐ 离线好得离谱的时候,先查这里
🎯 一句话
数据泄漏 = 训练时用到了「预测那一刻根本拿不到」的信息。 它不会报错、不会让 loss 不降、不会让任何一个测试失败 —— 它只会让你的离线指标变好看,然后在上线那天把账一次性还回来。
🔬 一、先看一组实跑出来的数字
下面这些不是估计值,是真跑出来的(numpy + sklearn,代码在本章末尾和第 20 章)。 同一份数据、同一个模型,只改「怎么切、什么时候做预处理」:
把七种泄漏【同时】犯一遍:
离线 5 折 CV 的 AUC .......... 0.977 ← 你会截图发群里
诚实评估的 AUC ............... 0.573 ← 上线后的真实水平
落差 = 0.404 三个随机种子跑出来:0.406 / 0.403 / 0.391
再把七种拆开,各自单独犯一次(都是同一套数据、同一个模型):
泄漏类型 有泄漏 无泄漏 落差
───────────────────────────────────────────────
① 时间穿越(随机切分) 0.810 → 0.682 0.128
② 目标编码没做 OOF 0.854 → 0.707 0.147 ⭐ 这是 CV 分
③ 预处理先于切分(SMOTE)0.984 → 0.849 0.135
④ 分组泄漏(同用户) 0.706 → 0.564 0.142
⑤ 重复样本跨集 0.745 → 0.605 0.140
⑥ 代理变量 0.970 → 0.686 0.284 💀 最大
⑦ 外部数据带未来信息 —— 症状同 ①,但更难查
⭐ 注意这组数字的形状:单独一种泄漏大约值 0.13–0.15 AUC, 但七种叠起来是 0.40 —— 它们会互相放大,不是相加。 这就是为什么「离线 0.97、线上 0.6」在真实项目里并不罕见: 通常不是犯了一个大错,而是同时犯了五六个小错。
🧭 二、判断是不是泄漏,只需要问三个问题
对每一个特征、每一条流程,问:
① 时间:这个值在【预测那一刻】就已经存在了吗?
—— 不是"数据库里有",是"那一刻已经产生并且能查到"
② 身份:这个值的计算,用到过【被预测的这一行的标签】吗?
—— 包括间接用到(编码、填充、采样、筛选)⭐
③ 边界:训练集和测试集之间,有没有【同一个东西】跨过去?
—— 同一个人、同一次会话、同一条被复制的记录、同一天
🔑 第 ② 条最容易被漏掉,因为它常常藏在「预处理」里:
StandardScaler见过测试集、fillna(median)用的是全量中位数、SMOTE拿测试集的正例插值出了训练样本 —— 这些都不叫特征工程,这些叫「让标签从后门溜进训练集」。
⏰ 三、① 时间穿越
最常见,也最贵。
| 内容 | |
|---|---|
| 症状 | 随机切分 CV 很稳(0.81),换成按时间切分立刻掉一大截(0.68)⭐ 这个对比本身就是诊断 |
特征重要性榜首是 dt / create_time / 更新时间 这类字段 |
|
| 上线后第一周还行,越往后越差(因为你训练时"见过"的那些日子过完了) | |
| 怎么验证 | 跑两次:随机 KFold vs 按时间切。差 > 0.03 AUC 就当作有时间泄漏 |
把每个特征单独和 day 做相关性排序,看有没有「只在训练期内可解释」的字段 |
|
| 怎么修 | 评测一律用按时间前后切;删掉任何形式的时间 id 类特征 |
| 所有聚合特征加一句「截止到 T 时刻」,并在代码里真的实现它 |
上面那个实跑例子里有个细节值得单独说:
随机切分 AUC = 0.810
按时间切分 = 0.682
按时间切 + 【把 day 这一列删掉】 = 0.685 ⭐ 几乎没变
⭐
day那一列在诚实评估里一点用都没有(0.682 vs 0.685), 但在随机切分里它值 0.13 个 AUC。 这就是时间泄漏最阴的地方:这个特征的"价值"完全来自切分方式,不来自数据。 你在特征重要性图上会看到它排第一,然后你会以为它很重要。
最隐蔽的一种时间穿越:口径写得对,实现写错了。
需求文档: "用户近 30 天的下单次数"
SQL 实现: SELECT COUNT(*) FROM orders WHERE user_id = ?
AND dt BETWEEN '2025-01-01' AND '2025-01-31'
💀 这条 SQL 对【每一行样本】用的都是同一个固定窗口,
于是 1 月 3 日那条样本的特征里,
包含了 1 月 3 日到 1 月 31 日发生的事。
⭐ 正确写法必须让窗口跟着样本时间走:
AND o.dt BETWEEN date_sub(s.event_time, 30) AND s.event_time
↑ 这个上界是命门
🎯 四、② 目标编码没做嵌套 CV
高基数类别(城市 id、商户 id、SKU)做 target encoding 时, 如果用整个训练集算编码、再在同一份数据上做 CV, 每一行的标签都参与了它自己那个类别的均值。
实跑(2000 个取值、平均每类 4 行、这个类别和标签完全无关):
| 做法 | 训练集 5 折 CV AUC | 真正 holdout AUC |
|---|---|---|
| 朴素目标编码 | 0.854 💀 | 0.577 |
| OOF 目标编码 | 0.707 | 0.726 ⭐ |
💀 第一行是所有虚假 CV 分数的教科书样本: 一个完全没有信息的随机 id,被朴素编码之后, 把 CV 从 0.71 抬到了 0.85,而 holdout 只有 0.577 —— 它抬高的全是幻觉,而且还顺手把真特征挤掉了。
⭐ 第二行有个反直觉但极重要的现象: 修好泄漏之后 CV 从 0.854 掉到 0.707, 但 holdout 从 0.577 涨到 0.726。 「修泄漏会让你的线下分数变难看」是正常的、也是必然的。 如果你的团队按 CV 分数考核,那这个修复动作在流程上就是不可能发生的 —— 这是一个组织问题,不是技术问题。
| 内容 | |
|---|---|
| 症状 | 高基数类别特征在重要性榜上排第一,但它的业务含义你说不清 |
| 类别取值越碎,"效果"越好(平均每类 3 行时最夸张)⭐ 这是判定信号 | |
| CV 和 holdout 差 > 0.1 | |
| 怎么验证 | 把该类别列打乱重排再跑一次。分数没掉多少 = 它本来就没信息,掉的分是泄漏 ⭐ |
| 怎么修 | out-of-fold 编码:编码器只在当前折以外的数据上 fit |
低频类别做平滑((sum + prior*k) / (cnt + k),k 取 10–50) |
|
编码器必须进 Pipeline,不能在切分之前算好 |
🧪 五、③ 预处理在切分之前做
这一类里最贵的三个:标准化、缺失填充、重采样(SMOTE)。
标准化 —— 泄漏了测试集的均值和方差 损失通常很小(0.00x)
缺失填充 —— 泄漏了测试集的中位数/众数 损失中等
SMOTE —— 💀 泄漏了测试集正例的【位置】 损失巨大
实跑(正例率 8.3%,随机森林):
| 做法 | AUC |
|---|---|
| 先对全量 SMOTE,再随机切分 | 0.984 💀 |
| 先切分,只对训练集 SMOTE,在真实测试集上评估 | 0.849 ⭐ |
💀 为什么 SMOTE 是这三个里最毒的: SMOTE 的合成样本是两个真实少数类样本之间的插值点。 先合成再切分,等于把测试集正例的坐标,以"合成样本"的名义抄进了训练集 —— 模型不需要学规律,只要记住"我见过这个点附近"。 而且它的最终指标是在一个被合成样本污染的测试集上算的,双重虚高。
| 内容 | |
|---|---|
| 症状 | 不平衡数据上 AUC > 0.97,但上线后精确率惨不忍睹 |
| 混淆矩阵好得不真实:极低的假阳性 + 极低的假阴性同时出现 | |
| 怎么验证 | 检查代码顺序:train_test_split 这一行在不在所有 fit/fit_resample 之前 |
用 Pipeline 重跑一次,两个分数一对比就知道了 |
|
| 怎么修 | ⭐ 一条铁律:切分是第一步,永远是第一步。 |
所有有状态的变换(scaler / imputer / encoder / 采样)全部塞进 Pipeline |
|
| ⭐ 测试集永远保持原始分布,绝不重采样 —— 重采样只是训练技巧,不是评测口径 |
👥 六、④ 分组泄漏:同一个用户跨集
同一个人、同一个病例、同一台设备的多条记录,被随机切分拆散到了训练集和测试集。
实跑(900 个用户 × 12 条记录,用户级随机效应很强):
| 切分方式 | AUC |
|---|---|
| 随机切分(行级) | 0.706 |
| GroupKFold 按用户切 | 0.564 ⭐ |
⭐ 0.706 里有 0.14 是"认人",不是"预测行为"。 模型学到的是「这个用户的另外 8 条记录标签是什么」, 而线上遇到的是新用户,它一条也没见过。
| 内容 | |
|---|---|
| 症状 | 线上对老用户表现尚可、对新用户明显更差 ⭐ 这是最强的信号 |
| 用户级特征(注册渠道、设备型号、常驻城市)重要性异常高 | |
| 训练集和测试集的用户 id 交集很大 | |
| 怎么验证 | 一行代码:len(set(uid_train) & set(uid_test))。期望是 0 |
换 GroupKFold 重跑,掉 > 0.03 就确认 |
|
| 怎么修 | 按业务实体切分(用户 / 病人 / 门店 / 设备),不按行切 |
| ⚠️ 组的选择要看线上会遇到谁:如果线上大多是新用户,就必须按用户切;如果线上永远是老用户的新行为,那按时间切才对 |
⚠️ 医疗和风控里这个坑最贵:同一个病人的多张影像、同一个人的多笔交易。 一篇论文报 AUC 0.95,换成按病人切之后是 0.78 —— 这类"复现失败"绝大多数是它。
👯 七、⑤ 重复样本跨集
数据回填、多次导出、上游重试、宽表 join 出现一对多 —— 重复记录是常态。
实跑(40% 的行被复制了一份,1-NN 分类器):
| 做法 | AUC |
|---|---|
| 带重复直接随机切分 | 0.745 |
| 先去重再切分 | 0.605 ⭐ |
| 内容 | |
|---|---|
| 症状 | 复杂模型(深树、KNN、大网络)比简单模型好得离谱 —— 因为只有它们记得住 ⭐ |
| 训练集行数比业务口径的"应有条数"多 | |
| 数据量翻倍时指标不涨反跌(去重后才涨) | |
| 怎么验证 | 对全部特征列做哈希,统计重复行数;再统计跨训练/测试的重复对数 |
| ⚠️ 还要查近似重复:文本样本改了个标点、图片改了个尺寸 | |
| 怎么修 | 切分前去重;去重必须先于切分,顺序反了等于没做 |
| 文本用 MinHash / SimHash,图片用感知哈希,不能只查完全相同 | |
| ⭐ 去重之后指标会掉,掉下去的那部分本来就不属于你(第 6 章) |
🕵️ 八、⑥ 代理变量:一个只有事后才存在的字段
这是落差最大的一种(0.284),也是最容易被业务同学一眼看穿的一种。
实跑:给数据加一列「退款金额」,只有流失用户里 92% 的人非零。
| 特征集 | AUC |
|---|---|
含 退款金额 |
0.970 💀 |
| 剔除后 | 0.686 |
常见的代理变量(每一个都在真实项目里出现过):
预测流失 ← 「退款金额」「客服工单数」「账号注销申请时间」
预测欺诈 ← 「风控备注」「人工审核结论」「冻结状态」⭐ 结论字段最毒
预测疾病 ← 「用药记录」「转诊科室」「住院天数」
预测违约 ← 「催收次数」「展期申请」
预测点击 ← 「曝光时长」「页面停留」(这些是点击【之后】才有的)
💀 最毒的一类是「人工审核结论」这种字段: 它不是"和标签相关",它几乎就是标签本身的另一个副本。 而且它在数据库里的名字往往看不出来 ——
status_code、flag_3、remark_type。
| 内容 | |
|---|---|
| 症状 | AUC > 0.95 且只靠一两个特征就能达到(去掉它就崩)⭐⭐ |
| 单特征 AUC 就有 0.9+ | |
| 怎么验证 | ⭐ 算每个特征的单变量 AUC,排序。任何单特征 AUC > 0.85 都要人工过一遍 |
| 对 Top-3 特征问业务:「这个字段是在我们要预测的事情发生之前写入的,还是之后?」 | |
| 怎么修 | 删掉;或者查清它的写入时刻,只用预测时刻之前的版本 |
⭐ 在数据契约(第 3 章)里给每个字段登记 produced_at,这是根治的办法 |
🛑 读到这里可以停 —— 前半章讲完了(约 25 分钟)。 后半章还有:⑦ 外部数据带来的未来信息 · 症状速查:「离线好得离谱」到底长什么样 · 一次真实的事故复盘 · 一套上线前的泄漏体检(可以直接抄) · 把这一章变成流程,而不是知识 回来的时候不用重读,直接从下一节接着看就行。
🌍 九、⑦ 外部数据带来的未来信息
买来的第三方数据、公开数据集、爬来的补充特征 —— 它们的时间戳通常是"最后更新时间",不是"当时的值"。
典型形态:
· 企业工商数据 ——「经营状态」是【今天】的状态,
你却把它当成两年前那笔贷款申请时的状态 💀
· 第三方信用分 ——供应商每月刷新,历史版本不保留
· 城市 POI / 房价 ——只有最新一版
· 爬来的商品评分 ——评分是累计到今天的
· 预训练模型的语料 ——⭐ 你的评测集可能已经在它的训练语料里了
| 内容 | |
|---|---|
| 症状 | 加了外部数据之后离线指标大涨(+0.05 以上),线上却几乎没变 ⭐ |
| 越久远的样本,外部特征"越准" —— 因为它们泄漏的未来更多 | |
| 怎么验证 | ⭐ 按样本时间分段看提升:真信息的提升应该各段接近;泄漏的提升会在老样本上更大 |
| 问供应商要一个字段:这一行的快照时间。要不到就当作有泄漏 | |
| 怎么修 | 只用能提供历史快照的外部数据源;自己按天存快照 |
| ⚠️ 用预训练模型做评测时,先查评测集有没有进过它的语料(第 13 章第四节讲了怎么查:去重 / n-gram 重叠 / 改写测试 / 新鲜题对照,⚠️ 拿不到训练语料时只剩后两个) |
🩺 十、症状速查:「离线好得离谱」到底长什么样
这张表是本章最该被记住的东西。 你不需要记住七种类型,只需要认得这些形状:
| 你看到的现象 | 最先怀疑 |
|---|---|
| AUC > 0.95,而这个业务问题本来很难 ⭐⭐ | 代理变量或时间穿越 |
| 去掉某一个特征,AUC 从 0.97 掉到 0.70 💀 | 代理变量,几乎可以确诊 |
| 随机 CV 稳,按时间切掉一大截 | 时间穿越 |
| CV 0.85,holdout 0.58 | 目标编码 / 预处理先于切分 |
| 对老用户好、对新用户差 | 分组泄漏 |
| 复杂模型比简单模型好得不成比例 | 重复样本 |
| 不平衡数据上假阳假阴同时极低 | SMOTE 先于切分 |
| 加了外部数据后离线大涨、线上不涨 | 外部数据带未来 |
| 重要性榜首是一个你说不清含义的字段 ⭐ | 泄漏(哪一种都有可能) |
| 上线第一周尚可、之后逐周变差 | 时间穿越 |
⭐⭐ 一条可以当口诀的判据: 「模型好得让你想截图」和「模型好得让你想再验一遍」,应该是同一个反应。 在数据这一关,指标突然变好首先是一个待排查的异常(第 0 章 那张表的第二行)。
💀 十一、一次真实的事故复盘
系统:某消费信贷的首逾预测模型(预测放款后首期是否逾期)
数据:申请单 62 万条,跨 26 个月
团队:3 个算法 + 1 个数据工程
发生了什么
| 时间 | 事件 |
|---|---|
| 第 1 周 | 基线模型 KS 0.32、AUC 0.74,符合行业水平 |
| 第 3 周 | 接入第三方「多头借贷」数据,AUC 跳到 0.89、KS 0.61 ⭐ 全组振奋 |
| 第 4 周 | 又加了商户维度的目标编码(商户 id 约 4 万个),AUC 0.93 |
| 第 5 周 | 评审通过,理由:「提升幅度巨大且 5 折 CV 非常稳定,折间标准差只有 0.004」 |
| 第 8 周 | 上线,10% 流量 |
| 第 12 周 | 首批样本到期,线上 AUC 0.71 —— 比上线前的老模型还低 0.01 |
为什么没被发现
① 5 折 CV 是【随机】切的 —— 26 个月的数据被打散了 泄漏①
② 目标编码在切分之前算好,4 万个商户平均每个 15 单 泄漏②
③ 第三方多头数据是【当前快照】,供应商不提供历史版本 泄漏⑦
→ 一个 2023 年的申请人,配的是 2025 年的多头记录
→ 已经逾期的人在 2025 年的多头次数当然更高 💀
⭐ 折间标准差 0.004 反而是【泄漏的证据】而不是稳定性的证据:
泄漏的信息在每一折里都同样存在,所以每一折都同样虚高。
"非常稳定"在这里的意思是"每一折都错得一样多"。
代价
放款金额 这 4 个月按新模型放出去 2.7 亿
实际首逾率 预期 1.9% → 实际 3.4%
多出来的坏账 约 4050 万 × 回收率折算后 ≈ 2900 万
人力 3 人 × 12 周 白做,加 5 周返工
⭐ 从上线到发现 28 天(等首期到期,这是硬约束)
⭐ 从建模到发现 9 周 ← 真正该压缩的是这一段
该补什么
| 缺失 | 补上之后 |
|---|---|
| CV 用随机切分 | 改成按申请时间切;新老两种切法的差值进模型报告,差 > 0.03 不许评审 ⭐ |
| 目标编码在 Pipeline 外 | 全部有状态变换进 Pipeline,代码评审时专门看 split 那一行的位置 |
| 外部数据没有快照时间 | 采购合同里加一条「必须提供 snapshot_date」;⭐ 拿不到就只在"当前预测"用,绝不进历史训练集 |
| 没有单特征 AUC 体检 | 上线前必跑;单特征 AUC > 0.85 的字段要写一份说明 |
| 用 CV 分数汇报成果 | 改用按时间的滚动回测,且必须报「最后一段」的分数 |
💀 这个事故里最值得记住的一句: 「5 折 CV 折间标准差只有 0.004」被当成了模型可靠的证据。 而实际上,当所有折都共享同一份泄漏时,一致性恰恰是泄漏的指纹。 ⭐ 判断稳定性要看的是不同切分方式之间的差异,不是同一种切分内部的方差。
🔧 十二、一套上线前的泄漏体检(可以直接抄)
第 1 段:单特征 AUC 排序 —— 抓代理变量,10 行,最划算的一段代码
import numpy as np
from sklearn.metrics import roc_auc_score
def single_feature_auc(X, y, names, warn=0.85):
"""每个特征单独算 AUC。⭐ 抓代理变量最快的办法"""
out = []
for j, nm in enumerate(names):
v = np.asarray(X[:, j], dtype=float)
ok = ~np.isnan(v)
if ok.sum() < 50 or len(np.unique(y[ok])) < 2:
continue
a = roc_auc_score(y[ok], v[ok])
a = max(a, 1 - a) # ⭐ 方向无所谓,看的是"分得开吗"
out.append((nm, round(a, 3), "🚨 疑似泄漏" if a > warn else ""))
return sorted(out, key=lambda r: -r[1])
# ⭐ 判读:任何单特征 AUC > 0.85 都要人工过一遍它的写入时刻
第 2 段:切分体检 —— 三个边界一次查完
import numpy as np, hashlib
def split_audit(X_tr, X_te, t_tr=None, t_te=None, g_tr=None, g_te=None):
"""一次查完:时间重叠 / 组重叠 / 重复样本跨集"""
rep = {}
if t_tr is not None: # ⭐ 时间边界:训练集最晚 < 测试集最早
rep["时间重叠"] = float(np.max(t_tr) - np.min(t_te))
if g_tr is not None: # ⭐ 组边界:期望恰好是 0
rep["跨集组数"] = len(set(np.asarray(g_tr).tolist())
& set(np.asarray(g_te).tolist()))
h = lambda A: {hashlib.md5(np.ascontiguousarray(r).tobytes()).hexdigest()
for r in np.asarray(A, dtype=float)}
rep["跨集重复行"] = len(h(X_tr) & h(X_te)) # ⭐ 期望也是 0
return rep
# 判读:三个数字里任何一个 > 0,就先别看指标了
第 3 段:正确的 out-of-fold 目标编码(带平滑)
import numpy as np
from sklearn.model_selection import KFold
def oof_target_encode(cat_tr, y_tr, cat_te, k=20, n_splits=5, seed=0):
"""⭐ 训练集用 OOF 值,测试集用全训练集的映射;k 是平滑强度"""
cat_tr, y_tr = np.asarray(cat_tr), np.asarray(y_tr, dtype=float)
prior = y_tr.mean()
def fit_map(c, y):
m = {}
for v in np.unique(c):
s = y[c == v]
m[v] = (s.sum() + prior * k) / (len(s) + k) # ⭐ 低频类别被拉回先验
return m
oof = np.full(len(cat_tr), prior)
for a, b in KFold(n_splits, shuffle=True, random_state=seed).split(cat_tr):
mp = fit_map(cat_tr[a], y_tr[a]) # ⭐⭐ 只用【折外】数据 fit
oof[b] = [mp.get(v, prior) for v in cat_tr[b]]
mp_all = fit_map(cat_tr, y_tr)
enc_te = np.array([mp_all.get(v, prior) for v in np.asarray(cat_te)])
return oof, enc_te
# ⚠️ 修好之后 CV 分数【一定会掉】。掉下去的那部分本来就不是你的。
🧱 十三、把这一章变成流程,而不是知识
泄漏是「知道了也照样会犯」的那类问题,因为它藏在代码顺序和 SQL 边界里。 唯一有效的办法是把检查变成会失败的东西:
上线前必跑的四条门禁(任何一条不过就拦住):
① 切分体检三个数字全为 0(时间重叠 / 跨集组 / 跨集重复行)
② 单特征 AUC 榜首 < 0.85,否则必须附一份字段说明
③ 随机切分 AUC 和 按时间切分 AUC 的差 < 0.03 ⭐⭐ 最有价值的一条
④ 所有有状态变换都在 Pipeline 里(代码评审人肉确认)
⭐ ③ 之所以最有价值:它不需要你知道泄漏在哪,
它只需要两次实验,就能回答"我的评测方式本身可不可信"。
⭐ 一条会省下很多时间的经验: 每次指标出现「大幅、意外、说不清来源」的提升,先花半小时跑一遍这四条门禁。 代价是半小时;不跑的代价,看上面那个 2900 万。
🔗 这一章连到哪里
| 去哪 | 为什么 |
|---|---|
| Kaggle 23 数据泄露与外部数据 | 竞赛视角的同一件事:那边泄漏是可以"利用"的(榜单奖励它),这边是纯粹的事故。对比着读能看清两种语境的差别 |
| 模型上线之后 02 离线好不等于线上好 | 「离线好线上差」的完整原因清单,泄漏只是其中一条;那边还有分布差异、反馈回路、口径不一致 |
| 模型上线之后 04 训练推理一致性 | ⭐ 「特征穿越」在上线侧的样子。本章讲怎么在训练前发现,那章讲怎么在上线前逐字段比对 |
| ML 基础 05 评估与过拟合 | 切分和交叉验证的基本功。泄漏本质上是评测协议的问题,不是模型问题 |
✅ 检查点
- 数据泄漏的一句话定义是什么?判断泄漏的三个问题分别问什么?
- 七种泄漏各自单独犯一次,AUC 落差大约是多少?七种一起犯呢?这说明了什么?
- 时间穿越那个实验里,
day这一列在诚实评估里值多少?在随机切分里值多少?这说明特征重要性图会告诉你什么错误信息? - 朴素目标编码在那个实验里把 CV 抬到了多少、holdout 是多少?修好之后 CV 和 holdout 分别变成多少?这个变化方向为什么很重要?
- 标准化、缺失填充、SMOTE 三种"预处理先于切分"里,哪一种损失最大?为什么?
- 分组泄漏最强的线上症状是什么?怎么用一行代码验证?
- 代理变量怎么快速筛出来?阈值定在多少?举三个真实场景的代理变量例子。
- 外部数据泄漏怎么验证?为什么"老样本上提升更大"是泄漏的信号?
- 信贷那个事故里,「5 折 CV 折间标准差只有 0.004」为什么反而是泄漏的证据?代价是多少?发现用了多久?
- 上线前四条门禁是什么?哪一条最有价值、为什么?
👀 答案
- 训练时用到了「预测那一刻根本拿不到」的信息。三个问题:①时间——这个值在预测那一刻已经产生并能查到吗;②身份——它的计算用到过被预测这一行的标签吗(包括间接:编码、填充、采样、筛选);③边界——训练集和测试集之间有没有同一个东西跨过去(同一个人/会话/被复制的记录/同一天)。
- 单独一种约 0.13–0.15 AUC(①0.810→0.682、②0.854→0.707、③0.984→0.849、④0.706→0.564、⑤0.745→0.605、⑥0.970→0.686),七种一起犯是 0.977 → 0.573,落差 0.404。说明泄漏会互相放大不是相加——「离线 0.97 线上 0.6」通常不是犯了一个大错,而是同时犯了五六个小错。
- 诚实评估里
day值 0.003(0.682 vs 去掉它的 0.685,几乎为零);随机切分里它值 0.128。说明这个特征的"价值"完全来自切分方式而不是数据,但它会在特征重要性图上排第一,让你以为它很重要。 - 朴素编码 CV 0.854 / holdout 0.577;OOF 编码 CV 0.707 / holdout 0.726。方向很重要因为:修好泄漏会让 CV 分数掉(0.854→0.707)而真实分数涨(0.577→0.726)。⭐ 如果团队按 CV 分数考核,这个修复动作在流程上就是不可能发生的——这是组织问题不是技术问题。
- SMOTE 最大(0.984 vs 0.849)。因为合成样本是两个真实少数类样本之间的插值点,先合成再切分等于把测试集正例的坐标以"合成样本"的名义抄进训练集;而且最终指标还是在被合成样本污染的测试集上算的,双重虚高。铁律:切分永远是第一步,测试集永远保持原始分布、绝不重采样。
- 对老用户表现尚可、对新用户明显更差。验证:
len(set(uid_train) & set(uid_test)),期望是 0;或换GroupKFold重跑,掉超过 0.03 即确认(实验里 0.706 → 0.564)。 - 算每个特征的单变量 AUC 并排序,任何单特征 AUC > 0.85 都要人工过一遍。例子:预测流失用「退款金额/客服工单数/注销申请时间」、预测欺诈用「人工审核结论/冻结状态」(结论字段最毒,它几乎是标签的副本)、预测点击用「页面停留时长」(这是点击之后才有的)。
- 按样本时间分段看提升:真信息的提升各段接近,泄漏的提升在老样本上更大——因为老样本距离"当前快照"更远,快照里包含的未来更多。同时向供应商索要
snapshot_date,要不到就当作有泄漏。 - 因为泄漏的信息在每一折里都同样存在,所以每一折都同样虚高——"非常稳定"在这里的意思是"每一折都错得一样多"。⭐ 判断稳定性要看不同切分方式之间的差异,不是同一种切分内部的方差。代价:4 个月放款 2.7 亿,首逾率 1.9% → 3.4%,坏账约 2900 万,3 人 12 周白做 + 5 周返工。从上线到发现 28 天(等首期到期,是硬约束),从建模到发现 9 周——真正该压缩的是后面这一段。
- ①切分体检三个数字全为 0 ②单特征 AUC 榜首 < 0.85 ③随机切分与按时间切分的 AUC 差 < 0.03 ④所有有状态变换都在 Pipeline 里。③最有价值:它不需要你知道泄漏在哪,只需要两次实验就能回答「我的评测方式本身可不可信」。
🛑 可以停在这里
⚡ 走神救援
⭐⭐泄漏 = 训练时用到了「预测那一刻拿不到」的信息。它不报错、不让 loss 不降、不让任何测试失败,只让离线指标变好看,然后上线那天一次性还账。三个判据:①时间(那一刻这个值产生了吗)②身份(它的计算用过这一行的标签吗,包括间接的编码/填充/采样/筛选)③边界(有没有同一个东西跨集)。⭐实跑数字:单独一种泄漏值 0.13–0.15 AUC,七种一起犯是 0.977 → 0.573,落差 0.404——泄漏会互相放大不是相加,所以"离线 0.97 线上 0.6"通常不是一个大错而是五六个小错。七种:①时间穿越(随机切 0.810 / 按时间切 0.682;⭐而
day那列在诚实评估里只值 0.003——它的"价值"全来自切分方式,却会在重要性图上排第一;最隐蔽形态是 SQL 用了固定窗口而不是跟着样本时间走的窗口)②目标编码没做 OOF(一个完全无信息的随机 id 把 CV 从 0.707 抬到 0.854,holdout 只有 0.577;⭐⭐修好后 CV 掉到 0.707 而 holdout 涨到 0.726——「修泄漏会让线下分数变难看」是必然的,如果团队按 CV 考核,这个修复在流程上就不可能发生;验证法:把该列打乱重跑,分数没掉多少说明它本来就没信息)③预处理先于切分(标准化损失很小、填充中等、💀SMOTE 最毒 0.984 vs 0.849,因为合成样本是真实少数类之间的插值点,等于把测试集正例的坐标抄进训练集,而且指标还是在被污染的测试集上算的;⭐铁律:切分永远是第一步,测试集绝不重采样)④分组泄漏(随机切 0.706 / 按用户切 0.564,⭐最强症状是"对老用户还行、对新用户明显差";一行验证len(set(uid_tr)&set(uid_te))期望为 0;医疗论文"复现失败"大多是它)⑤重复样本跨集(0.745 vs 0.605;⭐症状是复杂模型比简单模型好得不成比例——因为只有它们记得住;必须先去重再切分,还要查近似重复 MinHash/感知哈希)⑥代理变量(落差最大 0.970 vs 0.686;退款金额/客服工单/人工审核结论——💀结论字段几乎就是标签的副本,而且名字看不出来叫status_code、flag_3;⭐筛法:算每个特征的单变量 AUC,>0.85 就人工过一遍写入时刻)⑦外部数据带未来(第三方数据的时间戳是"最后更新时间"不是"当时的值";⭐验证法:按样本时间分段看提升,泄漏的提升会在老样本上更大;要不到snapshot_date就当作有泄漏)。⭐⭐症状速查最该记的几条:AUC>0.95 而问题本来很难 → 代理变量或时间穿越;去掉一个特征 AUC 从 0.97 掉到 0.70 → 代理变量可以确诊;随机 CV 稳但按时间切掉一大截 → 时间穿越;对老用户好对新用户差 → 分组泄漏;重要性榜首是一个你说不清含义的字段 → 泄漏。💀信贷事故:接了第三方多头数据 AUC 0.74→0.89,再加商户目标编码到 0.93,评审理由是"5 折 CV 折间标准差只有 0.004",上线 4 个月后线上 AUC 0.71,比老模型还低。⭐⭐那个 0.004 恰恰是泄漏的指纹——泄漏的信息在每一折里同样存在,所以"非常稳定"的意思是"每一折都错得一样多";判断稳定性要看不同切分方式之间的差异,不是同一种切分内部的方差。代价:放款 2.7 亿、首逾 1.9%→3.4%、坏账约 2900 万、3 人 12 周白做;上线到发现 28 天(等首期到期,硬约束),建模到发现 9 周——该压缩的是后面这段。⭐四条上线门禁:①切分体检三个数字(时间重叠/跨集组/跨集重复行)全为 0 ②单特征 AUC 榜首 <0.85 ③⭐⭐随机切分与按时间切分的 AUC 差 <0.03 ④有状态变换全在 Pipeline 里。③最值钱,因为它不需要你知道泄漏在哪,两次实验就能回答"我的评测方式本身可不可信"。每次出现大幅、意外、说不清来源的提升,先花半小时跑这四条——不跑的代价看那 2900 万。
下一节 👉 15-采样偏差与幸存者偏差.md