Skip to content

第 17 章 · 三种泄漏总览:划分 / 预处理 / 特征 ​

这一章要解决什么:把"泄漏"从一句吓人的话变成一张可以逐条排查的表 —— 它只会在三个地方发生:划分、预处理、特征。 本章用同一份数据、同一个模型把三种各跑一遍,量出各自的虚高量,并给出统一判据。

最容易踩的坑: ① 三种泄漏全都不报错,而且在报告里长得都像"好成绩"; ② 它们的量级差 4 个数量级(+0.00000 到 +0.03497), 用一个阈值去卡全部三种,一定会漏; ③ 其中一种(预处理泄漏)在本数据上确实看不出差距 —— 这一章会诚实地说清"看不见"和"不存在"的区别。

本章你需要完成的代码:demos/ch17-leakage-overview.py

上一本《监督学习实战》第 10 章把划分泄漏量过一次(按行随机 0.98764 vs 按设备 0.96503), 那一章解决的是"怎么让分数可信"。本书这一章要往前走一步: 把"分数为什么虚高"拆成三个可以分别排查的环节 —— 后面五章(第 18 ~ 22 章)会把它们逐个亲手做出来、逐个给出防范规则。

如果你还没读上一本,只需要知道它的一句话结论: 泄漏 = 在评估时,模型获得了一些"上线时不可能获得"的信息。 本站链接:第 10 章 · 划分泄漏。

17.1 先给一个能用的定义 ​

"泄漏"这个词最大的问题是它太笼统。 "哪里漏了?" —— 答案是三个地方。

text
评估 = 用数据模拟一次上线

    原始表 →「怎么切」→「怎么算」→「喂什么」→ 分数
              ↑           ↑          ↑
           划分闸门     fit 闸门    特征闸门

三个闸门里任何一个是开的,
评估用到的信息就比上线时多 —— 分数就必然偏高。

按这三个闸门,泄漏分成三类:

类别出问题的环节一句话说清本章实测虚高
① 划分泄漏数据怎么切测试集里出现了"训练时见过的同一支设备"+0.02261
② 预处理泄漏统计量怎么算某个 fit 看到了测试集的行+0.00000(本数据)
③ 特征泄漏喂进去什么某一列在"预测这一刻"根本拿不到+0.00556 / +0.03497

★ 三类泄漏的共同点只有一条,但它足够用来做统一判据:

它们都让"离线评估用到的信息"严格多于"上线时能拿到的信息"。

所以最容易记住、也最容易执行的判据是这句"上线演习":

text
把评估流程当成一次上线演习,问:

    在这个评估里,模型能看到哪些信息?
    上线时,这些信息我一样都拿得到吗?

任何一个数字,只要它在"预测这一刻"拿不到,
就不许出现在评估路径里。

★ 这句话要拆成三个具体问题,才能在代码里落地:

闸门要问的具体问题本章示例的答案
划分测试单元的"身份"(设备 / 用户 / 产线 / 时间段)有没有出现在训练集里?按行随机 → 出现了 60 支
fit缩放 / 编码 / 填充 / 特征选择,有没有在哪一步看到了测试集的行?全量挑特征 → 看到了
特征每一列在预测这一刻真的算得出来吗?soh_margin → 算不出来

★ 顺序不能反。 排查顺序就是 划分 → 预处理 → 特征,理由是:

text
划分错了     → 后面所有数字都不可信,先修它才有意义
预处理错了   → 每个数字都"沾"了一点测试集,误差随折变化
特征错了     → 数字本身可能直接到 1.0,一眼可疑(但 cycle_frac 只有 +0.00556)

上游错了,下游的排查结论全部作废
点开:为什么只有三类?(和上一本那张七类表的对应关系)

上一本第 10 章给过一张七类泄漏的表:① 标签泄漏 ② 计算来源泄漏 ③ 时间穿越泄漏 ④ 划分泄漏 ⑤ 交叉验证泄漏 ⑥ 预处理泄漏 ⑦ 重复样本泄漏。

