☰
深度学习训练实战:优化器、学习率调度与过拟合排查全攻略
2026/10/19 16:07:39 网站建设 项目流程

前两篇笔记我写的是正经网络结构和数学推导:反向传播怎么推、卷积怎么算、注意力机制的内积为什么除根号d。那会儿觉得自己什么都懂了,直到开始在真实数据集上训练模型,才发现真正折磨人的不是公式,而是那些训练过程里“玄学”一样的细节。这篇笔记换个角度,不聊网络设计,专门记录我这几个月调模型攒下来的实战经验,包括优化器选型、学习率调度、过拟合排查、训练不收敛的解决思路,还有迁移学习里容易被忽略的坑。内容主要来自我自己跑实验的训练日志和踩坑记录,适合已经掌握深度学习基础、正在动手训练自己模型的读者,也适合那些理论看了不少但一上手就loss乱飘的人。

1. 先从优化器说起:不是所有任务都适合默认配置

1.1 三个常用优化器的本质区别

很多人拿到一个模型就默认“优化器用Adam就行”,这没有错,但不够聪明。我自己在这个问题上吃过亏,所以先搞清楚三个主流优化器到底差在哪。

第一是带动量的SGD。它的核心思想很简单:在梯度下降的基础上,引入一个速度项,让更新方向不只依赖当前梯度,还参考之前几步的运动方向。这么做能抑制震荡,尤其在loss曲面比较狭长的场景里效果很明显。缺点是学习率要自己调得很小心,而且对参数的每个维度用同一个学习率,适应性差一些。

第二是Adam。它在SGD动量思路上更进一步,给每个参数单独维护一阶动量(梯度均值)和二阶动量(梯度平方的均值),相当于每个参数都有自己的自适应学习率。这个特性让它对初始学习率的敏感度低很多,很多新手上来设1e-3也能跑起来。但Adam有个问题,它在训练后期容易出现极端的学习率,泛化能力理论上不如调好的带动量SGD。

第三是AdamW,本质上是把权重衰减从梯度更新中解耦出来。在传统Adam里,权重衰减会被除以二阶动量的平方根,导致大梯度参数衰减得多、小梯度参数衰减得少,这其实是错的。AdamW直接把权重衰减单独作用在参数上,不参与动量的归一化计算,行为更可控。几乎所有Transformer类模型都用它,不是没道理的。

这里有个我在实际项目里体会很深的例子。有一回我训练一个图像分类模型,主干网络用的是预训练的卷积网络,刚开始图省事直接上了Adam,初始学习率1e-3,训练50个epoch,验证集准确率卡在86%左右上不去。后来把优化器换成AdamW,权重衰减设5e-2,同样的epoch数,准确率涨到了89%。原因就是AdamW的权重衰减作用得更干净,没有和自适应学习率搅在一起。

1.2 学习率调度才是重头戏

优化器决定了参数往哪个方向走、走多快,但怎么走完全程,要看学习率调度。我目前用得最多的是两个阶段组合:先把学习率从一个很小的值线性升到目标值(warmup阶段),再用余弦退火慢慢降下来。

为什么要warmup?因为训练初期模型参数是随机状态,梯度方向噪声很大,如果一上来就用大学习率,很容易把参数推到某个不合适的区域,后面很难收回来。尤其是在用Adam类优化器时,初期二阶动量估计不够准确,学习率偏大容易产生震荡。warmup相当于让模型先试探性地走几步,等梯度方向稳定了再加速。

余弦退火的理由比较直观:训练后期需要更细腻的步长来落入好的局部最优解,余弦曲线前半段缓慢下降、后半段快速下降,正好符合训练由粗到细的节奏。我也试过Step Decay(每30个epoch减半),效果其实也不差,但它需要手动设定衰减节点,不同数据集的最佳节点还不一样,不够省心。

warmup加余弦退火的实现非常简单,用PyTorch可以这样写:

