Skip to content

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

这一章要解决什么:第 11~15 章各管一件事 —— 缩放、编码、合成预处理器、相关性、消融。这一章把它们收进一张表: 每一列一行,五本账(来源 / 预测时刻可得性 / 单列相关性 / 消融贡献 / 风险备注)。

这是本书的第二个交付物(第一个是第 10 章那张判据清单), 它会在第 22 章的防泄漏检查清单里被直接复用。

最容易踩的坑: ① 把组级的消融 Δ 抄成单列的 Δ —— 两者可以差两个数量级(16.3); ② 把 ⚠️ 当 ✅ 直接上线 —— "有历史实测 SOH"是要去确认的,不是假设; ③ 账本写完就不维护 —— 列改名后账本过期,比没有账本更危险。

本章你需要完成的代码:demos/ch16-feature-ledger.py

16.1 为什么需要账本:三个问题必须一起回答 ​

一个特征"能不能用",取决于三件互不替代的事:

问题谁回答用错工具会怎样
它值多少?(精度)消融 Δ(第 15 章)用相关系数代替 → 漏掉非线性的列
它拿得到吗?(可行性)人工判断 + 上线环境核对只算精度 → 上线当天模型崩
它有风险吗?(合规/稳定性)风险备注 + 分布监控不看风险 → 数据漂移三个月后才发现

★ 把三者写在同一张表里,就是"特征账本":

text
列名        来源                   可得性   单列 r    消融 Δ      风险备注
soh_lag1    上一窗口的实测 SOH      ⚠️      +0.9991   +0.00000   上线必须能拿到历史实测容量
cycle_frac  cycle / 最终总寿命       ❌      -0.9783   +0.00555   分母要跑到退役才知道
...

★ 一句话说清它为什么是"交付物"而不只是"文档":

text
文档:写完就躺在那里,没人核对
账本:每一行的每一个数字都能被【重跑出来】,
      除了"可得性"那一栏 —— 它是人的签字,也必须能被审计
    ↓
所以它是一份"可执行的文档":代码生成 + 人工签一栏

16.2 账本的五栏:口径先定死 ​

栏位谁填口径(必须写清)
列名代码与 soh_features_v2.csv 的列名逐字符一致
组代码基础 / 窗口统计 / 差分斜率 / 极差 / 轨迹 / 泄漏钩子
来源人工一句话说清"怎么算出来的"(含窗口长度、是否 shift)
可得性人工✅ 预测这一刻一定拿得到 / ⚠️ 有条件 / ❌ 拿不到
单列 r代码与标签的皮尔逊相关,注明用哪份样本算的
消融 Δ代码组级 leave-one-group-out(轨迹组做逐列);注明对照组
风险备注人工上线/维护/漂移相关的具体风险

★ 一个具体的口径差异(读者一定会撞上):

text
本账本的 r 用【全表 21,239 行】算(含冷启动那 1,260 行)
    ↓
所以它和第 14 章的数字不完全一样(第 14 章用 dropna 后的 19,979 行):

    列名              第 14 章(19,979 行)   本账本(21,239 行)
    cycle                  -0.8999              -0.9137
    coulombic_eff          +0.9646              +0.9683
    soh_win_mean5          +0.9994              +0.9995
    ↓
★ 两个都不错,但它们回答的不是同一个问题:
  "在能建模的样本上,这一列和标签相关多少" vs
  "在这张表里,这一列和标签相关多少"。
  账本里必须写清用的是哪一份 —— 这就是下面常见坑里的第 ⑧ 条。

★ 三个"必须注明",否则账本不可复核:

text
① 单列 r 用哪份样本算的(本账本用全表 21,239 行 —— 它只是"看一眼"的地图,
   不是选特征的依据;选特征必须进 Pipeline,见第 14 章)
② 消融 Δ 的对照组是什么(其它组都在不在)
③ Δ 是组级还是单列级

★ 本账本的口径(写在表头,随表一起交付):

text
数据    :soh_features_v2.csv,21,239 行,60 支设备
可用样本:时序特征齐全的 19,979 行(训练 16,236 / 测试 3,743)
划分    :按设备 GroupShuffleSplit(test_size=0.2, random_state=42)
模型    :随机森林 200 棵(random_state=42)
消融 Δ  :组级 leave-one-group-out;轨迹组为逐列 leave-one-out;
          两个泄漏钩子以【7 列基础特征】为对照组
