模型优化这件事,我一向觉得被讨论得太玄了。很多人一上来就换优化器,Adam不行换AdamW,AdamW不行换LAMB,折腾一圈loss曲线还是心电图,最后怪模型结构有问题。其实在过去几年里我经手了不少从训练到部署的完整项目,越来越确认一件事:所谓Model-Optimizer,真正该优化的不只是某个optimizer的选型,而是从训练策略、超参数到推理压缩的一整条链路。这篇就按我自己的实操习惯,把值得认真对待的优化点逐一拆开讲。
先说个反直觉的结论:大部分模型训练效果不好,问题压根不出在优化器本身,而出在学习率策略和训练状态诊断的缺失。而到了推理阶段又是另一套逻辑,浮点算力再猛,模型体积和延迟卡在那里,业务照样跑不起来。所以我把这套东西分成训练侧优化和推理侧优化两个大块来讲,最后再给一套可以抄作业的工作流。
1. 优化器选型不是玄学:从SGD到AdamW的取舍逻辑
先聊训练侧。Optimizer这个词本身指的就是优化器,但这里有个混淆点:模型优化的第一层,恰恰是选对训练用的优化器。这不是说哪个优化器更好,而是要看你的任务、数据规模和收敛目标。
1.1 为什么默认Adam不是万能的
很多人从入门起就习惯直接model.compile(optimizer='adam')或者trainer = Trainer(optimizer='Adam'),一路用下来觉得挺顺。Adam的优点是自适应学习率,每个参数都有独立的更新步长,对稀疏梯度和不同尺度的参数都有不错的容忍度,这让它几乎不需要太多调参就能“跑起来”。但也正因为这种自适应特性,Adam在某些任务上反而容易停在尖锐的极小值附近,泛化表现会差一些。
SGD加Momentum看起来简单,但它的更新路径更平滑,在CV分类任务、一些序列模型上,调好学习率之后往往能收敛到比Adam更平的极小值,测试集表现更稳。这不是我个人的体感,很多研究里也提到过类似结论——“Adam收敛快,SGD+Momentum最终精度更高”。
所以我的默认选型逻辑是这样的:
- 如果你要快速验证模型结构能不能work,用Adam或AdamW,省时间。
- 如果你要冲最终的精度指标,并且有时间调参,优先试SGD + Momentum(momentum 0.9,Nesterov可以打开)。
- 你的任务是NLP类或者大规模预训练微调,基本锁定AdamW系列。
- 模型结构特别深或者batch size特别大,可以看LARS/LAMB,但这些场景多数人碰不到。
1.2 AdamW的权重衰减修复:L2正则与解耦的真正区别
这里值得展开说说AdamW,因为它是我见过最容易被误解的优化器之一。常规Adam里做L2正则,是在loss上直接加权重平方和,梯度回传的时候会把权重衰减项混入梯度里。问题在于Adam会根据梯度的一阶矩和二阶矩做归一化,L2正则在Adam里会被这种归一化“扭曲”,实际效果并不等于真正的权重衰减。
AdamW的做法是把权重衰减从梯度计算里解耦出来,直接在参数更新之后单独做一步衰减:
新的权重衰减公式理解起来不复杂,核心就是“更新梯度”和“权重缩小”两件事分开了。这句话听着轻巧,实际影响却很大。直观表现是:同样的weight decay系数,AdamW的泛化效果通常比Adam+L2好,尤其在大模型和Transformer类结构上,这基本成了标配。如果你还在用Adam然后外加L2正则,建议直接切换到AdamW,把weight_decay设成0.01或0.1再试试。
1.3 不同优化器同一任务的实测对比参考
光说理论没意思,我放一个之前跑图像分类任务时的对比记录,模型是ResNet50,batch size 256,总共120个epoch,学习率策略都是cosine decay,唯一变量是优化器:
| 优化器 | 达到最佳精度的epoch | Top-1准确率 | 训练耗时(相对) | 备注 |
|---|---|---|---|---|
| SGD + Momentum(0.9) | 98 | 76.8% | 1.0x | 前期收敛慢,后期冲高 |
| Adam(lr=1e-3) | 74 | 75.9% | 0.9x | 收敛快,但后期泛化弱 |
| AdamW(weight_decay=0.05) | 82 | 76.5% | 0.9x | 和SGD差距缩小 |
| AdamW + cosine + warmup | 86 | 76.6% | 0.9x | 稳定性提升明显 |
这张表不能代表所有任务,但可以说明一个问题:优化器没有绝对的优劣,只是收敛轨迹和最终落点不同。SGD类优化器上限高但需要耐心,AdamW在效率和精度之间平衡得比较好。
提示:在训练初期,建议把SGD和AdamW都跑一个短疗程(比如10个epoch),观察loss下降速度和验证集指标走向,再决定用哪个进行完整训练。不要一来就默认某一个。
2. 学习率才是优化器真正的“油门”:从预热到余弦退火
优化器定了,接下来决定成败的是学习率。可以说,学习率策略才是整个Model-Optimizer过程里真正的核心。我见过太多人折腾优化器,却始终用固定学习率从第1个epoch训到第100个epoch,效果自然上不去。
2.1 学习率预热解决的是什么问题
训练刚开始时,模型权重是随机初始化的,梯度统计也还没建立起来。如果一开始就用比较大的学习率,很容易产生梯度震荡,甚至把loss推到NaN。预热(warmup)的意思是:开头用很小的学习率,线性或余弦地增长到目标学习率。这个阶段相当于给训练过程一个“助跑”,让模型先探索到一个相对平稳的区域,再进入高速更新阶段。
具体实现上,我常用的是linear warmup,比如前5%的steps从0线性涨到目标学习率。你不一定需要很花哨的曲线,关键是必须有这么一段缓冲。
2.2 Warmup + Cosine Annealing的完整配置示例
我自己在PyTorch里最常用的组合是OneCycleLR或者手动实现warmup+cosine的调度器。下面这段是手动实现的版本,足够直观:
import math from torch.optim import AdamW from torch.optim.lr_scheduler import LambdaLR def get_cosine_schedule_with_warmup( optimizer, num_warmup_steps, num_training_steps, min_lr_ratio=0.0 ): def lr_lambda(current_step): if current_step < num_warmup_steps: # 预热段:从 0.0 线性涨到 1.0 return float(current_step) / float(max(1, num_warmup_steps)) # 余弦退火段:从 1.0 平滑降到 min_lr_ratio progress = float(current_step - num_warmup_steps) / float( max(1, num_training_steps - num_warmup_steps) ) return max(min_lr_ratio, 0.5 * (1.0 + math.cos(math.pi * progress))) return LambdaLR(optimizer, lr_lambda) # 用法示例 optimizer = AdamW(model.parameters(), lr=3e-4, weight_decay=0.05) total_steps = len(train_loader) * epochs scheduler = get_cosine_schedule_with_warmup( optimizer, num_warmup_steps=int(0.05 * total_steps), num_training_steps=total_steps, min_lr_ratio=0.1 )这里我把min_lr_ratio设成0.1,意思是最终学习率衰减到初始值的10%,不会完全归零。完全归零在部分数据集上会导致最后几个epoch几乎不更新,反而错失精度提升。保留10%的尾部学习率,模型在后期还能继续微调参数。
2.3 如何用学习率曲线诊断训练异常
学习率策略不仅是训练工具,也是诊断工具。我在观察训练曲线时一般看这几个信号:
- loss下降异常缓慢:可能是学习率过低,或者数据流有问题。
- loss剧烈震荡不收敛:学习率过高,考虑减小初始lr或加长warmup。
- 验证集指标在某个点开始退化:模型过拟合,可以提前停止,或者增大weight_decay。
- loss突然出现NaN:除了学习率,还要检查梯度裁剪,特别是Transformer类模型。
有一次我在训练一个序列模型时,loss前十步直接冲到NaN。排查了一圈,最后发现是学习率设为5e-3,而warmup steps又只给了50步,模型根本没机会稳定下来。改成1e-3 + 5% warmup之后,问题立刻消失。这个经验告诉我:先诊断,再调参,不要盲目套模板。
注意:如果你用了AMP混合精度训练,学习率策略不用变,但务必要开启梯度缩放(GradScaler),否则小学习率段的梯度可能因为下溢直接被忽略,表现成loss卡住不动。
3. 推理侧优化:量化、剪枝与蒸馏怎么选
训练侧的优化解决的是“模型能不能学好”,但Model-Optimizer的另一半是部署侧的优化,解决的是“模型能不能跑起来、跑得够不够快”。如果只盯着训练不管推理,模型再准也只能活在实验环境里。这部分的三个核心手段是量化、剪枝和知识蒸馏。
3.1 量化的精度损失点和PTQ/QAT两条路线
量化是最常用、收益最直接的推理优化手段。原理不复杂:模型权重和激活值从FP32降到INT8,用更低比特表示数据,换取更小的模型体积和更快的计算速度。理论上参数直接缩到原来的四分之一,配合支持INT8的算子库,推理速度能提升2到3倍。
但量化不是白拿的,精度损失主要来自几个点:
- 权重分布不均匀时,均匀量化误差变大。
- 激活值存在离群点,直接钳制到INT8范围会丢失信息。
- BN层在量化时会被折叠到卷积层,统计量变化影响精度。
实操中我建议这样选路线:
- 优先尝试后训练量化(PTQ),比如PyTorch里的
torch.ao.quantization.quantize_dynamic,或者ONNX Runtime的INT8量化。什么都不用改,跑一遍校准数据看精度,如果掉点小于1%,直接用。 - 精度掉点超过1%,上量化感知训练(QAT)。套一层fake quant节点,让模型在训练过程中适应量化噪声,代价是需要重新训练若干个epoch。
- 如果业务对延迟极度敏感,进一步考虑混合精度量化——敏感层保留FP16,非敏感层用INT8。不过这个需要细致的逐层分析,建议最后再碰。
下面是一段PTQ动态量化的简单示例,适用于RNN、Transformer这类结构中权重多数在Linear层的模型:
import torch from torch.ao.quantization import quantize_dynamic model_fp32 = load_model("model.pt") model_int8 = quantize_dynamic( model_fp32, {torch.nn.Linear, torch.nn.LSTM}, # 指定量化哪些层 dtype=torch.qint8 ) torch.save(model_int8.state_dict(), "model_int8.pt") print("FP32 size:", get_model_size(model_fp32)) print("INT8 size:", get_model_size(model_int8))常用的量化层面三件套:Weight-only量化适合语言模型那种内存瓶颈场景;Dynamic量化适合推理时激活值分布变化大的场景;Static量化适合CNN这种激活值分布稳定的场景。选型之前先想清楚你跑什么模型、部署在哪。
3.2 结构化剪枝与稀疏化:什么该剪什么不该剪
剪枝本质上是在做“参数层面的删繁就简”。非结构化剪枝会把不重要的单个权重置零,但权重矩阵还是稀疏存储,没有专属硬件支持的话,加速效果很有限。真正能吃到硬件红利的是结构化剪枝——把整个卷积通道或者Transformer里的某个头剪掉,模型变小变快,且不需要特制推理库。
我的经验是:剪枝一定要分层看,不要全模型统一比例。最后一层和注意力层通常比较敏感,剪多了直接掉点。相反,冗余大的往往是深层卷积层或者FFN的中间层。可以先做个敏感性分析:逐层把权重置零,观察验证集loss变化,形成一张“敏感度地图”,再决定每层剪多少。
剪枝的流程我建议分成三步:
- 训练一个大模型到收敛,尽量训满epoch,把精度上限顶高。
- 迭代式剪枝:每次剪掉10%左右的通道或者头,然后微调几十个step恢复精度,重复多轮。一次性剪太多会让模型崩掉,后续很难恢复。
- 最后一次微调用较小的学习率,把模型重新稳到一个新局部最优。
假设你有一个卷积层,权重形状是 (64, 128, 3, 3),代表64个输出通道。结构化剪枝把这64个通道里的某些通道整个去掉,后续层的对应输入通道数也会减少,最终模型宽度变小。
3.3 知识蒸馏的软标签为什么有效
蒸馏和剪枝是两种思路。剪枝是从大往小“删”,蒸馏是用一个教师模型带出学生模型。学生模型通常结构更小、更省资源,训练目标不只是预测真实标签,还要模仿教师模型的输出概率分布。
软标签有效的原因在于信息密度。真实标签是one-hot,只说“这张图是猫”,但教师模型的softmax输出可能包含“0.7是猫,0.2是狗,0.1是狐狸”——这后面两个非零概率其实是模型学到的高层语义关联。学生模型通过模仿这些概率分布,学到的不只是类别,还有类别之间的相似性结构,泛化能力自然更强。
蒸馏的时候有两个温度参数要调。温度T控制softmax的平滑程度,T越高,概率分布越平滑,可以把更多暗知识传递给学生。我在实际项目里的初始建议是:教师输出和学生输出的蒸馏loss权重设为0.7,hard label的交叉熵loss权重设为0.3,温度T=4。跑几个实验后,根据验证集分数微调。
4. 用Model-Optimizer的思路搭一套可复用的优化流水线
前面讲的全是单项技术,但真实项目里没人会只用一种手段。一套完整的模型优化流程,应该像流水线一样,从训练策略优化开始,依次叠加量化、剪枝、蒸馏,并且每一步都有验证环节。我把它叫做Model-Optimizer工作流。
4.1 定义优化目标:精度、延迟、内存三位一体
任何优化开始前,先回答一个问题:你到底要优化什么?有些业务是模型太大,部署塞不进显存;有些业务是延迟超标,需要更快;有些业务则精度还差几个点,训练策略没调好。三者往往互相制约,不能全都要。
建议动手前记录三组数字作为baseline:
| 指标 | 当前值 | 优化目标 | 约束条件 |
|---|---|---|---|
| 验证集准确率/F1 | 92.3% | 不低于90% | 底线,不能跌破 |
| 单次推理延迟 | 45ms | 小于20ms | 硬性业务指标 |
| 模型内存占用 | 980MB | 小于300MB | 部署设备限制 |
这个表格定下来之后,优化手段的选择就清晰了。如果模型大了300MB,主要靠量化加通道剪枝;如果延迟超标,量化优先,必要时配合蒸馏换成更小的模型结构;如果精度差,先把训练侧调好,再考虑蒸馏。
4.2 可执行的优化流水线参考
拿我最近做的一个Bert-like文本分类模型举例,原始模型120M参数,QA任务F1是88.5,线上延迟35ms。目标是在F1不低于87的前提下,把延迟压到15ms以内。流水线大概是这样的:
- 训练侧:切换AdamW + warmup + cosine,weight_decay设为0.01,F1提升到89.2。这一步几乎零成本。
- 蒸馏:用原来的120M模型当教师,训练一个60M的学生模型。学生结构精简一些,FFN层维度减半,蒸馏温度T=4,F1降到88.8。
- 剪枝:对学生的注意力头做敏感性分析,剪掉冗余度最高的4个头(原12头),微调20%的steps,F1回到88.6。
- 量化:对最终模型做静态INT8量化,F1掉到88.4,仍在可接受范围。
- 验证:延迟从35ms降到12ms,内存占用从980MB压到340MB,达标上线。
每个步骤之间都有独立的验证集评测和回滚机制。一旦某一步掉点超过阈值(比如0.8个点),就立刻回退重新设计。
4.3 验证阶段容易被忽略的两个坑
第一,验证集和测试集必须分离。很多人在量化或者剪枝之后,直接拿测试集调参,调多了测试集就变成了验证集,最终上线结果不可信。正确做法是留出独立的holdout测试集,最后只碰一次。
第二,要关注延迟的分布而不是平均值。模型的推理延迟受输入长度影响,平均值达标但P99超标,线上照样会堆积请求。我在部署前都会跑一个长尾测试,统计P50、P95、P99三档延迟,确保最坏情况也可接受。
5. 实测中踩过的优化器相关坑
最后聊几个我在实操中反复踩过的坑,这些细节常规文档里很少会写。
5.1 学习率调大后的“假性收敛”
有时候你觉得模型loss在一个平台期怎么都降不下去,把学习率调大三个数量级,loss反而猛降一大截,随后又崩了。这个现象的本质是,你在原来的学习率下陷入了局部极小值,需要用大学习率“逃逸”,但不能永远待在大学习率区间。后来我固定了一套做法:在cosine退火的后期停掉更新,换一种全新的小学习率再启动两三个epoch。这相当于用“二次启动”帮忙跳出坏极小值,实测在很多任务上都能白捡零点几个点的提升。
5.2 混合精度训练里GradScaler的更新时机
AMP的GradScaler默认每过一定步数才检查一次是否需要增大scale因子,如果训练步数很短,scale还没来得及调整,loss可能在某一步突然溢出。我以前因此误判过优化器有问题。解决办法是在训到第5个epoch之后手动看一下scaler.get_scale(),确认它是动态变化的,而不是一直卡在初始值。
5.3 优化器状态造成的显存开销
很多人会忽略优化器状态占用的显存。AdamW每个参数需要额外保存一阶矩和二阶矩,这两个变量的大小和参数本身一样大。一个10亿参数的模型,AdamW跑混合精度训练时,显存大头其实是优化器的两倍参数状态,而不是模型本身。遇到显存不够时,先别急着换小模型,可以尝试:
- 打开AdamW的
foreach选项或使用bitsandbytes的8bit优化器,能砍掉一部分状态内存。 - 如果模型是微调场景,只对可训练参数计算优化器状态,冻结非关键层。
- 实在不行再考虑gradient checkpointing,用时间换空间。
注意:优化器状态、梯度、参数三者的显存占用是三个独立的量级。你在估算显存够不够时千万别只算模型权重,那只是冰山一角。
5.4 不同框架的优化器默认参数不同
PyTorch的Adam默认学习率是1e-3,而TensorFlow的Keras优化器默认学习率通常写的是0.001,看起来一样,实际行为却因为weight decay的默认值不同而有差异。迁移项目时如果只改优化器名字,不改默认参数,训练结果很可能完全对不上。我的建议是:所有超参数显式指定,包括eps、betas、weight_decay,不要依赖任何框架的默认值。
结束前的一点个人体会
做了这么多模型的训练和部署之后,我最大的体会是:Model-Optimizer不是一个具体的算法,也不是某个工具,而是一种“目标导向的取舍方法论”。训练侧,优化器和学习率策略是杠杆,花最小的力气撬动最大的精度收益;推理侧,量化、剪枝、蒸馏是手段,围绕业务目标组合使用。我现在的默认流程是:AdamW + warmup + cosine先把模型潜力榨干,再做蒸馏和剪枝缩小模型,最后用PTQ量化收尾,如果掉点严重就局部退回QAT。这套流程在文本分类、图像识别、语音模型上都跑通过,你可以直接拿去当第一版方案试验。最后再分享一个小技巧:每次改动只动一个变量,所有结果记录到表格里。优化这件事最怕东改一下西改一下,最后连你自己都不确定是哪个改动起了作用。