观文听傑

返回

上一篇把二分类目标写成了可导的二元交叉熵。只要对损失求导,似乎就能更新参数。

但现实训练集可能有一千万个样本:每更新一次都扫描全量数据,等待太久;只看一个样本再更新,方向又会剧烈摇摆。工程实践为何几乎总把数据切成小批量(Mini-batch)?一个 batch 究竟对应几次更新?

今天只追踪一件事:一批样本如何从磁盘或内存进入模型,并最终变成一次参数更新。

01 一次“训练步”到底包含什么?#

设模型参数为 θ\theta,第 ii 个样本损失为 Li(θ)L_i(\theta)。拿到含 BB 个样本的小批后,通常先求批内平均损失:

Lbatch(θ)=1Bi=1BLi(θ)L_{batch}(\theta)=\frac{1}{B}\sum_{i=1}^{B}L_i(\theta)

再计算平均梯度并更新:

g=θLbatch(θ)=1Bi=1BθLi(θ)g=\nabla_\theta L_{batch}(\theta) =\frac{1}{B}\sum_{i=1}^{B}\nabla_\theta L_i(\theta) θθηg\theta\leftarrow\theta-\eta g
  • BB:批大小(Batch Size)。
  • gg:当前批对全部参数的梯度估计。
  • η\eta:学习率(Learning Rate)。
  • 一次 optimizer.step():一次参数更新,也常叫一个训练步(Training Step)。

完整数据流如下:

02 全批量、随机与小批量差在哪里?#

设训练集共有 NN 个样本:

方法每次更新使用样本数每轮更新次数梯度特点
全批量梯度下降(Batch Gradient Descent)NN1精确但单步昂贵
随机梯度下降(Stochastic Gradient Descent,SGD)1NN便宜但噪声大
小批量梯度下降(Mini-batch Gradient Descent)1<B<N1<B<NN/B\lceil N/B\rceil并行效率与噪声的折中

这里容易产生一个术语歧义:深度学习代码中的 torch.optim.SGD 通常也用于小批量训练。优化器只接收当前 .grad,并不知道这个梯度来自 1 个、32 个还是全体样本。

Epoch、iteration 与 step#

  • Epoch(训练轮次):所有训练样本大致被使用一遍。
  • Iteration(迭代):取出一个 batch 并运行一次训练循环。
  • Step(参数更新步):调用一次优化器更新参数。

没有梯度累积时,一次 iteration 通常对应一次 step。若 N=10,000,B=128,drop_last=FalseN=10{,}000,B=128,drop\_last=False,一轮有:

10,000128=79\left\lceil\frac{10{,}000}{128}\right\rceil=79

个 batch,因此通常更新 79 次;最后一个 batch 只有 16 个样本。

03 手算两个 mini-batch 的更新#

继续使用最简单的无偏置线性模型:

y^=wx,Li=12(wxiyi)2\hat y=wx,\qquad L_i=\frac{1}{2}(wx_i-y_i)^2

单样本梯度为:

Liw=(wxiyi)xi\frac{\partial L_i}{\partial w}=(wx_i-y_i)x_i

数据集有 4 个样本,批大小为 2:

Batch 1: (x=1,y=2), (x=2,y=4)
Batch 2: (x=3,y=6), (x=4,y=8)

初始 w=0,学习率 η=0.1
text

第一个 batch#

两个样本在 w=0w=0 时的梯度:

g1=(0×12)×1=2g_1=(0\times1-2)\times1=-2 g2=(0×24)×2=8g_2=(0\times2-4)\times2=-8

批内平均梯度:

gbatch1=282=5g_{batch1}=\frac{-2-8}{2}=-5

更新参数:

w00.1×(5)=0.5w\leftarrow0-0.1\times(-5)=0.5

第二个 batch#

注意:第二批使用的是已经更新后的 w=0.5w=0.5

g3=(0.5×36)×3=13.5g_3=(0.5\times3-6)\times3=-13.5 g4=(0.5×48)×4=24g_4=(0.5\times4-8)\times4=-24 gbatch2=13.5242=18.75g_{batch2}=\frac{-13.5-24}{2}=-18.75 w0.50.1×(18.75)=2.375w\leftarrow0.5-0.1\times(-18.75)=2.375

一轮结束后 w=2.375w=2.375。它越过了真实关系 y=2xy=2x 的最优值 2,说明后一个 batch 的大尺度特征产生了更大梯度。这个例子同时暴露两个工程事实:样本顺序会影响轨迹,特征尺度会影响更新幅度。

