Skip to content

第 28 章 · 多窗口平均:三个回看窗口,以及一个最容易漏的口径陷阱 ​

这一章要解决什么:第 5 章起,"回看多少个窗口"就是一个反复出现的旋钮。 这一章把它当成超参系统地试一遍,并顺手做一次集成(多窗口平均)。

最容易踩的坑: ① 三张特征表的可用样本数不一样(20,879 / 19,979 / 18,179)—— 直接比"测试 R²"时,Δ 里混进了 Δ 样本; ② 对长度不同的预测数组直接取平均 → ValueError; ③ 以为"窗口越长越好"或者"越短越好"——本数据上三者几乎一样。

本章你需要完成的代码:demos/ch28-multi-window.py

本章的数字口径:soh_features_v2.csv 的 21 列安全特征, 回看窗口分别取 5 / 20 / 50(shift(1).rolling(w),轨迹列固定 shift(1) / shift(5)), dropna 后可用样本 20,879 / 19,979 / 18,179 行,共同行 18,179; 在共同行上按设备 GroupShuffleSplit(test_size=0.2, random_state=42) → 训练 14,796 / 测试 3,383 行。

28.1 回看窗口:那个一直在用的旋钮 ​

先回顾:什么叫"回看 w 个窗口"

时间轴(每支设备自己的窗口序列,1 窗口 = 3 圈)
        ┌──── w 个窗口 ────┐
 C001   ● ● ● ● ● ● ● ● ● ● ◀ 当前窗口
        └─── 只用这些算统计量 ───┘
              (shift(1) 之后,所以不含当前行)

代码上就是一句话里的那个 w:

python
df["volt_win_mean"] = g["mean_charge_voltage_v"].transform(
    lambda s: s.shift(1).rolling(w).mean())
#                                    ↑ 这个 w 就是"回看多少个窗口"

★ 这个 w 同时控制两件相反的事

w 变大影响
统计量更平滑噪声被平均掉 → 特征更稳
冷启动更晚每支设备最前面更多的行没有历史 → 可用样本更少
反应更迟钝近期的突变被稀释 → 可能丢掉真实信号

★ 所以它是一个典型的"超参":没有先验答案,必须试。

★ 但试的时候有一个陷阱 —— 就是本章的主角

w=5  :可用 20,879 行
w=20 :可用 19,979 行
w=50 :可用 18,179 行
      ↑
三个数字【不一样】
   ↓
如果各表用【自己那 20%】当测试集:
  测试集长度分别是 4,176 / 3,996 / 3,636 行
  而且它们【不是同一批行】
      ↓
你比较的"哪个 w 更好"里,混进了"哪一档多吃了哪些更难的样本"

★ 本章的核心纪律:

先取"三个窗口都能用"的共同行,在共同行上划分, 然后三个窗口用同一个测试集、同一套测试下标。 各窗口模型仍用自己的特征表训练,但只在共同测试下标上预测、再取平均。

点开:为什么"共同行"要用 Index.intersection,而不是按位置对齐?

因为三张表的"行"不是同一个编号体系。

text
X.loc[某个下标] 里的下标,是【原始 v2 表的行号】(reset_index 之前带洞的 index)
   ↓
w=5 的表里有一行 index=100,w=50 的表里可能根本没有 index=100
   ↓
所以"第 100 行"在三张表里指的是【三个不同的东西】

★ 一个可以自己验证的小实验

