第 16 章 · 特征账本:给每一列记账
这一章要解决什么:第 11~15 章各管一件事 —— 缩放、编码、合成预处理器、相关性、消融。这一章把它们收进一张表: 每一列一行,五本账(来源 / 预测时刻可得性 / 单列相关性 / 消融贡献 / 风险备注)。
这是本书的第二个交付物(第一个是第 10 章那张判据清单), 它会在第 22 章的防泄漏检查清单里被直接复用。
最容易踩的坑: ① 把组级的消融 Δ 抄成单列的 Δ —— 两者可以差两个数量级(16.3); ② 把 ⚠️ 当 ✅ 直接上线 —— "有历史实测 SOH"是要去确认的,不是假设; ③ 账本写完就不维护 —— 列改名后账本过期,比没有账本更危险。
本章你需要完成的代码:demos/ch16-feature-ledger.py
16.1 为什么需要账本:三个问题必须一起回答
一个特征"能不能用",取决于三件互不替代的事:
| 问题 | 谁回答 | 用错工具会怎样 |
|---|---|---|
| 它值多少?(精度) | 消融 Δ(第 15 章) | 用相关系数代替 → 漏掉非线性的列 |
| 它拿得到吗?(可行性) | 人工判断 + 上线环境核对 | 只算精度 → 上线当天模型崩 |
| 它有风险吗?(合规/稳定性) | 风险备注 + 分布监控 | 不看风险 → 数据漂移三个月后才发现 |
★ 把三者写在同一张表里,就是"特征账本":
列名 来源 可得性 单列 r 消融 Δ 风险备注
soh_lag1 上一窗口的实测 SOH ⚠️ +0.9991 +0.00000 上线必须能拿到历史实测容量
cycle_frac cycle / 最终总寿命 ❌ -0.9783 +0.00555 分母要跑到退役才知道
...★ 一句话说清它为什么是"交付物"而不只是"文档":
文档:写完就躺在那里,没人核对
账本:每一行的每一个数字都能被【重跑出来】,
除了"可得性"那一栏 —— 它是人的签字,也必须能被审计
↓
所以它是一份"可执行的文档":代码生成 + 人工签一栏16.2 账本的五栏:口径先定死
| 栏位 | 谁填 | 口径(必须写清) |
|---|---|---|
| 列名 | 代码 | 与 soh_features_v2.csv 的列名逐字符一致 |
| 组 | 代码 | 基础 / 窗口统计 / 差分斜率 / 极差 / 轨迹 / 泄漏钩子 |
| 来源 | 人工 | 一句话说清"怎么算出来的"(含窗口长度、是否 shift) |
| 可得性 | 人工 | ✅ 预测这一刻一定拿得到 / ⚠️ 有条件 / ❌ 拿不到 |
| 单列 r | 代码 | 与标签的皮尔逊相关,注明用哪份样本算的 |
| 消融 Δ | 代码 | 组级 leave-one-group-out(轨迹组做逐列);注明对照组 |
| 风险备注 | 人工 | 上线/维护/漂移相关的具体风险 |
★ 一个具体的口径差异(读者一定会撞上):
本账本的 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
"在这张表里,这一列和标签相关多少"。
账本里必须写清用的是哪一份 —— 这就是下面常见坑里的第 ⑧ 条。★ 三个"必须注明",否则账本不可复核:
① 单列 r 用哪份样本算的(本账本用全表 21,239 行 —— 它只是"看一眼"的地图,
不是选特征的依据;选特征必须进 Pipeline,见第 14 章)
② 消融 Δ 的对照组是什么(其它组都在不在)
③ Δ 是组级还是单列级★ 本账本的口径(写在表头,随表一起交付):
数据 :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 列)
真实输出(节选前三段):
③ ★ 特征账本(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 章) |