import math from torch.optim import AdamW from torch.optim.lr_scheduler import LambdaLR def linear_warmup_cosine_decay(step, warmup_steps, total_steps): if step < warmup_steps: return float(step + 1) / float(warmup_steps) progress = float(step - warmup_steps) / max(1, float(total_steps - warmup_steps)) return 0.5 * (1.0 + math.cos(math.pi * progress)) total_steps = 10000 warmup_steps = 500 optimizer = AdamW(model.parameters(), lr=2e-4, weight_decay=0.05) scheduler = LambdaLR( optimizer, lr_lambda=lambda step: linear_warmup_cosine_decay(step, warmup_steps, total_steps) )

这段代码配合一个逐step调用的循环就行,注意这里lambda接收的是step计数而不是epoch,因为不同batch size下每个epoch的步数不一样,按step调度更均匀。

1.3 我常用的参数参考表

下面是我在几种常见任务上试出来比较稳的初始配置,可以作为起点再细调:

任务类型优化器初始学习率调度器权重衰减
CNN图像分类SGD(动量0.9)0.1(配合batch size 256)余弦退火1e-4
CNN图像分类AdamW1e-4warmup+余弦0.05
Transformer文本分类AdamW2e-5到5e-5warmup+线性衰减0.01
目标检测(微调)SGD(动量0.9)0.001Step Decay1e-4
生成对抗网络Adam2e-4不衰减或固定0

需要注意一个关联变量:初始学习率不是孤立设定的,它和batch size成正比关系。经验法则是,batch size翻倍,学习率大致也要翻倍,因为梯度估计更稳定了,可以承担更大的更新步长。我一开始不懂这个,batch size从32调到128的时候没动学习率,结果训练非常慢,白白浪费了一个下午。

2. 过拟合:训练集涨、验证集跌,最让人头疼的问题

2.1 先判断是不是真的过拟合

过拟合这个事,判断错了比不判断还麻烦。我之前有过一次经历,看到验证集准确率停下来就急着加各种正则手段,结果把模型压得太狠,训练集都不收敛了。后来认真看曲线才发现,训练集loss还在下降,验证集loss已经反弹,这才是典型的过拟合信号。

判断过拟合的几条标准:

  • 训练集loss持续降低,验证集loss先降后升,中间出现拐点。
  • 训练集准确率接近甚至超过验证集准确率,并且差距越来越大。
  • 模型权重的L2范数在训练后期明显增大,说明部分参数被“撑”得很大。

过拟合的根本原因是模型容量相对于数据量偏大,模型把训练集里的噪声也当成规律学进去了。所以对策有两个方向:一是减小模型有效容量,二是增加数据的多样性和数量。正则化手段的本质,都是在限制模型对训练数据的过度拟合程度。

2.2 正则化手段怎么组合

正则化不是堆得越多越好,关键要看任务类型。

Dropout是注意力模型里几乎必用的手段,一般加在Feed-Forward层和注意力输出的残差连接之后,dropout率取0.1或0.2就够。但在CNN主干网络里,Dropout的效果远没有在Transformer里明显,因为卷积本身带有空间共享的天然正则效果。我见过有人给ResNet50强行加了很多Dropout,准确率不升反降,就是这个道理。如果要在CNN里用,建议只加在全连接层之前,而且概率不要超过0.5。

权重衰减是最省心的正则手段,它的对偶理解就是给参数空间画了一个约束球,防止参数变得过大。权重衰减系数的选择我建议做一次快速小规模搜索,比如在1e-5、1e-4、1e-3、0.01、0.05这几个量级里对比。我踩过的一个坑是:在AdamW里权重衰减设得太小,比如1e-4,几乎不起作用,设得太大又会欠拟合,所以这个参数一定要放在和任务类型相关的上下文里调。

标签平滑是另一个容易被忽视的正则技巧。传统交叉熵在训练时要求模型对正确类别输出接近1的置信度,模型容易变得过度自信。标签平滑把正确类别的目标概率从1降到比如0.9,剩下的0.1分摊给其他类别,相当于告诉模型“不要那么绝对”。这在分类任务里基本没有副作用,我一般设0.1。

早停是性价比最高的止损失控手段。验证集loss在连续N个epoch不下降时直接停掉,保存历史最优的模型。这里有个细节:早停不是看验证集loss本身,而是看它有没有打破历史最低记录,比如连续7个epoch没有新低就停止。我用的是保存最优权重加记录“最优epoch序号”的方式,而不是简单淘汰模型文件。

这几种手段可以组合,但要注意主次:

手段适用场景常见系数/参数备注
DropoutTransformer、MLP0.1-0.2CNN主干慎用
权重衰减所有任务1e-4到0.05AdamW里不要设太小
标签平滑分类任务0.05-0.1对噪声标签也有好处
早停所有任务patience 5-10配合模型保存
梯度裁剪训练不稳定时裁剪阈值1.0不是正则,但防爆炸

2.3 数据增强的正确姿势

数据增强是隐形的正则化,它通过改变训练样本的形态,让模型学到更本质的特征而不是输入表面的统计模式。对图像任务,我常用随机水平翻转、随机裁剪、颜色抖动(亮度、对比度、饱和度)的组合。颜色抖动对光照变化明显的场景很有效,但也别把颜色变到失真,否则模型学不到真实分布的纹理。

增强强度是个需要把握的度。过度增强会导致训练集样本和真实数据分布差距过大,模型在验证集上表现反而下降。我的经验是先从标准增强开始,比如水平翻转加随机裁剪,观察曲线形态;如果过拟合明显,再逐步加入颜色抖动、旋转、尺度变换,每一步都记录验证集的变化。

对Transformer类模型,我现在用得比较多的是随机掩码。具体做法是对输入嵌入的序列在时间维度上随机mask掉一部分,这能增强模型对缺失输入的鲁棒性。注意mask的比例不要超过15%,否则训练信号太稀疏,收敛很慢。

3. 训练不收敛怎么办:一套可复制的排查流程

3.1 先看loss曲线形态

模型训练不收敛的时候,最忌讳的就是盲目改参数。我现在的做法是先看loss曲线长什么样,不同形态对应完全不同的病因。

loss曲线形态可能原因排查方向
一直不降,在初始值附近震荡学习率太低或数据没配对好检查数据、提高学习率
loss直接跳NaN梯度爆炸、除零、数值溢出检查模型数值稳定性、用梯度裁剪
快速下降后反弹并且震荡剧烈学习率过高降低学习率或加warmup
训练loss正常,验证loss乱窜overfitting或验证集太小检查数据划分、加正则
降到某个平台就是不动陷入局部极值或学习率太小换优化器、重启训练、cosine退火

我印象最深的一次排查是loss一直不降,试了很多办法都没用。后来抱着怀疑的心态打印了一批训练样本,发现输入x和标签y在数据加载时对齐错了,x还是第一版的索引,y已经换成了新版本。这种低级错误最耗时间,因为它不报错,损失只是不降而已。所以排查不收敛时,第一件事永远是检查数据。

3.2 从数据侧排查

数据问题排在模型问题前面,因为数据比模型更容易出错。我整理了自己常用的排查清单:

  • 确认x和y的对应关系,随机打印几个样本人工核对标签。
  • 检查数据是否每个epoch都进行了shuffle。如果不shuffle,模型会学到样本顺序的假规律,验证集表现会很不稳定。
  • 检查样本分布是否极端不均衡。某个类占比超过90%时,模型会倾向全部预测成这个类,看起来loss在降但实际没学到东西,这时候需要class weight或重采样。
  • 检查归一化方式。图像一般用均值0.5、标准差0.5归一化到[-1,1],或者用数据集自身的均值方差。文本任务里padding mask有没有正确传给损失函数,也容易被忽略。

这里有个容易踩的坑:在验证集上也使用了训练时的数据增强。增强会在验证时给同一个样本引入随机性,导致每次验证结果都不一致,你根本分不清模型是变好了还是变坏了。验证阶段必须关闭所有随机增强,只保留确定性预处理。

3.3 从模型侧排查

数据检查完没问题,就该看模型内部的数值情况。我最常用的手段是打印每一步的梯度范数。

训练不收敛的模型,梯度范数要么小到接近0,要么大到几千。梯度接近0,说明梯度在反向传播过程中消失了,常见原因是网络太深、激活函数选择不当,或者初始化不当导致输出饱和;梯度很大,说明反向传播时梯度越积越大,层数多的时候尤其容易发生梯度爆炸。

具体检查方法是在每个step的backward之后、optimizer.step之前,把各层参数的梯度范数打印出来:

