Skip to content

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

这一章要解决什么:第 23 章看到了"训练分还在涨、验证分不涨"。 那到底该训到第几轮?早停就是把这个决定交给数据。

最容易踩的坑: ① 拿测试集当早停的验证集 —— 那是划分泄漏的另一种形式; ② 只报"早停省了时间",不报"测试分有没有降"; ③ 看到"验证最优 = 最后一棵"就硬说成"已经拐头了"(没拐头就说没拐头)。

本章你需要完成的代码:demos/ch24-early-stop.py

本章的数字口径:soh_features_v2.csv 的 11 列安全特征(dropna 后 19,979 行 / 60 台设备)、 按设备 GroupShuffleSplit(test_size=0.2, random_state=42) → 训练 16,236 / 测试 3,743 行。 曲线用训练集内的 6,000 行子样本画;早停对比用完整训练集。

24.1 早停在回答什么 ​

先建立一个心智模型:训练一个迭代模型,就是"花钱买验证分"。

每一轮迭代 = 花一份算力,买一份"验证分"
   ↓
前几轮:一份算力能买很多分(0.25 → 0.91 只要 10 棵)
中段  :一份算力只能买一点分(+0.002 / 50 棵)
后段  :一份算力买不到分,甚至【倒扣】(开口扩大、验证分开始回头)
   ↓
早停 = 在"倒扣"发生之前停手

★ 三种"停手"的方式

方式怎么定代价
定死轮数n_estimators=200(拍脑袋)可能要重训好几次才知道 200 合不合适
画曲线人工看第 23 章的做法要跑一次完整训练,还得有人看图
早停模型自己盯验证分,连续 K 轮不涨就停需要一个验证集

★ 早停的三个参数(sklearn 的 HistGradientBoostingRegressor)

python
HistGradientBoostingRegressor(
    max_iter=2000,            # 上限:最多训多少轮(不是"一定训这么多")
    early_stopping=True,      # 开关
    validation_fraction=0.15, # 从训练集内部切 15% 当验证集
    n_iter_no_change=30,      # 连续 30 轮验证分没涨就停
    tol=1e-4,                 # "没涨"的判定阈值
)

四个参数的关系:

max_iter        = 硬上限(保险丝)
validation_fraction = 验证集从哪来(只能从训练集内部!)
n_iter_no_change    = 耐心值(连续几轮不涨才放弃)
tol                 = "涨"要涨多少才算涨(低于它就算"没涨")
   ↓
耐心值 × 阈值 = 你对"噪声"的容忍度

★ 关键区别:max_iter 不是"训多少轮",而是"最多训多少轮"

max_iter=2000 + early_stopping=True
   → 实际可能只训 57 轮(本章实测)
   ↓
max_iter 是安全阀,不是目标

24.2 ★ 实测一:验证曲线与停止点 ​

真实输出(demos/ch24-early-stop.py):

text
可用样本 19979 行 / 60 台设备(训练 16236 行 / 测试 3743 行,测试集只在最后用一次)

=== ① 先看曲线(温和的配置):验证最优在第几棵树 ===
经典 GBDT 200 棵(lr=0.15, max_depth=3, subsample=0.6)拟合 2.5 s,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.01501 vs +0.00879)→ 开口在扩大
   ↓
所以正确的结论是两句:
  ① 「验证分还没到顶」→ 想提分只能靠特征,不是靠加树
  ② 「开口已经扩大到 0.01033」→ 继续加树只是在背训练集

★ demo 里原样打印了这段判断:

text
验证最优在第 200 棵(0.97131)—— 就是最后一棵。
⚠️ 这不叫「拐头」。验证曲线还在缓慢上升,只是上升得很慢:
  第 30 棵 0.96252 → 第 100 棵 0.96842 → 第 200 棵 0.97131
  同期训练分从 0.96662 涨到 0.98164(+0.01501)—— 训练跑得比验证快,开口在拉大。
  ⇒ 诚实的读法:验证分还没回头,但开口已经说明它在背训练集了。

★ 为什么必须这样"抠字眼"?

"验证最优在最后一棵" 这句话有两种解读:
  ❌ 解读 A:「模型到顶了,再加树会变差」      → 错(我们没看到变差)
  ✅ 解读 B:「在这个训练规模下,200 棵还没到拐点」→ 对
   ↓
两种解读给出的下一步动作完全不同:
  A:减复杂度(但可能减过头)
  B:换特征(因为验证分还在爬,说明还有没被利用的信息)
   ↓
【把"没拐头"说成"拐头",会导致你在错误的方向上花时间】

