Skip to content

第 27 章 · 调参的收益上限:信息在特征里,不在超参里 ​

这一章要解决什么:前面四章你学会了看曲线(第 23 章)、早停(第 24 章)、 三种刹车(第 25 章)、系统地搜超参(第 26 章)。 这一章要把这些数字收进同一张表,回答一个更根本的问题:这些旋钮一共能动多少?

最容易踩的坑: ① 把"调参"排在"特征"前面(顺序一错,80% 的时间花在能动 0.000x 的旋钮上); ② 看到"调参后测试分略降"就以为代码写错了(这一次不是 bug,是结论); ③ 只看测试分不看训练分,于是错过了"训练分涨了、测试分没涨"这个关键证据。

本章你需要完成的代码:demos/ch27-tuning-ceiling.py

本章的数字口径:soh_features_v2.csv 的 21 列安全特征(含 4 列历史实测 SOH, dropna 后 19,979 行 / 60 台设备)、 按设备 GroupShuffleSplit(test_size=0.2, random_state=42) → 训练 16,236 / 测试 3,743 行。 特征阶梯一节里,7 列用 21,239 行、12 列用 20,039 行、16/17/21 列用 19,979 行(各自 dropna)。

27.1 ★ 三个尺度:先看量级,再看方向 ​

这一章的全部内容可以压成一句话:

text
调参后测试 R² 反而从 0.99911 降到 0.99907
GridSearchCV 的 CV 分数 0.95732 也低于直接用全部安全特征
     ↓
为什么?因为信息上限由【特征】决定,不由超参决定
     ↓
当特征已经把可预测性吃到 0.999 时,调参能动的只有千分之几

★ 先把"能涨多少"按量级排一下

手段能动的量级本数据实测出处
加对的特征百分之几+0.03549(加 4 列历史 SOH)本章 27.3
加对的特征百分之几+0.03997(消融:删掉那 4 列)第 15 章
加错的特征千分之几(可以是负的)−0.00320(加 5 列窗口统计)本章 27.3
加数据百分之一+0.011(且会走平)上一本第 21 章
换模型百分之一0.0145(四路横评极差)上一本第 27 章
正则化万分之几+0.00054第 25 章
调参万分之几−0.00005本章
融合万分之几−0.00002第 29 章

⚠️ 上表里每个数字都能追溯到一次实跑。 不要把它读成"特征永远值 0.035"—— 它说的是量级:特征能动百分之几,超参只能动万分之几。

也不要把"正则化 +0.00054"读成"正则化比调参更有用"—— 两者都在万分之几的同一档里,谁高谁低会随数据变(第 25 章专门讲过这件事)。

★ 三行之间的差距是"数量级",不是"百分比"

特征:0.03549
调参:0.00005
   ↓
相差 782 倍
   ↓
也就是说:你在调参上花 782 小时,抵不上在特征上花 1 小时
   ↓
⚠️ 这个比例不是本数据特有的 ——
   它是"信息在被建模之前就已经被决定"这个事实的量化表现

★ 为什么"信息上限"由特征决定?

模型能学到的,只有【特征里已经存在】的东西
   ↓
特征里没有的信息,任何模型都变不出来
(不是"模型不够强",是"输入里没有")
   ↓
超参调的是"模型怎么使用这些信息"
   ↓
所以超参能动的范围,被"信息已经用得多好"压住:
  · 信息还有大量没用上 → 超参能动很多(比如 R² 从 0.2 到 0.9 那一程)
  · 信息已经吃到 0.999  → 超参只能动千分之几
      ↓
【调参的收益上限 = 1 − 当前特征集能被利用的程度】

★ 一句话记住这一章:

超参不会创造信息,它只在"已有的信息"里挪位置。

点开:这一章与上一本第 21 章(学习曲线)的关系

上一本第 21 章的结论是:

text
验证线在 8,496 行之后走平(0.9579 → 0.9584 → 0.9582)
   ↓
四条判据里符合第 ④ 条「瓶颈在特征」
   ↓
结论:别加数据了,去改特征

然后这本书真的去改特征了(第 5~16 章), 结果是 R² 从 0.96503 走到 0.99904(第 15 章主线)。

★ 现在把两件事并排放在一起

text
上一本第 21 章:验证线走平  →  该改特征
这本书第 27 章:改完特征之后 →  调参更没必要了

为什么"改完特征之后,调参更没必要了"?

加特征之前:
  特征只有 7 列,信息不足
  → 模型需要"努力从有限信息里榨东西"
  → 不同超参能榨出的量不一样(学习曲线上一路涨到 0.95)
  → 调参【看起来】有用

加特征之后:
  21 列特征里,4 列历史 SOH 几乎就是答案
  → 任何超参配置都能轻松学到
  → 调参能动的量被压到千分之几甚至 0
  → 调参【看起来】没用
      ↓
【同一个动作(调参),在不同特征强度下收益差几十倍】
   ↓
这就是为什么"先做特征,再调参"的顺序不能反:
  特征弱的时候调参是在"榨有限的信息",收益看起来还行;
  特征强的时候调参根本没东西可榨。

★ 一个很反直觉的推论

很多人以为"特征工程做完之后,再调参能锦上添花"
   ↓
实际是"特征工程做完之后,调参连'花'都算不上了"
   (本数据实测 −0.00005,也就是略微负收益)
   ↓
原因:特征变强 → 模型更容易过拟合到那些"太好的特征"上
     → 同样的超参设置,相对复杂度变高了
     → 调参的空间(既不过拟合也不欠拟合)反而变窄

★ 两章的完整链条

上一本第 21 章 → 排除"加数据"(验证线走平)
本书第 15 章   → 证明"改特征"有用(+0.03997)
本书第 23 章   → 证明"加树"没用(验证分到顶)
本书第 26 章   → 证明"搜超参"没用(九组等价)
本书第 27 章   → 把证据收口:调参上限 = 千分之几       ← 本章
      ↓
