Skip to content

第 07 章 · 冷启动 NaN 与 dropna 的代价 ​

这一章要解决什么:时序特征有一个躲不掉的副作用 —— 每支设备最早的那几行没有历史,只能是 NaN。 这一章算清这笔账:缺多少行、丢掉的是哪些行、 以及为什么 fillna(0) 比 dropna 更毒。

最容易踩的坑: ① 看到 NaN 就 fillna(0) —— 等于告诉模型"这台设备的电压是 0 V"; ② 做 A/B 对照时两边用了不同行数 —— Δ 分数里混进了 Δ 样本; ③ 把这条纪律写进代码注释,而不是写进特征账本(第 16 章会来收)。

本章你需要完成的代码:demos/ch07-cold-start-nan.py

7.1 时序特征的副作用:开头没有历史 ​

先回到"窗口"这个动作本身:

text
第 5.3 节说过:rolling(W) 默认【攒够 W 个数才给结果】,攒不够就是 NaN
                    ↓
但窗口表的第一个窗口,前面【一个窗口都没有】
                    ↓
所以每一支设备最前面的 W 个窗口,必然是 NaN

这件事有个正式名字:冷启动(cold start)。

text
新设备刚上线
   ↓
它没有过去 —— 不是"数据丢了",而是"过去【还不存在】"
   ↓
所有"回看型"特征在这一刻都是未知的

★ 关键区分(这一章反复要用):

NaN 的含义例子该怎么处理
过去还不存在(结构性缺失)设备第 1 个窗口留下来当"没有历史",或显式标记
数据丢了(数据质量问题)传感器掉线补测、丢弃、或专门排查
不该有值(业务上不存在)未开卡的账户余额单独一档

这三类混在一起,就是 fillna(0) 最典型的犯错现场:

把"过去还不存在"当成"数值是 0"
   ↓
填完之后,缺失率从 5.9% 变成 0%
   ↓
报表上"数据质量提升"了

这一章要把这笔交易的真实代价摊开。

7.2 NaN 是从哪来的:14 列,五种回看长度 ​

主表里有 14 列时序特征(窗口 5 + 差分 4 + 极差 1 + 轨迹 4)。

真实输出:

text
=== ① 14 列时序特征,各缺多少行 ===
  volt_slope20       NaN 1,260 行(每支设备缺 21 行)
  res_slope20        NaN 1,260 行(每支设备缺 21 行)
  temp_slope20       NaN 1,260 行(每支设备缺 21 行)
  volt_win_mean      NaN 1,200 行(每支设备缺 20 行)
  volt_win_std       NaN 1,200 行(每支设备缺 20 行)
  res_win_mean       NaN 1,200 行(每支设备缺 20 行)
  res_win_std        NaN 1,200 行(每支设备缺 20 行)
  temp_win_mean      NaN 1,200 行(每支设备缺 20 行)
  temp_win_range20   NaN 1,200 行(每支设备缺 20 行)
  soh_lag5           NaN   300 行(每支设备缺  5 行)
  soh_win_mean5      NaN   300 行(每支设备缺  5 行)
  soh_win_std5       NaN   300 行(每支设备缺  5 行)
  res_diff1          NaN    60 行(每支设备缺  1 行)
  soh_lag1           NaN    60 行(每支设备缺  1 行)
  14 列里有几个列一个都不缺:0

★ "每支设备缺几行"取决于这一列回看多久:

列写法每支设备缺为什么
soh_lag1、res_diff1shift(1) / diff()1 行需要 1 个历史点
soh_lag5 等shift(5) / shift(1).rolling(5)5 行需要 5 个历史点
*_win_mean、*_win_std、temp_win_range20shift(1).rolling(20)20 行需要 20 个历史点
*_slope20(shift(1) - shift(21)) / 2021 行需要 21 个历史点

注意最后一行:斜率列要用到 21 个点,因为它算的是"上一个窗口与再往前 20 个窗口之差":

text
slope20[t] = ( x[t-1] - x[t-21] ) / 20
                    ↑        ↑
              需要 t-1    需要 t-21  → 至少要有 21 个历史点

★ 一条可以直接抄走的判断法:

一列要回看多久,就先问"它的算式里最远的那个 shift(k) 是多少"。 缺的行数 = 最大的 k。

1,260 是怎么凑出来的 ​