python
t5, t20, t50 = tabs[5], tabs[20], tabs[50]
print("三张表的 index 前 5 个:")
print("  w=5 :", t5.index[:5].tolist())
print("  w=20:", t20.index[:5].tolist())
print("  w=50:", t50.index[:5].tolist())
print(f"
三张表 index 的交集大小 = {len(t5.index.intersection(t20.index).intersection(t50.index))}")

典型输出(真实实测):

text
三张表的 index 前 5 个:
  w=5 : [6, 7, 8, 9, 10]
  w=20: [21, 22, 23, 24, 25]
  w=50: [51, 52, 53, 54, 55]

三张表 index 的交集大小 = 18179

★ 这张输出揭示了什么?

三张表的"第一行"分别是原始表的第 6 / 21 / 51 行
   ↓
(不是 0/20/50 —— 因为每台设备的窗口数不同,
   `dropna` 之后第一台设备剩下了几行,全局行号会整体前移)
   ↓
但规律是同一个:w 越大,第一行的全局行号越大
   ↓
因为 w=20 的表丢掉了每台设备最早 20 个窗口,w=50 丢掉了最早 50 个
   ↓
所以【下标本身就是"这行有多少历史"的编码】
   ↓
如果我们按位置对齐("都取前 18,179 行"):
   = 把 w=5 表里【有 5 个窗口历史】的行,
     和 w=50 表里【有 50 个窗口历史】的行,当成同一行
      ↓
【按位置对齐 = 拿不同历史长度的样本硬凑 = 结论不可解释】

★ 一条更根本的纪律

当两份数据的"行"是通过不同的筛选条件得到的时候, 只能用【标签/键】对齐,不能用【位置】对齐。

这和 SQL 里 JOIN ON key 优于 ORDER BY + LIMIT 是同一个道理。

★ 顺便说一个容易忽略的细节

python
# ✅ 交集之后排序(保证划分的确定性)
common = common.sort_values()

# ❌ 不排序
common = t5.index.intersection(t50.index)
#   → Index.intersection 的返回顺序依赖内部算法
#   → 同一份数据两次运行可能给出不同的顺序
#   → GroupShuffleSplit 的结果就会不一样

本 demo 里加了 .sort_values() —— 这是"可复现"要求下必须做的一步,虽然它看起来只是"整理一下顺序"。

28.2 ★ 实测一:三套表的可用样本数 ​

真实输出(demos/ch28-multi-window.py):

text
=== ① 现场造三套特征表:回看 5 / 20 / 50 个窗口 ===

      回看窗口       可用样本数      每台平均       最早可用行
         5       20879       348         229
        20       19979       333         214
        50       18179       303         184

★ 回看窗口越长,起步越晚:20 个窗口 ≈ 60 圈,50 个窗口 ≈ 150 圈。
  最多的一档(回看 50)比最少的一档(回看 5)少 2700 行(12.9%)。

三张表的第一行分别是原始表的第几行(= 这行有多少历史):
  回看  5:index 前 5 个 = [6, 7, 8, 9, 10]
  回看 20:index 前 5 个 = [21, 22, 23, 24, 25]
  回看 50:index 前 5 个 = [51, 52, 53, 54, 55]

三个窗口都能用的【共同行】 = 18179
  回看  5:20879 行里有 18179 行可以直接比(另有 2700 行只在它自己这一档能用)
  回看 20:19979 行里有 18179 行可以直接比(另有 1800 行只在它自己这一档能用)
  回看 50:18179 行里有 18179 行可以直接比(另有 0 行只在它自己这一档能用)

★ 逐行读

第一:可用样本数的关系是"包含"

w=5  :20,879 行   ← 最多(起步最早)
w=20 :19,979 行   ← 少 900 行
w=50 :18,179 行   ← 再少 1,800 行
   ↓
总数 21,239 行
w=5  丢掉 360 行(每台 5 个窗口 ≈ 15 圈里的前 5 个窗口)
w=20 丢掉 1,260 行(第 4 章实测的冷启动 NaN 数)
w=50 丢掉 3,060 行

第二:共同行 = 最少的那一档 = 18,179

common = 三个 index 的交集
   ↓
因为"可用行"在三个 w 上是【嵌套】的(w 越大越少)
   ↓
所以共同行恰好等于 w=50 的可用行数
   ↓
⚠️ 本例的"嵌套"是结构性的(同一支设备的窗口按时间排序),
   但代码里不能依赖这一点 —— 老老实实求交集(`Index.intersection`)

第三:最早可用行 那一列说的是每台设备最少能用多少行

w=5  → 每台最少 229 行
w=50 → 每台最少 184 行
   ↓
差 45 行 —— 就是"起步晚了 45~50 个窗口"(每台设备的窗口总数不同,这个差也略有浮动)
   ↓
348 / 333 / 303 是平均值,最小值和平均值差得挺远
   ↓
【每台设备的窗口总数本来就不一样(235 ~ 464)】——
  这也是"分组划分"必须存在的原因

★ 产出的图(运行 demo 生成 outputs/ch28-multi-window.png):

左:三套表的可用样本数 vs 共同行数;右:两种口径下的测试 R²

左图:蓝柱(该表可用)随 w 变大而降低,绿柱(共同行)是一条水平线 —— 这就是"必须在共同行上比"的图示。

28.3 ★ 实测二:正确口径的比较 ​

真实输出:

text
=== ② 正确口径:只在共同行上划分,三档用同一个测试集 ===
训练 14796 行 / 测试 3383 行(三个窗口共用这一份划分)

    回看      训练 R²      测试 R²      耗时
     5    0.99985    0.99889   23.0s
    20    0.99985    0.99890   21.8s
    50    0.99986    0.99891   21.8s
    平均          —    0.99891

★ 逐行读:三个数字几乎是同一个数字

回看  5 → 0.99889
回看 20 → 0.99890   (+0.00001)
回看 50 → 0.99891   (+0.00001)
   ↓
极差 = 0.00002
   ↓
而分组 CV 的折间标准差是 0.0003 量级(第 26 章实测)
   ↓
【0.00002 比噪声小一个数量级 → 三个窗口在统计上无法区分】

★ 训练 R² 三档几乎完全一样(0.99985 / 0.99985 / 0.99986)说明什么?

三张特征表"信息含量"几乎相同
   ↓
回看 50 个窗口并没有比回看 5 个窗口多出有用的信息
   ↓
为什么?因为本数据里真正值钱的是【4 列历史实测 SOH】
  它们与 w 无关(soh_lag1 / soh_lag5 / soh_win_mean5 / soh_win_std5)
  而窗口统计/斜率那 17 列对分数的贡献本来就接近 0(第 15 章消融实测)
   ↓
【这又是一次"信息上限由特征决定"的实证】

★ 平均之后呢?

三档平均 → 0.99891
最好的单档(w=50)→ 0.99891
   ↓
差 0.00000
   ↓
⚠️ 因为三档的预测几乎完全一样(相关性太高),平均它们等于取其中一个
   ↓
【融合的前提是"成员之间要有差异"】——
   三个几乎相同的模型,融合收益必然是 0

★ 耗时那一列这次三档几乎一样(23.0 / 21.8 / 21.8 s)

⚠️ 本次三档耗时几乎相同(差 < 6%)—— 和另一次实测(5.5 / 5.7 / 14.8 s)不同
   ↓
差异的原因:两次是在同一台机器的【不同负载】下跑的
   (这台机器同时还在跑别的任务,load average > 20)
   ↓
【绝对耗时在这台机器上不可复现;能复现的是分数(0.99889 ~ 0.99891)】
   ↓
⚠️ 工程上仍然要算这笔账:**回看窗口变大 → 样本变少,但特征的计算成本上升**
   (w=50 要为每一行算 50 个历史值的统计量,w=5 只算 5 个)
   → 上线前按自己的机器实测一次,不要照搬别人的耗时

28.4 ★ 实测三:反面口径 —— 各表各划各的 ​

这一节是本章最重要的部分。 把 ③ 的代码写成"每张表各自划分、各自报分":

真实输出:

text
=== ③ ⚠️ 反面口径:各表用自己的行数报分数(Δ 里混进了 Δ 样本)===

    回看      该表可用            该表自己划分的测试行      测试 R²
     5     20879                  3923    0.99908
    20     19979                  3743    0.99904
    50     18179                  3383    0.99891

反面口径的 Δ(50 窗口 − 5 窗口) = -0.00016
共同口径的 Δ(同一件事)          = +0.00002
两者相差 0.00018 —— 这就是「Δ 样本」混进「Δ 分数」的量。

★ 注意:两个口径给出的 Δ【符号相反】

共同口径:Δ = +0.00002  →  "w=50 略好一点点"
反面口径:Δ = −0.00016  →  "w=50 反而更差"
   ↓
⚠️ 同一个问题、同一批模型,只因为【测试集不是同一批行】,结论的符号就翻了
   ↓
绝对值都不大(0.00002 / 0.00016),但方向相反这件事本身就是警告:
   你无法从反面口径里得到"哪个 w 更好"的答案
   ↓
原因:反面口径下 w=5 那一档的测试集多了 2,700 行【最早的】样本
     (3923 行 vs 3383 行)
   ↓
【这 0.00017 的差(0.99908 vs 0.99891)完全来自"样本构成",
  和"w 的特征好坏"没有关系】

★ 一个必须看的对照:换成 17 列(去掉历史 SOH)时,口径的差别立刻露头

同口径实测(同一份数据、同一个脚本,只换特征列):

text
17 列(去掉历史实测 SOH):
  回看  5(该表可用 20879 行)  测试 R² = 0.95132
  回看 20(该表可用 19979 行)  测试 R² = 0.95076
  回看 50(该表可用 18179 行)  测试 R² = 0.95479
  三个窗口简单平均(共同测试 3383 行)  0.95398
      ↑
  注意这里的 Δ:从 0.95132 到 0.95479,差 0.00347
  而这三档的测试集【行数不同、内容不同】
      ↓
  这 0.00347 里,有多少是"w=50 更好",有多少是"w=50 少吃了 2,700 行难样本"?
  【在这个口径下无法回答】

★ 那 2,700 行"多出来的"是什么?

w=5 能覆盖 20,879 行,比 w=50 多 2,700 行
   ↓
多出来的那 2,700 行是哪来的?—— 每支设备【最早的】那一段
   ↓
那一段的特点:
  · 历史只有几条 → 窗口统计/斜率最不可靠
  · 轨迹特征(soh_lag1 等)也在刚起步的位置
      ↓
【短窗口能覆盖更早的样本 —— 这在业务上是优点,在比分数时是干扰】
   ↓
⚠️ 本数据实测的方向值得记住:
   反面口径下 w=5 的分数【更高】(0.99908 > 0.99891)
   ↓
   为什么"多吃了早期样本"反而分数更高?
   因为那批早期样本的 soh 更接近 1(退化才刚刚开始),
   预测它们的绝对偏差本来就小
   → 换句话说:这批样本的标签方差小,R² 天然容易高
      ↓
【不管方向是正是反,"Δ 里混进 Δ 样本"这件事都让 Δ 失去意义】
   这就是为什么必须固定共同测试集

★ 正确的写法(把两件事分开)

python
# ① 先在共同行上比较 → 回答"哪个 w 的特征更好"
common = None
for w in (5, 20, 50):
    common = tabs[w].index if common is None else common.intersection(tabs[w].index)
# ... 在 common 上划分、各档预测、各档报分

# ② 再单独报告"各档能覆盖多少样本" → 这是【工程权衡】,不是分数
for w in (5, 20, 50):
    print(f"w={w}: 可用 {len(tabs[w]):5d} 行(比共同行多 {len(tabs[w]) - len(common)} 行)")

★ 一句话总结:

"哪个模型更好"和"哪个模型能用在更多样本上"是两个问题。 混在一起比,得到的 Δ 就没有意义。

28.5 常见坑 ​

text
① 不同回看窗口的行数不同,却直接对预测数组取平均
   → ValueError: setting an array element with a sequence
② 各表各自划分、各自报分 → Δ 分数里混进 Δ 样本
③ 忘了 dropna 的对齐:rolling 产生的 NaN 会让 index 错位
④ 以为"窗口越长越好"或"越短越好" → 本数据上是 0.99889 ~ 0.99891(无差别)
⑤ 只看分数不看"覆盖多少样本" → 短窗口的额外行在业务上可能是最值钱的
⑥ 对三个几乎相同的模型做"融合" → 收益必然是 0(成员之间要有差异才能融合)
⑦ 忘了:三张表的 label 必须一致(同一个 soh 列,没有重新计算)

★ ① 报错信息长什么样

text
ValueError: setting an array element with a sequence.
The requested array has an inhomogeneous shape after 1 dimensions.

它的意思是:np.mean([pred_5, pred_20, pred_50], axis=0) 里的三个数组长度不一样, NumPy 想把它变成一个 2 维数组,但发现"第二维长度不一致",所以拒绝。

正确的写法(本章 demo 用的):

python
# 三个窗口各自用【自己那套特征】训练,但只在【共同测试下标】上预测
preds = {}
for w in (5, 20, 50):
    X, y, _g = tabs[w]
    m = rf().fit(X.loc[idx_tr], y.loc[idx_tr])
    preds[w] = m.predict(X.loc[idx_te])       # ← 用同一批 idx_te
avg = np.mean(list(preds.values()), axis=0)   # 长度一致,可以平均

★ ② 为什么"Δ 样本"这么难发现?

因为报出来的都是【测试 R²】这一个数
   ↓
你看到 0.95132 → 0.95479,会自然解释成"w=50 的特征更好"
   ↓
但分母(测试集)已经变了:
  0.95132 是在 4,176 行(含最难的早期行)上算的
  0.95479 是在 3,636 行(只有中后期行)上算的
      ↓
【分数的分母变了,分数就不可比】
   ↓
这和"不同班级的平均分不能直接比"是同一件事:
  如果 A 班多算了 20 个不及格的学生,A 班的平均分当然更低

★ ⑥ 值得展开:为什么"三个几乎相同的模型"不能融合?

融合(bagging / 多模型平均)能降方差的前提是:
  成员之间的【误差不相关】
   ↓
三个回看窗口的模型:
  · 用同一批标签、同一批行(共同行)、同一个模型类(随机森林 200 棵)
  · 特征高度重叠(4 列轨迹特征完全相同,另外 13 列高度相关)
      ↓
它们的预测相关系数接近 1
      ↓
平均之后 ≈ 取其中一个 → 收益 0
   ↓
【想靠融合涨分,成员必须"错得不一样":
   不同模型族(树 + 线性)、不同特征集、不同数据子集】

第 29 章会做"不同模型族"的融合,那里能看到一点点收益(千分之几)。

28.6 完整示例与输出 ​

bash
cd code
MPLCONFIGDIR=/tmp/fe-mpl python demos/ch28-multi-window.py

真实输出(节选):

text
=== ① 现场造三套特征表:回看 5 / 20 / 50 个窗口 ===

      回看窗口      可用样本数      每台平均      最早可用行
         5       20879        348          235

三张表的第一行分别是原始表的第几行(= 这行有多少历史):
  回看  5:index 前 5 个 = [6, 7, 8, 9, 10]
  回看 20:index 前 5 个 = [21, 22, 23, 24, 25]
  回看 50:index 前 5 个 = [51, 52, 53, 54, 55]

三个窗口都能用的【共同行】 = 18179

=== ② 正确口径:只在共同行上划分,三档用同一个测试集 ===",
训练 14796 行 / 测试 3383 行(三个窗口共用这一份划分)

    回看      训练 R²      测试 R²      耗时
     5    0.99985    0.99889   23.0s
    20    0.99985    0.99890   21.8s
    50    0.99986    0.99891   21.8s
    平均          —    0.99891

=== ③ ⚠️ 反面口径:各表用自己的行数报分数(Δ 里混进了 Δ 样本)===
    回看      该表可用            该表自己划分的测试行      测试 R²
     5     20879                  3923    0.99908
    20     19979                  3743    0.99904
    50     18179                  3383    0.99891

反面口径的 Δ(50 窗口 − 5 窗口) = -0.00016
共同口径的 Δ(同一件事)          = +0.00002

[plots] 图已保存: .../outputs/ch28-multi-window.png

读法(三句话):
  ① 不同回看窗口的可用样本数不同 —— 先在共同行上划分,否则 Δ 分数里混着 Δ 样本;
  ② 各窗口模型仍用自己的特征表训练,只在共同测试下标上预测、再取平均;
  ③ 多窗口平均的收益是千分之几(本章 +0.00000)—— 它降方差,不涨上限。

本章练习 ​

练习 1:在共同行上比较三个回看窗口

在一份脚本里造出回看 5 / 20 / 50 个窗口的三套 21 列特征表,然后:

  1. 打印每套表的可用样本数,并求出三套都能用的共同行;
  2. 只在共同行上按设备划分(test_size=0.2, random_state=42);
  3. 三个窗口各训一个随机森林 200 棵(各自用自己那套特征), 在同一个测试集上报 R²,最后取预测平均;
  4. 再让每张表用自己的行数报一次 R²,比较两个口径的 Δ 差多少。
参考答案
python
import numpy as np
import pandas as pd
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import r2_score
from sklearn.model_selection import GroupShuffleSplit

WINDOWS = (5, 20, 50)
tabs = {w: build(w) for w in WINDOWS}            # build(w) 见 demos/ch28-multi-window.py
for w in WINDOWS:
    print(f"回看 {w:2d}:可用 {len(tabs[w])} 行")

common = None
for w in WINDOWS:
    common = tabs[w].index if common is None else common.intersection(tabs[w].index)
common = common.sort_values()
print(f"共同行 = {len(common)}")

tr, te = next(GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
              .split(common, tabs[20]["soh"].loc[common], tabs[20]["cell_id"].loc[common]))
idx_tr, idx_te = common[tr], common[te]
y_te = tabs[20]["soh"].loc[idx_te]

print(f"\n{'回看':>6}{'共同口径':>14}{'自划口径':>14}")
preds = {}
for w in WINDOWS:
    d = tabs[w]
    m = RandomForestRegressor(n_estimators=200, random_state=42, n_jobs=1)
    m.fit(d.loc[idx_tr, SAFE21], d.loc[idx_tr, "soh"])
    preds[w] = m.predict(d.loc[idx_te, SAFE21])
    shared = r2_score(y_te, preds[w])

    i_tr, i_te = next(GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
                      .split(d[SAFE21], d["soh"], d["cell_id"]))
    m2 = RandomForestRegressor(n_estimators=200, random_state=42, n_jobs=1)
    m2.fit(d[SAFE21].iloc[i_tr], d["soh"].iloc[i_tr])
    own = r2_score(d["soh"].iloc[i_te], m2.predict(d[SAFE21].iloc[i_te]))
    print(f"{w:>6}{shared:>14.5f}{own:>14.5f}")

print(f"\n三窗口平均(共同测试 {len(idx_te)} 行) = "
      f"{r2_score(y_te, np.mean(list(preds.values()), axis=0)):.5f}")

期望输出:

text
回看  5:可用 20879 行
回看 20:可用 19979 行
回看 50:可用 18179 行
共同行 = 18179

   回看          共同口径          自划口径
     5       0.99889       0.99889
    20       0.99890       0.99890
    50       0.99891       0.99891

三窗口平均(共同测试 3383 行) = 0.99891

★ 这个结果里最重要的一行是"共同行 = 18179"

它等于三档里最少的那一档(w=50)
   ↓
本数据上"可用行"在三个 w 上是嵌套的(w 越大越少)
   ↓
所以共同行恰好就是最少的那一档
   ↓
⚠️ 但代码里不能依赖这个巧合 —— 老老实实求交集
   (如果哪天有人改了特征构造,让 w=5 的可用行反而更少,
     交集就会小于任何一档,依赖"嵌套"的代码会静默出错)

★ 两个口径在本数据上恰好一致,不代表口径不重要

三档分数都在 0.9989 附近(贴着天花板)
   ↓
"多吃的 2,700 行难样本"在这个特征集上影响不到 0.0001 量级
   ↓
换成 17 列(去掉历史 SOH,分数掉到 0.95 附近)时:
  w=5 → 0.95132 / w=20 → 0.95076 / w=50 → 0.95479
  三档的差距变成 0.004 —— 而它们的测试集【长度和内容都不同】
      ↓
这时候两个口径会明显分岔
   ↓
【口径问题不是"有没有影响",而是"你无法在报分那一刻知道自己有没有被影响"】

★ 一个可以直接加进实验记录的自检

python
assert len(idx_te) < len(tabs[5]), "共同测试集不可能比任何一档的 20% 还大"
sizes = {w: len(tabs[w]) for w in WINDOWS}
print(f"各档可用行数:{sizes}")
print(f"共同行数 {len(common)}(= 最少的一档? {len(common) == min(sizes.values())})")
print(f"各档多出的行数:{ {w: sizes[w] - len(common) for w in WINDOWS} }")
练习 2:亲手复现 ValueError

写一段代码,故意用两个行数不同的预测数组去做平均,看到报错;然后改成正确写法。

要求:

  1. 把报错信息原样贴出来;
  2. 解释报错里 inhomogeneous shape 是什么意思;
  3. 给出两种修法("共同下标" 与 "先对齐行")。
参考答案
python
import numpy as np
from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import GroupShuffleSplit

preds = {}
for w in (5, 50):
    d = tabs[w]
    i_tr, i_te = next(GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
                      .split(d[SAFE21], d["soh"], d["cell_id"]))
    m = RandomForestRegressor(n_estimators=200, random_state=42, n_jobs=1)
    m.fit(d[SAFE21].iloc[i_tr], d["soh"].iloc[i_tr])
    preds[w] = m.predict(d[SAFE21].iloc[i_te])
    print(f"w={w}: 预测数组长度 {len(preds[w])}")

# ❌ 直接平均
avg = np.mean([preds[5], preds[50]], axis=0)

期望输出:

text
w=5: 预测数组长度 3923
w=50: 预测数组长度 3383
Traceback (most recent call last):
  ...
ValueError: setting an array element with a sequence. The requested array has an
inhomogeneous shape after 1 dimensions. The detected shape was (2,) + inhomogeneous part.

② inhomogeneous shape 是什么意思?

NumPy 想把 [array(3923,), array(3383,)] 变成一个 2 维数组 (2, N)
   ↓
它发现两行长度不同(3923 ≠ 3383)→ 拼不出规则的矩形
   ↓
"inhomogeneous shape after 1 dimensions" = "第 1 维之后形状不统一"
   (第 0 维是 2,没问题;第 1 维一个是 3923、一个是 3383)

★ 这个报错其实是个"好东西"

如果 NumPy 默默帮你把短的那个补零(或者广播)
   → 你会得到一堆看着像分数、其实是垃圾的数字
   → 而且不会报错
      ↓
【报错比静默出错好一百倍】

③ 两种修法

python
# ✅ 修法 1(推荐):在共同下标上预测 —— 从源头保证长度一致
common = tabs[5].index.intersection(tabs[50].index)
c_tr, c_te = next(GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
                  .split(common, tabs[20]["soh"].loc[common],
                         tabs[20]["cell_id"].loc[common]))
i_tr, i_te = common[c_tr], common[c_te]
preds = {}
for w in (5, 50):
    d = tabs[w]
    m = RandomForestRegressor(n_estimators=200, random_state=42, n_jobs=1)
    m.fit(d.loc[i_tr, SAFE21], d.loc[i_tr, "soh"])
    preds[w] = m.predict(d.loc[i_te, SAFE21])       # 长度 = len(i_te)
print(f"对齐后长度:{ {w: len(v) for w, v in preds.items()} }")
print(f"平均后测试 R² = {r2_score(tabs[20]['soh'].loc[i_te],
                                np.mean(list(preds.values()), axis=0)):.5f}")