total_norm = 0.0 for p in model.parameters(): if p.grad is not None: param_norm = p.grad.data.norm(2) total_norm += param_norm.item() ** 2 total_norm = total_norm ** 0.5 print(f"grad norm: {total_norm:.4f}")

如果发现某一层的梯度和别层差了好几个数量级,基本就是数值不稳定的根源。解决方案通常是梯度裁剪,把整体梯度范数限制在1.0或5.0以内:

torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)

初始化方式也值得检查。现代网络结构一般自带默认初始化,但如果是自己搭的非常规结构,要用Kaiming或Xavier初始化来匹配激活函数的尺度。我见过一个自己写的MLP用了全零初始化,结果训练了200步loss纹丝不动,换成Kaiming初始化后马上开始下降,这种问题从数值上查是最快的。

3.4 关于随机种子的执念

排查不收敛的时候,我还有一个习惯:固定随机种子。不固定的种子会让每次运行结果都有细微不同,排查问题的时候很难判断改动到底有没有效果。建议在训练脚本开头固定所有相关种子:

import random import numpy as np import torch def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed)

但我也要提醒一点:固定种子只是排查和复现实验的手段,不是解决不收敛的方案。如果模型在好几个不同的种子下训练结果波动巨大,那说明训练本身不稳定,光靠固定种子是在掩盖问题,真正的病因可能是学习率太高或者数据太少。

4. 迁移学习与微调:小数据集救星,但不是无脑加载权重

4.1 什么时候适合微调

手上数据量少的时候,从头训练一个大模型基本是死路。预训练模型已经在海量数据上学到了通用的底层特征,比如边缘、纹理、形状结构,这些知识对小数据集很有价值。

我的经验判断标准是:如果训练集只有几千张图或几万条文本,从头训练很难达到满意的效果,微调几乎必然优于从头训练。当数据量达到百万级时,从头训练才可能和微调打平。在数据量为中等规模(比如几万到几十万),微调依然是最省时间的方案。

4.2 冻结与解冻策略

微调最忌讳的是直接用一个大学习率去更新全部参数,尤其是预训练模型的底层特征已经足够通用,大更新会把它们破坏掉。我常用的策略分两步走:

第一步,冻结backbone部分参数,只训练新加的任务头。比如图像分类时,冻结卷积网络的所有层,只训练global pooling和最后的全连接层。这一步让任务头先适应预训练特征输出,大约训练5到10个epoch。冻结的实现很简单:

for param in model.backbone.parameters(): param.requires_grad = False optimizer = AdamW(filter(lambda p: p.requires_grad, model.parameters()), lr=1e-3)

第二步,解冻backbone,但用一个小得多的学习率,比如1e-5到3e-5,和任务头一起继续训练。这么做是为了让预训练特征在目标任务数据上做细微调整,又不至于偏离太远。有些任务甚至全程都只训任务头就够了,尤其是任务和预训练任务比较接近、数据量很小的时候。

我还试过一种变体:把参数分成两个组,backbone一个组用低学习率,任务头一个组用正常学习率,用一个optimizer就能管理:

optimizer = AdamW([ {"params": model.backbone.parameters(), "lr": 1e-5}, {"params": model.head.parameters(), "lr": 1e-3}, ], weight_decay=0.05)

这种分层学习率的方法比手动冻结解冻更方便,适合一次跑完整个流程。

4.3 经验坑:BN层在微调时的行为

微调时最容易被忽略的是BatchNorm层。BN层在训练时维护一组running mean和running variance,这是从训练数据上积累出来的统计量。如果你的batch size比较小,比如8或16,BN统计量会很不稳定,因为它只用当前batch的均值和方差来归一化,小batch本身噪声就大。

刚才说的“先冻结backbone只训分类头”,对含BN的网络要格外小心。冻结参数只是不再更新scale和shift,但BN的running stats在训练模式下还是在更新的,这可能导致预训练模型统计量被小batch小数据带偏。我踩过这个坑,微调一个带BN的卷积网络时,冻结backbone解冻head,验证集准确率反而下降,就是因为BN统计量被污染了。

解决办法有两个方向。一个是把BN层也冻结,通过设置requires_grad之外再维护一个“只跑前向不更新统计量”的模式;另一个是batch size尽量保持适中,不要太小,让BN统计量有足够的样本支撑。如果显存不允许大batch,建议干脆用LayerNorm替代BN结构的模型,或者使用GroupNorm这类不依赖batch大小的归一化方式。