可得性  :✅ 一定拿得到 / ⚠️ 有条件 / ❌ 拿不到(人工签,必须能被审计)

16.3 ★ 账本全文(23 行 × 7 列) ​

真实输出(节选前三段):

text
③ ★ 特征账本(23 行 × 7 列)

  列名                       组      可得        单列 r       消融 Δ  风险备注
  ---------------------------------------------------------------
  cycle                    基础     ✅      -0.9137   -0.00030  计数器口径要与训练一致;换设备要清零重计
  mean_charge_voltage_v    基础     ✅      -0.8003   -0.00030  传感器漂移会整体平移;建议定期比对
  mean_temp_rise_c         基础     ✅      -0.7491   -0.00030  受环境温度影响;跨季节要监控漂移
  resistance_mohm          基础     ✅      -0.8784   -0.00030  离群点敏感(第 11 章:MinMax 被它拉满)
  coulombic_eff            基础     ✅      +0.9683   -0.00030  分母很小时比值不稳;窗口太短要当心
  cap_fade_rate_ah_per_100 基础     ✅      -0.4257   -0.00030  依赖窗口长度;换窗口口径要重算
  temp_std_c               基础     ✅      -0.7868   -0.00030  对采样频率敏感;降采样会系统性变小
  volt_win_mean            窗口统计   ✅      -0.7878   +0.00000  与 mean_charge_voltage_v 的 r=0.995,冗余
  ...

★ 完整账本(可直接抄进项目文档):

列名组可得性单列 r消融 Δ风险备注
cycle基础✅−0.9137−0.00030计数器口径要与训练一致;换设备要清零重计
mean_charge_voltage_v基础✅−0.8003−0.00030传感器漂移会整体平移
mean_temp_rise_c基础✅−0.7491−0.00030受环境温度影响;跨季节要监控漂移
resistance_mohm基础✅−0.8784−0.00030离群点敏感(第 11 章:MinMax 被它拉满)
coulombic_eff基础✅+0.9683−0.00030分母很小时比值不稳
cap_fade_rate_ah_per_100基础✅−0.4257−0.00030依赖窗口长度;换口径要重算
temp_std_c基础✅−0.7868−0.00030对采样频率敏感;降采样会系统性变小
volt_win_mean窗口统计✅−0.7878+0.00000与 mean_charge_voltage_v 的 r=0.995,冗余
volt_win_std窗口统计✅−0.1145+0.00000相关系数只有 0.11;消融证明它不涨分
res_win_mean窗口统计✅−0.8717+0.00000与 resistance_mohm 的 r=0.995,冗余
res_win_std窗口统计✅−0.3626+0.00000与 res_slope20 类似,属「过程」信息
temp_win_mean窗口统计✅−0.7395+0.00000与 mean_temp_rise_c 的 r=0.992,冗余
volt_slope20差分斜率✅−0.1288+0.00000斜率对噪声放大,先平滑再算
res_slope20差分斜率✅−0.3168+0.00000仍未进前 5,消融 Δ≈0
temp_slope20差分斜率✅−0.1000+0.00000本数据上无用
res_diff1差分斜率✅−0.0197+0.00000逐窗口差分噪声大
temp_win_range20极差✅−0.0599−0.00000极差对单个离群点极敏感
soh_lag1轨迹⚠️+0.9991+0.00000上线必须能拿到历史实测容量;少写 shift 就是泄漏
soh_lag5轨迹⚠️+0.9990+0.00000同上;冷启动前 5 个窗口缺失
soh_win_mean5轨迹⚠️+0.9995−0.00036与 soh_lag1 的 r=0.9996,几乎同一列
soh_win_std5轨迹⚠️−0.0682+0.00000相关系数 0.065,但轨迹组整体值钱
cycle_frac泄漏钩子❌−0.9783+0.00555分母是最终总寿命 —— 特征泄漏(第 20 章)
soh_margin泄漏钩子❌+1.0000+0.03497它就是标签的线性函数 —— 特征泄漏(第 21 章)

特征账本:每一列都要有来源、可得性、相关性与消融贡献

★ 这张表里有三处需要停下来想一想的地方。

反直觉一:轨迹组的 Δ 是"整组值钱、单列可删" ​

真实输出:

text
  轨迹组值钱,所以它需要【逐列】消融(其余组只做组级):
    (组级)删【轨迹】整组      测试 R² = 0.95906   Δ = -0.03998
    删 soh_lag1         测试 R² = 0.99904   Δ = +0.00000
    删 soh_lag5         测试 R² = 0.99904   Δ = +0.00000
    删 soh_win_mean5    测试 R² = 0.99904   Δ = -0.00036
    删 soh_win_std5     测试 R² = 0.99904   Δ = +0.00000

★ 一个末位小数的说明:同一个"删【轨迹】整组"配置,第 15 章单次划分的 Δ 是 −0.03997,这里打印的是 −0.03998。两次运行用的是同一份数据、同一个 random_state —— 差异来自 n_jobs=-1 时随机森林多棵树预测值求和顺序不同 (浮点加法不满足结合律),只影响第 5 位小数。 写报告时不必纠结末位;要纠结的是"这个数有没有超过折间标准差"(第 15 章)。

★ 两句话同时成立:

text
"删掉 soh_lag1(单列)→ 分数不动"           ← 真的
"删掉整组轨迹(4 列)→ 分数掉 0.0400"       ← 也是真的
    ↓
为什么?因为这 4 列互相顶替(r ≈ 0.999 量级)
    ↓
★ 结论:消融的【粒度】决定结论。
  "这一列可以删"和"这一组不能删"可以同时为真。

★ 这条结论对工程的直接含义:

text
① 想省列数:可以从轨迹组里只留一列(比如 soh_win_mean5,它的单列 Δ 最大)
② 想省数据管道:不能整组砍 —— 那要掉 0.04
③ 想理解"为什么值钱":要看【这一组提供的是一种信息结构】,不是看单列

反直觉二:相关系数和 Δ 经常不一致 ​

text
volt_win_std   :|r| = 0.1145,Δ = +0.00000     ← 相关低、也没用(一致)
soh_win_std5   :|r| = 0.0682,Δ = +0.00000     ← 相关低,但它属"整组值钱"的那 4 列
cycle_frac     :|r| = 0.9783,Δ = +0.00555     ← 相关高,但那是因为它泄漏
soh_margin     :|r| = 1.0000,Δ = +0.03497     ← 相关满分,因为它就是标签

★ 三列"相关高"的,恰好全都是危险信号:

text
|r| 高 + Δ 小  → 信息被别的列覆盖了(cycle_frac 在 21 列口径下就是这种)
|r| 高 + Δ 大  → 要么真有用,要么泄漏(必须回到"拿得到吗"这一栏)
|r| 低 + Δ 大  → 非线性/交互信息(本数据没有这种列,但真实项目里常见)

反直觉三:两个 ❌ 列的"值多少"完全不能用来决定去留 ​

text
cycle_frac  Δ = +0.00555   → 在 7 列基线口径下"很有用"
soh_margin  Δ = +0.03497   → 全表最大的一笔"收益"
    ↓
如果只看 Δ 这一栏,你会把它们留下来
    ↓
但"可得性"那一栏写着 ❌ —— 预测这一刻拿不到
    ↓
★ 这就是账本的价值:它让"精度诱惑"和"可行性判断"出现在同一行上,
  谁也盖不住谁。

16.4 三栏风险统计:上线清单是怎么来的 ​

真实输出:

text
④ 风险统计

  ✅ 预测这一刻一定拿得到                       17 列   cycle, mean_charge_voltage_v, ...
  ⚠️ 有条件:要先确认上线环境有历史实测值                4 列   soh_lag1, soh_lag5, soh_win_mean5, soh_win_std5
  ❌ 拿不到:一出场就是泄漏                       2 列   cycle_frac, soh_margin

  上线清单 = ✅ 的 17 列 + ⚠️ 里【确认拿得到】的列;❌ 的 2 列必须删掉。
  ★ '可得性'这一列是账本里唯一【算不出来】的:它靠人回答一个问题 ——
    「预测这一刻、这一行,我真的拿得到这个数吗?」

★ 三档的含义必须写死,不能含糊:

标记判定标准动作
✅预测这一刻,从当前窗口或更早的数据就能算出来直接用
⚠️需要一个额外的数据管道(历史实测值、外部系统、另一个模型)去做一次"上线环境核对",确认后再用
❌需要预测之后才知道的信息立刻删,并且写清为什么(防止有人再加回来)

★ ⚠️ 那 4 列的正确处理方式:

text
⚠️ 不是"有问题",而是"条件性可用"
    ↓
