Early Stopping 为什么停不下来?相等 loss、NaN 与最佳模型保存
我网站上有一个 PyTorch 入门问题:能否根据 loss 自动停止训练?常见答案是记录历史最好值,如果连续若干次验证都没有改善,就提前退出。思路不复杂,但一个比较运算符,就可能让完全不变的 loss 永远无法耗尽 patience。
本文从原例出发,先复现相等值的边界,再写一个可独立测试的停止器,最后说明怎样接入 PyTorch。重点是让停止条件、异常指标和模型保存各自有清楚的约定。
一、相等的 loss 为什么会把计数清零
原例用下面的条件判断“没有改善”:
if val_loss > best_loss - delta:
counter += 1
else:
best_loss = val_loss
counter = 0
当 delta=0、best_loss=1.0、val_loss=1.0 时,1.0 > 1.0 为假,代码进入 else,计数重新归零。即使后面连续十次都是 1.0,也不会累计到 patience。若需求是“指标必须下降才算改善”,这个分支就与需求不符。
我在重写版本中明确采用严格比较:loss < best - min_delta 才算显著改善。等于阈值也算没有改善。Lightning 的 EarlyStopping 文档采用同样的阈值约定,并说明 patience 统计验证检查次数;它不一定等于训练轮数。例如每隔几轮才验证一次,就只能在这些检查点累计计数。
二、先写一个不依赖 PyTorch 的停止器
下面只处理“指标越小越好”的单进程场景。patience 至少为 1;min_delta 为有限非负数;step 接收普通 Python int 或 float,返回“本次是否显著改善、是否应停止”。Tensor 请在调用前转换成标量。
import math
class EarlyStopping:
"""单进程、最小化指标;返回 (显著改善, 应停止)。"""
def __init__(self, patience=3, min_delta=0.0):
if type(patience) is not int or patience < 1:
raise ValueError("patience 必须是正整数")
if type(min_delta) not in (int, float):
raise TypeError("min_delta 必须是数值")
min_delta = float(min_delta)
if not math.isfinite(min_delta) or min_delta < 0:
raise ValueError("min_delta 必须有限且非负")
self.patience = patience
self.min_delta = min_delta
self.best = None
self.bad_checks = 0
self.stopped = False
def step(self, loss):
if self.stopped:
return False, True
if type(loss) not in (int, float):
raise TypeError("请先将指标转换成 Python 数值")
loss = float(loss)
if not math.isfinite(loss):
raise ValueError("验证指标出现 NaN 或无穷值")
improved = self.best is None or loss < self.best - self.min_delta
if improved:
self.best = loss
self.bad_checks = 0
else:
self.bad_checks += 1
self.stopped = self.bad_checks >= self.patience
return improved, self.stopped
这里把 NaN、正无穷和负无穷当作指标异常,直接抛出 ValueError,并保留此前的基准与计数。调用者应让本次训练失败并排查输入、损失函数或数值稳定性,不能捕获异常后假装指标正常。这个策略是本文的实现选择;Lightning 还提供 check_finite,在监控值非有限时触发停止。
stopped 一旦变成 True,后续调用也返回停止。要开始新的独立训练,应创建新的停止器,避免把旧实验的最好值与计数带入新实验。这个简化类没有实现分布式同步或断点续训状态恢复。
三、用短序列检查 patience 和阈值
stopper = EarlyStopping(patience=3)
for check, loss in enumerate([1.0, 1.0, 1.0, 1.0], 1):
improved, should_stop = stopper.step(loss)
print(check, improved, stopper.bad_checks, should_stop)
if should_stop:
break
# 1 True 0 False
# 2 False 1 False
# 3 False 2 False
# 4 False 3 True
第一次检查只是建立基准,并不是第一次“没有改善”。所以 patience=3 会在第四次检查后停止。若序列是 1.0、1.5、0.5、0.6,第三次出现显著下降,计数归零,第四次才重新累计为 1。
还要区分“显著改善基准”和“观察到的最小值”。取 min_delta=0.25,依次输入 1.0、0.875、0.75、0.625:前两次小幅下降不足以越过严格阈值,比较基准仍是 1.0;到 0.625 才累计成一次显著改善。这里的 best 是阈值基准,因此不能把它无条件当成所有观测值的数学最小值。
示例使用能被二进制浮点精确表示的 0.125、0.25 来测试等号边界。实际训练中的小数还受浮点精度影响。min_delta 应表达业务上有意义的改变量,不宜拿极小阈值试图消除所有数值噪声。
四、停止训练与保存最佳模型分别处理
训练停止时的权重未必对应最佳验证结果。下面把保存条件独立出来:停止器按 min_delta 判断进步;检查点按实际观测到的最低有限验证损失保存。即使某次小幅下降不足以重置 patience,也仍可保存更低损失的权重。
import math
import torch
stopper = EarlyStopping(patience=3, min_delta=1e-4)
best_observed = math.inf
saved = False
for epoch in range(max_epochs):
model.train()
train_one_epoch(model, train_loader, optimizer)
model.eval()
with torch.no_grad():
val_loss = validate(model, val_loader)
# 约定 validate 返回完整验证集的标量平均损失
if isinstance(val_loss, torch.Tensor):
val_loss = val_loss.item()
val_loss = float(val_loss)
_, should_stop = stopper.step(val_loss)
if val_loss < best_observed:
torch.save(model.state_dict(), "best.pt")
best_observed = val_loss
saved = True
if should_stop:
break
if not saved:
raise RuntimeError("没有产生有效验证结果")
model.load_state_dict(torch.load("best.pt", weights_only=True))
model.eval()
这是一段接入示意,model、两个 DataLoader、optimizer、max_epochs、train_one_epoch 和 validate 由实际项目提供。validate 的聚合口径必须一致;例如批大小不同时,不能想当然地把各批平均损失再等权平均,当作整个验证集的按样本平均损失。
model.eval() 切换评估行为,torch.no_grad() 关闭这段计算的梯度记录,二者用途不同。每轮训练前要恢复 model.train()。上面的例子在出现更低损失时立即序列化权重;若只在内存保留 model.state_dict(),其中张量仍可能随训练更新,需按 PyTorch 官方说明深拷贝或立即保存。
恢复权重用于推理与恢复整个训练过程也不同。后者还应保存优化器等训练状态,以及停止器的基准和累计次数;本文没有把这个简化示例包装成完整训练框架。
五、本次实际验证了什么
在 Python 3.14.6 下运行 16 个 unittest 测试,全部通过。覆盖常量序列、恶化序列、改善后计数归零、严格阈值等号、连续小幅下降、patience=1、停止后不重启、负指标、非法配置、错误类型,以及首次和中途出现 NaN/无穷值。测试也复现了原例“相等 loss 将 counter 清零”的行为。
这些测试验证的是纯 Python 停止逻辑。本地环境没有安装 PyTorch,本文没有执行真实模型训练,也没有把训练接入示例宣称为端到端实测。接入项目后,应再用一个小数据集核对验证频率、指标聚合、检查点可恢复性和最终使用的模型版本。
排查“为什么不停”时,可以先记录检查序号、当前指标、显著改善基准、bad_checks 和停止结果。把这五项对齐,通常比只盯着 epoch 数字更容易判断问题发生在哪一步。
来源与参考
改编自本人网站“安全问答”的原题《python pytorch实现根据loss停止训练》:https://www.cveoy.top/t/topic/czPE 。本文使用 AI 辅助重写说明、补充实现与测试;原例与本文的边界策略已在正文分别说明。
Lightning EarlyStopping 参数约定:https://lightning.ai/docs/pytorch/stable/optional-api/api/lightning.pytorch.callbacks.EarlyStopping
PyTorch 模型保存与恢复:https://docs.pytorch.org/tutorials/beginner/saving_loading_models
PyTorch Module.eval:https://docs.pytorch.org/docs/stable/generated/torch.nn.Module.html#torch.nn.Module.eval
PyTorch no_grad:https://docs.pytorch.org/docs/stable/generated/torch.no_grad.html
- 点赞
- 收藏
- 关注作者
评论(0)