★ 那真正的"到顶"长什么样? 把模型放自由(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
     200    1.00000    0.98038   0.01962
     400    1.00000    0.98038   0.01962
     418    1.00000    0.98038   0.01962   ← 验证最优
     800    1.00000    0.98038   0.01962

验证最优在第 418 棵(0.98038),训满 800 棵时验证 0.98038(-0.00000)—— 到顶后一动不动。
开口:第 418 棵 0.01962 → 第 800 棵 0.01962(变化 +0.00000)

★ 两个配置的对比(这才是"到顶"的证据)

温和配置(depth=3, 200 棵)自由配置(None, 800 棵)
训练 R² 何时到 1没到(0.98164)第 100 棵
验证 R² 末尾0.97131(还在爬)0.98038(纹丝不动)
开口0.01033(在扩大)0.01962(稳定)
结论还能学,但学的是训练集信息用尽

★ 从这张对照表能读出两条完全不同的行动建议:

温和配置:验证分还在爬 → 「这个特征集里还有没被利用的信息」
                        → 但加的树越多开口越大 → 先停,然后【去改特征】

自由配置:验证分锁死在 0.98038 → 「当前特征集已经到顶」
                              → 加树、调深度都没用 → 【必须改特征】
   ↓
【两条路的终点都是"改特征",但理由不同 —— 报告时要说清是哪一条】

24.3 ★ 实测二:早停开关,一个参数的差别 ​

真实输出:

text
=== ② 早停开关:early_stopping=True / False(HistGB,max_iter=2000)===

  early_stopping      实际迭代      训练 R²      测试 R²       耗时
            True        57    0.97184    0.95799     0.9s
           False      2000    0.99880    0.95742    74.9s

迭代数:57 vs 2000(省了 97.2%)
耗时:0.9 s vs 74.9 s(82.3 倍)
训练 R²:0.97184 vs 0.99880(多训把训练分推高了 +0.02695)
测试 R²:0.95799 vs 0.95742(+0.00057)

★ 逐行读:三项同时发生了变化

第一:迭代数从 2000 → 57(省 97.2%)

max_iter=2000 是上限
   ↓
早停版只用到 57 轮就停了
   ↓
因为从第 27 轮开始,验证分连续 30 轮没有改善(超过 tol=1e-4)

第二:耗时 74.9 s → 0.9 s(82 倍)

迭代次数少 35 倍,耗时少 82 倍
   ↓
耗时比迭代数降得更多,因为:
  HistGB 在训练后期会做更细的直方图维护/更少的无效分裂
   ↓
⚠️ 这个 74.9 s 是在一台【正在被其他任务占满】的 8 核机器上测的
   (load average > 20)。绝对时间不可复现,但 82 倍这个比值是稳的

第三:训练分被推高 0.02695,测试分只涨 0.00057

训练 R²:0.97184 → 0.99880(+0.02695)
测试 R²:0.95799 → 0.95742(−0.00057)
   ↓
多训 1943 轮,换来的训练分提升是测试分提升的 47 倍(而且方向相反)
   ↓
【多出来的那些迭代,全花在背训练集上了】

★ 这就是"多训不等于更好"的量化版本

多训的收益  :测试 R² −0.00057(在这个量级上等于 0,甚至是负的)
多训的成本  :82 倍训练时间 + 1943 轮推理轮数
   ↓
如果上线要每天重训:
  早停版 0.9 s × 365 天 = 5.5 分钟/年
  训满版 74.9 s × 365 天 = 7.6 小时/年
   ↓
【成本差 82 倍,收益差在一个噪声里】

★ 到底哪个测试分更好?

0.95799(早停) vs 0.95742(训满)
   ↓
差 0.00057 —— 比分组 CV 的折间标准差(0.0003 ~ 0.005)还小
   ↓
诚实的结论:「两者在统计上无差别」
   ↓
⚠️ 不要写"早停提升了 0.00057" —— 它可能只是这一份测试集的随机波动
   正确的写法是「早停把耗时降低 82 倍,测试分没有可观测的损失」

★ 产出的图(运行 demo 生成 outputs/ch24-early-stop.png):

左:两种配置的验证曲线;右:早停 vs 训满的迭代数对比

左图:实线是温和配置(还在爬),点线是自由配置(第 100 棵后完全平掉), 竖直虚线标出自由配置的验证最优位置(第 418 棵,那里之后一动不动)。 右图:两根柱子——57 次迭代 / 0.9 s vs 2000 次迭代 / 74.9 s, 柱子上标着各自的测试 R²,两者几乎相同。

24.4 ★ 实测三:验证集从哪来 ​

这一节是早停最容易被忽略的前提。

真实输出:

text
=== ③ ⚠️ 验证集从哪来(早停最容易被忽略的前提)===
两种切验证集的切法(都从【训练集内部】切,测试集一行都不碰):
  ① 按设备切(本 demo 的手动写法):拟合 7996 行 / 验证 8240 行 → 早停在 57 次迭代
  ② 按行随机切(sklearn 内部默认):拟合 13801 行 / 验证 2435 行 → 早停在 57 次迭代

★ 三种"验证集来源",只有一种是对的

来源对不对为什么
测试集❌ 绝对不行测试集被用在"决定停止点"上了 → 划分泄漏
训练集内部按行随机切⚠️ 不严格同一台设备的相邻窗口会跨折 → 停止点偏乐观
训练集内部按设备切✅ 正确验证集里的设备在拟合集里完全没见过

★ 本数据上两种内部切法"碰巧"给出同一个停止点(57 轮)

按设备切:拟合 7,996 行 / 验证 8,240 行 → 停在 57 轮
按行随机切:拟合 13,801 行 / 验证 2,435 行 → 停在 57 轮
   ↓
⚠️ 这是【巧合】,不是"切法无所谓"
   ↓
原因:本数据的 48 台设备彼此水平接近
     (soh 的取值范围 0.8032 ~ 1.0017,每台设备的轨迹都是同一族曲线)
     随机抽一半设备 vs 随机抽 15% 的行,验证集的"难度"差不多
   ↓
【换一批设备差异更大的数据,两种切法会给出不同的停止点】

★ 代码上怎么"按设备"切(手动早停的写法)

python
# ① 先把训练池里的设备编号排序
cells_tr = sorted(gtr.unique())

# ② 一半设备去拟合、一半去验证(这里用的是"前一半",也可以随机抽)
half = set(cells_tr[: len(cells_tr) // 2])
mask = gtr.isin(half).values

# ③ 手动监控验证分,自己决定停在哪一轮
Xa, ya = Xtr[mask], ytr[mask]
Xb, yb = Xtr[~mask], ytr[~mask]

★ 三条纪律(照抄进实验记录)

① 验证集必须是【训练集内部】再切出来的一块 ——
   拿测试集当验证集 = 划分泄漏(第 18 章的机制);
② 分组/时序数据上,sklearn 内部按【行】随机切并不严格 ——
   同一台设备的相邻窗口会跨折,停止点会偏乐观;
   要较真就用 GroupKFold 手动早停;
③ 把「验证集怎么切」写进实验记录 ——
   别等到换数据才想起来(本次两种切法碰巧一致,不代表以后也一致)。

★ 关于 ②,一个可直接用的手动早停模板

python
from sklearn.model_selection import GroupKFold

cv = GroupKFold(n_splits=3)
best_rounds, best_mean = None, -np.inf

for n in (30, 60, 100, 200, 400, 800):
    scores = []
    for i_tr, i_va in cv.split(Xtr, ytr, gtr):
        m = HistGradientBoostingRegressor(max_iter=n, early_stopping=False,
                                          random_state=42).fit(Xtr.iloc[i_tr], ytr.iloc[i_tr])
        scores.append(r2_score(ytr.iloc[i_va], m.predict(Xtr.iloc[i_va])))
    mean = float(np.mean(scores))
    print(f"max_iter={n:4d}  3 折 CV R² = {mean:.5f} ± {np.std(scores):.5f}")
    if mean > best_mean:
        best_mean, best_rounds = mean, n

print(f"★ 分组 CV 选出的轮数 = {best_rounds}(CV R² = {best_mean:.5f})")

这个模板的好处:用分组 CV 而不是单次留出 —— 停止点不再依赖"恰好切到哪 15% 的行",而是取 3 折的平均。

点开:为什么 validation_fraction=0.15 就够了?验证集要多大?

这是早停最常被问的一个问题。答案分两层。

第一层:验证集只用来"读一个曲线",不参与训练

text
验证集的唯一任务:判断"验证分有没有连续 30 轮不涨"
   ↓
它不需要精确估计泛化误差(那是测试集的活)
它只需要能"看出趋势"
   ↓
一个 2,000~8,000 行的验证集,判断趋势足够了

第二层:本数据的实测——验证集从 2,435 行到 8,240 行,停止点都是 57 轮

text
按行随机切(validation_fraction=0.15)→ 2,435 行 → 停在 57 轮
按设备切(一半设备)                  → 8,240 行 → 停在 57 轮
   ↓
验证集大小差 3.4 倍,结论一样

★ 但"够用"有一个前提:验证集和训练集要【同分布】

如果验证集只有 100 行,而它们恰好来自 1~2 台设备
   → 这 100 行的"难度"可能完全不代表整体
   → 停止点会偏(可能太早,也可能太晚)
      ↓
所以真正该关心的不是"多少行",而是"覆盖了多少台设备"

★ 一个实用的检查

python
# sklearn 内部按行切时,看看验证集覆盖了多少台设备
from sklearn.model_selection import train_test_split
i_fit, i_val = train_test_split(range(len(Xtr)), test_size=0.15, random_state=42)
print(f"验证集 {len(i_val)} 行,覆盖 {gtr.iloc[i_val].nunique()} / {gtr.nunique()} 台设备")

典型输出:

text
验证集 2435 行,覆盖 48 / 48 台设备

如果这一行打印出"覆盖 2 / 48 台设备",那 15% 就不够了 —— 序数据上,"按行随机切"其实是在赌"每台设备都被抽到一些行"。 设备数少、每台行数多的时候,这个赌注就不安全。

★ 所以最终建议是:

数据规模建议
设备多(几十台以上)、每台行数多validation_fraction=0.15 够用
设备少(< 10 台)改成按设备切,或者直接上 GroupKFold 手动早停
时序数据(不能随机打乱)必须用"最后一段时间"当验证集

一句话:验证集的大小不是关键,"它代表不代表整体"才是关键。

★ 一个必须说清的代价

早停用【训练集的一部分】当验证集
   ↓
意味着这部分数据【不能用来训练】
   ↓
validation_fraction=0.15 → 训练数据少了 15%
   ↓
⚠️ 数据紧张时这是个真实的损失
   (本该用它训练,现在只用来决定"什么时候停")
   ↓
【数据紧张时的替代方案】:
  · 用 K 折交叉验证的 out-of-fold 预测来决定轮数(每组数据都参与训练,只是不同时)
  · 或者干脆把 max_iter 定小一点,用第 25 章的正则化来控复杂度

24.5 常见坑 ​

text
① 早停用测试集(validation_fraction 之外自己传 Xte)→ 分数漂亮,但测试集被用掉了
② 忘了 max_iter 是"上限"不是"目标"→ 以为设 2000 就会训 2000 轮
③ n_iter_no_change 设太小(比如 5)→ 在噪声里反复触发,停得太早
④ n_iter_no_change 设太大(比如 200)→ 早停退化成"训满",白费算力
⑤ tol 设得比噪声还小 → 每次都判定"有改善",永远不停
⑥ 只看"省了多少时间",不报"测试分有没有降"→ 无法判断早停是不是划算
⑦ 把"最优在第 200 轮"说成"已经拐头"→ 结论方向错误(第 24.2 节)
⑧ 分组/时序数据上用 sklearn 内部的按行切验证集 → 停止点偏乐观

★ ③④⑤ 一起说:这三个参数其实是一个东西

"连续 K 轮没有改善超过 tol" → 停止
   ↓
K × tol = 你能容忍的总"噪声漂移"
   ↓
本数据实测:
  K=30, tol=1e-4 → 停在 57 轮
   ↓
如果 K=5  → 可能在 30 轮附近就被噪声劝退(欠拟合)
如果 tol=1e-6 → 每一轮都算"有改善"→ 跑到 max_iter=2000(白费)
   ↓
【一个经验起点】:K = 20~50,tol = 1e-4 ~ 1e-5
   然后看训练日志里验证分的波动幅度,把它设成"波动的 2~3 倍"

★ ① 值得展开:为什么"早停用测试集"特别隐蔽?

它不会报错,也不会让分数变高得离谱
   ↓
它只是让【停止点】变成了"对测试集最优的停止点"
   ↓
于是你报告的测试分是"经过挑选的"→ 系统性偏高
   ↓
而且这个偏差不会随着数据变大而消失
   (它来自"选择",不来自"抽样")
   ↓
【任何"用测试集做的决定"都会留下永久性偏差】
   停止点、超参、权重、融合方式、特征选择 —— 一个都不能例外

★ ⑦ 值得展开:怎么写"没拐头"的结论

❌ 错的写法:

text
"验证 R² 在第 200 棵达到最优 0.97131,说明 200 棵是最佳树数,
 之后模型开始过拟合。"

问题:读者会以为"第 200 棵之后验证分掉了"——但实际没有。

✅ 对的写法:

text
"验证 R² 在第 200 棵(= 训练上限)达到 0.97131,曲线【仍在缓慢上升】,
因此本配置下看不出明确的拐点。但同期训练 R² 从 0.96252(第 30 棵)
涨到 0.98164(第 200 棵),开口从 0.00410 扩大到 0.01033 ——
这说明后 170 棵树主要在学习训练集的细节,而不是可泛化的规律。
结论:本配置下建议早停在第 200 棵附近;进一步提升需要改特征,而不是加树。"

★ 关键差别:

错的写法把"没拐头"讲成了"拐头"→ 会引导读者去"减复杂度"
对的写法把两件事分开报:
  ① 验证线还在爬(没到顶)     → 想提分要改特征
  ② 开口在扩大(在背训练集)   → 但别再加树了
   ↓
【两句话都对,而且不矛盾 —— 这才是"诚实报告"的样子】

24.6 完整示例与输出 ​

bash
cd code
MPLCONFIGDIR=/tmp/fe-mpl python demos/ch24-early-stop.py

真实输出(节选):

text
=== ① 先看曲线(温和的配置):验证最优在第几棵树 ===
经典 GBDT 200 棵(lr=0.15, max_depth=3, subsample=0.6)拟合 2.5 s,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
     100    0.97541    0.96842   0.00699
     200    0.98164    0.97131   0.01033   ← 验证最优

=== ② 早停开关:early_stopping=True / False(HistGB,max_iter=2000)===

  early_stopping      实际迭代      训练 R²      测试 R²       耗时
            True        57    0.97184    0.95799     0.9s
           False      2000    0.99880    0.95742    74.9s

迭代数:57 vs 2000(省了 97.2%)
耗时:0.9 s vs 74.9 s(82.3 倍)
训练 R²:0.97184 vs 0.99880(多训把训练分推高了 +0.02695)
测试 R²:0.95799 vs 0.95742(+0.00057)

读法:多训不等于更好。早停用百分之一量级的迭代数拿到【同样甚至略好】的测试分。
⚠️ 别忘了同时看【训练 R²】:训满时训练分被推到 0.99880(比早停高 +0.02695),
   而测试分没有跟着涨 —— 多出来的那些迭代全花在背训练集上了。

=== ③ ⚠️ 验证集从哪来(早停最容易被忽略的前提)===
  ① 按设备切(本 demo 的手动写法):拟合 7996 行 / 验证 8240 行 → 早停在 57 次迭代
  ② 按行随机切(sklearn 内部默认):拟合 13801 行 / 验证 2435 行 → 早停在 57 次迭代

[plots] 图已保存: .../outputs/ch24-early-stop.png

⚠️ 耗时说明:上面这两行耗时(0.9 s / 74.9 s)是在一台被其他任务占满的 8 核机器上实测的(load average > 20)。绝对时间不可复现, 但只要在同一台机器、同一时刻跑,"早停比训满快两个数量级"这个结论是稳的。

本章练习 ​

练习 1:固定同一组超参,只拨 early_stopping 开关

用 11 列安全特征,按设备划分训练/测试(test_size=0.2, random_state=42)。

固定:

python
HistGradientBoostingRegressor(max_iter=1500, learning_rate=0.05, random_state=42,
                              validation_fraction=0.15, n_iter_no_change=30, tol=1e-4)

只改 early_stopping,记录四项:实际迭代数 / 训练 R² / 测试 R² / 耗时,然后回答 "多训一定更好吗"。

参考答案
python
out = {}
for early in (True, False):
    m = HistGradientBoostingRegressor(max_iter=1500, learning_rate=0.05, random_state=42,
                                      early_stopping=early, validation_fraction=0.15,
                                      n_iter_no_change=30, tol=1e-4)
    t0 = time.perf_counter()
    m.fit(Xtr, ytr)
    dt = time.perf_counter() - t0
    out[early] = (m.n_iter_, r2_score(ytr, m.predict(Xtr)),
                  r2_score(yte, m.predict(Xte)), dt)
    print(f"{str(early):>6}{out[early][0]:>10}{out[early][1]:>12.5f}"
          f"{out[early][2]:>12.5f}{out[early][3]:>9.1f}s")

print(f"\n迭代数比:{out[False][0] / out[True][0]:.0f} 倍")
print(f"训练 R²:多训把训练分推高了 {out[False][1] - out[True][1]:+.5f}")
print(f"测试 R²:{out[True][2]:.5f} vs {out[False][2]:.5f}"
      f" → 差 {out[True][2] - out[False][2]:+.5f}")

期望输出:

text
  early_stopping      实际迭代      训练 R²      测试 R²       耗时
            True        57    0.97184    0.95799     0.9s
           False      1500    0.99812    0.95823   292.2s

迭代数比:26 倍
训练 R²:多训把训练分推高了 +0.02628
测试 R²:0.95799 vs 0.95823 → 差 -0.00024

★ 回答"多训一定更好吗":不一定,而且这次的答案是"多训略微更好但无关紧要"

训练 R²:+0.02628(多训把训练分推高了一大截)
测试 R²:−0.00024(多训反而略低)
   ↓
三个数字放在一起看:
  ① 多训 1443 轮,训练分涨了 0.026
  ② 测试分没涨(−0.00024 在噪声里)
  ③ 耗时涨了 292.2 / 0.9 ≈ 325 倍
   ↓
【多训的收益全部落在训练集上,成本全部落在时间上】

★ 一个必须注意的细节:本次"训满"的测试分略高于早停

0.95823(训满) vs 0.95799(早停)→ 训满高 0.00024
   ↓
⚠️ 不要写成"早停提升了精度"(本章 demo 里早停是 +0.00057)
   ↓
两个方向都可能出现 —— 因为 0.0002 ~ 0.0006 这个量级本来就在噪声里
   ↓
正确的表述:「早停与训满在测试分上无统计差别(|Δ| < 0.001),
             但早停把耗时降了 1~2 个数量级」
   ↓
【把"无差别"如实报出来,比硬说"早停更好"更专业】

★ 一个可以顺手算的"投入产出比"

python
speedup = out[False][3] / out[True][3]
delta = out[False][2] - out[True][2]
print(f"耗时降 {speedup:.0f} 倍,测试分差 {delta:+.5f}")
if abs(delta) < 0.001:
    print("→ 测试分无统计差别:早停是纯赚(省时间、不损失精度)")
elif delta < 0:
    print("→ 训满反而更好:检查早停的耐心值是不是设太小了")

期望输出:

text
耗时降 325 倍,测试分差 +0.00024
→ 测试分无统计差别:早停是纯赚(省时间、不损失精度)
练习 2:为什么"早停的验证集"不能用测试集?

设计一个小实验来演示后果:

  1. 用训练集内部切出的验证集做早停,记录停止轮数 n1、测试 R²;
  2. 用测试集当验证集做早停(X_val=Xte, y_val=yte),记录停止轮数 n2、测试 R²;
  3. 比较两组结果,并解释"为什么第 2 组的测试分不能拿去汇报"。
参考答案
python
from sklearn.ensemble import HistGradientBoostingRegressor
from sklearn.metrics import r2_score

# ① 正确:验证集来自训练集内部(sklearn 自动切 15%)
m1 = HistGradientBoostingRegressor(max_iter=1500, learning_rate=0.05, random_state=42,
                                   early_stopping=True, validation_fraction=0.15,
                                   n_iter_no_change=30, tol=1e-4)
m1.fit(Xtr, ytr)
print(f"① 内部验证集:停在 {m1.n_iter_:4d} 轮,测试 R² = "
      f"{r2_score(yte, m1.predict(Xte)):.5f}")

# ② 错误:把测试集当验证集(sklearn 没有这个入口,需要手动循环)
from sklearn.model_selection import GroupShuffleSplit
itr, iva = next(GroupShuffleSplit(n_splits=1, test_size=0.15, random_state=0)
                .split(Xte, yte, groups.iloc[te]))
import numpy as np
curve = []
for n in range(20, 1501, 20):
    m = HistGradientBoostingRegressor(max_iter=n, learning_rate=0.05, random_state=42,
                                      early_stopping=False).fit(Xtr, ytr)
    curve.append((n, r2_score(yte, m.predict(Xte))))
best_n, best_s = max(curve, key=lambda t: t[1])
print(f"② 用测试集挑轮数:最佳 {best_n} 轮,测试 R² = {best_s:.5f}")
print(f"   注意:这个 {best_s:.5f} 是【在测试集上挑出来的最大值】")

期望输出(数值会随环境波动):

text
① 内部验证集:停在   57 轮,测试 R² = 0.95799
② 用测试集挑轮数:最佳  940 轮,测试 R² = 0.95841
   注意:这个 0.95841 是【在测试集上挑出来的最大值】

★ 三个必须看清的点

① 第 ② 组的"最佳轮数"是 940 轮 —— 而第 ① 组只用了 57 轮

为什么差这么多?
  ① 组用的验证集是训练集内部的 15%(2,435 行)
  ② 组用的是测试集(3,743 行,而且是"另一批设备")
   ↓
而且 ② 组的每一轮都【看了测试集】—— 它挑的是 75 个点里的最大值
   ↓
【"最佳轮数"这个结论本身就已经被污染了】

② 第 ② 组的测试分(0.95841)比第 ① 组(0.95799)高 0.00042

这 0.00042 是什么?
  · 不是"更好的模型"——两组用的是同一份训练数据、同一个模型类
  · 是【在 75 个候选中挑最大值】的产物
   ↓
统计学上这叫 selection bias(选择偏差):
  在一批"实际水平相同"的估计里挑最大值,挑出来的必然偏高

③ 最严重的后果不是这 0.00042,而是【测试集作废了】

②组用完测试集之后:
  · 你已经知道"940 轮在这份数据上最好"
  · 这个知识来自测试集
  · 所以你以后不能再声称"0.95841 是没见过的数据上的表现"
   ↓
⚠️ 这个损失【不可撤销】
   你不能说"我只用了一点点"——
   只要参与过选择,就不再干净
   ↓
【拿不可撤销的损失,换一个噪声级别的数字,是最差的交易】

★ 一个正确的替代:用分组 CV 挑轮数(每个数据都参与过训练,但不同时)

python
from sklearn.model_selection import GroupKFold

cv = GroupKFold(n_splits=3)
curve = []
for n in (30, 60, 120, 250, 500, 1000):
    s = []
    for i_tr, i_va in cv.split(Xtr, ytr, gtr):
        m = HistGradientBoostingRegressor(max_iter=n, learning_rate=0.05, random_state=42,
                                          early_stopping=False).fit(Xtr.iloc[i_tr],
                                                                    ytr.iloc[i_tr])
        s.append(r2_score(ytr.iloc[i_va], m.predict(Xtr.iloc[i_va])))
    curve.append((n, float(np.mean(s)), float(np.std(s))))
    print(f"max_iter={n:5d}  CV R² = {np.mean(s):.5f} ± {np.std(s):.5f}")
best_n, best_s, _ = max(curve, key=lambda t: t[1])
print(f"\n★ 分组 CV 选出的轮数 = {best_n}(CV R² = {best_s:.5f})")
print(f"  拿到测试集上:{r2_score(yte, HistGradientBoostingRegressor(max_iter=best_n, learning_rate=0.05, random_state=42, early_stopping=False).fit(Xtr, ytr).predict(Xte)):.5f}")

期望输出:

text
max_iter=   30  CV R² = 0.93726 ± 0.00412
max_iter=   60  CV R² = 0.95412 ± 0.00138
max_iter=  120  CV R² = 0.95736 ± 0.00090
max_iter=  250  CV R² = 0.95714 ± 0.00032
max_iter=  500  CV R² = 0.95663 ± 0.00041
max_iter= 1000  CV R² = 0.95612 ± 0.00036

★ 分组 CV 选出的轮数 = 120(CV R² = 0.95736)
  拿到测试集上:0.95514

★ 这一次 CV 选出的轮数(120)和早停给出的(57)不一样 —— 这很正常

早停:用"连续 30 轮不涨"的规则提前收手 → 偏保守(57 轮)
分组 CV:在 6 个候选轮数里挑 CV 最高的 → 偏激进(120 轮)
   ↓
两者都不错,因为 60~250 轮的 CV 分差别很小(0.95412 ~ 0.95736)
   ↓
⚠️ 但注意第 ⑦ 行的测试分(0.95514)比早停的 0.95799 低 0.00285
   ↓
这再次说明:**"选轮数"这件事的差异,本身就在噪声里**
   ↓
【工程上的建议】:优先用早停 ——
   它不需要额外跑 K 折、不需要额外的人工判断,而且天然只用训练集内部数据
练习 3:把"早停决策"写成一个函数

写一个函数 early_stop_report(model_factory, Xtr, ytr, Xte, yte, groups=None):

  1. 分别用 early_stopping=True 与 False 训练同一个模型;
  2. 返回一个字典,含两个配置的 n_iter_ / 训练 R² / 测试 R² / 耗时 / speedup / delta_test;
  3. 并根据 |delta_test| 给出建议("早停纯赚" / "训满更好" / "检查耐心值")。

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

参考答案
python
import time

import numpy as np
from sklearn.ensemble import HistGradientBoostingRegressor
from sklearn.metrics import r2_score


def early_stop_report(make_model, Xtr, ytr, Xte, yte, noise=0.001):
    """对比 early_stopping=True / False 的四项记录 + 一句建议。"""
    out = {}
    for early in (True, False):
        m = make_model(early_stopping=early)
        t0 = time.perf_counter()
        m.fit(Xtr, ytr)
        dt = time.perf_counter() - t0
        out[early] = {
            "n_iter": int(m.n_iter_),
            "train": float(r2_score(ytr, m.predict(Xtr))),
            "test": float(r2_score(yte, m.predict(Xte))),
            "seconds": float(dt),
        }
    out["speedup"] = out[False]["seconds"] / out[True]["seconds"]
    out["delta_test"] = out[False]["test"] - out[True]["test"]

    if abs(out["delta_test"]) < noise:
        out["verdict"] = "早停纯赚(省时间、精度无统计差别)"
    elif out["delta_test"] < 0:
        out["verdict"] = "训满更好(检查早停的 n_iter_no_change / tol 是不是太小)"
    else:
        out["verdict"] = "训满略好(差距在噪声附近,看上线成本决定)"
    return out


make = lambda **kw: HistGradientBoostingRegressor(
    max_iter=1500, learning_rate=0.05, random_state=42,
    validation_fraction=0.15, n_iter_no_change=30, tol=1e-4, **kw)

rep = early_stop_report(make, Xtr, ytr, Xte, yte)
for k in (True, False):
    r = rep[k]
    print(f"early_stopping={str(k):5s}  迭代 {r['n_iter']:4d}  "
          f"训练 {r['train']:.5f}  测试 {r['test']:.5f}  {r['seconds']:6.1f}s")
print(f"\n耗时比 {rep['speedup']:.0f} 倍,测试分差 {rep['delta_test']:+.5f}")
print(f"建议:{rep['verdict']}")

期望输出:

text
early_stopping=True   迭代   57  训练 0.97184  测试 0.95799     0.9s
early_stopping=False  迭代 1500  训练 0.99812  测试 0.95823   292.2s

耗时比 325 倍,测试分差 +0.00024
建议:早停纯赚(省时间、精度无统计差别)

★ 这个函数的三个设计要点

① noise 参数显式化(默认 0.001)

"测试分有没有差别"必须有个阈值
   ↓
0.001 是一个经验起点(本数据的分组 CV 折间标准差在 0.0003 ~ 0.005)
   ↓
把它做成参数 → 调用者可以按自己的数据调整
   ↓
【不要硬编码"0.0005 以内就算无差别"这类神奇数字】

② verdict 有三种结果,而不是两种

|Δ| < noise        → 早停纯赚(这是最常见的情况)
Δ < −noise         → 训满更好 → 值得去查早停参数是不是设错了
Δ > +noise         → 训满略好 → 这是一个"便宜的换精度"的决策点
   ↓
⚠️ 第三种最少见,但一旦出现就要认真算成本
   (本数据上 Δ = +0.00024 < 0.001 → 落到第一支)

③ 返回字典而不是只 print

返回结构化数据 → 可以接进实验记录、可以批量跑几十个配置
   ↓
而 print 只能给人看
   ↓
【"能进流水线的报告"比"能看的报告"更有价值】

★ 一个可以直接加进报告模板的行

python
print(f"| max_iter | 实际迭代 | 训练 R² | 测试 R² | 耗时 |")
print(f"|---|---|---|---|---|")
for k in (True, False):
    r = rep[k]
    print(f"| early_stopping={k} | {r['n_iter']} | {r['train']:.5f} | "
          f"{r['test']:.5f} | {r['seconds']:.1f}s |")

期望输出:

text
| max_iter | 实际迭代 | 训练 R² | 测试 R² | 耗时 |
|---|---|---|---|---|
| early_stopping=True | 57 | 0.97184 | 0.95799 | 0.9s |
| early_stopping=False | 1500 | 0.99812 | 0.95823 | 292.2s |

这张表可以直接贴进实验记录 —— 它把"多训的收益"和"多训的成本"并排放在一起, 读者一眼就能看到 26 倍的迭代数换来了多大(或者多小)的收益。

面向工程实践:早停是"性价比最高的一个参数" ​

① 为什么它值得"默认打开"

维度早停
代码成本一个参数(early_stopping=True)
数据成本训练集的 15%(这部分不能参与训练)
收益耗时降 1~2 个数量级;常常顺带略微提精度
风险几乎没有(只要验证集切得对)

★ 对照其他手段:

早停    :几乎免费,收益稳定(省时间)
正则化  :很便宜,收益不确定(本数据上千分之一)
加数据  :很贵,收益取决于"验证线是否还在涨"
改特征  :很贵,收益最大(本数据 0.04 量级)
调参    :中等成本,收益最小(第 27 章)
   ↓
【早停是唯一一个"无脑打开也不亏"的选项】

② 一份实验记录模板

markdown
## 实验 024:早停 vs 训满(11 列安全特征 + HistGB)

- 划分:GroupShuffleSplit(test_size=0.2, random_state=42) → 训练 16,236 / 测试 3,743
- 配置:max_iter=2000, lr=0.05, validation_fraction=0.15, n_iter_no_change=30, tol=1e-4

| early_stopping | 实际迭代 | 训练 R² | 测试 R² | 耗时 |
|---|---|---|---|---|
| True | 57 | 0.97184 | 0.95799 | 0.9 s |
| False | 2000 | 0.99880 | 0.95742 | 74.9 s |

### 结论
- ✅ **采用早停**:迭代数省 97.2%,耗时省 82 倍,测试分无统计差别
- ⚠️ 验证集来源:sklearn 内部按【行】随机切 15%(本数据上两种切法碰巧同为 57 轮,
  但这是巧合,见第 24.4 节)—— 换设备差异更大的数据时必须改用 GroupKFold
- ⚠️ 耗时数据来自一台被其他任务占满的 8 核机器(load > 20),
  绝对时间不可复现,比值可复现

★ 这份记录里最值钱的是"验证集来源"那一条

它主动暴露了一个【已知的不严谨之处】
   ↓
六个月后有人把这份代码用到别的数据上
   → 会看到这条警告,改用 GroupKFold
   ↓
【记录"我哪里不确定",比记录"我很确定"更有价值】

③ 早停与"学习曲线"的分工

学习曲线(上一本第 21 章)
  横轴:训练样本数 → 回答"数据够不够" → 结论往往是"够,该改特征"
早停(本章)
  横轴:迭代轮数 → 回答"训到哪停" → 结论往往是"停在第 N 轮"
   ↓
两者都不需要碰测试集,两者的成本都是一次训练
   ↓
【它们共同构成本书"在模型侧做诊断"的完整工具箱】

④ 本章的位置

第 23 章 → 诊断:开口大不大、在不在扩大
第 24 章 → 治疗(时间维):早停                    ← 本章
第 25 章 → 治疗(结构维):L1 / L2 / max_depth
第 26 章 → 流程:怎么系统地搜这些旋钮
第 27 章 → 收口:这些旋钮一共能动多少

本章是"模型侧"的第一个治疗手段 —— 也是性价比最高的一个。


下一章:第 25 章 · 三种「刹车」:L1 把系数压没、L2 把系数压小、树深度限制复杂度