条件:生产环境的 BMS 是否保存该设备的历史容量实测值?
    ├─ 是 → 把 ⚠️ 降级为 ✅,写进账本("已确认:BMS 保存最近 N 个窗口的实测容量")
    └─ 否 → 整组删掉,并记录代价:
            测试 R² 从 0.99904 掉到 0.95906(Δ −0.03997)
    ↓
★ 这个"是/否"必须由人回答并签字 —— 它是账本里唯一的非计算栏。

16.5 把"来源"栏变成可执行的断言 ​

账本最容易被质疑的一栏是"来源" —— 因为它是一句话,不是数字。 所以要用断言把它变成可执行的证据。

真实输出:

text
⑤ 三条自动检查(把「来源」这句话变成可执行的证据)

  检查 1:soh_lag1 是不是【真的只取历史】
    soh_lag1 与 '上一行 soh' 的最大差 = 0.00e+00
    soh_lag1 与 '本行 soh' 的最大差   = 9.80e-03   ← 必须远大于 0
    → 恒等于上一行的实测值;少写一位 shift 就会变成【本行标签】。

  检查 2:soh_win_mean5 是不是【过去 5 个窗口】的均值
    与 shift(1).rolling(5).mean() 的最大差 = 4.00e-05

  检查 3:cycle_frac 到底用了什么
    与 cycle / n_cycles_to_eol 的最大差 = 5.00e-05
  而 n_cycles_to_eol(该设备的最终总寿命)范围 = 803 ~ 1490
    → 这一列在预测那一刻【根本不存在】——它要求你提前知道设备哪天退役。

★ 为什么"两个检查要一起看":

text
检查 1 的第一行说"等于上一行"(0.00e+00)
检查 1 的第二行说"不等于本行"(9.80e-03)
    ↓
只报第一行,无法区分"等于上一行"和"等于本行"(如果两行 soh 恰好相同)
只报第二行,无法证明它用的是什么
    ↓
★ 两条一起看,才证明"这一列只用历史",而且用的是【确切的上一行】

★ 那两个 e-05 的差从哪来:

text
CSV 落盘时做了 round(4)(第 02 章讲过的数据纪律)
    ↓
所以用 CSV 重算的 shift(1).rolling(5).mean() 与落盘值差 4e-05
    ↓
这不是 bug,是舍入;但它提醒你:
账本里的断言要在【和训练数据同一份 CSV】上跑,别混用原始曲线

★ 一段可以复制到项目里的校验函数:

python
def assert_ledger_sources(df, cells):
    """把账本'来源'栏里的声明变成断言。任何一条失败就拒绝发布。"""
    import pandas as pd

    g = df.groupby("cell_id", sort=False)
    checks = {
        "soh_lag1 == 上一行 soh": (df["soh_lag1"] - g["soh"].shift(1)).abs().max(),
        "soh_lag1 != 本行 soh": (df["soh_lag1"] - df["soh"]).abs().max(),
        "soh_win_mean5 == shift(1).rolling(5).mean()":
            (df["soh_win_mean5"]
             - g["soh"].transform(lambda s: s.shift(1).rolling(5).mean())).abs().max(),
        "soh_lag5 == shift(5) soh": (df["soh_lag5"] - g["soh"].shift(5)).abs().max(),
    }
    m = df[["cell_id", "cycle", "cycle_frac"]].merge(
        cells[["cell_id", "n_cycles_to_eol"]], on="cell_id", how="left")
    checks["cycle_frac == cycle / n_cycles_to_eol"] = (
        m["cycle_frac"] - m["cycle"] / m["n_cycles_to_eol"]).abs().max()

    for label, diff in checks.items():
        expect_zero = "!=" not in label
        ok = (diff < 1e-3) if expect_zero else (diff > 1e-3)
        print(f"{'✅' if ok else '❌'} 最大差 {diff:.2e}   {label}")
        if not ok:
            raise AssertionError(f"账本来源声明与数据不符:{label}")

★ 为什么这个函数值得放进 CI:

text
有人改了特征生成脚本(比如把 shift(1) 写成 shift(2))
    ↓
分数几乎不变(soh_lag2 和 soh_lag1 的 r = 0.9999)
    ↓
没有人会注意到
    ↓
但这个断言会当场炸 → 账本与数据不一致,拒绝发布

16.6 账本怎么维护:一次变更的三步 ​

★ 一份会过期的账本比没有账本更危险(因为它会让人"以为核对过了")。 所以要把"改特征"和"改账本"绑在一起:

text
第 1 步:改代码(加/删/改一列)
第 2 步:重跑生成账本的脚本 → 新账本自动产生(r 与 Δ 都是重算的)
第 3 步:人工复核【来源】与【可得性】两栏,签字 + 日期

★ 一个可操作的门禁规则:

变更类型必须更新账本的哪几栏谁签字
新增一列全部五栏特征负责人
改公式(比如窗口 20 → 30)来源、单列 r、消融 Δ、风险备注特征负责人
改列名列名 + 所有引用它的文档特征负责人
上线环境变化(BMS 升级)可得性(这是最容易被忽略、后果最严重的一栏)上线负责人

★ 一个真实的过期案例(本数据上可以复现):

text
账本写的是"soh_lag1 = 上一窗口的实测 SOH"
    ↓
某次重构把特征生成里的 s.shift(1) 写成了 s.rolling(5).mean()
    ↓
分数从 0.99904 掉到 0.99862(第 15 章"只用轨迹"那一行)
    ↓
★ 掉得不多 —— 所以没人会发现;但"这一列现在是什么"已经变了
    ↓
16.5 的断言会抓住它

16.7 常见坑 ​

text
① 把组级 Δ 抄成单列 Δ            → 轨迹组:整组 −0.03997 vs 单列 +0.00000
② 引用 Δ 不写对照组              → 同一个 cycle_frac 可以是 +0.00000 或 +0.00555(第 15 章)
③ 把 ⚠️ 当 ✅ 直接上线            → "有历史实测 SOH"是要去确认的假设,不是事实
④ 把 ❌ 列的 Δ 当作去留依据        → soh_margin 的 Δ = +0.03497 是全表最大,
                                    但它是标签本身(第 21 章的事故现场)
⑤ 用全表相关性挑特征              → 账本里的 r 只是地图,选特征必须进 Pipeline(第 14 章)
⑥ 账本写完不维护                  → 列改名/改公式后账本过期,比没有更危险(16.6)
⑦ 只写"来源"不写断言              → 一句话无法被复核;用 16.5 的断言函数钉住
⑧ 忘了记录"用哪份样本算的 r"       → 全表 21,239 行?训练集 16,236 行?结论会不同
⑨ 把账本当一次性交付              → 它应该跟着每次特征变更自动重跑

★ 第 ③ 条展开一下(这是最贵的坑):

text
"历史实测 SOH 拿得到吗?"
    ↓
在离线实验里:拿得到(CSV 里就有)
在真实产线上:取决于 BMS 是否存了、存多久、以什么频率存、能不能实时取
    ↓
一次真实的失败模式:
    训练用"最近 5 个窗口的实测容量均值" → 0.9986
    上线时 BMS 只有"容量报警"这一个事件 → 这一列根本构造不出来
    ↓
★ 所以"可得性"这一栏的审计对象不是数据,是【上线环境】

16.8 完整示例与输出 ​

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

真实输出(节选):

text
表:soh_features_v2.csv  21,239 行;账本对象:21 列安全特征 + 2 列泄漏钩子 = 23 行
划分:按设备 GroupShuffleSplit(test_size=0.2, random_state=42),可用样本 19,979 行(训练 16,236 / 测试 3,743)

① 先算【消融贡献】这一列(口径与第 15 章完全一致)
  全 21 列基线                 测试 R² = 0.99904
  删【基础】(14 列)        测试 R² = 0.99874   组级 Δ = -0.00030
  删【窗口统计】(16 列)        测试 R² = 0.99904   组级 Δ = +0.00000
  删【差分斜率】(17 列)        测试 R² = 0.99904   组级 Δ = +0.00000
  删【极差】(20 列)        测试 R² = 0.99904   组级 Δ = -0.00000
    (组级)删【轨迹】整组      测试 R² = 0.95906   Δ = -0.03998
    删 soh_win_mean5    测试 R² = 0.99904   Δ = -0.00036
  两个泄漏钩子(对照组 = 7 列基础):
    7 列基础(对照)            测试 R² = 0.96503
    7 列基础 + cycle_frac    测试 R² = 0.97058   Δ = +0.00555
    7 列基础 + soh_margin    测试 R² = 1.00000   Δ = +0.03497

④ 风险统计
  ✅ 17 列 / ⚠️ 4 列 / ❌ 2 列