交互检查:如果一开始先处理 Batch 2 呢?

w=0w=0 时,第二批平均梯度为 [(6)×3+(8)×4]/2=25[(-6)\times3+(-8)\times4]/2=-25,一步就把 ww 更新到 2.5。随后 Batch 1 又会把它拉回。最终值与原顺序不同,因此每轮打乱数据通常很重要。

04 为什么小批量的“噪声”有时有帮助?#

全量经验风险是:

R^(θ)=1Ni=1NLi(θ)\hat R(\theta)=\frac{1}{N}\sum_{i=1}^{N}L_i(\theta)

随机抽取的小批梯度:

g^B=1BiBθLi(θ)\hat g_B=\frac{1}{B}\sum_{i\in\mathcal B}\nabla_\theta L_i(\theta)

在均匀抽样等条件下,g^B\hat g_B 是全量梯度的近似估计。不同 batch 会给出不同方向,形成 梯度噪声(Gradient Noise)

参数空间中的损失等高线

              · batch 2 梯度

        ┌──────────┐
      ┌─┘    ● θ   └─┐────► 全量梯度
        └──────────┘

              · batch 1 梯度
text

批越大,估计通常越稳定,但单步计算和显存占用越高;批越小,更新频繁且噪声更大。适量噪声可能帮助离开狭窄区域,但太大时损失会剧烈震荡甚至发散。

批大小改变后,不能机械保持其余设置不变。损失的 meansum、学习率、归一化层和分布式并行方式都会改变有效更新。

05 先写不依赖框架的伪代码#

initialize θ

for epoch in 1 ... E:
    shuffle(training_examples)

    for (X_batch, y_batch) in batches:
        predictions = model(X_batch; θ)
        per_example_loss = loss(predictions, y_batch)
        batch_loss = mean(per_example_loss)

        gradients = backward(batch_loss, θ)
        θ = θ - learning_rate * gradients

    evaluate once on validation set without updating θ
text

关键边界是:

  1. 每轮只打乱训练集,不打乱时间顺序任务中不能交换的因果顺序。
  2. 每个 batch 先前向并汇总为标量损失,再反向。
  3. 验证阶段不调用 backward()step()
  4. 一个 epoch 内参数会更新很多次,所以后面的 batch 看到的是更新后的模型。

06 用当前 PyTorch API 落地#

PyTorch 的 Dataset 描述“怎样按索引得到一个样本”,DataLoader 负责采样、组批和可选的多进程加载。下面仍拟合 y=2xy=2x,但保留明确的二维形状:

每个 API 真正控制什么?#

  • TensorDataset(X, y):按第 0 维索引所有张量,返回 (X[i], y[i])
  • DataLoader(..., batch_size=2):把两个样本自动堆成一批;最后一批可能更小。
  • shuffle=True:每个 epoch 重新排列索引,降低固定顺序偏差。
  • drop_last=False:保留不足一个完整 batch 的最后样本。
  • num_workers=0:在主进程加载,调试最清楚;数据读取成为瓶颈后再测更大的值。
  • optimizer.zero_grad(set_to_none=True):清除旧梯度;当前官方默认语义也是把梯度设为 None
  • loss.item():把零维张量取成 Python 数字,仅用于日志,不参与反向传播。

07 meansum 与梯度规模#

假设每个样本梯度都是 2:

batch sizereduction='mean' 后梯度reduction='sum' 后梯度
224
32264

使用 mean 时,扩大 batch 通常不会仅因为样本数增加就线性放大梯度;使用 sum 时会。因此复现实验必须同时记录 batch size、损失 reduction 和学习率。

梯度累积不是免费等价#

显存只能放 8 个样本,却想模拟 batch size 32,可以累计 4 个 micro-batch 的梯度,再调用一次 step()

optimizer.zero_grad(set_to_none=True)

for micro_step, (X_batch, y_batch) in enumerate(loader, start=1):
    loss = loss_fn(model(X_batch), y_batch) / 4
    loss.backward()

    if micro_step % 4 == 0:
        optimizer.step()
        optimizer.zero_grad(set_to_none=True)
python

除以 4 是为了让四次 mean 梯度的和仍对应大批均值。若最后不足 4 个 micro-batch,还要单独处理;含 Batch Normalization、随机增强或可变长度样本时,累计与真正大 batch 也未必完全等价。