真实输出:

text
=== ② 1,260 行的 NaN 是怎么凑出来的 ===
  任意一列缺 → 该行不可用:1,260 行
  其中「什么都还没有」的行(组内最前面 20 行):1,200  = 60 支 × 20 行
  其中「只缺 3 个斜率列」的行        :60  = 60 支 × 1 行
  1,200 + 60 = 1,260 ← 就是这 1,260 行
  齐全的行 = 21,239 - 1,260 = 19,979 行 —— 这是 dropna 之后的可用样本数。

★ 这个分解非常重要,因为它说明"缺失"是两段,不是一段:

text
第 1 段:每支设备开头 20 行     → 什么都没有(窗口、极差、轨迹、斜率全缺)
第 2 段:每支设备第 21 行       → 只缺那 3 个要 21 个点的斜率列
                                  ↑
        60 支设备 × 1 行 = 60 行,看起来很少,但它决定了"能不能用 21 列"

1200 + 60 = 1260  →  21,239 - 1,260 = 19,979

★ 一个容易被忽略的推论:

最"贪心"的那一列,决定了整张表的可用行数。

3 列斜率里只要 shift(21),整表就少了 60 行 —— 而如果那 3 列的窗口改短一点,这 60 行就能留下来。

这不是"少 60 行"的小事:这 60 行是每支设备的"第 21 个窗口", 也就是设备刚进入稳定期的那一行(第 5.2 节说过它属于"标签几乎不动"的开头段, 所以分数上的影响很小;但如果你要预测的正是"设备早期状态",它就是关键样本)。

7.3 丢掉的到底是什么行:是"最容易"的那一批 ​

真实输出:

text
=== ③ 丢掉的到底是哪些行:全是「最早期」那几行 ===
  被丢掉的 1,260 行的标签:
    soh  均值 0.9896   标准差 0.0068   范围 0.9664 ~ 1.0017
  留下来的 19,979 行的标签:
    soh  均值 0.9037   标准差 0.0525   范围 0.8032 ~ 0.9977
  → 丢掉的是【标签几乎不动】的开头段:标准差 0.0068 vs 0.0525。

左:14 列时序特征各缺多少行;右:dropna 丢掉的 1,260 行全部挤在标签的最右端

★ 把两行数字并排放:

被丢掉的 1,260 行留下来的 19,979 行
标签均值0.98960.9037
标签标准差0.00680.0525
标签范围0.9664 ~ 1.00170.8032 ~ 0.9977

两个结论:

① 它们不是随机丢的:全挤在标签的最高端(SOH ≈ 0.99), 也就是每支设备"还是新的"那一段。

② 它们的标签几乎是个常数(标准差 0.0068,只有整体的 13%):

text
预测一段几乎不变的标签,本来就容易
   ↓
所以 dropna 对【分数】的伤害很小
   ↓
但它把"上线第一天"这个真问题藏了起来

★ 这一节最值钱的一句:

**dropna 扔掉的不是"随机 5.9% 的样本", 而是"每一支设备的青春期"。

你的模型从此只在"成年之后的设备"上被验证过 —— 而线上每一支新设备都要经历青春期。**

⚠️ 这一段会在第 18 章(划分泄漏)和第 22 章(检查清单)里再次出现: "你的测试集里有没有设备的第一天?"是一个必须回答的问题。

7.4 dropna 的代价:19,979 行 ​

把账算清楚:

text
21,239 行(主表)
   - 1,260 行(14 列里任意一列缺) ← dropna
   = 19,979 行(可用样本)

★ 5.9% 的样本损失,在工程上算什么量级?

项目数字判断
丢失比例5.93%通常可以接受
丢失的位置每支设备最前面不可忽略(不是随机丢失)
丢失后还剩19,979 行 / 60 支设备每支设备都有剩,没有设备被整支丢掉

最后一行很重要:

60 支设备 × 每支缺 21 行 → 每支都还有 235+ 行
   ↓
没有哪支设备被"整支丢光" → 按设备划分时不会出现"某支设备只在测试集里"

★ 但如果回看长度更大,情况会变:

text
回看 20 个窗口  → 每支缺 20 行  → 每支还剩 ~330 行   可用 19,979 行
回看 100 个窗口 → 每支缺 100 行 → 每支还剩 ~250 行   可用 15,239 行(估算)
                  ↑
        "多回看一点"看起来免费,其实每加 1 个窗口回看,
        都要从每支设备身上砍掉 1 行 —— 60 行/窗口

