第 17 章 · 三种泄漏总览:划分 / 预处理 / 特征
这一章要解决什么:把"泄漏"从一句吓人的话变成一张可以逐条排查的表 —— 它只会在三个地方发生:划分、预处理、特征。 本章用同一份数据、同一个模型把三种各跑一遍,量出各自的虚高量,并给出统一判据。
最容易踩的坑: ① 三种泄漏全都不报错,而且在报告里长得都像"好成绩"; ② 它们的量级差 4 个数量级(+0.00000 到 +0.03497), 用一个阈值去卡全部三种,一定会漏; ③ 其中一种(预处理泄漏)在本数据上确实看不出差距 —— 这一章会诚实地说清"看不见"和"不存在"的区别。
本章你需要完成的代码:demos/ch17-leakage-overview.py
上一本《监督学习实战》第 10 章把划分泄漏量过一次(按行随机 0.98764 vs 按设备 0.96503), 那一章解决的是"怎么让分数可信"。本书这一章要往前走一步: 把"分数为什么虚高"拆成三个可以分别排查的环节 —— 后面五章(第 18 ~ 22 章)会把它们逐个亲手做出来、逐个给出防范规则。
如果你还没读上一本,只需要知道它的一句话结论: 泄漏 = 在评估时,模型获得了一些"上线时不可能获得"的信息。 本站链接:第 10 章 · 划分泄漏。
17.1 先给一个能用的定义
"泄漏"这个词最大的问题是它太笼统。 "哪里漏了?" —— 答案是三个地方。
评估 = 用数据模拟一次上线
原始表 →「怎么切」→「怎么算」→「喂什么」→ 分数
↑ ↑ ↑
划分闸门 fit 闸门 特征闸门
三个闸门里任何一个是开的,
评估用到的信息就比上线时多 —— 分数就必然偏高。按这三个闸门,泄漏分成三类:
| 类别 | 出问题的环节 | 一句话说清 | 本章实测虚高 |
|---|---|---|---|
| ① 划分泄漏 | 数据怎么切 | 测试集里出现了"训练时见过的同一支设备" | +0.02261 |
| ② 预处理泄漏 | 统计量怎么算 | 某个 fit 看到了测试集的行 | +0.00000(本数据) |
| ③ 特征泄漏 | 喂进去什么 | 某一列在"预测这一刻"根本拿不到 | +0.00556 / +0.03497 |
★ 三类泄漏的共同点只有一条,但它足够用来做统一判据:
它们都让"离线评估用到的信息"严格多于"上线时能拿到的信息"。
所以最容易记住、也最容易执行的判据是这句"上线演习":
把评估流程当成一次上线演习,问:
在这个评估里,模型能看到哪些信息?
上线时,这些信息我一样都拿得到吗?
任何一个数字,只要它在"预测这一刻"拿不到,
就不许出现在评估路径里。★ 这句话要拆成三个具体问题,才能在代码里落地:
| 闸门 | 要问的具体问题 | 本章示例的答案 |
|---|---|---|
| 划分 | 测试单元的"身份"(设备 / 用户 / 产线 / 时间段)有没有出现在训练集里? | 按行随机 → 出现了 60 支 |
| fit | 缩放 / 编码 / 填充 / 特征选择,有没有在哪一步看到了测试集的行? | 全量挑特征 → 看到了 |
| 特征 | 每一列在预测这一刻真的算得出来吗? | soh_margin → 算不出来 |
★ 顺序不能反。 排查顺序就是 划分 → 预处理 → 特征,理由是:
划分错了 → 后面所有数字都不可信,先修它才有意义
预处理错了 → 每个数字都"沾"了一点测试集,误差随折变化
特征错了 → 数字本身可能直接到 1.0,一眼可疑(但 cycle_frac 只有 +0.00556)
上游错了,下游的排查结论全部作废点开:为什么只有三类?(和上一本那张七类表的对应关系)
上一本第 10 章给过一张七类泄漏的表:① 标签泄漏 ② 计算来源泄漏 ③ 时间穿越泄漏 ④ 划分泄漏 ⑤ 交叉验证泄漏 ⑥ 预处理泄漏 ⑦ 重复样本泄漏。
本书把那张表按"环节"重新组织,变成三类,对应关系如下:
| 上一本的分类 | 本书归类 | 为什么归到一起 |
|---|---|---|
① 标签泄漏(soh 混进 X) | ③ 特征泄漏 | 都是"喂进去的那一列不该喂" |
| ② 计算来源泄漏(标签的等价形式) | ③ 特征泄漏 | 同上(soh_margin 就是这一类) |
| ③ 时间穿越泄漏(未来才知道) | ③ 特征泄漏 | 同上(cycle_frac 是这一类) |
| ④ 划分泄漏(一次留出) | ① 划分泄漏 | 都是"信息边界画错了" |
| ⑤ 交叉验证泄漏(K 折版本) | ① 划分泄漏 | 同一件事在 K 折上重演 |
| ⑥ 预处理泄漏 | ② 预处理泄漏 | 都是"fit 看到了不该看的数据" |
| ⑦ 重复样本泄漏 | ② / ③ 都算 | 重复行既污染统计量,也让近邻探针失效 |
★ 为什么这次要按环节重新分类?
按"是哪一列错了"分类(上一本)→ 适合【事后归因】:已经出事了,看是哪一类
按"在哪个环节错了"分类(本书)→ 适合【事前排查】:还没出报告,按顺序逐个卡
前者是诊断表,后者是检查清单
第 22 章要把后者变成流水线上的卡点★ 两个视角都对,但用途不同。 你在真实项目里应该先按环节排查,出事后再按类别归因。
17.2 一条曲线说明为什么泄漏专挑"好成绩"
三种泄漏有一个共同的行为特征:它们让分数变高(或变得不可预测),而不会让它变低。
如果泄漏让分数变【低】,你当场就会发现,然后去查
但它让分数变【高】→ 你会觉得"我做得不错"
↓
偏差的方向和人的期望一致 → 最不容易被怀疑★ 而"分数变高"这件事会立刻触发一个错误动作:
看到 0.98764 →"再换个更强的模型试试" → 0.989 → 换更大的森林 → 0.9905 → …
每一步都在优化"记忆能力",而不是"泛化能力"
↓
因为卡点根本不在模型,在评估口径★ 所以本章要建立的第一个反射是:
当一个分数"好到换什么模型都差不多"的时候, 要做的事不是继续换模型,而是回头检查评估方式。
这正是本书第 18 ~ 21 章要做的事:不是把分数做高,而是把分数做"对"。