python
# ⚠️ 修法 2(不推荐):只取两者测试下标的交集,事后裁剪
common_te = tabs[5].index[i_te_5].intersection(tabs[50].index[i_te_50])
# 然后把两个 preds 都按 common_te 重新索引
#   → 能跑,但"哪一批行被留下了"取决于两次划分的随机结果,很难解释

★ 为什么修法 1 更好

修法 1 把"共同"提前到【划分之前】
   → 三档用完全相同的训练集和测试集
   → "Δ 分数 = Δ 特征"这个等式成立

修法 2 是"各划各的,事后取交集"
   → 三档的训练集不同、测试集是"两个测试集的交集"
   → Δ 分数里混了三种差异(特征、训练集、测试集)
      ↓
【先对齐,再比较】

★ 期望输出(修法 1):

text
对齐后长度:{5: 3383, 50: 3383}
平均后测试 R² = 0.99891
练习 3:把"口径"写进一个函数

写一个函数 compare_windows(windows, builder, model_factory, feats),它:

  1. 现场造出每个 w 的特征表;
  2. 自动求共同行、在共同行上划分;
  3. 返回一个 DataFrame,列包含:w、该表可用行、多吃下的行、共同口径测试 R²、 自划口径测试 R²、耗时;
  4. 并额外返回"共同行数"和"三档平均的测试 R²"。

