Skip to content

第 10 章 · 特征构造的纪律:预测这一刻,我手上真的拿得到这个数吗? ​

这一章要解决什么:前五章造了四类时序特征(水平 / 波动 / 变化 / 极值), 也踩了五种坑。这一章不再造新特征 —— 它把那些坑收成一张检查表,并正式提出全书的统一判据。

最容易踩的坑: ① 用分数判断一列特征该不该留 —— 本章会证明分数分不出"合法"和"非法"; ② 把"我分组了、我排序了"当成纪律的全部 —— 时间方向、标签函数、未来聚合同样致命; ③ 把纪律放在脑子里 —— 它会随着人走;清单要写下来,判据要能执行。

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

10.1 一句话判据:把"拿得到吗"问三次 ​

先看这一章要立的那句话 —— 它是全书出现频率最高的一句:

text
预测这一刻,我手上真的拿得到这个数吗?

★ 这句话要问三次,对应三种"拿不到":

第几次问什么拿不到的样子
第 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 列审一遍 ​

真实输出:

text
=== ① 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_fraccycle / n_cycles_to_eol分母是该设备的最终总寿命 —— 退役那天才知道
soh_marginsoh − 0.80它就是标签自己的线性变换

这两列是数据里故意埋的钩子**,第 17~21 章会把它们做成完整专题。** 这一章先用它们把判据讲清楚。

10.3 探针 1 · 时间方向:用到当前行了吗 ​

这一章把判据做成三个"探针"(probe)—— 每个探针都是一段可运行的代码。

先把口径定死(第 07 章的纪律):

真实输出:

text
=== ② 先把口径定死:同一批行、同一次划分 ===
  四个候选列都非空的行:19,739 行(都被开头/结尾的 shift 窗口砍掉了一些)
  按设备划分:训练 16,044 行 / 测试 3,695 行
  ★ 纪律(第 07 章):所有对比都用这同一批行 ——
     否则 Δ 分数里混进的是 Δ 样本,而不是 Δ 特征。

然后是探针 1 本身:

真实输出:

text
=== ③ 探针 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 的检查方式:

text
把这一列的算式写出来,找 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.0000001.000000

系数恰好 1、截距恰好 0 —— 这个"整数般的整齐"就是泄漏的指纹。

第 21 章会把它做成完整的 R² = 1.000000 现场。

10.4 探针 2 · 标签函数:这一列是不是标签自己算出来的 ​

真实输出:

text
=== ④ 探针 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_margin1.000000❌ 标签的线性变换
mean5_noshift0.999642❌ 含当前行(探针 1)
mean5_shift10.999451✅ 合法
soh_win_mean50.999451✅ 合法(同样的东西)
future_mean50.999447❌ 用未来(探针 3)
soh_lag10.999136✅ 合法
soh_lag50.999049✅ 合法

★ 这张表最刺眼的地方:

合法与非法混在同一份名单里,
它们的 |r| 只差 0.0002
                ↓
"看相关系数筛特征" 这个动作,
在这份数据上【分不出合法和非法】

⚠️ 所以探针 2 的用法是"警报器",不是"判决书":

|r| 接近 1  → 亮黄灯,回去查这个数是怎么来的
   ├─ 测量来的(电压 / 电阻 / 温度 / 历史实测容量)→ 可能合法
   └─ 算出来的(标签的加减乘除 / 用未来的量)      → 不合法

★ 一个 5 秒判断法:

"这个数是从传感器/PMS 里读出来的,还是从标签列上算出来的?"

读出来的 → 只要时间方向对,就是合法特征。 算出来的 → 继续问:算它用到了标签吗?用到了未来吗?

★ soh_margin 的系数与截距,是这一节最值钱的证据:

text
系数 = 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 · 未来聚合:一个"事后才成立"的汇总 ​

真实输出:

text
=== ⑤ 探针 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 的检查方式:

text
把这一列的算式写出来,找负的 shift / 未来的聚合 / 全局统计:
   shift(-k)                      → ❌ 用未来
   rolling(k).mean().shift(-k)    → ❌ 用未来
   groupby(...).transform("mean") → ⚠️ 用【全体】统计 → 含未来
   x / n_cycles_to_eol            → ❌ 分母是未来才知道的量

⚠️ 第三行(全局 mean/std)是最隐蔽的一种,因为它在代码里长得最"正常":

python
# ❌ 全员平均:这个数是"整批设备的平均",但它是拿【含未来】的数据算的
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 ★ 分数不是判据 ​

把四个候选列放在一起,用同一批行、同一次划分:

真实输出:

text
=== ⑥ 四个候选列放在一起看(同一批行、同一次划分)===
  候选列                                         结论             测试 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。
  → 如果只按分数排,你会把其中两个留下来,而它们上线当天就会失效。

分数分不出谁合法:三个"拿不到"的写法,两个只差万分之几,一个直接 1.000000

★ 这张图要盯住三个高度差:

正版(✅)          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 条 —— 因为"长期均值"有两种算法:

python
# ❌ 写法 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 常见坑 ​

text
① 用分数判断该不该留              → 三个非法写法 +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 完整示例与输出 ​

bash
cd code
python demos/ch10-feature-discipline.py

真实输出(节选):

text
=== ① 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_fraccycle / n_cycles_to_eol
soh_marginsoh − 0.80

要求:对后 4 列分别说出它违反了哪一条纪律。

参考答案
python
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}")

期望输出:

text
=== 练习 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 的公共非空行做一次按设备划分:

  1. 两个单特征 LinearRegression,打印测试 R²、系数、截距;
  2. 打印分数差。

要求:回答"分数差这么小,为什么还不能用 mean5_noshift"。

参考答案
python
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}")

期望输出:

text
=== 练习 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 取证
  1. 合并 cells.csv 的 n_cycles_to_eol,复核 cycle_frac == cycle / n_cycles_to_eol(注意表里存的是四舍五入值);
  2. 分别打印 cycle 与 cycle_frac 和 soh 的相关系数;
  3. 用统一判据写一句结论。
参考答案
python
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()} 圈")

期望输出:

text
=== 练习 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
                ↓
多出来的那部分相关性,来自"最终总寿命"这个未来信息
                ↓
它看起来像"更好的特征",实质是"提前看到了结局"

★ 一个可以直接抄走的自查:

python
# 任何一个"比值型"特征,先问分母是什么
for col in ["cycle_frac", "cap_fade_rate_ah_per_100", "coulombic_eff"]:
    print(col, "的分母是:", "...")   # 逐个写出来

⚠️ 比值的分母是最容易藏未来信息的地方 —— 因为分子通常是你想看的那个量,而分母"看起来只是个归一化系数"。

面向工程实践:把判据变成流程里的一个卡点 ​

① 判据的三层落地

层形式谁执行
个人本章的可勾选清单写特征的本人
团队特征账本里"可用性"一栏必填评审人
流程PR 模板里加一条:"本列在预测时刻拿得到吗?依据是什么?"团队约定

★ 三层里最有效的是第三层 —— 因为它把"自觉"变成了"必须回答"。

② 一个可以直接放进 PR 模板的问题清单

text
新增/修改特征时,请回答:
  [ ] 这一列用到当前行了吗?(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 章)的收尾:

特征工程的前半程是"造",后半程是"审"。 造的时候靠直觉和领域知识, 审的时候只能靠那句朴素的话:

"预测这一刻,我手上真的拿得到这个数吗?"


下一章:第 11 章 · 缩放:让系数可比,让尺度敏感的模型能用