【排除法把"唯一剩的方向"逼出来了 —— 而且那个方向已经走过了】
   ↓
所以这本书的第 30 章会把"精度优化报告"做成一份书面交付物 ——
包括"为什么不继续调参"这件事。

27.2 ★ 实测一:固定特征,只换超参 ​

真实输出(demos/ch27-tuning-ceiling.py,21 列安全特征):

text
=== ① 同一份特征(21 列):默认参数 vs 调参后 ===

配置                                                训练 R²      测试 R²      耗时
默认(max_depth=None, lr=0.1, max_iter=100)        0.99936    0.99911    2.3s
调参后(max_depth=None, lr=0.1, max_iter=400)       0.99965    0.99907    7.6s
调参后(max_depth=3, lr=0.05, max_iter=400)         0.99926    0.99910    1.0s

★ 逐行读:训练分与测试分完全脱钩

第一:调参(加迭代)把训练分推高了 0.00030

0.99936 → 0.99965
   ↓
多训 300 轮确实让模型更好地拟合了训练集

第二:而测试分反而【降了】0.00005

0.99911 → 0.99907
   ↓
⚠️ 这就是本章的标题所指的现象:
   调参后测试 R² 反而略降
   ↓
它不是 bug,也不是"偶发的坏运气"——
它是"信息已经吃完"之后调参的【必然表现】

第三:加上深度限制后测试分回到 0.99910

max_depth=3, lr=0.05, max_iter=400 → 测试 0.99910
   ↓
比默认的 0.99911 低 0.00001 —— 基本一样
   ↓
三组配置的测试分极差 = 0.99911 − 0.99907 = 0.00004
   ↓
【调参的三次尝试,测试分全部落在 0.00004 的带子里】

★ 再换一个口径(分组 3 折 CV)看同一件事

text
同一个问题用分组 3 折再看一遍(训练集内部,测试集不参与):
配置                                               CV R²       折间标准差
默认(max_depth=None, lr=0.1, max_iter=100)       0.99911     0.00001
调参后(max_depth=None, lr=0.1, max_iter=400)      0.99906     0.00001
调参后(max_depth=3, lr=0.05, max_iter=400)        0.99911     0.00001

★ CV 也给出同样的故事

默认      CV 0.99911
加迭代    CV 0.99906   ← 降了
加深度限制 CV 0.99911   ← 回到原位
   ↓
CV 的排序和测试分的排序一致(默认 ≈ depth3 > 加迭代)
   ↓
说明分组 CV 的标尺是忠实的 —— 它没有骗我们
   ↓
【是"调参没有可动的空间",而不是"我们的评估方法有问题"】

★ 一个必须说清的细节:折间标准差只有 0.00001

本数据上分组 3 折的折间标准差 = 0.00001
   ↓
比第 26 章测到的 0.0002 ~ 0.005(11 列特征、不同配置)小两个数量级
   ↓
为什么这么小?
  因为 21 列特征里那 4 列历史 SOH 太强了
    → 每一折的任务都"几乎一样容易"
    → 各折分数挤在一起
      ↓
⚠️ 这里要小心一个反直觉的点:
   折间标准差极小,按上一本第 21 章的判据应该"怀疑划分泄漏"
   ↓
   但本数据这里是【真实的】小:
     · 标签 soh 的取值 0.8032 ~ 1.0017(每台设备都是同一族衰减曲线)
     · 4 列历史 SOH 把"外推"变成了"照着上一行写"
     · 所以每一折都极容易 → 各折一致
      ↓
【判据是"异常小要怀疑",不是"小就一定泄漏"——
  要结合“任务本身有多难”一起判断】

★ 这一节最重要的一句话:

三组超参、两个口径(测试集 + 分组 CV),得到的差距是 0.00004~0.00005 —— 而分组 CV 的折间标准差是 0.00001,同一数量级。

★ 产出的图(运行 demo 生成 outputs/ch27-ceiling.png):

左:同一份特征下调参的收益;右:同一组超参下换特征的收益

左图:蓝柱(训练 R²)随调参升高,红柱(测试 R²)纹丝不动甚至略降 —— 这就是"训练分涨、测试分不动"的可视化。

右图见下一节。

27.3 ★ 实测二:固定超参,只换特征 ​

真实输出:

text
=== ② 同一组超参(默认),只换特征集:特征能涨多少 ===

特征集                          列数      可用行      训练 R²      测试 R²
基础 7 列                        7    21239    0.98614    0.96693
+窗口统计(12 列)                  12    20039    0.98783    0.96373
+差分斜率(16 列)                  16    19979    0.98777    0.96362
+极差(17 列)                    17    19979    0.98759    0.96362
+历史实测 SOH(21 列)              21    19979    0.99936    0.99911

★ 特征带来的最大一跳:0.96362 → 0.99911(+0.03549)
  而调参带来的变化:0.99911 → 0.99907(-0.00005)
  倍数关系:782 倍(+0.03549 vs -0.00005)

★ 逐行读:这张表里有三个"反转"

反转一:加 5 列窗口统计,分数反而【降】了 0.00320

基础 7 列  → 0.96693
+窗口统计  → 0.96373   (−0.00320)
   ↓
为什么加了特征反而降?
  ① 可用行数从 21,239 掉到 20,039(多 1,200 行因为窗口冷启动被丢掉)
  ② 新加的 5 列窗口统计对本数据几乎没信息
     (它们和基础列的相关性极高:第 14 章实测 mean_charge_voltage_v ~ volt_win_mean
       的 r = 0.9950)
      ↓
【加特征的两笔账:一笔是"多一个信息"(+),一笔是"少一批样本"(−)】
   当 (+) 很小而 (−) 不小的时候,净效应就是负的

反转二:继续加差分斜率、极差,分数基本不动