★ 这解释了第 28 章(多窗口平均)为什么要做取舍:

回看越长,信息越"厚";但可用行数越少,而且丢的都是早期行**。 这不是一个纯技术选择,它决定了"你的模型见过多少支设备的第一天"。**

7.5 fillna(0) 为什么比 dropna 更毒 ​

先看最直接的一条。

真实输出:

text
=== ④ fillna(0) 为什么毒:0 是一个从未出现过的数 ===
  volt_win_mean    训练集范围   3.6078 ~   4.0339   填 0 = 偏离均值  -46.5 个标准差
  res_win_mean     训练集范围  35.6348 ~  85.3552   填 0 = 偏离均值   -5.2 个标准差
  temp_win_mean    训练集范围   0.8500 ~  22.7217   填 0 = 偏离均值   -2.8 个标准差
  soh_win_mean5    训练集范围   0.8068 ~   0.9994   填 0 = 偏离均值  -16.8 个标准差
  → 填 0 不是「填一个偏小的值」,而是告诉模型:
     这支设备的窗口平均电压是 0 V、上一个窗口的实测健康度是 0。
     这两个数在整张表里【一次都没出现过】。

★ "偏离 46.5 个标准差"是什么概念:

text
一个有物理含义的量(电压 3.6 ~ 4.0 V),被填成 0
   ↓
0 不在这个量的定义域里 —— 不是"小",是"不可能"
   ↓
对模型来说,这一行的输入落在了【训练数据从未覆盖的区域】

⚠️ 一个必须说清的细节:

填 0 对树模型"无害",恰恰是它危险的另一半。

树只看"这个值大于还是小于切分点", 0 落在最左边,它就当这一行走"最小值"那条路径; 而线性模型会把 0 乘以系数加进去, 一个 46σ 的异常值足以把整条回归线拽偏。

实测:线性模型掉 0.0556,树模型纹丝不动 ​

真实输出:

text
=== ⑤ 严格对照实验:两边必须用同一批测试行 ===
  按设备划分:训练 17,244 行 / 测试 3,995 行
  其中干净行:训练 16,236 行,测试 3,743 行;测试集里还有 252 行带 NaN。

  模型          处理               训练行数     测试行数     测试 R²    测试 MAE
  线性回归        dropna         16,236    3,743   0.99910   0.00125
  线性回归        fillna(0)      17,244    3,743   0.94350   0.01025
              差距                        同一批行  +0.05560   ← dropna 比 fillna(0) 高 +0.05560
  随机森林        dropna         16,236    3,743   0.99904   0.00129
  随机森林        fillna(0)      17,244    3,743   0.99903   0.00130
              差距                        同一批行  +0.00001   ← dropna 比 fillna(0) 高 +0.00001
  梯度提升        dropna         16,236    3,743   0.99911   0.00124
  梯度提升        fillna(0)      17,244    3,743   0.99905   0.00128
              差距                        同一批行  +0.00006   ← dropna 比 fillna(0) 高 +0.00006
  → 线性模型掉得很惨(回归线被 1,260 个 0 拽走了),
     两个树模型几乎没感觉 —— 所以【分数不会提醒你这件事】。

★ 这张表要横着读,也要竖着读:

模型dropnafillna(0)差距你的直觉
线性回归0.999100.94350−0.05560"填 0 果然有毒"
随机森林0.999040.99903−0.00001"好像没差"
梯度提升0.999110.99905−0.00006"好像没差"

★ 竖着读(同一列换模型):

"填 0 有毒"这件事的可见度,取决于模型:
   线性模型:严重,一眼可见
   树模型: 几乎不可见
        ↓
所以【你不能用"分数没掉"来证明"填 0 没问题"】

线性模型被拽到哪去了:看系数 ​

真实输出:

text
=== ⑥ 线性模型被拽到哪去了:看系数 ===
  特征                     dropna 系数    fillna(0) 系数
  soh_win_mean5            0.98235        -0.03339
  soh_lag1                -0.02293         0.05485
  volt_win_mean           -0.00779         0.00279
  res_win_mean             0.00002         0.00028
  截距                       0.00981         1.22955
  → 最强的那一列 soh_win_mean5 系数从 +0.98235 变成 -0.03339,
     截距从 +0.00981 涨到 +1.22955 —— 它在替那 1,260 个 0 补窟窿。

