AI ENGINEERING阅读地图
7个主要章节
1段代码示例
5组图片与图表

沿定义、计算与实验边界阅读,分开模型原理、教学验证和实际系统效果。

章节速览7 节 · 展开选择

反向传播已经说明梯度会累加,但“多做几次backward再step”还不够完整。若最后一个微批较短,或者一个微批包含更多有效token,简单把每个微批损失除以同一个累积次数,可能悄悄改变训练目标。

本篇只讨论一个清楚的问题:希望得到某批记录的平均损失梯度时,怎样把它拆成微批计算,最后只更新一次参数? 我们先固定目标分母,再推导权重,用完整的小程序核对算术。程序是标量回归的教学模型,不运行深度网络,也不声称测量显存、训练速度或模型收益。

先确定目标平均的是样本、元素还是有效token?

把分母写进记录,再分配各微批的权重。微批个数相同,不代表每个微批包含的信息单位一样多。

再固定更新时刻参数不动,梯度先合成

先收齐本窗口的贡献,再做属于一次更新的裁剪、step和清零;不要在中途改变参数或缩放口径。

累积的对象是什么

在PyTorch中,反向传播会把叶子参数的梯度累加到.grad中,而不是每次自动覆盖;官方清零教程说明了这一行为,见文献1。这是一项存储规则,不是“无论怎样拆批都与大批次完全等价”的保证。

设本次有效批有N条记录,拆成K个不重叠微批B₁至B_K,第k个有n_k条,且Σn_k=N。所有微批的梯度必须针对同一个参数值θ计算。先假定每条记录的损失可以独立相加,不含依赖本批其他记录的运算。

公式A1|先写清完整批次的目标分母
L(θ) = (1/N) Σᵢ ℓᵢ(θ)
L_k(θ) = (1/n_k) Σᵢ∈B_k ℓᵢ(θ)

N与n_k为正计数,微批不重叠且覆盖同一批记录;所有项在相同θ处求值。损失、参数与梯度的单位取决于模型;本篇标量数值按无量纲合成数据处理。

N|目标分母
本次更新对应的有效单位总数。标量回归例子是一条记录一个单位,不能自动推广成任意张量的第一维长度。
n_k|微批分母
若微批返回自己的均值,应知道它实际除以什么。掩码、忽略标签或样本权重会改变有效分母。
K|计算分块数
只描述分成几次计算。只有各n_k相同、目标口径相同等条件满足时,1/K才是正确的均值权重。

由求和与求导的线性性,完整梯度可以按微批均值加权。若所有n_k相等,n_k/N=1/K;不相等时,就不能继续套用同一个1/K。

公式A2|不等长微批的正确合成
∇L(θ) = Σₖ (n_k/N) ∇L_k(θ)
= (1/N) Σₖ ∇[Σᵢ∈B_k ℓᵢ(θ)]

第一行合成微批均值,第二行先累加各微批损失和。两条路径应使用同一个目标分母,并在窗口内保持θ不变。若每条记录带权,分母应按实际目标改写,不能机械替换为记录数。

三条记录,错误平均在哪里出现

取无偏置线性模型预测wx,单条损失为ℓ=½(wx−y)²,梯度为x(wx−y)。这里特意使用½,方便手算;它不是torch.nn.MSELoss默认损失的原样表达。官方MSELoss默认均值对输出中的所有元素取平均且没有这个½,使用时需核对缩放,见文献2。

令w=1,三条记录为(1,0)、(2,0)、(3,0)。单条梯度依次为1、4、9,因此完整批次平均梯度是14/3。拆成前两条一个微批、最后一条一个微批,就得到下表。

微批记录数n_k梯度和微批均值梯度正确权重
(1,0)、(2,0)252.52/3
(3,0)1991/3
两条记录的微批均值2.5乘2/3,一条记录的微批均值9乘1/3,结果14/3;错误各乘1/2得到5.75
图1|原创算术对照。框宽不表示显存、运行时间或数据分布;比较时所有梯度都在w=1处计算。查看原图

正确结果为(2/3)×2.5+(1/3)×9=14/3≈4.666667。若直接平均两个微批均值,得到(2.5+9)/2=5.75,第三条记录相对于前两条中的每一条被赋予了双倍权重。

这不是浮点精度导致的小差异,而是优化目标改变了。若学习率为0.1,普通SGD的一次正确更新是w_new=1−0.1×14/3=8/15≈0.533333;用错误均值则得到0.425。

自检1:两个微批各自都用了mean,为什么它们的均值不能再直接平均?

因为两个微批的分母分别为2和1。再各乘1/2会让较短微批中的每条记录占更大权重。应按2/3与1/3合成,或先累加梯度和再统一除以3。

逐微批更新为什么是另一条训练轨迹

仍用这三条记录,若第一微批就更新,w先从1变成1−0.1×2.5=0.75。第二微批的梯度因此变成9×0.75=6.75,再更新得到0.075。它不是固定w=1时各微批贡献的和,也没有理由等于0.533333。

参数何时改变,是累积定义的一部分。AdamW等有状态优化器还维护内部状态,增加step次数会同时改变状态更新、衰减应用与调度计数。不要只数已经处理多少样本,就把不同更新频率的训练称为完全相同。

公式A3|一次普通SGD更新的时刻
g = Σₖ (n_k/N) ∇L_k(θ_old)
θ_new = θ_old − ηg

η为学习率,本式仅写普通SGD,无动量或权重衰减。每个微批均使用θ_old;不能把中途更新后的梯度混入同一个固定参数梯度的等式。