⑤ 三条自动检查
    soh_lag1 与 '上一行 soh' 的最大差 = 0.00e+00
    soh_lag1 与 '本行 soh' 的最大差   = 9.80e-03
    soh_win_mean5 与 shift(1).rolling(5).mean() 的最大差 = 4.00e-05
    cycle_frac 与 cycle / n_cycles_to_eol 的最大差 = 5.00e-05

账本已写入:.../code/outputs/ch16-feature-ledger.md

★ 一页记住这一章:

text
账本 = 每列一行 × 五本账:
       来源(人工)/ 可得性(人工)/ 单列 r(代码)/ 消融 Δ(代码)/ 风险备注(人工)

三个必须:
  ① 必须写清 Δ 的对照组与粒度(组级 or 单列)
  ② 必须用断言把"来源"钉住(16.5)
  ③ 必须随每次特征变更重跑(16.6)

一句话:账本让"精度诱惑"和"可行性判断"出现在同一行上。

本章练习 ​

练习 1:给账本补两列(缺失值比例 / 分布偏移)
  1. 对 23 列各算 NaN 比例;
  2. 各算"训练集均值与整表均值之差 ÷ 整表标准差"(一个简单的分布偏移指标);
  3. 回答:为什么"缺失值比例"必须进账本?本数据里新特征的 NaN 集中在哪?
参考答案
python
import os

import pandas as pd
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"]
HOOKS = ["cycle_frac", "soh_margin"]
ALL = BASE + WIN + DIFF + RANGE + TRAJ

df = pd.read_csv(os.path.join(DATA, "soh_features_v2.csv"))
X, y, g = df[ALL], df["soh"], df["cell_id"]
tr, te = next(GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
              .split(X, y, g))

print(f"{'列名':26s} {'NaN 比例':>9s} {'训练均值':>12s} {'整表均值':>12s} {'偏移(std)':>10s}")
for c in ALL + HOOKS:
    sd = df[c].std()
    print(f"{c:26s} {df[c].isna().mean():9.2%} {df[c].iloc[tr].mean():12.5f} "
          f"{df[c].mean():12.5f} {abs(df[c].iloc[tr].mean() - df[c].mean()) / sd:10.4f}")

期望输出(节选):

text
列名                       可得性     NaN 比例         训练均值         整表均值       训练-整表偏移(std)
cycle                    ✅        0.00%    614.11727    602.81991             0.0330
...
volt_win_mean            ✅        5.65%      3.81609      3.81612             0.0003
...
volt_slope20             ✅        5.93%      0.00064      0.00065             0.0171
...
res_diff1                ✅        0.28%      0.09134      0.09385             0.0018
...
soh_lag1                 ⚠️       0.28%      0.90958      0.90904             0.0100
soh_lag5                 ⚠️       1.41%      0.91066      0.91015             0.0094
soh_win_mean5            ⚠️       1.41%      0.90964      0.90911             0.0097
soh_win_std5             ⚠️       1.41%      0.00170      0.00171             0.0064
cycle_frac               ❌        0.00%      0.50217      0.50004             0.0080
soh_margin               ❌        0.00%      0.10931      0.10876             0.0101

③ 回答:缺失值比例决定"这一列会把多少行样本带走"。

text
本数据的 NaN 全部来自【每支设备最早的窗口】:
    soh_lag1      缺 1 行/设备   → 0.28%
    soh_lag5      缺 5 行/设备   → 1.41%
    窗口统计       缺 20 行/设备  → 5.65%(shift(1) + rolling(20) 需要 20 个观测)
    差分斜率       缺 21 行/设备  → 5.93%(shift(1) − shift(21))
    res_diff1     缺 1 行/设备   → 0.28%
    ↓
21 列一起 dropna → 丢掉 1,260 行(5.93%)
    ↓
如果账本不写这一栏,你会以为"加了 14 列特征没代价" ——
实际上它带走了 1,260 行样本(第 15 章量过这个代价)

★ 一个可迁移的读法:

text
NaN 比例 0%        → 无需处理
NaN 比例小(<1%)  → dropna 可接受,但要在报告里写明丢了哪些行
NaN 比例大(>10%) → 必须先想"怎么填",或者接受"冷启动期模型不可用"
    ↓
★ 本数据的 5.93% 属于第二档,而且它集中在【每支设备的开头】——
  这正是"新设备刚上线时轨迹特征不存在"的真实问题(第 07 章讲过)
练习 2:可得性审查的代价(把 ⚠️ 的 4 列全删掉)
  1. 用 21 列全特征跑一次基线;
  2. 把 ⚠️ 的 4 列(轨迹组)全删掉,再跑一次,报 Δ;
  3. 回答:如果上线环境拿不到历史实测 SOH,你会怎么做?
