Skip to content

第 15 章 · 消融实验:每一类特征到底值多少 ​

这一章要解决什么:把"这个特征重要"从感觉变成一个数字。 做法只有一个:逐组拿掉、重训一次、看分数掉多少(ablation)。

最容易踩的坑: ① 只报均值不报方差 —— 你会分不清"掉了一个台阶"和"划分运气"; ② 拿单列做消融 —— 21 列 × 逐列重训 = 时间爆炸,而且结论会互相打架(16.3 会看到); ③ 把"分数没掉"读成"这列是错的" —— 它只是没用, 而"没用"的结论必须是"可以砍",不是"有害"。

本章你需要完成的代码:demos/ch15-ablation.py

15.1 消融:把"重要性"变成"丢掉它,掉多少" ​

先看三种常见的"重要性",它们各自的问题:

说法怎么得到问题
"这一列和标签相关 0.96"相关系数只测线性关系;两列冗余时都会显示高相关(第 14 章)
"模型给这一列的重要性是 0.31"树的不纯度下降共线特征会随机挑一个给高重要性(上一本第 20 章)
"系数是 +26.06"线性模型量纲、共线都会污染(第 11 章 + 上一本第 14 章)
"拿掉它,R² 掉 0.03997"消融慢(每删一组要重训一次)—— 但它回答的是业务真正关心的问题

★ 消融的定义,一句话:

text
消融 Δ(组 G) = 分数(全特征 − G) − 分数(全特征)

Δ 是负的 → 这一组有用(拿掉会掉分)
Δ ≈ 0    → 这一组可以砍(不影响分数,但省时间/省维护)
Δ 是正的 → 这一组在测试集上是净负担(删掉反而好)

★ 为什么用"组"而不是"列":

text
21 列逐列消融 = 21 次重训(本章一次重训约 5 秒 → 两分钟,还能忍)
    ↓ 但如果是 200 列、模型是提升树(每次 30 秒)呢?
    ↓ 200 × 30 秒 = 100 分钟,而且结论会互相矛盾
      (删掉 soh_lag1 时 soh_lag5 顶上,两列的 Δ 都接近 0)
    ↓
所以工程上的默认口径是【按信息来源分组消融】:
    同一类信息(同一段窗口、同一种算子的产物)放在一组里一起评估

★ 本数据的分组(第 14 章已经用过这套分组):

组列数内容
基础7单行读数:cycle、电压、温升、内阻、库仑效率…
窗口统计5过去 20 个窗口(不含当前)的均值/标准差
差分斜率4过去 20 个窗口的斜率、相邻窗口一阶差分
极差1temp_win_range20
轨迹4历史实测 SOH:soh_lag1、soh_lag5、soh_win_mean5、soh_win_std5
合计21安全特征(不含两个泄漏钩子)

15.2 三件必须固定的事,否则消融是白做的 ​

消融的对照组必须只有"特征组"一个变量:

text
✅ 固定:划分方式(同一 tr / te)、模型与超参、random_state、可用样本集
✅ 只改:喂进去的特征列表
    ↓
否则你量到的差异里混着"划分运气"或"模型差异",无法归因

代码骨架(本章 demo 的全部内容):

python
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)

★ 一个容易被忽略的细节:可用样本集必须固定

text
21 列口径:dropna(subset=ALL) → 19,979 行(丢掉 1,260 行冷启动样本)
    ↓
如果在"删掉【窗口统计】"那一次顺手少 dropna 两列,
样本数就变了(19,979 → 更多)
    ↓
那时 Δ 里混着"样本变多了"的效应 —— 实验就被污染了
    ↓
✅ 本章的所有配置都固定在同一个 19,979 行样本集上(15.6 会专门量这个代价)

15.3 ★ 单次划分的消融表:只有一组掉台阶 ​

真实输出:

text
① 单次按设备划分:全特征与逐组拿掉

   配置                    特征数   训练 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(删掉反而略好)。

消融:21 列里真正值钱的是那 4 列历史 SOH

★ 把这张表读成三句话:

观察数字一句话
只有一组掉台阶删【轨迹】 −0.039974 列拿走了几乎全部功劳
三组"删了更好"窗口 +0.00000 / 差分 +0.00001 / 极差 +0.00000它们是净负担(15.6 讲原因)
4 列顶 21 列只用【轨迹】 0.99862 vs 全 21 列 0.99904差 0.00042,列数少了 17 列