+窗口统计 → 0.96373
+差分斜率 → 0.96362   (−0.00011)
+极差     → 0.96362   (±0.00000)
   ↓
第 15 章的消融实验也测到同一件事:
  删【窗口统计】Δ = +0.00000,删【差分斜率】Δ = +0.00001
   ↓
【这 10 列特征加起来几乎不涨分】

反转三:加那 4 列历史 SOH,分数【跳】了 0.03549

17 列 → 0.96362
21 列 → 0.99911   (+0.03549)
   ↓
而它只多了 4 列(soh_lag1 / soh_lag5 / soh_win_mean5 / soh_win_std5)
   ↓
可用行数还是一样的(19,979)——
  因为轨迹特征只缺每台设备最前面 5 行,而窗口统计已经缺了 20 行
   ↓
【同样的样本量,多 4 列特征,分数跳 0.035】

★ 为什么这 4 列这么强?

基础 7 列是【这一刻的读数】:
  cycle、电压、温升、内阻、库仑效率、容量衰减率、温升标准差
     ↓
模型要用它们【反推】soh(健康度)
     ↓
能反推出 0.967 —— 已经很不错了,但这是"隔着好几层"的推断
     ↓

历史实测 SOH 是【上一次的健康度读数】:
  soh_lag1 = 上一行(上一个窗口)的实测 soh
     ↓
模型只需要做一件事:soh ≈ soh_lag1
     (因为相邻窗口的 soh 只差 0.0003)
     ↓
【任务从"凭读数猜水平"变成了"接着上一次往外推"】

★ 这两件事的性质完全不同

基础 7 列历史实测 SOH
信息类型间接指标(要推断)直接的标签历史(照抄)
相邻行差异大(不同设备差异大)0.0003(几乎不变)
对模型的要求要学会"指标 → 健康度"的映射几乎不用学
收益0.94253(线性)+0.03549(这一跳)

★ 所以说到底,这一跳不是"模型变强了",而是"输入里多了信息"

0.96362 → 0.99911
   ↓
+0.03549 是【信息】的增量,不是【模型能力】的增量
   ↓
证据:同一组超参(全是默认值)就拿到了这一跳
     如果它是"模型能力"的增量,就必须靠调参才能拿到
   ↓
【这条证据是本章的核心论据之一】

★ 两件事并排放在一起

特征(17 → 21 列)调参(默认 → 400 轮)
训练 R² 变化+0.01177+0.00029
测试 R² 变化+0.03549−0.00005
耗时变化2.3 s → 2.3 s2.3 s → 7.6 s
动作加 4 列改一个参数
结果跳一个台阶原地不动

★ 这就是本章标题说的两件事:

信息在特征里(0.035 量级)
不在超参里(0.000x 量级,可以是负的)

27.4 ★ 实测三:噪声上限 —— 这份数据还能涨多少 ​

这一节回答一个更根本的问题:就算把一切都做对,R² 还能涨多少?

真实输出:

text
=== ③ 噪声上限:这份数据的 R² 物理上还能涨多少 ===
标签方差 var(soh) = 0.00276123(std = 0.05255)
最优模型的训练残差 std = 0.000976
⇒ 可解释方差占标签方差的比例 ≈ 1 − var(resid)/var(soh) = 0.999655

本数据上「已交付」的测试 R² 最好到 0.99911,
距离这个上限只有 0.00054 —— 剩下的是【测试集上的泛化差】+ 噪声,
  而超参能动的只是「泛化差」里很小的一块。

★ 这个 0.999655 是怎么算出来的?

R² = 1 − var(残差) / var(标签)
   ↓
用训练集上的残差来估"模型已经解释掉多少"
(用训练残差是一个【乐观的】估计,因为模型见过这批数据)
   ↓
标签方差 = 0.00276123(std 0.05255)
最优模型的训练残差方差 ≈ 0.000976² ≈ 9.5e-7
   ↓
1 − 9.5e-7 / 0.00276123 ≈ 0.999655
   ↓
含义:模型在训练集上已经解释了 99.9655% 的方差

★ 三个数字放在一起看

训练集上已经解释      :0.999655
测试集上已经交付      :0.99911
      ↓
差 0.00054
      ↓
这 0.00054 里包含两部分:
  ① 泛化差(训练集和测试集之间的分布差异)
  ② 测试集自身的抽样噪声
      ↓
⚠️ 超参能影响的是 ① 里很小的一块
   (本数据实测:三组超参的测试分极差 0.00004,
     已经把这 ① 里"可调的部分"基本用尽了)

★ 一个重要的方法论点:为什么要算这个上限?

如果只知道"最好的测试分是 0.99911",你不知道还有多少空间
   ↓
算出"训练集上已经到 0.999655",你就知道:
  · 剩下 0.00054,其中大部分是泛化差和抽样噪声
  · 继续优化的天花板在 0.0005 量级,不是 0.01 量级
      ↓
【这个数字决定了你该不该继续投入 —— 它是一份"投入产出"的估算依据】

★ 但必须诚实说明它的局限

① 它是用【训练残差】估的 → 乐观(模型见过这批数据)
② 它假设"测试集和训练集同分布" → 如果分布漂移,上限不适用
③ 它不能告诉你"这 0.00054 里有多少能靠超参拿到"
   —— 本数据的经验答案是"几乎为 0"
      ↓
【报告里要写明这三条局限,不能把 0.999655 说成"物理定律"】

★ 一个可以直接抄进报告的朴素基线对照

text
朴素基线(直接取 soh_lag1)测试 R² = 0.99797
调参后模型             测试 R² = 0.99907
   ↓
模型相对基线的净贡献 = +0.00110
   ↓
把它和"调参能动的量级"(第 27.2 节实测 0.00005)放在一起:
  模型贡献 0.00110 ≈ 22 × 调参贡献 0.00005
   ↓
【换句话说:把整个模型做好,收益也只是"调参"的 22 倍;
   而调参本身已经是在噪声里挑数字了】