参考答案
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
NO_TRAJ = BASE + WIN + DIFF + 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 = r2_score(y.iloc[te], m.predict(X.iloc[te][feats]))
    print(f"{label:26s} 特征 {len(feats):2d}  测试 R² = {r:.5f}")
    return r


base = run(ALL, "全 21 列(含 ⚠️ 轨迹)")
no_traj = run(NO_TRAJ, "删掉 ⚠️ 的 4 列(17 列)")
print(f"{'':26s} Δ = {no_traj - base:+.5f}")

期望输出:

text
全 21 列(含 ⚠️ 轨迹)            特征 21  测试 R² = 0.99903
删掉 ⚠️ 的 4 列(17 列)          特征 17  测试 R² = 0.95930
                           Δ = -0.03973

③ 回答:三件事按顺序做。

text
① 去【确认】而不是假设:生产 BMS 到底有没有存该设备的历史实测容量?
   (存多久?什么频率?能不能实时取?)
② 拿得到 → 把账本里的 ⚠️ 改成 ✅,并写清"已确认"的条件与日期
③ 拿不到 → 整组删掉,并把这个代价写进报告:
        测试 R² 0.99903 → 0.95930(Δ −0.03973)
    ↓
★ 关键:这个决定【不能靠分数做】—— 0.99903 比 0.95930 好看得多,
  但那 4 列如果上线拿不到,那个 0.99903 就是假的。

★ 一个中间方案(真实项目常用):

text
如果 BMS 只保存"最近 N 个窗口"的实测容量:
    轨迹特征仍然可用,但要接受"设备刚上线的前 N 个窗口没有轨迹特征"
    → 上线策略:冷启动期用 17 列模型,等历史攒够再切到 21 列模型
    → 这条策略必须写进账本的【风险备注】栏
练习 3:把"来源"栏变成断言

对下面四条声明各写一个断言,并在真实数据上跑:

  1. soh_lag1 恒等于"同一设备上一行的 soh";
  2. soh_lag1 不等于本行的 soh;
  3. soh_win_mean5 恒等于"同一设备 shift(1).rolling(5).mean()";
  4. cycle_frac 恒等于 cycle / n_cycles_to_eol(需要 cells.csv)。

再回答:为什么第 1 条和第 2 条必须一起报?

参考答案
python
import os

import pandas as pd

HERE = os.path.dirname(os.path.abspath(__file__))
CODE = os.path.abspath(os.path.join(HERE, "..", ".."))
DATA = os.path.join(CODE, "data", "battery")

df = pd.read_csv(os.path.join(DATA, "soh_features_v2.csv"))
cells = pd.read_csv(os.path.join(DATA, "cells.csv"))

g = df.groupby("cell_id", sort=False)
prev_soh = g["soh"].shift(1)
mean5 = g["soh"].transform(lambda s: s.shift(1).rolling(5).mean())
m = df[["cell_id", "cycle", "soh", "soh_lag1", "soh_win_mean5", "cycle_frac"]].merge(
    cells[["cell_id", "n_cycles_to_eol"]], on="cell_id", how="left")

checks = [
    ("soh_lag1 == 上一行 soh(应等于 0)", (m["soh_lag1"] - prev_soh).abs().max(), "small"),
    ("soh_lag1 != 本行 soh(应远大于 0)", (m["soh_lag1"] - m["soh"]).abs().max(), "large"),
    ("soh_win_mean5 == shift(1).rolling(5).mean()(应等于 0)",
     (m["soh_win_mean5"] - mean5).abs().max(), "small"),
    ("cycle_frac == cycle / n_cycles_to_eol(应等于 0)",
     (m["cycle_frac"] - m["cycle"] / m["n_cycles_to_eol"]).abs().max(), "small"),
]
for label, diff, expect in checks:
    ok = (diff < 1e-3) if expect == "small" else (diff > 1e-3)
    print(f"{'✅' if ok else '❌'} 最大差 {diff:.2e}   {label}")

期望输出:

text
✅ 最大差 0.00e+00   soh_lag1 == 上一行 soh(应等于 0)
✅ 最大差 9.80e-03   soh_lag1 != 本行 soh(应远大于 0)
✅ 最大差 4.00e-05   soh_win_mean5 == shift(1).rolling(5).mean()(应等于 0)
✅ 最大差 5.00e-05   cycle_frac == cycle / n_cycles_to_eol(应等于 0)