然后用本章的真实数字验证它。

参考答案
python
import time

import numpy as np
import pandas as pd
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import r2_score
from sklearn.model_selection import GroupShuffleSplit


def compare_windows(windows, builder, feats, test_size=0.2, seed=42):
    tabs = {w: builder(w) for w in windows}

    common = None
    for w in windows:
        common = (tabs[w].index if common is None
                  else common.intersection(tabs[w].index))
    common = common.sort_values()

    tr, te = next(GroupShuffleSplit(n_splits=1, test_size=test_size, random_state=seed)
                  .split(common, tabs[windows[0]]["soh"].loc[common],
                         tabs[windows[0]]["cell_id"].loc[common]))
    i_tr, i_te = common[tr], common[te]
    y_te = tabs[windows[0]]["soh"].loc[i_te]

    rows, preds = [], {}
    for w in windows:
        d = tabs[w]
        t0 = time.perf_counter()
        m = RandomForestRegressor(n_estimators=200, random_state=seed, n_jobs=1)
        m.fit(d.loc[i_tr, feats], d.loc[i_tr, "soh"])
        preds[w] = m.predict(d.loc[i_te, feats])
        dt = time.perf_counter() - t0

        j_tr, j_te = next(GroupShuffleSplit(n_splits=1, test_size=test_size,
                                            random_state=seed)
                          .split(d[feats], d["soh"], d["cell_id"]))
        m2 = RandomForestRegressor(n_estimators=200, random_state=seed, n_jobs=1)
        m2.fit(d[feats].iloc[j_tr], d["soh"].iloc[j_tr])
        own = r2_score(d["soh"].iloc[j_te], m2.predict(d[feats].iloc[j_te]))

        rows.append({"w": w, "该表可用行": len(d), "多吃下的行": len(d) - len(common),
                     "共同口径测试 R²": r2_score(y_te, preds[w]),
                     "自划口径测试 R²": own, "耗时": round(dt, 1)})

    avg = r2_score(y_te, np.mean(list(preds.values()), axis=0))
    return pd.DataFrame(rows), len(common), avg


