SGDR学习率调整策略,这几年在目标检测、图像分类、语义分割这些任务上,几乎成了我默认的调参起手式。它不是那种需要你反复试错半天才能看到效果的花哨技巧,而是把“学习率怎么衰减”这件事,从玄学变成了一个有章可循的周期性过程。很多刚接触的朋友第一反应是:这不就是余弦退火吗?跟热重启有什么关系?怎么实现?效果真的比Step Decay或者Cosine Annealing强吗?这篇文章我会把我从原理理解到工程落地的完整经验拆开讲,包含PyTorch下的具体实现、参数怎么设、以及在几个真实任务里踩过的坑和建议。
看这篇文章的,我默认你至少已经跑通过一个分类或者检测模型,知道什么是epoch、什么是优化器、什么是学习率。如果你刚入门,也不用怕,前面的原理部分我会用最直白的话解释清楚,你只需要先动手把代码跑起来,等对loss曲线有了体感,再回来看原理,会通透很多。
1. 从“一条直线衰减”到“周期回弹”:SGDR到底在想什么
1.1 学习率为什么不能一直很大,也不能直接归零
我们先回顾一个最基础的问题:训练后期学习率为什么要降下来?因为模型参数已经逼近一个比较好的区域,如果学习率还很大,每一步都在这个区域附近乱跳,loss降不下去,甚至可能直接跳出去。所以常见的做法是训练到一半或三分之二的时候,把学习率乘以0.1,这就是Step Decay;或者更平滑一点,用余弦函数把学习率从初始值慢慢降到接近0,这是Cosine Annealing。
这两种方法都有一个问题:学习率是单调递减的。它背后的潜台词是:模型应该沿着一个方向走到黑,不回头。但神经网络的实际优化地形根本不是这样。真实的高维loss landscape极度崎岖,到处都是局部极小值、鞍点、陡峭的沟壑。你在下坡路上走得好好的,不代表前面不是个大坑。单调递减的学习率,相当于你只给自己一次机会,选了一个方向,赌它是对的。
SGDR换个思路:既然我不知道哪个区域最好,那我就多试几次。每次学习率降到很低的时候,我把学习率突然拉回一个比较大的值,相当于让模型暂时忘掉之前收敛的路线,重新在当前这个位置附近大范围探索。这就是Warm Restarts,中文常叫热重启或暖重启。注意不是Cold Restarts,模型参数没有重置,只是把学习率重新拉高,所以是从当前参数状态重新出发,效率高得多。
1.2 余弦退火和热重启的配合逻辑
SGDR的完整名字是Stochastic Gradient Descent with Warm Restarts。它把整个训练过程切成很多个周期,每个周期内部:
- 学习率从一个设定的最大值开始,按余弦函数下降;
- 降到周期末尾时接近一个最小值;
- 下个周期开始时,学习率直接跳跃回最大值;
- 参数继承上一次的结果,继续下一轮。
这个“跳回”的动作,就是重启用意的体现。为什么有用?两个层面理解。
第一,跳出局部最优。训练后期,如果只在很小的学习率下沿着梯度方向微调,模型很可能卡在某个尖锐的极小值里,泛化性能并不好。学习率突然拉大,模型会在损失面上做一次大范围跳跃,有机会找到另一个更宽阔、更平坦的极小值。做CV任务的朋友应该有印象,验证集上你偶然看到某个epoch效果特别好,但下个epoch又掉回去了,其实这就是模型在不同极小值区域之间游弋的正常表现。
第二,和SGD+Momentum结合有奇效。SGDR论文里实验用的一直是带momentum的SGD,他们在Warm Restarts的启动阶段,会把momentum buffer里的历史梯度方向做一次重置或者重新累积。因为学习率重新拉大之后,旧的梯度方向是低学习率阶段积累的,它反映的是之前那个极小值周围的局部方向,直接沿用会把模型拖回老路。实际用PyTorch实现时可以不做这一步,但了解这个细节,你就知道为什么有的复现里会单独为momentum做处理。
1.3 一个必要的类比:煮饺子点水
想快速理解SGDR,你就想煮饺子的过程。水烧开(学习率最大),饺子剧烈翻滚,这是大步探索;然后你点一碗凉水(降低学习率),翻滚逐渐平息,饺子在温和沸腾中慢慢煮熟;但你不等它彻底凉掉,又点一次水,再次翻滚。每一轮翻滚都让饺子皮和馅更充分煮熟,而不是第一轮就一次性煮到烂。深度学习也一样,一轮complete下降加一轮大范围探索,往往比一次闷头跑到底更稳。
当然类比只是辅助理解。真正重要的是你要认识到SGDR不是用来“加速收敛”的,它的核心收益体现在最终模型的泛化性能和稳定性上。你可能会发现训练loss不如单次余弦退火好看,但验证集指标却是历史最佳。
1.4 它和普通Cosine Annealing的关键区别
有些人把SGDR和Cosine Annealing混为一谈,因为PyTorch里CosineAnnealingLR和CosineAnnealingWarmRestarts名称相近,实现也高度相似。但关键区别就在WarmRestarts这五个字上。普通余弦退火是单周期:lr从最大值平滑降到一个极小值,然后训练结束。SGDR是多重启的余弦退火:lr从一个相对高的值反复弹射到相对低的值,多个周期叠加。
还有一个经常被忽视的点,SGDR里的重启间隔是可以等比扩大的。T_mult=2的时候,第二个周期长度是第一个的两倍,第三个是四倍。这样设计的原因是,随着训练推进,模型已经比较成熟,每个周期需要更多的时间来充分探索和收敛,如果每次都是固定长度,越到后面越局促。这个T_mult参数我强烈建议保持默认值2,除非你有明确理由。
2. 在PyTorch里把SGDR稳妥落地:实现细节与完整参数清单
2.1 首选方案:直接用torch.optim.lr_scheduler.CosineAnnealingWarmRestarts
不需要自己手写余弦公式,PyTorch已经内置好了。核心代码就这样:
import torch from torch.optim import SGD from torch.optim.lr_scheduler import CosineAnnealingWarmRestarts model = build_model() # 你自己的模型 optimizer = SGD(model.parameters(), lr=0.1, momentum=0.9, weight_decay=5e-4) scheduler = CosineAnnealingWarmRestarts( optimizer, T_0=10, # 第一个完整周期的epoch数 T_mult=2, # 每个周期的长度乘以这个倍数 eta_min=1e-6 # 周期内学习率下降到的最低值 )注意一点,PyTorch中这个调度器要求每个epoch调用一次scheduler.step()。如果用的是PyTorch Lightning或者自己写train loop,核对一下调用时机。Lightning的lr_scheduler配置里,如果调度器没有明确绑定step模式,默认是epoch级别的step。
如果你需要在同一epoch内多次调整学习率,比如batch-level的周期(训练样本巨大时可能一个epoch内就做一次完整衰减),可以在每个batch之后手动调用scheduler.step()。但绝大多数CV任务,epoch级就够了。
2.2 如果担心内置调度器行为不符,手写一个也很快
内置调度器封装的细节很多,不方便调试的时候,自己写一个更可控。核心其实就一个余弦公式,但注意PyTorch状态管理,写起来要严谨:
import math import torch class SGDRScheduler: def __init__(self, optimizer, T_0, T_mult=2, eta_min=1e-6): self.optimizer = optimizer self.T_0 = T_0 self.T_mult = T_mult self.eta_min = eta_min self.eta_max = optimizer.param_groups[0]['initial_lr'] self.T_cur = 0 self.T_i = T_0 self.last_epoch = 0 def get_lr(self): # 当前周期内,学习率按余弦从eta_max降到eta_min ratio = math.cos(math.pi * self.T_cur / self.T_i) return [self.eta_min + 0.5 * (self.eta_max - self.eta_min) * (1 + ratio)] def step(self): self.last_epoch += 1 self.T_cur += 1 if self.T_cur >= self.T_i: self.T_cur = 0 self.T_i *= self.T_mult if self.T_i == 0: self.T_i = self.T_0 lr = self.get_lr()[0] for param_group in self.optimizer.param_groups: param_group['lr'] = lr return lr注意这里的自定义实现没有处理momentum buffer重置,这在标准SGD实现里需要额外操作,PyTorch原生调度器也没有内置这个逻辑。先不用管它,工程上影响不大。
2.3 关键参数怎么选:T_0、T_mult、eta_min
我把这几个参数的口语化解释和推荐值整理成表,方便你复制到实验记录里:
| 参数 | 含义 | 常用设置 | 我的建议 |
|---|---|---|---|
base_lr/lr | 周期初始学习率(也是最大学习率) | 跟普通SGD最大学习率保持一致 | 先用Step Decay能跑通的lr,SGDR不需要额外调大,除非你明确要做lr warmup |
T_0 | 第一个周期的epoch数 | 等于总epoch数的1/4到1/2 | 如果总训练只有50个epoch,T_0可以设为10,T_mult=2的话,周期就是10+20+20=50,刚好三段 |
T_mult | 周期长度扩大倍数 | 2 | 这个值我试过1和3,1(固定周期)对超大数据集友好,3会让后期单周期过长,不推荐 |
eta_min | 周期内学习率的下限 | 最大lr的1/100到1/1000 | 目标检测里我常用max_lr * 0.01,分类任务用1e-6比较稳 |
warmup_epochs | 前期学习率预热 | 总epoch的5%到10% | 大batch训练必须配合,不然第一个周期太猛,模型直接爆炸 |
值得单独说的一点是:T_0的取值不应该一拍脑袋。如果你的任务之前用Step Decay在30个epoch收敛得很好,现在切到SGDR,我建议T_0取总epoch的1/3左右,然后让T_mult=2。比如总训练90 epoch,T_0=20,周期划分就是20 + 40 + 40(最后一个周期不完整),这样做的好处是最后一段训练时间还足够长,学习率在低位上有足够时间做精细收敛。
如果你总epoch数本身就不多,比如只有20个epoch,那就不太建议用SGDR。周期刚开始第一个下降还没完成就切走了,后面的周期全都吃不到完整的余弦衰减福利,效果可能还不如Step Decay。
2.4 完整训练循环里,step的调用顺序千万别整错
PyTorch里调度器step的调用顺序,经常让新手头疼。主流做法是epoch结束时调用:
for epoch in range(total_epochs): train_one_epoch(model, dataloader, optimizer, criterion) validate(model, valid_dataloader, criterion) scheduler.step() current_lr = optimizer.param_groups[0]['lr'] print(f'Epoch {epoch+1}, LR = {current_lr:.6f}') # 顺便记录日志,方便画lr曲线这个顺序看起来简单,但和ReduceLROnPlateau那种“先验证再调整”的调度器不同,SGDR是严格基于epoch计数的,不依赖验证loss,所以在epoch结束后调用即可。如果你在epoch开始前调用,也没有原则性错误,因为你只是把整个衰减曲线整体左移了不到一个epoch,但为了和PyTorch文档里示例一致,我建议epoch尾调用。
还有一点很关键,多GPU训练时它天然没问题,因为调度器只修改optimizer里的lr,不涉及梯度同步,不用额外注意。
3. 为什么SGDR能在验证集上稳定拿分:从损失地形到模型集成视角
3.1 平坦极小值:为什么它能泛化更好
你如果看过一些优化理论文章,应该对“锐利极小值和平坦极小值”不陌生。在锐利极小值附近,loss对参数变化非常敏感,参数稍微动一点点,验证性能就崩了。而在平坦极小值附近,参数变化很大,loss变化都很小,模型表现稳定得多。
SGDR的周期性大学习率探索,天然倾向于找到这种平坦的极小值区域。为什么不解释原理呢?因为在平坦区域,模型对训练数据的拟合不会是“死记硬背”式的极端行为,而是学习到了更通用的特征。这就好比一个学生复习时如果只抱着题库最后一页的题目答案背,考试题目稍微变一点就懵了;如果他在复习中经常把学过的内容放一放,换一种方式重新看一遍,掌握的是知识结构本身。
这也能解释为什么SGDR得到的模型不一定在训练集上打得过单调Cosine Annealing,但验证集上经常更稳。你追求的是泛化,不是过拟合训练集的极值。
3.2 多周期平均值:免费的模型集成
SGDR有一个让我非常喜欢的附带效果:可以用它做廉价的模型集成。既然每个周期结束时,模型都处于一个相对稳定的极小值附近,那你完全可以把不同周期结束时的checkpoint保存下来,在推理阶段对这组模型的预测取平均。
在检测任务和分类任务里,我都做过这个操作。具体做法是,在T_cur为0的那个epoch(即每个周期最后一步)保存模型权重,比如SGDR跑了4个周期,就有4个模型文件,推理时平均4个模型的softmax输出或者检测框回归结果。这个效果比单个模型能再提升一个不小的点,而且代价几乎为零,因为训练流程本身没有改变,只是多个存了文件。
我用一个小表格来总结这个行为:
| 方案 | 验证集top-1准确率(模拟某分类任务) | 说明 |
|---|---|---|
| Step Decay | 77.2% | 0.1倍衰减两次 |
| 单周期Cosine Annealing | 78.0% | 无重启 |
| SGDR + 最后一个周期模型 | 78.4% | 热重启带来的提升 |
| SGDR + 多周期模型平均 | 79.1% | 免费集成的收益 |
注意数字只是举个例子,不同任务提升幅度不一样,但多周期平均大概率会比单模型表现好。如果项目对推理延迟不是特别敏感,建议认真考虑一下。
3.3 和warmup、batch size的协同,别拿大batch硬上
现在很多训练脚本里都有warmup,前几个epoch学习率从很小的值慢慢爬升到设定的最大值。SGDR和固定warmup并不冲突。训练初期用warmup,是为了避免模型参数初始化很随机的时候,突然给一个很大的学习率导致loss爆炸。而SGDR后面的重启是“热”重启,模型参数已经有意义了,所以不需要每个周期都做warmup。
那如果你的batch size很大,比如一张卡塞不下,只能开8卡同步训练,batch size从256涨到2048,这时候直接用SGDR很容易崩。我的建议是,最大学习率不要沿用之前小batch时的值,要么降低1到2倍,要么搭配更长的warmup。这个经验在目标检测里尤为明显,大batch下loss曲线抖得厉害,SGDR前期的大学习率会加剧抖动。
3.4 数据增强层面的同步调整
有一件容易被忽略的事:SGDR周期性强,如果数据增强策略也是每个epoch动态变化的(比如某些任务的增强强度随epoch增加),两者会在时间上产生耦合。我没法给出一个万能结论,但建议至少保证数据增强在单个SGDR周期内尽量平稳,避免学习率重新拉高时,增强强度也在峰值,双重冲击叠加,训练稳定性会变差。
4. 训练日志怎么读:从loss曲线和水波纹看出门道
4.1 完整训练曲线长什么样
一段训练跑完,你画出loss曲线,会看到明显的“锯齿”形状。每个周期开始时,由于学习率跳回较高值,loss出现一个明显上升的台阶,随后跟着余弦下降慢慢回落,在周期尾部达到当前阶段的最低点。很多人第一次看到这个锯齿会被吓到,觉得是不是训练出问题了。不是的,这正是SGDR的正常特征。
重要的是,每一个周期的loss最低点,通常都比前一个周期的最低点更低。如果有某个周期的最低点没有下降,而是持平甚至反弹了,就要警惕是不是学习率最大值的设定有问题,或者数据量相对模型规模太小,导致反复重启都收敛到同一个区域。
4.2 验证集指标有波动?别急着中断训练
伴随loss锯齿,验证集指标也会上下波动。周期中段验证指标下降(因为学习率较高,模型在剧烈探索),周期末尾指标回升。如果你在某个周期中段的验证指标还不如上一个周期末尾,直接中断训练并归咎于SGDR不好用,这就误判了。要判断训练是否正常,应该看每个周期末尾的趋势,而不是单点epoch的结果。
我在一次分割任务中就犯过这个错,第11个epoch(第二个周期中段)的验证mIoU比第10个epoch(第一个周期末尾)跌了不少,当时差点把调度器换回Step Decay,后来按下了冲动,跑到第15个epoch,指标直接创了新高。所以第一次用SGDR,建议跑完至少两个完整周期再做判断。
4.3 借助lr曲线,检查调度器是否按预期工作
想确认调度器工作正常,最简单的方法就是记录每个epoch的lr,训练结束后画出曲线。正常曲线是由一段段各不相同的“山峰”组成,每座山峰的跨度递增(T_mult=2时),高度相同(因为每个周期的最大值没变),谷底都贴近eta_min。如果曲线在某些周期高度不一样,就得检查initial_lr是不是被某个回调(比如wandb的hook)意外修改了。
这里有一个实用的小技巧:设置eta_min不要设为0。设为0的话,最后一个周期末尾学习率趋近于0,模型更新幅度极小,如果那个位置的模型不是最好的点,后面的周期要花很长时间才能“跳”出来。eta_min设为最大学习率的1/100,比如0.1对应0.001,这样周期末尾模型还有一定的流动性,不会凝固住。
5. 常用框架的实现差异以及TensorFlow怎么处理
5.1 PyTorch和TensorFlow在接口上的对比
PyTorch我们已经看过了,CosineAnnealingWarmRestarts(optimizer, T_0=..., T_mult=..., eta_min=...)。TensorFlow/Keras这边,没有现成的同一名字回调,需要自己写一个Callback子类。逻辑很简单:在每个epoch结束时的on_epoch_end里,根据当前epoch计算出当前应该的学习率,然后K.set_value(model.optimizer.lr, new_lr)。
import math import tensorflow as tf from tensorflow.keras.callbacks import Callback class SGDRCallback(Callback): def __init__(self, T_0=10, T_mult=2, eta_min=1e-6): super().__init__() self.T_0 = T_0 self.T_mult = T_mult self.eta_min = eta_min self.T_i = T_0 self.T_cur = 0 self.epoch_count = 0 def on_epoch_end(self, epoch, logs=None): self.epoch_count += 1 self.T_cur += 1 if self.T_cur >= self.T_i: self.T_cur = 0 self.T_i *= self.T_mult base_lr = self.model.optimizer.lr.numpy() if hasattr(self.model.optimizer.lr, 'numpy') else float(self.model.optimizer.lr) # 注意:不能用当前lr当base,应该保存初始lr ratio = math.cos(math.pi * self.T_cur / self.T_i) new_lr = self.eta_min + 0.5 * (self.initial_lr - self.eta_min) * (1 + ratio) tf.keras.backend.set_value(self.model.optimizer.lr, new_lr)这里有个很常见的坑,我在代码里特别注释了:不能获取当前lr作为base_lr,因为当前lr在每个epoch结束后已经被改掉了。理论上应该捕获optimizer创建时的初始lr,更稳妥的办法是在on_train_begin里记录一次:
def on_train_begin(self, logs=None): self.initial_lr = float(tf.keras.backend.get_value(self.model.optimizer.lr))有朋友用Adam这种自适应优化器跑SGDR,说实话收益没有SGD+Momentum那么明显,因为Adam对每个参数的学习率有独立缩放,外部调度器的影响被削弱了。我的经验是:Adam作为主优化器时,还是优先把精力放在batch size、weight decay这些本身对Adam影响更大的超参上;但如果你想在Transformer类模型上用SGDR,也是一种可行的baseline,至少它不会比固定学习率更差。
5.2 warmup和SGDR的完整顺序:先平缓爬坡再周期性震荡
很多工程框架现在都内置了warmup,但和SGDR结合时顺序容易乱。以PyTorch为例,正确做法是前面若干epoch用一个单独的线性warmup调度器,然后切换到SGDR,而不是在SGDR内部硬塞warmup。代码逻辑可以这样组织:
scheduler_warmup = LinearLR(optimizer, start_factor=0.01, total_iters=5) scheduler_sgdr = CosineAnnealingWarmRestarts(optimizer, T_0=15, T_mult=2) for epoch in range(total_epochs): train_one_epoch() validate() if epoch < 5: scheduler_warmup.step() else: scheduler_sgdr.step()这里唯一要留意的是,如果系统对_最后一个调度器_有限制,需要在切换后确认当前lr确实是从warmup结束时的值平滑过渡到SGDR的起始最大值。理想情况下,warmup结束时lr应该正好等于SGDR设定的initial_lr。
6. 我在本地任务中真实遇到的坑和排查速查表
6.1 少数族类/长尾分布下,SGDR表现反而变差?
这句话先放在这里,我会用我的实验复盘说明。
在某次长尾分布的分类任务里,头部类别很多,尾部类别极少。用了SGDR后,训练集loss锯齿特别明显,验证集整体准确率有所提升,但尾部类别的recall明显下降。复盘后发现,大学习率重启阶段让模型产生了“偏向头部类别”的倾向,因为头部类别样本多,梯度方向上头部占的权重更大。这是SGDR本身的问题吗?不是,是任务和条件不匹配。更合适的做法是配合Class-Balanced Sampler或Focal Loss,SGDR只负责控制器学习率,不负责解决采样不均衡。
所以如果一个任务本身类别很不均衡,不要指望SGDR能救回来,先把采样和损失函数处理好。
6.2 周期内epoch数太少,余弦衰减的优势发挥不出来
有一次我图省事,把T_0设成2,总共28个epoch,T_mult=2。跑出来发现训练曲线和固定学习率没什么区别,每个周期才2个epoch,学习率还来不及形成完整的余弦曲线就重制了。调整成T_0=7之后,锯齿形态清晰可见,最终指标也好了不少。
经验值:单个周期最好不少于5个epoch,如果数据集小、训练总epoch少,就不建议强行用SGDR。
6.3 BatchNorm和SGDR的兼容坑
BatchNorm层在训练和验证时的行为不同,它依赖当前batch的统计量。SGDR大学习率阶段,模型参数变化剧烈,BN层统计量也随之波动。如果你在验证时直接切到model.eval(),BN会用训练过程中累计的running_mean和running_var,但SGDR周期性参数大变动,会让这些统计量的历史混合显得“滞后”,导致指标波动更大。
建议:如果观察到验证集指标在周期中段严重震荡,可以先在验证前多跑几个batch的“warm-up”(在train模式下用训练集batch前向传播小量次数,让BN统计量贴近当前参数),再切到eval。Pytorch官方没有直接支持,但我试过在验证step前手动调用模型前向传播在训练模式下的几次dummy batch,确实能缓解部分震荡。
6.4 日志可视化别只记录“最终lr”
还有一次花了很多时间排查,最后发现得到的lr曲线完全不像预期的多段余弦,而是每个周期后lr直接掉到0附近起不来。检查代码后才意识到,是某个日志回调在每隔N个batch时调用了一次optimizer.param_groups[0]['lr']的平方,画的时候我忘了取平方根,冤枉了调度器。
不用嘲笑我,这种问题很典型。建议在日志里同时记录optimizer.param_groups[0]['lr']和epoch两个字段,画出图后如果锯齿异常,先确认是不是日志记录本身错了。
6.5 完整排查速查表
| 现象 | 可能原因 | 建议措施 |
|---|---|---|
| loss曲线在每个周期开头暴涨 | 学习率最大值太大或warmup不足 | 降低base_lr,或加长warmup |
| 周期尾部loss不降反升 | eta_min太大或验证集影响 | 调小eta_min,检查数据顺序是否固定 |
| 验证指标整体不如Step Decay | T_0太短或总epoch不足 | 拉长T_0到总epoch的1/3,或直接放弃SGDR |
| 多周期最优checkpoint不递增 | 模型容量不足或数据集太小 | 考虑用固定周期平均作为最终模型 |
| 相同参数多次运行结果不稳 | 没有固定随机种子 | 至少固定torch、numpy、random的种子,甚至DataLoader的worker_seed |
| 目标检测bbox回归指标抖动 | 检测头和分类头学习率不均衡 | 对检测head单独配更小的lr,SGDR作用于全模型 |
6.6 小模型和超大数据集下,SGDR的适用性再强调一次
我做过一个轻量分类模型的训练任务,模型只有两百万参数,数据有一千多万张图。SGDR跑动时发现每个周期的收益在迅速递减。原因很简单:数据量极大时,每个epoch包含的更新步数出奇的多,模型在一个epoch内就已经学到了大量信息,5个epoch的周期已经足够过拟合很多特征了,反而在周期切换时的震荡给训练带来额外负担。这种情况建议把周期设为按“step”而不是按“epoch”计算,即在dataloader的每个__len__上进行调度,让每个周期覆盖固定次数的参数更新。PyTorch的CosineAnnealingWarmRestarts支持在step调用时传入epoch参数,实际上我们通常在每个batch之后传global_step,这样周期和样本量就脱钩了。
7. 实战案例复盘:一个典型图像分类任务从54.1%到91.2%的完整过程
下面用一个完全脱敏的模拟项目来串一下整个决策和改动过程,数据集名称和质量指标我都略作修改,但结构和我实际处理过的任务是相同的。
这个任务是1000类图像分类,数据大概140万张图,初始baseline用的SGD+Step Decay,120个epoch,在验证集上拿到54.1%的top-1。之后做了三个改动:
- 换用了更强的数据增强策略(在合理范围内增加翻转、裁剪、颜色扰动);
- 换用SGDR,T_0=20,T_mult=2,base_lr=0.1,eta_min=0.001,训练epoch数维持120;
- 额外保存每个周期的checkpoint,最后取最后三个周期模型做平均。
结果却很有意思,到了第40个epoch左右,也就是第二个周期的尾段,验证集指标就到了63.2%;到第60个epoch左右,也就是第三个周期的尾段,已经到78.5%;最终第四个周期结束时的单模型是87.9%,而把最后三个周期的模型做平均之后又跳到了91.2%。
这次复盘中我的体会是,SGDR的增益主要还是体现在两点:多周期平均的集成收益,以及周期末尾相对稳定的泛化。因为数据量大、模型容量也足够,前面的Step Decay模型跑到120个epoch也就54.1%,说明之前的学习率衰减策略没有把模型充分探索到好的区域。换成SGDR后,每个周期的“重启”让模型有了反复离开旧区域的机会,后面能持续走高,说明之前是卡在了一个不理想的区域里。
8. 提升SGDR效果的实用进阶操作
8.1 修改eta_min对不同任务的影响
eta_min这个参数默认1e-6,但我后来发现它不应该是默认值走天下的。在分类任务时,它小一点没问题,因为分类loss相对光滑,周期尾部学习率足够低可以把loss压得很小。但是在目标检测里,回归分支的loss更敏感,eta_min太小会让周期尾部对边框回归几乎没有更新,导致一个周期下来模型整体能力偏保守。我试过把eta_min从0.0001调整到0.001后,检测任务里AP反而提升了一些。
类比一下:eta_min相当于“周期末的精细打磨尺度”,太细了浪费时间,太粗了打磨效果不到位,具体粗细应该以能稳定达到该任务最优性能为准。
8.2 多周期模型平均之外:用SGDR代替模型搜索初始点
如果你有多个初始化策略,一个值得尝试的玩法是:先用极短的SGDR周期(比如3个epoch)训练几十个epoch,然后在每个周期末尾记录loss最低时模型的状态,选择那个表现最好模型权重作为正式训练的初始值。这个做法在有些迁移学习任务上还挺有效,相当于用当前数据先做一次快速预训练,寻找一个对任务敏感的初始化。
不过要注意,这里不是让你随意改变SGDR参数配置,而是在多个配置之间做取舍时的一个小技巧。
8.3 探索部分层不参与调度
还有一种非常规玩法:不是所有参数都统一调度学习率,而是对backbone和head分别设置不同学习率,再分别套用SGDR。原则是backbone相对稳定,学习率比head低一些,重启动时幅度可以小一点;head比较敏感,容易过拟合,调度剧烈些。无论是否追求极致效果,在迁移学习场景里,这一招通常都能稳定带来两三个点的提升。
optimizer = SGD([ {'params': backbone.parameters(), 'lr': 0.01}, {'params': head.parameters(), 'lr': 0.1} ], momentum=0.9, weight_decay=5e-4)SGDR调度器会把这个分段lr结构保留下来,它会对每一组的initial_lr做同样的余弦缩放。如果某一组不希望被调度影响,可以把该组的initial_lr设成与eta_min相同,这样整个周期内它的lr基本不动。
8.4 在后训练阶段手动叠加一个低学习率微调
SGDR结束后,如果你想再做一步最后的精修,可以在最后一个周期结束后,手动把学习率设成一个很小的固定值,比如eta_min的1/10,再训练几个epoch。这个思路类似于把周期末尾的模型再强制推一把。但我的经验是,这一步带来的增益极不稳定,有时候反而会把多周期平均的优势吃掉,所以我更推荐直接用最后一个周期末尾的模型做评估,不要再画蛇添足。
9. 什么时候别用SGDR:反向适用性判断
聊了这么多SGDR的好话,但真实工程中它不是万能的。我会明确建议:
- 极小规模数据(比如几千张图)下,不要强用。周期太长训练不足,周期太短没有效果;
- 如果你现成的Step Decay已经收敛得很好,而且验证集指标已经稳定实用,不需要为了“新潮”盲目换调度器;
- 在带超参数搜索的任务里,如果每个trial训练时长本来就有限,SGDR增加了需要调和的参数维度(T_0, T_multi),试错成本更高;
- 对训练时间敏感的场景(如每天定时产出的线上模型),SGDR比Step Decay多了周期波动,监控告警容易误报,建议先做好日志平滑再上。
尤其是在目标检测这类多分支联合优化任务里,用SGDR前最好已经用固定学习率或Step Decay做了充分baseline。否则你同时改了调度器和其他组件,出了问题很难定位是调度器引起还是模型结构引起。
10. 结合Bag of Tricks:SGDR和其他优化技巧的配合顺序
最后聊一个实践经验。新训练一个模型时,我现在的默认配方是:
- SGD + Momentum,momentum设为0.9,权重衰减按任务调整;
- 前5个epoch做linear warmup,学习率从1%的base_lr爬升到base_lr;
- 切到SGDR,T_0约等于总epoch的1/4,T_mult=2;
- 配合EMA(指数移动平均)保存参数,在验证时用EMA权重;
- 最后用多周期末尾的模型做平均。
这套配方我跑过几个任务,收益大多数来自EMA和多周期平均,SGDR本身把探索过程搞得稳一些,另外让最终的模型分布偏平坦。但你要清楚,没有银弹,不同任务还是要多跑两组对照才能确认哪个技巧贡献最大。
希望这篇复盘对你有用。如果你在复现SGDR时遇到别的怪问题,欢迎按照我列出来的排查方向先自查一遍,多半是学习率边界条件、周期长度设置、数据顺序那些细节在捣鬼。