★ 这就是这本书最重要的一张表,因为它把三个反转全放在一起:

text
反转一:加 10 列(窗口 + 差分 + 极差)→ 分数【没涨】(甚至略降)
反转二:加 4 列(轨迹)→ 分数【跳了】0.04(0.95906 → 0.99904)
反转三(第 21 章):再加 1 列(soh_margin)→ R² 恰好 1.000000 —— 那是事故

★ 一个必须说清的口径:删掉【基础】 只掉 −0.00030, 但第 11~13 章里"只用 7 列基础特征"的分数是 0.96503。为什么同一组特征差这么多?

text
"只用基础 7 列"      = 0.96503   (其它 14 列一列都没有)
"删掉基础,剩下 14 列" = 0.99874   (轨迹那 4 列还在!)
    ↓
两次的对照组完全不同
    ↓
这就是 15.7 要专门讲的那句话:Δ 必须连着对照组一起读。

15.4 只用轨迹的 4 列:0.99862 ​

再看一眼那个数字:

text
全 21 列      :测试 R² = 0.99904
只用【轨迹】4 列:测试 R² = 0.99862   (Δ = -0.00042)
只用【基础】7 列:测试 R² = 0.96503   (第 11~13 章的口径)

★ 三行对照,结论很硬:

text
把 21 列压到 4 列,只掉 0.00042
把 21 列压到 7 列(但删错了那 4 列),掉 0.03401
    ↓
"多少列"不是关键,"哪 4 列"才是关键
    ↓
这 4 列是什么?历史【实测】SOH —— 它们把问题从
"看这一刻的读数,猜 SOH"(横截面回归)
变成了
"看这支设备最近走过的路,推 SOH"(轨迹外推)

★ 为什么这 4 列这么值钱:它们改变了问题的类型

基础 7 列轨迹 4 列
每一行看到的信息这一刻的电压/内阻/温度这支设备过去的 SOH 实测值
模型要做的事从物理读数反推退化程度从历史轨迹外推下一时刻
类比的难度给你一张体检单,猜年龄给你过去 5 年的照片,猜今年的样子
实测上限0.965030.99862(单用这 4 列)

★ 但要写清前提(第 10 章的判据):

text
soh_lag1 = s.shift(1)  → 只用【过去】的实测值
    ↓ 前提一:上线时能拿到该设备的历史实测 SOH(真实 BMS 会存)
    ↓ 前提二:shift(1) 一位都不能少(第 16 章会给出自动校验)

★ 一句可以写进报告的话:

在这份数据上,21 列里真正值钱的是那 4 列历史 SOH; 其余十几列几乎不涨分,甚至因为丢了冷启动样本而略降。

15.5 5 折 CV:Δ 要和方差一起看 ​

单次划分只有一个"运气"。 把同一批配置放到 5 折 GroupKFold 上:

真实输出:

text
③ 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

★ 三条读法:

text
① 删【轨迹】的 Δ = -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,"这两组可以砍"是被数据支持的

③ 只报均值不报方差 → 你无法区分①和②。

★ 方差大不是缺点:

text
删【轨迹】那一组的折间标准差是 ±0.01312(比全特征大 400 倍)
    ↓
它在说:"这一组在不同设备子集上的表现差很多"
    (有一折只有 0.9302 —— 那一折验证的设备特别"难")
    ↓
这是信息,不是噪声:它提醒你"轨迹特征的效果依赖设备类型"

★ 一个常见误用:

text
❌ "删【轨迹】后 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
text
两个口径给出同一个【定性结论】(轨迹值钱、另两组可砍),
数值上差了 0.006(单次划分的运气)
    ↓
所以:报告里两个都给,
      用单次划分说"方向和量级",用 CV 说"这个结论稳不稳"

15.6 为什么"删掉反而略好":冗余 + 1,260 行样本的代价 ​

先把现象摆清楚:

text
删【窗口统计】 Δ = +0.00000
删【差分斜率】 Δ = +0.00001
删【极差】     Δ = +0.00000

★ 三个原因,逐个说:

原因一:信息冗余(第 14 章的相关系数已经预警) ​