本书把那张表按"环节"重新组织,变成三类,对应关系如下:

上一本的分类本书归类为什么归到一起
① 标签泄漏(soh 混进 X)③ 特征泄漏都是"喂进去的那一列不该喂"
② 计算来源泄漏(标签的等价形式)③ 特征泄漏同上(soh_margin 就是这一类)
③ 时间穿越泄漏(未来才知道)③ 特征泄漏同上(cycle_frac 是这一类)
④ 划分泄漏(一次留出)① 划分泄漏都是"信息边界画错了"
⑤ 交叉验证泄漏(K 折版本)① 划分泄漏同一件事在 K 折上重演
⑥ 预处理泄漏② 预处理泄漏都是"fit 看到了不该看的数据"
⑦ 重复样本泄漏② / ③ 都算重复行既污染统计量,也让近邻探针失效

★ 为什么这次要按环节重新分类?

text
按"是哪一列错了"分类(上一本)→ 适合【事后归因】:已经出事了,看是哪一类
按"在哪个环节错了"分类(本书)→ 适合【事前排查】:还没出报告,按顺序逐个卡

前者是诊断表,后者是检查清单
第 22 章要把后者变成流水线上的卡点

★ 两个视角都对,但用途不同。 你在真实项目里应该先按环节排查,出事后再按类别归因。

17.2 一条曲线说明为什么泄漏专挑"好成绩" ​

三种泄漏有一个共同的行为特征:它们让分数变高(或变得不可预测),而不会让它变低。

text
如果泄漏让分数变【低】,你当场就会发现,然后去查
但它让分数变【高】→ 你会觉得"我做得不错"
     ↓
偏差的方向和人的期望一致 → 最不容易被怀疑

★ 而"分数变高"这件事会立刻触发一个错误动作:

text
看到 0.98764 →"再换个更强的模型试试" → 0.989 → 换更大的森林 → 0.9905 → …

每一步都在优化"记忆能力",而不是"泛化能力"
     ↓
因为卡点根本不在模型,在评估口径

★ 所以本章要建立的第一个反射是:

当一个分数"好到换什么模型都差不多"的时候, 要做的事不是继续换模型,而是回头检查评估方式。

这正是本书第 18 ~ 21 章要做的事:不是把分数做高,而是把分数做"对"。

三种泄漏的虚高量与评估管道上的三个闸门

图的读法:

  • 左图:横轴是四种配置,绿柱是"干净写法"、红柱是"违规写法"。 四个红柱的高度差就是本数据上三种泄漏的真实量级 —— 从 +0.00000 到 +0.03497。
  • 右图:评估管道的三个闸门。红箭头是三种泄漏的入口: 闸门一(划分)、闸门二(fit)、闸门三(特征)。

17.3 可运行示例:三种泄漏一次跑完 ​

本章的 demo 只做一件事:在同一份数据、同一套口径上,把三种泄漏各跑一遍。

python
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 泄漏一:划分(唯一变量是"怎么切") ​

python
# 干净口径:按设备分组
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)

真实输出:

text
① 泄漏一 · 划分泄漏:测试集里有没有『训练时见过的设备』
  按设备划分(干净)  训练 17,244 行 / 测试 3,995 行   共有设备  0 支   测试 R² = 0.96503
  按行随机(违规)    训练 16,991 行 / 测试 4,248 行   共有设备 60 支   测试 R² = 0.98764
  测试行中『同设备在训练集出现过』的比例:100.0%
  ★ 虚高 = 0.98764 − 0.96503 = +0.02261

★ 三行输出要连起来读:

text
两侧测试行数 3,995 vs 4,248(只差 6%)
两侧训练 R²  0.99838 vs 0.99821(只差 0.00017)
唯一的大差别:共有设备 0 支 vs 60 支
     ↓
