一批样本如何完成一次参数更新?从全批量到小批量 SGD
沿一次训练迭代追踪张量与梯度,手算两个 mini-batch 的参数更新,并用 PyTorch DataLoader 实现可调试的小批量训练循环。
上一篇把二分类目标写成了可导的二元交叉熵。只要对损失求导,似乎就能更新参数。
但现实训练集可能有一千万个样本:每更新一次都扫描全量数据,等待太久;只看一个样本再更新,方向又会剧烈摇摆。工程实践为何几乎总把数据切成小批量(Mini-batch)?一个 batch 究竟对应几次更新?
今天只追踪一件事:一批样本如何从磁盘或内存进入模型,并最终变成一次参数更新。
01 一次“训练步”到底包含什么?#
设模型参数为 ,第 个样本损失为 。拿到含 个样本的小批后,通常先求批内平均损失:
再计算平均梯度并更新:
- :批大小(Batch Size)。
- :当前批对全部参数的梯度估计。
- :学习率(Learning Rate)。
- 一次
optimizer.step():一次参数更新,也常叫一个训练步(Training Step)。
完整数据流如下:
Dataset 中的 B 个样本
│ 组批
▼
X_batch [B,D],y_batch [B,1]
│ forward
▼
预测 [B,1] ──► 每样本损失 [B,1]
│ mean
▼
标量 loss []
│ backward
▼
每个参数的 .grad
│ optimizer.step
▼
新参数 θtext02 全批量、随机与小批量差在哪里?#
设训练集共有 个样本:
| 方法 | 每次更新使用样本数 | 每轮更新次数 | 梯度特点 |
|---|---|---|---|
| 全批量梯度下降(Batch Gradient Descent) | 1 | 精确但单步昂贵 | |
| 随机梯度下降(Stochastic Gradient Descent,SGD) | 1 | 便宜但噪声大 | |
| 小批量梯度下降(Mini-batch Gradient Descent) | 并行效率与噪声的折中 |
这里容易产生一个术语歧义:深度学习代码中的 torch.optim.SGD 通常也用于小批量训练。优化器只接收当前 .grad,并不知道这个梯度来自 1 个、32 个还是全体样本。
Epoch、iteration 与 step#
- Epoch(训练轮次):所有训练样本大致被使用一遍。
- Iteration(迭代):取出一个 batch 并运行一次训练循环。
- Step(参数更新步):调用一次优化器更新参数。
没有梯度累积时,一次 iteration 通常对应一次 step。若 ,一轮有:
个 batch,因此通常更新 79 次;最后一个 batch 只有 16 个样本。
03 手算两个 mini-batch 的更新#
继续使用最简单的无偏置线性模型:
单样本梯度为:
数据集有 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.1text第一个 batch#
两个样本在 时的梯度:
批内平均梯度:
更新参数:
第二个 batch#
注意:第二批使用的是已经更新后的 。
一轮结束后 。它越过了真实关系 的最优值 2,说明后一个 batch 的大尺度特征产生了更大梯度。这个例子同时暴露两个工程事实:样本顺序会影响轨迹,特征尺度会影响更新幅度。
交互检查:如果一开始先处理 Batch 2 呢?
在 时,第二批平均梯度为 ,一步就把 更新到 2.5。随后 Batch 1 又会把它拉回。最终值与原顺序不同,因此每轮打乱数据通常很重要。
04 为什么小批量的“噪声”有时有帮助?#
全量经验风险是:
随机抽取的小批梯度:
在均匀抽样等条件下, 是全量梯度的近似估计。不同 batch 会给出不同方向,形成 梯度噪声(Gradient Noise)。
参数空间中的损失等高线
· batch 2 梯度
↘
┌──────────┐
┌─┘ ● θ └─┐────► 全量梯度
└──────────┘
↗
· batch 1 梯度text批越大,估计通常越稳定,但单步计算和显存占用越高;批越小,更新频繁且噪声更大。适量噪声可能帮助离开狭窄区域,但太大时损失会剧烈震荡甚至发散。
批大小改变后,不能机械保持其余设置不变。损失的 mean 或 sum、学习率、归一化层和分布式并行方式都会改变有效更新。
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关键边界是:
- 每轮只打乱训练集,不打乱时间顺序任务中不能交换的因果顺序。
- 每个 batch 先前向并汇总为标量损失,再反向。
- 验证阶段不调用
backward()和step()。 - 一个 epoch 内参数会更新很多次,所以后面的 batch 看到的是更新后的模型。
06 用当前 PyTorch API 落地#
PyTorch 的 Dataset 描述“怎样按索引得到一个样本”,DataLoader 负责采样、组批和可选的多进程加载。下面仍拟合 ,但保留明确的二维形状:
import torch
from torch.utils.data import DataLoader, TensorDataset
torch.manual_seed(42)
X = torch.arange(1, 9, dtype=torch.float32).view(-1, 1) # [N=8, D=1]
y = 2 * X # [8, 1]
dataset = TensorDataset(X, y) # 沿第 0 维配对;两个张量长度必须相同
loader = DataLoader(
dataset,
batch_size=2,
shuffle=True,
drop_last=False,
num_workers=0,
)
model = torch.nn.Linear(in_features=1, out_features=1) # [B,1] -> [B,1]
loss_fn = torch.nn.MSELoss(reduction='mean')
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)
for epoch in range(100):
model.train()
epoch_loss_sum = 0.0
example_count = 0
for X_batch, y_batch in loader:
predictions = model(X_batch) # [B_current, 1]
loss = loss_fn(predictions, y_batch) # [],当前批均值
optimizer.zero_grad(set_to_none=True)
loss.backward()
optimizer.step()
current_batch_size = X_batch.shape[0]
epoch_loss_sum += loss.item() * current_batch_size
example_count += current_batch_size
if epoch % 20 == 0:
print(epoch, epoch_loss_sum / example_count)
model.eval()
with torch.no_grad():
test_X = torch.tensor([[9.0]]) # [1, 1]
prediction = model(test_X) # [1, 1]
print(prediction.item())python每个 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 mean、sum 与梯度规模#
假设每个样本梯度都是 2:
| batch size | reduction='mean' 后梯度 | reduction='sum' 后梯度 |
|---|---|---|
| 2 | 2 | 4 |
| 32 | 2 | 64 |
使用 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 训练循环最容易出错在哪里?#
- 忘记清梯度。 PyTorch 默认累加
.grad;若非刻意做梯度累积,应在每步反向前清除。 - 把
step()放错层级。 放到 epoch 外会变成整轮只更新一次;放到样本内又会破坏计划的 batch 语义。 - 验证时仍在更新。 使用
model.eval()和torch.no_grad(),并且不调用优化器。 - 最后一批导致形状假设失败。 不要把代码写死为固定
batch_size,用X_batch.shape[0]获取实际大小。 - 打乱了不可打乱的数据。 时间序列、语言序列内部顺序不能被普通随机采样破坏;要设计窗口与采样器。
- 数据加载比计算更慢。 分别计时取 batch 与前向/反向,再调整
num_workers、缓存和pin_memory,不要盲目加进程。 - 只看 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。可以按以下顺序试验:
- 从能稳定运行的中小值开始,例如 32 或 64。
- 测量每秒样本数、显存峰值和验证指标,而不只比较单步耗时。
- 调大 batch 后重新检查学习率和训练步数;相同 epoch 不代表相同更新次数。
- 若样本长度差异大,按长度分桶可减少填充浪费。
- 分布式训练要区分每设备 batch 与全局 batch:全局大小通常是二者再乘设备数与累积步数。
global_batch_size
= per_device_batch_size × device_count × accumulation_stepstext批太小的失败表现通常是曲线抖动、硬件利用率低;批太大的表现可能是显存溢出、每轮更新步数过少,以及固定训练轮数下优化不充分。
10 与相近概念的边界#
| 概念 | 改变了什么 | 没有自动解决什么 |
|---|---|---|
| 小批量 SGD | 用子集估计当前梯度 | 不自动选择学习率 |
| Momentum(动量) | 汇总历史方向,平滑更新 | 不改变数据如何组批 |
| 梯度累积 | 多次反向后再更新,降低单次显存 | 不加速数据读取 |
| 数据并行 | 多设备分别计算局部 batch 梯度再聚合 | 不保证随机性与复现 |
| 学习率调度 | 随训练过程改变步长 | 不修复错误标签或数据泄漏 |
本文只建立最基本的小批量训练语义;动量、自适应优化器和分布式训练都建立在这个循环上。
11 今天真正需要记住什么?#
- 一个 mini-batch 先产生批内平均损失,再反向得到梯度,最后通常对应一次参数更新。
- batch size 在梯度稳定性、更新频率、硬件吞吐和显存之间做折中。
DataLoader负责取样和组批,优化器只看到当前参数梯度,不知道梯度来自多少样本。zero_grad → forward → loss → backward → step的边界必须明确。- 调试时先让单个 batch 的形状、数值和梯度正确,再扩展到完整训练。
12 思考题与小练习#
练习 1:计算每轮更新次数
时,每轮有 次更新,最后一批 41 个样本;若 drop_last=True,则只有 15 次更新且每轮丢掉 41 个样本。
练习 2:找出梯度变大的原因
把 MSELoss(reduction='mean') 改成 'sum',再将 batch size 从 2 改成 8。为什么相同学习率可能发散?因为梯度会随批内元素数近似线性放大。
练习 3:验证样本顺序的影响
在手算例子中交换两个 batch 的顺序,完成两次更新。比较最终 ,并解释为什么每轮 shuffle 能减弱固定排序带来的系统偏差,却不能保证每次运行完全相同。
相关工作#
- Robbins & Monro: A Stochastic Approximation Method ↗:随机近似与随机梯度思想的奠基工作。
- Bottou: Large-Scale Machine Learning with Stochastic Gradient Descent ↗:SGD 用于大规模学习的经典综述。
- Keskar et al.: On Large-Batch Training for Deep Learning ↗:讨论大批量训练与泛化行为的代表性研究。
- Goyal et al.: Accurate, Large Minibatch SGD ↗:大批量训练中学习率与训练策略的实证工作。
- PyTorch: torch.utils.data ↗:当前
Dataset、DataLoader、自动组批与多进程加载的官方接口。
13 下一篇预告#
现在我们已经能让损失稳定下降,但“训练得更好”仍可能只是记住训练集。下一篇将用训练曲线拆开欠拟合与过拟合,判断问题究竟来自模型容量、数据数量,还是训练过程本身。