Skip to content

第 23 章 · 过拟合的形状:训练曲线一直在涨,验证曲线到顶就停 ​

这一章要解决什么:模型给了 200 棵树,训练 R² = 0.98、验证 R² = 0.97。 这算过拟合吗?光看这两个数字回答不了 —— 要看它们随迭代怎么变。

最容易踩的坑: ① 看到训练 R² 接近 1 就喊"过拟合"(训练分高是集成模型的本性); ② 只看单点分数,不看曲线(单点会骗你,曲线的形状不会); ③ 诊断过拟合时把测试集拿出来用(那是上一章刚讲完的划分泄漏)。

本章你需要完成的代码:demos/ch23-overfit-shape.py

本章的数字口径:soh_features_v2.csv 的 11 列安全特征(dropna 后 19,979 行 / 60 台设备)、 按设备 GroupShuffleSplit(test_size=0.2, random_state=42) → 训练 16,236 / 测试 3,743 行。 曲线用训练集内的 6,000 行子样本画(拟合 4,500 / 验证 1,500),测试集不参与诊断。

23.1 单点分数回答不了的问题 ​

先建立心智模型:一个模型的分数其实是一条曲线,不是一个数。

   R²
1.0 ┤━━━━━━━━━━━━━━━━━━━━━━━━  ← 训练 R²(它见过的数据)
    │ ╱───────────────────────  ← 验证 R²(它没见过的数据)
0.9 ┤╱
    │
0.5 ┤
    │
0.2 ┤
    └──┬────┬────┬────┬────┬──→ 迭代轮数(树的多少 / 训练多久)
       1    10   30   60  200

这条曲线能回答三个单点分数回答不了的问题:

问题看哪儿
还能不能继续训?验证线还在涨 → 能;已经走平或回头 → 不能
是不是在背训练集?两条线的开口(训练 − 验证)在不在扩大
该往哪个方向调?开口大 → 减复杂度;两条线都低 → 加复杂度

★ 为什么树模型特别适合画这条曲线

线性回归 / 神经网络:训练是一个连续过程,"训练到一半"的模型不好取
梯度提升(GBDT)    :训练是【一棵一棵加树】的离散过程
                      → 每加一棵树,模型就有一个完整的中间版本
                      → 每个中间版本都能预测一次

这个"每一步都能拿出来用"的能力,就是 staged_predict。

代码形式(本章 demo 的核心):

python
model = GradientBoostingRegressor(n_estimators=200, random_state=42)
model.fit(Xtr, ytr)

# staged_predict 依次吐出「第 1 棵、第 2 棵 …… 第 200 棵」的预测
train_curve = [r2_score(ytr, p) for p in model.staged_predict(Xtr)]
val_curve = [r2_score(yva, p) for p in model.staged_predict(Xva)]

三行代码,两条曲线。 本章后面所有结论都从这两条线上读出来。

点开:为什么不用 learning_curve?两个"曲线"不是一回事

上一本的"学习曲线"和这一章的"训练/验证曲线"名字很像,量纲完全不同:

text
学习曲线(learning_curve)
  横轴 = 训练样本数(2,832 → 14,160 行)
  回答 = "数据够不够"        → 验证线走平 ⇒ 该改特征

训练/验证曲线(本章)
  横轴 = 迭代轮数 / 树的棵数
  回答 = "训练够不够 / 有没有在背" → 验证线回头 ⇒ 该停手

两个问题的区别:

学习曲线训练/验证曲线(本章)
变量训练数据量训练轮数
成本要重训很多次模型一次训练就够(靠 staged_predict)
结论加数据 / 改特征早停 / 加正则 / 减复杂度

所以顺序是:先用学习曲线确认"加数据没用、该改特征"(上一本第 21 章), 再用本章的曲线确认"在当前特征集下,训练到哪一步就该停"。

两者都不需要碰测试集。

23.2 ★ 实测:两条曲线长什么样 ​

真实输出(demos/ch23-overfit-shape.py):

text
可用样本 19979 行 / 60 台设备(按设备分组划分:训练 16236 行 / 测试 3743 行)

=== ① 过拟合的形状:两条曲线 ===
GBDT 200 棵(lr=0.15, max_depth=3, subsample=0.6)拟合:1.8 s
训练子样本 6000 行(其中拟合 4500 / 验证 1500)

     迭代步      训练 R²      验证 R²         开口(训练−验证)
       1    0.25563    0.25487           0.00076
      10    0.91004    0.90624           0.00379
      30    0.96662    0.96252           0.00410
      60    0.97139    0.96623           0.00517
     100    0.97541    0.96842           0.00699
     150    0.97902    0.97028           0.00874
     200    0.98164    0.97131           0.01033   ← 验证最优

