模型卡在 71% 验证集三周不动,结构换过、增强加过、Loss 也换过,最后发现坑不在模型结构,而在 Model-Optimizer 这一层:训练优化器从头到尾用默认参数,部署侧也没有任何压缩手段。Model-Optimizer 是我给自己攒的一套模型优化实践笔记,不绑定某个框架,只围绕一件事——怎么让模型收敛更稳、泛化更好、跑起来更便宜。这篇文章把这套笔记整理出来,适合那种"训练日志看着正常,但指标就是上不去"的朋友。
1. 优化器不是黑盒:从一次训不动的模型说起
1.1 先理解训练中的优化器在干什么
模型训练的本质是不断最小化损失函数。每一次迭代,我们算出当前权重下的梯度,再根据梯度去更新权重。而这个"根据梯度更新权重"的具体策略,就是优化器。
我用一个下山类比来解释:损失函数是地形,当前权重是站的位置,梯度是脚下最陡的方向,学习率是你迈出的步子大小。优化器则是整体的下山策略——有人每一步都朝最陡方向走,有人靠惯性往前冲,有人会根据地形自动调整步长。不同策略造就了不同的收敛速度和最终落脚点。
如果退回到最朴素的梯度下降,每一步都写成θ = θ - η * g,你会发现它有三个明显的毛病:平坦地带走得极其缓慢、峡谷里来回震荡、遇到局部极小值直接卡死。后来出现的动量、自适应学习率等方法,本质上都是针对这三个问题打的补丁。动量把历史梯度方向累积起来,像一颗滚下山的球,遇到小坑能借助惯性冲过去;RMSProp、Adam 这类自适应方法则给每个参数单独分配一套"步长感知器",让更新幅度随梯度大小自动调整。
我见过不少初学者把优化器当成"无脑工具",套个默认 Adam 就开跑。模型训不动的时候,从网络结构、数据增强、损失函数里找了一圈原因,唯独漏掉最基础的优化器匹配。其实这一层有时候反而是最值得先动的。
1.2 为什么换掉优化器,模型表现会差这么多
同样是让 loss 下降,不同优化器得到的结果差异可以非常大。有的优化器下降很快,但容易"自我感觉良好"——训练集上很漂亮,验证集却掉点;有的优化器收敛慢,但最后得到的权重泛化反而更好。
核心在于它们对梯度的"解释方式"不一样。Adam 类优化器会把训练前期的梯度方差记录到二阶矩里,给每个参数算出一个近似最优的缩放因子,所以前期跑得飞快。但也正因为这个缩放,部分参数更新会过于激进,导致模型落入训练集上很舒适、验证集上表现很差的局部解。SGD 没有这种自适应机制,每一步更新都相当"老实",反而更可能穿过尖锐的极小值,走到平坦、泛化好的区域。
还有一个关键变量是权重衰减的处理方式。Adam 里 L2 正则和权重衰减是耦合在一起被梯度吸收的,这就导致部分权重被不均衡地惩罚,拖累了泛化。AdamW 把这个耦合拆开之后,训练稳定性和泛化都有了可感知的提升。所以"换一个优化器"表面上只是一行代码,实际上改变的是整条参数更新路径。
1.3 三个基本概念先对齐
在继续往下之前,我习惯先把三个经常被混淆的概念分开:
- 优化器(Optimizer):训练时更新权重的算法,决定梯度怎么被使用。
- 学习率调度器(Scheduler):控制学习率随训练进程变化的策略,属于独立组件。
- 正则项(Regularization):对权重的额外约束,最常见是 L2 和权重衰减。
这三者经常被混在一起讨论,实际上是三个独立的东西。Model-Optimizer 这套笔记里我把它们分开管理:优化器管方向,调度器管步长,正则项管约束。这样出问题时,你才知道是方向歪了、步子不对还是约束过强。
2. SGD、Adam、AdamW怎么选:三个主流方案的机制与取舍
2.1 三个优化器的核心差异
SGD + Momentum是最朴素的方案之一,只维护一个动量项:
m = β * m + g θ = θ - η * m它用历史梯度的加权平均方向来更新,相当于在原始 SGD 上加了惯性。优点是好解释、通用、内存占用小;缺点是学习率必须调得比较准,否则收敛速度很慢。
Adam在动量之外多维护了一个梯度平方的指数移动平均v:
m = β1 * m + (1-β1) * g v = β2 * v + (1-β2) * g^2 θ = θ - η * m_hat / (sqrt(v_hat) + ε)它相当于给每个参数单独设置学习率:梯度大的维度步长缩小,梯度小的维度步长放大。所以 Adam 在稀疏梯度、非平稳目标上表现很好,收敛也快,但两个额外状态变量会占用更多显存。
AdamW和 Adam 的区别几乎只在一个细节:权重衰减。Adam 把 L2 正则加进梯度里,和梯度一起被二阶矩缩放;AdamW 则把权重衰减从梯度中拆出来,在参数更新后单独做一步θ -= η * weight_decay * θ。这个微小的位置改动,让 Transformer、扩散模型这类任务上的稳定性和泛化大幅提升。
2.2 一份实测对比表
不同任务上的表现会有浮动,但大方向是稳定的。我常用的对比维度如下:
| 方面 | SGD + Momentum | Adam | AdamW |
|---|---|---|---|
| 收敛速度 | 较慢 | 快 | 快 |
| 泛化能力 | 通常最强 | 偏弱 | 较好 |
| 内存占用 | 最低 | 额外两套状态 | 同 Adam |
| 超参敏感度 | 高 | 低 | 低 |
| 典型场景 | 经典 CNN 精调、小模型 | 快速验证、稀疏特征 | Transformer、NLP、扩散模型、大规模 CV |
表格里"泛化能力 SGD 最强"并不是绝对的。数据量很小时,SGD 的优势体现不出来;数据量很大时,Adam 的泛化差距可以通过更多 epoch 追回来。关键还是看任务体量和你的训练预算。
2.3 我现在的选型逻辑
我目前的策略很简单:新想法快速验证,一律用 AdamW,学习率 3e-4,weight decay 0.01,十次有八次不会翻车。如果是一个要上线、要冲最终成绩的模型,且算力允许,我会在 AdamW 跑通之后切到 SGD + Momentum,学习率 0.01~0.1,配 warmup 和 cosine decay,上限通常更高,只是需要更多耐心。
我还遇到过一种中间态:用 Adam 验证集 loss 一直下不去,切到 SGD 又嫌太慢。这种时候可以尝试继续用 Adam,但把 weight decay 调大,同时开 EMA,经常能把泛化救回来一部分。现在连 PyTorch 官方不少示例都默认 AdamW,说明这个方向已经被广泛验证过了。
提示:不要因为某篇论文说 Adam 泛化不行就彻底抛弃它。训练时长、数据规模、任务类型都会影响最终结论。优化器选型要跟着项目和算力走,而不是跟着"最流行"走。
3. 比调参更重要的联动关系:学习率、权重衰减与batch size
3.1 学习率不是单独存在的
几乎所有优化器对学习率都极其敏感。一个在 batch size 64 下能正常收敛的学习率,直接搬到 batch size 512 上大概率会炸。原因很直观:batch 变大之后,单次梯度估计更稳定,有效更新步长相对变大,所以学习率也要按比例调整。
经验规则是:batch size 扩大 k 倍,学习率也相应扩大 k 倍,也就是线性缩放规则。但这个规则不能无限套用,batch 特别大的时候还要配合更长的 warmup,让模型有一个缓冲期去适应更大的步长。我自己习惯的做法是:改 batch size 时,一定同步检查前几十个 step 的 loss 曲线,如果一开始就往上蹿,优先降回原学习率,而不是怀疑优化器坏了。
3.2 权重衰减不等于 L2 正则
严格来说,L2 正则是在 loss 中加1/2 * wd * ||θ||²,梯度里会多出wd * θ;权重衰减则是参数更新时直接做一步衰减θ *= (1 - lr * wd)。在 SGD 且学习率恒定时,两者等价;在 Adam 里就完全不一样了,因为梯度会被二阶矩缩放,耦合在一起的 L2 正则会让部分参数被不均衡地惩罚。
这就是 AdamW 存在的意义。我用 AdamW 时,Transformer 类模型初始 weight decay 给 0.01~0.05,CNN 给 5e-4~2e-2。没人能一眼给出正确的 weight decay,所以我通常会在小规模数据上先跑三组对比,选 loss 最稳的那组。
3.3 一套可复用的超参起点模板
以一个中等规模的视觉或文本项目为例,我的默认起点是这样的:
| 训练阶段 | 配置 | 说明 |
|---|---|---|
| 优化器 | AdamW | 默认首选 |
| 初始学习率 | 3e-4 | 若手动切 SGD 则给 1e-2 |
| Batch size | 64~128 | 结合显存和训练速度 |
| Weight decay | 0.02 | NLP 用 0.01~0.05,CV 用 5e-4 |
| Warmup | 5 epoch | 从 1e-6 线性升到目标 lr |
| 调度 | cosine decay | 训练后段逐渐降到接近 0 |
| 梯度裁剪 | 1.0 | 文本任务或不稳定训练建议开启 |
| EMA | 0.999 | 训练收尾时用平均权重替代原权重 |
核心原则是:先固定 seed,用一小批数据跑一个短训练,把明显震荡、明显欠拟合的超参排除掉,再逐个细调。我自己一般先固定 weight decay,扫学习率[1e-4, 3e-4, 1e-3],选出最好的一档之后再去扫 weight decay。每次只动一个变量,否则结果好坏根本说不清来源。
4. 模型减肥实战:部署前的量化、剪枝与蒸馏
4.1 训练优化只解决一半问题
Model-Optimizer 不只有训练阶段的优化器选型,还有部署侧的模型优化。训练优化解决"这个模型能不能学会",部署优化解决"学完之后能不能跑起来、跑多快、占多少内存"。很多模型在服务端或边缘设备上推理,瓶颈不是 FLOPs,而是内存带宽和显存占用。这也是模型压缩能带来显著加速的原因——减少数据搬运量,往往比减少计算量更有效。
4.2 量化:先 PTQ 探底,再考虑 QAT
量化的核心是把 fp32 的权重和激活变成 fp16 或 int8。fp16 几乎无损,很多新 GPU 上还有原生加速;int8 能把模型体积压到原始的四分之一,但精度风险更高。
我的落地顺序是:
- 先把模型导出成 ONNX,测量 fp32 推理延迟和精度作为 baseline。
- 做 PTQ(训练后量化),不需要重新训练,大部分模型掉点能控制在 0.3% 以内。
- 如果掉点超过 0.5%,优先保留敏感层为 fp32,比如注意力层、最后的分类头、BN 附近的激活。
- 仍然不行,再上 QAT:在训练阶段插入 fake quant 节点,让模型去主动适应量化误差。成本高,但精度最稳。
注意:量化最怕模型里有动态 shape 或自定义算子。这类节点在导出 ONNX 时往往直接报错,或者导出后无法被量化器识别。真正动手前先跑一遍完整 export,能导出才有资格谈量化。
4.3 剪枝和蒸馏怎么搭配
剪枝分为两种。非结构化剪枝是直接删掉部分权重,让矩阵里出现大量 0,这类稀疏矩阵需要专门支持稀疏计算的硬件才有收益,普通 CPU 和 GPU 上往往越剪越慢。结构化剪枝是删掉整个 channel 或 block,对现有硬件友好,但精度更容易掉。经验上 20%~30% 的剪枝比例对精度影响不大,超过 50% 就必须配合其他手段兜底。
蒸馏的逻辑更简单:用一个大模型(teacher)去指导小模型(student),训练时让小模型的输出尽量逼近大模型的输出。实测中,蒸馏得到的小模型往往比直接训练同结构小模型高出好几个点。所以我习惯的组合拳是:先蒸馏缩小结构差距,再做结构化剪枝去掉冗余通道,最后量化压缩数值精度。三步走下来,模型体积通常能缩到原来的 1/6~1/10,延迟减半,精度损失可以控制在 1% 以内。
4.4 工具链怎么选
从性价比角度,我的推荐路径是:
- 快速验证:PyTorch 自带的
torch.ao.quantization+torch.onnx.export,几个小时就能拿到一个可运行的 ONNX 模型。 - 更高性能:ONNX Runtime 跑 fp16/int8,GPU 场景再上 TensorRT。
- CPU 边缘端:OpenVINO 能自动把模型转换成 IR 格式,通常在 x86 设备上有不错的加速。
- 蒸馏:别一上来就套重框架。单模型蒸馏手写一个 logits 蒸馏 loss 只要几十行,比引入一堆依赖更可控。
很多团队一步到位选 TensorRT,结果被版本兼容、算子支持折腾到崩溃。我建议先走 ONNX Runtime,把精度和延迟 baseline 摸清楚,再根据瓶颈决定要不要上更重的引擎。
5. 一次完整调优复盘:从训练卡死到稳定部署
5.1 场景和问题描述
用一个工业质检图像分类任务当案例:2 万张图、20 个类别、ResNet-18、单卡 V100。同事搭好了全套 pipeline,训练 60 个 epoch 后验证准确率卡在 92.3%,训练集已经到 98.6%。看训练日志时发现两个明显问题:Adam 默认配置一用到底,学习率全程 0.001 恒定;weight decay 设置到 0.1,明显偏大。相当于把模型按在水里,光训练集都很难完全拟合,验证集自然上不去。
5.2 第一轮调整:AdamW + cosine 调度
把优化器改成 AdamW,学习率设 3e-4,weight decay 从 0.1 降到 0.02,先跑 15 个 epoch 验证,验证准确率从 92.3% 升到 94.6%。训练集和验证集之间还有 3% 的 gap,但这是正常水平。再加 cosine decay 和 5 个 epoch 线性 warmup,跑满 50 个 epoch,验证准确率到 95.3%。
这个问题的根源是静态学习率:训练后期模型一直以同一个步长在一个局部区域来回震荡,损失降不下去,验证集也开始过拟合。加入 warmup 和 decay 之后,前期能稳定探索,后期步长缩小,更容易跌进一个既平坦又泛化好的极小值。
5.3 第二轮:切 SGD 冲上限
第一轮的结果已经能用了,但我想看看上限在哪。把优化器换回 SGD + Momentum,学习率 0.03,weight decay 5e-4,保留同样的 warmup、cosine 和 EMA。训练时间长了将近一半,最终验证准确率到了 95.8%。差值不大,但对于一个要上线的模型,0.5% 的提升有时就是决定性的。
这里要多说一句:SGD 不是万金油,它对学习率等超参更敏感,起步配置不对会非常痛苦。我这次能顺利切换,是因为已经通过 AdamW 确定了一个合理的学习率和调度区间,再切到 SGD 时只是微调,而不是从零开始探索。
我也顺手做了个 batch size 实验:从 64 提到 128,按线性缩放把学习率从 0.03 提到 0.045,并多加了 3 个 epoch warmup。结果最终精度基本持平,但每个 epoch 的训练时间明显减少。如果你的算力充裕,大 batch 加适当缩放是完全可行的,前提是必须补偿 warmup。
5.4 第三轮:部署侧减肥
模型推理 baseline 是 fp32 的 ONNX Runtime,单张 224x224 图约 12ms,模型体积 44MB。先转 fp16,体积降到 22MB,延迟降到 8ms,精度几乎没掉。再试 PTQ int8,体积降到 11MB,延迟降到 3ms,准确率掉 0.4%,还在可接受范围。我尝试把注意力层、分类头保留为 fp32 后,掉点缩到 0.15%,延迟 3.5ms。
最终上线版本用了混合方案:主网络放 int8,敏感层保留 fp32。线上跑了两个月,准确率波动正常,说明这个取舍在真实环境中成立。整个过程里最值得坚持的是:任何一步压缩,都要拿原始 fp32 权重做对照,否则你根本分不清掉点来自量化误差还是数据漂移。
5.5 复盘清单与一个忠告
Model-Optimizer 这套笔记最后沉淀成一张固定清单:
- 先固定 seed,写一个可复现的短训练脚本。
- 基线配置用 AdamW + lr 3e-4 + weight decay 0.02。
- 不管用什么优化器,都加上 warmup + cosine decay,不要用平板学习率。
- 收敛慢时先调学习率和调度,而不是急着换网络结构。
- 每轮只改一个变量,记录好指标。
- 泛化达不到上限,再试 SGD + Momentum。
- 部署前先导出 ONNX baseline,再按量化、剪枝、蒸馏的顺序做减法。
- 永远保留一份原始 fp32 高精度权重,上线前逐项对比验证。
这几条规则是我最近两个项目反复用过之后沉淀下来的。它不一定能解决所有"模型训不动"的问题,但至少能把问题拆解成可以归因的小变量,而不是靠运气一页一页翻训练日志。
最后分享一点个人感受:优化器在很多代码里只是一行调用,但它几乎是全流程的提纲。学习率、权重衰减、batch size 这些看似独立的超参,全部围绕优化器才能定义清楚。Model-Optimizer 这个项目从最初"试一圈看谁分高"的盲试,慢慢变成现在这套固定决策框架,帮我省掉的无效实验时间,远比多跑的几个 epoch 值钱。如果你也遇到类似卡 validation 的情况,建议先别动网络结构,从优化器这一层开始排查。