★ 这三行系数值得逐个看:

列dropnafillna(0)怎么读
soh_win_mean5+0.98235−0.03339从"用历史均值预测当前"退化为"完全没用"
soh_lag1−0.02293+0.05485符号翻转,系数失去意义
截距+0.00981+1.22955涨了 125 倍

为什么截距会暴涨:

text
模型要同时满足两组样本:
   A 组(19,979 行):soh_win_mean5 ≈ 0.90,soh ≈ 0.90
   B 组(1,260 行): soh_win_mean5 = 0,    soh ≈ 0.99
        ↓
线性模型只能给 soh_win_mean5 一个系数 w
   如果 w ≈ 1 → A 组拟合好,B 组会预测成 0(差 0.99)
   如果 w ≈ 0 → B 组不至于太离谱,A 组全靠截距撑着
        ↓
最优解落在中间:把 w 压到 0 附近,把截距抬到 1.23
        ↓
代价:A 组(真正重要的 19,979 行)的拟合也被破坏了

★ 这就是"毒"的机制:

它不只是让 1,260 行预测错, 而是让模型为了照顾这 1,260 个假数,把 19,979 个真数的规律调歪了。

⚠️ 注意 soh_win_mean5 系数从 0.98 掉到 −0.03 这件事: 这一列是整个特征集里最强的(第 15 章:删掉【轨迹】组分数掉 −0.046), 而现在它被一个填 0 的动作废掉了。

7.6 ★ 做对照实验时,两边必须用同一批行 ​

这一节是一条通用纪律,不只适用于 NaN。

先看一个"看起来对"的对照:

text
方案 A:dropna   → 19,979 行可用
方案 B:fillna(0) → 21,239 行可用
    ↓
各自训练、各自在"自己那 20% 测试集"上评分
    ↓
B 的测试集里多了 252 行(标签几乎不变的行)
    ↓
B 的分数"更高"—— 但这高出来的一点,跟"填 0 好不好"毫无关系

真实输出:

text
=== ⑦ 分数里的 Δ 可能只是「换了一批行」 ===
  fillna(0) 训练 → 在干净测试行上评分:R² 0.99903(3,743 行)
  fillna(0) 训练 → 在全部测试行上评分:R² 0.99907(3,995 行)  MAE 0.00132
  差 +0.00004 —— 这 +0.00004 里没有一丝「模型变好了」,
  只是多出来的 252 行(真值均值 0.9889,几乎是个常数)更容易而已。
  ★ 纪律:对照实验的 Δ 分数,必须建立在【同一批行】上。

★ 把两个变量的影响拆开:

对比两边测试行差的是什么Δ 测试 R²
公平:dropna vs fillna(0)同一批 3,743 行只差训练时怎么处理 NaN森林 −0.00001 / 线性 −0.05560
不公平:fillna(0) 换测试集3,743 → 3,995 行行也换了+0.00004

★ 纪律写下来就一句话:

text
对照实验里,只能有一个变量。
   行数变了 → 你测的不是特征/处理,是"题目难度"

同一批行的实操写法:

python
# ① 先用"最严的那份特征集"定出公共行
public = df.dropna(subset=SAFE_FEATURES).index
# ② 训练/测试划分只在这批行上做一次
tr, te = next(GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
              .split(df.loc[public], df.loc[public, "soh"],
                     groups=df.loc[public, "cell_id"]))
# ③ 之后所有方案都用 tr / te 这同一批索引

⚠️ 一个更容易犯的版本:

方案 A:21 列安全特征(需要 dropna)        → 19,979 行
方案 B:7 列基础特征(没有 NaN)           → 21,239 行
    ↓
"B 比 A 分数高/低" 这个结论里,
混进了"B 多了 1,260 行早期样本"这件事

★ 第 15 章(消融实验)会严格按"同一批行"来做; 这一章先把这条纪律立起来。

7.7 NaN 的四种处理,各自的代价 ​

真实输出:

text
=== ⑧ NaN 的三种处理,写进特征账本的是哪一条 ===
  ① dropna        :可用 19,979 行(丢掉 1,260 行)← 本课程口径
  ② fillna(0)     :可用 21,239 行,但 0 是编出来的(线性模型 R² 0.94350)
  ③ 模型原生缺失  :HistGB 能吃 NaN,但线性模型 / KNN 不能,换模型就炸
  ④ 用「有没有历史」当特征:新增 1 列布尔量,把「冷启动」变成模型能学的东西
  v1 表(上一本书的主表)21,239 行 —— 它本来就没有时序特征,所以从来没有这个问题。

★ 四种做法的完整对照:

做法行数优点代价什么时候用
① dropna19,979干净、任何模型都能吃丢掉每支设备的青春期默认选择
② fillna(0)21,239行数最多编造了一个不存在的数基本不用
③ 交给模型(HistGB)21,239不丢行换模型就炸、结果不可复现明确只用树模型时
④ 加 has_history 列21,239把"没有历史"变成模型能学的东西多一列、要解释推荐组合

★ 为什么 ④ 值得推荐 —— 一个思想实验:

text
做法 ①(dropna):
   模型永远见不到"新设备第一天"→ 上线遇到新设备时,它没有训练过的应对方式

做法 ④(加 has_history + 用 min_periods=1 填上能填的):
   新设备的行也在训练集里,只是 has_history=0
   → 模型会学到:"当 has_history=0 时,只看基础列"
   → 上线第一天就有明确的行为

⚠️ 但 ④ 有一个必须遵守的前提:

text
"填上的那个值"不能是编的。
   ✔ 可以填"到目前为止的真实均值"(min_periods=1,语义明确)
   ✘ 不能填 0(编的)
   ✘ 不能填"全表均值"(跨设备泄漏 + 线上拿不到)

★ 一句话总结这一章的选择:

默认 dropna;如果"上线第一天"很重要, 就加一列 has_history 并把缺失的语义显式写进特征账本。

永远不要 fillna(0) —— 除非那个量的 0 真的有意义(比如"计数为 0")。

点开:什么时候 fillna(0) 是对的?

只有一种情况:0 是这个量的合法取值,而且"缺失"在业务上就等于 0。

text
✔ 合理:df["购买次数"].fillna(0)     —— 没买过 = 0 次,语义成立
✔ 合理:df["故障次数"].fillna(0)     —— 没故障 = 0 次
✘ 不合理:df["电压"].fillna(0)       —— 没数据 ≠ 电压为 0
✘ 不合理:df["上一个窗口的实测 SOH"].fillna(0) —— 没历史 ≠ 健康度为 0

★ 一个 5 秒判断法:

把这个量的名字读出来,加上" = 0", 看这句话在你的业务上说不说得通。

"购买次数 = 0" → 说得通 ✅ "窗口平均电压 = 0 V" → 说不通 ❌

这个判断法之所以有效,是因为它要求你把"缺失"翻译成业务语言 —— 而 fillna(0) 的问题恰恰是"跳过了这次翻译"。

7.8 常见坑 ​

text
① 看到 NaN 就 fillna(0)          → 电压填成 0 V(偏离均值 46.5 个标准差)
② 以为 dropna 只丢 5.9%          → 丢的是每支设备的"青春期",不是随机样本
③ 对照实验两边行数不同            → Δ 分数里混进 Δ 样本(+0.00004 是假的)
④ 用"分数没掉"证明 fillna(0) 没事 → 树模型几乎无感(−0.00001),线性模型 −0.05560
⑤ 忘了"最贪心的那一列"决定行数    → 3 列 slope20 要 21 个历史点,全表少 60 行
⑥ 把 "NaN 少" 当成 "数据质量好"    → 第 06 章:错误版本 NaN 更少(1,140 → 19)

第 ⑥ 条值得再强调一次:

NaN 的多少不是质量指标,NaN 的【含义】才是。
   结构性缺失(过去不存在)→ 该有就有,别消掉
   数据质量缺失(掉了)    → 该查就查

7.9 完整示例与输出 ​

bash
cd code
python demos/ch07-cold-start-nan.py

真实输出(节选):

text
=== ① 14 列时序特征,各缺多少行 ===
  volt_slope20       NaN 1,260 行(每支设备缺 21 行)
  volt_win_mean      NaN 1,200 行(每支设备缺 20 行)
  soh_lag1           NaN    60 行(每支设备缺  1 行)
  14 列里有几个列一个都不缺:0