所以虚高不是"数据变少了"造成的,
而是"测试集里坐着谁"造成的

17.3.2 泄漏二:预处理(本数据上"看不见") ​

python
# ❌ 全量挑特征:测试集的行参与了 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])

真实输出:

text
② 泄漏二 · 预处理泄漏:挑特征这件事发生在『划分之前』还是『划分之后』
  全量挑特征(违规)    测试 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)

★ 这是本书唯一一处"看不见差距"的泄漏,必须诚实处理:

text
不能写:"两种做法结果一样,所以预处理泄漏不重要"   ❌
要写:  "本数据上量不出差距,原因是【样本多 + 线性等变】;
        换个条件差距立刻露头(第 19 章亲手放大)"     ✅

理由很简单:这里的 0.00000 是结果,不是性质。 它随样本量、模型种类、被 fit 的步骤而变 —— 第 19 章会把训练集砍到 2 支设备, 把虚高放大到 +0.06525。

17.3.3 泄漏三:特征(两个钩子,两种量级) ​

python
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

真实输出:

text
③ 泄漏三 · 特征泄漏:那一列在『预测这一刻』拿得到吗
  基线(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)
text
真正危险的是上面那一个:+0.00556 在周报里是"优化有成效",
而它用的是预测这一刻根本不存在的信息。

17.3.4 汇总:一张对照表 ​

真实输出:

text
④ 一张对照表:三种泄漏,量级差 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

★ 这张表就是本章的交付物。 三个读法:

读法一:虚高量 = "上线时拿不到的那部分信息"的价值。

text
+0.02261  → 设备身份(个体偏置)值 2.26 个百分点
+0.00556  → "最终总寿命"这个未来量值 0.56 个百分点
+0.03497  → 标签本身值 3.5 个百分点

读法二:量级跨了 4 个数量级,一个阈值卡不住。

text
如果你只写"分数差 > 0.02 才算泄漏":
    → 抓得到划分泄漏(+0.02261)和 soh_margin(+0.03497)
    → 漏掉 cycle_frac(+0.00556)
    → 完全看不见预处理泄漏(+0.00000)

如果你只写"R² = 1.0 就是泄漏":
    → 只抓得到 soh_margin 这一种,其余全漏

读法三:症状完全不同,所以要三套检查。

