第 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 的核心):
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?两个"曲线"不是一回事
上一本的"学习曲线"和这一章的"训练/验证曲线"名字很像,量纲完全不同:
学习曲线(learning_curve)
横轴 = 训练样本数(2,832 → 14,160 行)
回答 = "数据够不够" → 验证线走平 ⇒ 该改特征
训练/验证曲线(本章)
横轴 = 迭代轮数 / 树的棵数
回答 = "训练够不够 / 有没有在背" → 验证线回头 ⇒ 该停手两个问题的区别:
| 学习曲线 | 训练/验证曲线(本章) | |
|---|---|---|
| 变量 | 训练数据量 | 训练轮数 |
| 成本 | 要重训很多次模型 | 一次训练就够(靠 staged_predict) |
| 结论 | 加数据 / 改特征 | 早停 / 加正则 / 减复杂度 |
所以顺序是:先用学习曲线确认"加数据没用、该改特征"(上一本第 21 章), 再用本章的曲线确认"在当前特征集下,训练到哪一步就该停"。
两者都不需要碰测试集。
23.2 ★ 实测:两条曲线长什么样
真实输出(demos/ch23-overfit-shape.py):
可用样本 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)只画开口随迭代的变化:

这张图比左图更适合判断"开口是不是在扩大" —— 因为它把两条线的差画成了一条线。
23.3 ★ 区分「真过拟合」与「正常」
这一节是本章的重点。 很多人把"训练 R² 高"直接等同于"过拟合", 但训练分高本身不是病,开口【大】或开口【在扩大】才是病。
真实输出(训练集内部按设备切出的检验集,测试集一行没碰):
=== ② 真过拟合 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%
↓
这是【正常】:训练分高是它的本性,开口被"深度限制 + 小学习率 + 子采样"压住了★ 两个判据,用在不同的场合
判据 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 棵(最后一棵), 即"还没拐头"。这必须诚实地说出来,不能把"没拐头"讲成"拐头"。
真实输出里的那段原话:
验证最优在第 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 棵):
=== ①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 之后,验证分再也涨不动" —— 这是"当前特征集的信息已经用尽"的指纹,不是"模型需要更多树"。
★ 怎么自己动手量"开口"
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 常见坑
① 看到训练 R² = 0.99 就喊「过拟合」
→ 训练分高是树模型的本性。先算开口,再看开口的趋势。
② 只看最后一轮的分数,不看中间过程
→ 单点分数会骗你;曲线能告诉你「收益什么时候开始衰减」。
③ 诊断过拟合时拿测试集当"样本外"
→ 测试集只能用一次。诊断过拟合请从【训练集内部】再切一块(并按设备切)。
④ 把「验证最优 = 最后一棵」当成「已经拐头了」
→ 没拐头就是没拐头。把「开口在扩大」当成另一个独立结论来报。
⑤ 用 staged_predict 却传了测试集
→ staged_predict 要预测两次(训练 + 验证),每次都只在【已知的数据】上。
⑥ 用 n_estimators 很小的模型画曲线
→ 曲线在 30 棵树就结束了,你看不到"收益衰减"那一段。★ ③ 值得展开:为什么诊断过拟合也要按设备切?
同一支设备的相邻窗口,soh 只差 0.0003(第 05 章实测)
↓
如果"样本外检验"是按行随机切的
→ 检验集里的每一行,在训练集里都有"几乎一样"的邻居
→ 训练分和"样本外"分靠得极近 → 开口看起来很小
→ 你会得出「没有过拟合」的错误结论
↓
【过拟合诊断本身也会被泄漏污染】所以本章 demo 的样本外检验用的是 GroupShuffleSplit(按设备切), 测试集则一行都没有碰。
★ ⑤ 值得展开:staged_predict 的正确用法
# ✅ 训练和验证各预测一次 —— 两次的"模型版本"是一致的
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 完整示例与输出
cd code
MPLCONFIGDIR=/tmp/fe-mpl python demos/ch23-overfit-shape.py真实输出(节选):
可用样本 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 棵的分数,然后回答:
- 验证 R² 最优在第几棵?
- 开口(训练 − 验证)是"已经稳定"还是"还在扩大"?
- 第 100~300 棵之间,训练涨了多少、验证涨了多少?
参考答案
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}")期望输出:
子样本 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,500 | 6,000 |
| 开口(末尾) | 0.01033 | 0.00763 |
| 验证最优 | 第 200 棵 | 第 300 棵 |
拟合行数从 4,500 涨到 6,000(+33%),末尾开口从 0.01033 降到 0.00763(−26%) —— 因为"背答案"变得更难。 这正是"加数据能缓解过拟合"的机制(但它治不了"信息不足")。
练习 2:亲手制造"开口很大"的模型
用同一份数据(8000 行子样本、同样的 75% / 25% 切法),比较三种模型的开口:
- 单棵无限深
DecisionTreeRegressor(random_state=42); RandomForestRegressor(n_estimators=200, random_state=42);HistGradientBoostingRegressor(random_state=42, early_stopping=False)。
要求:列出三者的(训练 R²、样本外 R²、开口),并解释"为什么集成能压小开口"。
参考答案
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}")期望输出:
模型 训练 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 的两组真实数字验证它:
温和配置:开口 0.00076 → 0.01033(末 100 棵扩大 0.00334)
自由配置:开口 0.00437 → 0.01962(末 100 棵扩大 0.00000)参考答案
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}")期望输出:
温和配置 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"里的"棵"其实是"抽样点"
↓
【这个函数的设计前提是:喂给它的是完整曲线,不是抽样点】★ 工具的正确用法:
# ✅ 喂完整曲线(每个迭代步一个分数)
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★ 把两条判据合起来用的正确姿势
verdict, reason = diagnose_curve(train_curve, val_curve)
print(verdict, reason)
# 🔑 永远补一句「和噪声比」
print("折间噪声量级参考:分组 CV 的标准差约 0.0003 ~ 0.005")
print("如果开口的变化小于噪声 → 不要把它当信号")★ 这个函数真正的价值不在"给出结论",而在"让判据显式化":
把「我觉得有点过拟合」变成「开口末 100 棵扩大 +0.0043,阈值 0.002」
↓
① 可复现:不同人算同一个数字
② 可讨论:阈值可以商量(本数据的噪声在哪个量级?)
③ 可自动化:能接进实验平台的报警规则面向工程实践:曲线是训练过程中的"仪表盘"
① 单点分数是"体检结论",曲线是"心电图"
单点分数告诉你:现在状态如何
曲线告诉你 :趋势往哪走、还有多少余量、什么时候该停
↓
只报单点分数的实验记录,别人无法判断"还有没有优化空间"② 一个可直接抄进实验记录的模板
## 实验 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 章那个"千分之几"的结论从哪来。