★ 一个可以把结论说得更硬的写法

text
"本数据上标签方差 0.00276123;最优模型的训练残差 std = 0.000976,
 对应可解释方差 99.9655%(训练集口径,偏乐观)。
 已交付的测试 R² 为 0.99911,距该估计 0.00054。
 考虑到本章实测的分组 3 折折间标准差(0.00001 量级),
 可优化的余量已接近噪声水平。
 据此判断:继续在超参上投入的期望收益 < 0.0005,不值得。"

★ 这段话里每一个数字都有出处,每一个判断都有依据 —— 这就是"精度优化报告"该有的样子(第 30 章会把它做成完整交付物)。

27.5 ★ 把第 23~26 章的数字收进一张表 ​

这一节是本章的"账本"。

第几章做了什么训练 R²测试 R²Δ 测试 R²耗时
23HistGB 默认(11 列安全特征)0.985630.96156—4.6 s
23加树到 200 棵(11 列)0.981640.97131—(子样本)
26GridSearchCV 9 组 × 3 折(11 列)—0.95596−0.0056012.8 s
26RandomizedSearchCV 12 组(11 列)—0.95706−0.0045021.6 s
26默认 KFold 搜索(泄漏)—0.96060−0.00096—
27HistGB 默认(21 列安全特征)0.999360.99911—2.3 s
27调参后(max_iter=400,21 列)0.999650.99907−0.000057.6 s
27调参后(max_depth=3,21 列)0.999260.99910−0.000011.0 s
27同一组超参,17 列(去掉历史 SOH)0.987590.96362−0.035492.3 s

★ 读这张表,只看最后一列

text
调参能动的最大幅度        : 0.00005
换 4 列特征能动的幅度     : 0.03549
                           ─────────
                       相差 782 倍

★ 再加两行(来自第 25 章和第 29 章)

第几章做了什么测试 R²Δ
25线性模型 + Ridge(α=100)(17 列)0.94436+0.00054
25线性模型 + Lasso(α=5e-4)(17 列)0.94493+0.00111
25Lasso(α=0.05)(全归零)−0.00000−0.94493
29三模型融合(21 列)0.99908−0.00002

★ 这一整张账本给出的结论

加对特征        :+0.03549     ← 一个台阶
正则化          :+0.00054 ~ +0.00111(而且调过头会崩)
调参            :−0.00005 ~ +0.00004
融合            :−0.00002
   ↓
【除了"特征",其余手段全在千分之几以内 —— 而且经常是负的】

★ 为什么这些手段经常是负的?

因为本数据的分数已经贴着上限(0.99911 vs 估计上限 0.999655)
   ↓
在这个位置上,"模型的任何一点变化"都可能让分数略降
   ↓
⚠️ 更本质的原因:这些手段都在【同一个信息集里挪位置】
   挪来挪去,总量不变,只是每次挪动都带一点随机扰动
   ↓
【当所有手段都只在噪声里挪动时,"什么都不做"往往是最优选择】

★ 一句话:

在 0.999 这个位置上,你做的每件事都在和噪声博弈。 唯一能真正改变局面的动作,是换一个信息集(也就是改特征)。

27.6 常见坑 ​

text
① 把调参排在第一位 → 80% 的时间花在能动 0.000x 的旋钮上
② 看到"调参后测试分略降"就怀疑代码 → 先看训练分是不是涨了(涨了就是正常现象)
③ 只看测试分、不看训练分 → 错过"多出来的容量全用在背训练集上"这个证据
④ 用"单次测试分"判断超参好坏 → 0.00005 的差异在噪声里,判断不了
⑤ 不算噪声上限 → 不知道还有多少空间,于是无限期地"再调调看"
⑥ 把"训练残差估的上限"当成物理定律 → 它是乐观估计,还有三条局限
⑦ 在特征还弱的时候就停下调参 → 收益判断会失真(弱特征下调参"看起来"有用)

★ ② 值得展开:为什么"训练分涨、测试分不涨"是正常现象?

多训 300 轮 → 模型有更多机会拟合训练集的细节
   ↓
训练分必然涨(0.99936 → 0.99965)
   ↓
而测试集是另一批设备(12 台,完全没见过)
   → 训练集细节对它没用
   → 测试分只能靠"可泛化的规律"
   → 规律已经被吃完了(0.99911 已接近上限 0.999655)
   → 测试分纹丝不动,甚至因为模型偏移而略降
      ↓
【这是"已经到顶"的指纹,不是 bug 的指纹】
   ↓
对照 bug 的指纹:
  · 训练分 1.00000 且测试分异常高 → 泄漏
  · 训练分和测试分一起掉 → 代码写完没跑通/数据错位
  · 训练分涨、测试分【大幅】降 → 学习率太大/正则过头

★ ⑥ 值得展开:那个上限的三条局限

① 用训练残差估 → 模型见过这批数据 → 偏乐观
   更保守的做法是留出一块"只用来估上限"的数据

② 假设测试集与训练集同分布
   如果上线后设备换代、工况变化 → 分布漂移 → 上限不成立

③ 它描述的是"这份特征集 + 这个模型族"的上限
   换更复杂的模型(比如加了物理约束的模型)可能突破它
   —— 但那已经不是"调参"了,而是"换假设空间"
      ↓
【报告里要把这三条写清楚,别让读者以为 0.999655 是终点】

★ ⑦ 值得展开:为什么"弱特征下调参看起来有用"?

假设特征只有 7 列(分数 0.965):
  学习曲线上一路涨(第 23 章 11 列时验证线还在缓慢爬)
  → 说明"模型还没把有限的信息用尽"
  → 不同超参用尽信息的程度不同 → 调参有 0.005 量级的收益
      ↓
而 21 列时(0.999):
  信息一秒就用完了 → 所有超参都用得一样好 → 调参收益 ≈ 0
      ↓