真实输出:

text
④ 为什么删掉反而略好:冗余 + 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
text
"过去 20 个窗口的内阻均值" 与 "当前这一行的内阻" 相关 0.9951
    ↓
为什么?设备老化是慢过程:相邻 20 个窗口里这一列几乎没变
    ↓
所以"窗口统计"给的是同一个数的另一种写法 → 加进去不涨分

原因二:这十来列的 |r| 本来就很低 ​

text
  volt_slope20         -0.1288
  res_slope20          -0.3168
  temp_slope20         -0.1000
  res_diff1            -0.0171
text
斜率/差分列的 |r| 都在 0.32 以下(res_diff1 只有 0.017)
    ↓
它们描述的是"变化的方向和速度" —— 在 20 个窗口的尺度上,
这个变化量相对测量噪声太小
    ↓
所以它们既没提供新信息,也没提供有效信息

原因三:它们带来了 NaN,dropna 又丢掉 1,260 行样本 ​

真实输出:

text
② 把【基础】这一组单拎出来看:它自己值多少

  只用【基础 7 列】:可用样本 21,239 行(比 21 列多 1,260 行),测试 R² = 0.96503
  全 21 列        :可用样本 19,979 行,测试 R² = 0.99904
  → 加了 14 列时序特征、代价是丢掉 1,260 行冷启动样本,净收益 +0.03401

★ 这三条原因合成一句结论:

text
① 信息冗余  → 加了不涨分
② |r| 极低  → 本来就没用
③ 带 NaN    → 每支设备最早的 20 个窗口被迫丢掉(全表 1,260 行)
    ↓
净效果 = 分数不动(甚至 +0.00001)+ 训练更慢 + 维护更贵
    ↓
所以"删掉"是【正确的工程决定】,哪怕 Δ 是 +0.00000

★ 一条必须写进结论的方向纪律:

这两组的 Δ 是两个正负号都可能出现的极小量。不要把 +0.00001 解读成"这列有害",它的正确解读是"这列可以砍"。

text
"有害" 需要证据:删掉之后分数【显著】上升(比如 +0.01 以上,且大于折间波动)
"没用" 只需要证据:删掉之后分数【在噪声内不动】
    ↓
本数据属于后者

15.7 同一组特征在不同上下文里的 Δ 不是同一个数 ​

真实输出(把【基础】这一组单拎出来):

text
  但删掉【基础】之后只剩 14 列,测试 R² = 0.99874(Δ -0.00030):
  基础 7 列单独用是 0.96503,作为 21 列里的一组只贡献 -0.00030 —— 
  同一组特征在【不同上下文】里的消融 Δ 完全不是一个数。

★ 为什么差这么多:

text
"只用基础 7 列"      → 模型手里只有横截面读数,没有历史 → 0.96503
"删掉基础,剩 14 列"  → 那 14 列里有【轨迹 4 列】→ 0.99874
    ↓
轨迹列已经把"这支设备走到哪一段了"交代清楚了,
基础列提供的是"同一件事的更粗的读数"
    ↓
所以基础列在【有轨迹】的上下文里只值 −0.00030,
在【没轨迹】的上下文里值 0.03401(相对 7 列基线 0.96503 到 21 列的 0.99904 的差)

★ 这条纪律适用于所有消融报告:

引用一个 Δ 的时候,必须同时写清两件事: ① 对照组是什么(其它组都在不在);② 用什么口径量的(单次划分 / 几折 CV)。

★ 一个具体例子:同一个泄漏钩子,两个对照组,两个数

真实输出(5 折 CV 口径,对照组 A = 21 列安全特征):

text
⑤ 消融也是找泄漏的探针:Δ 高得离谱就要报警(但 Δ 要连着对照组读)

  对照组 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:

text
对照组 A 里已经有轨迹 4 列 —— 它们已经告诉模型"这支设备走到寿命的哪一段"
    ↓
cycle_frac 想说的"同一件事",模型早就知道了 → Δ ≈ 0
    ↓
对照组 B 里只有横截面读数 → cycle_frac 提供的是新信息 → Δ = +0.00555

★ 所以"这个特征值 0.005"这句话是不完整的:

text
❌ "cycle_frac 值 0.0055"
✅ "在【只有 7 列基础特征】的对照下,cycle_frac 的测试 R² Δ = +0.0055;
    在【21 列安全特征(含轨迹)】的对照下,Δ = +0.0000"

★ 而 soh_margin 在哪个对照组里都指向 1.00000:

text
因为它【就是标签本身】(soh_margin ≡ soh − 0.80)
    ↓
Δ 大得离谱(+0.03497,是全表最大的一笔)
    ↓
★ 先别高兴 —— 先问第 10 章那句话:
  "预测这一刻,我手上真的拿得到这个数吗?"
    ↓
拿不到 → 这不是特征,这是泄漏(第 20~21 章会把它做成 R² = 1.000000 的事故现场)

15.8 消融的三个真实用途 ​

① 决定"砍哪一组"(省时间、省维护)

text
本数据的现场决定:
    【窗口统计】/【差分斜率】/【极差】→ 砍(Δ 在噪声内)
    【轨迹】→ 必须留(Δ = −0.04601,是折间标准差的 3.5 倍)
    【基础】→ 留着(它撑住 0.96 档的下限;模型换、设备换时它更稳)

② 当泄漏探针(Δ 异常大 = 报警)

text
Δ 的量级经验值:
    +0.00x  → 正常(这一组有用)
    +0.000x → 噪声内(可以砍)
    +0.03 以上、或者直接把 R² 顶到 0.99999/1.00000 → 先查它是不是用了未来信息

③ 给"特征工程值不值"定预算

text
本数据的教训:
    花在"窗口统计/差分斜率"上的 9 列工程 → 收益 0.00000
    花在"能不能拿到历史实测 SOH"上的 4 列工程 → 收益 0.04000
    ↓
★ 收益不在"算得更花哨",而在"把问题的信息结构找对"

★ 一个可以复用的消融报告骨架:

python
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 常见坑 ​

text
① 消融时忘了固定划分/种子        → 差异被"划分运气"淹没(同一配置两次能差 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²):

text
删掉【轨迹】:训练 0.99859 / 测试 0.95906   ← 训练也掉了,说明真的少了信息
删掉【窗口统计】:训练 0.99987 / 测试 0.99904 ← 训练几乎不动
    ↓
"训练 R² 不动 + 测试 R² 不动" = 这组特征对模型【没有新增可用信息】

15.10 完整示例与输出 ​

bash
cd tools/feature-engineering-pipeline/code
MPLCONFIGDIR=/tmp/fe-mpl /path/to/venv/bin/python demos/ch15-ablation.py

真实输出(节选):

text
整表 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)

★ 一页记住这一章:

text
单次划分:全特征 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):

  1. 用随机森林跑"全特征"作为基线,报训练/测试 R²;
  2. 逐组拿掉(基础 / 窗口统计 / 差分斜率 / 极差 / 轨迹),各报特征数与 Δ 测试 R²;
  3. 再跑一次"只用【轨迹】",报它的 Δ;
  4. 用一句话回答:哪一组值得留、哪几组可以砍?
参考答案
python
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 棵):

text
全特征                    特征 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

④ 回答:留【轨迹】,砍【窗口统计】/【差分斜率】/【极差】。

text
删掉【轨迹】掉了一个完整的台阶(-0.03973)—— 这是唯一一个
"均值差远大于噪声"的组(见练习 2)
删掉【窗口统计】/【差分斜率】/【极差】:Δ 在 +0.00001 ~ -0.00000 之间,
完全在折间波动以内
    ↓
【基础】只掉 0.00030,但它是 0.96 档的下限:模型换、设备换时它最稳 → 留着

★ 口径提醒:这里用 100 棵树;正文用 200 棵,所以正文字典里 全特征是 0.99904、删【轨迹】是 0.95906(Δ −0.03997)。 两边结论完全一致,数值差异在第 4~5 位小数。

练习 2:用 3 折 CV 把 Δ 报成「均值 ± 标准差」
  1. 用 GroupKFold(n_splits=3) 跑「全特征 + 逐组拿掉 + 只用轨迹」,各报逐折 R²、均值 ± 标准差、Δ;
  2. 回答:哪一组的 Δ 大于它自己的折间标准差?哪一组的 Δ 落在噪声里?
  3. 回答:如果只报均值不报标准差,你会做出什么错误判断?
