AI早停踩坑记录
固定跑满 100 个 epoch 很常见,验证集上的拐点往往来得更早。
为什么要关注早停
很多初学者的习惯是:定好 epoch 数,然后等它跑完。小数据集也许还能凑合,真实项目里往往浪费算力,还容易练过头。
深度学习训练本质是拟合规律,epoch 多了就开始记噪声。过拟合的表现很典型:训练集指标继续涨,验证集反而往下掉——拐点只能盯着验证集看,没法靠猜。
更麻烦的是,拐点跟数据量、模型容量、学习率、正则化都有关,同一套配置换数据集 best epoch 可能差很多。就像炒菜,菜谱一样,火候差一点,起锅时间就不一样。

早停的基本原理
早停的做法很直接:训练过程中盯验证集指标(通常是 loss 或准确率),连续若干轮没有改善就停,并保留历史最优权重。
# 伪代码示意
best_val_loss = float('inf')
patience = 10
no_improve_count = 0
for epoch in range(max_epochs):
train_model_one_epoch()
val_loss = evaluate_on_validation_set()
if val_loss < best_val_loss:
best_val_loss = val_loss
save_model_checkpoint()
no_improve_count = 0
else:
no_improve_count += 1
if no_improve_count >= patience:
print(f"Early stopping at epoch {epoch}")
break
这里的 patience 是一个关键参数。它决定了在模型不再改进时,我们还要给它多少次机会。就像钓鱼,鱼漂动一下不代表真的咬钩,要连续几次确认才行。
但实际使用中,这个简单的逻辑会遇到不少坑。
常见早停策略
基础指标监控
最直接的做法是监控验证集损失。损失越低通常意味着模型预测与真实标签的差异越小。但要注意,损失函数的选择会影响早停的判断。
比如在分类任务中,如果用交叉熵损失,数值会受类别分布影响;如果用准确率,则可能在某些阶段出现震荡,导致早停误判。
from tensorflow.keras.callbacks import EarlyStopping
early_stopping = EarlyStopping(
monitor='val_loss', # 监控验证集损失
patience=10, # 容忍 10 轮不改善
restore_best_weights=True, # 恢复最佳权重
mode='min' # 越小越好
)
model.fit(X_train, y_train,
validation_data=(X_val, y_val),
callbacks=[early_stopping])
多指标组合监控
有时候单纯看损失或准确率都不够靠谱。一个更健壮的做法是组合多个指标。
from tensorflow.keras.callbacks import Callback
class MultiMetricEarlyStopping(Callback):
def __init__(self, patience=10, delta=0.001):
super().__init__()
self.patience = patience
self.delta = delta
self.wait = 0
self.best_score = -float('inf')
def on_epoch_end(self, epoch, logs=None):
logs = logs or {}
# 组合指标:准确率 70% + F1 分数 30%
current_score = 0.7 * logs.get('val_accuracy', 0) + \
0.3 * logs.get('val_f1', 0)
if current_score > self.best_score + self.delta:
self.best_score = current_score
self.wait = 0
self.model.save_weights('best_weights.h5')
else:
self.wait += 1
if self.wait >= self.patience:
self.model.stop_training = True
self.model.load_weights('best_weights.h5')
这样做的优势是,即使某个指标出现异常波动,整体判断仍然比较稳定。
学习率感知的早停
还有一种更精细的策略是结合学习率调整。当学习率降低到某个阈值时,模型进入"精调"阶段,这时过早停止可能错失更好的结果。
import math
class AdaptiveEarlyStopping(Callback):
def __init__(self, initial_patience=10, min_lr=1e-6):
super().__init__()
self.initial_patience = initial_patience
self.min_lr = min_lr
self.best_val_loss = float('inf')
self.wait = 0
def on_epoch_end(self, epoch, logs=None):
logs = logs or {}
current_lr = self.model.optimizer.lr.numpy()
val_loss = logs.get('val_loss', float('inf'))
# 动态调整 patience:学习率越低,耐心越大
lr_factor = math.log10(self.min_lr / current_lr)
current_patience = self.initial_patience + int(lr_factor * 5)
if val_loss < self.best_val_loss:
self.best_val_loss = val_loss
self.wait = 0
self.model.save_weights('best_weights.h5')
else:
self.wait += 1
if self.wait >= current_patience:
self.model.stop_training = True
self.model.load_weights('best_weights.h5')
踩坑记录
坑一:验证集泄露
第一次用早停时,我犯了个低级错误:把测试集当验证集用了。训练时监控的"验证集"其实是测试集,早停策略无形中"偷看"了答案。最后模型在测试集上表现好得一塌糊涂,但上线后效果平平。