★ 这张表里有三处需要停下来想一想的地方。
反直觉一:轨迹组的 Δ 是"整组值钱、单列可删"
真实输出:
轨迹组值钱,所以它需要【逐列】消融(其余组只做组级):
(组级)删【轨迹】整组 测试 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 章)。
★ 两句话同时成立:
"删掉 soh_lag1(单列)→ 分数不动" ← 真的
"删掉整组轨迹(4 列)→ 分数掉 0.0400" ← 也是真的
↓
为什么?因为这 4 列互相顶替(r ≈ 0.999 量级)
↓
★ 结论:消融的【粒度】决定结论。
"这一列可以删"和"这一组不能删"可以同时为真。★ 这条结论对工程的直接含义:
① 想省列数:可以从轨迹组里只留一列(比如 soh_win_mean5,它的单列 Δ 最大)
② 想省数据管道:不能整组砍 —— 那要掉 0.04
③ 想理解"为什么值钱":要看【这一组提供的是一种信息结构】,不是看单列反直觉二:相关系数和 Δ 经常不一致
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 ← 相关满分,因为它就是标签★ 三列"相关高"的,恰好全都是危险信号:
|r| 高 + Δ 小 → 信息被别的列覆盖了(cycle_frac 在 21 列口径下就是这种)
|r| 高 + Δ 大 → 要么真有用,要么泄漏(必须回到"拿得到吗"这一栏)
|r| 低 + Δ 大 → 非线性/交互信息(本数据没有这种列,但真实项目里常见)反直觉三:两个 ❌ 列的"值多少"完全不能用来决定去留
cycle_frac Δ = +0.00555 → 在 7 列基线口径下"很有用"
soh_margin Δ = +0.03497 → 全表最大的一笔"收益"
↓
如果只看 Δ 这一栏,你会把它们留下来
↓
但"可得性"那一栏写着 ❌ —— 预测这一刻拿不到
↓
★ 这就是账本的价值:它让"精度诱惑"和"可行性判断"出现在同一行上,
谁也盖不住谁。16.4 三栏风险统计:上线清单是怎么来的
真实输出:
④ 风险统计
✅ 预测这一刻一定拿得到 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 列的正确处理方式:
⚠️ 不是"有问题",而是"条件性可用"
↓
条件:生产环境的 BMS 是否保存该设备的历史容量实测值?
├─ 是 → 把 ⚠️ 降级为 ✅,写进账本("已确认:BMS 保存最近 N 个窗口的实测容量")
└─ 否 → 整组删掉,并记录代价:
测试 R² 从 0.99904 掉到 0.95906(Δ −0.03997)
↓
★ 这个"是/否"必须由人回答并签字 —— 它是账本里唯一的非计算栏。16.5 把"来源"栏变成可执行的断言
账本最容易被质疑的一栏是"来源" —— 因为它是一句话,不是数字。 所以要用断言把它变成可执行的证据。
真实输出:
⑤ 三条自动检查(把「来源」这句话变成可执行的证据)
检查 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
→ 这一列在预测那一刻【根本不存在】——它要求你提前知道设备哪天退役。★ 为什么"两个检查要一起看":
检查 1 的第一行说"等于上一行"(0.00e+00)
检查 1 的第二行说"不等于本行"(9.80e-03)
↓
只报第一行,无法区分"等于上一行"和"等于本行"(如果两行 soh 恰好相同)
只报第二行,无法证明它用的是什么
↓
★ 两条一起看,才证明"这一列只用历史",而且用的是【确切的上一行】★ 那两个 e-05 的差从哪来:
CSV 落盘时做了 round(4)(第 02 章讲过的数据纪律)
↓
所以用 CSV 重算的 shift(1).rolling(5).mean() 与落盘值差 4e-05
↓
这不是 bug,是舍入;但它提醒你:
账本里的断言要在【和训练数据同一份 CSV】上跑,别混用原始曲线★ 一段可以复制到项目里的校验函数:
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:
有人改了特征生成脚本(比如把 shift(1) 写成 shift(2))
↓
分数几乎不变(soh_lag2 和 soh_lag1 的 r = 0.9999)
↓
没有人会注意到
↓
但这个断言会当场炸 → 账本与数据不一致,拒绝发布16.6 账本怎么维护:一次变更的三步
★ 一份会过期的账本比没有账本更危险(因为它会让人"以为核对过了")。 所以要把"改特征"和"改账本"绑在一起:
第 1 步:改代码(加/删/改一列)
第 2 步:重跑生成账本的脚本 → 新账本自动产生(r 与 Δ 都是重算的)
第 3 步:人工复核【来源】与【可得性】两栏,签字 + 日期★ 一个可操作的门禁规则:
| 变更类型 | 必须更新账本的哪几栏 | 谁签字 |
|---|---|---|
| 新增一列 | 全部五栏 | 特征负责人 |
| 改公式(比如窗口 20 → 30) | 来源、单列 r、消融 Δ、风险备注 | 特征负责人 |
| 改列名 | 列名 + 所有引用它的文档 | 特征负责人 |
| 上线环境变化(BMS 升级) | 可得性(这是最容易被忽略、后果最严重的一栏) | 上线负责人 |
★ 一个真实的过期案例(本数据上可以复现):
账本写的是"soh_lag1 = 上一窗口的实测 SOH"
↓
某次重构把特征生成里的 s.shift(1) 写成了 s.rolling(5).mean()
↓
分数从 0.99904 掉到 0.99862(第 15 章"只用轨迹"那一行)
↓
★ 掉得不多 —— 所以没人会发现;但"这一列现在是什么"已经变了
↓
16.5 的断言会抓住它16.7 常见坑
① 把组级 Δ 抄成单列 Δ → 轨迹组:整组 −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 行?结论会不同
⑨ 把账本当一次性交付 → 它应该跟着每次特征变更自动重跑★ 第 ③ 条展开一下(这是最贵的坑):
"历史实测 SOH 拿得到吗?"
↓
在离线实验里:拿得到(CSV 里就有)
在真实产线上:取决于 BMS 是否存了、存多久、以什么频率存、能不能实时取
↓
一次真实的失败模式:
训练用"最近 5 个窗口的实测容量均值" → 0.9986
上线时 BMS 只有"容量报警"这一个事件 → 这一列根本构造不出来
↓
★ 所以"可得性"这一栏的审计对象不是数据,是【上线环境】16.8 完整示例与输出
cd tools/feature-engineering-pipeline/code
MPLCONFIGDIR=/tmp/fe-mpl /path/to/venv/bin/python demos/ch16-feature-ledger.py真实输出(节选):
表: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★ 一页记住这一章:
账本 = 每列一行 × 五本账:
来源(人工)/ 可得性(人工)/ 单列 r(代码)/ 消融 Δ(代码)/ 风险备注(人工)
三个必须:
① 必须写清 Δ 的对照组与粒度(组级 or 单列)
② 必须用断言把"来源"钉住(16.5)
③ 必须随每次特征变更重跑(16.6)
一句话:账本让"精度诱惑"和"可行性判断"出现在同一行上。本章练习
练习 1:给账本补两列(缺失值比例 / 分布偏移)
- 对 23 列各算
NaN比例; - 各算"训练集均值与整表均值之差 ÷ 整表标准差"(一个简单的分布偏移指标);
- 回答:为什么"缺失值比例"必须进账本?本数据里新特征的 NaN 集中在哪?
参考答案
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}")期望输出(节选):
列名 可得性 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③ 回答:缺失值比例决定"这一列会把多少行样本带走"。
本数据的 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 章量过这个代价)★ 一个可迁移的读法:
NaN 比例 0% → 无需处理
NaN 比例小(<1%) → dropna 可接受,但要在报告里写明丢了哪些行
NaN 比例大(>10%) → 必须先想"怎么填",或者接受"冷启动期模型不可用"
↓
★ 本数据的 5.93% 属于第二档,而且它集中在【每支设备的开头】——
这正是"新设备刚上线时轨迹特征不存在"的真实问题(第 07 章讲过)练习 2:可得性审查的代价(把 ⚠️ 的 4 列全删掉)
- 用 21 列全特征跑一次基线;
- 把 ⚠️ 的 4 列(轨迹组)全删掉,再跑一次,报 Δ;
- 回答:如果上线环境拿不到历史实测 SOH,你会怎么做?
参考答案
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}")期望输出:
全 21 列(含 ⚠️ 轨迹) 特征 21 测试 R² = 0.99903
删掉 ⚠️ 的 4 列(17 列) 特征 17 测试 R² = 0.95930
Δ = -0.03973③ 回答:三件事按顺序做。
① 去【确认】而不是假设:生产 BMS 到底有没有存该设备的历史实测容量?
(存多久?什么频率?能不能实时取?)
② 拿得到 → 把账本里的 ⚠️ 改成 ✅,并写清"已确认"的条件与日期
③ 拿不到 → 整组删掉,并把这个代价写进报告:
测试 R² 0.99903 → 0.95930(Δ −0.03973)
↓
★ 关键:这个决定【不能靠分数做】—— 0.99903 比 0.95930 好看得多,
但那 4 列如果上线拿不到,那个 0.99903 就是假的。★ 一个中间方案(真实项目常用):
如果 BMS 只保存"最近 N 个窗口"的实测容量:
轨迹特征仍然可用,但要接受"设备刚上线的前 N 个窗口没有轨迹特征"
→ 上线策略:冷启动期用 17 列模型,等历史攒够再切到 21 列模型
→ 这条策略必须写进账本的【风险备注】栏练习 3:把"来源"栏变成断言
对下面四条声明各写一个断言,并在真实数据上跑:
soh_lag1恒等于"同一设备上一行的 soh";soh_lag1不等于本行的 soh;soh_win_mean5恒等于"同一设备shift(1).rolling(5).mean()";cycle_frac恒等于cycle / n_cycles_to_eol(需要cells.csv)。
再回答:为什么第 1 条和第 2 条必须一起报?
参考答案
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}")期望输出:
✅ 最大差 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 条必须一起报:
只报第 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
① 账本在评审会上怎么用
评审一个"新增特征"的提案,按账本的五栏逐个问:
来源 :这一段窗口口径和已有的列一致吗?(口径不一致 = 上线维护两套)
可得性 :预测这一刻拿得到吗?上线环境确认过了吗?
单列 r :它和已有列的相关性多高?(>0.95 就要问"为什么不用已有的")
消融 Δ :对照组是什么?Δ 大于折间标准差吗?
风险备注:数据漂移/离群点/口径变更 会怎么影响它?
↓
五栏里有一栏答不上来,就不该进模型② 断言进 CI 的最小版本
# 伪代码:每次改特征脚本都要跑
- run: python scripts/assert_ledger.py # 16.5 的断言函数
- run: python scripts/gen_ledger.py # 重算 r 与 Δ,生成新账本
- run: git diff --exit-code docs/feature-ledger.md # 账本变了就必须提交★ 第三条是关键:
"账本变了就必须一起提交"
↓
如果只改了特征脚本、没更新账本 → CI 报错
↓
这样"账本过期"就从"人的疏忽"变成了"流程不通过"③ 一个可复用的账本生成器骨架
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)★ 这个骨架的三个要点:
① r 与 Δ 都是现场算的 → 不可能"抄错"
② 来源/可得性/风险来自 meta → 必须人工维护,改了就看得出来
③ 返回 DataFrame → 可以直接 to_csv / to_markdown 进文档★ 最后一句,也是本书第二部分的收尾:
到这里,"让特征能用"这条路走完了:缩放 → 编码 → 合成预处理器 → 挑选 → 消融 → 记账。
账本里那两行 ❌ 一直没删干净 —— 因为下一部分(第 17~22 章) 要用它们当主角:
cycle_frac会告诉你什么叫"用了未来的信息",soh_margin会亲手把 R² 做成 1.000000。