★ 为什么第 1、2 条必须一起报:

text
只报第 1 条:"soh_lag1 等于上一行的 soh"(差 0.00e+00)
    ↓ 这句话有一个漏洞:如果【上一行的 soh 恰好等于本行的 soh】呢?
    (设备老化很慢,相邻窗口的 SOH 确实经常相同)
    ↓
只报第 1 条,无法区分这两种实现:
    ✅ soh_lag1 = s.shift(1)
    ❌ soh_lag1 = s            (本行标签!)
    在"相邻值恰好相同"的行上,两者的差都是 0
    ↓
加上第 2 条:"最大差 9.80e-03,远大于 0"
    → 说明确实存在"上一行 ≠ 本行"的行,且实现取的是上一行
    ↓
★ 两条一起,才排除了"这一列其实就是本行标签"这个致命实现错误。

★ 那两个 e-05 的差:来自 CSV 落盘的 round(4)(第 02 章的数据纪律)。 断言阈值用 1e-3 是合适的 —— 它比舍入误差大,又比"写错 shift 位"的差异小得多 (写错一位的差是 9.8e-03 量级)。

面向工程实践:账本进评审,断言进 CI ​

① 账本在评审会上怎么用

text
评审一个"新增特征"的提案,按账本的五栏逐个问:
    来源    :这一段窗口口径和已有的列一致吗?(口径不一致 = 上线维护两套)
    可得性  :预测这一刻拿得到吗?上线环境确认过了吗?
    单列 r  :它和已有列的相关性多高?(>0.95 就要问"为什么不用已有的")
    消融 Δ  :对照组是什么?Δ 大于折间标准差吗?
    风险备注:数据漂移/离群点/口径变更 会怎么影响它?
    ↓
五栏里有一栏答不上来,就不该进模型

② 断言进 CI 的最小版本

yaml
# 伪代码:每次改特征脚本都要跑
- run: python scripts/assert_ledger.py     # 16.5 的断言函数
- run: python scripts/gen_ledger.py         # 重算 r 与 Δ,生成新账本
- run: git diff --exit-code docs/feature-ledger.md   # 账本变了就必须提交

★ 第三条是关键:

text
"账本变了就必须一起提交"
    ↓
如果只改了特征脚本、没更新账本 → CI 报错
    ↓
这样"账本过期"就从"人的疏忽"变成了"流程不通过"

③ 一个可复用的账本生成器骨架

python
def build_ledger(df, groups, target, group_col, tr, te, meta, model_factory):
    """生成账本:r 与 Δ 由代码算,来源/可得性/风险由 meta 提供。

    meta: {列名: (来源, 可得性, 风险备注)}
    """
    from sklearn.metrics import r2_score

    all_cols = [c for cols in groups.values() for c in cols]
    X, y = df[all_cols], df[target]
    r = df[all_cols + [target]].corr()[target].drop(target)

    def score(feats):
        m = model_factory().fit(X.iloc[tr][feats], y.iloc[tr])
        return r2_score(y.iloc[te], m.predict(X.iloc[te][feats]))

    base = score(all_cols)
    delta = {}
    for name, cols in groups.items():
        left = [c for c in all_cols if c not in cols]
        delta[name] = score(left) - base

    rows = []
    for c in all_cols:
        src, avail, note = meta[c]
        rows.append({"列名": c, "来源": src, "可得性": avail,
                     "单列 r": round(float(r[c]), 4),
                     "消融 Δ": round(delta[GROUP_OF[c]], 5), "风险备注": note})
    return pd.DataFrame(rows)

★ 这个骨架的三个要点:

text
① r 与 Δ 都是现场算的 → 不可能"抄错"
② 来源/可得性/风险来自 meta → 必须人工维护,改了就看得出来
③ 返回 DataFrame → 可以直接 to_csv / to_markdown 进文档

★ 最后一句,也是本书第二部分的收尾:

到这里,"让特征能用"这条路走完了:缩放 → 编码 → 合成预处理器 → 挑选 → 消融 → 记账。

账本里那两行 ❌ 一直没删干净 —— 因为下一部分(第 17~22 章) 要用它们当主角:cycle_frac 会告诉你什么叫"用了未来的信息",soh_margin 会亲手把 R² 做成 1.000000。


下一章:第 17 章 · 三种泄漏总览与判据