table, n_common, n_avg = compare_windows((5, 20, 50), build, SAFE21)
print(table.to_string(index=False))
print(f"\n共同行 {n_common},测试 {3383} 行,三档平均 测试 R² = {n_avg:.5f}")

期望输出:

text
 w  该表可用行  多吃下的行  共同口径测试 R²  自划口径测试 R²   耗时
 5    20879     2700     0.99889     0.99889  5.5
20    19979     1800     0.99890     0.99890  5.7
50    18179        0     0.99891     0.99891 14.8

共同行 18179,测试 3383 行,三档平均 测试 R² = 0.99891

★ 这个函数的价值:把"口径"变成返回值的一部分

返回的 DataFrame 里同时有两列:
  · 该表可用行 / 多吃下的行   ← 工程信息(这个 w 能覆盖多少样本)
  · 共同口径 / 自划口径        ← 统计信息(这个 w 的特征有多好)
   ↓
两列并排放在表里 → "口径"这件事不会被忘掉
   ↓
【把纪律做进工具,比靠记性可靠】

★ 一个可以顺手加上的诊断列

python
rows[-1]["共同行占比"] = round(len(common) / len(d), 4)

期望输出(加一列):

text
 w  该表可用行  共同行占比
 5    20879    0.8707
20    19979    0.9099
50    18179    1.0000