教训:验证集必须独立,不能和测试集混淆。如果数据量有限,考虑用交叉验证或者分层采样。
坑二:patience 设置不当
patience 太小,模型还在震荡阶段就被停掉;patience 太大,又浪费计算资源。
我后来采用了一种渐进式策略:前 50 轮用较大的 patience(比如 15),让模型充分探索;之后逐步缩小到 5 左右,快速捕捉最佳点。
class ProgressiveEarlyStopping(Callback):
def __init__(self, initial_patience=15, final_patience=5,
transition_epoch=50):
super().__init__()
self.initial_patience = initial_patience
self.final_patience = final_patience
self.transition_epoch = transition_epoch
self.wait = 0
self.best_val_loss = float('inf')
def get_patience(self, epoch):
if epoch <= self.transition_epoch:
return self.initial_patience
# 线性衰减到 final_patience
decay_ratio = (epoch - self.transition_epoch) / 50
patience = self.initial_patience - \
(self.initial_patience - self.final_patience) * decay_ratio
return max(self.final_patience, int(patience))
def on_epoch_end(self, epoch, logs=None):
logs = logs or {}
val_loss = logs.get('val_loss', float('inf'))
current_patience = self.get_patience(epoch)
if val_loss < self.best_val_loss:
self.best_val_loss = val_loss
self.wait = 0
self.model.save_weights('best_weights.h5')
else:
self.wait += 1
if self.wait >= current_patience:
self.model.stop_training = True
self.model.load_weights('best_weights.h5')
坑三:batch size 影响
有一次发现同样配置,在不同 GPU 上跑出来的最佳 epoch 差异很大。排查后发现是 batch size 不同导致的:大 batch size 更新次数少,损失曲线更平滑,但也更容易错过局部最优。
现在我在早停回调里加了一个 batch size 修正因子,按比例调整 patience 的计数单位。
def get_patience(base_patience, batch_size, reference_batch=32):
# 按更新次数归一化
return base_patience * (batch_size / reference_batch)
坑四:恢复权重时机
Keras 的 EarlyStopping 回调默认不会自动恢复最佳权重,需要显式设置 restore_best_weights=True。我第一次用时没注意这个参数,导致最后保存的是早停触发时的模型,而不是表现最好的那个。
更隐蔽的问题是,如果训练被手动中断或者异常退出,最佳权重可能已经丢失。所以我在每个 epoch 后都会保存 checkpoint,并保留最近三个版本。
实际效果对比
为了验证不同早停策略的效果,我在一个图像分类任务上做了对比实验:
| 策略 | 平均训练轮次 | 验证集准确率 | 测试集准确率 | 训练时间 |
|---|---|---|---|---|
| 无早停(100轮) | 100 | 92.3% | 87.1% | 4.2小时 |
| 基础早停 | 34 | 91.8% | 88.2% | 1.5小时 |
| 多指标监控 | 41 | 92.5% | 89.1% | 1.8小时 |
| 自适应早停 | 38 | 92.8% | 89.3% | 1.6小时 |

在我这个数据集上,合理早停省掉 60% 以上的训练时间,测试集准确率还略好一点——说明"练到底"有时反而更危险。换模型或换数据,结论可能不同。
选择建议
根据我的经验,不同场景下的早停策略选择可以参考这个决策流程:
实际选择时,还可以考虑以下几点:
- 数据噪音大:适当增大 patience,给模型更多机会过滤噪声
- 模型容量大:更容易过拟合,可以减小 patience
- 学习率调度激进:配合自适应 patience,避免在精调阶段过早停止
- 训练资源紧张:可以用更激进的策略,牺牲一点性能换取时间
结语
早停代码几行就能写完,用好它得熟悉自己的数据和训练曲线——没有放之四海皆准的 patience。
我现在跑训练,不太纠结"该停不停":不同场景换 patience、换监控指标,异常时知道从验证集划分、batch size、权重恢复几条线去查。
知道什么时候停,有时比硬撑更有效——训练如此,别的长期任务也差不多。
可用性说明:本文发布于 2021 年 6 月,距今已超过五年。文中涉及的软件版本、接口、下载地址、命令参数和操作界面可能已经发生变化,部分方案在当前环境下可能失效。请结合官方最新文档核对后再操作,生产环境使用前务必先行验证。
版权声明: 本文首发于 指尖魔法屋-AI早停踩坑记录(https://blog.thinkmoon.cn/post/279-ai-early-stopping-overfitting-best-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。