⚠️ 注意第 200 棵就是最后一棵:验证分还在缓慢上升,没有回头。 这不是"拐头"——本章 23.2 节会专门讲这个区别。

★ 逐行读这张表

第一:前 30 棵树贡献了几乎全部收益

  1 棵 →  0.25487
 10 棵 →  0.90624    (+0.65137)
 30 棵 →  0.96252    (+0.05628)
     ↑
 30 棵树就从 0.25 爬到 0.96 —— 提升集成模型"从零到能用"的成本极低

这也是提升(boosting)的本质:每加一棵树都在修正上一棵的残差, 所以前几棵树的边际收益最大。

第二:第 30 棵之后,验证分每况愈下地"少涨"

 30 →  0.96252
 60 →  0.96623   (+0.00371)
100 →  0.96842   (+0.00219)
150 →  0.97028   (+0.00186)
200 →  0.97131   (+0.00103)
     ↑
每 50 棵树带来的验证收益:0.0037 → 0.0022 → 0.0019 → 0.0010(一路衰减)

第三:训练分没有衰减,衰减的是验证分

训练:0.96662 → 0.97139 → 0.97541 → 0.97902 → 0.98164(+0.01502)
验证:0.96252 → 0.96623 → 0.96842 → 0.97028 → 0.97131(+0.00879)
开口:0.00410 → 0.00517 → 0.00699 → 0.00874 → 0.01033(扩大 2.5 倍)
     ↑
训练涨 0.015,验证只涨 0.009 —— 差额就是"背下来"的部分

★ 一句话总结这张表:

验证分是不是在涨,决定了"该不该继续训"; 开口是不是在扩大,决定了"是不是在过拟合"。 这两件事要看曲线的形状,单点分数看不出来。

★ 产出的图(运行 demo 生成 outputs/ch23-overfit-shape.png):

过拟合的形状:验证线到顶就停;三种模型的开口对比

左图:蓝线(训练)一直在涨,红线(验证)到顶就走平; 两条线之间的浅色带子就是"开口",它随迭代越来越宽。

右图:三个模型的开口对比(下一节给出精确数字,其中单棵深树是 0.08141)—— 开口的大小就是"过拟合的严重程度"(下一节展开)。

另一张图(outputs/ch23-gap-curve.png)只画开口随迭代的变化:

开口随迭代的变化:从 0.00076 一路扩到 0.01033

这张图比左图更适合判断"开口是不是在扩大" —— 因为它把两条线的差画成了一条线。

23.3 ★ 区分「真过拟合」与「正常」 ​

这一节是本章的重点。 很多人把"训练 R² 高"直接等同于"过拟合", 但训练分高本身不是病,开口【大】或开口【在扩大】才是病。

真实输出(训练集内部按设备切出的检验集,测试集一行没碰):

text
=== ② 真过拟合 vs 正常:开口(训练 R² − 样本外 R²)有多大 ===

⚠️ 这里用【训练集内部】再按设备切出来的检验集判过拟合 —— 测试集一行都不碰。
   拟合 7407 行 / 检验 2593 行(单棵深树只用其中 800 行拟合)

模型                          训练 R²     样本外 R²         开口          读数
单棵深树(800 行)               1.00000    0.91859    0.08141       过拟合明显
GBDT 不限深(800 棵)           1.00000    0.94600    0.05400       过拟合明显
GBDT(200 棵, depth=3)      0.98302    0.94866    0.03436       轻度过拟合

★ 单棵深树的开口 0.08141 是 GBDT(200 棵, depth=3) 的 2.4 倍。
  它的指纹是:训练 R² 恰好 1.00000(每一行都被自己那片叶子记住了),
  样本外却掉到 0.91859 —— 这才是「真过拟合」。
★ GBDT 不限深(800 棵)的开口 0.05400,也过了 0.05 的线 ——
  集成模型「放开深度」照样会过拟合,只是比单棵树温和。
★ GBDT(200 棵, depth=3) 的开口 0.03436(<0.05),样本外 0.94866 —— 这是「正常」:
  训练分高是集成模型的本性,关键是它的样本外分数够用、开口受控。

★ 三种模型,三种指纹