还有个更典型的坑,是解冻全部层之后验证集准确率反而下降。这往往发生在目标数据和预训练数据分布差异比较大的场景。比如预训练模型是从通用图像数据上学的,你的任务是某种特定的医学影像,浅层特征虽然通用,但高层特征差异很大。此时要谨慎解冻最后几层,或者学习率设得更低,比如1e-6量级,同时加上早停,在验证集最优的时候及时停止。

5. 实验管理:别让结果无法复现

5.1 日志记录与模型保存

训练深度学习模型,实验管理做得越好,后期效率越高。我最早训练模型时只在控制台打印日志,模型只保存最后一个checkpoint,结果想分析某个epoch为什么效果最好时,根本没有数据可用。现在我的习惯是至少记录三样东西:每个epoch的loss和指标、模型的历史最优权重、完整的配置参数。

模型保存方面,我建议保存历史最好的几个checkpoint,而不是只保留最后一个。可以用类似这样一个简单逻辑:每个epoch结束后在验证集上评估,如果指标超过历史最优,就把当前权重存成best_model.pth,同时把epoch号写在文件名里。这样即使后面训练崩了,也还有最优模型兜底。

配置参数一定要保存成独立文件,和模型权重放在同一个目录下。很多复现不了实验的问题,都是因为过了一周自己都忘了当时用了什么学习率、什么batch size、什么增强方式。用PyTorch的话,可以在启动训练时把所有超参数整理成字典,存成JSON:

import json config = { "optimizer": "adamw", "lr": 2e-4, "weight_decay": 0.05, "batch_size": 64, "epochs": 30, "warmup_steps": 500, "loss": "cross_entropy", "seed": 42, "model_name": "resnet50", } with open("config.json", "w") as f: json.dump(config, f, indent=2)

5.2 对比实验的变量控制

做对比实验时,最大的敌人是变量不干净。我见过别人满怀信心地跟我说某技巧提升了两个点,但仔细一问,他同时改了学习率调度、改了数据增强、还换了优化器,这根本没法归因。正确的打开方式是一次只改一个变量,其余全部保持一致。

具体执行时,我习惯给每轮实验编一个简短的名字,比如baseline_v1、aug_v2、adamw_v3,然后把每轮实验对应的config文件和日志文件都存好。横向比较时,固定评价指标和评估数据集,比如只用验证集上的top-1准确率做比较。不要一会儿看准确率,一会儿看loss,这两个指标关心的问题不一样。

这里有个我自己的小习惯:每次实验前先在表格里记下“这次要改的变量是什么、预期效果是什么、对照组是哪个”。虽然看起来有点繁琐,但它能逼着你想清楚每个实验的目的,避免白白浪费算力在无意义的试错上。

实验编号改动变量固定不变的因素验证集指标
baseline_v1无全部默认86.2%
aug_v2增加颜色抖动默认其余全部87.1%
adamw_v3换成AdamW默认增强+调度89.0%
lr_v4学习率降到1e-4同上88.4%

这张表看起来简单,但坚持记录下来,你会发现几轮实验之后完成了一套很有价值的“经验地图”,哪些改动对当前任务有效、哪些无效,一目了然。

结束语

这篇笔记写到这里,其实还没有把我想记录的都写完,比如分布式训练、混合精度、模型量化这些话题都还没展开,以后有机会再单独写。但从我自己这半年的实战感受来说,深度学习训练中真正拉开差距的,往往不是谁看了更多论文,而是谁能在loss不降的时候冷静地一步步排查,在过拟合出现的时候找到正确的组合拳,在微调的时候尊重预训练模型的“体质”。如果你是正在自己动手跑模型的读者,我建议把优化器、学习率调度、早期停止、种子固定这几件事先踏踏实实做扎实,大部分训练问题都能在这些基础设置里找到答案。最后再分享一个小技巧:每次训练前,先在验证集上跑一个epoch看看模型能不能快速用少数样本过拟合,这一步看起来多余,但它能帮你快速确认代码链路没有低级错误,省下来的时间远比训练一次多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询