读法:w=5 只有 87% 的行能和别人一起比 —— 剩下 13% 是"只有它自己能用"的样本。 这 13% 在业务上可能就是最有价值的那一批(新设备刚上线时的早期预测), 所以"能不能覆盖它们"本身就是一个需要单独评估的工程问题。

面向工程实践:口径问题是"看不见的错误" ​

① 为什么口径问题比其他坑难发现?

泄漏有"分数突然变很高"的征兆
过拟合有"开口"的征兆
口径错误没有征兆 —— 它只是让你【比较了一个不可比的东西】
   ↓
而且两个口径下的数字都在"合理范围"内
   (本章:0.99889 vs 0.99891,看着都正常)
   ↓
【你只能在"做实验之前"防它,不能在"看到结果之后"发现它】

② 一个通用的"口径检查清单"

text
在比较任何两个配置之前,问四句:
  ① 它们的【评估集】是同一批行吗?(长度 + index 都比)
  ② 它们的【标签口径】一样吗?(同一个 soh 列、同样的时间点)
  ③ 它们的【划分方式】一样吗?(都是按设备、同一个 random_state)
  ④ 它们的【缺失值处理】一样吗?(dropna 的列集合是否相同)
      ↓
四句里任何一句是"不一样",两个分数就不能直接减

