第 07 章 · 冷启动 NaN 与 dropna 的代价
这一章要解决什么:时序特征有一个躲不掉的副作用 —— 每支设备最早的那几行没有历史,只能是 NaN。 这一章算清这笔账:缺多少行、丢掉的是哪些行、 以及为什么
fillna(0)比dropna更毒。最容易踩的坑: ① 看到 NaN 就
fillna(0)—— 等于告诉模型"这台设备的电压是 0 V"; ② 做 A/B 对照时两边用了不同行数 —— Δ 分数里混进了 Δ 样本; ③ 把这条纪律写进代码注释,而不是写进特征账本(第 16 章会来收)。
本章你需要完成的代码:demos/ch07-cold-start-nan.py
7.1 时序特征的副作用:开头没有历史
先回到"窗口"这个动作本身:
第 5.3 节说过:rolling(W) 默认【攒够 W 个数才给结果】,攒不够就是 NaN
↓
但窗口表的第一个窗口,前面【一个窗口都没有】
↓
所以每一支设备最前面的 W 个窗口,必然是 NaN这件事有个正式名字:冷启动(cold start)。
新设备刚上线
↓
它没有过去 —— 不是"数据丢了",而是"过去【还不存在】"
↓
所有"回看型"特征在这一刻都是未知的★ 关键区分(这一章反复要用):
| NaN 的含义 | 例子 | 该怎么处理 |
|---|---|---|
| 过去还不存在(结构性缺失) | 设备第 1 个窗口 | 留下来当"没有历史",或显式标记 |
| 数据丢了(数据质量问题) | 传感器掉线 | 补测、丢弃、或专门排查 |
| 不该有值(业务上不存在) | 未开卡的账户余额 | 单独一档 |
这三类混在一起,就是 fillna(0) 最典型的犯错现场:
把"过去还不存在"当成"数值是 0"
↓
填完之后,缺失率从 5.9% 变成 0%
↓
报表上"数据质量提升"了这一章要把这笔交易的真实代价摊开。
7.2 NaN 是从哪来的:14 列,五种回看长度
主表里有 14 列时序特征(窗口 5 + 差分 4 + 极差 1 + 轨迹 4)。
真实输出:
=== ① 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_diff1 | shift(1) / diff() | 1 行 | 需要 1 个历史点 |
soh_lag5 等 | shift(5) / shift(1).rolling(5) | 5 行 | 需要 5 个历史点 |
*_win_mean、*_win_std、temp_win_range20 | shift(1).rolling(20) | 20 行 | 需要 20 个历史点 |
*_slope20 | (shift(1) - shift(21)) / 20 | 21 行 | 需要 21 个历史点 |
注意最后一行:斜率列要用到 21 个点,因为它算的是"上一个窗口与再往前 20 个窗口之差":
slope20[t] = ( x[t-1] - x[t-21] ) / 20
↑ ↑
需要 t-1 需要 t-21 → 至少要有 21 个历史点★ 一条可以直接抄走的判断法:
一列要回看多久,就先问"它的算式里最远的那个
shift(k)是多少"。 缺的行数 = 最大的 k。
1,260 是怎么凑出来的
真实输出:
=== ② 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 之后的可用样本数。★ 这个分解非常重要,因为它说明"缺失"是两段,不是一段:
第 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 丢掉的到底是什么行:是"最容易"的那一批
真实输出:
=== ③ 丢掉的到底是哪些行:全是「最早期」那几行 ===
被丢掉的 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。
★ 把两行数字并排放:
| 被丢掉的 1,260 行 | 留下来的 19,979 行 | |
|---|---|---|
| 标签均值 | 0.9896 | 0.9037 |
| 标签标准差 | 0.0068 | 0.0525 |
| 标签范围 | 0.9664 ~ 1.0017 | 0.8032 ~ 0.9977 |
两个结论:
① 它们不是随机丢的:全挤在标签的最高端(SOH ≈ 0.99), 也就是每支设备"还是新的"那一段。
② 它们的标签几乎是个常数(标准差 0.0068,只有整体的 13%):
预测一段几乎不变的标签,本来就容易
↓
所以 dropna 对【分数】的伤害很小
↓
但它把"上线第一天"这个真问题藏了起来★ 这一节最值钱的一句:
**
dropna扔掉的不是"随机 5.9% 的样本", 而是"每一支设备的青春期"。你的模型从此只在"成年之后的设备"上被验证过 —— 而线上每一支新设备都要经历青春期。**
⚠️ 这一段会在第 18 章(划分泄漏)和第 22 章(检查清单)里再次出现: "你的测试集里有没有设备的第一天?"是一个必须回答的问题。
7.4 dropna 的代价:19,979 行
把账算清楚:
21,239 行(主表)
- 1,260 行(14 列里任意一列缺) ← dropna
= 19,979 行(可用样本)★ 5.9% 的样本损失,在工程上算什么量级?
| 项目 | 数字 | 判断 |
|---|---|---|
| 丢失比例 | 5.93% | 通常可以接受 |
| 丢失的位置 | 每支设备最前面 | 不可忽略(不是随机丢失) |
| 丢失后还剩 | 19,979 行 / 60 支设备 | 每支设备都有剩,没有设备被整支丢掉 |
最后一行很重要:
60 支设备 × 每支缺 21 行 → 每支都还有 235+ 行
↓
没有哪支设备被"整支丢光" → 按设备划分时不会出现"某支设备只在测试集里"★ 但如果回看长度更大,情况会变:
回看 20 个窗口 → 每支缺 20 行 → 每支还剩 ~330 行 可用 19,979 行
回看 100 个窗口 → 每支缺 100 行 → 每支还剩 ~250 行 可用 15,239 行(估算)
↑
"多回看一点"看起来免费,其实每加 1 个窗口回看,
都要从每支设备身上砍掉 1 行 —— 60 行/窗口★ 这解释了第 28 章(多窗口平均)为什么要做取舍:
回看越长,信息越"厚";但可用行数越少,而且丢的都是早期行**。 这不是一个纯技术选择,它决定了"你的模型见过多少支设备的第一天"。**
7.5 fillna(0) 为什么比 dropna 更毒
先看最直接的一条。
真实输出:
=== ④ 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 个标准差"是什么概念:
一个有物理含义的量(电压 3.6 ~ 4.0 V),被填成 0
↓
0 不在这个量的定义域里 —— 不是"小",是"不可能"
↓
对模型来说,这一行的输入落在了【训练数据从未覆盖的区域】⚠️ 一个必须说清的细节:
填 0 对树模型"无害",恰恰是它危险的另一半。
树只看"这个值大于还是小于切分点", 0 落在最左边,它就当这一行走"最小值"那条路径; 而线性模型会把 0 乘以系数加进去, 一个 46σ 的异常值足以把整条回归线拽偏。
实测:线性模型掉 0.0556,树模型纹丝不动
真实输出:
=== ⑤ 严格对照实验:两边必须用同一批测试行 ===
按设备划分:训练 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 拽走了),
两个树模型几乎没感觉 —— 所以【分数不会提醒你这件事】。★ 这张表要横着读,也要竖着读:
| 模型 | dropna | fillna(0) | 差距 | 你的直觉 |
|---|---|---|---|---|
| 线性回归 | 0.99910 | 0.94350 | −0.05560 | "填 0 果然有毒" |
| 随机森林 | 0.99904 | 0.99903 | −0.00001 | "好像没差" |
| 梯度提升 | 0.99911 | 0.99905 | −0.00006 | "好像没差" |
★ 竖着读(同一列换模型):
"填 0 有毒"这件事的可见度,取决于模型:
线性模型:严重,一眼可见
树模型: 几乎不可见
↓
所以【你不能用"分数没掉"来证明"填 0 没问题"】线性模型被拽到哪去了:看系数
真实输出:
=== ⑥ 线性模型被拽到哪去了:看系数 ===
特征 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 补窟窿。★ 这三行系数值得逐个看:
| 列 | dropna | fillna(0) | 怎么读 |
|---|---|---|---|
soh_win_mean5 | +0.98235 | −0.03339 | 从"用历史均值预测当前"退化为"完全没用" |
soh_lag1 | −0.02293 | +0.05485 | 符号翻转,系数失去意义 |
| 截距 | +0.00981 | +1.22955 | 涨了 125 倍 |
为什么截距会暴涨:
模型要同时满足两组样本:
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。
先看一个"看起来对"的对照:
方案 A:dropna → 19,979 行可用
方案 B:fillna(0) → 21,239 行可用
↓
各自训练、各自在"自己那 20% 测试集"上评分
↓
B 的测试集里多了 252 行(标签几乎不变的行)
↓
B 的分数"更高"—— 但这高出来的一点,跟"填 0 好不好"毫无关系真实输出:
=== ⑦ 分数里的 Δ 可能只是「换了一批行」 ===
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 |
★ 纪律写下来就一句话:
对照实验里,只能有一个变量。
行数变了 → 你测的不是特征/处理,是"题目难度"同一批行的实操写法:
# ① 先用"最严的那份特征集"定出公共行
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 的四种处理,各自的代价
真实输出:
=== ⑧ NaN 的三种处理,写进特征账本的是哪一条 ===
① dropna :可用 19,979 行(丢掉 1,260 行)← 本课程口径
② fillna(0) :可用 21,239 行,但 0 是编出来的(线性模型 R² 0.94350)
③ 模型原生缺失 :HistGB 能吃 NaN,但线性模型 / KNN 不能,换模型就炸
④ 用「有没有历史」当特征:新增 1 列布尔量,把「冷启动」变成模型能学的东西
v1 表(上一本书的主表)21,239 行 —— 它本来就没有时序特征,所以从来没有这个问题。★ 四种做法的完整对照:
| 做法 | 行数 | 优点 | 代价 | 什么时候用 |
|---|---|---|---|---|
① dropna | 19,979 | 干净、任何模型都能吃 | 丢掉每支设备的青春期 | 默认选择 |
② fillna(0) | 21,239 | 行数最多 | 编造了一个不存在的数 | 基本不用 |
| ③ 交给模型(HistGB) | 21,239 | 不丢行 | 换模型就炸、结果不可复现 | 明确只用树模型时 |
④ 加 has_history 列 | 21,239 | 把"没有历史"变成模型能学的东西 | 多一列、要解释 | 推荐组合 |
★ 为什么 ④ 值得推荐 —— 一个思想实验:
做法 ①(dropna):
模型永远见不到"新设备第一天"→ 上线遇到新设备时,它没有训练过的应对方式
做法 ④(加 has_history + 用 min_periods=1 填上能填的):
新设备的行也在训练集里,只是 has_history=0
→ 模型会学到:"当 has_history=0 时,只看基础列"
→ 上线第一天就有明确的行为⚠️ 但 ④ 有一个必须遵守的前提:
"填上的那个值"不能是编的。
✔ 可以填"到目前为止的真实均值"(min_periods=1,语义明确)
✘ 不能填 0(编的)
✘ 不能填"全表均值"(跨设备泄漏 + 线上拿不到)★ 一句话总结这一章的选择:
默认
dropna;如果"上线第一天"很重要, 就加一列has_history并把缺失的语义显式写进特征账本。永远不要
fillna(0)—— 除非那个量的 0 真的有意义(比如"计数为 0")。
点开:什么时候 fillna(0) 是对的?
只有一种情况:0 是这个量的合法取值,而且"缺失"在业务上就等于 0。
✔ 合理: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 常见坑
① 看到 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 完整示例与输出
cd code
python demos/ch07-cold-start-nan.py真实输出(节选):
=== ① 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)。
- 打印单列 NaN 最少的列与最多列;
- 打印"任意一列缺"的行数与"14 列齐全"的行数;
- 用一句算式核对两个数之和等于总行数。
参考答案
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} ✓")期望输出:
=== 练习 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 数,你可以反推它的回看长度:
NaN 数 / 设备数 = 每支缺几行 = 这一列的回看深度练习 2:同一批行,dropna vs fillna(0)
按设备做 GroupShuffleSplit(test_size=0.2, random_state=42):
- 两种 NaN 处理各训一个
LinearRegression(21 列安全特征); - 两边都在同一批干净测试行上评分,打印行数、R²、MAE;
- 打印
soh_win_mean5的系数与截距在两种处理下的变化。
要求:解释为什么截距会大幅变化。
参考答案
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}")期望输出:
=== 练习 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) 训出来的模型分别放在两批行上评分:
- 干净测试行;
- 全部测试行(含被填成 0 的那几行)。
打印两边的行数、R²,以及多出来那批行的标签均值,说明这个 Δ 分数能不能用。
参考答案
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})")期望输出:
=== 练习 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 处理模板
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 章:写进特征账本(交付物)