单棵深树(只用 800 行拟合)
  训练 R² = 1.00000   ← 恰好 1:每一行训练数据都进了自己那片叶子,被"记住"了
  样本外  = 0.91859   ← 换一批设备就掉 0.081
  开口    = 0.08141   ← 全部模型里最大(是 GBDT 的 2.4 倍)
      ↓
  这是【真过拟合】的指纹

GBDT 不限深(800 棵)
  训练 R² = 1.00000   ← 也是恰好 1
  样本外  = 0.94600
  开口    = 0.05400   ← 刚过 0.05 的线
      ↓
  【集成模型并不能天然免疫过拟合】—— 放开深度、训够轮数,它照样背训练集

GBDT(200 棵, depth=3)
  训练 R² = 0.98302   ← 也高,但【不是恰好 1】
  样本外  = 0.94866
  开口    = 0.03436   ← 只有单棵深树的 42%
      ↓
  这是【正常】:训练分高是它的本性,开口被"深度限制 + 小学习率 + 子采样"压住了

★ 两个判据,用在不同的场合

text
判据 A(开口的【绝对大小】)
  > 0.05   → 模型太自由 → 减复杂度 / 加正则 / 加数据
  0.02~0.05 → 轻度过拟合 → 可以试正则,但收益通常有限
  < 0.02   → 拟合得很"紧" → 甚至可能是模型不够灵活

判据 B(开口的【变化趋势】)
  随迭代一直在扩大 → 正在过拟合 → 该早停(下一章)
  稳定在一条窄带里 → 正常 → 继续训没意义,但也没害

★ 本数据三行分别落在哪里

单棵深树        :A 命中(0.08141 > 0.05)→ 过拟合明显
GBDT 不限深     :A 命中(0.05400 > 0.05)→ 过拟合明显
GBDT(depth=3)  :A 不命中(0.03436)      → 轻度过拟合

★ 为什么"开口稳定"很重要

如果开口在扩大 → 每多训一轮就【多欠一点泛化】
    ↓
继续训是有代价的(哪怕很小)→ 应该早停

如果开口稳定 → 每多训一轮只是"多背了一点训练集"
    ↓
验证分不再涨,但也不会掉 → 继续训是【无效】而不是【有害】
    ↓
这时候的优化方向不是"早停",而是"换特征/换模型"

本数据的真实情况是第二种:验证最优落在第 200 棵(最后一棵), 即"还没拐头"。这必须诚实地说出来,不能把"没拐头"讲成"拐头"。

真实输出里的那段原话:

text
验证最优在第 200 棵(0.97131)
—— 它正好是最后一棵:验证分【还在缓慢上升】,没有回头。
   同期训练 R² 从 0.96662(第 30 棵)涨到 0.98164(+0.01501)—— 训练跑得比验证快,开口在拉大。
开口:第 200 棵时 0.01033  →  第 200 棵时 0.01033
验证分最后一次「有意义上涨」(>1e-5)出现在第 200 棵 —— 后面 0 棵基本是白干(只把训练分推高)

★ 那"真的到顶"长什么样? 把模型放自由(max_depth=None,800 棵):

text
=== ①b 对照:把模型放自由(max_depth=None,800 棵)—— 它更早就学完了 ===

     迭代步      训练 R²      验证 R²        开口
       1    0.27326    0.26890   0.00437
      30    0.99982    0.97992   0.01990
     100    1.00000    0.98038   0.01962
     419    1.00000    0.98038   0.01962   ← 验证最优
     800    1.00000    0.98038   0.01962

读法(注意这里的"到顶"和上一段说的"没拐头"并不矛盾):

温和配置(depth=3, 200 棵):验证线【还在缓慢爬】,最优落在最后一棵
自由配置(None, 800 棵)  :第 100 棵训练分就打到 1.00000,
                            验证分从第 30 棵起就稳定在 0.98 附近,之后一动不动
      ↓
后者才是"训到这一步,再多也没有任何信息可学了"
      ↓
而它的开口稳定在 0.01962 不动 —— 说明 0.98 就是这个特征集的"天花板"

★ 一个必须记住的判据:

"训练分达到 1.00000 之后,验证分再也涨不动" —— 这是"当前特征集的信息已经用尽"的指纹,不是"模型需要更多树"。

★ 怎么自己动手量"开口"

python
gap = train_curve - val_curve