=== ② 1,260 行的 NaN 是怎么凑出来的 ===
  1,200 + 60 = 1,260 ← 就是这 1,260 行
  齐全的行 = 21,239 - 1,260 = 19,979 行

=== ③ 丢掉的到底是哪些行:全是「最早期」那几行 ===
  被丢掉的 1,260 行的标签:  soh  均值 0.9896   标准差 0.0068
  留下来的 19,979 行的标签: soh  均值 0.9037   标准差 0.0525

=== ④ fillna(0) 为什么毒:0 是一个从未出现过的数 ===
  volt_win_mean    训练集范围   3.6078 ~   4.0339   填 0 = 偏离均值  -46.5 个标准差

=== ⑤ 严格对照实验:两边必须用同一批测试行 ===
  线性回归        dropna         16,236    3,743   0.99910   0.00125
  线性回归        fillna(0)      17,244    3,743   0.94350   0.01025
  随机森林        dropna         16,236    3,743   0.99904   0.00129
  随机森林        fillna(0)      17,244    3,743   0.99903   0.00130

=== ⑦ 分数里的 Δ 可能只是「换了一批行」 ===
  在干净测试行上评分:R² 0.99903(3,743 行)
  在全部测试行上评分:R² 0.99907(3,995 行)

结论:14 列时序特征让每支设备最早的 21 行不完整,全表 1,260 行不可用,
      剩下 19,979 行。丢掉的行标签标准差只有 0.0068,是最容易的那些行。
      fillna(0) 会让线性模型的测试 R² 从 0.99910 掉到 0.94350;
      树模型几乎看不出来 —— 所以这件事只能靠【想清楚】,不能靠分数。

本章练习 ​

练习 1:14 列时序特征各缺多少行

主表里有 14 列时序特征(窗口 5 + 差分 4 + 极差 1 + 轨迹 4)。

  1. 打印单列 NaN 最少的列与最多列;
  2. 打印"任意一列缺"的行数与"14 列齐全"的行数;
  3. 用一句算式核对两个数之和等于总行数。
参考答案
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)
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"]
TRAJ = ["soh_lag1", "soh_lag5", "soh_win_mean5", "soh_win_std5"]
TS = WIN + DIFF + ["temp_win_range20"] + TRAJ

na = df[TS].isna().sum()
any_na = df[TS].isna().any(axis=1)
print(f"  时序特征列数:{len(TS)}")
print(f"  单列 NaN 最少:{na.min():,d}({na.idxmin()})")
print(f"  单列 NaN 最多:{na.max():,d}({na.idxmax()})")
print(f"  任意一列缺的行:{int(any_na.sum()):,d}")
print(f"  14 列齐全的行  :{int((~any_na).sum()):,d}")
print(f"  {len(df):,d} - {int(any_na.sum()):,d} = {int((~any_na).sum()):,d} ✓")

期望输出:

text
=== 练习 1:14 列时序特征各缺多少行 ===
  时序特征列数:14
  单列 NaN 最少:60(res_diff1)
  单列 NaN 最多:1,260(volt_slope20)
  任意一列缺的行:1,260
  14 列齐全的行  :19,979
  21,239 - 1,260 = 19,979 ✓

★ 为什么"最少 60 / 最多 1,260"这个组合是必然的:

最少 60  → 那是最短回看(shift(1))的列:每支设备只缺 1 行
最多 1,260 → 那是最长回看(shift(21))的列:每支设备缺 21 行
                ↓
一列缺多少,完全由它算式里最大的 shift(k) 决定

所以看到一列的 NaN 数,你可以反推它的回看长度:

text
NaN 数 / 设备数 = 每支缺几行 = 这一列的回看深度
练习 2:同一批行,dropna vs fillna(0)

按设备做 GroupShuffleSplit(test_size=0.2, random_state=42):

  1. 两种 NaN 处理各训一个 LinearRegression(21 列安全特征);
  2. 两边都在同一批干净测试行上评分,打印行数、R²、MAE;
  3. 打印 soh_win_mean5 的系数与截距在两种处理下的变化。

要求:解释为什么截距会大幅变化。

参考答案
python
import os

