十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Adam优化器:原理、手写实现与PyTorch调参实战

Adam优化器:原理、手写实现与PyTorch调参实战 调参这件事真正让人头大的往往不是网络结构本身而是优化器。同一个模型、同一份数据换个优化器或者改一下默认参数收敛速度和最终指标能差出一大截。我这些年做模型训练和工程落地从早期手写SGD加动量到后来一路用到Adam、AdamW踩过的坑基本能写满一个笔记本。Adam优化器之所以几乎成了默认选项不是因为它有多高级而是它在稀疏梯度、不同尺度参数、噪声较大的小批量训练这几种常见场景下鲁棒性明显更好学习率也不用调得太精细就能跑起来。这篇文章我打算把理论、公式、代码三件事一次讲透从SGD卡在哪里讲起把Adam每一步的更新公式拆到能拿笔手算再给你一份能直接跑的NumPy实现和PyTorch工程用法最后聊聊loss不降时该怎么按顺序排查。刚入门深度学习的、做模型调优的、以及想把优化器原理补齐的工程同学都能从里面拿到能直接用的东西。1. 为什么默认优化器是Adam从SGD的三个痛点说起1.1 朴素SGD到底卡在哪里朴素SGD的更新规则简单到一行就能写完参数沿着梯度的反方向走一步步长由学习率决定。它的优点是好理解、内存占用小、泛化性能在很多视觉任务上还不错但问题也同样明显。第一个痛点是对学习率极度敏感学习率大一点就震荡甚至发散小一点收敛慢得让人抓狂而且这个合适值在不同层、不同参数上还不一样。第二个痛点是遇到鞍点和狭长山谷就磨蹭梯度在某个方向上很小、另一个方向上很大SGD会沿着陡的方向来回横跳沿着平缓方向几乎不动看起来loss不动了其实离最优点还远。第三个痛点是稀疏特征更新不均在词嵌入、推荐系统这类场景里某些特征出现频率极低它们的梯度绝大多数时候是零SGD对它们几乎没有有效更新。这三个痛点背后其实是同一个问题所有参数共用一个标量学习率而不同参数的梯度尺度天然差异巨大。一个自然的想法就是给每个参数配一个自适应的步长梯度大的方向步子小一点梯度小的方向步子大一点Adam就是沿着这条思路走到比较成熟的一个方案。1.2 一阶动量和二阶动量的直觉类比理解Adam抓住两个动量就够了。一阶动量是梯度的指数移动平均你可以把它想成惯性下山时不是每一步都完全听当前脚下的坡度而是把过去一段时间的坡度平均一下这样遇到小坑洼或者梯度噪声时不会一惊一乍方向更稳。二阶动量是梯度平方的指数移动平均它反映的是这个参数最近的梯度波动有多大用它去归一化步长波动大的参数就把步子收一收波动小的参数就放开走。打个生活化的比方你在一片起伏的山地里找最低点。一阶动量相当于你记住了自己刚才的移动方向不会因为脚下突然一个小坡就掉头二阶动量相当于你感知地面的颠簸程度路越颠你走得越谨慎。两者结合就有了Adam每个参数独立自适应步长 平滑更新方向的特性。这里要强调一个容易混淆的点二阶动量不是二阶导数也不是Hessian矩阵的近似它只是梯度平方的滑动平均计算成本几乎可以忽略。这一点和牛顿法类方法有本质区别也是Adam能在超大参数量模型上用得起来的原因。1.3 Adam在整个优化器家族中的位置把常见优化器按用了什么信息排一下脉络会清楚很多优化器一阶动量二阶动量核心特点SGD无无简单对学习率敏感Momentum有无加惯性缓解震荡AdaGrad无累积平方和稀疏场景好后期步长趋零RMSProp无指数移动平均修掉AdaGrad步长衰减过快Adam有指数移动平均两者结合 偏差校正AdamW有指数移动平均权重衰减与梯度更新解耦你会发现Adam其实是一个非常工程化的产物它把Momentum的惯性和RMSProp的自适应步长拼在一起再补上一个偏差校正来修早期估计偏小的问题。这种拼接式设计让它在绝大多数任务上开箱即用代价是理论收敛性分析比SGD麻烦不少而且在某些凸问题上泛化不如精调过的SGD。但就实际工程而言这种取舍是划算的。2. 公式拆到能手算Adam每一步在做什么2.1 五个公式逐项拆解Adam的完整更新流程一共五步我把符号统一一下$t$ 是当前步数从1开始$g_t$ 是第 $t$ 步算出来的梯度$\beta_1$、$\beta_2$ 是两个衰减系数$\alpha$ 是学习率$\epsilon$ 是一个极小的常数。第一步更新一阶动量$$m_t \beta_1 \cdot m_{t-1} (1 - \beta_1) \cdot g_t$$第二步更新二阶动量$$v_t \beta_2 \cdot v_{t-1} (1 - \beta_2) \cdot g_t^2$$第三步对一阶动量做偏差校正$$\hat{m}_t \frac{m_t}{1 - \beta_1^t}$$第四步对二阶动量做偏差校正$$\hat{v}_t \frac{v_t}{1 - \beta_2^t}$$第五步更新参数$$\theta_t \theta_{t-1} - \alpha \cdot \frac{\hat{m}_t}{\sqrt{\hat{v}_t} \epsilon}$$这五步里前两步是纯粹的滑动平均好理解真正需要动脑子的是第三、四步的校正以及第五步里 $\epsilon$ 放置的位置。先说一个实操中很容易被忽略的事实不同框架的第五步写法并不完全一致。原始论文写的是 $\alpha \cdot \hat{m}_t / (\sqrt{\hat{v}_t} \epsilon)$而有些实现写成 $\alpha \cdot \hat{m}_t / \sqrt{\hat{v}_t \epsilon}$。前者在 $v$ 很小时分母被 $\epsilon$ 兜住后者是在开方前加 $\epsilon$。两种写法在数值上差别不大但如果你想严格复现论文结果就得确认框架用的是哪一种。PyTorch的torch.optim.Adam采用的是前一种形式。2.2 偏差校正到底修正了什么偏差校正这一步几乎每个初学的人都会犯迷糊为什么要除以 $1 - \beta_1^t$原因在于初始化。我们把 $m_0$ 和 $v_0$ 都初始化为零向量而 $\beta_1$ 通常取0.9意味着每次更新只有10%的新信息进来。那么在第1步$m_1 0.1 \cdot g_1$它只有真实梯度量级的十分之一第2步 $m_2 0.9 \times 0.1 g_1 0.1 g_2$依然明显偏小。如果不校正训练刚开始的几十步里更新量会被严重低估模型几乎不动。校正的办法很直接既然我们知道 $m_t$ 相对于真实梯度期望的缩放因子是 $(1 - \beta_1^t)$那就直接除回去。你可以用 $t1$、$\beta_10.9$ 验算一下$1 - 0.9^1 0.1$$m_1 / 0.1 g_1$正好还原。当 $t$ 变大后$0.9^t$ 迅速趋近于零分母趋近于1校正自动失效不再干扰。二阶动量的校正逻辑完全一样只是 $\beta_2$ 通常取0.999衰减更慢所以前几百步的校正都很关键。注意偏差校正只在训练初期起明显作用但不能省。我见过有人为了简化代码把校正去掉结果前几百步loss曲线几乎是平的误以为是数据或模型有问题白查了半天。2.3 超参数默认值与失效场景Adam的论文默认值到现在依然是绝大多数场景的起点$\alpha 0.001$$\beta_1 0.9$$\beta_2 0.999$$\epsilon 10^{-8}$。这几个数不是随便定的背后有它们的适用范围。$\beta_1 0.9$对应大约10步的记忆窗口。调小它比如0.5会让更新更贴近当前梯度噪声大调大0.99会更平滑但对方向变化的响应变慢。$\beta_2 0.999$对应大约1000步的窗口这个值其实是相当保守的。在batch很小、梯度噪声很大的任务里0.999可能让 $v$ 反应太慢导致有效学习率被长期压制。有些训练大模型的配置会把 $\beta_2$ 降到0.95甚至0.98就是想让自适应步长跟得上梯度变化。$\epsilon 10^{-8}$主要负责数值稳定。但如果你用混合精度训练$10^{-8}$ 在fp16下会直接下溢成0这时候通常要把它提到 $10^{-6}$ 或 $10^{-7}$。Adam确实有个已知的失效场景在某些凸问题上随着 $v_t$ 单调累积有效学习率会一直衰减导致收敛后期变慢。AMSGrad通过维护 $v$ 的历史最大值来缓解这个问题思路是保证分母不减从而让有效步长不会忽大忽小。不过在深度网络里这个现象并不总是明显所以AMSGrad用得没有AdamW普遍。3. 从零手写一个AdamNumPy版本跑通3.1 接口设计与状态缓存自己写一遍是理解Adam最有效的方式。设计上我倾向于把优化器写成一个类因为它天然有状态$m$、$v$ 和步数 $t$ 都要跨调用保存。接口保持和框架一致接收一个参数列表在step()里传入梯度在zero_grad()之类的地方重置。参数用字典还是数组取决于你要模拟的对象。做教学演示时用一维数组最直观每个参数就是一个标量$m$ 和 $v$ 和它同形状。这样你在调试时可以直接打印每个参数的 $m$、$v$、有效步长观察它们怎么变比看张量形状一堆维度清楚得多。还有一个细节值得提$t$ 从1开始计数因为偏差校正公式里是 $\beta^t$如果从0开始第一步会除以 $(1 - \beta^0) 0$直接除零报错。这个坑我在早期实现里真踩过报错信息还特别不明显只是loss变成nan。3.2 完整实现代码与逐行说明下面这份实现没有依赖复制下来就能跑我加了详细注释import numpy as np class AdamOptimizer: def __init__(self, params, lr1e-3, betas(0.9, 0.999), eps1e-8): # params: 参数数组列表这里假设是 numpy 一维数组 self.params params self.lr lr self.beta1, self.beta2 betas self.eps eps self.t 0 # 步数从 0 开始step 里先自增 # 一阶、二阶动量形状与参数一致初始化为 0 self.m [np.zeros_like(p) for p in params] self.v [np.zeros_like(p) for p in params] def step(self, grads): self.t 1 for i, (p, g) in enumerate(zip(self.params, grads)): # 1. 更新一阶动量 self.m[i] self.beta1 * self.m[i] (1 - self.beta1) * g # 2. 更新二阶动量梯度平方的滑动平均 self.v[i] self.beta2 * self.v[i] (1 - self.beta2) * (g * g) # 3. 偏差校正 m_hat self.m[i] / (1 - self.beta1 ** self.t) v_hat self.v[i] / (1 - self.beta2 ** self.t) # 4. 参数更新注意 eps 加在开方之后的分母上 p - self.lr * m_hat / (np.sqrt(v_hat) self.eps) return self.params这份代码里有几个地方值得单独说。第一np.zeros_like(p)保证了动量和参数形状完全一致换成多维数组也不用改逻辑。第二p - ...是原地更新对大规模参数能省一份内存拷贝。第三self.t放在step()里自增保证每次调用都对应一个新的时间步千万别放在__init__里然后忘了更新。如果你要处理的是神经网络的多层参数通常会把每层的权重和偏置展平后塞进列表或者直接写一个字典版本用参数名做key。原理完全一样只是索引方式变了。3.3 用二次函数和Rosenbrock验证写完不验证等于没写。我一般用两个测试函数一个凸的一个非凸的。第一个是简单二次函数 $f(x) (x - 3)^2$理论最小值在 $x 3$。用Adam从 $x -10$ 出发学习率取0.1跑200步看它能不能稳稳落到3附近。这个测试主要验证偏差校正和基本更新逻辑对不对如果校正写错你会看到前几十步几乎不动。第二个是Rosenbrock函数 $f(x, y) (1 - x)^2 100(y - x^2)^2$最小值在 $(1, 1)$。这是一个经典的狭长山谷函数梯度在不同方向上尺度差了几个数量级正好用来检验自适应步长的效果。用SGD跑这个函数学习率稍微大一点就发散Adam则通常能在几千步内接近最优。def rosenbrock(x, y): return (1 - x) ** 2 100 * (y - x ** 2) ** 2 def rosenbrock_grad(x, y): dx -2 * (1 - x) - 400 * x * (y - x ** 2) dy 200 * (y - x ** 2) return np.array([dx, dy]) params [np.array([-1.5, 2.0])] # 初始点 opt AdamOptimizer(params, lr0.05) for _ in range(5000): x, y params[0] opt.step([rosenbrock_grad(x, y)]) print(params[0]) # 应接近 [1.0, 1.0]这里有个经验值可以分享Rosenbrock在Adam下学习率取0.05左右比较稳太大比如0.5会在初期直接跳飞。这个初期容易跳飞的现象本质是因为偏差校正后前几步的更新量接近 $\alpha \cdot g / |g|$也就是每步走的距离接近学习率本身参数尺度小的任务里这个绝对步长可能已经很大了。4. 工程落地PyTorch里的Adam与AdamW4.1 torch.optim.Adam参数表与常见误用实际项目里没人手写Adam直接用torch.optim.Adam。它的签名里几个参数需要记住参数默认值说明lr1e-3学习率最需要调的那个betas(0.9, 0.999)两个动量的衰减系数eps1e-8数值稳定项混合精度下需调大weight_decay0L2惩罚系数注意它和AdamW语义不同amsgradFalse是否启用AMSGrad变体误用最多的是weight_decay。很多人以为在torch.optim.Adam里设了weight_decay1e-4就等价于L2正则其实在Adam里它确实是按L2正则实现的——把 $\lambda \theta$ 加到梯度里。问题就出在这里这个额外的梯度项会被Adam的自适应分母一起缩放导致那些梯度本身很小的参数往往是大权重衰减得不够而那些梯度大的参数反而被过度衰减。这跟我们想要让大权重变小的初衷正好拧着。4.2 AdamW和L2正则的本质区别AdamW的改动只有一句话把权重衰减从梯度里拿出来直接作用在参数上不参与自适应缩放。对应的参数更新变成$$\theta_t \theta_{t-1} - \alpha \cdot \frac{\hat{m}_t}{\sqrt{\hat{v}t} \epsilon} - \alpha \cdot \lambda \cdot \theta{t-1}$$前两项还是原来的Adam更新最后一项是独立的衰减项大小只和学习率、衰减系数有关跟梯度尺度没关系。这样权重衰减就变成了一个干净的正则手段你设1e-2就是实打实的1e-2。提示现在做Transformer类模型社区基本默认用AdamWweight_decay通常设0.01到0.1之间而且一般不对LayerNorm的缩放和平移参数、以及偏置项做衰减。这个细节在微调大模型时尤其重要我见过有人一股脑给所有参数加衰减结果训练稳定性明显变差。实际写法上需要把参数分成两组no_decay [bias, LayerNorm.weight] params [ {params: [p for n, p in model.named_parameters() if not any(nd in n for nd in no_decay)], weight_decay: 0.01}, {params: [p for n, p in model.named_parameters() if any(nd in n for nd in no_decay)], weight_decay: 0.0}, ] optimizer torch.optim.AdamW(params, lr1e-4, betas(0.9, 0.98))注意这里的betas我改成了(0.9, 0.98)这是大模型训练里比较常见的做法前面解释过$\beta_2$ 调小能让自适应步长响应更快。4.3 混合精度、梯度裁剪与学习率调度的配合单用Adam往往不够工程上还要和几个组件配合顺序和写法都有讲究。混合精度用torch.cuda.amp时梯度在fp16下计算$\epsilon$ 如果还是1e-8会下溢。要么把eps提到1e-6要么把优化器更新放在fp32里做GradScaler会自动处理。我一般的做法是eps1e-6然后配合GradScalerscaler torch.cuda.amp.GradScaler() with torch.cuda.amp.autocast(): loss model(batch) scaler.scale(loss).backward() scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) scaler.step(optimizer) scaler.update()梯度裁剪必须在scaler.step()之前、unscale_()之后做否则剪的是被放大过的梯度尺度不对。max_norm一般取1.0RNN和Transformer类模型里几乎必备。学习率调度Adam和warmup是绝配。因为训练最开始偏差校正加上随机初始化梯度方向很不稳定直接上大学习率容易把参数推到坏区域。常见组合是线性warmup加余弦退火scheduler torch.optim.lr_scheduler.OneCycleLR( optimizer, max_lr1e-3, total_stepstotal_steps, pct_start0.1)pct_start0.1表示前10%的步数用来warmup。这个比例在小数据集微调时可以降到0.03到0.05。5. 开发环境与代码运行少踩工具链的坑5.1 在WSL里跑Python代码的推荐配置做深度学习开发Windows下我比较推荐WSL2加Ubuntu的组合。原因很实际很多库在Linux上装起来顺CUDA工具链的路径也清楚命令行操作和服务器环境一致写完的代码直接能搬到远端跑不用改。装好之后在WSL里python -m venv venv建虚拟环境然后source venv/bin/activate激活再pip装torch和numpy这套流程和服务器上完全一样。有一点要留意WSL和Windows的文件系统跨系统访问会慢一些。代码放在Linux侧的家目录比如~/projects/adam_demo比放在/mnt/c/...下跑得快尤其是数据集有大量小文件时差别很明显。我第一次没注意把数据放在Windows盘里读一个epoch多花了好几分钟挪过去之后速度恢复正常。5.2 好用的等宽字体与VS Code配置写代码久了字体真的影响体验。如果你习惯macOS上那种清爽的渲染Windows和WSL下有几个不错的选择JetBrains Mono、Cascadia Code、Fira Code、Maple Mono。它们的共同点是字宽统一、连字符ligature做得克制、小写字母辨识度高。我目前在VS Code里用的是JetBrains Mono字号14行高1.5长时间盯着眼睛不那么累。VS Code配合Remote-WSL插件可以在Windows的界面里直接编辑WSL中的文件终端也跑在WSL里体验基本没有割裂感。建议顺手打开format on save再装个Pylance做代码补全和类型提示。说到补全很多人会抱怨写C的时候没有代码提示原因通常是没装对应的语言插件或者没配置c_cpp_properties.json里的include路径Python这边同理虚拟环境的解释器要选对否则Pylance找不到你装的包补全自然就失灵了。5.3 把代码从文本文档里跑起来经常有人问代码写在记事本或者文本文档里怎么运行这里面的坑其实不少我一条条说。第一是扩展名。Windows记事本默认保存成.txt你双击打开看着像代码但Python不认。要在保存时把保存类型改成所有文件文件名写成adam_demo.py否则会变成adam_demo.py.txt。第二是编码。记事本早期默认可能是GBK或者带BOM的UTF-8代码里有中文注释时容易报SyntaxError。统一存成不带BOM的UTF-8最省事。第三是缩进。记事本的制表符和空格混用Python会直接抛TabError: inconsistent use of tabs and spaces in indentation这个错误信息看着莫名其妙实际就是缩进不一致。正确的做法是用VS Code或者PyCharm这类编辑器把缩进统一设成4个空格保存为.py然后在终端里python adam_demo.py运行。如果只是想快速试一小段代码用python -i进交互模式或者Jupyter更合适。运行报错时先看最后一行错误类型再看它指向的文件和行号绝大多数问题都能自己定位。6. 调参与排错实录loss不降时的排查顺序6.1 症状对照速查表训练不收敛的原因可能有很多我整理了一张对照表按从最常见到最少见排症状可能原因优先检查项loss完全不动学习率过小 / 梯度没传回打印梯度范数确认非零loss变成nan学习率过大 / eps下溢 / 除零降lr混合精度下调大epsloss震荡剧烈lr偏大 / batch太小降lr加warmup梯度裁剪前期降后期平有效学习率衰减 / 过拟合查lr调度加权重衰减训练降验证升过拟合加正则、早停、增数据不同层更新差异大尺度不均 / 衰减分组不对检查weight_decay分组先说一个最容易被忽略的动作在第一次backward()之后立刻打印梯度范数。如果全是0那问题根本不在优化器而在计算图断了比如用了.item()或者with torch.no_grad()把某段包住了。确认梯度非零之后再谈调参否则调多久都是白费。6.2 学习率扫描与warmup的实操学习率是Adam里最值得花时间调的参数默认的1e-3不一定适合你的任务。我自己常用的方法是短程扫描拿一小部分数据取1e-5到1e-1之间的若干个对数间隔值每个跑几百步把loss曲线画在一张图上选那条下降最快又没震荡的。for lr in [1e-5, 3e-5, 1e-4, 3e-4, 1e-3, 3e-3, 1e-2]: opt torch.optim.AdamW(model.parameters(), lrlr) # 跑 300 步记录 loss经验上微调预训练模型用1e-5到5e-5从头训练中小网络用1e-3到3e-3训练大模型常配合warmup把峰值学习率放在1e-4到3e-4。扫描的目的不是找精确最优而是把量级先定下来再在这个量级附近细调。warmup的步数怎么定我的习惯是总步数的5%到10%数据量小、batch小的时候可以更短。判断warmup是否合适的信号是如果训练前几十步loss突然飙高然后才回落说明warmup太短或峰值lr太高如果loss前几百步都在原地蹭说明warmup太长或者基础lr太低。6.3 几个我真踩过的坑第一个坑是zero_grad()忘了调或者调错位置。PyTorch的梯度是累加的不清理就会一步比一步大最后必然发散。我一般把optimizer.zero_grad()放在loss.backward()之前逻辑最清楚。第二个坑是加载优化器状态时没做设备映射。断点续训时optimizer.load_state_dict出来的状态默认在原来的设备上换了卡或者从GPU切到CPU会报错。加map_location就能解决。第三个坑是weight_decay分组写反了。前面提过bias和LayerNorm参数不该衰减。这个错误不会报错只会让模型效果变差特别隐蔽。建议写完之后打印一下两组的参数数量确认没漏。第四个坑是换了batch size没换学习率。线性缩放规则batch翻倍lr翻倍在SGD上比较常用Adam上虽然没那么敏感但batch变化大时还是值得重新扫一下。我有一次把batch从32提到256忘了调lr结果收敛慢了一大截。第五个坑是过早下结论。Adam在训练最开始的几百步因为偏差校正和动量还没稳定loss曲线本来就比较抖。我见过有人看到前100步的曲线不好看就断定方法有问题其实再跑一会就正常了。判断一个配置好不好至少要看完整一个epoch的走势别拿前几十步说事。最后分享一个我自己常用的调试习惯在训练循环里固定每N步记录一次m和v的均值、梯度的范数、以及当前的有效学习率也就是lr * m_hat / (sqrt(v_hat) eps)的模长。这三个量放一起看很多时候能直接看出问题在哪——比如有效学习率远小于设定lr那基本就是v被早期的某个大梯度顶起来了这时候降lr或者调小beta2往往比继续调别的参数管用。这套观察方式我是从一次次loss不动、发散的排查里攒下来的比单纯盯着loss数字有用得多。
返回列表