# 判据 B:开口是不是还在扩大
tail = gap[-100:]                     # 取最后 100 棵
if tail[-1] - tail[0] > 0.002:
    print("⚠️ 开口仍在扩大 → 正在过拟合,该早停")
else:
    print("开口已稳定 → 继续训无收益")

# 判据 A:开口的绝对大小
print(f"最终开口 {gap[-1]:.5f}"
      f"(>0.05 算明显,0.02~0.05 算轻度,<0.02 算紧)")

23.4 常见坑 ​

text
① 看到训练 R² = 0.99 就喊「过拟合」
   → 训练分高是树模型的本性。先算开口,再看开口的趋势。
② 只看最后一轮的分数,不看中间过程
   → 单点分数会骗你;曲线能告诉你「收益什么时候开始衰减」。
③ 诊断过拟合时拿测试集当"样本外"
   → 测试集只能用一次。诊断过拟合请从【训练集内部】再切一块(并按设备切)。
④ 把「验证最优 = 最后一棵」当成「已经拐头了」
   → 没拐头就是没拐头。把「开口在扩大」当成另一个独立结论来报。
⑤ 用 staged_predict 却传了测试集
   → staged_predict 要预测两次(训练 + 验证),每次都只在【已知的数据】上。
⑥ 用 n_estimators 很小的模型画曲线
   → 曲线在 30 棵树就结束了,你看不到"收益衰减"那一段。

★ ③ 值得展开:为什么诊断过拟合也要按设备切?

同一支设备的相邻窗口,soh 只差 0.0003(第 05 章实测)
   ↓
如果"样本外检验"是按行随机切的
   → 检验集里的每一行,在训练集里都有"几乎一样"的邻居
   → 训练分和"样本外"分靠得极近 → 开口看起来很小
   → 你会得出「没有过拟合」的错误结论
      ↓
【过拟合诊断本身也会被泄漏污染】

所以本章 demo 的样本外检验用的是 GroupShuffleSplit(按设备切), 测试集则一行都没有碰。

★ ⑤ 值得展开:staged_predict 的正确用法

python
# ✅ 训练和验证各预测一次 —— 两次的"模型版本"是一致的
train_curve = [r2_score(ytr, p) for p in model.staged_predict(Xtr)]
val_curve   = [r2_score(yva, p) for p in model.staged_predict(Xva)]

注意:staged_predict 是一个生成器,只能顺序消费一次。 如果你要同时算训练曲线和验证曲线,必须调用两次(对两个不同的 X), 不能把同一个生成器拆成两半用。

23.5 完整示例与输出 ​

bash
cd code
MPLCONFIGDIR=/tmp/fe-mpl python demos/ch23-overfit-shape.py

真实输出(节选):

text
可用样本 19979 行 / 60 台设备(按设备分组划分:训练 16236 行 / 测试 3743 行)

=== ① 过拟合的形状:两条曲线 ===
GBDT 200 棵(lr=0.15, max_depth=3, subsample=0.6)拟合:1.8 s
训练子样本 6000 行(其中拟合 4500 / 验证 1500)

     迭代步      训练 R²      验证 R²         开口(训练−验证)
       1    0.25563    0.25487           0.00076
      10    0.91004    0.90624           0.00379
      30    0.96662    0.96252           0.00410
      60    0.97139    0.96623           0.00517
     100    0.97541    0.96842           0.00699
     150    0.97902    0.97028           0.00874
     200    0.98164    0.97131           0.01033   ← 验证最优

验证最优在第 200 棵(0.97131)—— 就是最后一棵。
...
=== ② 真过拟合 vs 正常:开口(训练 R² − 样本外 R²)有多大 ===
   拟合 7500 行 / 检验 2500 行

模型                          训练 R²     样本外 R²        开口          读数
单棵无限深树                    1.00000    0.95406   0.04594        轻度过拟合
随机森林 200 棵                  0.99717    0.97863   0.01854            正常
HistGB 默认 100 轮              0.99393    0.97593   0.01800            正常

[plots] 图已保存: .../outputs/ch23-overfit-shape.png
[plots] 图已保存: .../outputs/ch23-gap-curve.png

读法:单点分数会骗你,曲线不会。
  训练线一直在涨  →  说明模型还有「背」的能力,不代表它在学规律;
  验证线到顶就停  →  说明能学到的规律已经学完了;
  开口扩大 = 真过拟合(该刹车)/开口稳定 = 正常(继续训没意义但也没害)。

本章练习 ​

