第 15 章 · 消融实验:每一类特征到底值多少
这一章要解决什么:把"这个特征重要"从感觉变成一个数字。 做法只有一个:逐组拿掉、重训一次、看分数掉多少(ablation)。
最容易踩的坑: ① 只报均值不报方差 —— 你会分不清"掉了一个台阶"和"划分运气"; ② 拿单列做消融 —— 21 列 × 逐列重训 = 时间爆炸,而且结论会互相打架(16.3 会看到); ③ 把"分数没掉"读成"这列是错的" —— 它只是没用, 而"没用"的结论必须是"可以砍",不是"有害"。
本章你需要完成的代码:demos/ch15-ablation.py
15.1 消融:把"重要性"变成"丢掉它,掉多少"
先看三种常见的"重要性",它们各自的问题:
| 说法 | 怎么得到 | 问题 |
|---|---|---|
| "这一列和标签相关 0.96" | 相关系数 | 只测线性关系;两列冗余时都会显示高相关(第 14 章) |
| "模型给这一列的重要性是 0.31" | 树的不纯度下降 | 共线特征会随机挑一个给高重要性(上一本第 20 章) |
| "系数是 +26.06" | 线性模型 | 量纲、共线都会污染(第 11 章 + 上一本第 14 章) |
| "拿掉它,R² 掉 0.03997" | 消融 | 慢(每删一组要重训一次)—— 但它回答的是业务真正关心的问题 |
★ 消融的定义,一句话:
消融 Δ(组 G) = 分数(全特征 − G) − 分数(全特征)
Δ 是负的 → 这一组有用(拿掉会掉分)
Δ ≈ 0 → 这一组可以砍(不影响分数,但省时间/省维护)
Δ 是正的 → 这一组在测试集上是净负担(删掉反而好)★ 为什么用"组"而不是"列":
21 列逐列消融 = 21 次重训(本章一次重训约 5 秒 → 两分钟,还能忍)
↓ 但如果是 200 列、模型是提升树(每次 30 秒)呢?
↓ 200 × 30 秒 = 100 分钟,而且结论会互相矛盾
(删掉 soh_lag1 时 soh_lag5 顶上,两列的 Δ 都接近 0)
↓
所以工程上的默认口径是【按信息来源分组消融】:
同一类信息(同一段窗口、同一种算子的产物)放在一组里一起评估★ 本数据的分组(第 14 章已经用过这套分组):
| 组 | 列数 | 内容 |
|---|---|---|
| 基础 | 7 | 单行读数:cycle、电压、温升、内阻、库仑效率… |
| 窗口统计 | 5 | 过去 20 个窗口(不含当前)的均值/标准差 |
| 差分斜率 | 4 | 过去 20 个窗口的斜率、相邻窗口一阶差分 |
| 极差 | 1 | temp_win_range20 |
| 轨迹 | 4 | 历史实测 SOH:soh_lag1、soh_lag5、soh_win_mean5、soh_win_std5 |
| 合计 | 21 | 安全特征(不含两个泄漏钩子) |
15.2 三件必须固定的事,否则消融是白做的
消融的对照组必须只有"特征组"一个变量:
✅ 固定:划分方式(同一 tr / te)、模型与超参、random_state、可用样本集
✅ 只改:喂进去的特征列表
↓
否则你量到的差异里混着"划分运气"或"模型差异",无法归因代码骨架(本章 demo 的全部内容):
X, y, groups = d[ALL], d["soh"], d["cell_id"]
tr, te = next(GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
.split(X, y, groups))
def run(feats, label):
m = RandomForestRegressor(n_estimators=200, random_state=42, n_jobs=-1)
m.fit(X.iloc[tr][feats], y.iloc[tr])
r_te = r2_score(y.iloc[te], m.predict(X.iloc[te][feats]))
print(label, len(feats), r_te)
return r_te
best = run(ALL, "全特征")
for name, cols in GROUPS.items():
left = [c for c in ALL if c not in cols]
print("Δ =", run(left, f"删掉【{name}】") - best)★ 一个容易被忽略的细节:可用样本集必须固定
21 列口径:dropna(subset=ALL) → 19,979 行(丢掉 1,260 行冷启动样本)
↓
如果在"删掉【窗口统计】"那一次顺手少 dropna 两列,
样本数就变了(19,979 → 更多)
↓
那时 Δ 里混着"样本变多了"的效应 —— 实验就被污染了
↓
✅ 本章的所有配置都固定在同一个 19,979 行样本集上(15.6 会专门量这个代价)15.3 ★ 单次划分的消融表:只有一组掉台阶
真实输出:
① 单次按设备划分:全特征与逐组拿掉
配置 特征数 训练 R² 测试 R² Δ 测试 R² 耗时
全特征 21 0.99987 0.99904 — 6.0s
删掉【基础】 14 0.99982 0.99874 -0.00030 4.3s
删掉【窗口统计】 16 0.99987 0.99904 +0.00000 4.6s
删掉【差分斜率】 17 0.99987 0.99904 +0.00001 4.8s
删掉【轨迹】 17 0.99859 0.95906 -0.03997 5.3s
删掉【极差】 20 0.99987 0.99904 +0.00000 5.8s
只用【轨迹】 4 0.99981 0.99862 -0.00042 1.4s
读法:删掉【轨迹】掉了一个完整的台阶(-0.03997),
其余各组几乎不动 —— 有三组是 +0.00000 / +0.00001 / +0.00000(删掉反而略好)。
★ 把这张表读成三句话:
| 观察 | 数字 | 一句话 |
|---|---|---|
| 只有一组掉台阶 | 删【轨迹】 −0.03997 | 4 列拿走了几乎全部功劳 |
| 三组"删了更好" | 窗口 +0.00000 / 差分 +0.00001 / 极差 +0.00000 | 它们是净负担(15.6 讲原因) |
| 4 列顶 21 列 | 只用【轨迹】 0.99862 vs 全 21 列 0.99904 | 差 0.00042,列数少了 17 列 |
★ 这就是这本书最重要的一张表,因为它把三个反转全放在一起:
反转一:加 10 列(窗口 + 差分 + 极差)→ 分数【没涨】(甚至略降)
反转二:加 4 列(轨迹)→ 分数【跳了】0.04(0.95906 → 0.99904)
反转三(第 21 章):再加 1 列(soh_margin)→ R² 恰好 1.000000 —— 那是事故★ 一个必须说清的口径:删掉【基础】 只掉 −0.00030, 但第 11~13 章里"只用 7 列基础特征"的分数是 0.96503。为什么同一组特征差这么多?
"只用基础 7 列" = 0.96503 (其它 14 列一列都没有)
"删掉基础,剩下 14 列" = 0.99874 (轨迹那 4 列还在!)
↓
两次的对照组完全不同
↓
这就是 15.7 要专门讲的那句话:Δ 必须连着对照组一起读。15.4 只用轨迹的 4 列:0.99862
再看一眼那个数字:
全 21 列 :测试 R² = 0.99904
只用【轨迹】4 列:测试 R² = 0.99862 (Δ = -0.00042)
只用【基础】7 列:测试 R² = 0.96503 (第 11~13 章的口径)★ 三行对照,结论很硬:
把 21 列压到 4 列,只掉 0.00042
把 21 列压到 7 列(但删错了那 4 列),掉 0.03401
↓
"多少列"不是关键,"哪 4 列"才是关键
↓
这 4 列是什么?历史【实测】SOH —— 它们把问题从
"看这一刻的读数,猜 SOH"(横截面回归)
变成了
"看这支设备最近走过的路,推 SOH"(轨迹外推)★ 为什么这 4 列这么值钱:它们改变了问题的类型
| 基础 7 列 | 轨迹 4 列 | |
|---|---|---|
| 每一行看到的信息 | 这一刻的电压/内阻/温度 | 这支设备过去的 SOH 实测值 |
| 模型要做的事 | 从物理读数反推退化程度 | 从历史轨迹外推下一时刻 |
| 类比的难度 | 给你一张体检单,猜年龄 | 给你过去 5 年的照片,猜今年的样子 |
| 实测上限 | 0.96503 | 0.99862(单用这 4 列) |
★ 但要写清前提(第 10 章的判据):
soh_lag1 = s.shift(1) → 只用【过去】的实测值
↓ 前提一:上线时能拿到该设备的历史实测 SOH(真实 BMS 会存)
↓ 前提二:shift(1) 一位都不能少(第 16 章会给出自动校验)★ 一句可以写进报告的话:
在这份数据上,21 列里真正值钱的是那 4 列历史 SOH; 其余十几列几乎不涨分,甚至因为丢了冷启动样本而略降。
15.5 5 折 CV:Δ 要和方差一起看
单次划分只有一个"运气"。 把同一批配置放到 5 折 GroupKFold 上:
真实输出:
③ 5 折 GroupKFold:用方差检验这些 Δ
全特征(21 列)
逐折 R² = [0.99902, 0.99906, 0.99908, 0.9991, 0.99907]
均值 0.99906 ± 0.00003
删【轨迹】(17 列)
逐折 R² = [0.96989, 0.95338, 0.96027, 0.95155, 0.9302]
均值 0.95306 ± 0.01312 Δ = -0.04601
删【窗口统计】(16 列)
逐折 R² = [0.99902, 0.99907, 0.99908, 0.9991, 0.99906]
均值 0.99907 ± 0.00003 Δ = +0.00000
删【差分斜率】(17 列)
逐折 R² = [0.99903, 0.99907, 0.99908, 0.9991, 0.99907]
均值 0.99907 ± 0.00002 Δ = +0.00001
只用【轨迹】(4 列)
逐折 R² = [0.99859, 0.99866, 0.99865, 0.99871, 0.99864]
均值 0.99865 ± 0.00004 Δ = -0.00042★ 三条读法:
① 删【轨迹】的 Δ = -0.04601,【均值差】是【它自己折间标准差】的 3.5 倍:
全特征 0.99906 ± 0.00003(折间几乎不动)
删轨迹 0.95306 ± 0.01312(逐折 [0.9699, 0.9534, 0.9603, 0.9515, 0.9302])
→ 这个 Δ 站得住:它比噪声大一个数量级
② 删【窗口统计】/【差分斜率】的 Δ = +0.00000 / +0.00001,
都【小于】折间波动 ±0.00003:
→ 在统计上这两个 Δ 就是 0,"这两组可以砍"是被数据支持的
③ 只报均值不报方差 → 你无法区分①和②。★ 方差大不是缺点:
删【轨迹】那一组的折间标准差是 ±0.01312(比全特征大 400 倍)
↓
它在说:"这一组在不同设备子集上的表现差很多"
(有一折只有 0.9302 —— 那一折验证的设备特别"难")
↓
这是信息,不是噪声:它提醒你"轨迹特征的效果依赖设备类型"★ 一个常见误用:
❌ "删【轨迹】后 CV 是 0.95306 ± 0.01312,说明这个方法不稳定,不可用"
✅ "Δ = −0.04601 是 ±0.01312 的 3.5 倍,说明【轨迹组非常重要】这件事是稳的;
同时 ±0.01312 提醒我们【具体的分数水平】在不同设备子集上波动较大"★ 单次划分 vs 5 折 CV,两个口径都要报:
| 口径 | 删【轨迹】 | 删【窗口统计】 | 删【差分斜率】 |
|---|---|---|---|
| 单次划分 | −0.03997 | +0.00000 | +0.00001 |
| 5 折 CV | −0.04601 ± 0.01312 | +0.00000 ± 0.00003 | +0.00001 ± 0.00002 |
两个口径给出同一个【定性结论】(轨迹值钱、另两组可砍),
数值上差了 0.006(单次划分的运气)
↓
所以:报告里两个都给,
用单次划分说"方向和量级",用 CV 说"这个结论稳不稳"15.6 为什么"删掉反而略好":冗余 + 1,260 行样本的代价
先把现象摆清楚:
删【窗口统计】 Δ = +0.00000
删【差分斜率】 Δ = +0.00001
删【极差】 Δ = +0.00000★ 三个原因,逐个说:
原因一:信息冗余(第 14 章的相关系数已经预警)
真实输出:
④ 为什么删掉反而略好:冗余 + 1260 行样本的代价
基础列与它的时序版本几乎在说同一件事(|r| > 0.99):
r(resistance_mohm , res_win_mean ) = 0.9951
r(mean_charge_voltage_v , volt_win_mean ) = 0.9950
r(mean_temp_rise_c , temp_win_mean ) = 0.9917"过去 20 个窗口的内阻均值" 与 "当前这一行的内阻" 相关 0.9951
↓
为什么?设备老化是慢过程:相邻 20 个窗口里这一列几乎没变
↓
所以"窗口统计"给的是同一个数的另一种写法 → 加进去不涨分原因二:这十来列的 |r| 本来就很低
volt_slope20 -0.1288
res_slope20 -0.3168
temp_slope20 -0.1000
res_diff1 -0.0171斜率/差分列的 |r| 都在 0.32 以下(res_diff1 只有 0.017)
↓
它们描述的是"变化的方向和速度" —— 在 20 个窗口的尺度上,
这个变化量相对测量噪声太小
↓
所以它们既没提供新信息,也没提供有效信息原因三:它们带来了 NaN,dropna 又丢掉 1,260 行样本
真实输出:
② 把【基础】这一组单拎出来看:它自己值多少
只用【基础 7 列】:可用样本 21,239 行(比 21 列多 1,260 行),测试 R² = 0.96503
全 21 列 :可用样本 19,979 行,测试 R² = 0.99904
→ 加了 14 列时序特征、代价是丢掉 1,260 行冷启动样本,净收益 +0.03401★ 这三条原因合成一句结论:
① 信息冗余 → 加了不涨分
② |r| 极低 → 本来就没用
③ 带 NaN → 每支设备最早的 20 个窗口被迫丢掉(全表 1,260 行)
↓
净效果 = 分数不动(甚至 +0.00001)+ 训练更慢 + 维护更贵
↓
所以"删掉"是【正确的工程决定】,哪怕 Δ 是 +0.00000★ 一条必须写进结论的方向纪律:
这两组的 Δ 是两个正负号都可能出现的极小量。不要把
+0.00001解读成"这列有害",它的正确解读是"这列可以砍"。
"有害" 需要证据:删掉之后分数【显著】上升(比如 +0.01 以上,且大于折间波动)
"没用" 只需要证据:删掉之后分数【在噪声内不动】
↓
本数据属于后者15.7 同一组特征在不同上下文里的 Δ 不是同一个数
真实输出(把【基础】这一组单拎出来):
但删掉【基础】之后只剩 14 列,测试 R² = 0.99874(Δ -0.00030):
基础 7 列单独用是 0.96503,作为 21 列里的一组只贡献 -0.00030 ——
同一组特征在【不同上下文】里的消融 Δ 完全不是一个数。★ 为什么差这么多:
"只用基础 7 列" → 模型手里只有横截面读数,没有历史 → 0.96503
"删掉基础,剩 14 列" → 那 14 列里有【轨迹 4 列】→ 0.99874
↓
轨迹列已经把"这支设备走到哪一段了"交代清楚了,
基础列提供的是"同一件事的更粗的读数"
↓
所以基础列在【有轨迹】的上下文里只值 −0.00030,
在【没轨迹】的上下文里值 0.03401(相对 7 列基线 0.96503 到 21 列的 0.99904 的差)★ 这条纪律适用于所有消融报告:
引用一个 Δ 的时候,必须同时写清两件事: ① 对照组是什么(其它组都在不在);② 用什么口径量的(单次划分 / 几折 CV)。
★ 一个具体例子:同一个泄漏钩子,两个对照组,两个数
真实输出(5 折 CV 口径,对照组 A = 21 列安全特征):
⑤ 消融也是找泄漏的探针:Δ 高得离谱就要报警(但 Δ 要连着对照组读)
对照组 A = 21 列安全特征(已经含【轨迹】):
全 21 列 + cycle_frac 测试 R² = 0.99904 Δ = +0.00000
全 21 列 + soh_margin 测试 R² = 1.00000 Δ = +0.00096
对照组 B = 7 列基础特征(不含轨迹,全表 21,239 行,第 11/12 章的划分口径):
7 列基础(无钩子) 测试 R² = 0.96503
7 列基础 + cycle_frac 测试 R² = 0.97058 Δ = +0.00555
7 列基础 + soh_margin 测试 R² = 1.00000 Δ = +0.03497★ 同一列 cycle_frac,两个对照组给出 +0.00000 和 +0.00555:
对照组 A 里已经有轨迹 4 列 —— 它们已经告诉模型"这支设备走到寿命的哪一段"
↓
cycle_frac 想说的"同一件事",模型早就知道了 → Δ ≈ 0
↓
对照组 B 里只有横截面读数 → cycle_frac 提供的是新信息 → Δ = +0.00555★ 所以"这个特征值 0.005"这句话是不完整的:
❌ "cycle_frac 值 0.0055"
✅ "在【只有 7 列基础特征】的对照下,cycle_frac 的测试 R² Δ = +0.0055;
在【21 列安全特征(含轨迹)】的对照下,Δ = +0.0000"★ 而 soh_margin 在哪个对照组里都指向 1.00000:
因为它【就是标签本身】(soh_margin ≡ soh − 0.80)
↓
Δ 大得离谱(+0.03497,是全表最大的一笔)
↓
★ 先别高兴 —— 先问第 10 章那句话:
"预测这一刻,我手上真的拿得到这个数吗?"
↓
拿不到 → 这不是特征,这是泄漏(第 20~21 章会把它做成 R² = 1.000000 的事故现场)15.8 消融的三个真实用途
① 决定"砍哪一组"(省时间、省维护)
本数据的现场决定:
【窗口统计】/【差分斜率】/【极差】→ 砍(Δ 在噪声内)
【轨迹】→ 必须留(Δ = −0.04601,是折间标准差的 3.5 倍)
【基础】→ 留着(它撑住 0.96 档的下限;模型换、设备换时它更稳)② 当泄漏探针(Δ 异常大 = 报警)
Δ 的量级经验值:
+0.00x → 正常(这一组有用)
+0.000x → 噪声内(可以砍)
+0.03 以上、或者直接把 R² 顶到 0.99999/1.00000 → 先查它是不是用了未来信息③ 给"特征工程值不值"定预算
本数据的教训:
花在"窗口统计/差分斜率"上的 9 列工程 → 收益 0.00000
花在"能不能拿到历史实测 SOH"上的 4 列工程 → 收益 0.04000
↓
★ 收益不在"算得更花哨",而在"把问题的信息结构找对"★ 一个可以复用的消融报告骨架:
def ablation_report(df, groups, target, groups_col, tr, te, model_factory):
"""逐组拿掉重训,输出 Δ 与耗时 —— 报告里必须同时给对照组说明。"""
import time
from sklearn.metrics import r2_score
all_cols = [c for cols in groups.values() for c in cols]
X, y, g = df[all_cols], df[target], df[groups_col]
def run(feats):
m = model_factory()
t0 = time.perf_counter()
m.fit(X.iloc[tr][feats], y.iloc[tr])
dt = time.perf_counter() - t0
return r2_score(y.iloc[te], m.predict(X.iloc[te][feats])), dt
base, dt0 = run(all_cols)
print(f"{'配置':24s} {'特征':>4s} {'测试 R²':>10s} {'Δ':>10s} {'耗时':>8s}")
print(f"{'全特征':24s} {len(all_cols):4d} {base:10.5f} {'—':>10s} {dt0:7.1f}s")
for name, cols in groups.items():
left = [c for c in all_cols if c not in cols]
r, dt = run(left)
print(f"{'删掉【' + name + '】':24s} {len(left):4d} {r:10.5f} "
f"{r - base:+10.5f} {dt:7.1f}s")
print("⚠️ 口径:同一划分 / 同一模型 / 同一可用样本集;Δ 要和折间方差一起读")15.9 常见坑
① 消融时忘了固定划分/种子 → 差异被"划分运气"淹没(同一配置两次能差 0.006)
② 只报均值不报方差 → 分不清"掉台阶"和"噪声"(15.5)
③ 拿单列做消融 → 时间爆炸,且共线列会互相顶替(结论互相打架)
④ 忘了固定可用样本集 → 删掉带 NaN 的组时,样本数悄悄变了(15.2)
⑤ 把"分数没掉"读成"这列是错的" → 正确读法是"可以砍"(15.6)
⑥ 把 +0.00001 读成"这列有害" → 它在噪声量级内,方向没有意义
⑦ 引用 Δ 时不写对照组 → 同一个 cycle_frac 可以是 +0.00000 或 +0.00555(15.7)
⑧ 只看测试 R² 不看训练 R² → 训练 R² 涨、测试 R² 不涨说明"学进去了但没用"(见下表)
⑨ 把消融当成"特征选择算法" → 消融是【验证】工具,选特征还是要靠领域知识 + 第 14 章★ 第 ⑧ 条在本数据里的读法(真实输出里那一列训练 R²):
删掉【轨迹】:训练 0.99859 / 测试 0.95906 ← 训练也掉了,说明真的少了信息
删掉【窗口统计】:训练 0.99987 / 测试 0.99904 ← 训练几乎不动
↓
"训练 R² 不动 + 测试 R² 不动" = 这组特征对模型【没有新增可用信息】15.10 完整示例与输出
cd tools/feature-engineering-pipeline/code
MPLCONFIGDIR=/tmp/fe-mpl /path/to/venv/bin/python demos/ch15-ablation.py真实输出(节选):
整表 21,239 行 → 21 列时序特征齐全的可用样本 19,979 行 / 60 支设备
(丢掉 1,260 行冷启动样本:每支设备最早的若干窗口没有历史)
① 单次按设备划分:全特征与逐组拿掉
全特征 21 0.99987 0.99904 — 6.0s
删掉【基础】 14 0.99982 0.99874 -0.00030 4.3s
删掉【窗口统计】 16 0.99987 0.99904 +0.00000 4.6s
删掉【差分斜率】 17 0.99987 0.99904 +0.00001 4.8s
删掉【轨迹】 17 0.99859 0.95906 -0.03997 5.3s
删掉【极差】 20 0.99987 0.99904 +0.00000 5.8s
只用【轨迹】 4 0.99981 0.99862 -0.00042 1.4s
② 只用【基础 7 列】:可用样本 21,239 行,测试 R² = 0.96503
全 21 列 :可用样本 19,979 行,测试 R² = 0.99904
③ 5 折 GroupKFold:
全特征 均值 0.99906 ± 0.00003
删【轨迹】 均值 0.95306 ± 0.01312 Δ = -0.04601
删【窗口统计】均值 0.99907 ± 0.00003 Δ = +0.00000
删【差分斜率】均值 0.99907 ± 0.00002 Δ = +0.00001
只用【轨迹】均值 0.99865 ± 0.00004 Δ = -0.00042
⑤ 全 21 列 + cycle_frac → 0.99904(Δ +0.00000);+ soh_margin → 1.00000(Δ +0.00096)
7 列基础 + cycle_frac → 0.97058(Δ +0.00555);+ soh_margin → 1.00000(Δ +0.03497)★ 一页记住这一章:
单次划分:全特征 0.99904
删【轨迹】0.95906(Δ −0.03997) ← 唯一掉台阶的组
删【窗口统计】Δ +0.00000
删【差分斜率】Δ +0.00001 ← 删掉反而略好
只用【轨迹】4 列 = 0.99862
5 折 CV :全特征 0.99906 ± 0.00003
删【轨迹】0.95306 ± 0.01312(Δ −0.04601,是折间标准差的 3.5 倍)
↓
结论:21 列里真正值钱的是那 4 列历史 SOH;其余十几列几乎不涨分,
甚至因为丢了 1,260 行冷启动样本而略降。本章练习
练习 1:把正文的消融表自己跑一遍
用 21 列安全特征(dropna 后 19,979 行),按设备划分(test_size=0.2, random_state=42):
- 用随机森林跑"全特征"作为基线,报训练/测试 R²;
- 逐组拿掉(基础 / 窗口统计 / 差分斜率 / 极差 / 轨迹),各报特征数与 Δ 测试 R²;
- 再跑一次"只用【轨迹】",报它的 Δ;
- 用一句话回答:哪一组值得留、哪几组可以砍?
参考答案
import os
import pandas as pd
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import r2_score
from sklearn.model_selection import GroupShuffleSplit
HERE = os.path.dirname(os.path.abspath(__file__))
CODE = os.path.abspath(os.path.join(HERE, "..", ".."))
DATA = os.path.join(CODE, "data", "battery")
GROUPS = {
"基础": ["cycle", "mean_charge_voltage_v", "mean_temp_rise_c", "resistance_mohm",
"coulombic_eff", "cap_fade_rate_ah_per_100", "temp_std_c"],
"窗口统计": ["volt_win_mean", "volt_win_std", "res_win_mean", "res_win_std",
"temp_win_mean"],
"差分斜率": ["volt_slope20", "res_slope20", "temp_slope20", "res_diff1"],
"轨迹": ["soh_lag1", "soh_lag5", "soh_win_mean5", "soh_win_std5"],
}
RANGE = ["temp_win_range20"]
ALL = [c for cols in GROUPS.values() for c in cols] + RANGE
df = pd.read_csv(os.path.join(DATA, "soh_features_v2.csv"))
d = df.dropna(subset=ALL)
X, y, g = d[ALL], d["soh"], d["cell_id"]
tr, te = next(GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
.split(X, y, g))
def run(feats, label):
m = RandomForestRegressor(n_estimators=100, random_state=42, n_jobs=-1)
m.fit(X.iloc[tr][feats], y.iloc[tr])
r_tr = r2_score(y.iloc[tr], m.predict(X.iloc[tr][feats]))
r_te = r2_score(y.iloc[te], m.predict(X.iloc[te][feats]))
print(f"{label:22s} 特征 {len(feats):2d} 训练 R² = {r_tr:.5f} "
f"测试 R² = {r_te:.5f}")
return r_te
base = run(ALL, "全特征")
for name, cols in GROUPS.items():
left = [c for c in ALL if c not in cols]
print(f"{'':22s} Δ 测试 R² = {run(left, '删掉【' + name + '】') - base:+.5f}")
print(f"{'':22s} Δ 测试 R² = "
f"{run([c for c in ALL if c not in RANGE], '删掉【极差】') - base:+.5f}")
print(f"{'':22s} Δ 测试 R² = {run(GROUPS['轨迹'], '只用【轨迹】') - base:+.5f}")期望输出(随机森林 100 棵):
全特征 特征 21 训练 R² = 0.99987 测试 R² = 0.99903
删掉【基础】 特征 14 训练 R² = 0.99982 测试 R² = 0.99873
Δ 测试 R² = -0.00030
删掉【窗口统计】 特征 16 训练 R² = 0.99987 测试 R² = 0.99903
Δ 测试 R² = +0.00000
删掉【差分斜率】 特征 17 训练 R² = 0.99987 测试 R² = 0.99904
Δ 测试 R² = +0.00001
删掉【轨迹】 特征 17 训练 R² = 0.99855 测试 R² = 0.95930
Δ 测试 R² = -0.03973
删掉【极差】 特征 20 训练 R² = 0.99987 测试 R² = 0.99903
Δ 测试 R² = -0.00000
只用【轨迹】 特征 4 训练 R² = 0.99981 测试 R² = 0.99861
Δ 测试 R² = -0.00042④ 回答:留【轨迹】,砍【窗口统计】/【差分斜率】/【极差】。
删掉【轨迹】掉了一个完整的台阶(-0.03973)—— 这是唯一一个
"均值差远大于噪声"的组(见练习 2)
删掉【窗口统计】/【差分斜率】/【极差】:Δ 在 +0.00001 ~ -0.00000 之间,
完全在折间波动以内
↓
【基础】只掉 0.00030,但它是 0.96 档的下限:模型换、设备换时它最稳 → 留着★ 口径提醒:这里用 100 棵树;正文用 200 棵,所以正文字典里 全特征是 0.99904、删【轨迹】是 0.95906(Δ −0.03997)。 两边结论完全一致,数值差异在第 4~5 位小数。
练习 2:用 3 折 CV 把 Δ 报成「均值 ± 标准差」
- 用
GroupKFold(n_splits=3)跑「全特征 + 逐组拿掉 + 只用轨迹」,各报逐折 R²、均值 ± 标准差、Δ; - 回答:哪一组的 Δ 大于它自己的折间标准差?哪一组的 Δ 落在噪声里?
- 回答:如果只报均值不报标准差,你会做出什么错误判断?
参考答案
import os
import time
import numpy as np
import pandas as pd
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import r2_score
from sklearn.model_selection import GroupKFold
HERE = os.path.dirname(os.path.abspath(__file__))
CODE = os.path.abspath(os.path.join(HERE, "..", ".."))
DATA = os.path.join(CODE, "data", "battery")
GROUPS = {
"基础": ["cycle", "mean_charge_voltage_v", "mean_temp_rise_c", "resistance_mohm",
"coulombic_eff", "cap_fade_rate_ah_per_100", "temp_std_c"],
"窗口统计": ["volt_win_mean", "volt_win_std", "res_win_mean", "res_win_std",
"temp_win_mean"],
"差分斜率": ["volt_slope20", "res_slope20", "temp_slope20", "res_diff1"],
"轨迹": ["soh_lag1", "soh_lag5", "soh_win_mean5", "soh_win_std5"],
}
RANGE = ["temp_win_range20"]
ALL = [c for cols in GROUPS.values() for c in cols] + RANGE
df = pd.read_csv(os.path.join(DATA, "soh_features_v2.csv"))
d = df.dropna(subset=ALL)
X, y, g = d[ALL], d["soh"], d["cell_id"]
cv = GroupKFold(n_splits=3)
def folds_of(feats):
out = []
for a, b in cv.split(X[feats], y, g):
m = RandomForestRegressor(n_estimators=100, random_state=42, n_jobs=-1)
m.fit(X[feats].iloc[a], y.iloc[a])
out.append(r2_score(y.iloc[b], m.predict(X[feats].iloc[b])))
return np.array(out)
t0 = time.perf_counter()
base = folds_of(ALL)
print(f"全特征 逐折 {[round(float(v), 5) for v in base]} "
f"均值 {base.mean():.5f} ± {base.std():.5f}")
for name, cols in GROUPS.items():
left = [c for c in ALL if c not in cols]
f = folds_of(left)
print(f"删【{name}】 逐折 {[round(float(v), 5) for v in f]} "
f"均值 {f.mean():.5f} ± {f.std():.5f} Δ = {f.mean() - base.mean():+.5f}")
f = folds_of(GROUPS["轨迹"])
print(f"只用【轨迹】逐折 {[round(float(v), 5) for v in f]} "
f"均值 {f.mean():.5f} ± {f.std():.5f} Δ = {f.mean() - base.mean():+.5f}")
print(f"总耗时 {time.perf_counter() - t0:.1f}s")期望输出:
全特征(21 列)
逐折 = [0.99902, 0.99907, 0.99905]
均值 0.99905 ± 0.00002
删【基础】(14 列)
逐折 = [0.99871, 0.99875, 0.99874]
均值 0.99873 ± 0.00002 Δ = -0.00031
删【窗口统计】(16 列)
逐折 = [0.99902, 0.99908, 0.99905]
均值 0.99905 ± 0.00002 Δ = +0.00000
删【差分斜率】(17 列)
逐折 = [0.99903, 0.99908, 0.99906]
均值 0.99905 ± 0.00002 Δ = +0.00001
删【轨迹】(17 列)
逐折 = [0.93307, 0.96259, 0.96055]
均值 0.95207 ± 0.01346 Δ = -0.04698
只用【轨迹】(4 列)
逐折 = [0.99861, 0.99864, 0.99863]
均值 0.99863 ± 0.00001 Δ = -0.00042
总耗时 29.0s② 回答:
删【轨迹】:Δ = -0.04698,它自己的折间标准差是 0.01346
→ |Δ| / std ≈ 3.5 倍 → 结论站得住
删【基础】:Δ = -0.00031,是 std(0.00002) 的 15 倍 → 也站得住,但量级只有万分之三
删【窗口统计】:Δ = +0.00000,std = 0.00002 → 落在噪声里
删【差分斜率】:Δ = +0.00001,std = 0.00002 → 落在噪声里
只用【轨迹】:Δ = -0.00042,std = 0.00001 → 站得住(但只是万分之四)③ 回答:只报均值会让你把"噪声"当成"结论"。
删【窗口统计】Δ = +0.00000:只报均值 → "删了没影响"(这次恰好对)
删【差分斜率】Δ = +0.00001:只报均值 → "删了反而更好?"
↓
而它们的 std 是 0.00002 —— 两个 Δ 都小于一个标准差
↓
只报均值时你无法判断:
这个 +0.00001 是"真的更好"还是"划分运气"?
↓
★ 报告纪律:Δ 后面永远跟一个 ±(来自 CV 或多次划分)★ 顺带一个对比:3 折的 Δ(−0.04698)比 5 折(−0.04601)略大 —— 折数越少,每折的训练集越小、验证集越大,数值会有差异,但方向和量级一致。
练习 3:Δ 要连着对照组读(同一个钩子,两个数)
- 在对照组 A(21 列安全特征,含轨迹)上加
cycle_frac与soh_margin,报 Δ; - 在对照组 B(7 列基础特征,不含轨迹)上加同样两列,报 Δ;
- 解释为什么同一个
cycle_frac会有两个不同的 Δ; - 回答:写报告时该怎么写这一列的价值?
参考答案
import os
import pandas as pd
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import r2_score
from sklearn.model_selection import GroupShuffleSplit
HERE = os.path.dirname(os.path.abspath(__file__))
CODE = os.path.abspath(os.path.join(HERE, "..", ".."))
DATA = os.path.join(CODE, "data", "battery")
BASE = ["cycle", "mean_charge_voltage_v", "mean_temp_rise_c", "resistance_mohm",
"coulombic_eff", "cap_fade_rate_ah_per_100", "temp_std_c"]
WIN = ["volt_win_mean", "volt_win_std", "res_win_mean", "res_win_std", "temp_win_mean"]
DIFF = ["volt_slope20", "res_slope20", "temp_slope20", "res_diff1"]
RANGE = ["temp_win_range20"]
TRAJ = ["soh_lag1", "soh_lag5", "soh_win_mean5", "soh_win_std5"]
ALL = BASE + WIN + DIFF + RANGE + TRAJ
LEAKY = ["cycle_frac", "soh_margin"]
df = pd.read_csv(os.path.join(DATA, "soh_features_v2.csv"))
d = df.dropna(subset=ALL)
RF = dict(n_estimators=100, random_state=42, n_jobs=-1)
def r2(table, feats, target, groups, key):
tr, te = next(GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
.split(table[feats], target, groups))
m = RandomForestRegressor(**RF).fit(table[feats].iloc[tr], target.iloc[tr])
return round(r2_score(target.iloc[te], m.predict(table[feats].iloc[te])), 5), (tr, te)
X23 = d[ALL + LEAKY]
y, g = d["soh"], d["cell_id"]
tr, te = next(GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
.split(X23[ALL], y, g))
base_a = round(r2_score(y.iloc[te], RandomForestRegressor(**RF)
.fit(X23[ALL].iloc[tr], y.iloc[tr])
.predict(X23[ALL].iloc[te])), 5)
print(f"对照组 A(21 列安全特征)基线 {base_a:.5f}")
for c in LEAKY:
r = round(r2_score(y.iloc[te], RandomForestRegressor(**RF)
.fit(X23[ALL + [c]].iloc[tr], y.iloc[tr])
.predict(X23[ALL + [c]].iloc[te])), 5)
print(f" + {c:12s} → {r:.5f} Δ = {r - base_a:+.5f}")
Xb, yb, gb = df[BASE + LEAKY], df["soh"], df["cell_id"]
trb, teb = next(GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
.split(Xb[BASE], yb, gb))
base_b = round(r2_score(yb.iloc[teb], RandomForestRegressor(**RF)
.fit(Xb[BASE].iloc[trb], yb.iloc[trb])
.predict(Xb[BASE].iloc[teb])), 5)
print(f"\n对照组 B(7 列基础特征)基线 {base_b:.5f}")
for c in LEAKY:
r = round(r2_score(yb.iloc[teb], RandomForestRegressor(**RF)
.fit(Xb[BASE + [c]].iloc[trb], yb.iloc[trb])
.predict(Xb[BASE + [c]].iloc[teb])), 5)
print(f" + {c:12s} → {r:.5f} Δ = {r - base_b:+.5f}")期望输出:
对照组 A(21 列安全特征,含轨迹):基线 0.99903
+ cycle_frac → 0.99903 Δ = +0.00000
+ soh_margin → 1.00000 Δ = +0.00097
对照组 B(7 列基础,不含轨迹,全表 21,239 行):基线 0.96487
+ cycle_frac → 0.97027 Δ = +0.00540
+ soh_margin → 1.00000 Δ = +0.03513③ 回答:因为"Δ 衡量的是新增信息,而新增信息取决于你已经知道多少"。
对照组 A 里已有【轨迹 4 列】
→ 它们已经告诉模型"这支设备走到了寿命的哪一段"
→ cycle_frac 想说的正是同一件事 → 新增信息 ≈ 0 → Δ ≈ 0
对照组 B 里只有横截面读数
→ "走到哪一段"是新信息 → Δ = +0.005 量级
而 soh_margin 在两个对照组里都把 R² 顶到 1.00000
→ 因为它不是"新信息",它就是【标签本身】(soh_margin ≡ soh − 0.80)④ 报告要这样写:
❌ "cycle_frac 值 0.0054"
✅ "在【7 列基础特征】这个对照组下,cycle_frac 的测试 R² Δ = +0.0054;
在【21 列安全特征(含轨迹)】的对照组下,Δ = +0.0000。
两处口径的划分均为按设备 GroupShuffleSplit(test_size=0.2, random_state=42)。"
❌ "soh_margin 让 R² 涨了 0.035,是个强特征"
✅ "soh_margin 让 R² 恰好变成 1.00000 —— 它是标签的线性函数,
属于特征泄漏,必须删掉(第 21 章会把它做成事故现场)"面向工程实践:把消融变成一次"预算评审"
① 消融报告的最小格式
每组一行,至少四列:
组名 | 列数 | Δ 测试 R²(单次划分) | Δ 均值 ± 标准差(CV)
再加一条固定的口径说明:
划分方式 / 模型与超参 / 可用样本数 / 对照组(哪些组同时在)★ 缺了最后一项,这份报告就没法被别人复核(15.7 的教训)。
② 三种结论,三种动作
| Δ 的表现 | 结论 | 动作 |
|---|---|---|
| 负,且 |Δ| > 3 × 折间标准差 | 这一组有用 | 保留,并写进文档"为什么需要它" |
| 在噪声内(|Δ| < 1 × 折间标准差) | 这一组可以砍 | 砍掉,记录"砍掉后分数不变" |
| 大得离谱(Δ > 0.03,或 R² → 1.00000) | 先别高兴 | 按第 10 章判据查它是不是用了未来信息 |
③ 一个真实的预算账(本数据)
工程投入 收益(测试 R²)
基础 7 列(第一版就有) 0.96503
+ 窗口统计 / 差分 / 极差(10 列) +0.00000 ← 白做(而且丢了 1,260 行样本)
+ 轨迹 4 列(要接历史实测 SOH 管道) +0.04001 ← 全部收益在这里★ 一句话总结这件事:
特征工程的价值不在"多算几个统计量", 而在"把问题的信息结构找对"。消融是唯一能把这笔账算清楚的工具。
④ 消融和下一章的关系
第 14 章(相关性 + k 曲线):看"谁和标签有关、谁和谁冗余"(地图)
第 15 章(消融): 看"拿掉它掉多少"(证据)
第 16 章(特征账本): 把前两章的结论 + 可得性,写成一张每列一行的表(交付物)★ 最后一句,也是通往下一章的桥:
消融告诉你"这一组值多少",但它没告诉你"这一列上线拿不拿得到"。两件事必须写在同一张表里 —— 否则"值 0.0055"的那一列, 可能恰好是明天让线上模型崩掉的那一列。