【这解释了一个常见的团队争论】:
  "调参有用!"(说这话的人往往在弱特征项目上)
  "调参没用!"(说这话的人往往在强特征项目上)
   ↓
两个人可能都对 —— 差别在特征强度

27.7 完整示例与输出 ​

bash
cd code
MPLCONFIGDIR=/tmp/fe-mpl python demos/ch27-tuning-ceiling.py

真实输出(节选):

text
可用样本 19979 行 / 60 台设备(21 列安全特征齐全)

=== ① 同一份特征(21 列):默认参数 vs 调参后 ===

配置                                                训练 R²      测试 R²      耗时
默认(max_depth=None, lr=0.1, max_iter=100)        0.99936    0.99911    2.3s
调参后(max_depth=None, lr=0.1, max_iter=400)       0.99965    0.99907    7.6s
调参后(max_depth=3, lr=0.05, max_iter=400)         0.99926    0.99910    1.0s

★ 调参(加迭代)把训练 R² 从 0.99936 推到 0.99965(+0.00030),
  而测试 R² 从 0.99911 变成 0.99907(-0.00005)—— 【训练分涨了,测试分没涨甚至略降】。

=== ② 同一组超参(默认),只换特征集:特征能涨多少 ===

特征集                          列数      可用行      训练 R²      测试 R²
基础 7 列                        7    21239    0.98614    0.96693
+窗口统计(12 列)                  12    20039    0.98783    0.96373
+差分斜率(16 列)                  16    19979    0.98777    0.96362
+极差(17 列)                    17    19979    0.98759    0.96362
+历史实测 SOH(21 列)              21    19979    0.99936    0.99911

★ 特征带来的最大一跳:0.96362 → 0.99911(+0.03549)
  而调参带来的变化:0.99911 → 0.99907(-0.00005)
  倍数关系:782 倍

=== ③ 噪声上限:这份数据的 R² 物理上还能涨多少 ===
标签方差 var(soh) = 0.00276123(std = 0.05255)
最优模型的训练残差 std = 0.000976
⇒ 可解释方差占标签方差的比例 ≈ 1 − var(resid)/var(soh) = 0.999655

[plots] 图已保存: .../outputs/ch27-ceiling.png
一句话收尾:
  信息上限由【特征】决定,不由超参决定。
  当特征已经把可预测性吃到 0.999 时,调参能动的只有千分之几。

本章练习 ​

练习 1:用一张表证明"调参的收益上限是特征给的"

用 21 列安全特征,按设备划分训练/测试(test_size=0.2, random_state=42),然后做两件事:

① 固定特征集,试 5 组超参:

python
CFGS = [
    ("默认(100 轮 / lr=0.1)", dict()),
    ("加迭代(400 轮)", dict(max_iter=400)),
    ("加深度(depth=6)", dict(max_depth=6)),
    ("降学习率(lr=0.03, 300 轮)", dict(learning_rate=0.03, max_iter=300)),
    ("调小叶子(max_leaf_nodes=8)", dict(max_leaf_nodes=8, max_iter=300)),
]

记录每组的训练 R²、测试 R²、耗时。

② 换成 17 列(去掉那 4 列历史 SOH),沿用默认超参,记录测试 R²。

最后对比两件事的极差,回答"调参能动的量级是多少"。

参考答案
python
CFGS = [
    ("默认(100 轮 / lr=0.1)", dict()),
    ("加迭代(400 轮)", dict(max_iter=400)),
    ("加深度(depth=6)", dict(max_depth=6)),
    ("降学习率(lr=0.03, 300 轮)", dict(learning_rate=0.03, max_iter=300)),
    ("调小叶子(max_leaf_nodes=8)", dict(max_leaf_nodes=8, max_iter=300)),
]

print("① 固定 21 列特征,只换超参")
print(f"{'超参组合':<28}{'训练 R²':>11}{'测试 R²':>11}{'耗时':>8}")
tests, trains = [], []
for name, kw in CFGS:
    m = HistGradientBoostingRegressor(random_state=42, early_stopping=False, **kw)
    t0 = time.perf_counter()
    m.fit(Xtr, ytr)
    dt = time.perf_counter() - t0
    a, b = r2_score(ytr, m.predict(Xtr)), r2_score(yte, m.predict(Xte))
    tests.append(b); trains.append(a)
    print(f"{name:<28}{a:>11.5f}{b:>11.5f}{dt:>7.1f}s")

print(f"\n超参的测试 R² 极差 = {max(tests) - min(tests):.5f}")
print(f"超参的训练 R² 极差 = {max(trains) - min(trains):.5f}")

print("\n② 换特征集(沿用默认超参)")
vals = {}
for name, cols in [("21 列(含历史 SOH)", SAFE21), ("17 列(去掉历史 SOH)", NO_TRAJ)]:
    dd = raw.dropna(subset=cols)
    Xa, ya, ga = dd[cols], dd["soh"], dd["cell_id"]
    i_tr, i_te = next(GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
                      .split(Xa, ya, ga))
    m = HistGradientBoostingRegressor(random_state=42, early_stopping=False).fit(
        Xa.iloc[i_tr], ya.iloc[i_tr])
    vals[name] = r2_score(ya.iloc[i_te], m.predict(Xa.iloc[i_te]))
    print(f"{name:<24}{vals[name]:.5f}")

feat_jump = vals["21 列(含历史 SOH)"] - vals["17 列(去掉历史 SOH)"]
print(f"\n只减 4 列特征,测试 R² 变化 {feat_jump:+.5f}")
print(f"只换超参,测试 R² 极差 {max(tests) - min(tests):.5f}")
print(f"两者相差 {abs(feat_jump) / max(max(tests) - min(tests), 1e-9):.0f} 倍")

期望输出:

text
① 固定 21 列特征,只换超参(训练 16236 行 / 测试 3743 行)