★ 一个可以自动化的写法

python
def assert_comparable(tag, y_true, idx, n_rows):
    """在比较两个配置前,断言它们可比。"""
    import hashlib
    key = hashlib.md5(str(list(idx)).encode()).hexdigest()[:8]
    print(f"{tag:<12} 行数 {n_rows:6d}  评估集指纹 {key}  "
          f"标签均值 {y_true.mean():.6f}")
    return key

k1 = assert_comparable("w=5 ", y.loc[i_te], i_te, len(i_te))
k2 = assert_comparable("w=50", y.loc[i_te], i_te, len(i_te))
assert k1 == k2, "★ 两个配置的评估集不是同一批行,分数不可比"
print("✅ 两个配置的评估集完全一致,可以比 Δ")

★ 期望输出:

text
w=5         行数   3383  评估集指纹 986535e3  标签均值 0.895712
w=50        行数   3383  评估集指纹 986535e3  标签均值 0.895712
✅ 两个配置的评估集完全一致,可以比 Δ

③ 这个检查与"多窗口"之外的场景

同样的口径问题会出现在:
  · 不同特征集的比较(dropna 的列不同 → 行数不同)
  · 不同时间段的比较(训练窗口滑动 → 评估期不同)
  · 不同模型族的比较(有的能处理 NaN、有的不能 → 行数不同)
  · A/B 实验(两组用户的构成不同 → 需要匹配或加权)
      ↓
【只要两组的"评估集"不是同一批对象,"Δ 分数"就永远要打问号】

④ 多窗口平均在工程上的真实价值

本数据上它的收益是 0.00000(三档太像了)
   ↓
但那不代表这个方法没用 —— 它的价值在【降低"选哪个 w"的决策成本】:
  · 不用在 w 上纠结(反正都一样)
  · 拿三个的平均值,避免"恰好选中了最差那个 w"
      ↓
⚠️ 前提是三个成员要有差异
   → 真正能带来收益的融合是"不同模型族"(第 29 章)

⑤ 本章的位置

第 26 章 → 超参搜索:怎么系统地找旋钮(测试集只能用一次)
第 27 章 → 收口:旋钮一共能动多少
第 28 章 → 同一个旋钮(回看窗口)+ 口径纪律        ← 本章
第 29 章 → 融合:把多个模型"开会",权重必须用验证集定

本章的"口径"和上一章的"测试集只能用一次"是同一件事的两个面: 前者保证"同一个测试集",后者保证"只打开一次"。


下一章:第 29 章 · 模型融合:权重必须用验证集定