第 10 章 · 特征构造的纪律:预测这一刻,我手上真的拿得到这个数吗?
这一章要解决什么:前五章造了四类时序特征(水平 / 波动 / 变化 / 极值), 也踩了五种坑。这一章不再造新特征 —— 它把那些坑收成一张检查表,并正式提出全书的统一判据。
最容易踩的坑: ① 用分数判断一列特征该不该留 —— 本章会证明分数分不出"合法"和"非法"; ② 把"我分组了、我排序了"当成纪律的全部 —— 时间方向、标签函数、未来聚合同样致命; ③ 把纪律放在脑子里 —— 它会随着人走;清单要写下来,判据要能执行。
本章你需要完成的代码:demos/ch10-feature-discipline.py
10.1 一句话判据:把"拿得到吗"问三次
先看这一章要立的那句话 —— 它是全书出现频率最高的一句:
预测这一刻,我手上真的拿得到这个数吗?★ 这句话要问三次,对应三种"拿不到":
| 第几次 | 问什么 | 拿不到的样子 |
|---|---|---|
| 第 1 次 | 这个数用到当前了吗? | 用"含今天在内"的均值;用标签自己 |
| 第 2 次 | 这个数用到未来了吗? | 用"该设备的总寿命"、用后续实测值 |
| 第 3 次 | 这个数是算出来的还是测出来的? | 由标签做线性变换得到的"业务指标" |
★ 为什么这句话比"避免数据泄漏"更好用:
"避免数据泄漏" → 抽象,需要先定义什么算泄漏
"拿得到吗" → 具体,可以在这一列上直接回答
↓
它把"泄漏"这个抽象概念,变成了一次可执行的检查★ 一个可以立刻用的改写练习:
把每一列的特征定义读出来,然后在前面加一句时间状语:
"预测第 t 个窗口时,
我用【第 t-20 到 t-1 个窗口的均值】" ← ✅
"预测第 t 个窗口时,
我用【未来 5 个窗口的均值】" ← ❌ 拿不到
"预测第 t 个窗口时,
我用【该设备的总寿命】" ← ❌ 还没发生⚠️ 这个练习的难点不在"判断", 而在于"你得能读出这一列的定义" —— 如果一列的定义已经没人说得清,那它本身就危险(第 9 章练习 1 的结论)。
点开:这句话在第 17 章会长成什么样?
第 10 章提出判据,第 17 章把它扩展成"三种泄漏"的完整体系:
| 泄漏类型 | 第几章 | 判据对应的问题 |
|---|---|---|
| ① 划分泄漏 | 第 18 章 | 训练集和测试集里是不是同一支设备? |
| ② 预处理泄漏 | 第 19 章 | 标准化/特征选择的参数是在全量上算的吗? |
| ③ 特征泄漏 | 第 20 章 | 这一列在预测时刻拿得到吗? |
★ 注意顺序:
第 10 章(本章):先立判据(能执行的一句话)
第 11~16 章: 把特征做对(缩放 / 编码 / 选择 / 消融 / 账本)
第 17~22 章: 再把判据扩成体系(三种泄漏 + 亲手做出 R² = 1.000000)为什么不在本书开头就讲泄漏?
因为"泄漏"这个词只有在【你自己造过特征】之后才有分量。
先见过 0.998716 / 0.999169 / 1.000000 这一排数,
再回来看"拿得到吗"这三个字,感受完全不同。10.2 先把 27 列审一遍
真实输出:
=== ① 27 列的可用性审计 ===
列名 组 结论 理由
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 窗口/差分/极差 可用 只用过去(前提:写的是 shift(1).rolling)
...(窗口、差分、极差共 10 列,结论相同)
soh_lag1 轨迹 需前提 上线时能拿到历史实测 SOH,且必须 shift(1)
soh_lag5 轨迹 需前提 上线时能拿到历史实测 SOH,且必须 shift(1)
soh_win_mean5 轨迹 需前提 上线时能拿到历史实测 SOH,且必须 shift(1)
soh_win_std5 轨迹 需前提 上线时能拿到历史实测 SOH,且必须 shift(1)
batch 类别 可用 本数据上与标签无关(第 12 章)
test_condition 类别 可用 本数据上与标签无关(第 12 章)
cycle_frac 极差/业务 拿不到 分母是「该设备的最终总寿命」——只有退役那天才知道
soh_margin 极差/业务 拿不到 soh − 0.80:它就是标签自己
soh 标签 — 要预测的东西
cell_id 标识 — 分组键,不是特征
合计 27 列:可用 19 / 需前提 4 / 拿不到 2 / 标签与标识 2
★ 判据只有一句:「预测这一刻,我手上真的拿得到这个数吗?」
拿不到的列,无论它在训练集上多灵,上线那天都会原地失效。★ 注意这张表里出现了第三种结论:「需前提」:
| 结论 | 列数 | 含义 |
|---|---|---|
| 可用 | 19 | 只要代码写对(分组/排序/shift),任何模型任何时间都拿得到 |
| 需前提 | 4 | 拿得到,但需要一个业务前提 |
| 拿不到 | 2 | 无论怎么写都不该用 |
| 标签 / 标识 | 2 | 不是特征 |
★ "需前提"是最容易被含糊过去的一类:
轨迹列(soh_lag1 等)的前提是:
"上线时,BMS 里能读到这支设备【上一个窗口的实测 SOH】"
↓
这个前提在很多场景成立(BMS 确实记录历史容量)
在另一些场景不成立(比如离线检测、只给你一张瞬时快照)
↓
所以它是"前提",不是"事实"⚠️ 纪律:
"需前提"的列,前提必须写进文档,并由业务方确认。
数据科学家自己拍脑袋说"上线时肯定拿得到", 是项目里最常见的一类事故来源。
★ 而两个"拿不到"的列,正好是两种不同的泄漏:
| 列 | 怎么算的 | 为什么拿不到 |
|---|---|---|
cycle_frac | cycle / n_cycles_to_eol | 分母是该设备的最终总寿命 —— 退役那天才知道 |
soh_margin | soh − 0.80 | 它就是标签自己的线性变换 |
这两列是数据里故意埋的钩子**,第 17~21 章会把它们做成完整专题。** 这一章先用它们把判据讲清楚。
10.3 探针 1 · 时间方向:用到当前行了吗
这一章把判据做成三个"探针"(probe)—— 每个探针都是一段可运行的代码。
先把口径定死(第 07 章的纪律):
真实输出:
=== ② 先把口径定死:同一批行、同一次划分 ===
四个候选列都非空的行:19,739 行(都被开头/结尾的 shift 窗口砍掉了一些)
按设备划分:训练 16,044 行 / 测试 3,695 行
★ 纪律(第 07 章):所有对比都用这同一批行 ——
否则 Δ 分数里混进的是 Δ 样本,而不是 Δ 特征。然后是探针 1 本身:
真实输出:
=== ③ 探针 1 · 时间方向:用到当前行了吗 ===
写法 与标签相关 测试 R² 系数 截距
shift(1).rolling(5).mean(),只用历史 0.999451 0.998716 1.003067 -0.004317
rolling(5).mean(),含当前行 0.999642 0.999169 1.002180 -0.003000
分数差 +0.000453 —— 只差万分之几。
⚠️ 这正是它危险的地方:这个数据太光滑,含不含当前行只差约 15 圈的退化量,
分数几乎不动,但特征的含义已经完全变了。
极端写法:把标签自己当特征 → 系数 1.000000、截距 -0.000000、R² 1.000000
← 系数恰好 1、截距恰好 0:模型一个规律都没学到(第 21 章)。★ 探针 1 的检查方式:
把这一列的算式写出来,找 shift(k):
最大的 k ≥ 1 吗?
├─ 是 → 时间方向正确(至少没用到当前行)
└─ 否 → 它用到了当前行 → ❌用这一条扫一遍主表(第 05 章的 8 列窗口特征):
volt_win_mean = s.shift(1).rolling(20).mean() → 最大 shift = 1 ✅
soh_lag1 = s.shift(1) → 最大 shift = 1 ✅★ 为什么"含当前行"在这份数据上几乎看不出来:
SOH 太光滑:相邻两个窗口只差 2~4 圈
↓
"含当前行"与"不含当前行"的差 = 约 15 圈的退化量 ≈ 0.003
↓
而标签的跨度是 0.20 —— 差 1.5%
↓
R² 只差 +0.000453⚠️ 但这不能作为"没关系"的理由 ——
因为"含当前行"这件事的性质与分数无关:
如果这一列统计的是【标签自身的历史】,
那么"含当前行"= 把答案的一部分直接放进特征
↓
性质上它是泄漏;只是这份数据太光滑,让它看起来无害
↓
换成一份变化快的数据(或者换一个不那么光滑的标签),
同样的代码会立刻给出 R² ≈ 0.9999★ 极端写法给出的信号最清楚:
| 写法 | 系数 | 截距 | R² |
|---|---|---|---|
| 把标签自己当特征 | 1.000000 | −0.000000 | 1.000000 |
系数恰好 1、截距恰好 0 —— 这个"整数般的整齐"就是泄漏的指纹。
第 21 章会把它做成完整的 R² = 1.000000 现场。
10.4 探针 2 · 标签函数:这一列是不是标签自己算出来的
真实输出:
=== ④ 探针 2 · 标签函数:这一列是不是标签自己算出来的 ===
|r| > 0.999 的列:
soh_margin |r| = 1.000000
mean5_noshift |r| = 0.999642
mean5_shift1 |r| = 0.999451
soh_win_mean5 |r| = 0.999451
future_mean5 |r| = 0.999447
soh_lag1 |r| = 0.999136
soh_lag5 |r| = 0.999049
|r| 恰好 = 1.000000 的列:['soh_margin']
用 soh_margin 单列拟合:系数 = 1.0000000000,截距 = 0.8000000000,R² = 1.000000
→ 系数恰好 1、截距恰好 0.80:模型没有学到任何规律,
它只是把标签乘以 1 再加 0.8(第 21 章会把它做成 R² = 1.000000)。
★ 探针的用法:|r| 高只是【警报】,不是判决 —— 要回头看它是【测量】
来的还是【算】出来的。测量来的(电压/电阻/温度)可用;
算出来又依赖当前或未来的,不能用。★ 这张"|r| > 0.999"的名单本身就是一份教材:
| 列 | |r| | 它是合法的吗 |
|---|---|---|
soh_margin | 1.000000 | ❌ 标签的线性变换 |
mean5_noshift | 0.999642 | ❌ 含当前行(探针 1) |
mean5_shift1 | 0.999451 | ✅ 合法 |
soh_win_mean5 | 0.999451 | ✅ 合法(同样的东西) |
future_mean5 | 0.999447 | ❌ 用未来(探针 3) |
soh_lag1 | 0.999136 | ✅ 合法 |
soh_lag5 | 0.999049 | ✅ 合法 |
★ 这张表最刺眼的地方:
合法与非法混在同一份名单里,
它们的 |r| 只差 0.0002
↓
"看相关系数筛特征" 这个动作,
在这份数据上【分不出合法和非法】⚠️ 所以探针 2 的用法是"警报器",不是"判决书":
|r| 接近 1 → 亮黄灯,回去查这个数是怎么来的
├─ 测量来的(电压 / 电阻 / 温度 / 历史实测容量)→ 可能合法
└─ 算出来的(标签的加减乘除 / 用未来的量) → 不合法★ 一个 5 秒判断法:
"这个数是从传感器/PMS 里读出来的,还是从标签列上算出来的?"
读出来的 → 只要时间方向对,就是合法特征。 算出来的 → 继续问:算它用到了标签吗?用到了未来吗?
★ soh_margin 的系数与截距,是这一节最值钱的证据:
系数 = 1.0000000000
截距 = 0.8000000000
R² = 1.000000
↓
模型学到的是: soh = 1.0 × soh_margin + 0.80
而 soh_margin ≡ soh − 0.80
↓
代回去: soh = soh —— 一个恒等式
↓
这个"模型"没有任何预测能力,它是一面镜子★ 记住这个指纹:
| 指纹 | 含义 |
|---|---|
| 系数恰好 = 1、截距恰好 = 一个常数 | 这一列是标签的平移 |
| 系数恰好 = 1/scale | 这一列是标签的缩放 |
| R² 恰好 = 1.000000(不是 0.9999995) | 几乎一定是恒等式,不是"模型很好" |
第 21 章会用这个指纹,把一个"完美模型"当场拆开。
10.5 探针 3 · 未来聚合:一个"事后才成立"的汇总
真实输出:
=== ⑤ 探针 3 · 未来聚合:一个「事后才成立」的汇总 ===
列 与标签相关 测试 R² 能拿到吗
future_mean5(未来 5 窗口均值) 0.999447 0.998724 ❌ 不能
future_min20(未来 20 窗口最小) 0.998221 0.996074 ❌ 不能
mean5_shift1(正版) 0.999451 0.998716 ✅ 能
→ 未来 5 窗口的均值比正版【+0.000008】—— 几乎一模一样;
未来 20 窗口的最小值反而【-0.002642】。
分数既没涨也没跌,但这一列在预测时刻根本不存在。
★ 结论:分数不是判据。分数只能告诉你「它有用」,
永远不能告诉你「它合法」。合法性只能靠上面这句话问出来。★ 探针 3 的检查方式:
把这一列的算式写出来,找负的 shift / 未来的聚合 / 全局统计:
shift(-k) → ❌ 用未来
rolling(k).mean().shift(-k) → ❌ 用未来
groupby(...).transform("mean") → ⚠️ 用【全体】统计 → 含未来
x / n_cycles_to_eol → ❌ 分母是未来才知道的量⚠️ 第三行(全局 mean/std)是最隐蔽的一种,因为它在代码里长得最"正常":
# ❌ 全员平均:这个数是"整批设备的平均",但它是拿【含未来】的数据算的
df["temp_dev"] = df["mean_temp_rise_c"] - df["mean_temp_rise_c"].mean()
# ✅ 只用历史:
df["temp_dev"] = df["mean_temp_rise_c"] - g["mean_temp_rise_c"].transform(
lambda s: s.shift(1).expanding().mean())★ 为什么"全局 mean"也算未来聚合:
你今天上线,要算这台设备的 temp_dev
↓
如果这个 mean 是"整批 60 支设备、全生命周期"的平均
↓
它包含了:
· 你自己未来才会产生的数据
· 别的设备的数据(如果是逐行预测,这也可能是泄漏)
↓
上线时你手上只有"到目前为止的数据"★ 探针 3 的结论值得单独强调:
未来聚合不一定让分数变好。 在这个数据上,
future_mean5只比正版高 0.000008,future_min20还低了 0.002642。但它仍然不能用 —— 因为它在预测时刻不存在。
这也是"分数不是判据"这个结论最锋利的版本。
10.6 ★ 分数不是判据
把四个候选列放在一起,用同一批行、同一次划分:
真实输出:
=== ⑥ 四个候选列放在一起看(同一批行、同一次划分)===
候选列 结论 测试 R² 比正版
shift(1).rolling(5).mean(),只用历史 ✅ 能 0.998716 +0.000000
rolling(5).mean(),含当前行 ❌ 不能 0.999169 +0.000453
未来 5 个窗口的均值(shift(-5)) ❌ 不能 0.998724 +0.000008
soh − 0.80,标签自己 ❌ 不能 1.000000 +0.001284
三个「拿不到」的写法:+0.0005 / +0.0000 / +0.0013。
→ 如果只按分数排,你会把其中两个留下来,而它们上线当天就会失效。
★ 这张图要盯住三个高度差:
正版(✅) 0.998716
含当前行(❌) 0.999169 ← 高 0.000453(万分之四)
用未来(❌) 0.998724 ← 高 0.000008(十万分之八)
标签自己(❌) 1.000000 ← 高 0.001284(千分之一)★ 三种"分数骗人"的形态:
| 形态 | 例子 | 危害 |
|---|---|---|
| 分数明显更高 | soh_margin → 1.000000 | 谁都想要,最危险 |
| 分数略高 | 含当前行 → +0.000453 | 看起来"有点用",会被留下 |
| 分数几乎一样 | 用未来 → +0.000008 | 连怀疑的理由都没有 |
★ 第三种最可怕,因为它连"可疑"这个信号都不给:
"这一列加了以后分数没变"
↓
常见的反应:删掉它(因为没用)
↓
但如果你的判断是"分数没变 → 保留也无所谓"
↓
上线那天,这一列【不存在】→ 特征缺失 → 整个预测流程的表现与离线完全不同★ 所以本章的结论是:
分数回答"有没有用",判据回答"能不能用"。 两件事的检查方法完全不同:
"有没有用" → 跑一次对照实验(第 15 章) "能不能用" → 把特征的定义读一遍,问那三句话(本章)
⚠️ 一个真实项目里的顺序建议:
加一列新特征时:
① 先过判据(拿得到吗) ← 5 分钟,不用跑模型
② 再过对照(有没有用) ← 需要跑模型
↓
顺序反了会怎样?
先跑分数 → 看到"涨了 0.0005" → 兴奋 → 补文档时才发现拿不到
→ 已经花了两天,而且很可能"顺手把它留在特征表里了"10.7 第 05~09 章的坑:一张检查表
这一节把前面五章的内容收成一张可以勾的表。 每次加一列新特征,从头到尾过一遍。
★ 怎么用这张表:
① 从上往下逐条问;任何一条答不上来 → 这一列先不进特征表
② 答"是"不代表通过 —— 有些条目答"是"反而是问题
③ 全部过完之后,把答案抄进特征账本(第 16 章)已勾选 0 / 0 项
★ 八条里最容易被跳过的是第 5 条和第 8 条 —— 因为它们不产生分数,也不产生报错:
第 5 条(可复算):写不出 3 行定义的列 → 下一个人只能靠猜
第 8 条(账本): 没写下来的列 → 三个月后没人知道它能用不能用⚠️ 一个反直觉的建议:
把第 8 条(账本)放在最前面做。
因为写下定义的过程,会自动触发第 2~4 条的检查 —— 你没法在"可用性:____"这一栏前面含糊过去。
★ 这张表与后面章节的对应:
| 条目 | 本章的实测证据 | 后续展开 |
|---|---|---|
| 1 排序与分组 | 交界处 0.8123,分数 0.999029 不变 | — |
| 2 时间方向 | +0.000453(分数几乎不动) | 第 20 章 |
| 3 标签函数 | 系数 1.0 / 截距 0.8 / R² 1.000000 | 第 21 章 |
| 4 未来聚合 | +0.000008 / −0.002642 | 第 18~20 章 |
| 5 可复算 | 3.109e-15 | — |
| 6 缺失含义 | 1,260 行 / 19,979 行 | 第 16 章 |
| 7 对照口径 | +0.00004 的假提升 | 第 15 章 |
| 8 账本记录 | — | 第 16 章(交付物) |
10.8 检查表怎么用:一个例子走一遍
假设有人提了一列新特征:
"我加了一列
volt_dev:窗口平均电压 减去 该设备的长期平均电压。 这样能反映这台设备是不是偏离了它自己的正常水平。"
★ 这听起来非常合理。现在拿清单过一遍:
| 条 | 问题 | 回答 | 判定 |
|---|---|---|---|
| 1 | 分组排序? | 按 cell_id 分组、按 cycle 排序 | ✅ |
| 2 | 时间方向? | volt_win_mean - 长期均值 | ⚠️ 取决于"长期均值"怎么算 |
| 3 | 标签函数? | 不涉及标签 | ✅ |
| 4 | 未来聚合? | ⚠️ 同上 | |
| 5 | 可复算? | 3 行代码写得出来 | ✅ |
| 6 | 缺失含义? | 前 20 行是 NaN | ✅(继承窗口列的语义) |
| 7 | 对照口径? | 待对照 | |
| 8 | 账本? | 待写 |
★ 卡在第 2 和第 4 条 —— 因为"长期均值"有两种算法:
# ❌ 写法 A:全生命周期均值(含未来)
mu = g["volt_win_mean"].transform("mean")
# ✅ 写法 B:到目前为止的均值(只用历史)
mu = g["volt_win_mean"].transform(lambda s: s.shift(1).expanding().mean())★ 两种写法在这份数据上的差别(可自己验证):
写法 A:第 5 行的 mu 用到了这支设备【最后一次测量】的电压
写法 B:第 5 行的 mu 只用到第 1~4 行
↓
写法 A 在训练集上会"更准"(因为它偷看了整段生命周期)
写法 B 才是上线时能算出来的版本★ 这个例子的价值在于:
"合理"和"合法"是两件事。
volt_dev这个特征的想法完全合理 —— 但它的实现细节里藏着一次时间方向的违规。
⚠️ 而且要强调:写法 A 的上限很可能是"分数略高一点点"(就像探针 3 的 +0.000008), 所以你不会在分数上看到它。
★ 结论:
"这个特征有没有用" → 跑一次对照实验
"这个特征能不能用" → 把定义写出来,逐条过清单
↑
这一步不需要跑模型,但需要"读代码"10.9 常见坑
① 用分数判断该不该留 → 三个非法写法 +0.0005 / +0.0000 / +0.0013
② 只查"时间方向"不查"标签函数" → soh_margin 的 |r| 恰好 1.000000,时间方向完全正确
③ 把 "|r| 高" 当"强特征" → 合法与非法混在同一张名单里,|r| 只差 0.0002
④ 全局 mean/std 当成"描述性统计" → 它就是未来聚合(第 10.5 节)
⑤ 判据只装在脑子里 → 人会走、清单不会;用本章的可勾选表
⑥ 先跑分数再补判据 → 两天后才发现拿不到,而且它已经在特征表里了第 ② 条值得展开:
时间方向查的是"这一行的时间基准"
标签函数查的是"这一列和标签的关系"
↓
soh_margin 是 soh - 0.80:
它只用当前行的标签 → 时间方向"完全正确"
但它是标签自己 → 必须被"标签函数"这一条拦住
↓
★ 所以三个探针是【并列】的,不是"层层递进"的10.10 完整示例与输出
cd code
python demos/ch10-feature-discipline.py真实输出(节选):
=== ① 27 列的可用性审计 ===
合计 27 列:可用 19 / 需前提 4 / 拿不到 2 / 标签与标识 2
★ 判据只有一句:「预测这一刻,我手上真的拿得到这个数吗?」
=== ③ 探针 1 · 时间方向:用到当前行了吗 ===
shift(1).rolling(5).mean(),只用历史 0.999451 0.998716
rolling(5).mean(),含当前行 0.999642 0.999169
分数差 +0.000453 —— 只差万分之几。
=== ④ 探针 2 · 标签函数:这一列是不是标签自己算出来的 ===
|r| 恰好 = 1.000000 的列:['soh_margin']
用 soh_margin 单列拟合:系数 = 1.0000000000,截距 = 0.8000000000,R² = 1.000000
=== ⑤ 探针 3 · 未来聚合:一个「事后才成立」的汇总 ===
future_mean5(未来 5 窗口均值) 0.999447 0.998724 ❌ 不能
mean5_shift1(正版) 0.999451 0.998716 ✅ 能
=== ⑥ 四个候选列放在一起看(同一批行、同一次划分)===
soh − 0.80,标签自己 ❌ 不能 1.000000 +0.001284
结论:三个探针的顺序不能颠倒 —— 先问「拿不拿得到」,再问「有没有用」。
正版(shift(1),只用历史)测试 R² = 0.998716;
含当前行 0.999169、未来聚合 0.998724、标签函数 1.000000。
分数分不出它们谁合法 —— 能分出的是那张检查表。本章练习
练习 1:给 6 个候选列做可用性审计
对下面 6 列分别给出"可用 / 不可用"与一句理由,并打印它们各自与 soh 的 |r|:
| 列 | 怎么来的 |
|---|---|
temp_win_mean | 过去 20 个窗口的均值(shift(1) 已写对) |
mean5_shift1 | 过去 5 个窗口的实测 SOH 均值 |
mean5_noshift | 含当前行的 5 窗口均值 |
future_mean5 | 未来 5 个窗口的实测 SOH 均值 |
cycle_frac | cycle / n_cycles_to_eol |
soh_margin | soh − 0.80 |
要求:对后 4 列分别说出它违反了哪一条纪律。
参考答案
import os
import pandas as pd
HERE = os.path.dirname(os.path.abspath(__file__))
CODE = os.path.abspath(os.path.join(HERE, "..", ".."))
V2_CSV = os.path.join(CODE, "data", "battery", "soh_features_v2.csv")
df = pd.read_csv(V2_CSV).sort_values(["cell_id", "cycle"]).reset_index(drop=True)
g = df.groupby("cell_id")["soh"]
df["mean5_shift1"] = g.transform(lambda s: s.shift(1).rolling(5).mean())
df["mean5_noshift"] = g.transform(lambda s: s.rolling(5).mean())
df["future_mean5"] = g.transform(lambda s: s.rolling(5).mean().shift(-5))
cands = [
("temp_win_mean", "✅ 能", "过去 20 个窗口的均值(shift(1) 已写对)"),
("mean5_shift1", "✅ 能", "过去 5 个窗口的实测 SOH 均值"),
("mean5_noshift", "❌ 不能", "含当前行 —— 预测这一刻,这一刻的标签还没发生"),
("future_mean5", "❌ 不能", "要用未来 5 个窗口的实测 SOH"),
("cycle_frac", "❌ 不能", "分母是最终总寿命,退役那天才知道"),
("soh_margin", "❌ 不能", "soh − 0.80,就是标签自己"),
]
for c, verdict, why in cands:
print(f" {c:<16}{verdict:<10}{abs(df[c].corr(df['soh'])):>12.6f} {why}")期望输出:
=== 练习 1:给 6 个候选列做可用性审计 ===
列名 结论 |r| 与 soh 理由
temp_win_mean ✅ 能 0.739531 过去 20 个窗口的均值(shift(1) 已写对)
mean5_shift1 ✅ 能 0.999451 过去 5 个窗口的实测 SOH 均值
mean5_noshift ❌ 不能 0.999642 含当前行 —— 预测这一刻,这一刻的标签还没发生
future_mean5 ❌ 不能 0.999447 要用未来 5 个窗口的实测 SOH
cycle_frac ❌ 不能 0.978296 分母是最终总寿命,退役那天才知道
soh_margin ❌ 不能 1.000000 soh − 0.80,就是标签自己★ 请注意 temp_win_mean 那一行:
|r| = 0.739531 —— 在这 6 列里最低
↓
但它可能是这 6 列里【唯一真正能用】的那一类(除了 mean5_shift1)
↓
"|r| 低" ≠ "没用","|r| 高" ≠ "能用"★ 这张表正是"分数/相关性不能当判据"的第二个证据:
相关性的排序和可用性的排序,完全不是一回事。
练习 2:时间方向探针(同一批行、同一次划分)
用 mean5_shift1 与 mean5_noshift 的公共非空行做一次按设备划分:
- 两个单特征
LinearRegression,打印测试 R²、系数、截距; - 打印分数差。
要求:回答"分数差这么小,为什么还不能用 mean5_noshift"。
参考答案
import os
import pandas as pd
from sklearn.linear_model import LinearRegression
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, "..", ".."))
V2_CSV = os.path.join(CODE, "data", "battery", "soh_features_v2.csv")
df = pd.read_csv(V2_CSV).sort_values(["cell_id", "cycle"]).reset_index(drop=True)
g = df.groupby("cell_id")["soh"]
df["mean5_shift1"] = g.transform(lambda s: s.shift(1).rolling(5).mean())
df["mean5_noshift"] = g.transform(lambda s: s.rolling(5).mean())
sub = df[df[["mean5_shift1", "mean5_noshift"]].notna().all(axis=1)]
tr, te = next(GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
.split(sub, sub["soh"], groups=sub["cell_id"]))
tr, te = sub.index[tr], sub.index[te]
print(f" 同一批行:训练 {len(tr):,d} / 测试 {len(te):,d}")
for c in ("mean5_shift1", "mean5_noshift"):
m = LinearRegression().fit(df.loc[tr, [c]], df.loc[tr, "soh"])
r2 = r2_score(df.loc[te, "soh"], m.predict(df.loc[te, [c]]))
print(f" {c:<16} 测试 R² {r2:.6f} 系数 {m.coef_[0]:+.6f} 截距 {m.intercept_:+.6f}")期望输出:
=== 练习 2:时间方向探针(同一批行、同一次划分)===
同一批行:训练 17,004 / 测试 3,935
mean5_shift1 测试 R² 0.998877 系数 +1.003179 截距 -0.004425
mean5_noshift 测试 R² 0.999272 系数 +1.002253 截距 -0.003071
分数差 +0.000395 —— 分数几乎不动,但一个合法、一个非法。③ 回答:
因为**"分数差得小"和"特征合法"是两件不相干的事**。
mean5_noshift用到了当前行的标签。 在这份数据上,"当前行"与"前 5 行"只差约 15 圈的退化量(0.003), 所以分数只差 0.000395 —— 但**"用到当前行"这个性质不因为分数小而改变**。上线时你会发现:"当前行的标签"根本不存在 (你要预测的就是它)。那一刻这个特征只能填 0 或者 拿不到, 于是线上输入分布与离线完全不同。
★ 一个更锋利的说法:
离线时:当前行的标签【躺在表里】
线上时:当前行的标签【就是要算的东西】
↓
"用当前行"这件事的代价,不是"分数虚高",
而是"这个特征在线上根本没有定义"。★ 这也解释了为什么判据要先问"拿得到吗":
"拿不到"是一个【定义层面】的问题 —— 不需要跑模型就能发现
"分数虚高"是一个【实验层面】的现象 —— 有时根本不出现
↓
先检查定义,再检查现象练习 3:给 cycle_frac 取证
- 合并
cells.csv的n_cycles_to_eol,复核cycle_frac == cycle / n_cycles_to_eol(注意表里存的是四舍五入值); - 分别打印
cycle与cycle_frac和soh的相关系数; - 用统一判据写一句结论。
参考答案
import os
import numpy as np
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")
V2_CSV = os.path.join(DATA, "soh_features_v2.csv")
df = pd.read_csv(V2_CSV).sort_values(["cell_id", "cycle"]).reset_index(drop=True)
cells = pd.read_csv(os.path.join(DATA, "cells.csv"))[["cell_id", "n_cycles_to_eol"]]
merged = df.merge(cells, on="cell_id", how="left")
merged["cycle_frac_check"] = merged["cycle"] / merged["n_cycles_to_eol"]
d = (merged["cycle_frac"] - merged["cycle_frac_check"]).abs()
print(f" 表里的 cycle_frac 就是 cycle / n_cycles_to_eol:最大差 {d.max():.1e}"
f"(表里存的是四舍五入到 4 位小数的值)")
print(f" corr(cycle, soh) = {merged['cycle'].corr(merged['soh']):+.4f}")
print(f" corr(cycle_frac, soh) = {merged['cycle_frac'].corr(merged['soh']):+.4f}")
print(f" n_cycles_to_eol 范围:{merged['n_cycles_to_eol'].min()} ~ "
f"{merged['n_cycles_to_eol'].max()} 圈")期望输出:
=== 练习 3:给 cycle_frac 取证 ===
表里的 cycle_frac 就是 cycle / n_cycles_to_eol:最大差 5.0e-05(表里存的是四舍五入到 4 位小数的值)
corr(cycle, soh) = -0.9137
corr(cycle_frac, soh) = -0.9783
→ 分母换一个数,相关系数就从 -0.90 变成 -0.98:多出来的那部分不是信息,
是「这支设备一共能跑多久」这件退役那天才知道的事。
n_cycles_to_eol 范围:803 ~ 1490 圈
统一判据:预测这一刻,我手上真的拿得到 n_cycles_to_eol 吗?—— 拿不到。★ 这组数字就是"泄漏"的量化样子:
用 cycle 单列: |r| = 0.9137
改成 cycle_frac: |r| = 0.9783 ← 涨了 0.065
↓
多出来的那部分相关性,来自"最终总寿命"这个未来信息
↓
它看起来像"更好的特征",实质是"提前看到了结局"★ 一个可以直接抄走的自查:
# 任何一个"比值型"特征,先问分母是什么
for col in ["cycle_frac", "cap_fade_rate_ah_per_100", "coulombic_eff"]:
print(col, "的分母是:", "...") # 逐个写出来⚠️ 比值的分母是最容易藏未来信息的地方 —— 因为分子通常是你想看的那个量,而分母"看起来只是个归一化系数"。
面向工程实践:把判据变成流程里的一个卡点
① 判据的三层落地
| 层 | 形式 | 谁执行 |
|---|---|---|
| 个人 | 本章的可勾选清单 | 写特征的本人 |
| 团队 | 特征账本里"可用性"一栏必填 | 评审人 |
| 流程 | PR 模板里加一条:"本列在预测时刻拿得到吗?依据是什么?" | 团队约定 |
★ 三层里最有效的是第三层 —— 因为它把"自觉"变成了"必须回答"。
② 一个可以直接放进 PR 模板的问题清单
新增/修改特征时,请回答:
[ ] 这一列用到当前行了吗?(shift 写了吗?)
[ ] 这一列用到未来吗?(总寿命 / 后续实测 / 全局统计)
[ ] 这一列和标签是什么关系?(是不是标签的变换)
[ ] 缺失代表什么?怎么处理?
[ ] 和已有特征的相关性?会不会只是重复?
[ ] 验证方式?(手算核对 / 复算 / 相关系数)★ 这 6 个问题里,前 4 个就是本章清单的前 4 条 —— 剩下的两个(相关性、验证方式)是第 14~16 章的内容。
③ 一个"判据卡点"的真实收益
没有卡点:
特征从"想法"直接进"特征表"→ 跑分 → 涨了就留
泄漏的平均寿命:直到某次线上事故
有卡点:
特征从"想法"→【回答 6 个问题】→ 进特征表 → 跑分
泄漏被拦在"定义"这一层,成本 ≈ 5 分钟④ 本章与下一部分(第 11~16 章)的关系
第 05~09 章:造特征(水平 / 波动 / 变化 / 极值)
第 10 章: 立判据(你在这里) ← 造完之后先别急着用
第 11~13 章:把特征"喂对"(缩放 / 编码 / ColumnTransformer)
第 14~16 章:把特征"选对、讲清"(选择 / 消融 / 账本)
第 17~22 章:把判据扩成体系(三种泄漏 + 亲手做出 R² = 1.000000)★ 最后一句,也是这一部分(第 03~10 章)的收尾:
特征工程的前半程是"造",后半程是"审"。 造的时候靠直觉和领域知识, 审的时候只能靠那句朴素的话:
"预测这一刻,我手上真的拿得到这个数吗?"