068、早停 Early Stopping 源码实现:基于 mAP 或 Loss 的 Patience 计数器
068、早停 Early Stopping 源码实现基于 mAP 或 Loss 的 Patience 计数器从一次通宵调试说起去年秋天我在调一个YOLOv5的改进版——把Backbone换成了GhostNet训练VOC数据集。跑了大概80个epochloss曲线已经平得像死水mAP0.5在0.78附近反复横跳。我心想“再跑跑说不定能突破0.8”结果又熬了三个通宵到第200个epochmAP还是0.78反而因为过拟合掉到了0.76。那晚我盯着终端里不断刷新的“EarlyStopping triggered”提示突然意识到我写的早停逻辑是错的。后来翻YOLOv5源码发现它用的早停策略跟我写的完全是两码事。今天就把这个坑填上顺便把基于mAP和Loss的两种早停实现都拆开揉碎。早停不是“停了就行”很多人理解的早停连续N个epoch验证集loss不下降就停。但实际训练中loss下降和mAP提升不是同步的。YOLO这种多任务模型分类回归objloss可能还在降但mAP已经饱和了。反过来mAP偶尔跳一下loss可能已经过拟合。所以YOLOv5的早停逻辑是只看mAP不看loss。为什么因为mAP是最终评价指标loss只是中间代理。你优化loss是为了提升mAP如果mAP不涨了loss再降也没意义。但有些场景比如小目标检测、类别不平衡mAP波动很大这时候用loss做早停反而更稳定。我自己的经验是mAP早停用于常规检测loss早停用于难样本训练。源码级实现基于mAP的Patience计数器先看YOLOv5的早停核心代码我简化了但逻辑不变classEarlyStopping:def__init__(self,patience30):self.best_fitness0.0# 最佳mAP值self.best_epoch0# 达到最佳mAP的epochself.patiencepatience# 容忍多少个epoch不提升self.stopFalse# 是否触发早停def__call__(self,epoch,fitness): fitness: 当前epoch的mAP值YOLOv5里是mAP0.5:0.95 # 这里踩过坑fitness可能为None比如验证集为空iffitnessisNone:returnFalse# 如果当前mAP比历史最佳好更新最佳值iffitnessself.best_fitness:self.best_fitnessfitness self.best_epochepoch self.stopFalse# 别这样写直接return True会导致计数器不重置else:# 连续不提升的epoch数ifepoch-self.best_epochself.patience:self.stopTrueprint(fEarlyStopping triggered at epoch{epoch}, best fitness{self.best_fitness:.4f}at epoch{self.best_epoch})returnself.stop注意这个fitness参数。YOLOv5里它不是简单的mAP0.5而是mAP0.5:0.95和mAP0.5的加权组合。源码里是这样算的# 在val.py中fitness0.1*(mAP0.5:0.95)0.9*(mAP0.5)为什么这么加权因为mAP0.5:0.95对定位精度更敏感但波动大mAP0.5更稳定。加权后既保留了对精度的追求又避免了频繁触发早停。基于Loss的早停别被表面现象骗了有些场景下mAP波动太大比如训练集只有几百张图用mAP早停可能刚跑10个epoch就停了。这时候用loss更靠谱。但loss早停有个陷阱验证集loss和训练集loss趋势不同。训练集loss一直降验证集loss先降后升过拟合。所以要用验证集loss。classLossEarlyStopping:def__init__(self,patience20,delta0.001):self.best_lossfloat(inf)self.best_epoch0self.patiencepatience self.deltadelta# 容忍的loss下降幅度self.stopFalsedef__call__(self,epoch,val_loss):# 这里踩过坑val_loss是tensor要转成floatifisinstance(val_loss,torch.Tensor):val_lossval_loss.item()# 如果loss下降超过delta认为有提升ifval_lossself.best_loss-self.delta:self.best_lossval_loss self.best_epochepoch self.stopFalseelse:ifepoch-self.best_epochself.patience:self.stopTrueprint(fLoss EarlyStopping at epoch{epoch}, best loss{self.best_loss:.4f}at epoch{self.best_epoch})returnself.stop这个delta参数很关键。如果设成0只要loss不降就触发但loss可能因为batch采样波动设个0.001能过滤掉噪声。我一般设成1e-4到1e-3之间具体看loss的量级。两种早停的融合策略实际项目中我经常把两者结合mAP早停为主loss早停为辅。比如classCombinedEarlyStopping:def__init__(self,map_patience30,loss_patience20,loss_delta0.001):self.map_stopperEarlyStopping(patiencemap_patience)self.loss_stopperLossEarlyStopping(patienceloss_patience,deltaloss_delta)self.stopFalsedef__call__(self,epoch,fitness,val_loss):# mAP早停map_stopself.map_stopper(epoch,fitness)# loss早停loss_stopself.loss_stopper(epoch,val_loss)# 只要有一个触发就停self.stopmap_stoporloss_stopifself.stop:print(fCombined EarlyStopping at epoch{epoch})returnself.stop但注意别让loss早停太敏感。我遇到过loss早停先触发但mAP还在涨的情况。所以一般把loss的patience设得比mAP大比如mAP patience30loss patience50。训练循环中的集成早停类写好了怎么塞进训练循环YOLOv5的做法很优雅# 在train.py的主循环里stopperEarlyStopping(patience30)forepochinrange(start_epoch,epochs):# 训练一个epochtrain_one_epoch(...)# 验证fitnessvalidate(...)# 返回mAP# 早停检查ifstopper(epoch,fitness):break# 直接跳出循环# 保存最佳模型iffitnessstopper.best_fitness:save_checkpoint(...)注意这个save_checkpoint的位置只在mAP提升时保存而不是每个epoch都保存。这样既节省磁盘空间又保证最后加载的是最佳模型。个人经验早停的“黄金参数”不同数据集、不同模型早停参数差异很大。我总结了几条经验小数据集1000张patience设小一点10-15因为模型很快过拟合。用loss早停比mAP更稳定。大数据集10000张patience设大一点30-50因为训练过程长mAP可能后期才爆发。用mAP早停。YOLOv5默认patience100这是给COCO这种大数据集用的。如果你跑自己的小数据集记得改小否则早停形同虚设。别把patience设成0有人想“只要不提升就停”结果模型刚跑几个epoch就停了因为mAP初始值低后面肯定有波动。早停后别直接结束我习惯早停后再回退到最佳epoch用更小的学习率比如1/10继续训练几个epoch。这叫“早停微调”有时候能再涨0.5个点。一个容易被忽略的细节验证频率早停的“epoch”不是训练epoch而是验证epoch。如果你每N个epoch验证一次那patience要乘以N。比如你每5个epoch验证一次patience30意味着实际容忍了150个训练epoch。YOLOv5默认每个epoch都验证所以patience直接对应训练epoch数。如果你改了验证频率记得同步调整patience。写在最后早停不是银弹。它只是帮你节省时间不是帮你提升精度。真正提升精度的是数据增强、模型结构、损失函数这些硬功夫。但早停能让你在调参时少熬夜——至少我那次通宵调试后再也没犯过同样的错误。下次写“学习率衰减策略”的时候你会发现早停和学习率衰减是孪生兄弟早停触发前学习率应该已经衰减到很低了。如果学习率还很高就触发早停说明patience设得太小。完