一个能直接运行的标准库模型

下面的程序显式传入微批列表,固定w计算每个微批的均值梯度,再按实际记录数加权。空窗口、空微批和非有限输入被拒绝;输入及中间计算须在float可表达范围内。它没有张量自动微分、GPU或分布式通信,不是训练框架的替代实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
from math import fsum, isfinite


def mean_gradient(rows, w):
if not rows or not isfinite(w):
raise ValueError("nonempty rows and finite w required")
if not all(isfinite(x) and isfinite(y) for x, y in rows):
raise ValueError("finite observations required")
result = fsum(x * (w * x - y) for x, y in rows) / len(rows)
if not isfinite(result):
raise OverflowError("gradient outside finite float range")
return result


def accumulated_gradient(microbatches, w):
if not microbatches or any(not batch for batch in microbatches):
raise ValueError("nonempty window and microbatches required")
total = sum(len(batch) for batch in microbatches)
result = fsum(len(batch) / total * mean_gradient(batch, w)
for batch in microbatches)
if not isfinite(result):
raise OverflowError("accumulated gradient is not finite")
return result


def main():
batches = [[(1.0, 0.0), (2.0, 0.0)], [(3.0, 0.0)]]
w, rate = 1.0, 0.1
gradient = accumulated_gradient(batches, w)
wrong_mean = fsum(mean_gradient(b, w) for b in batches) / len(batches)
sequential = w
for batch in batches:
sequential -= rate * mean_gradient(batch, sequential)
print(f"gradient={gradient:.12f}")
print(f"wrong_mean={wrong_mean:.12f}")
print(f"one_step={w - rate * gradient:.12f}")
print(f"micro_steps={sequential:.12f}")


if __name__ == "__main__":
main()

最后一个窗口、掩码与分母契约

如果原计划每四个微批更新一次,数据尾部只剩两个,应先决定尾部处理策略。若仍希望优化这两批实际记录的平均损失,就用尾部实际总数归一化;继续除以四,会缩小这一更新的梯度。也可以明确丢弃尾部,但这会改变本轮使用的数据,不能不记录。

带掩码的序列任务还要问,loss的mean是否只覆盖有效token。若微批A有100个有效token、微批B有20个,且目标是120个有效token的均值,权重应为100/120和20/120;若目标本来是“每条序列先平均,再平均序列”,它又是另一项目标。两种目标都可定义,关键是不能混用分母。

清零属于窗口边界:每次新窗口开始前,不应留着上个窗口的历史梯度;窗口内部又不能在还没收齐贡献时清空。调度器与日志也应注明按微批、有效单位还是优化器更新计步。一个step的含义含糊时,仅报告“训练了一千步”不足以复现实验。

裁剪、AMP和BatchNorm的额外边界

若需要全窗口梯度裁剪,应在合成之后按该窗口目标执行。裁剪是非线性操作,通常不能与累加交换。例如两个等权微批的标量梯度为10和−8,阈值为1:分别裁剪再平均得到0,先平均得到1再裁剪仍为1。这两种操作实现了不同规则。

AMP还有缩放状态:PyTorch文档要求同一有效批内累积时保持缩放因子一致,反缩放应等相关梯度累积完成后进行;相应检查与缩放更新按有效批粒度处理,见文献3。这里不把普通float模型冒称为AMP验证,也不展开分布式通信平均的实现细节。

窗口内固定参数累计加权贡献;收齐后按所用数值模式处理反缩放与裁剪,再做一次优化器更新并准备下一窗口
图2|原创操作边界图,AMP框只表示需要额外遵守的规则,不是可直接复制的框架代码。各框宽度不代表耗时。查看原图

即便分母完全正确,BatchNorm的微批统计也未必等于完整批统计。官方文档说明其训练时使用批内统计并维护运行估计,见文献4。随机层、跨样本损失或批间状态改变,都可能使拆批后实际计算的函数不同。A2只保证同一可加目标在同一参数处的线性合成,不自动保证整段训练轨迹、随机数使用或浮点舍入逐位一致。

自检2:微批期间提前step一次,下一微批的梯度还能放进同一个固定参数等式吗?

不能。下一微批通常在新参数处求梯度,已经不属于A2右侧的同一个θ。它可以是一种训练方案,但应按逐微批更新描述。

自检3:分母权重正确,是否就能保证BatchNorm与大批次完全相同?

不能。训练时批内统计依赖实际微批中的其他记录;拆批可能改变前向计算与运行统计。应先检查模型是否满足本篇可加、固定函数的假设。

自检4:计划累积四批,尾部只有两批,为什么不总是继续除以四?

如果目标是尾部实际有效单位的均值,应按这两批的实际总分母归一化。继续按四个等长微批缩放,会得到较小梯度。若刻意采用这种规则,应明确记录其目标与步长含义。

参考文献与验证范围

  1. PyTorch:Zeroing out gradients。 官方教程。核对.grad累加与清零行为;本篇微批权重由目标求和独立推导。
  2. PyTorch 2.15:MSELoss。 官方API文档。用于区分输出元素均值与记录数,以及本文额外½因子;程序不是此API的复刻。
  3. PyTorch 2.15:Automatic Mixed Precision examples,Gradient accumulation。 有效批与缩放规则。本文没有运行AMP或GPU训练,只核对规则并提供标准库算术模型。返回A2。
  4. PyTorch 2.15:BatchNorm1d。 官方API文档。用于说明批内统计与运行状态边界;完整批等价需要另行检查。