import pandas as pd
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_absolute_error, 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)
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"]
TRAJ = ["soh_lag1", "soh_lag5", "soh_win_mean5", "soh_win_std5"]
TS = WIN + DIFF + ["temp_win_range20"] + TRAJ
SAFE = ["cycle", "mean_charge_voltage_v", "mean_temp_rise_c", "resistance_mohm",
        "coulombic_eff", "cap_fade_rate_ah_per_100", "temp_std_c"] + TS

clean = df[TS].notna().all(axis=1)
tr, te = next(GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
              .split(df, df["soh"], groups=df["cell_id"]))
tr, te = pd.Index(tr), pd.Index(te)
cl_tr, cl_te = tr[clean[tr]], te[clean[te]]
d0 = df.copy()
d0[TS] = d0[TS].fillna(0.0)

for tag, data, trn in (("dropna", df, cl_tr), ("fillna(0)", d0, tr)):
    m = LinearRegression().fit(data.loc[trn, SAFE], data.loc[trn, "soh"])
    pred = m.predict(data.loc[cl_te, SAFE])
    print(f"  {tag:<10s} 训练 {len(trn):>6,d} 行  测试 {len(cl_te):>6,d} 行  "
          f"R² {r2_score(df.loc[cl_te, 'soh'], pred):.5f}  "
          f"MAE {mean_absolute_error(df.loc[cl_te, 'soh'], pred):.5f}")

m_ok = LinearRegression().fit(df.loc[cl_tr, SAFE], df.loc[cl_tr, "soh"])
m_bad = LinearRegression().fit(d0.loc[tr, SAFE], d0.loc[tr, "soh"])
print(f"  soh_win_mean5 系数:dropna {m_ok.coef_[SAFE.index('soh_win_mean5')]:+.5f}"
      f" → fillna(0) {m_bad.coef_[SAFE.index('soh_win_mean5')]:+.5f}")
print(f"  截距              :dropna {m_ok.intercept_:+.5f}"
      f" → fillna(0) {m_bad.intercept_:+.5f}")

期望输出:

text
=== 练习 2:同一批行,dropna vs fillna(0) ===
  dropna     训练 16,236 行  测试  3,743 行  R² 0.99910  MAE 0.00125
  fillna(0)  训练 17,244 行  测试  3,743 行  R² 0.94350  MAE 0.01025
  soh_win_mean5 系数:dropna +0.98235 → fillna(0) -0.03339
  截距              :dropna +0.00981 → fillna(0) +1.22955

③ 截距为什么会涨 125 倍:

训练集里现在有两组互相矛盾的样本:
   A 组(16,236 行):soh_win_mean5 ≈ 0.90  →  soh ≈ 0.90
   B 组( 1,008 行):soh_win_mean5 = 0      →  soh ≈ 0.99
                          ↑ 0 是编的,但模型不知道

模型只有一个 w 和一个 b:
   想拟合 A 组 → w ≈ 1, b ≈ 0     (B 组预测成 0,错得很离谱)
   想照顾 B 组 → w ≈ 0, b ≈ 1.23  (A 组只剩截距撑)
                       ↓
   最小二乘找折中 → w 被压到 -0.03,b 抬到 1.23
                       ↓
   代价是 A 组(真正重要的那些行)也拟合不好了:R² 0.99910 → 0.94350

★ 这就是 fillna(0) 的"毒"的准确机制:

它不是让 1,260 行预测错,而是让模型为了迁就这 1,260 个假数, 把 16,236 个真数上学到的规律调歪了。

练习 3:Δ 分数里混进了 Δ 样本

把 fillna(0) 训出来的模型分别放在两批行上评分:

  1. 干净测试行;
  2. 全部测试行(含被填成 0 的那几行)。

打印两边的行数、R²,以及多出来那批行的标签均值,说明这个 Δ 分数能不能用。

参考答案
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)
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"]
TRAJ = ["soh_lag1", "soh_lag5", "soh_win_mean5", "soh_win_std5"]
TS = WIN + DIFF + ["temp_win_range20"] + TRAJ
SAFE = ["cycle", "mean_charge_voltage_v", "mean_temp_rise_c", "resistance_mohm",
        "coulombic_eff", "cap_fade_rate_ah_per_100", "temp_std_c"] + TS

clean = df[TS].notna().all(axis=1)
tr, te = next(GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
              .split(df, df["soh"], groups=df["cell_id"]))
