训练YOLO最磨人的阶段,不是盯着loss曲线不动,而是模型明明已经把一大半图片学得差不多了,每个epoch还是要把几万张图从头到尾刷一遍。这些“已经学会”的图,梯度贡献接近零,却照样占满显存、吃满GPU。我当时的想法很直接:能不能让YOLO训练自动跳过这些图片,把算力留给真正没学会的难样本?
答案是可以,但不能无脑跳。直接跳会造成灾难性遗忘,模型会把你辛辛苦苦学会的旧知识也一起扔掉。这篇文章想聊的Anti-Forgetting Sampling,就是一套在YOLO训练中动态调整采样策略的方法:先判断每张图片的“学习状态”,对已掌握的图片降低采样概率,把省出来的时间用来重复强化难样本,同时安排定期“复习”,防止模型遗忘旧知识。简单说,就是把固定全量训练集,变成一个随模型状态动态变化的训练集。
这篇内容适合训练周期长、数据量大、或者发现模型后期提升很慢的读者。读完你能直接拿到一套可以落地的采样器思路、一段PyTorch风格的核心代码,以及我在实际项目里踩过的几个比较隐蔽的坑。
1. 先想清楚:跳过“已学会”的图片,为什么能加速YOLO收敛
1.1 训练中后期,大量样本的梯度已经“没营养”
很多人把训练时长慢归结于显卡不够强、模型太大,实际上很多时候是数据喂得太低效。YOLO这类目标检测模型,训练后期一张简单图片的损失可能已经降到0.01甚至更低,它的梯度范数非常小,对权重更新的贡献趋近于零。但反向传播的计算量、显存占用、数据读取时间一点没少。
从随机梯度下降的角度看,每个batch本质上是在用一批样本的梯度均值,去近似全体样本的梯度方向。如果batch里大部分是这种“已经学会”的简单样本,梯度方向就被它们主导,真正没学会的难样本反而被淹没了。与其把图片过一遍,不如提高batch内难样本的密度,让每次更新的“信息量”更大。这也是在线难例挖掘(OHEM)能提升收敛速度的根本原因。
不过这引出一个问题:既然难样本信息量更大,那我干脆只抽loss最高的图片不就行了?
1.2 只抽难样本,灾难性遗忘马上会来
只抽难样本的激进方案我试过,前期收敛确实快,但跑着跑着mAP开始往下掉,尤其是在目标检测这种数据分布很不均匀的任务里。
原因有两层。第一,灾难性遗忘。模型在更新中会持续覆盖旧知识,如果反复用某几类难样本训练,之前对简单背景、常见目标形态的稳定判断会逐渐漂移。最典型的表现是,模型突然开始把干净背景误检成目标,或者把原本能正确定位的大目标检测成碎片。第二,难样本不等于高价值样本,高loss可能是标注噪声、严重遮挡、极度模糊造成的。反复抽这些图,等于把噪声当成主要学习信号,过拟合是必然的。
所以训练绕不开“遗忘”这两个字。当你想要偷懒跳过已学会的图片时,必须同时设计一个防止遗忘的机制,否则这个懒偷得不偿失。
1.3 Anti-Forgetting Sampling的本质:动态采样加定期回放
把思路再往前推一步,我们要的其实是三件事的组合:在线难例挖掘、课程学习和记忆回放。
- 对尚未掌握的样本:维持正常采样,甚至是提高采样频率,保证难样本能反复刺激模型。
- 对已经掌握的样本:降低采样概率,让它们少占计算资源,但不是完全删除。
- 对长期没复习的已掌握样本:每隔若干轮强制回放一遍,对抗权重更新带来的遗忘。
这个思路和人类学习很像。你背单词,已经滚瓜烂熟的那批不需要天天拿出来背,但隔一段时间必须重新过一遍,否则时间长了还是会忘。Anti-Forgetting Sampling本质上就是把这个学习节奏搬进了YOLO训练。
2. 设计前先解决三个问题:什么算“已学会”、什么时候更新、怎么跳
2.1 “已学会”不能只看单轮loss
很多第一次实现的人会踩同一个坑:拿某一轮的单个样本loss做阈值,loss低就标记为mastered。这在YOLO训练里非常不稳定,因为单batch的loss波动很大,而且检测任务的loss是多个损失项的加和,一张图loss低可能只是分类学得不错,框却不准;反之,框回归学得好但分类置信度不足,总loss也会偏高。
我的经验是找一个组合指标,而不是单一指标。训练过程中可以用三个维度叠加:
- 样本loss的指数移动平均(EMA),消除单轮噪声;
- 连续多轮命中“低loss区间”的次数,避免某个batch偶发低loss造成误判;
- 如果模型已经训练到中后期,还可以在epoch结束时做一次快速验证,统计每张图GT目标被检出的平均IoU和置信度。
下表是我在实际方案里对比过的几个候选指标:
| 指标 | 计算成本 | 稳定性 | 是否适合在线判断 |
|---|---|---|---|
| 单轮loss | 极低 | 差 | 不适合 |
| EMA loss | 低 | 中 | 适合做初步筛选 |
| EMA loss加连续命中次数 | 低 | 较好 | 推荐 |
| 每图GT召回率/平均IoU | 高 | 最好 | 适合隔N轮复核 |
最终我的选择是:在线判断用“EMA loss加连续低loss次数”,每5到10个epoch再用验证集做一次整体复核,把复核不达标的样本重新拉回难样本池。
2.2 更新粒度要粗一点,别在step级别频繁改采样
采样策略不能每个step都变,否则数据集分布一直在变,模型连稳定的学习环境都没有。更明显的问题是:可能会导致同一个batch内样本来源剧烈变化,训练震荡。
我建议以一个epoch作为更新粒度。每个epoch结束之后,根据这个epoch累计的样本状态重新计算一次“已学会名单”,再生成下一轮的采样索引。如果数据集特别大、每个epoch时间太长,可以在epoch中间拆成若干大段,比如每5000个batch更新一次也可以,但至少保证更新后的策略能稳定跑一段时间。
2.3 跳过不是删除:软概率和复习窗口缺一不可
已学会样本不能硬跳过。最稳妥的做法是给它们保留一个较低的采样概率,比如0.15到0.3。同时每R个epoch把所有mastered样本强制过一遍,我通常设R等于4或5。
为什么要这么绕?因为模型在训练过程中会不断变化,今天学会的图片,过几个epoch后可能因为中间层特征重构又变得陌生。如果完全跳过并且从不复习,模型可能悄悄把以前学会的类别特征丢掉。复习窗口的意义就是给这些“冷藏样本”一个重新回到训练流的机会,让模型真正形成长期记忆。
3. 实操:一个可运行的Anti-Forgetting采样器
3.1 先搭一个样本状态记录器
如果训练循环是PyTorch风格,第一步是定义一个SampleState类,负责维护每张图片的EMA loss、连续低loss次数和是否mastered。
import numpy as np class SampleState: def __init__(self, num_samples, alpha=0.95, streak_required=3): self.num_samples = num_samples self.alpha = alpha self.streak_required = streak_required self.loss_ema = [None] * num_samples self.low_streak = [0] * num_samples self.mastered = np.zeros(num_samples, dtype=bool) def update(self, idx, loss_value, low_threshold): if self.loss_ema[idx] is None: self.loss_ema[idx] = loss_value else: self.loss_ema[idx] = (self.alpha * self.loss_ema[idx] + (1 - self.alpha) * loss_value) if self.loss_ema[idx] < low_threshold: self.low_streak[idx] += 1 else: self.low_streak[idx] = 0 if self.low_streak[idx] >= self.streak_required: self.mastered[idx] = True def refresh(self, idx, loss_value, low_threshold): # 复习时如果样本loss已经超过阈值,解除mastered状态 if loss_value > low_threshold: self.mastered[idx] = False self.low_streak[idx] = 0注意low_threshold不用手工设死。我采用的方法是每个epoch结束后,取当前所有样本EMA loss的分位数作为动态阈值,比如np.percentile取30%到40%。因为loss的绝对数值会随着训练阶段变化,固定阈值前期太紧后期太松,完全不如动态分位数好用。
3.2 动态生成每个epoch的采样索引
记录器有了,接下来要在每个epoch开始前生成一份“这个epoch要看哪些图”的列表。
import random def build_epoch_indices(state, epoch, replay_interval=4, mastered_keep_prob=0.25): normal_idx = [i for i in range(state.num_samples) if not state.mastered[i]] mastered_idx = [i for i in range(state.num_samples) if state.mastered[i]] # 复习窗口:每隔replay_interval个epoch,把所有mastered样本全量过一遍 if epoch % replay_interval == 0: keep_mastered = mastered_idx[:] else: # 非复习epoch,对mastered样本做软概率保留 keep_mastered = [i for i in mastered_idx if random.random() < mastered_keep_prob] indices = normal_idx + keep_mastered random.shuffle(indices) return indices这里有几个细节值得展开。
第一,mastered样本的保留概率不是越高越好。我测下来的规律是,如果数据集里简单样本占比超过60%,保留概率设在0.2到0.3之间比较合适,既能明显缩短epoch,又不会让简单样本彻底消失。如果数据集本身就不大,或者简单样本比例不高,保留概率要提到0.5以上。
第二,复习窗口的采样顺序必须是全量mastered,不是随机抽一部分。否则“复习”效果会被稀释。你可以把复习epoch理解成一个专门的“记忆巩固轮”,这轮训练时间会长一些,这是设计上允许的。
第三,代码里我用random.shuffle打乱,但实际项目中建议先把索引转换成生成batch的序列,再交给DataLoader的sampler。如果你的训练框架不支持动态索引,可以把这份索引列表直接导出成.txt,再用自定义Dataset去读取。
3.3 在YOLO训练循环里怎么接
下面这段不是完整的训练代码,而是把核心逻辑接进训练循环时的最小骨架,重点是展示数据流。
from torch.utils.data import DataLoader, Subset state = SampleState(len(full_dataset)) global_low_threshold = 1e-3 for epoch in range(epochs): indices = build_epoch_indices(state, epoch) train_subset = Subset(full_dataset, indices) loader = DataLoader(train_subset, batch_size=batch_size, shuffle=True, num_workers=8) epoch_losses = np.zeros(len(full_dataset), dtype=np.float32) epoch_counts = np.zeros(len(full_dataset), dtype=np.int32) for images, labels, img_ids in loader: preds = model(images) loss, per_sample_losses = criterion(preds, labels) loss.backward() optimizer.step() optimizer.zero_grad() # 这里per_sample_losses需要你训练框架里能拿到样本级loss # 如果拿不到,用后面3.5节的办法替代 for local_i, global_i in enumerate(img_ids): epoch_losses[global_i] += per_sample_losses[local_i].item() epoch_counts[global_i] += 1 # epoch结束时更新阈值和状态 with torch.no_grad(): valid_mask = epoch_counts > 0 avg_losses = np.zeros(len(full_dataset), dtype=np.float32) avg_losses[valid_mask] = epoch_losses[valid_mask] / epoch_counts[valid_mask] low_threshold = np.percentile(avg_losses[valid_mask], 35) for i in range(len(full_dataset)): if epoch_counts[i] > 0: state.update(i, avg_losses[i], low_threshold)那段per_sample_losses的获取是个关键问题。很多封装好的YOLO训练框架只返回整batch的平均loss,不会暴露每个样本的loss。后面第4节我会单独说怎么处理这个限制。
3.4 一个更省事的做法:双DataList切换
如果不想深度修改训练脚本,还有一套更“懒”的落地方式。
先把训练集拆成两份文件:一份是当前训练的普通列表,一份是复习用全量列表。前几个epoch正常训练,让模型充分见过所有图片。之后每隔4个epoch,就把训练用的数据yaml或图片列表字段切换成全量列表,跑一个复习epoch,其他epoch全部用过滤后的mastered子集。
这种方案最大的优势是不碰网络结构、不碰loss、不碰优化器,只是在外层控制“喂给模型哪些图片”。代价是复习节奏比较僵硬,mastered名单不会随模型状态动态变化,但用来验证思路已经足够。我之后会把“影子学习率”这类改进丢进去,先把这个简单版本跑起来,收益会很直观。
3.5 拿不到per-sample loss时的替代方案
在YOLO系列训练里,有相当一部分版本确实不好拿per-sample loss。这种情况我的替代方案是:用“预测置信度”和“正样本匹配质量”来估计样本难度。
具体做法是训练时收集每个batch中每张图片的输出框置信度均值,以及预测框与GT的最大IoU。置信度越高、最大IoU越高,说明这个样本当前越“熟”。这个指标的噪声虽然没有loss那么直接,但胜在稳定,而且不需要修改loss计算逻辑。你甚至可以在一次前向里用torch.no_grad()完成计算,不影响梯度。缺点是计算量稍高,不过你本来就是在省epoch开销,这点多出来的前向成本完全能接受。
4. 常见问题与排查技巧实录
4.1 跳过太多,mAP反而掉了怎么调
这个现象我碰得最多。第一反应不要怀疑“跳过图片”这个方向错了,而是先检查是不是复习节奏太松。比如replay_interval设成10,mastered概率设为0.05,那模型可能连续很多epoch见不到简单样本,旧特征被冲掉。
我自己的排查顺序是:
- 看复习epoch的loss是不是明显高,如果高,说明遗忘已经出现,复习间隔要缩短到2或3;
- 看mastered占比,如果超过全数据集的80%,说明阈值太松,低loss分位数要从35%调到25%;
- 看batch内难样本占比,如果长期超过60%,就强制从mastered池补充一些样本进batch,把难样本比例压到50%以内。
记住,Anti-Forgetting Sampling优化的是训练效率,不是改变最终精度上限。如果模型还没到收敛阶段就用这套,反而会拖慢学习。
4.2 难样本翻来覆去就是那几张,容易过拟合
这是难例挖掘的通病。如果样本池里只有几百张难图被反复强化,模型很快就会开始记图,而不是学特征。表现出来是训练loss下降很快,验证集的mAP却波动甚至下降。
我的对策有两个。第一,batch构造时给难样本上限设一个阈值,比如50%。剩下的位置从mastered或普通样本里随机补,避免一个batch全部是极端难例。第二,对某个样本被抽中的次数做记录,如果一个epoch内被抽中次数明显高于平均,下个epoch临时降低它的采样权重。这个和去重逻辑类似,但实现成本更低。
4.3 复习轮loss突然暴涨,训练不稳定
如果你的模型已经连续三个epoch只看到mastered子集,突然一轮全量复习,loss暴增是正常现象。先不用停,让它跑完一个epoch,通常loss会回落到正常水平。这个现象说明当前训练策略中“复习”确实是必要的,模型已经出现了前向遗忘。
但如果连续两三个复习epoch都不能回落,就要检查两件事:一是学习率是否太大,建议复习轮把学习率降到正常值的0.5倍;二是mastered概率是否太低,导致非复习轮几乎看不到简单样本,模型对简单样本的适应能力退化得太多。另外,如果训练阶段本来就分冻结backbone和全量微调,尽量把复习轮安排在全量微调阶段,否则冻结状态下模型没有能力更新特征,复习意义不大。
4.4 训练框架封装太死,没法动态改采样器
市面上很多YOLO训练脚本不支持“动态生成索引列表”,因为数据加载部分已经被封装成黑盒。这种情况下我的经验是:不要死磕框架,绕开它。
把“训练集过滤”做成外层的双列表切换。先跑一个脚本,把每个epoch的图片索引列表写到一个current_train_ids.txt;训练框架每次读这个文件构建数据集。复习epoch时脚本自动切到all_train_ids.txt。虽然重启进程有开销,但训练本身长达数小时,这点开销可以忽略。这个方法几乎兼容所有YOLO训练框架,甚至包括只用命令行参数训练的情况。
5. 一次真实训练里的“偷懒”实验
5.1 数据情况和实验配置
这个实验来自我之前参与的一个设备外观检测项目。数据是自己采集标注的,一共6万张图,其中大约40%是空背景,35%是常见规格的零件,剩下25%才是真正的难样本,包括反光、遮挡、极小目标。模型用的就是YOLO系列检测器,输入尺寸固定,初始学习率0.001,训练目标是达到特定类别mAP。
正常训练时,每个epoch要把6万张图完整过一遍,单卡训练,epoch耗时比较长。模型在80轮左右mAP基本不再上升,但训练大约过了200轮才正式结束。这期间的绝大多数图片,其实已经训练得很“熟”。
5.2 用上Anti-Forgetting Sampling之后的变化
前10轮保持全量训练,让采样器拿到每个样本的初始EMA loss。从第11轮开始启用动态采样,mastered保留概率设为0.25,复习间隔设为4轮,低loss分位数取35%,每个batch里难样本比例上限50%。
10轮结束时,大约有60%的样本被标记为mastered。非复习epoch只需要跑大约3.2万张图,复习epoch还是6万张。折算下来平均每个epoch大约跑3.9万张,比原来的6万张少了35%。最终达到同样mAP的耗时,从原来的33小时降到了20小时左右。更意外的是最终mAP比原来还高了0.3个点,主要原因是难样本被强化得更充分,尾部类别的召回率更好。
这个项目让我比较确信,训练样本冗余度高的目标检测任务,静态全量训练确实存在大量浪费,动态采样是有实打实收益的。
5.3 也要说清楚:这个思路不适合所有场景
不要指望Anti-Forgetting Sampling在所有数据集上都有效。在一个只有1万张图、且每张图都是精心挑选的难例的数据集上,我把这套策略跑过,效果不明显。因为简单样本地基本来就不厚,每次过滤后剩余样本量太小,模型很快开始在很小范围内反复学习,收益完全被过拟合风险抵消。
我在这个项目上得到的适用范围判断是:训练样本量越大、简单样本或背景类样本占比越高、训练后期无提升现象越严重,这套策略的收益越明显。如果数据量本来就不大,或者你的业务场景要求每张图都是关键样本,别用这套东西,老老实实全量训练反而更稳。
我现在做目标检测训练,已经习惯把这种“动态采样加定期复习”的思路加进训练脚本里。当然,每个项目的mastered概率、复习间隔、低loss分位数都不会完全一样,需要微调。但方向是确定的:想让YOLO训练“偷懒”,关键是找到哪些图片可以少看,同时想好怎么让模型不忘记它们。别让偷懒偷掉了模型真正学会的东西。