图的读法:
- 左图:横轴是四种配置,绿柱是"干净写法"、红柱是"违规写法"。 四个红柱的高度差就是本数据上三种泄漏的真实量级 —— 从 +0.00000 到 +0.03497。
- 右图:评估管道的三个闸门。红箭头是三种泄漏的入口: 闸门一(划分)、闸门二(fit)、闸门三(特征)。
17.3 可运行示例:三种泄漏一次跑完
本章的 demo 只做一件事:在同一份数据、同一套口径上,把三种泄漏各跑一遍。
BASE = ["cycle", "mean_charge_voltage_v", "mean_temp_rise_c", "resistance_mohm",
"coulombic_eff", "cap_fade_rate_ah_per_100", "temp_std_c"]
SEED = 42
def rf():
return RandomForestRegressor(n_estimators=200, random_state=SEED, n_jobs=-1)17.3.1 泄漏一:划分(唯一变量是"怎么切")
# 干净口径:按设备分组
tr_g, te_g = next(GroupShuffleSplit(n_splits=1, test_size=0.2,
random_state=SEED).split(X, y, groups=g))
# 违规口径:按行随机
tr_r, te_r = train_test_split(np.arange(len(X)), test_size=0.2, random_state=SEED)真实输出:
① 泄漏一 · 划分泄漏:测试集里有没有『训练时见过的设备』
按设备划分(干净) 训练 17,244 行 / 测试 3,995 行 共有设备 0 支 测试 R² = 0.96503
按行随机(违规) 训练 16,991 行 / 测试 4,248 行 共有设备 60 支 测试 R² = 0.98764
测试行中『同设备在训练集出现过』的比例:100.0%
★ 虚高 = 0.98764 − 0.96503 = +0.02261★ 三行输出要连起来读:
两侧测试行数 3,995 vs 4,248(只差 6%)
两侧训练 R² 0.99838 vs 0.99821(只差 0.00017)
唯一的大差别:共有设备 0 支 vs 60 支
↓
所以虚高不是"数据变少了"造成的,
而是"测试集里坐着谁"造成的17.3.2 泄漏二:预处理(本数据上"看不见")
# ❌ 全量挑特征:测试集的行参与了 F 值的计算
sel = SelectKBest(f_regression, k=5).fit(X17, y17)
# ✅ 划分之后、只在训练集里挑
pipe = Pipeline([("sel", SelectKBest(f_regression, k=5)), ("m", rf())])
pipe.fit(X17.iloc[tr17], y17.iloc[tr17])真实输出:
② 泄漏二 · 预处理泄漏:挑特征这件事发生在『划分之前』还是『划分之后』
全量挑特征(违规) 测试 R² = 0.92962 挑出 5 列:['cycle', 'resistance_mohm', 'coulombic_eff', 'temp_std_c', 'res_win_mean']
训练集内挑(干净) 测试 R² = 0.92962 挑出 5 列:['cycle', 'resistance_mohm', 'coulombic_eff', 'temp_std_c', 'res_win_mean']
★ 虚高 = +0.00000 (两次挑出的列是否相同:True)★ 这是本书唯一一处"看不见差距"的泄漏,必须诚实处理:
不能写:"两种做法结果一样,所以预处理泄漏不重要" ❌
要写: "本数据上量不出差距,原因是【样本多 + 线性等变】;
换个条件差距立刻露头(第 19 章亲手放大)" ✅理由很简单:这里的 0.00000 是结果,不是性质。 它随样本量、模型种类、被 fit 的步骤而变 —— 第 19 章会把训练集砍到 2 支设备, 把虚高放大到 +0.06525。
17.3.3 泄漏三:特征(两个钩子,两种量级)
base = rf().fit(X[BASE], y).score(...) # 0.96503
s_frac = rf().fit(X[BASE + ["cycle_frac"]], y).score(...) # 0.97058
s_marg = rf().fit(X[BASE + ["soh_margin"]], y).score(...) # 1.00000真实输出:
③ 泄漏三 · 特征泄漏:那一列在『预测这一刻』拿得到吗
基线(7 列基础特征) 测试 R² = 0.96503 MAE = 0.00790
+ cycle_frac = cycle / 总寿命 测试 R² = 0.97058 虚高 +0.00556
这一列含『该设备的最终总寿命』—— 只有跑到退役才知道,预测时刻拿不到。
它与标签的相关系数 r = -0.9783(强,但不致命)
+ soh_margin = soh − 0.80 测试 R² = 1.00000 虚高 +0.03497
这一列就是标签自己减一个常数:r = 1.000000
—— 它不是『和标签相关』,它是『标签本身』。★ 两个钩子放在一起,正好说明"特征泄漏"不是一个东西,是一族:
| 钩子 | 它偷了什么 | 虚高 | 危险度 |
|---|---|---|---|
cycle_frac | 该设备的最终总寿命(未来聚合量) | +0.00556 | ★★★★★ 容易蒙混过关 |
soh_margin | 标签自己(差一个常数) | +0.03497 | ★★ 一眼可见(R² = 1.0) |
真正危险的是上面那一个:+0.00556 在周报里是"优化有成效",
而它用的是预测这一刻根本不存在的信息。17.3.4 汇总:一张对照表
真实输出:
④ 一张对照表:三种泄漏,量级差 4 个数量级
类型 干净写法 违规写法 干净 R² 违规 R² 虚高
泄漏一 · 划分 按设备分组划分 按行随机划分 0.96503 0.98764 +0.02261
泄漏二 · 预处理 Pipeline 内挑 全量挑 0.92962 0.92962 +0.00000
泄漏三 · 特征 不含钩子 + cycle_frac 0.96503 0.97058 +0.00556
泄漏三 · 特征 不含钩子 + soh_margin 0.96503 1.00000 +0.03497★ 这张表就是本章的交付物。 三个读法:
读法一:虚高量 = "上线时拿不到的那部分信息"的价值。
+0.02261 → 设备身份(个体偏置)值 2.26 个百分点
+0.00556 → "最终总寿命"这个未来量值 0.56 个百分点
+0.03497 → 标签本身值 3.5 个百分点读法二:量级跨了 4 个数量级,一个阈值卡不住。
如果你只写"分数差 > 0.02 才算泄漏":
→ 抓得到划分泄漏(+0.02261)和 soh_margin(+0.03497)
→ 漏掉 cycle_frac(+0.00556)
→ 完全看不见预处理泄漏(+0.00000)
如果你只写"R² = 1.0 就是泄漏":
→ 只抓得到 soh_margin 这一种,其余全漏读法三:症状完全不同,所以要三套检查。
划分泄漏 → 分数整体抬高(所有模型都受益),训练/测试差正常
预处理泄漏 → 分数不一定变(本数据为 0),但"模型的构成"可能被改变
特征泄漏 → 分数可以跳到 1.0,也可以只跳千分之五17.4 三种泄漏的完整对照(本章的核心表)
| 维度 | ① 划分泄漏 | ② 预处理泄漏 | ③ 特征泄漏 |
|---|---|---|---|
| 出问题的环节 | 数据怎么切 | 统计量怎么算 | 喂进去什么 |
| 本质 | 测试单元的"身份"跨集 | fit 看到了测试集的行 | 用到了预测时刻拿不到的信息 |
| 本数据实测虚高 | +0.02261 | +0.00000(条件性) | +0.00556 / +0.03497 |
| 典型症状 | 两种划分的分数差 > 0.02 | 分数看不出异常 | R² 跳变、MAE 掉到 1e-6、|r| ≈ 1 |
| 最危险的形态 | 分数漂亮、检查全过 | 小样本 + 尺度敏感模型 | 提升只有千分之几("看起来很合理") |
| 怎么查(一行) | set(df.loc[tr,'cell_id']) & set(df.loc[te,'cell_id']) | assert isinstance(model, Pipeline) | 逐列问"预测这一刻拿得到吗" |
| 怎么查(深入) | 近邻距离、跨集相邻对、分数一致性 | 全量 fit vs Pipeline 比分 | 打印 coef_、看 MAE 量级 |
| 修法 | 换 GroupShuffleSplit / GroupKFold | 所有 fit 进 Pipeline | 删掉那一列(或改用可得的信息) |
| 谁负责 | 评估负责人 | 建模负责人 | 特征负责人 + 业务方 |
| 本书位置 | 第 18 章 | 第 19 章 | 第 20 / 21 章 |
★ 这张表可以贴在显示器上。 它有三个用途:
① 出报告前逐行扫一遍 → 检查清单(第 22 章会把它变成卡点)
② 分数异常时对着"典型症状"列定位 → 诊断表
③ 争论"要不要修"时对着"本质"列 → 这是原则问题,不是精度问题点开:为什么"预处理泄漏"的量级这么小,还要花一整章讲?
因为它的量级不由"泄漏本身"决定,而由三个外部条件决定:
① 样本量:测试集占全量统计量的比例(污染度)
n_test / (n_train + n_test)
本数据 18.7% → 看不出;砍到 2 支训练设备 → 67.5% → 一眼可见
② 模型对尺度的敏感度
线性回归:等变 → 永远 0.00000
Ridge(alpha=100) / kNN / SVR:+0.05 ~ +0.065
③ 被 fit 的东西会不会改变"模型长什么样"
StandardScaler:只改尺度 → 线性模型无所谓
SelectKBest :会改【选哪几列】→ 小样本时两套列真的不同★ 三条合起来说明一件事:
"在本数据上量不出差距"是三个条件的巧合, 不是"这一类泄漏不重要"的证据。
★ 而它的修复成本是零。 把 scaler / imputer / encoder / selector 放进 Pipeline, 不需要多写一行逻辑,也不需要重新训练 —— 没有任何理由不做。
★ 这一类泄漏最容易在真实项目里埋下大坑,因为它有一个非常隐蔽的变体:
用【全量数据】的中位数填缺失值 → 单看分数可能毫无变化
但上线后,线上数据的中位数与训练时不同
↓
预处理参数与线上分布不匹配 → 离线好好的,线上精度崩
↓
这已经不是"分数虚高",而是"训练/推理不一致"所以第 19 章的结论会是两条,而不是一条: ① 分数层面:本数据上看不见; ② 工程层面:Pipeline 是上线姿势,不做就是埋雷。
17.5 统一判据:一次"上线演习"
三种泄漏、N 种症状,但只需要一句判据。 这句话要能被工程化,所以写成四个动作:
动作一(写下来):列出这个评估用到的全部【信息源】
· 数据划分方式(按什么键切?)
· 所有 fit 出来的统计量(均值 / 方差 / 中位数 / 类别表 / F 值 …)
· 所有特征列 + 每一列的计算方式
动作二(逐个问):这一条信息,在【预测这一刻】拿得到吗?
· 拿得到 → 写进特征账本,标注依据
· 拿不到 → 从评估路径里删掉
动作三(做对照):把"干净写法"和"违规写法"各跑一遍,量出差值
· 差值 = 这个泄漏在本配置下的价值(本数据:+0.02261 / +0.00000 / +0.00556 / +0.03497)
动作四(留证据):把上面三步的结论写进报告
· 不是"我们没有泄漏",而是"我们这样验证了没有泄漏"★ 动作四是关键。 一句"我们没有泄漏"没有任何信息量:
"没有泄漏" ← 无法验证,评审只能选择相信你
"按设备分组,训练/测试共有设备 0 支;所有 fit 在 Pipeline 内;
21 列特征逐列登记了预测时刻可得性,见账本第 N 行" ← 可以复现★ 这正是第 22 章那张清单存在的理由:把"动作一 ~ 四"变成可执行的检查。
17.6 三种泄漏的"痕迹"清单(先收藏,后五章逐个展开)
每一种泄漏都有它在输出里的"指纹"。 记住这些指纹,你就能在 3 秒内起疑:
| 泄漏 | 指纹(可观察信号) | 阈值(经验) |
|---|---|---|
| ① 划分 | 两种划分的分数差 | > 0.02 → 几乎肯定是泄漏 |
| ① 划分 | 测试样本到训练集的最近邻距离中位数 | 比另一种划分小 1.5 倍以上 |
| ① 划分 | 换划分种子的分数波动 | 干净口径抖动大、违规口径"很稳" = 假稳 |
| ② 预处理 | 全量 fit 与 Pipeline 的预测最大差 | 线性模型恒为 ~1e-16(看不见 ≠ 没有) |
| ② 预处理 | 小样本 + 尺度敏感模型下的分数差 | > 0.01 → 值得专门查 |
| ② 预处理 | 两套口径挑出的特征集合 | 不同 → 已经不只是分数问题了 |
| ③ 特征 | 某一列与标签的 |r| | > 0.999 → 逐列确认可得性 |
| ③ 特征 | 线性模型的 `max | coef |
| ③ 特征 | 测试 MAE | 掉到正常水平的 1/1000 以上 → 抄答案嫌疑 |
| ③ 特征 | 某一列的 feature_importances_ | 一列吃掉 > 90% 的重要性(cycle_frac = 0.9581) |
★ 这张表有一个非常重要的用法:
不要等"分数异常"再查 —— 上面每一个指纹都可以【主动测】:
划分指纹:两种划分各跑一次(成本:一次训练)
fit 指纹:两种口径各跑一次(成本:一次训练)
特征指纹:打印 coef_ / importances_ / MAE(成本:几乎为零)
↓
三条主动体检加起来,比一次调参便宜得多,而且能挡住 90% 的返工17.7 常见坑
① 用一个阈值卡三种泄漏 → 一定漏(量级跨 4 个数量级)
② 看到"两种做法结果一样"就下结论"没问题" → 本数据上预处理泄漏就是这样被冤枉的
③ 先调模型、后查泄漏 → 顺序反了;泄漏没修好之前,模型比较是无效的
④ 只在报告里写"无泄漏" → 不可验证;要写"怎么验证的"
⑤ 把"分数高"当成"特征好" → 特征泄漏最常见的成功伪装
⑥ 认为泄漏是"数据问题" → 它是【流程问题】:划分、fit、特征三个环节的纪律★ 第 ③ 条值得单独说,因为它是最贵的错误:
划分错了 → 模型选择错了(选了最会背的那个)
→ 特征工程方向错了(往"更容易背"的方向优化)
→ 调参方向错了(正则化抑制记忆 → 你会把它调松)
→ 上线后崩了,而且找不到原因(因为离线一切都好)
一个会报错的 bug,成本是几分钟;
一个不报错的 bug,成本是整条决策链。17.8 完整示例与输出
cd tools/feature-engineering-pipeline/code
MPLCONFIGDIR=/tmp/fe-mpl /Users/jini/Projects/MyBooks/Basic-Tutorials/.pytmp/venv/bin/python \
demos/ch17-leakage-overview.py真实输出(节选):
数据:soh_features_v2.csv 21,239 行 × 27 列 60 支设备
模型:RandomForestRegressor(n_estimators=200, random_state=42)
① 泄漏一 · 划分泄漏:测试集里有没有『训练时见过的设备』
按设备划分(干净) 训练 17,244 行 / 测试 3,995 行 共有设备 0 支 测试 R² = 0.96503
按行随机(违规) 训练 16,991 行 / 测试 4,248 行 共有设备 60 支 测试 R² = 0.98764
② 泄漏二 · 预处理泄漏:挑特征这件事发生在『划分之前』还是『划分之后』
全量挑特征(违规) 测试 R² = 0.92962
训练集内挑(干净) 测试 R² = 0.92962
★ 虚高 = +0.00000 (两次挑出的列是否相同:True)
③ 泄漏三 · 特征泄漏:那一列在『预测这一刻』拿得到吗
基线(7 列基础特征) 测试 R² = 0.96503 MAE = 0.00790
+ cycle_frac = cycle / 总寿命 测试 R² = 0.97058 虚高 +0.00556
+ soh_margin = soh − 0.80 测试 R² = 1.00000 虚高 +0.03497
④ 一张对照表:三种泄漏,量级差 4 个数量级
泄漏一 · 划分 按设备分组划分 按行随机划分 0.96503 0.98764 +0.02261
泄漏二 · 预处理 Pipeline 内挑 全量挑 0.92962 0.92962 +0.00000
泄漏三 · 特征 不含钩子 + cycle_frac 0.96503 0.97058 +0.00556
泄漏三 · 特征 不含钩子 + soh_margin 0.96503 1.00000 +0.03497
⑤ 统一判据:把评估当成一次上线演习
闸门一(划分):测试单元的『身份/个体偏置』有没有出现在训练集里?
闸门二(fit) :所有需要 fit 的步骤,看没看到测试集的行?
闸门三(特征):每一列在『预测这一刻』真的算得出来吗?本章练习
练习 1:把"划分泄漏"和"两侧行数"一起打印出来
用 7 列基础特征 + 随机森林(n_estimators=200, random_state=42):
- 按行随机划分(
test_size=0.2, random_state=42),打印测试 R² 与测试行数; - 按设备划分(
GroupShuffleSplit,test_size=0.2, random_state=42),打印同样两项; - 打印虚高,以及两侧测试行数之比;
- 用三行文字解释:为什么"测试行数几乎一样"这件事很重要?
参考答案
import numpy as np, pandas as pd
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import r2_score
from sklearn.model_selection import GroupShuffleSplit, train_test_split
BASE = ["cycle", "mean_charge_voltage_v", "mean_temp_rise_c", "resistance_mohm",
"coulombic_eff", "cap_fade_rate_ah_per_100", "temp_std_c"]
df = pd.read_csv("data/battery/soh_features_v2.csv")
X, y, g = df[BASE], df["soh"], df["cell_id"]
def rf():
return RandomForestRegressor(n_estimators=200, random_state=42, n_jobs=-1)
tr_r, te_r = train_test_split(np.arange(len(X)), test_size=0.2, random_state=42)
s_row = r2_score(y.iloc[te_r], rf().fit(X.iloc[tr_r], y.iloc[tr_r]).predict(X.iloc[te_r]))
tr_g, te_g = next(GroupShuffleSplit(n_splits=1, test_size=0.2,
random_state=42).split(X, y, groups=g))
s_grp = r2_score(y.iloc[te_g], rf().fit(X.iloc[tr_g], y.iloc[tr_g]).predict(X.iloc[te_g]))
print(f"按行随机 测试 {len(te_r):,d} 行 测试 R² = {s_row:.5f}")
print(f"按设备划分 测试 {len(te_g):,d} 行 测试 R² = {s_grp:.5f}")
print(f"虚高 = {s_row - s_grp:+.5f} 两侧测试行数之比 = {len(te_r) / len(te_g):.3f}")期望输出:
按行随机 测试 4,248 行 测试 R² = 0.98764
按设备划分 测试 3,995 行 测试 R² = 0.96503
虚高 = +0.02261 两侧测试行数之比 = 1.063★ 第 4 问的答案
如果两侧行数差很多(比如差 50%),你还可以辩解"是数据变少了"。
但这里只差 6.3%,而且两侧训练 R² 只差 0.00017(0.99821 vs 0.99838)。
↓
唯一能解释这 +0.02261 的变量,就是"测试集里坐着谁"
↓
换句话说:泄漏的解释力必须由【测试集的构成】承担,
不能让"数据量"替它背锅练习 2:把三个钩子的虚高量级排序,并解释倍数关系
用 7 列基础特征为基线,按设备划分,随机森林:
- 跑三种配置:基线 /
+cycle_frac/+soh_margin,打印 R² 与虚高; - 算出
soh_margin的虚高是cycle_frac的多少倍; - 用两句话解释这个倍数关系(提示:它们各自偷了什么信息?)。
参考答案
import pandas as pd
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import r2_score
from sklearn.model_selection import GroupShuffleSplit
BASE = ["cycle", "mean_charge_voltage_v", "mean_temp_rise_c", "resistance_mohm",
"coulombic_eff", "cap_fade_rate_ah_per_100", "temp_std_c"]
df = pd.read_csv("data/battery/soh_features_v2.csv")
X, y, g = df[BASE], df["soh"], df["cell_id"]
tr, te = next(GroupShuffleSplit(n_splits=1, test_size=0.2,
random_state=42).split(X, y, groups=g))
rows = []
for extra in ([], ["cycle_frac"], ["soh_margin"]):
feats = BASE + extra
m = RandomForestRegressor(n_estimators=200, random_state=42, n_jobs=-1)
m.fit(df[feats].iloc[tr], y.iloc[tr])
rows.append((extra[0] if extra else "(基线)", r2_score(y.iloc[te], m.predict(df[feats].iloc[te]))))
base = rows[0][1]
print(f"{'配置':<16}{'测试 R²':>10}{'虚高':>12}")
for name, r2 in rows:
print(f"{name:<16}{r2:>10.5f}{r2 - base:>+12.5f}")
print(f"\n倍数 = {(rows[2][1] - base) / (rows[1][1] - base):.1f}")期望输出:
配置 测试 R² 虚高
(基线) 0.96503 +0.00000
cycle_frac 0.97058 +0.00556
soh_margin 1.00000 +0.03497
倍数 = 6.3★ 第 3 问的答案
soh_margin 偷的是【答案本身】:soh_margin = soh − 0.80,
模型只要学到"乘 1 加 0.8"就完美了 → 虚高直接封顶(+0.03497,R² 顶到 1.0)。
cycle_frac 偷的是【未来的一个聚合量】:该设备的最终总寿命。
它不告诉模型答案,只告诉模型"这支设备还能活多久"这个尺度,
模型还得自己去解释 SOH 与进度的关系 → 只能拿到 +0.00556。
↓
所以它【更危险】:0.56 个百分点在报告里看起来完全像"正常优化"练习 3:写一个"三闸门诊断"函数
写一个函数 gate_report(df, feats, tr, te, group_col="cell_id", target="soh"), 一次性打印三个闸门的结论:
- 闸门一:训练/测试的共有设备数(0 才干净);
- 闸门二:所有
fit是否都在Pipeline内(本章先用一个布尔量占位,第 19 章给可执行检查); - 闸门三:
feats里与标签|r| > 0.999的列(报警,不等于泄漏); - 对三种配置各跑一次:按设备 + 7 列、按行随机 + 含轨迹列、按设备 +
soh_margin。
参考答案
import numpy as np, pandas as pd
from sklearn.model_selection import GroupShuffleSplit, train_test_split
BASE = ["cycle", "mean_charge_voltage_v", "mean_temp_rise_c", "resistance_mohm",
"coulombic_eff", "cap_fade_rate_ah_per_100", "temp_std_c"]
TRAJ = ["soh_lag1", "soh_lag5", "soh_win_mean5", "soh_win_std5"]
def gate_report(df, feats, tr, te, group_col="cell_id", target="soh"):
overlap = set(df.loc[tr, group_col]) & set(df.loc[te, group_col])
corr = {c: abs(np.corrcoef(df[c], df[target])[0, 1]) for c in feats
if df[c].notna().all()}
hi = sorted([c for c, v in corr.items() if v > 0.999])
print(f" 闸门一 划分:共有设备 {len(overlap)} 支"
f"{' ❌ 泄漏' if overlap else ' ✅ 干净'}")
print(f" 闸门二 fit :所有 fit 都在 Pipeline 内 = True(第 19 章给可执行检查)")
print(f" 闸门三 特征:|r| > 0.999 的列 = {hi}"
f"{' ⚠️ 逐个确认可得性' if hi else ' ✅ 无'}")
return {"overlap": len(overlap), "hi": hi}
df = pd.read_csv("data/battery/soh_features_v2.csv")
X, y, g = df[BASE], df["soh"], df["cell_id"]
tr_g, te_g = next(GroupShuffleSplit(n_splits=1, test_size=0.2,
random_state=42).split(X, y, groups=g))
tr_r, te_r = train_test_split(np.arange(len(X)), test_size=0.2, random_state=42)
print("配置 A:按设备划分 + 7 列基础特征")
gate_report(df, BASE, tr_g, te_g)
print("配置 B:按行随机 + 21 列安全特征(含历史 SOH 轨迹)")
d = df.dropna(subset=TRAJ).reset_index(drop=True) # 换表必须重算划分索引
tr_b, te_b = train_test_split(np.arange(len(d)), test_size=0.2, random_state=42)
gate_report(d, BASE + TRAJ, tr_b, te_b)
print("配置 C:按设备划分 + 7 列基础特征 + soh_margin")
gate_report(df, BASE + ["soh_margin"], tr_g, te_g)期望输出:
配置 A:按设备划分 + 7 列基础特征
闸门一 划分:共有设备 0 支 ✅ 干净
闸门二 fit :所有 fit 都在 Pipeline 内 = True
闸门三 特征:|r| > 0.999 的列 = [] ✅ 无
配置 B:按行随机 + 21 列安全特征(含历史 SOH 轨迹)
闸门一 划分:共有设备 60 支 ❌ 泄漏
闸门三 特征:|r| > 0.999 的列 = ['soh_lag1', 'soh_lag5', 'soh_win_mean5'] ⚠️ 逐个确认预测时刻可得性
配置 C:按设备划分 + 7 列基础特征 + soh_margin
闸门一 划分:共有设备 0 支 ✅ 干净
闸门三 特征:|r| > 0.999 的列 = ['soh_margin'] ⚠️ 逐个确认预测时刻可得性★ 注意配置 B 和 C 都报警了,但它们的性质完全不同:
配置 B 的 soh_lag1/lag5/win_mean5:只要上线时能拿到历史实测 SOH,它就是【合法特征】
配置 C 的 soh_margin :无论什么条件都拿不到 —— 它是标签自己
判据不是 |r| 的大小,而是"预测这一刻拿得到吗"
→ |r| 阈值只负责【叫你来回答问题】,不负责【给答案】面向工程实践
这一章的知识在真实项目里用在哪:
| 场景 | 怎么用 |
|---|---|
| 写验收报告 | 不要写"我们做了防泄漏检查",要写"划分共有单元 0 个、所有 fit 在 Pipeline 内、21 列逐列登记可得性" |
| 评审别人的模型 | 先问三句:按什么切的?fit 在哪发生的?这个特征上线时怎么算? |
| 定位"分数突然变好" | 对着 17.6 的指纹表逐条主动测,而不是继续调参 |
| 排查线上掉点 | 线上精度崩、离线很好 → 优先怀疑预处理泄漏(训练/推理不一致) |
| 新人上手 | 把 17.4 的对照表作为 onboarding 材料:三类泄漏、三个环节、三种查法 |
| 排期谈判 | 防泄漏检查的成本是"每次训练多跑一次"(约 2 倍),远低于一次返工 |
★ 一句话总结本章:
分数虚高不是模型的错,是流程的错。 流程上有三个环节能出错,所以要三套检查 —— 而不是一个阈值。