08 训练循环最容易出错在哪里?#

  1. 忘记清梯度。 PyTorch 默认累加 .grad;若非刻意做梯度累积,应在每步反向前清除。
  2. step() 放错层级。 放到 epoch 外会变成整轮只更新一次;放到样本内又会破坏计划的 batch 语义。
  3. 验证时仍在更新。 使用 model.eval()torch.no_grad(),并且不调用优化器。
  4. 最后一批导致形状假设失败。 不要把代码写死为固定 batch_size,用 X_batch.shape[0] 获取实际大小。
  5. 打乱了不可打乱的数据。 时间序列、语言序列内部顺序不能被普通随机采样破坏;要设计窗口与采样器。
  6. 数据加载比计算更慢。 分别计时取 batch 与前向/反向,再调整 num_workers、缓存和 pin_memory,不要盲目加进程。
  7. 只看 batch loss。 单批噪声很大;同时记录按样本加权的 epoch 均值、验证损失和梯度范数。

推荐在第一次运行中检查:

X_batch, y_batch = next(iter(loader))
print(X_batch.shape, y_batch.shape)

predictions = model(X_batch)
loss = loss_fn(predictions, y_batch)
loss.backward()

for name, parameter in model.named_parameters():
    print(name, parameter.shape, parameter.grad.shape,
          torch.isfinite(parameter.grad).all().item())
python

先验证一个 batch 的数据、形状、损失和梯度都正确,再开始长时间训练。

09 batch size 应该怎样选?#

没有脱离模型、硬件与数据的最佳 batch size。可以按以下顺序试验:

  1. 从能稳定运行的中小值开始,例如 32 或 64。
  2. 测量每秒样本数、显存峰值和验证指标,而不只比较单步耗时。
  3. 调大 batch 后重新检查学习率和训练步数;相同 epoch 不代表相同更新次数。
  4. 若样本长度差异大,按长度分桶可减少填充浪费。
  5. 分布式训练要区分每设备 batch 与全局 batch:全局大小通常是二者再乘设备数与累积步数。
global_batch_size
= per_device_batch_size × device_count × accumulation_steps
text

批太小的失败表现通常是曲线抖动、硬件利用率低;批太大的表现可能是显存溢出、每轮更新步数过少,以及固定训练轮数下优化不充分。

10 与相近概念的边界#

概念改变了什么没有自动解决什么
小批量 SGD用子集估计当前梯度不自动选择学习率
Momentum(动量)汇总历史方向,平滑更新不改变数据如何组批
梯度累积多次反向后再更新,降低单次显存不加速数据读取
数据并行多设备分别计算局部 batch 梯度再聚合不保证随机性与复现
学习率调度随训练过程改变步长不修复错误标签或数据泄漏

本文只建立最基本的小批量训练语义;动量、自适应优化器和分布式训练都建立在这个循环上。

11 今天真正需要记住什么?#

  1. 一个 mini-batch 先产生批内平均损失,再反向得到梯度,最后通常对应一次参数更新。
  2. batch size 在梯度稳定性、更新频率、硬件吞吐和显存之间做折中。
  3. DataLoader 负责取样和组批,优化器只看到当前参数梯度,不知道梯度来自多少样本。
  4. zero_grad → forward → loss → backward → step 的边界必须明确。
  5. 调试时先让单个 batch 的形状、数值和梯度正确,再扩展到完整训练。

12 思考题与小练习#

练习 1:计算每轮更新次数

N=1001,B=64,drop_last=FalseN=1001,B=64,drop\_last=False 时,每轮有 1001/64=16\lceil1001/64\rceil=16 次更新,最后一批 41 个样本;若 drop_last=True,则只有 15 次更新且每轮丢掉 41 个样本。

练习 2:找出梯度变大的原因

MSELoss(reduction='mean') 改成 'sum',再将 batch size 从 2 改成 8。为什么相同学习率可能发散?因为梯度会随批内元素数近似线性放大。

练习 3:验证样本顺序的影响

在手算例子中交换两个 batch 的顺序,完成两次更新。比较最终 ww,并解释为什么每轮 shuffle 能减弱固定排序带来的系统偏差,却不能保证每次运行完全相同。

相关工作#

13 下一篇预告#

现在我们已经能让损失稳定下降,但“训练得更好”仍可能只是记住训练集。下一篇将用训练曲线拆开欠拟合与过拟合,判断问题究竟来自模型容量、数据数量,还是训练过程本身。

一批样本如何完成一次参数更新?从全批量到小批量 SGD
https://zwjcode.cn/blog/mini-batch-gradient-descent
作者
发布于 2026年8月19日
版权协议 CC BY-NC-SA 4.0
评论加载似乎遇到了问题,请尝试刷新页面。