训练神经网络时最让人头疼的,往往不是网络结构怎么搭,而是那个叫作“优化器”的东西怎么选、怎么调。你费劲心思把数据处理好、模型搭起来,结果loss跟心电图一样乱跳,或者卡在一个平台期死活下不去,这时候八成就是优化策略出了问题。而绝大多数情况下,深度学习框架默认的“优化器”,以及绝大多数论文里实际在用的方案,归根到底都是那个看起来平平无奇的名字——小批量随机梯度下降,也就是Mini-batch Stochastic Gradient Descent,通常简写为Mini-batch SGD。这篇文章我就围绕这个核心主题,把它的原理、参数选择、实操细节和踩坑经历一次性说透。
这篇文章适合谁呢?刚入门深度学习、跑通了分类demo但搞不懂优化器在干嘛的初学者,以及已经训练过不少模型、想把自己手上的训练代码调得更稳更快的工程师。无论哪种基础,我尽量讲得具体一些,不只说“应该怎么做”,也把“为什么这么做”讲清楚。
1. 小批量随机梯度下降到底在干什么
1.1 三种梯度下降的直观对比
先抛开数学公式,用最生活化的方式理解梯度下降。你要下山,目标是走到谷底,但山里雾大,看不清路。你只有手里的海拔表,能告诉你脚下这一小块地方往哪个方向走是下坡。“往哪个方向走一步”这件事,就是计算梯度;“走一步”这个动作,就是参数更新。
关键在于,你每次决定方向之前,要观察多大范围的地形。批量梯度下降(Batch Gradient Descent)是站在山顶把整座山的地形全部看一遍才决定走哪步,步子稳、方向准,但看一圈地形的时间足够你喝三杯茶了。纯随机梯度下降(Stochastic Gradient Descent)则是只观察脚下这一块石头就往某个方向跳,速度快但方向可能很飘。小批量随机梯度下降正好在两者中间:每次随机抓一小群人来问路,看一眼这一小群人的平均意见就往前走一步。
这个“一小群人”在代码里就是一个batch,每个batch里样本的数量就是batch size。比如数据集有50000张图,你每次随机拿32张出来算梯度、更新一次参数。对深度学习来说,Mini-batch SGD几乎就是唯一默认选项,原因很简单:批量梯度下降在动辄几百万参数的模型上算一次全量梯度,显存和时间都撑不住;纯SGD虽然快,但单个样本的噪声太大,更新方向不稳定,训练过程很难收敛。
1.2 数学表达与关键变量
用公式表达就更清楚了。假设损失函数是每个样本损失的平均:
L(θ) = (1/m) Σ Lᵢ(θ)
其中m是样本总数,θ是模型参数。批量梯度下降的更新是:
θ ← θ − η · (1/m) Σ ∇Lᵢ(θ)
看一眼全部m个样本才更新一次。纯SGD的更新是:
θ ← θ − η · ∇Lᵢ(θ)
看一眼一个样本就更新一次。Mini-batch SGD取一个大小为B的随机子集B(这个B就是batch),更新为:
θ ← θ − η · (1/B) Σ ∇Lᵢ(θ)
式子看起来只是把m换成了B,但实际影响非常深远。这里有几个关键变量需要熟记:η是学习率,也就是下山时步长,定大了会跨过头,定小了半天走不动;B是batch size,决定了每次更新所用的样本量;epoch是完整过一遍数据集,iteration就是更新一次的代名词,一个epoch里大概有ceil(m/B)次iteration。很多初学者搞混epoch和iteration,其实很简单:你手里的50000张图被每32张分成一组,把所有组都走一遍,这就是一个epoch。
这里我特别想提一个容易忽略的点:为什么Mini-batch在深度学习里特别合适?浅层机器学习里样本量小,全量批量梯度下降也跑得起。但深度学习里样本动辄几十万,加上GPU并行计算的设计,一次性把所有样本读进显存几乎不可能。而batch的方式天然适配GPU的矩阵运算——32张图打包成一个张量送进显卡,比一张一张算效率高太多。这也是为什么batch size往往设置成32、64、128这类2的幂次,目的是内存对齐,让矩阵运算更高效。
2. 核心参数怎么定:batch size、学习率与数据顺序
2.1 batch size的选择逻辑
说到batch size具体取多少,网上答案很多,但真正靠谱的判断维度就两个:一是显存够不够,二是梯度噪声的平衡。拿最常见的图像分类任务来说,32、64、128都是见得最多的。有些在ImageNet上训练的大模型会把batch size推高到256甚至512,那是因为用了多卡并行、显存充裕,同时配合了学习率调整策略。
为什么大家都爱用小batch?除了显存压力,还有一个重要的泛化因素。训练时我们追求的不是把训练集的loss压到最低,而是让模型在没见过的测试集上表现好。一个有趣的实验现象是:小batch size训练出来的模型往往泛化性更好。对此常见的一个解释是,小batch带来的梯度噪声有助于模型跳出那些“狭窄”的局部极小值点,最后落在相对平坦的极小值区域。平坦的极小值区域对参数扰动不敏感,稍微动一下参数,loss变化也不大,这样到了测试集上鲁棒性就更好。相反,大batch的梯度噪声小,模型容易钻进某个尖锐的极小值点,训练loss很低但测试集上一换环境就露馅。
当然,batch size太小也有问题。如果设成1,那就是纯SGD,梯度噪声太大,更新方向不稳定,训练曲线跟过山车一样。我个人的经验是:纯SGD很容易出现收敛慢、最终精度不高的情况,因为每一步都在被单个样本的异常梯度左右。工程上至少取16,常用32或64,已经能兼顾稳定性和泛化性了。
2.2 学习率与batch size的联动关系
这里有一个很多人不知道的关键点:batch size和学习率不是相互独立的。直觉上,batch size越大,算出来的梯度越接近真实梯度,方向越可信,那步子就可以迈大一点;batch size越小,梯度越粗糙,步子就应该迈小一点。这就引出了学术界和工业界常用的线性缩放规则:batch size扩大k倍,学习率也大致扩大k倍。
比如你原来用batch size 32配学习率0.01,现在想把batch size调到128,那学习率可以考虑调到0.04附近。但要注意,这个规律不是无脑套用的,它有个前提:batch size变化时,你处理一个epoch的计算量大致一样。如果你还打算跑同样多的epoch,按这个规则调学习率,通常能保持差不多的收敛效果。但如果batch size从32一下跳到1024,直接线性放大学习率往往会让训练早期就不稳定,因为前几个batch的梯度本身就很不准,步子太大会震荡。这时候就需要warmup策略:前几个epoch用一个较小的学习率“热身”,再逐渐过渡到目标学习率。现在的深度学习框架里,很多SGD实现都自带warmup的选项,或者你可以自己写一个学习率调度,前5个epoch从0.001慢慢升到0.04。
学习率衰减也不能忘。Mini-batch SGD训练后期,梯度噪声依然存在,如果你一直用固定的学习率,loss很可能在一个低水平上来回震荡而无法继续下降。常见做法是按epoch数做余弦退火衰减,或者按固定步长做阶梯式衰减,比如每30个epoch学习率减半。具体衰减策略可以很灵活,但原则只有一个:后期步子要小,以便在极小值附近精细搜索。
2.3 shuffle与epoch的细节
还有一个容易被忽视但影响极大的细节:每个epoch开始前,必须把训练数据顺序打乱。不少人刚开始写训练循环时,直接按数据原顺序一组一组切开丢给模型,结果发现loss曲线呈周期性锯齿状。原因就在于原始数据往往有排列规律——比如前一半全是类别A,后一半全是类别B,那么这一批batch的梯度方向就会偏向某一类标签,训练就来回摇摆。每次epoch开始时随机shuffle数据,相当于让每个batch的数据分布尽可能接近全局分布,梯度估计才会比较稳定。
另一个跟shuffle相关的问题是,如果你用Mini-batch SGD做分布式训练,shuffle的随机种子一定要一致。在多卡并行场景里,每个进程拿到的数据切片如果不一致,会导致每个batch的梯度方差变大,严重时模型无法收敛。我见过不少团队在单卡上跑得好好的,一上多卡就崩,排查到最后发现就是shuffle种子没设置。
epoch数目怎么定?这个没有标准答案,我的经验是先用一个小模型跑起来,观察验证集loss降到平缓后还不停,才开始考虑早停。Mini-batch SGD很方便的一点是你可以随时中断、恢复,因为每个epoch都由若干独立的batch组成,天然具有可恢复性和并行性,不像有些优化算法有全局状态耦合。
3. 实操:实现Mini-batch SGD的关键细节
3.1 一段可以直接用的伪代码
理论讲得再多,不如来一段可以亲手跑起来的代码。以PyTorch风格的伪代码为例,Mini-batch SGD的核心训练循环大概是这样的:
import torch # 假设 model 是已经定义好的神经网络 optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9) def train_one_epoch(dataloader, model, optimizer, criterion): model.train() total_loss = 0.0 # data_loader 内部已经实现了每个 epoch 打乱顺序 for batch_idx, (inputs, labels) in enumerate(dataloader): optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, labels) loss.backward() optimizer.step() total_loss += loss.item() return total_loss / len(dataloader) for epoch in range(epochs): avg_loss = train_one_epoch(dataloader, model, optimizer, criterion) print(f"Epoch {epoch}, Loss: {avg_loss}")这段代码里有几个细节值得多说一句。optimizer.zero_grad()必须在每次更新前调用,把上一步的梯度清零,否则PyTorch会默认累加梯度。loss.backward()是反向传播,计算出当前batch的梯度;optimizer.step()才是拿梯度去更新参数。初学者最常见的错误就是把这三行的先后顺序弄乱,或者忘记zero_grad(),导致梯度不断累加,loss直接爆炸。
3.2 动量与优化器变体
用PyTorch的人多半注意到,SGD优化器里还有一个叫momentum的参数。这个参数就是动量法,核心思想是让参数更新方向不只依赖当前梯度,还要参考上一轮的更新方向。打个比方,你跑步下山,脚已经朝某个方向跑了,如果你突然想急转弯,速度越快越来不及转向,动量的作用就是给“刹车换向”增加惯性。在Mini-batch SGD里,由于每个batch的梯度有噪声,更新方向会来回抖,动量相当于把最近几步的方向做了平滑,抖动量大幅减少,收敛也就更稳。
数学上,动量法引入一个速度变量v,更新变为:
v ← μ · v − η · g θ ← θ + v
其中μ就是动量系数,常见取0.9或0.99。μ越大,历史速度的影响越强,方向越平滑,但如果设到0.999这种极端值,很可能收不住,参数会冲过头。实践中还有一个变体叫Nesterov动量,它不只看当前点的梯度,还提前看一步——先按照历史速度“预跳”到近似位置,再计算那个位置的梯度。这种“看得更远”的策略在某些任务上收敛更快,但复杂度略高,你可以在框架里设置nesterov=True来开启。
为什么我建议用SGD+Momentum而不是一股脑用Adam?这里有一个常见的实践结论:SGD+Momentum在很多视觉模型和NLP模型上的最终泛化效果,往往优于Adam。原因并不完全清楚,一个被广泛讨论的解释是,Adam在训练后期对参数的不同维度使用了不同的有效学习率,虽然前期收敛快,但后期对某些参数过大的自适应步长相当于一种正则化过强,导致模型卡在次优解。SGD则一视同仁地对所有参数用同一个学习率,配合动量,训练到后期更容易到达更平滑的极小值点。当然,Adam类优化器在Transformer类模型上依然是主流选择,这跟模型本身的特性有关。我的建议是:先跑SGD+Momentum作为基线,如果收敛太慢或者训练不稳定,再换Adam类优化器对比。
3.3 梯度累积与显存受限时的处理
很多时候你会遇到这个问题:实验想用batch size 128跑,但显卡只有8GB,单次塞128张图显存直接溢出。这时候不必降batch size或者换显卡,可以试试梯度累积(gradient accumulation)。
梯度累积的思路是:把一次大的batch拆成多个小的子batch,分别前向传播、反向传播,把梯度累加起来,攒够了再更新一次参数。比如你想用128的batch size,但每批只能放32张图,那就连续算4个子batch,每个子batch算完先不做优化器更新,而是把梯度累加(这也是PyTorch的gradient默认累加特性的一个用途),等累积到4次后再调optimizer.step()。代码上只需要判断一下iteration序号即可:
accumulation_steps = 4 # 相当于把batch size放大了4倍 for batch_idx, (inputs, labels) in enumerate(dataloader): outputs = model(inputs) loss = criterion(outputs, labels) loss = loss / accumulation_steps # 平均一下,避免梯度放大 loss.backward() if (batch_idx + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()注意这里我把loss做了/accumulation_steps处理,否则累积的梯度总量等于所有子batch梯度的和,比正常平均算出来的梯度放大了好几倍,学习率相当于虚高了。这个细节我一开始也踩过坑,最后对比loss曲线才发现,除以累积步数之后,梯度量级跟直接用大batch几乎一致,曲线才恢复正常。
不过梯度累积有个副作用:BatchNorm层的统计量会受实际送入网络的子batch大小影响。如果你把32张图分4次累积,BatchNorm统计的是32张图的均值方差,而不是128张图的,导致与真正大batch训练不完全等价。解决方法是先在累积模式下用较大有效batch跑几个epoch预热,再关闭累积模式微调,或者换用SyncBatchNorm做跨batch同步统计,不过那个在多卡场景下更常用。
4. 常见问题与排查实录
4.1 一张问题速查表
实操中遇到的问题就那么多,我把常见的整理成了一张速查表,方便你对照排查。
| 现象 | 可能的根因 | 处理方向 |
|---|---|---|
| loss完全不下降 | 学习率过大或过小、数据没归一化、梯度爆炸 | 先判断loss值量级,从1e-2、1e-3、1e-4几个数量级测试 |
| loss在训练后期震荡不平 | 学习率太高 | 调低学习率,或引入学习率衰减、增加动量系数 |
| 训练曲线呈锯齿状规律波动 | 训练数据没有shuffle | 在每个epoch开打乱,设固定种子 |
| 增大batch size后精度变差 | 学习率没有按比例调整 | 按线性缩放规则调整学习率,必要时加warmup |
| 显存溢出(OOM) | batch size超过显存容量 | 减小batch size或使用梯度累积 |
| 梯度出现NaN | 学习率过大、输入有NaN、网络结构不稳定 | 检查输入数据、降低学习率、加载权重初始化检查 |
| 训练不收敛且loss波动非常大 | 纯SGD模式(batch=1)噪声过大 | 增大batch size,加动量 |
| 多卡训练结果与单卡差异大 | 不同进程数据分片不齐 | 统一shuffle种子,确保数据分片一致 |
4.2 实战排查案例实录
我举一个亲历过的案例。某次训练图像分类模型,初始设置是batch size 64、学习率0.01、无warmup。训练到第10个epoch时验证集精度已经到0.82,然后我把batch size调大到256,想加快一点训练速度。结果一调上去,前面三个epoch的loss直接飙升,然后虽然慢慢降回来,但最终精度反而比小batch低了1.5个百分点。排查下来就是典型的线性缩放规则没跟上——64调到256相当于batch size扩大了4倍,学习率应该相应从0.01调高到0.04左右。但直接按0.04起步,因为前几个batch的梯度方差大,模型参数直接被推到很远,所以也需要warmup。后来我把学习率设成0.01起步,在前3个epoch内线性升到0.04,再转入正常训练,最终精度稳定在了接近0.84。
另一个案例是关于数据shuffle的。一次我在做时间序列预测,数据按时间顺序排列后投入训练,然后发现loss曲线几乎完美复现了“周期性”,每个周期大约对应一个epoch的长度,而且验证集效果一直很差。一开始我还以为模型结构有问题,折腾了一整天,后来突然想到,时间序列数据本来就是按时间排序的,每个batch里样本的分布随时间偏移,模型要学的东西每批都在变,能不崩溃吗?解决办法不止是shuffle,还要在shuffle后重新切分时间窗口,保证特征和标签的对应关系不被打乱。这个情况用随机shuffle或按序列分组shuffle都可以,取决于数据的具体结构。
5. 我踩过坑之后的一些实际建议
关于Mini-batch SGD,最后分享几点我个人操作中沉淀下来的习惯。
第一,调参时永远先固定一个变量。很多初学者一上来就同时调batch size、学习率、动量、网络深度,出问题根本不知道是哪个变量导致的。我的做法是,先用一个小数据集、固定batch size 32、固定学习率0.01跑通一个基线,然后只动一个变量,对比曲线。
第二,不要怕用小的batch size做“快速实验”。很多人一上来就想着用最大batch塞满显存,但小batch的实验迭代更快,适合快速试错。等结构、数据流畅跑通了,再放大batch size同步调学习率,节省的时间远大于那一点训练轮次的差距。
第三,SGD的收敛性对学习率极其敏感。很多框架默认的初始学习率是0.01或0.001,但实际要根据模型规模、数据量、损失函数做适配。模型越小、数据越干净,学习率可以越大;模型越深、训练越不稳定,学习率要越小。一个快速判断的方法是:把loss画出来,如果前100次迭代loss变化幅度超过一个数量级,说明学习率大概率过大,直接减半重试。
第四,即便现在有Adam、AdamW、LAMB这些花哨的优化器,我的习惯依然是在常规视觉任务上先训练一个SGD+Momentum基线。它收敛可能比Adam慢一些,但最终精度通常更可靠。等到SGD基线跑稳了,再对比其他优化器的收益更科学,也更能判断你的模型是不是真的“难优化”。
最后一个小技巧。如果训练中遇到loss到了平台期实在下不去,很多人第一反应是换优化器或调网络,其实多数时候就是学习率偏大了。你可以把当前学习率直接除以10,继续训练20个epoch,往往精度会再涨一截。这个小技巧在不少比赛和项目里救过我的最终指标,建议你也试试。