tr, te = pd.Index(tr), pd.Index(te)
cl_te = te[clean[te]]
d0 = df.copy()
d0[TS] = d0[TS].fillna(0.0)
m_bad = LinearRegression().fit(d0.loc[tr, SAFE], d0.loc[tr, "soh"])

print(f"  fillna(0) 模型在干净测试行上:R² "
      f"{r2_score(df.loc[cl_te, 'soh'], m_bad.predict(d0.loc[cl_te, SAFE])):.5f}"
      f"({len(cl_te):,d} 行)")
print(f"  fillna(0) 模型在全部测试行上:R² "
      f"{r2_score(df.loc[te, 'soh'], m_bad.predict(d0.loc[te, SAFE])):.5f}"
      f"({len(te):,d} 行)")
print(f"  多出来的 {len(te) - len(cl_te):,d} 行,标签均值 "
      f"{df.loc[te[~clean[te]], 'soh'].mean():.4f}(全表 {df['soh'].mean():.4f})")

期望输出:

text
=== 练习 3:Δ 分数里混进了 Δ 样本 ===
  fillna(0) 模型在干净测试行上:R² 0.94350(3,743 行)
  fillna(0) 模型在全部测试行上:R² 0.94626(3,995 行)
  多出来的 252 行,标签均值 0.9889(全表 0.9088)
  → 两个分数都叫「测试 R²」,但分母不是同一道题 —— 不能直接比。

★ 为什么这 252 行"更容易":

它们的标签均值 0.9889,标准差只有 0.0068
   ↓
相当于在问:「这几台新设备现在是不是 0.99 左右?」
   ↓
即使模型只回答"0.99",也基本都对
   ↓
所以分数被"稀释"上去了:0.94350 → 0.94626

⚠️ 注意这个方向的陷阱:

在这个例子里,"换批行"让分数变【高】了。 于是你会得出一个完全错误的结论: "填 0 好像还行,而且行数还更多"。

这正是第 07 章要防的那种"看起来对但其实错"。

面向工程实践:NaN 是一条必须写进文档的信息 ​

① 特征账本里必须有的三栏(第 16 章会做成表格)

栏例
缺失语义volt_win_mean 的 NaN = "这支设备还没有 20 个窗口的历史"
缺失比例1,200 / 21,239 = 5.65%
处理方式dropna(训练/评估);线上用 has_history 标记并退回基础模型

★ 为什么必须有"缺失语义"这一栏:

只写"缺失 5.65%"         → 下一个人会顺手 fillna(0)
写上"过去还不存在"        → 下一个人知道这不是数据质量问题

② 线上会遇到的三种"没有历史"

场景表现处理
新设备上线所有回看列都是空has_history=0,退回基础模型 / 拒识
设备停产又复产中间断了很久判断"断多久算新设备"并写进文档
数据回补历史突然补齐特征需要重算,不是只补原始数据

★ 第三种最容易被忽略:

"我们把去年的数据补进来了"
   ↓
原始表多了一批行 —— 但对已上线设备来说,
它们的【窗口特征】需要重新计算,否则前后的口径不一致

③ 一个可复制的 NaN 处理模板

python
def handle_missing(df, ts_cols, policy="dropna"):
    """policy: dropna / keep / flag。绝不提供 fillna(0)。"""
    if policy == "dropna":
        return df.dropna(subset=ts_cols)
    if policy == "flag":
        # 把"有没有历史"变成模型能学的东西
        df["has_history"] = df[ts_cols].notna().all(axis=1).astype(int)
        return df
    if policy == "keep":
        return df          # 交给能吃 NaN 的模型(HistGB)
    raise ValueError(f"未知策略:{policy}")

★ 注意这个模板里没有 fillna(0) 这个选项 ——

不是"不推荐",而是"不提供"。 一个在接口层面不存在的选项,比一条写在注释里的纪律更可靠 (这正是第 06 章"把容易忘变成不可能忘"的同一条思路)。

④ 这一章的坑在整本书里的位置

第 05 章:rolling 的语义
第 06 章:rolling 的边界(分组 / 排序)
第 07 章:rolling 的副作用(冷启动 NaN)   ← 你在这里
第 08 章:rolling 的另一类衍生(差分 / 斜率)
第 10 章:把"缺失含义"和"时间方向"一起收进检查表
第 16 章:写进特征账本(交付物)

下一章:第 08 章 · 差分与斜率:变化量比绝对值更早报警