懦探畏蔡1. 为什么要研究优化器算法?
它的关联问题:训练为什么要调参,调的是什么参?
如果就这个问题去问各种大语言模型,它们能给出一堆的理由。
但就博主而言,答案只有一个:
干掉调参,解放生产力,榨干算力。
说到底就一个字"穷"。
在多年的研发生涯里,对调参这个事深恶痛绝,为什么辛辛苦苦架构出来的模型,一训练就崩,训练收敛慢到龟速,这严重影响了开发进度,并且增加了很多不可抗力的消耗。
我相信有很多业内同行,都有这种痛,训练了很久,效果依旧很差,泛化能力也不行,然后就开始苦恼,为什么自己没有足够的钱,足够的算力。
明明自己很好的思路,戛然而止,退而求其次。
早年间,博主经常半夜醒来,看训练的损失曲线,生怕训崩。就算没有训崩,自己花费了大量时间精力,却没有很好的回报。
一次又一次,是很打击信心的。
在付出了大量时间和人民币之后,博主终于从泥潭里爬出来了,时光荏苒,这个困扰我九年的问题,画上句号了。
那大语言模型是怎么回答这个问题的。
核心就一句话:
"没有新优化器,下一代模型根本训不起来。"
从理论上看,它是在解决一个尚未被完全理解的复杂高维优化问题,充满挑战与机遇。
解决基础性训练难题------让模型"能学"
从工程上看,它是降低AI研发成本、推动技术普及的关键杠杆。
追求极致的效率与效益------让模型"快学"且"省学"
从性能上看,它是提升模型最终准确性、鲁棒性和泛化能力的决定性因素。
提升模型的终极性能------让模型"学好"
最终达到,拓展AI的技术边界------让"不可能"成为"可能"
当然就这个问题,大家可以自行去追问各家的大语言模型,给出的结论大同小异。
- 那博主为什么要写这篇博文?
最基本的还是希望抛砖引玉,希望能有更多的同行在力大砖飞,烧钱的当下,不要放弃底层算法的研究。
同时为更多的深度学习小白提供一个新的视角,学习并应用深度学习,温故而知新。
- 那什么是优化器算法?
优化器算法是驱动机器学习模型学习的"引擎"。它的核心任务是:在训练过程中,根据损失函数计算出的梯度(即方向),以某种策略更新模型的参数,从而最小化损失函数。
可以将训练过程想象成在复杂地形中寻找最低点:
损失函数:代表地形的高度。
模型参数:代表我们在地形中的位置。
梯度:代表我们脚下最陡峭的下坡方向。
优化器:就是那个决定"往哪个方向走、走多大步、以及是否要考虑之前的惯性"的导航策略。
Adam (Adaptive Moment Estimation)
思想:目前最流行和默认的优化器之一。它结合了Momentum和RMSProp的优点。
它计算梯度的一阶矩(均值,提供动量)和二阶矩(未中心化的方差,用于自适应调整学习率)。
然后对这两个矩进行偏差校正,使其在训练初期不那么偏向于0。
优点:
通常收敛速度快。
对超参数的选择相对鲁棒(默认参数通常就能工作得很好)。
能处理噪声和稀疏梯度。
如果把Adam的一阶矩和二阶矩去掉,它就蜕变为SGD。
而随机梯度下降(朴素SGD)是一种优化算法,通过随机选取单个样本来近似梯度,从而迭代更新模型参数,收敛至最小值。
换句话说,朴素SGD是一个没有应用任何先验补充的野蛮人,较于Adam的平滑学习而言,它就像一只无头苍蝇,到处乱撞,也不知道该撞多少次才能收敛至最小值。
- Adam相较于朴素SGD,它做了哪些改进?
引入动量缓冲m,也就是一阶矩,指数加权平滑梯度,它积累了历史梯度的方向趋势。使得朴素SGD的动荡趋于平稳平滑。
引入自适应步长v,也就是二阶矩,指数加权平均的平方,它积累了历史梯度平方的值趋势。
最终以 grad = m / sqrt(v) 作为目标梯度进行更新。
对于动量一阶矩,基本没啥好说的,就是求历史平均梯度,使得训练平稳。
核心还是自适应步长v,对于频繁更新、梯度大的参数,其二阶矩估计值大,因此实际更新步长会被调小(除以一个大数),避免"步子太大"而越过最优点。
对于不频繁更新、梯度小的参数,则给予更大的相对步长,鼓励其更新。
所以Adam能加速较于朴素SGD训练收敛,二阶矩功不可没。
原本故事到这里,就接近完结了。
在真实的场景下,我们发现Adam还是不够好。
但它的普及使得深度学习遍地开花。
虽然仍是需要调参,但是不像之前那么"玄学"了。
当然在一些场景下,例如GAN的训练,仍然有所争议。
Adam is no better than normalized SGD: Dissecting how adaptivity improves GAN performance | OpenReview
在博主的实测下,此文提及的nSGDA确实比朴素SGD稳健一些。
class nSGDA(torch.optim.Optimizer):
def init(
self,
params, # Model parameters
lr: Union[float, torch.Tensor] = 4e-5, # Learning rate (default: 4e-5)
Coefficients used for computing running averages of gradient (default: 0.9)
momentum: float = 0.9,
eps (float, optional): term added to the denominator to improve numerical stability (default: 1e-8)
eps: float = 1e-8,
weight_decay: float = 1e-2, # Weight decay (L2 penalty) (default:1e-2)
):
if lr < 0.0:
raise ValueError("Invalid learning rate: {}".format(lr))
if not 0.0 <= eps:
raise ValueError("Invalid epsilon value: {}".format(eps))
if momentum < 0.0 or momentum >= 1.0:
raise ValueError("Invalid momentum value: {}".format(momentum))
if weight_decay < 0.0:
raise ValueError("Invalid weight decay: {}".format(weight_decay))
defaults = dict(
lr=lr,
momentum=momentum,
weight_decay=weight_decay,
eps=eps)
super().init(params, defaults)
def step(self, closure=None):
r"""Performs a single optimization step.
Arguments:
closure: A closure that reevaluates the model and returns the loss.
"""
loss = None
if closure is not None:
loss = closure()
for group in self.param_groups:
momentum = group['momentum']
lr = group['lr']
weight_decay = group['weight_decay']
eps = group['eps']
one_minus_momentum = 1.0 - momentum
for p in group['params']:
if p.grad is None:
continue
if p.grad.is_sparse:
raise RuntimeError(
"current optimizer does not support sparse gradients")
state = self.state[p]
State initialization
if len(state) == 0:
state["m"] = torch.zeros_like(p.grad, memory_format=torch.preserve_format)
m = state['m']
bias_correction = 1.0 - momentum ** state["step"]
if weight_decay != 0:
p.grad = p.grad.add(p.data, alpha=weight_decay)
m.mul_(momentum).add_(p.grad, alpha=one_minus_momentum)
step_size = lr / torch.norm(m.div(bias_correction)).add_(eps).mul_(bias_correction)
p.data.add_(m, alpha=-step_size)
return loss
当你采用Adam调参训练,总是跑崩或者无法收敛,这个时候,稍微尝试一下nSGDA也未尝不可。
而Adam二阶矩的存在也实实在在埋了一个雷 : "过冲"问题
本来"对于不频繁更新、梯度小的参数,则给予更大的相对步长,鼓励其更新。"
是个很好的想法,
但是有一个特例,那就是训练到后期,梯度理论上也会越来越小,这个时候也不应该鼓励其更新。
有可能一更新,跑飞了,这就是后来为什么存在早停(Early Stopping)策略的根由之一。
如果继续训练,有可能从次优解里爬出来,但是更多实际情况是,若这里就是最优解,
由于激进地更新,反而会越跑越远。
理想的情况肯定是,训练到最优解。最后停在最优解上,或者在最优解周围转圈。
但这里有个悖论,
你凭什么认为这里是最优解,而不是次优解,这个标准怎么界定判断。
而且由于数据的稀缺性,我们希望模型在这种情况下,还能有更强大的泛化能力,即使它没见过的数据,也能适配到位。
也就是说,
理想上我们既希望能求到解的思路规律,最好覆盖更多的求解路径,而不是一条最短的求解路径。
绕路没问题,只要这个绕路方式能提升泛化能力。
1207.0580v1\] Improving neural networks by preventing co-adaptation of feature detectors 这就是后来dropout盛行的原因之一,因为简单有效。 让一部分神经元失活,也能求到解。 但是dropout这个技术思路,慎用,用得不好,反而会起反作用。 路漫漫其修远兮,一起努力吧~ 5. 后Adam家族时代,百家争鸣 由于这个话题展开,真的可以写一本书了。 所以本文的核心是"速览",博主带着大家看一看这后Adam的各种巧思。 相关的算法实现,可以参考以下项目仓库: PyTorch: https://github.com/kozistr/pytorch_optimizer TensorFlow/Keras: https://github.com/NoteDance/optimizers 本文没有提及的其他算法,自行移步查阅。 5.1 砍Adam的显存 由于一阶矩m和二阶矩v都需要历史平滑,所以Adam至少要占用两倍的可训练模型参数。 这样一来,只要模型参数一大,那训练的时候 1+2 = 3 至少要存储三份权重。显存很快就不够用了。 所以,针对这个问题,我们开始磨刀霍霍向二阶矩v。 5.1.1 18年的Adafactor \[1804.04235v1\] Adafactor: Adaptive Learning Rates with Sublinear Memory Cost 社区比较知名的实现: transformers/src/transformers/optimization.py at main · huggingface/transformers · GitHub 5.1.2 19年的SM3 \[1901.11150\] Memory-Efficient Adaptive Optimization 官方实现: https://github.com/google-research/google-research/tree/master/sm3 Adafactor和SM3都是分解近似的做法。SM3的实现较为复杂,所以基本上没有被推广开来。所以很长一段时间都是Adafactor是主流。 但是Adafactor的实现稍微有些问题。 问题函数: @staticmethod def _approx_sq_grad(exp_avg_sq_row, exp_avg_sq_col): # copy from fairseq's adafactor implementation: # https://github.com/huggingface/transformers/blob/8395f14de6068012787d83989c3627c3df6a252b/src/transformers/optimization.py#L505 r_factor = (exp_avg_sq_row / exp_avg_sq_row.mean(dim=-1, keepdim=True)).rsqrt_().unsqueeze(-1) c_factor = exp_avg_sq_col.unsqueeze(-2).rsqrt() return torch.mul(r_factor, c_factor) _approx_sq_grad 这个实现丢失了不少精度。 博主认为比较合理的实现,是把sqrt放到最后计算,精度会高些。 @staticmethod def _approx_sq_grad(row_exp_avg_sq, col_exp_avg_sq): row_factor = row_exp_avg_sq.unsqueeze(-1) row_factor = row_factor.mean(dim=-2, keepdim=True).div(row_factor) col_factor = col_exp_avg_sq.unsqueeze(-2) return row_factor.div(col_factor).sqrt_() 5.1.3 22年的Amos \[2210.11693\]Amos: An Adam-style Optimizer with Adaptive Weight Decay towards Model-Oriented Scale 在Adafactor和SM3之后很长一段时间,砍优化器显存占用这个事情似乎被遗忘了。 直到Amos的出现,它进一步砍掉了v的显存占用,直接采用了平方均值,美其名曰"信息共享"。 显存不够用,又想保住精度,可以考虑采用Amos,当然它较之Adam还有不少改进点。 5.1.4 24年损失作为学习率的奇思妙想 利用损失值(loss)本身来动态调整优化器的学习率,以此作为替代二阶v实现更快的收敛。 非常简单的思路: "损失越大,学习率越大;损失越小,学习率越小。" AdaLo: Adaptive learning rate optimizer with loss for classification 由于论文没有给出开源实现,也没有搜到第三方实现。 参考论文的思想,实现了该思路,代码实现不完全对应论文内容,仅供参考学习。 # mypy: allow-untyped-defs from typing import Tuple, Union import torch from torch import GradScaler class AdaLo(torch.optim.Optimizer): r""" AdaLo: Adaptive Learning Rate Optimizer with Loss for Classification paper: https://www.sciencedirect.com/science/article/abs/pii/S0020025524015214 code: https://github.com/cpuimage/AdaLo usage: for inputs, labels in dataloader: def closure(inp=inputs, lbl=labels): optimizer.zero_grad() loss = criterion(model(inp), lbl) loss.backward() return loss optimizer.step(closure) Args: params: Iterable of parameters to optimize or dicts defining parameter groups. lr: Learning rate (not used for step size calculation due to the adaptive learning rate mechanism; retained solely for API consistency) betas: (beta1, beta2) coefficients for gradient momentum and loss-EMA smoothing respectively weight_decay: L2 weight decay kappa: loss scaling factor eps: float. term added to the denominator to improve numerical stability. mode: control learning rate adaptation mode ('adversarial' or 'compliant') 'adversarial': decrease learning rate when loss increases (conservative strategy) 'compliant': increase learning rate when loss increases (aggressive strategy) """ def __init__(self, params, lr: Union\[float, torch.Tensor\] = 1e-8, betas: Tuple\[float, float\] = (0.9, 0.999), weight_decay: float = 1e-2, kappa: float = 3.0, eps: float = 1e-8, mode: str = 'adversarial'): if lr \< 0.0: raise ValueError("Invalid learning rate: {}".format(lr)) if betas\[0\] \< 0.0 or betas\[0\] \>= 1.0: raise ValueError("Invalid beta1 value: {}".format(betas\[0\])) if betas\[1\] \< 0.0 or betas\[1\] \>= 1.0: raise ValueError("Invalid beta2 value: {}".format(betas\[1\])) if weight_decay \< 0.0: raise ValueError("Invalid weight decay: {}".format(weight_decay)) defaults = dict(lr=lr, beta1=betas\[0\], beta2=betas\[1\], weight_decay=weight_decay, kappa=kappa, mode=mode, eps=eps) super(AdaLo, self).__init__(params, defaults) def step(self, closure=None, scaler: GradScaler = None, loss=None): already_updated_by_scaler = False if closure is not None: with torch.enable_grad(): loss = closure() if scaler is not None: scaler.scale(loss).backward() scaler.unscale_(self) scaler.step(self, loss=loss) scaler.update() already_updated_by_scaler = True if not already_updated_by_scaler: for group in self.param_groups: beta1 = group\['beta1'
beta2 = group['beta2']
weight_decay = group['weight_decay']
kappa = group['kappa']
mode = group['mode']
eps = group['eps']
for p in group['params']:
if p.grad is None:
continue
if p.grad.is_sparse:
raise RuntimeError("current optimizer does not support sparse gradients")
state = self.state[p]
if len(state) == 0:
state['m'] = torch.zeros_like(p.data)
state['loss_ema'] = torch.tensor(0.0, device=p.device, dtype=p.dtype)
m = state['m']
loss_ema = state['loss_ema']
m.lerp_(p.grad, 1.0 - beta1)
if loss is not None:
scaled_loss = torch.log1p(loss.detach())
transformed_loss = (torch.tanh(-scaled_loss * 0.5) + 1.0) * 0.5
loss_ema.lerp_(transformed_loss, 1.0 - beta2)
if mode == 'adversarial':
lr_t = loss_ema.div(kappa).clamp_min_(eps)
else:
lr_t = (1.0 - loss_ema).div(kappa).clamp_min_(eps)
if weight_decay != 0:
p.data.mul_(1.0 - lr_t * weight_decay)
p.data.sub_(m * lr_t)
return loss
在一些场景下实测也是很稳健,lr = v = loss 不得不夸一下论文原作者的奇思妙想。
PyTorch官方使用amp混合精度的时候,GradScaler.step里有这么一句。
if "closure" in kwargs:
raise RuntimeError(
"Closure use is not currently supported if GradScaler is enabled."
)
也就是说闭包和amp混合当前不支持一起用。
在AdaLo代码仓库里,博主演示怎么魔改实现闭包和amp可以同时使用,感兴趣的可以阅读具体实现。
在实测过程中,发现 "损失越大,学习率越大;损失越小,学习率越小。"
这个做法在一些场景下比较激进,所以增加了一个新的参数为mode可切换学习率适配模式,默认设为保守模式。
分别对应
-
adversarial (保守模式):"损失越大,学习率越小;损失越小,学习率越大。"
-
compliant (激进模式) :"损失越大,学习率越大;损失越小,学习率越小。"
5.2 Adam二阶矩v为0的问题
导致v为0有很多原因,在模型训练的不同阶段,由于噪声也好,精度也好,会直接或者间接导致v为0。
前面提到 grad = m / sqrt(v)
早期Adam论文里的解决方案就是直接给v加上一个epsilon,一般设为1e-8,避免除以0。
而后续经过不少团队的实践发现这么做有点鲁莽。
然后就有人开始针对这个问题进行修改。
但是林林总总,都是把epsilon移来移去,例如梯度平方后就加上epsilon,再进行指数加权平均。
也有采用softplus抑制分母过小的做法:
1908.00700\] Calibrating the Adaptive Learning Rate to Improve Convergence of ADAM grad = m / softplus(sqrt(v)) 这个问题一直到了2024年,有新的进展。 \[2407.05872v2\] Scaling Exponents Across Parameterizations and Optimizers 方法很简单,删除epsilon,采用atan2。 grad = atan2(m, sqrt(v)) 从数值稳定的角度来说,atan2确实是稳定了许多,而且基本规避了一些特殊情况下训练跑崩,导致损失为nan的情况。 Adam的betas默认参数是(0.9,0.999) ,也有人觉得这里也存在调参适配问题。 删除epsilon一般都可以理解,但把动量参数也干掉,做成自适应的"胆大妄为",也是挺绝的。 \[2510.04988v1\] Adaptive Memory Momentum via a Model-Based Framework for Deep Learning Optimization 不管成不成功,效果几何,就这魄力,值得我在此一提。 5.3 Adam的梯度长尾问题 这个很好理解,由于一阶矩m和二阶矩v都采用了指数平均,在不同程度上也是导致梯度长尾的诱因之一。 因为求平均值这个事,就跟奥运比赛打分一样,只用均值很不公平。去掉一个最高分,去掉一个最低分,然后再算平均相对合理一些。 求损失均值的时候一样存在,博主曾经设想过,也许求损失的中位数是一个可行的做法,但也有一定的局限性。 没有经过严格验证的求损失中位数思路的实现,仅供参考: def soft_median(losses, temperature=None): if temperature is None: temperature = max(0.1, 0.5 \* losses.std()) if losses.numel() % 2 == 0: losses = torch.cat(\[losses, losses.new_zeros(1)\]) x_sorted, _ = torch.sort(losses) n_loss = losses.shape\[0
median_idx = (n_loss - 1) * 0.5
idxs = torch.arange(n_loss, device=losses.device, dtype=losses.dtype)
weights = torch.softmax(-torch.abs(idxs - median_idx) / temperature, dim=0)
return torch.dot(weights, x_sorted)
同样的,梯度在训练过程中变化很大,一些长尾样本带来的贡献就会被淹没掉。
带来的后果,不是过拟合,就是泛化差,能拿到次优解那是属于幸运儿了。
这个方向的研究多,也不多,因为很多长尾问题基本上不会考虑在优化器里解决,一般会采用损失加权惩罚的思路来缓解。
这篇论文可以帮助进一步理解梯度长尾问题。
2201.05938v2\] GradTail: Learning Long-Tailed Data Using Gradient-based Sample Weighting 当然它不是一个主流的方案和思路,主流的方案更多的是采用元学习之类的做法,局限性也比较大。 那该如何直观地洞察梯度长尾呢? 采用TensorBoard,对参数和梯度进行可视化,查看其直方图,非常直观。 示例如下: 参数直方图: 20251007-133453 从参数权重的分布来看,蓝色左边一直在拖尾,红色的左边尾巴开始右移聚拢。从参数来看,可以看到一些趋势,但不够直观。 我们再来看其对应的梯度直方图: 20251007-133459