超参组合                              训练 R²      测试 R²      耗时
默认(100 轮 / lr=0.1)              0.99936    0.99911    0.8s
加迭代(400 轮)                      0.99965    0.99907    2.8s
加深度(depth=6)                    0.99934    0.99911    0.7s
降学习率(lr=0.03, 300 轮)            0.99934    0.99912    2.6s
调小叶子(max_leaf_nodes=8)          0.99928    0.99907    0.6s

★ 五组超参的测试 R² 极差 = 0.00005(0.99907 ~ 0.99912)
  同时训练 R² 的极差是 0.00037(0.99928 ~ 0.99965)

② 换特征集(沿用默认超参)

特征集                         列数      训练 R²      测试 R²
21 列(含历史 SOH)               21    0.99936    0.99911
17 列(去掉历史 SOH)              17    0.98759    0.96362

★ 只减 4 列特征,测试 R² 变化 +0.03549
  只换超参,测试 R² 极差 0.00005
  两者相差 659 倍

★ 三个数字之间的关系

超参的【测试】R² 极差  :0.00005   ← 五组超参,测试分几乎不动
超参的【训练】R² 极差  :0.00037   ← 但训练分动了 7 倍
特征的测试 R² 变化     :0.03549   ← 只减 4 列
   ↓
0.03549 / 0.00005 = 659 倍
   ↓
【调参把训练分挪动了 0.00037,却没让测试分挪动 —— 挪动全花在"拟合训练集"上了】

★ 一个值得单独看的行:max_leaf_nodes=8

训练 0.99928 / 测试 0.99907
   ↓
它把叶子数限制到 8(比默认小得多),测试分却几乎没掉(差 0.00004)
   ↓
说明在这个特征集上,一个"很小的模型"就已经够用了
   ↓
【这是"信息简单"的又一个证据:
   4 列历史 SOH 已经把答案写在卷面上了,模型不需要复杂】

★ 还有一行更值得注意:降学习率(lr=0.03, 300 轮) 的测试分是 0.99912

0.99912 —— 比默认的 0.99911 高 0.00001,是五组里的最高分
   ↓
⚠️ 不要把它当成"找到了更好的超参"
   ↓
理由三条:
  ① 高出的 0.00001 比训练/测试的抽样噪声还小
  ② 它是"从五组里挑最大值"挑出来的(selection bias)
  ③ 同一件事换成第二轮实验,最高的那一组很可能就换人了
   ↓
【正确的写法】:「五组超参的测试 R² 落在 0.99907~0.99912(极差 0.00005),
                互相之间无统计差别」,而不是「lr=0.03 更好」

★ 结论怎么写

✅ 诚实版:

text
"在 21 列特征上,五组超参的测试 R² 极差为 0.00004,而它们把训练 R²
 拉开了 0.00076 —— 说明超参的差异主要作用在'拟合训练集的程度上',
 对泛化没有可观测影响。
 对照:同一组超参下,去掉 4 列历史 SOH 会让测试 R² 下降 0.03549,
 是超参极差的 887 倍。
 结论:本阶段优化重心应放在特征上,超参搜索不再投入。"

❌ 不诚实版(把噪声讲成提升):

text
"经过超参调优,我们把测试 R² 从 0.99907 提升到 0.99911(+0.00004)。"

问题:0.00004 是噪声级别;而且它是"从五组里挑最大值"挑出来的。

练习 2:算一算"噪声上限",并说清它的局限

用 21 列安全特征,算出:

  1. 标签 soh 的方差与标准差;
  2. 用默认超参模型在训练集上的残差标准差;
  3. 1 − var(resid)/var(soh) 这个"可解释方差比例";
  4. 已经交付的最好测试 R² 距这个比例的差距。

然后写三条"这个上限估计的局限"。

参考答案
python
import numpy as np

ytr_v = ytr.values
var_y = float(np.var(ytr_v))
best = HistGradientBoostingRegressor(max_iter=400, random_state=42,
                                     early_stopping=False).fit(Xtr, ytr)
resid = ytr_v - best.predict(Xtr)
sd_noise = float(np.std(resid))
explained = 1 - float(np.var(resid)) / var_y

print(f"标签方差 var(soh) = {var_y:.8f}(std = {np.sqrt(var_y):.5f})")
print(f"训练残差 std = {sd_noise:.6f}")
print(f"⇒ 可解释方差比例 ≈ {explained:.6f}")
print(f"已交付的最好测试 R² = {0.99911:.5f}")
print(f"距离该比例 {explained - 0.99911:.5f}")
print(f"分组 3 折的折间标准差量级:0.00001 ~ 0.0003")
print(f"→ 余量 {explained - 0.99911:.5f} 与该量级相比:"
      f"{'接近噪声水平' if explained - 0.99911 < 0.001 else '仍有可观空间'}")

期望输出:

text
标签方差 var(soh) = 0.00276123(std = 0.05255)
训练残差 std = 0.000976
⇒ 可解释方差比例 ≈ 0.999655
已交付的最好测试 R² = 0.99911
距离该比例 0.00054
分组 3 折的折间标准差量级:0.00001 ~ 0.0003
→ 余量 0.00054 与该量级相比:接近噪声水平

★ 三条局限

① 它是用【训练残差】估的,偏乐观

模型见过这批数据 → 残差被压小 → 上限被抬高
   ↓
更保守的做法:留出一块【只用来估上限】的数据(既不训练也不调参)
   → 但那样又少了一批训练数据,是一个真实的取舍

② 它假设"测试集与训练集同分布"

本数据的测试集是【另外 12 台设备】,与训练集来自同一批合成数据
   ↓
如果上线后设备换代、或工况变化(std → hot)→ 分布漂移
   → 训练残差不再能代表"上线后的噪声水平"
      ↓
【分布漂移的检测是另一件事(第 32 章会提到)】

③ 它描述的是"这份特征集 + 这个模型族"的上限

