深度学习优化器算法中,有哪些巧思可以速览为?

摘要:这一篇博文想写很久了,一直没有下笔,核心原因也是有一些待办的思路在攻关验证。 我们先从一个核心的问题出发, 1. 为什么要研究优化器算法? 它的关联问题:训练为什么要调参,调的是什么参? 如果就这个问题去问各种大语言模型,它们能给出一堆的理
这一篇博文想写很久了,一直没有下笔,核心原因也是有一些待办的思路在攻关验证。 我们先从一个核心的问题出发, 1. 为什么要研究优化器算法? 它的关联问题:训练为什么要调参,调的是什么参? 如果就这个问题去问各种大语言模型,它们能给出一堆的理由。 但就博主而言,答案只有一个: 干掉调参,解放生产力,榨干算力。 说到底就一个字"穷"。 在多年的研发生涯里,对调参这个事深恶痛绝,为什么辛辛苦苦架构出来的模型,一训练就崩,训练收敛慢到龟速,这严重影响了开发进度,并且增加了很多不可抗力的消耗。 我相信有很多业内同行,都有这种痛,训练了很久,效果依旧很差,泛化能力也不行,然后就开始苦恼,为什么自己没有足够的钱,足够的算力。 明明自己很好的思路,戛然而止,退而求其次。 早年间,博主经常半夜醒来,看训练的损失曲线,生怕训崩。就算没有训崩,自己花费了大量时间精力,却没有很好的回报。 一次又一次,是很打击信心的。 在付出了大量时间和人民币之后,博主终于从泥潭里爬出来了,时光荏苒,这个困扰我九年的问题,画上句号了。 那大语言模型是怎么回答这个问题的。 核心就一句话: "没有新优化器,下一代模型根本训不起来。" 从理论上看,它是在解决一个尚未被完全理解的复杂高维优化问题,充满挑战与机遇。 解决基础性训练难题——让模型"能学" 从工程上看,它是降低AI研发成本、推动技术普及的关键杠杆。 追求极致的效率与效益——让模型"快学"且"省学" 从性能上看,它是提升模型最终准确性、鲁棒性和泛化能力的决定性因素。 提升模型的终极性能——让模型"学好" 最终达到,拓展AI的技术边界——让"不可能"成为"可能" 当然就这个问题,大家可以自行去追问各家的大语言模型,给出的结论大同小异。 2. 那博主为什么要写这篇博文? 最基本的还是希望抛砖引玉,希望能有更多的同行在力大砖飞,烧钱的当下,不要放弃底层算法的研究。 同时为更多的深度学习小白提供一个新的视角,学习并应用深度学习,温故而知新。 3. 那什么是优化器算法? 优化器算法是驱动机器学习模型学习的"引擎"。它的核心任务是:在训练过程中,根据损失函数计算出的梯度(即方向),以某种策略更新模型的参数,从而最小化损失函数。 可以将训练过程想象成在复杂地形中寻找最低点: 损失函数:代表地形的高度。 模型参数:代表我们在地形中的位置。 梯度:代表我们脚下最陡峭的下坡方向。 优化器:就是那个决定"往哪个方向走、走多大步、以及是否要考虑之前的惯性"的导航策略。 Adam (Adaptive Moment Estimation) 思想:目前最流行和默认的优化器之一。它结合了Momentum和RMSProp的优点。 它计算梯度的一阶矩(均值,提供动量)和二阶矩(未中心化的方差,用于自适应调整学习率)。 然后对这两个矩进行偏差校正,使其在训练初期不那么偏向于0。 优点: 通常收敛速度快。 对超参数的选择相对鲁棒(默认参数通常就能工作得很好)。 能处理噪声和稀疏梯度。 如果把Adam的一阶矩和二阶矩去掉,它就蜕变为SGD。 而随机梯度下降(朴素SGD)是一种优化算法,通过随机选取单个样本来近似梯度,从而迭代更新模型参数,收敛至最小值。 换句话说,朴素SGD是一个没有应用任何先验补充的野蛮人,较于Adam的平滑学习而言,它就像一只无头苍蝇,到处乱撞,也不知道该撞多少次才能收敛至最小值。 4. 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 (激进模式) :“损失越大,学习率越大;损失越小,学习率越小。” 注意,AdaLo算法的前提假设是不够严谨的, 损失小并不等于泛化好,也有概率会导致过拟合。 所以还必须用其他惩罚或者正则的思路辅助。 5.1.5 穷到极致,什么都能接受 如果显存极度匮乏,手头还挺紧,能训练比什么都重要的话。 [2412.08894] SMMF: Square-Matricized Momentum Factorization for Memory-Efficient Optimization 采用 非负矩阵分解(NNMF),将梯度权重转换为最接近正方形的矩阵,分解为行列两个向量。 虽然是有损的压缩解压操作,但在一些特定的场景能减少可观的内存占用,在内存效率和优化性能之间取得相对平衡。 核心算法如下: @torch.no_grad() def _unnmf(self, row_col: tuple) -> torch.Tensor: return torch.outer(row_col[0], row_col[1]) @torch.no_grad() def _nnmf(self, matrix: torch.Tensor, out) -> tuple: shape = matrix.shape torch.sum(matrix, dim=1, out=out[0]) torch.sum(matrix, dim=0, out=out[1]) if shape[0] < shape[1]: scale = out[0].sum() if scale != 0: torch.div(out[0], scale, out=out[0]) else: scale = out[1].sum() if scale != 0: torch.div(out[1], scale, out=out[1]) return out 而如今大多数transformers架构的显存占用更多是激活计算导致的显存占用,这个时候在优化器内部减少存储是没有太大意义的。 另外NNMF压缩解压的做法引入了的计算量,还不如采用checkpoint来得实在,当然只有小朋友才做选择,我们全都要。 一个简洁的示例实现: import torch from torch import nn from torch.utils.checkpoint import checkpoint class RoTanh(nn.Module): # f(x) = \tanh(0.5x*\beta)*(0.5x+\alpha)+0.5x def __init__(self, num_channels, gradient_checkpointing: bool = False): super().__init__() self.alpha = nn.Parameter(torch.zeros((1, num_channels))) self.beta = nn.Parameter(torch.ones((1, num_channels))) self._gradient_checkpointing = gradient_checkpointing def _forward(self, x): half_x = 0.5 * x return torch.tanh(half_x * self.beta) * (half_x + self.alpha) + half_x def forward(self, *args, **kwargs): if self.training and self._gradient_checkpointing: return checkpoint(self._forward, *args, use_reentrant=False, **kwargs) else: return self._forward(*args, **kwargs) 新增一个开关gradient_checkpointing开关选项, 把原来的forward 改成_forward 然后贴上新的forward即可。 RoTanh是笔者改写的一个激活函数,具体曲线,可以到desmos贴上公式: f(x) = \tanh(0.5x*\beta)*(0.5x+\alpha)+0.5x 进行可视化预览。 5.1.6 bfloat16的最佳伴侣:浮点权重分割与压扩量化 [2602.23349] FlashOptim: Optimizers for Memory Efficient Training FlashOptim将误差量化为8位或16位整数,而非BF16浮点数。对于BF16主权重配合INT8校正(共24位)。 实现99.92%的位级完美重建,相对误差低于1e9,远优于现有浮点误差存储方案。 它采用融合Triton内核的实现进一步提速,显存在极致的情况下能砍掉50%,精度几乎无损。 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 不管成不成功,效果几何,就这魄力,值得我在此一提。 而博主经过实测验证,betas如果实现动态适配,是有助于加速训练,以及提升泛化能力的, 原因很简单,训练到后期,betas适当调小, 由于历史平滑的信息减少了,长尾数据的权重自然也就变大了,长尾也就不会被湮灭掉。 而且也不会增加多少计算量,实乃妙药良方。 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,对参数和梯度进行可视化,查看其直方图,非常直观。 示例如下: 参数直方图: 从参数权重的分布来看,蓝色左边一直在拖尾,红色的左边尾巴开始右移聚拢。从参数来看,可以看到一些趋势,但不够直观。 我们再来看其对应的梯度直方图: 这就一目了然,左边蓝色明显存在梯度长尾,而右边红色的梯度长尾逐渐开始消失,且红色更趋向于正态分布。 我们再看另一组图: 这是vae潜空间0-9十个数字的聚类图。 相关vae代码示例见:https://github.com/cpuimage/AdaLo 图二整体聚合接近一个圆圈,而图一接近椭圆。 这两种情况,是图二还是图一的模型权重泛化能力更胜一筹呢。 答案是图二,它的kl散度损失更低。 真实情境下长尾也可以是噪声或标签错误,所以拟合长尾也不是完全是一件好事情。 一切以实测效果为准,长尾梯度只是一个仅供参考项。 博主一直认为如果可以优雅解决长尾问题,那是新一轮的曙光。 5.4 Adam的过拟合问题 由于Adam本身的机制问题, 训练损失下降极快 → 模型迅速进入插值(interpolation)区域 → 参数范数容易膨胀 → 边界更复杂 → 泛化差。 当然长尾问题也是它导致过拟合的原因之一。 比较知名且使用广泛的方案是l2正则化,即权重衰减。 Adam 进化为 AdamW,也就是现在主流的优化器算法 它思路也是非常简单粗暴,在每次更新时,从权重中减去一个固定的比例(weight * weight_decay),是正则也是先验惩罚。 [1711.05101v3] Decoupled Weight Decay Regularization 权重衰减是一个很好的思路,但它带来了一个新的问题。衰减量设为多少才是合适的,也就是说,惩罚力度该如何界定。 衰减过大,学习收敛缓慢,衰减过小,没有起到作用。 随后Scheduled (Stable) Weight Decay也被提出,但是应用不广,鲜为人知。 [2011.11152] On the Overlooked Pitfalls of Weight Decay and How to Mitigate Them: A Gradient-Norm Perspective 它的思路也很简单,通过汇总整个模型的参数信息,按照参数权重占比估算出每一层的衰减权重。 而有另一篇论文从另一个新颖的角度提出了一个方案。 [2103.06583] Preprint: Norm Loss: An efficient yet effective regularization method for deep neural networks 它的思路是在每次更新时,从权重中减去一个单元范数权重,可以近似看做是为权重衰减提供了范数先验。 而后,将正则化从“加性惩罚”转变为“约束优化”Constrained Parameter Regularization (CPR) [2311.09058] Improving Deep Learning Optimization through Constrained Parameter Regularization CPR 作为替代权重衰减的替代方案,就是为了权重衰减的调参困局,但请慎用。 为了改善权重衰减带来的收敛变慢问题,Cautious Weight Decay 随即也被提出。 [2510.12402v1] Cautious Weight Decay 思路比较简单,伪代码一眼就能看懂。 grad = m / (torch.sqrt(v) + epsilon) m = (p * grad).sign_().clamp_min_(0) cautious_weight_decay = weight_decay * m 它的灵感多半来自此: [2411.16085v3] Cautious Optimizers: Improving Training with One Line of Code 至于m要不要改写成 m / (m.mean()+eps),用在不同优化器内部性质不太一样, 如果是Adam理论上可以不做这个操作,但如果用在SGD里,为了稳定性,可以考虑m / (m.mean()+eps) 。 实在不确定,就实测。 思路出发点都是考虑方向的一致性。 一个作用在参数,参数与更新梯度的方向,一个作用在梯度,当前梯度与平滑梯度的方向。 真的是万变不离其宗,但凡能作用在梯度的,理论上也能作用在参数。 最近又有一篇论文说战胜了Adam。 [2602.15322v1] On Surprising Effectiveness of Masking Updates in Adaptive Optimizers 给各位读者提个醒,大多数论文只会把优势夸大,然后只字不提弊端。 论文提到的那个随机mask的本质,其实很早就有人这么做了。 [1912.00144v2] Learning Rate Dropout 思路就是直接将dropout的思路移到优化器内部。 至于为什么一般是dropout 一半,因为超过一半稳定性就撑不住了。 dropout要用可以,但是一定是逐步降低dropout的比例, 很多方案是需要场景约束的, 在llm场景下,这种思路可以换来一定的“智能”,是因为训练的参数都很巨大。 但如果你的场景参数量很小,这种做法就要考量一下了。 只是当一篇论文采用随机的思路来提升泛化能力, 只能证明他们没有找到更好的方案。 5.5 学习率热身与梯度裁剪 在说到Adam过拟合的时候,我们很容易就发现了一个问题。 在不同的模型架构,训练的每个阶段,每层权重的值域是不一样的,而且这个值域随着训练的增加,也一直在变化。 由于这个核心问题的存在,训练早期梯度的波动就会很大,这个时候通常就需要学习率调参,或者在模型内部加入归一化层,目的尽可能快地把每一层的值域确立下来。 由此就引发出来学习率热身以及梯度裁剪相关的思考。 学习率热身相关的资料和论文也有很多,这里不展开细讲。 学习率规划热身的基本逻辑都是: 早期用极其小的学习率进行预热训练 → 中期慢慢地增大学习率 → 后期再固定学习率或者慢慢减少学习率 虽然很傻,但是确实有效。 21年的时候谷歌为了把归一化层删掉,就提出了自适应梯度裁剪方案。 [2102.06171] High-Performance Large-Scale Image Recognition Without Normalization 思路也很简单,根据每层梯度和权重的值域,按比例缩放当前的梯度。 25年终于有人想要把学习率预热删掉。 [2505.21910] Taming Transformer Without Using Learning Rate Warmup 思路跟Scheduled (Stable) Weight Decay很像,只不过这次是作用在学习率上罢了。 本质就是根据每层权重梯度比例算出来一个全局学习率的缩小率。由于每层的激活函数不一样,算出来一个全局缩小率,从逻辑上其实很牵强。 当然除此之外还有其他类似的思路,例如: 梯度范数化 [1711.02257v4] GradNorm: Gradient Normalization for Adaptive Loss Balancing in Deep Multitask Networks def gradient_normalization(grad, eps: float = 1e-8): grad.div_(grad.norm(p=2) + eps) 层范数化缩放 [1904.00962v5] Large Batch Optimization for Deep Learning: Training BERT in 76 minutes def layer_norm_adaptation(grad, var): w_norm = var.norm(p=2) g_norm = grad.norm(p=2) grad.mul_(torch.where(torch.greater(w_norm, 0), torch.where(torch.greater(g_norm, 0), (w_norm / g_norm), 1.0), 1.0)) 梯度中心化 [2004.01461v2] Gradient Centralization: A New Optimization Technique for Deep Neural Networks def centralize_gradient(grad): if grad.dim() > 1: grad.data.add_(-grad.mean(dim=tuple(range(1, grad.dim())), keepdim=True)) 林林总总,大同小异。 博主根据自己的理解,也写了个梯度软裁剪,代码如下。 @staticmethod def _soft_clip(grad, var, epsilon=1e-12): dim = None if (r := var.dim()) <= 1 else tuple(range(1, r)) var_norm = var.square().mean(dim=dim, keepdim=True).sqrt_().clamp_min_(epsilon) grad_norm = grad.square().mean(dim=dim, keepdim=True).sqrt_().clamp_min_(epsilon) clipped_norm = grad_norm.clamp_max(var_norm) return grad.mul_(clipped_norm / grad_norm) 5.6 如何进一步加速训练收敛 前面已经提到不少关于调参,稳定性问题,但大多数人最关心的还是怎么加速训练。 主要的思路,基本上就是根据上一步的梯度信息,结合当前步的梯度,在两步之间求出一个合理的方向,往这个方向再走一步。 这样做有个好处,就是可以结合上一步的位置进一步修正方向,其实就是残差加权的路子。 [1909.11015v4] diffGrad: An Optimization Method for Convolutional Neural Networks [2106.11514v3] Rethinking Adam: A Twofold Exponential Moving Average Approach 有前后梯度交替的做法,自然也就有参数交替的做法。 [1907.08610v2] Lookahead Optimizer: k steps forward, 1 step back [2103.17182v5] Positive-Negative Momentum: Manipulating Stochastic Gradient Noise to Improve Generalization 但这两种做法都有一个弊端,就是需要多存一份参数,显存又要不够用了。 当然如果不考虑显存占用问题, 也可以采用Grünwald-Letnikov(G-L)分数阶导数,它利用分数阶微积分的全局记忆, 将参数更新的梯度替换为G-L分数阶近似梯度,从而更好地利用过去的长期曲率信息。 An Adaptive Learning Rate Deep Learning Optimizer Using Long and Short-Term Gradients Based on G–L Fractional-Order Derivative | Neural Processing Letters 在某些场景下,算力充足,也是一种选择。 博主验证过GL这个方案,实测并不高效,只能说思路可圈可点,实际差强人意。 如果考虑显存有限的话, 有一个折中的做法,Nesterov momentum,Adam升级为NAdam,它的思路也很简单"先沿惯性走一步,再看新梯度,沿修正后的方向走",也就是从Adam的"看一步走一步"变成了"看一步想两步"。 Incorporating Nesterov Momentum into Adam | OpenReview m_nesterov = m * beta1 + grad * (1.0 - beta1) grad = m_nesterov / sqrt(v) 但是总感觉有点牵强,结合上面提到了各种巧思手段,随即就有人想到了梯度范数也是一种先验。 对梯度范数进行指数加权平均,根据这个信息,动态调整梯度,换言之也就是动态调整学习率。 [2210.06364v1] AdaNorm: Adaptive Gradient Norm Correction based Optimizer for CNNs 但这个思路也潜藏着一个弊端,跟v一样的问题, 训练后期,激进加速,不但没有获得应有的收益,反而会引发不稳定,最终跑偏。 只能说力的作用是相互的,给它一个推力的时候,一定要设置一个相应的阻力去制衡,达到相对稳态。 那么有没有什么方案可以既加速又能提升泛化能力? 本来是没有的,但后来有了。 那就是Sharpness-Aware Minimization (SAM)。 [2010.01412v3] Sharpness-Aware Minimization for Efficiently Improving Generalization 眼见为实:必须先探测,才知道哪最陡。 为什么这么说呢,因为SAM刚提出的时候,还不够完善, 基本做法是通过预步进,再回退的机制,也就是说至少要走两步才能同时最小化损失值和损失的锐度(Sharpness), 第一步在 p+ϵ 处计算真实梯度,第二步回退并更新 思路的目标是寻找参数空间中那些不仅损失值低,且损失函数在这些参数周围的邻域内也保持低损失的参数点。 简而言之就是,求平坦区域(一般也是平滑区域)的解。 这些区域能有效避免陷入局部最优解,稍微扰动就波动大的地方,这些过于敏感的区域,泛化能力是肯定站不住脚的。 本质上也是一种先验。 但是由于早期提出的时候,稍微有些费算力,泛化能力提升了,训练却延迟了。 直到后续Momentum Sharpness-Aware Minimization的提出才进一步做到真正的高效。 [2401.12033v3] Momentum-SAM: Sharpness Aware Minimization without Computational Overhead 众志成城:用动量平滑掉单次梯度的偏差。 做法也很简单,就是往动量方向进行扰动,走一步之后再恢复扰动。 伪代码如下: # 进行扰动 p = p - (norm_factor * m) # 扰动恢复 p = p + (norm_factor * m)  也就说 扰动 -> 学习 ->恢复 这么一来就能做到高效Sharpness-Aware Minimization。 其中norm_factor 是根据全局m算出来的全局范数权重,通过rho系数来控制扰动的幅度。 当然除此之外SAM的变体还有不少,如采用上一步梯度的: A Single-Step, Sharpness-Aware Minimization is All You Need to Achieve Efficient and Accurate Sparse Training 刻舟求剑:假设昨天的地图今天还能用。 只是Momentum Sharpness-Aware Minimization的做法是博主认为比较有说服力且比较高效的。 也可以无缝接入各种优化器内部。 似乎一切都在往更理想的方向推进着。 看到这里,我相信有很多同学会问,加大学习率,难道不能加速训练收敛吗? 我的回答是,能,只有一个前提条件,就是batch size足够的大,且优化器算法足够的稳健。 因为看的信息足够多,用大学习率,直接迈大一步,是肯定没有问题的。 这个博主已经经过验证,实测过了。 大多数情况下,我们看到训练加速,损失飞快地降,不存在过拟合的话,绝大多数都是模型正在调整权重到对应的值域范围。 理想情况下,前期应当用Adam,后期在某种优雅稳定的方案下慢慢切换为SGD,也就是让二阶的v趋近于1,而不是0。 假设你使用了Sigmoid激活函数,输入的值在 [-6,6]左右的区间,对应的输出值是(0.0025, 0.9975)。 也就是说在Sigmoid的前一层,至少是[-6,6]的值域,才有信息能往后传。 如果你在Sigmoid前面野蛮地采用了归一化,却不进行缩放加权,放大它的值。那这个神经元基本上处于失活的状态。 所以理想的情况下在进入Sigmoid前手动放大值域,也算是一种先验,至于放大3.0,放大6.0那就看Sigmoid前一层到底做了什么了。 看到这里,我相信应该没有人会问归一化层到底应该加在哪里合适了吧。 这里只是便于理解,举了个小例子。 经常会有人问Muon这个基于矩阵正交化的优化器,实测为什么没有传说中那么高效。 [2502.16982v1] Muon is Scalable for LLM Training 你都已经看到这里了,Muon是个什么玩意,你别跟我说,你心里没数。 以上,初稿写于2025.10.06。 商业转载请联系作者进行授权,非商业转载请注明出处。 若有各种其他问题可以通过以下方式联系博主交流学习。 微信: Dbgmonks QQ: 200759103 邮箱: gaozhihan@vip.qq.com 注: 不注明来意者一律拒绝。 AdamW 改进自适应学习率之后,收敛肉眼可见地快。 训练dit生成蝴蝶,有图有真相。 仅仅在AdamW的基础上,实现免调学习率,取得的收益。 改进之前,训10个回合 改进之后,训练10个回合 就问这种效益收益,谁能不爱。 License This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License.