参考答案
python
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")

期望输出:

text
全特征(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

② 回答:

text
删【轨迹】:Δ = -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 → 站得住(但只是万分之四)

③ 回答:只报均值会让你把"噪声"当成"结论"。

text
删【窗口统计】Δ = +0.00000:只报均值 → "删了没影响"(这次恰好对)
删【差分斜率】Δ = +0.00001:只报均值 → "删了反而更好?"
    ↓
而它们的 std 是 0.00002 —— 两个 Δ 都小于一个标准差
    ↓
只报均值时你无法判断:
    这个 +0.00001 是"真的更好"还是"划分运气"?
    ↓
★ 报告纪律:Δ 后面永远跟一个 ±(来自 CV 或多次划分)

★ 顺带一个对比:3 折的 Δ(−0.04698)比 5 折(−0.04601)略大 —— 折数越少,每折的训练集越小、验证集越大,数值会有差异,但方向和量级一致。

练习 3:Δ 要连着对照组读(同一个钩子,两个数)
  1. 在对照组 A(21 列安全特征,含轨迹)上加 cycle_frac 与 soh_margin,报 Δ;
  2. 在对照组 B(7 列基础特征,不含轨迹)上加同样两列,报 Δ;
  3. 解释为什么同一个 cycle_frac 会有两个不同的 Δ;
  4. 回答:写报告时该怎么写这一列的价值?
参考答案
python
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}")

期望输出:

text
对照组 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

③ 回答:因为"Δ 衡量的是新增信息,而新增信息取决于你已经知道多少"。

text
对照组 A 里已有【轨迹 4 列】
    → 它们已经告诉模型"这支设备走到了寿命的哪一段"
    → cycle_frac 想说的正是同一件事 → 新增信息 ≈ 0 → Δ ≈ 0

对照组 B 里只有横截面读数
    → "走到哪一段"是新信息 → Δ = +0.005 量级

而 soh_margin 在两个对照组里都把 R² 顶到 1.00000
    → 因为它不是"新信息",它就是【标签本身】(soh_margin ≡ soh − 0.80)

④ 报告要这样写:

text
❌ "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 章会把它做成事故现场)"

面向工程实践:把消融变成一次"预算评审" ​

① 消融报告的最小格式

text
每组一行,至少四列:
    组名 | 列数 | Δ 测试 R²(单次划分) | Δ 均值 ± 标准差(CV)
再加一条固定的口径说明:
    划分方式 / 模型与超参 / 可用样本数 / 对照组(哪些组同时在)

★ 缺了最后一项,这份报告就没法被别人复核(15.7 的教训)。

② 三种结论,三种动作

Δ 的表现结论动作
负,且 |Δ| > 3 × 折间标准差这一组有用保留,并写进文档"为什么需要它"
在噪声内(|Δ| < 1 × 折间标准差)这一组可以砍砍掉,记录"砍掉后分数不变"
大得离谱(Δ > 0.03,或 R² → 1.00000)先别高兴按第 10 章判据查它是不是用了未来信息

③ 一个真实的预算账(本数据)

text
工程投入                          收益(测试 R²)
基础 7 列(第一版就有)                0.96503
+ 窗口统计 / 差分 / 极差(10 列)      +0.00000   ← 白做(而且丢了 1,260 行样本)
+ 轨迹 4 列(要接历史实测 SOH 管道)    +0.04001   ← 全部收益在这里

★ 一句话总结这件事:

特征工程的价值不在"多算几个统计量", 而在"把问题的信息结构找对"。消融是唯一能把这笔账算清楚的工具。

④ 消融和下一章的关系

text
第 14 章(相关性 + k 曲线):看"谁和标签有关、谁和谁冗余"(地图)
第 15 章(消融):          看"拿掉它掉多少"(证据)
第 16 章(特征账本):       把前两章的结论 + 可得性,写成一张每列一行的表(交付物)

★ 最后一句,也是通往下一章的桥:

消融告诉你"这一组值多少",但它没告诉你"这一列上线拿不拿得到"。两件事必须写在同一张表里 —— 否则"值 0.0055"的那一列, 可能恰好是明天让线上模型崩掉的那一列。


下一章:第 16 章 · 特征账本:给每一列记账