Early Stopping 为什么停不下来?相等 loss、NaN 与最佳模型保存

举报
yd_241387476 发表于 2026/11/11 09:38:22 2026/11/11
【摘要】 从本人网站的 PyTorch 入门例子出发,复现相等 loss 导致 patience 不累计的问题,补充严格阈值、NaN 校验与最佳检查点保存。纯 Python 停止器 16 项测试通过,并说明训练接入示例的验证范围。

我网站上有一个 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

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。