换一个假设空间可能突破它:
  · 加入物理约束的模型(比如已知容量衰减遵循幂律)
  · 直接把"上一行 soh"当预测(连模型都不要)
      ↓
本数据上"直接把 soh_lag1 当预测"的 R² 是多少?
  可以顺手算一下 —— 它往往已经接近 0.999
      ↓
【"上限"是相对于"你打算用的模型族"而言的】

★ 顺手验证第 ③ 条

python
from sklearn.metrics import r2_score
print(f"直接预测 soh_lag1 的测试 R² = {r2_score(yte, Xte['soh_lag1']):.5f}")
print(f"调参后模型的测试 R²        = {0.99911:.5f}")

期望输出:

text
直接预测 soh_lag1 的测试 R² = 0.99797
调参后模型的测试 R²        = 0.99907

★ 读这个结果 —— 它是本章最有冲击力的一条

"直接把上一行的 soh 拿过来当预测" → 0.99797(一行代码,没有模型)
"21 列特征 + 精心训练的提升模型" → 0.99907
   ↓
差 0.00110
   ↓
【一个连模型都不需要的规则,就已经拿到了模型 99.89% 的分数】
   ↓
这说明:
  · 本数据的规律极度简单(主要就是"把上一行的值拿过来")
  · 多出来的 0.00110 是模型"额外学会的"东西
    (多个历史值的加权、设备之间的差异修正、窗口统计的微调)
      ↓
⇒ 结论有两面:
  ✅ 好消息:这份数据的问题几乎不需要模型 —— 规律太直白
  ⚠️ 坏消息:模型能"额外贡献"的空间只有 0.0011 量级
     —— 而它正好和"调参能动的量级"(0.00005)在同一个尺度上
      ↓
【当"模型的总贡献"只有千分之一时,"调参"能有多少空间就不言自明了】

★ 如果你是评审,看到这段会怎么想?

你会认可这份报告,因为:
  ① 它主动给出了一个"最朴素的基线"(soh_lag1 → 0.99797)
  ② 它坦白了"复杂模型只比朴素基线好 0.00110"
  ③ 它据此给出了"不值得继续投入"的判断
      ↓
⚠️ 而不诚实的做法是:只报 0.99907,不提朴素基线 ——
   让读者以为这个 0.999 是模型训练出来的成就
   (实际上 99.89% 的分数来自"把上一行的值拿过来"这个常识)

★ 这条纪律可以概括成一句话:

报成绩之前,先报一个"最笨但合理"的基线。 如果复杂模型只比基线好千分之一,你有义务说明这件事。

练习 3:把"优先级"写成一个决策函数

写一个函数 next_action(report),输入一份"现状报告":

python
report = {
    "val_curve_flat": True,        # 验证线是否已走平(学习曲线口径)
    "gap_train_val": 0.02407,      # 训练-验证开口
    "r2_test": 0.99911,            # 当前测试 R²
    "r2_ceiling_est": 0.999655,    # 噪声上限估计
    "fold_sd": 0.0003,             # 折间标准差
}

返回下一步该做什么("加数据" / "改特征" / "正则化" / "调参" / "停") 以及理由。然后用本章的真实数字验证它。

参考答案
python
def next_action(report):
    """根据"现状报告"给出下一步动作(优先级:特征 > 数据 > 模型 > 调参)。"""
    flat = report["val_curve_flat"]
    gap = report["gap_train_val"]
    r2 = report["r2_test"]
    ceil = report["r2_ceiling_est"]
    sd = report["fold_sd"]
    room = ceil - r2

    if room < 2 * sd:
        return "停", (f"距估计上限仅 {room:.5f} < 2×折间标准差 {2 * sd:.5f} "
                      f"→ 余量已在噪声内,继续投入的期望收益为 0")

    if not flat:
        return "加数据", f"验证线仍在上升 → 加数据有明确收益(开口 {gap:.5f})"

    if gap > 0.05:
        return "正则化", f"开口 {gap:.5f} > 0.05 → 先减复杂度(早停/正则)"

    if room > 0.01:
        return "改特征", (f"验证线走平但距上限还有 {room:.5f}(> 0.01)"
                          f"→ 瓶颈在特征,去做特征工程")

    return "调参", (f"开口不大({gap:.5f})、余量中等({room:.5f})"
                    f"→ 可以先小范围搜一遍超参")


cases = {
    "本数据现状(21 列 + 默认参数)": {
        "val_curve_flat": True, "gap_train_val": 0.02407, "r2_test": 0.99911,
        "r2_ceiling_est": 0.999655, "fold_sd": 0.0003},
    "11 列 + 默认参数(第 26 章基线)": {
        "val_curve_flat": False, "gap_train_val": 0.02407, "r2_test": 0.96156,
        "r2_ceiling_est": 0.999655, "fold_sd": 0.0003},
    "过拟合明显(开口 0.09)": {
        "val_curve_flat": False, "gap_train_val": 0.09, "r2_test": 0.95,
        "r2_ceiling_est": 0.999655, "fold_sd": 0.0003},
}
for tag, rep in cases.items():
    verdict, reason = next_action(rep)
    print(f"{tag}")
    print(f"  → {verdict}:{reason}")

期望输出:

text
本数据现状(21 列 + 默认参数)
  → 停:距估计上限仅 0.00054 < 2×折间标准差 0.00060 → 余量已在噪声内,继续投入的期望收益为 0
11 列 + 默认参数(第 26 章基线)
  → 加数据:验证线仍在上升 → 加数据有明确收益(开口 0.02407)
过拟合明显(开口 0.09)
  → 正则化:开口 0.09000 > 0.05 → 先减复杂度(早停/正则)

★ 这个函数的判据顺序就是本章的结论

第一问:余量还在不在噪声里?
   → 是 → 【停】(本章的答案)
   ↓