text
划分泄漏   → 分数整体抬高(所有模型都受益),训练/测试差正常
预处理泄漏 → 分数不一定变(本数据为 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 章

★ 这张表可以贴在显示器上。 它有三个用途:

text
① 出报告前逐行扫一遍 → 检查清单(第 22 章会把它变成卡点)
② 分数异常时对着"典型症状"列定位 → 诊断表
③ 争论"要不要修"时对着"本质"列 → 这是原则问题,不是精度问题
点开:为什么"预处理泄漏"的量级这么小,还要花一整章讲?

因为它的量级不由"泄漏本身"决定,而由三个外部条件决定:

text
① 样本量:测试集占全量统计量的比例(污染度)
     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, 不需要多写一行逻辑,也不需要重新训练 —— 没有任何理由不做。

★ 这一类泄漏最容易在真实项目里埋下大坑,因为它有一个非常隐蔽的变体:

text
用【全量数据】的中位数填缺失值 → 单看分数可能毫无变化
但上线后,线上数据的中位数与训练时不同
     ↓
预处理参数与线上分布不匹配 → 离线好好的,线上精度崩
     ↓
这已经不是"分数虚高",而是"训练/推理不一致"

所以第 19 章的结论会是两条,而不是一条: ① 分数层面:本数据上看不见; ② 工程层面:Pipeline 是上线姿势,不做就是埋雷。

17.5 统一判据:一次"上线演习" ​

三种泄漏、N 种症状,但只需要一句判据。 这句话要能被工程化,所以写成四个动作:

text
动作一(写下来):列出这个评估用到的全部【信息源】
     · 数据划分方式(按什么键切?)
     · 所有 fit 出来的统计量(均值 / 方差 / 中位数 / 类别表 / F 值 …)
     · 所有特征列 + 每一列的计算方式

动作二(逐个问):这一条信息,在【预测这一刻】拿得到吗?
     · 拿得到 → 写进特征账本,标注依据
     · 拿不到 → 从评估路径里删掉

动作三(做对照):把"干净写法"和"违规写法"各跑一遍,量出差值
     · 差值 = 这个泄漏在本配置下的价值(本数据:+0.02261 / +0.00000 / +0.00556 / +0.03497)

动作四(留证据):把上面三步的结论写进报告
     · 不是"我们没有泄漏",而是"我们这样验证了没有泄漏"

★ 动作四是关键。 一句"我们没有泄漏"没有任何信息量:

text
"没有泄漏"       ← 无法验证,评审只能选择相信你
"按设备分组,训练/测试共有设备 0 支;所有 fit 在 Pipeline 内;
 21 列特征逐列登记了预测时刻可得性,见账本第 N 行"   ← 可以复现

★ 这正是第 22 章那张清单存在的理由:把"动作一 ~ 四"变成可执行的检查。

17.6 三种泄漏的"痕迹"清单(先收藏,后五章逐个展开) ​

每一种泄漏都有它在输出里的"指纹"。 记住这些指纹,你就能在 3 秒内起疑:

泄漏指纹(可观察信号)阈值(经验)
① 划分两种划分的分数差> 0.02 → 几乎肯定是泄漏
① 划分测试样本到训练集的最近邻距离中位数比另一种划分小 1.5 倍以上
① 划分换划分种子的分数波动干净口径抖动大、违规口径"很稳" = 假稳
② 预处理全量 fit 与 Pipeline 的预测最大差线性模型恒为 ~1e-16(看不见 ≠ 没有)
② 预处理小样本 + 尺度敏感模型下的分数差> 0.01 → 值得专门查
② 预处理两套口径挑出的特征集合不同 → 已经不只是分数问题了
③ 特征某一列与标签的 |r|> 0.999 → 逐列确认可得性
③ 特征线性模型的 `maxcoef
③ 特征测试 MAE掉到正常水平的 1/1000 以上 → 抄答案嫌疑
③ 特征某一列的 feature_importances_一列吃掉 > 90% 的重要性(cycle_frac = 0.9581)

★ 这张表有一个非常重要的用法:

text
不要等"分数异常"再查 —— 上面每一个指纹都可以【主动测】:
    划分指纹:两种划分各跑一次(成本:一次训练)
    fit 指纹:两种口径各跑一次(成本:一次训练)
    特征指纹:打印 coef_ / importances_ / MAE(成本:几乎为零)
     ↓
三条主动体检加起来,比一次调参便宜得多,而且能挡住 90% 的返工

17.7 常见坑 ​

text
① 用一个阈值卡三种泄漏          → 一定漏(量级跨 4 个数量级)
② 看到"两种做法结果一样"就下结论"没问题" → 本数据上预处理泄漏就是这样被冤枉的
③ 先调模型、后查泄漏            → 顺序反了;泄漏没修好之前,模型比较是无效的
④ 只在报告里写"无泄漏"          → 不可验证;要写"怎么验证的"
⑤ 把"分数高"当成"特征好"        → 特征泄漏最常见的成功伪装
⑥ 认为泄漏是"数据问题"          → 它是【流程问题】:划分、fit、特征三个环节的纪律

★ 第 ③ 条值得单独说,因为它是最贵的错误:

text
划分错了 → 模型选择错了(选了最会背的那个)
        → 特征工程方向错了(往"更容易背"的方向优化)
        → 调参方向错了(正则化抑制记忆 → 你会把它调松)
        → 上线后崩了,而且找不到原因(因为离线一切都好)

一个会报错的 bug,成本是几分钟;
一个不报错的 bug,成本是整条决策链。

17.8 完整示例与输出 ​

bash
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

真实输出(节选):

text
数据: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):

  1. 按行随机划分(test_size=0.2, random_state=42),打印测试 R² 与测试行数;
  2. 按设备划分(GroupShuffleSplit, test_size=0.2, random_state=42),打印同样两项;
  3. 打印虚高,以及两侧测试行数之比;
  4. 用三行文字解释:为什么"测试行数几乎一样"这件事很重要?
参考答案
python
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}")

期望输出:

text
按行随机   测试 4,248 行   测试 R² = 0.98764
按设备划分 测试 3,995 行   测试 R² = 0.96503
虚高 = +0.02261   两侧测试行数之比 = 1.063

★ 第 4 问的答案

text
如果两侧行数差很多(比如差 50%),你还可以辩解"是数据变少了"。
但这里只差 6.3%,而且两侧训练 R² 只差 0.00017(0.99821 vs 0.99838)。
     ↓
唯一能解释这 +0.02261 的变量,就是"测试集里坐着谁"
     ↓
换句话说:泄漏的解释力必须由【测试集的构成】承担,
          不能让"数据量"替它背锅
练习 2:把三个钩子的虚高量级排序,并解释倍数关系

用 7 列基础特征为基线,按设备划分,随机森林:

  1. 跑三种配置:基线 / +cycle_frac / +soh_margin,打印 R² 与虚高;
  2. 算出 soh_margin 的虚高是 cycle_frac 的多少倍;
  3. 用两句话解释这个倍数关系(提示:它们各自偷了什么信息?)。
参考答案
python
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}")

期望输出:

text
配置                 测试 R²          虚高
(基线)               0.96503    +0.00000
cycle_frac         0.97058    +0.00556
soh_margin         1.00000    +0.03497

倍数 = 6.3

★ 第 3 问的答案

text
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"), 一次性打印三个闸门的结论:

  1. 闸门一:训练/测试的共有设备数(0 才干净);
  2. 闸门二:所有 fit 是否都在 Pipeline 内(本章先用一个布尔量占位,第 19 章给可执行检查);
  3. 闸门三:feats 里与标签 |r| > 0.999 的列(报警,不等于泄漏);
  4. 对三种配置各跑一次:按设备 + 7 列、按行随机 + 含轨迹列、按设备 + soh_margin。
参考答案
python
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)

期望输出:

text
配置 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 都报警了,但它们的性质完全不同:

text
配置 B 的 soh_lag1/lag5/win_mean5:只要上线时能拿到历史实测 SOH,它就是【合法特征】
配置 C 的 soh_margin            :无论什么条件都拿不到 —— 它是标签自己

判据不是 |r| 的大小,而是"预测这一刻拿得到吗"
    → |r| 阈值只负责【叫你来回答问题】,不负责【给答案】

面向工程实践 ​

这一章的知识在真实项目里用在哪:

场景怎么用
写验收报告不要写"我们做了防泄漏检查",要写"划分共有单元 0 个、所有 fit 在 Pipeline 内、21 列逐列登记可得性"
评审别人的模型先问三句:按什么切的?fit 在哪发生的?这个特征上线时怎么算?
定位"分数突然变好"对着 17.6 的指纹表逐条主动测,而不是继续调参
排查线上掉点线上精度崩、离线很好 → 优先怀疑预处理泄漏(训练/推理不一致)
新人上手把 17.4 的对照表作为 onboarding 材料:三类泄漏、三个环节、三种查法
排期谈判防泄漏检查的成本是"每次训练多跑一次"(约 2 倍),远低于一次返工

★ 一句话总结本章:

分数虚高不是模型的错,是流程的错。 流程上有三个环节能出错,所以要三套检查 —— 而不是一个阈值。

下一章:第 18 章 · 泄漏①:划分泄漏