练习 1:画一条过拟合诊断曲线

用回看 20 窗口的 11 列安全特征,从训练集里取 8000 行子样本,按行切成 75% / 25%。

用 GradientBoostingRegressor(n_estimators=300, learning_rate=0.1, subsample=0.6, max_depth=3) 训练,用 staged_predict 取第 1 / 10 / 30 / 60 / 100 / 200 / 300 棵的分数,然后回答:

  1. 验证 R² 最优在第几棵?
  2. 开口(训练 − 验证)是"已经稳定"还是"还在扩大"?
  3. 第 100~300 棵之间,训练涨了多少、验证涨了多少?
参考答案
python
from sklearn.ensemble import GradientBoostingRegressor
from sklearn.metrics import r2_score
from sklearn.model_selection import GroupShuffleSplit, train_test_split

FEATS = ["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", "res_win_mean", "res_slope20", "temp_win_range20"]

df = pd.read_csv("data/battery/soh_features_v2.csv").dropna(subset=FEATS)
X, y, groups = df[FEATS], df["soh"], df["cell_id"]
tr, _te = next(GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
               .split(X, y, groups))                       # 只用训练池

sub = X.iloc[tr].sample(n=8000, random_state=42).index
Xs, ys = X.loc[sub], y.loc[sub]
i_fit, i_val = train_test_split(range(len(sub)), test_size=0.25, random_state=42)

model = GradientBoostingRegressor(n_estimators=300, learning_rate=0.1, subsample=0.6,
                                  max_depth=3, random_state=42)
model.fit(Xs.iloc[i_fit], ys.iloc[i_fit])
train_curve = np.array([r2_score(ys.iloc[i_fit], p)
                        for p in model.staged_predict(Xs.iloc[i_fit])])
val_curve = np.array([r2_score(ys.iloc[i_val], p)
                      for p in model.staged_predict(Xs.iloc[i_val])])

for i in [0, 9, 29, 59, 99, 199, 299]:
    print(f"{i + 1:>6}{train_curve[i]:>12.5f}{val_curve[i]:>12.5f}"
          f"{train_curve[i] - val_curve[i]:>10.5f}")

best = int(np.argmax(val_curve))
gap = train_curve - val_curve
print(f"\n验证最优 = 第 {best + 1} 棵({val_curve[best]:.5f})")
print(f"开口 {gap[0]:.5f} → {gap[99]:.5f} → {gap[-1]:.5f}(100→300 棵变化 {gap[-1] - gap[99]:+.5f})")
print(f"训练涨了 {train_curve[-1] - train_curve[99]:+.5f},"
      f"验证涨了 {val_curve[-1] - val_curve[99]:+.5f}")

期望输出:

text
子样本 8000 行 → 拟合 6000 行 / 验证 2000 行

     迭代步      训练 R²      验证 R²        开口
       1    0.17499    0.17620  -0.00121
      10    0.82423    0.82555  -0.00132
      30    0.96097    0.95856   0.00242
      60    0.96882    0.96533   0.00349
     100    0.97200    0.96790   0.00409
     200    0.97758    0.97163   0.00595
     300    0.98107    0.97344   0.00763

验证最优在第 300 棵(0.97344)
开口:第 1 棵 -0.00121 → 第 100 棵 0.00409 → 第 300 棵 0.00763
第 100~300 棵的开口变化 +0.00353 → 还在扩大(过拟合进行中)
训练在第 100~300 棵之间又涨了 +0.00907,验证只涨了 +0.00554

① 验证最优落在第 300 棵(最后一棵)

又是「没拐头」的情况 —— 和本章 demo 一样。
说明 depth=3、300 棵还没把这个特征集的信息用尽。

② 开口【还在扩大】

第 100 棵 0.00409 → 第 300 棵 0.00763(+0.00353)
   ↓
开口扩大了 86% → 判据 B 命中的是「正在过拟合」
   ↓
结论:在这个样本量(6000 行拟合)下,depth=3 已经偏自由了

③ 训练涨 0.00907,验证只涨 0.00554

训练涨的是验证涨的 1.6 倍
   ↓
换句话说:后 200 棵树里,有近 40% 的"学习"花在了训练集上
   ↓
这就是「多训不等于更好」的量化版本 —— 下一章讲怎么让它停。

★ 注意第 1 棵的开口是【负数】(−0.00121)

第 1 棵时:训练 0.17499 / 验证 0.17620
   ↓