第二问:验证线还在涨吗?
   → 是 → 加数据(上一本第 21 章的判据)
   ↓
第三问:开口大吗?
   → 大 → 先减复杂度(第 23~25 章)
   ↓
第四问:余量还大吗?
   → 大 → 改特征(真正能改变局面的动作)
   ↓
最后才轮到调参

★ 为什么"停"要放在第一位?

因为"停"是最难做的决定,也是最容易被忽略的
   ↓
当一个团队已经投入了两周在调参上,承认"该停了"很难
   ↓
所以把它做成【第一个判断分支】—— 只要余量在噪声内,就直接返回"停"
   ↓
【把"停"的判断写在工具里,它就变成流程的一部分,
   而不是一个需要勇气的口头决定】

★ 三个测试用例故意覆盖了三种处境

① 本数据现状:特征已经吃到顶 → 停
② 11 列基线:验证线还在涨、但分数只有 0.96 → 加数据
   ⚠️ 注意这里的判断依赖 `val_curve_flat=False`
      如果把它改成 True,函数会跳到"改特征"——
      而真实答案确实是"改特征"(这本书第 15 章做的事)
   → 说明这个字段要填准(它来自学习曲线,不是猜的)
③ 开口 0.09:典型的过拟合 → 正则化
   ⚠️ 注意它排在"改特征"前面:
      因为开口大说明"当前模型在背噪声",
      这时换特征可能只是换一批噪声来背

面向工程实践:把"不做什么"写进交付物 ​

① 这一章的价值不在"调参没用",而在"知道什么时候该停"

真实的项目失败往往不是"做错了什么",
而是"在该停的地方没停"
   ↓
本数据上:
  测试 R² 0.99911,估计上限 0.999655,余量 0.00054
  折间标准差 0.00001 ~ 0.0003
   ↓
【余量和噪声同量级 → 该停】
   ↓
如果继续投入:
  调参 2 周 → 期望收益 < 0.0005(可能在噪声里看不见)
  做集成 1 周 → 期望收益 < 0.0005(第 29 章实测 −0.00002)
   ↓
【这两周应该花在别的地方:上线、监控、文档、下一批特征】

② 一份"投入产出"记录的模板

markdown
## 实验 027:精度优化的投入产出盘点

### 现状
| 指标 | 值 |
|---|---|
| 测试 R²(21 列 + 默认 HistGB) | 0.99911 |
| 训练残差估计的可解释方差比例 | 0.999655 |
| 余量 | 0.00054 |
| 分组 3 折折间标准差 | 0.00001 ~ 0.0003 |
| 朴素基线(直接取 soh_lag1) | 0.99797 |

### 各手段的实测收益
| 手段 | Δ 测试 R² | 成本 | 结论 |
|---|---|---|---|
| 加 4 列历史 SOH | **+0.03549** | 低(已在特征表里) | ✅ 已做 |
| 正则化(Ridge α=100) | +0.00054 | 极低 | ⚠️ 收益在噪声内,可选 |
| 加迭代到 400 轮 | −0.00005 | 3.3 倍训练时间 | ❌ 不做 |
| 调参(5 组网格) | +0.00004 | 12.8 s | ❌ 不做 |
| 三模型融合 | −0.00002 | 3 倍训练 + 3 倍推理 | ❌ 不做 |

### 决策
- ❌ **停止超参搜索**:五组极差 0.00004 < 2×折间标准差
- ❌ **不做融合**:融合 0.99908 < 最好单模型 0.99910
- ✅ **保持默认超参 + 早停**(早停省 82 倍耗时,精度无损失)
- 🔜 **下一步**:把精力移到"下一批特征"和"上线监控"上

### 遗留问题
- 噪声上限是用训练残差估的(偏乐观),建议用独立数据重估一次
- 朴素基线 0.99797 说明规律极简单(一行代码即拿到 99.89% 的分数),需评估"模型是否值得引入"

★ 这份记录里最值钱的是最后两个小节

"决策"里的三个 ❌  + "遗留问题"里的两条
   ↓
它们回答了"为什么不继续做"和"还有什么不确定"
   ↓
【一份只写"做了什么"的记录,六个月后无法回答"为什么不做"】

③ 这一章与上一本第 21 章的呼应(本章的核心)

text
上一本第 21 章(学习曲线):
  验证线在 8,496 行之后走平
    → 结论:加数据收益有限,【该改特征】
      ↓
这本书真的去改了特征(第 5~16 章),把 R² 从 0.96503 推到 0.99904
      ↓
本章(第 27 章):
  改完特征之后,调参、正则化、融合的收益全部落在千分之几
    → 结论:【调参更没必要了】
      ↓
【两章合起来是一条完整的推理链:
   "先排除数据 → 再改特征 → 改完特征之后连模型侧也没得调了"】

★ 这条链条最有价值的地方是它的"排除法"结构

不是"我觉得特征重要",而是:
  ① 加数据:实测 +0.011(且会走平)→ 排除
  ② 换模型:四路横评极差 0.0145 → 排除
  ③ 调参:五组极差 0.00004 → 排除
  ④ 融合:−0.00002 → 排除
      ↓
剩下的唯一方向就是"特征" —— 而且实测它确实能动 0.035
      ↓
【每一步都有数字,不是立场】

④ 本章的位置

第 23 章 → 诊断:开口大不大、在不在扩大
第 24 章 → 治疗(时间维):早停
第 25 章 → 治疗(结构维):L1 / L2 / max_depth
第 26 章 → 流程:怎么系统地搜这些旋钮
第 27 章 → 收口:这些旋钮一共能动多少        ← 本章
第 28 章 → 多窗口平均 + 口径纪律
第 29 章 → 模型融合 + 权重纪律

本章是第 23~26 章的"账本",也是整本书的转折点: 从这里开始,"模型侧"的所有手段都被证明只有千分之几的量级 —— 第 30 章会把这份账本做成一份可交付的报告。


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