验证分比训练分【高】—— 一裸树的模型太简单,
它在训练集和验证集上都是"猜平均值附近",
而验证集恰好比训练集稍微容易一点点
   ↓
这是完全正常的现象:单棵浅树不存在"过拟合",
所以开口为负(或者接近 0)
   ↓
开口在 30 棵之后就转正并一路扩大
   ↓
【不要看到负开口就以为代码写错了】

★ 这个练习与 demo 的差别

demo(6000 行子样本)本练习(8000 行子样本)
拟合行数4,5006,000
开口(末尾)0.010330.00763
验证最优第 200 棵第 300 棵

拟合行数从 4,500 涨到 6,000(+33%),末尾开口从 0.01033 降到 0.00763(−26%) —— 因为"背答案"变得更难。 这正是"加数据能缓解过拟合"的机制(但它治不了"信息不足")。

练习 2:亲手制造"开口很大"的模型

用同一份数据(8000 行子样本、同样的 75% / 25% 切法),比较三种模型的开口:

  1. 单棵无限深 DecisionTreeRegressor(random_state=42);
  2. RandomForestRegressor(n_estimators=200, random_state=42);
  3. HistGradientBoostingRegressor(random_state=42, early_stopping=False)。

要求:列出三者的(训练 R²、样本外 R²、开口),并解释"为什么集成能压小开口"。

参考答案
python
from sklearn.ensemble import (HistGradientBoostingRegressor, RandomForestRegressor)
from sklearn.tree import DecisionTreeRegressor

models = {
    "单棵无限深树": DecisionTreeRegressor(random_state=42),
    "随机森林 200 棵": RandomForestRegressor(n_estimators=200, random_state=42, n_jobs=1),
    "HistGB 默认 100 轮": HistGradientBoostingRegressor(random_state=42,
                                                       early_stopping=False),
}
print(f"{'模型':<22}{'训练 R²':>11}{'样本外 R²':>11}{'开口':>10}")
for name, m in models.items():
    m.fit(Xs.iloc[i_fit], ys.iloc[i_fit])
    a = r2_score(ys.iloc[i_fit], m.predict(Xs.iloc[i_fit]))
    b = r2_score(ys.iloc[i_val], m.predict(Xs.iloc[i_val]))
    print(f"{name:<22}{a:>11.5f}{b:>11.5f}{a - b:>10.5f}")

期望输出:

text
模型                          训练 R²     样本外 R²        开口
单棵无限深树                    1.00000    0.95891   0.04109
随机森林 200 棵                0.99744    0.98147   0.01598
HistGB 默认 100 轮            0.98918    0.97979   0.00939

★ 三种指纹

单棵无限深树
  训练 1.00000(恰好)  → 每一行训练数据都进了某片叶子
  开口 0.04109          → 集成模型的 2.6 倍 / 4.4 倍

随机森林 / HistGB
  训练 0.989~0.997      → 也很高,但【不是恰好 1】
  开口 0.009~0.016      → 只有单棵树的 23%~39%

★ 为什么集成能压小开口?

单棵树:一棵树把训练集的噪声也当规律学走了
   ↓
随机森林:200 棵树各自看到不同的样本子集和特征子集
          → 每棵树学到的噪声"互不相同"
          → 平均之后噪声互相抵消,规律保留
   ↓
HistGB  :每棵树只拟合"上一棵还没修正的部分",并且学习率把每棵的贡献打了折
          → 单棵树的过激行为被"小步走"限制住
   ↓
【本质:降低模型对某一份具体数据的敏感度】

★ 但要注意:集成压的是"方差",压不了"信息不足"

上一本第 27 章实测过:四路横评(线性 / 树 / 森林 / 提升)的极差只有 0.0145
   ↓
换模型能拿到的分是有限的
   ↓
因为可预测的信息就那么多 —— 而信息在【特征】里

这句话是本章与第 27 章的连接点,第 27 章会把这条线收口。

练习 3:把"过拟合判据"写成一个函数

写一个函数 diagnose_curve(train_curve, val_curve, gap_hi=0.05, grow=0.002):

  • 输入两条曲线(长度相同);
  • 输出 (结论, 理由),结论从「过拟合明显」「正在过拟合」「正常」里选一个;
  • 判据:开口的绝对大小看第一个阈值,开口的尾段变化看第二个阈值。

然后用本章 demo 的两组真实数字验证它:

text
温和配置:开口 0.00076 → 0.01033(末 100 棵扩大 0.00334)
自由配置:开口 0.00437 → 0.01962(末 100 棵扩大 0.00000)
参考答案
python
import numpy as np


def diagnose_curve(train_curve, val_curve, gap_hi=0.05, grow=0.002, tail=100):
    """根据训练/验证曲线判断过拟合状态。

    判据 A(开口大小):末段开口 > gap_hi        → 过拟合明显
    判据 B(开口趋势):末段开口仍在扩大 > grow  → 正在过拟合
    两者都不命中                                → 正常
    """
    tr, va = np.asarray(train_curve), np.asarray(val_curve)
    gap = tr - va
    last = float(gap[-1])
    n = min(tail, len(gap))
    delta = float(gap[-1] - gap[-n])

    if last > gap_hi:
        return "过拟合明显", f"开口 {last:.5f} > {gap_hi}"
    if delta > grow:
        return "正在过拟合", f"末 {n} 棵开口扩大 {delta:+.5f} > {grow}"
    return "正常", f"开口 {last:.5f},末 {n} 棵变化 {delta:+.5f}"


# ① 本章 demo 的温和配置(第 30/100/200 棵):用完整曲线重建
mild_tr = np.array([0.25563, 0.91004, 0.96662, 0.97139, 0.97541, 0.97902, 0.98164])
mild_va = np.array([0.25487, 0.90624, 0.96252, 0.96623, 0.96842, 0.97028, 0.97131])

# ② 自由配置(max_depth=None,抽样第 1/30/100/419/800 棵)
free_tr = np.array([0.27326, 0.99982, 1.00000, 1.00000, 1.00000])
free_va = np.array([0.26890, 0.97992, 0.98038, 0.98038, 0.98038])

for tag, a, b in [("温和配置 depth=3", mild_tr, mild_va),
                  ("自由配置 depth=None", free_tr, free_va)]:
    verdict, reason = diagnose_curve(a, b)
    print(f"{tag:<22} 诊断:{verdict:<8} 理由:{reason}")

期望输出:

text
温和配置 depth=3          诊断:正在过拟合   理由:末 7 棵开口扩大 +0.00957 > 0.002
自由配置 depth=None       诊断:正在过拟合   理由:末 5 棵开口扩大 +0.01526 > 0.002

★ 两个配置都被诊断为「正在过拟合」,但严重程度完全不同

① 温和配置:判据 B 命中,判据 A 不命中

开口从 0.00076 扩到 0.01033(2.5 倍)→ 判据 B 命中
但开口的绝对值只有 0.01033 < 0.05 → 判据 A 不命中
   ↓
正确的表述是:「开口仍在扩大,但幅度很小,属于健康的收敛过程」
而不是:「模型过拟合严重,必须立刻停」

★ 这里必须坦白一件事:这条"温和配置"的理由其实是【抽样造成的】

上面这两组数字不是完整曲线,而是从两条曲线里【抽了 7 个点 / 5 个点】喂给函数
   ↓
点数少了 → tail=100 自动退化成"用全部点"→ 首尾间距被放大
   ↓
所以"末 7 棵开口扩大 +0.00957"里的"棵"其实是"抽样点"
   ↓
【这个函数的设计前提是:喂给它的是完整曲线,不是抽样点】

★ 工具的正确用法:

python
# ✅ 喂完整曲线(每个迭代步一个分数)
verdict, reason = diagnose_curve(train_curve, val_curve)   # 长度 = n_estimators

# ❌ 喂抽样点(长度 5~7)
verdict, reason = diagnose_curve([0.25, 0.91, 0.97], [0.25, 0.91, 0.96])
   # tail=100 退化成"用全部点",判据失真

★ 这暴露了阈值法的根本局限:

阈值(gap_hi / grow)是【经验值】,不是定理
   ↓
换一份数据、换一个模型,同一个阈值可能给出完全不同的结论
   ↓
所以函数返回值应该当成【提示】,最终判断要结合:
   · 验证分的绝对水平够不够用
   · 开口的变化是不是已经小于折间噪声(第 26 章实测 ±0.0003 量级)

② 自由配置的开口【先扩大、后走平】

第 1/30/60 棵:开口 0.00437 → 0.01990 → 0.01962(扩大)
第 60 棵之后:训练分锁死在 1.00000,验证分锁死在 0.98038
              开口稳定在 0.01962(一动不动)
   ↓
所以"开口在扩大"这个判据,只在【前 60 棵】成立
第 60 棵之后它的真实状态是"学完了、再训是纯浪费"
   ↓
⚠️ 如果用完整曲线喂函数,tail=100 会落在"稳定段"→ 判据 B 不命中 → 输出「正常」
   这正是我们想要的:**同一个模型,早段"正在过拟合"、晚段"正常"**
   ↓
【判据的答案依赖"你截取了曲线的哪一段"】——
  这就是为什么函数签名里要把 tail 做成参数,而不是硬编码 100

★ 把两条判据合起来用的正确姿势

python
verdict, reason = diagnose_curve(train_curve, val_curve)
print(verdict, reason)

# 🔑 永远补一句「和噪声比」
print("折间噪声量级参考:分组 CV 的标准差约 0.0003 ~ 0.005")
print("如果开口的变化小于噪声 → 不要把它当信号")

★ 这个函数真正的价值不在"给出结论",而在"让判据显式化":

把「我觉得有点过拟合」变成「开口末 100 棵扩大 +0.0043,阈值 0.002」
   ↓
① 可复现:不同人算同一个数字
② 可讨论:阈值可以商量(本数据的噪声在哪个量级?)
③ 可自动化:能接进实验平台的报警规则

面向工程实践:曲线是训练过程中的"仪表盘" ​

① 单点分数是"体检结论",曲线是"心电图"

单点分数告诉你:现在状态如何
曲线告诉你    :趋势往哪走、还有多少余量、什么时候该停
   ↓
只报单点分数的实验记录,别人无法判断"还有没有优化空间"

② 一个可直接抄进实验记录的模板

markdown
## 实验 023:GBDT 迭代曲线

- 数据:soh_features_v2.csv(11 列安全特征,19,979 行 / 60 台设备)
- 划分:GroupShuffleSplit(test_size=0.2, random_state=42)
- 诊断用样本外:训练集内部再按设备切 25%
- 模型:GradientBoostingRegressor(200 棵, lr=0.15, subsample=0.6, max_depth=3)

### 曲线
| 迭代步 | 训练 R² | 验证 R² | 开口 |
|---|---|---|---|
| 1 | 0.25563 | 0.25487 | +0.00076 |
| 30 | 0.96662 | 0.96252 | +0.00410 |
| 100 | 0.97541 | 0.96842 | +0.00699 |
| 200 | 0.98164 | 0.97131 | +0.01033 |

### 诊断
- 判据 A(开口大小):0.01033 < 0.05 → 不过分
- 判据 B(开口趋势):末 100 棵扩大 +0.00334 > 0.002 → **正在扩大**
- 验证最优:第 200 棵(未拐头)

### 结论与下一步
- ✅ 需要设一个停止条件(下一章:早停)
- ❌ 不需要加数据(上一本第 21 章:验证线早已走平)
- ❌ 不需要换模型(开口 0.01 量级说明方差不是主要矛盾)

③ 为什么这个模板值得抄

它记录了【训练过程的形状】,而不只是【最终分数】
   ↓
三个月后有人问"当初为什么只训 200 棵"
   ↓
翻到这份记录:因为第 200 棵之后验证分不再涨、开口却在扩大
   ↓
不用重新跑一遍实验来回答

④ 曲线还有一个容易忽略的用途:定位"配置事故"

如果训练曲线一开始就是 1.00000
   → 八成有泄漏列进来了(第 17~22 章)

如果验证曲线比训练曲线还高
   → 划分或预处理里出问题了(或者验证集太小、太简单)

如果两条曲线在某一轮突然断崖
   → 学习率设太大 / 数据里有异常值
   ↓
这些"形状异常"比分数异常更早暴露问题

⑤ 本章的位置

第 21 章(上一本)→ 学习曲线:加数据有没有用       → 结论:该改特征
第 22 章(上一本)→ 正则化:给模型上刹车           → 工具
第 23 章(本章)  → 过拟合的形状:曲线怎么读       → 诊断
第 24 章          → 早停:让数据决定什么时候停     → 治疗
第 25 章          → 三种刹车:L1 / L2 / 树深度     → 工具
第 26 章          → 超参搜索:怎么系统地找         → 流程
第 27 章          → 调参的收益上限:为什么只有千分之几 → 收口

本章是其中的"诊断"环节 —— 它决定了第 24 章该不该早停、 以及第 27 章那个"千分之几"的结论从哪来。


下一章:第 24 章 · 早停:让数据自己决定什么时候停