同一个小模型训练到收敛
主线位置:训练主线整条走 100 遍:forward → loss → backward → 优化器一步。本页看的是两边的差别怎么随步数累积。
版本:PyTorch 2.15.0(CPU)、burn 0.22.1(Flex 设备)
flowchart TB gen["f64 生成 舍入一次"] --> pt32["PyTorch f32"] gen --> bn32["burn f32"] pt32 --> gap["逐步的差"] bn32 --> gap gen --> pt64["PyTorch f64"] gen --> bn64["burn F64"] pt64 --> split["拆出来源"] bn64 --> split gap --> split split --> round["各自的 f32 舍入"] split --> algo["算法本身的差"]
实验代码:发布的一对程序是 train_both.py 和 crate handson-train,全文在本页末尾。分析用的是 trace.py(同样的模型和数据,每步记下全部参数,可以切到 float64)和 crate handson-trace(同一个程序加上每步参数和 F64 开关,另有 bin factors、grad1)。burn 侧 feature 同模板(std、flex、autodiff、optim),release 构建。平台 M2 Max、macOS 27、rustc 1.99.0。
任务
训练一步和训练循环用的都是 A1 那个两层 MLP。这里换一个稍大的模型,把卷积也带上:
- 模型:
Conv1d(1→4, kernel 5)→ ReLU → 展平成 48 维 →Linear(48→3),共 171 个参数。 - 数据:96 条长度 16 的序列,三类。第
k条属于类c = k % 3,第t个值是sin(0.3(c+1)·t + 0.37k) + 0.3·sin(2.3t + 1.1k):后一项和类别无关,当作噪声。两边都先在 f64 里算,最后舍入成 f32 一次,所以输入逐位相同(原因见怎么读这一卷)。 - 初值:按下标
i生成,conv.weight是0.4·sin(i+1),fc.weight是0.15·cos(i+1),conv.bias是0.02·o − 0.03,fc.bias是 0。 - 训练:每批 16 条,不打乱,6 个批轮流用,共 100 步(约 17 个 epoch)。SGD 学习率 0.5;Adam 学习率 0.01,ε 两边都设成 1e-8(burn 默认是 1e-5,见优化器状态)。
- 记录:每步更新前的 loss;100 步后的全部参数;最后在全部 96 条上的 loss 和答对的条数。
模型的两种写法:
class Cnn(nn.Module):
def __init__(self):
super().__init__()
self.conv = nn.Conv1d(1, CH, K)
self.fc = nn.Linear(FLAT, CLASSES)
def forward(self, x):
return self.fc(torch.relu(self.conv(x)).flatten(1))
#[derive(Module, Debug)]
struct Cnn {
conv: Conv1d,
fc: Linear,
relu: Relu,
}
impl Cnn {
fn forward(&self, x: Tensor<3>) -> Tensor<2> {
self.fc.forward(self.relu.forward(self.conv.forward(x)).flatten(1, 2))
}
}
和 A1 比,新出现的只有两处:
- 卷积权重的布局两边相同。burn 的
Conv1d权重是[channels_out, channels_in / groups, kernel_size],和nn.Conv1d.weight一样,按同样的下标生成就行,不用转置;Linear的权重仍然是[in, out],要转置(见定义模型)。输入两边都是[batch, channels, length]。 flatten(1)在 burn 里写成flatten(1, 2):起止维都要写,结果的秩由返回类型Tensor<2>给出(见创建与形状)。两边都按行优先展平,第c个通道的第t个位置落在第12c + t列。
训练循环逐行对得上,和训练一步一样。取一批是 x[i:i + BATCH] 对 x.clone().slice(s![i..i + BATCH])(见切片与索引),评估是 torch.no_grad() 对 model.valid() 加 device.inner() 上的数据(见不求导的区域)。
结果
python -I train_both.py > pt.json
cargo run --release --offline > burn.json
python -I compare.py pt.json burn.json
sgd.losses n=100 max|diff|=1.79e-07
sgd.final.loss n= 1 max|diff|=1.49e-08
sgd.final.correct n= 1 max|diff|=0.00e+00
sgd.params.conv.weight n= 20 max|diff|=7.15e-07
sgd.params.conv.bias n= 4 max|diff|=5.96e-07
sgd.params.fc.weight n=144 max|diff|=4.77e-07
sgd.params.fc.bias n= 3 max|diff|=4.92e-07
adam.losses n=100 max|diff|=2.77e-06
adam.final.loss n= 1 max|diff|=7.75e-07
adam.final.correct n= 1 max|diff|=0.00e+00
adam.params.conv.weight n= 20 max|diff|=3.04e-06
adam.params.conv.bias n= 4 max|diff|=2.62e-06
adam.params.fc.weight n=144 max|diff|=3.52e-06
adam.params.fc.bias n= 3 max|diff|=1.61e-06
worst=3.52e-06 tol=1e-06 MISMATCH
两边都训练到了 96 条全对。最后在全部数据上的 loss:
| PyTorch | burn | |
|---|---|---|
| SGD | 0.05885924771428108 | 0.058859262615442276 |
| Adam | 0.07011167705059052 | 0.0701124519109726 |
SGD 这一半在 1e-6 以内通过,Adam 这一半没有。两边的程序各连跑两次,输出逐字节相同,所以这些差不是随机的。下面按步看差是怎么来的。
为了把差拆开,另外跑了两边的 float64 版本:PyTorch 侧把 f32 的初值和数据转成 float64 再训练(trace.py f64x);burn 侧先 Device::flex().configure(FloatDType::F64)(F64=1)。两份 f64 运行之间的差,就是两边算法本身的差;每一边的 f32 运行离它自己的 f64 运行多远,就是这一边 f32 舍入攒下的量。
SGD:loss 不涨,参数差慢慢涨
按步比,max|dparam| 是那一步更新后 171 个参数的最大差;后三列是 PyTorch f32 离 PyTorch f64、burn f32 离 burn F64、两份 f64 之间的最大参数差:
step torch loss burn loss |dloss| max|dparam| torch-f64 burn-f64 f64-f64
1 1.13687289 1.13687277 1.19e-07 2.98e-08 2.40e-08 1.93e-08 5.55e-17
2 1.10447657 1.10447657 0.00e+00 2.98e-08 2.84e-08 2.37e-08 5.55e-17
5 1.06677926 1.06677914 1.19e-07 4.47e-08 3.09e-08 2.89e-08 8.33e-17
10 0.98627138 0.98627144 5.96e-08 4.47e-08 6.23e-08 3.99e-08 1.39e-16
20 0.84775448 0.84775442 5.96e-08 8.94e-08 1.12e-07 1.16e-07 8.88e-16
30 0.64109868 0.64109868 0.00e+00 1.79e-07 2.01e-07 1.63e-07 2.66e-15
40 0.50108528 0.50108522 5.96e-08 2.98e-07 4.37e-07 2.00e-07 6.33e-15
50 0.38287219 0.38287210 8.94e-08 4.77e-07 7.14e-07 2.49e-07 1.17e-14
60 0.22960071 0.22960062 8.94e-08 5.36e-07 8.23e-07 3.72e-07 1.38e-14
70 0.15450853 0.15450841 1.19e-07 4.77e-07 7.37e-07 3.26e-07 1.53e-14
80 0.10406791 0.10406786 4.47e-08 4.77e-07 8.16e-07 4.42e-07 1.88e-14
90 0.07615292 0.07615286 5.96e-08 7.15e-07 9.32e-07 4.99e-07 2.15e-14
100 0.06210175 0.06210171 3.73e-08 7.15e-07 1.05e-06 5.75e-07 2.32e-14
- loss 的差 100 步里最大 1.79e-7(第 37、46、48、58、67 步),绝对值没有随步数变大。按 ulp 数是变大了:loss 从 1.14 降到 0.06,ulp 跟着变小,第 1 步的 1.19e-7 是 1 个 ulp,第 100 步的 3.73e-8 是 5 个,100 步里最多 15 个。
- 参数的差从第 1 步的 2.98e-8 涨到第 100 步的 7.15e-7,涨了 24 倍。
- 两份 f64 运行 100 步只差 2.32e-14:两边的 SGD、卷积、交叉熵是同一套算法。f32 的差全部来自舍入:PyTorch 离自己的 f64 解 1.05e-6,burn 离自己的 5.75e-7,两边的差 7.15e-7 和这两个量同一个量级。
- 舍入为什么会攒起来:每一步算出的参数带着一点舍入误差,下一步的梯度就在这组略有不同的参数上算,误差被带进后面每一步。两边的舍入各走各的(求和顺序不同),所以两边之间的差也在攒。同样 100 步、学习率改成 0.1 时(loss 只降到 0.79,96 条对 76 条),参数最大差是 1.19e-7。
所以 SGD 训练 100 步,容差 1e-6 刚好够;步数再多,参数要按步数放宽,loss 不用。
Adam:第一步就大一个量级
同样的表,Adam:
step torch loss burn loss |dloss| max|dparam| torch-f64 burn-f64 f64-f64
1 1.13687289 1.13687277 1.19e-07 4.69e-07 4.91e-07 2.15e-08 2.23e-10
2 1.11771631 1.11771631 0.00e+00 5.07e-07 5.05e-07 2.38e-08 3.44e-08
5 1.08280766 1.08280778 1.19e-07 4.21e-07 4.82e-07 3.74e-08 1.58e-07
10 1.03940225 1.03940248 2.38e-07 3.87e-07 4.66e-07 5.62e-08 3.91e-07
20 0.97808975 0.97809017 4.17e-07 7.60e-07 4.95e-07 8.15e-08 7.85e-07
30 0.83425587 0.83425671 8.34e-07 1.19e-06 4.84e-07 1.19e-07 1.23e-06
40 0.69612402 0.69612581 1.79e-06 1.65e-06 4.64e-07 1.06e-07 1.69e-06
50 0.58540988 0.58541220 2.32e-06 2.32e-06 4.48e-07 1.92e-07 2.35e-06
60 0.38402307 0.38402560 2.53e-06 2.92e-06 4.61e-07 1.76e-07 2.93e-06
70 0.24789160 0.24789384 2.24e-06 3.46e-06 4.66e-07 2.21e-07 3.36e-06
80 0.17158426 0.17158614 1.88e-06 3.46e-06 4.36e-07 3.06e-07 3.66e-06
90 0.10003939 0.10004054 1.15e-06 3.52e-06 4.45e-07 3.25e-07 3.70e-06
100 0.07264095 0.07264175 7.97e-07 3.52e-06 4.80e-07 3.55e-07 3.71e-06
这张表里有两个现象,来源不同。
第一个:第 1 步参数就差 4.69e-7,是 SGD 第 1 步(2.98e-8)的 16 倍。这时两份 f64 运行只差 2.23e-10,算法还没分开;差几乎全在 torch-f64 那一列(4.91e-7),也就是 PyTorch 的 f32 运行离 f64 解远了。差最大的参数是 fc.weight[67],即 PyTorch 布局下的 fc.weight[1, 19]。它第 1 步的更新量,PyTorch f32 是 -0.009901359677314758,f64 是 -0.009901850779581708。
fc.weight[1, 19] 的更新量在 f32 和 f64 之间差了 4.91e-7,约是更新量的 5e-5。这个参数第 1 步的梯度,PyTorch f32 和 f64 之间的相对差是多少:1e-7 量级(几个 f32 ulp),还是更大?用同一批数据(第一批 16 条)、同样的初值,两边分别取这一个梯度:PyTorch 侧 m.fc.weight.grad[1, 19],burn 侧 model.fc.weight.val().grad(&grads) 的 [19, 1](burn 的布局是 [in, out])。先想好再看本页末尾的答案。
第二个:之后两边一路分开,第 100 步差 3.52e-6。这次 torch-f64 和 burn-f64 两列都没有涨多少(各自不超过 5.1e-7),涨的是 f64-f64 那一列:两份 f64 运行之间的差从 2.23e-10 涨到 3.71e-6,和 f32 下的 3.52e-6 几乎一样大。所以这部分不是舍入,是两边每一步的更新量本来就不一样。
原因在优化器状态里已经找到了:burn 的 AdaptiveMomentum::transform 在 f32 里算偏差修正的两个标量,PyTorch 在 Python 的 float(f64)里算。那一页只训练了 10 步;这里把 bin factors(照抄 burn 的三行,打印 t = 1..100)的输出和 f64 里用同样的 f32 betas 算出的值比到第 100 步:
t burn f32 f64 rel err
1 0.316225648 0.316225655 -2.23e-08
2 0.235314354 0.235315158 -3.42e-06
5 0.172497287 0.172497706 -2.43e-06
10 0.153187588 0.153188071 -3.15e-06
20 0.160230935 0.160231541 -3.79e-06
30 0.179566771 0.179567461 -3.84e-06
40 0.201034531 0.201035340 -4.02e-06
50 0.222036406 0.222037423 -4.58e-06
60 0.241810858 0.241811856 -4.12e-06
70 0.260235041 0.260236113 -4.12e-06
80 0.277403384 0.277404527 -4.12e-06
90 0.293464452 0.293465549 -3.74e-06
100 0.308562845 0.308564004 -3.76e-06
从第 2 步起,burn 的因子每一步都小 2.4 到 6.9 ppm,100 步里一直是负的。每一步 burn 都比 PyTorch 少走同样比例的一点,方向不变,所以累加起来,不像舍入那样有正有负。在两份 f64 运行里查 100 步后差最大的 fc.weight[89]:它从初值移动了 -0.3817,burn 少移动 3.71e-6,约为移动量的 9.7e-6。全部 171 个参数里,93% 是 burn 移动得少,「burn 减 PyTorch 的差除以移动量」的中位数是 -2.7e-6,和因子的误差同号、同一个量级。比例并不正好等于因子的误差,因为步长一变,后面每一步的梯度也跟着变。
验证这个解释:在 PyTorch 里用 float64 手写 Adam(trace.py emul),矩的更新照 burn 的写法,步长用 bin factors 打印的 f32 因子:
c, e = self.fac[self.t] # bin factors 打印的 combined_factor 和 epsilon * bias_correction2_sqrt
m = m * self.b1 + g * (1 - self.b1)
v = v * self.b2 + g * g * (1 - self.b2)
p -= (m * c / (v.sqrt() + e)) * self.lr
和 burn 的 F64 运行比:
step |dloss| max|dparam| where
1 0.00e+00 7.63e-17 fc.weight[67]
10 0.00e+00 8.33e-17 fc.weight[106]
50 1.11e-16 2.36e-16 conv.weight[5]
100 4.16e-17 6.94e-16 fc.weight[0]
100 步都只差 f64 的舍入。两边 Adam 之间 3.71e-6 的差,全部由这几个 f32 标量解释。其中 betas 本身存成 f32(0.9 在 f32 里是 0.8999999761581421)也有一份:PyTorch 的 f64 运行只把 betas 换成这两个 f32 值,100 步后和原来差 1.18e-7,比 3.71e-6 小一个量级多。大头是两个因子在 f32 里的运算本身(1 − β₂ᵗ 为什么对 f32 舍入这么敏感,见优化器状态)。
问: Adam 的 loss 差在第 60 步左右最大(2.53e-6),到第 100 步降回 7.97e-7。是不是两边后来又靠拢了?
答: 没有。参数的差从第 70 步起一直停在 3.5e-6 左右,没有变小。loss 的差约等于梯度和参数差的内积,参数差不变时就跟着梯度变。训练后段 loss 降到 0.07,交叉熵的梯度也小了:PyTorch 侧打印每步的全局梯度范数,第 60 步 0.2788,第 100 步 0.0813,降到约 1/3.4;同一段 loss 的差从 2.53e-6 降到 7.97e-7,降到约 1/3.2。判断两边是不是对得上,要看参数,不能只看 loss。
容差怎么定
f32 下逐个数比较时,容差要按优化器和步数定:
- SGD:差是两边各自的 f32 舍入,100 步参数最大 7.15e-7,1e-6 够用。
- Adam:差里有一份每步都在攒的系统偏差,100 步 3.52e-6。
compare.py ... 1e-5通过(worst=3.52e-06 tol=1e-05 OK)。
这个 1e-5 的门要能挡住真的错误。把 PyTorch 侧的 ε 改成 burn 的默认值 1e-5(另一边仍是 1e-8),同样的命令:
adam.losses n=100 max|diff|=4.25e-04
adam.final.loss n= 1 max|diff|=1.58e-05
adam.final.correct n= 1 max|diff|=0.00e+00
adam.params.conv.weight n= 20 max|diff|=5.67e-03
adam.params.conv.bias n= 4 max|diff|=3.16e-03
adam.params.fc.weight n=144 max|diff|=4.57e-03
adam.params.fc.bias n= 3 max|diff|=4.78e-04
worst=5.67e-03 tol=1e-05 MISMATCH
参数差到 5.67e-3,比 f32 因子造成的差大三个量级,退出码 1。准确率仍然都是 96/96:只看最后的准确率,这种配置上的差别看不出来。
完整程序
PyTorch(train_both.py):
# 一维小 CNN 在合成数据上训练 100 步(SGD 和 Adam 各一次),输出每步 loss 和最后的参数
import json, math, sys
import torch
from torch import nn
N, LEN, CLASSES, BATCH, STEPS = 96, 16, 3, 16, 100
CH, K = 4, 5
FLAT = CH * (LEN - K + 1) # 4 * 12 = 48
def data():
# 先在 f64 里算,最后舍入成 f32 一次;burn 侧同样的公式、同样的顺序
xs, ys = [], []
for k in range(N):
c = k % CLASSES
f = 0.3 * (c + 1)
xs.append([[math.sin(f * t + 0.37 * k) + 0.3 * math.sin(2.3 * t + 1.1 * k) for t in range(LEN)]])
ys.append(c)
return torch.tensor(xs, dtype=torch.float32), torch.tensor(ys)
class Cnn(nn.Module):
def __init__(self):
super().__init__()
self.conv = nn.Conv1d(1, CH, K)
self.fc = nn.Linear(FLAT, CLASSES)
def forward(self, x):
return self.fc(torch.relu(self.conv(x)).flatten(1))
def build():
m = Cnn()
with torch.no_grad():
# conv.weight [out, in, k],fc.weight [out, in]:按下标 i 生成,两边相同
m.conv.weight.copy_(torch.tensor([0.4 * math.sin(i + 1) for i in range(CH * K)]).reshape(CH, 1, K))
m.conv.bias.copy_(torch.tensor([0.02 * o - 0.03 for o in range(CH)]))
m.fc.weight.copy_(torch.tensor([0.15 * math.cos(i + 1) for i in range(CLASSES * FLAT)]).reshape(CLASSES, FLAT))
m.fc.bias.zero_()
return m
def train(make_opt):
x, y = data()
model = build()
opt = make_opt(model.parameters())
losses = []
for step in range(STEPS):
i = (step % (N // BATCH)) * BATCH # 不打乱,6 个批轮流用
loss = nn.functional.cross_entropy(model(x[i:i + BATCH]), y[i:i + BATCH])
losses.append(loss.item())
opt.zero_grad()
loss.backward()
opt.step()
with torch.no_grad():
out = model(x)
final = {
"loss": nn.functional.cross_entropy(out, y).item(),
"correct": (out.argmax(1) == y).sum().item(),
}
params = {n: p.tolist() for n, p in model.named_parameters()}
return {"losses": losses, "final": final, "params": params}
json.dump({
"sgd": train(lambda p: torch.optim.SGD(p, lr=0.5)),
"adam": train(lambda p: torch.optim.Adam(p, lr=0.01, eps=1e-8)),
}, sys.stdout)
burn 的 Cargo.toml:
[package]
name = "handson-train"
version = "0.1.0"
edition = "2024"
[dependencies]
burn = { version = "=0.22.1", default-features = false, features = ["std", "flex", "autodiff", "optim"] }
serde_json = "1"
burn 的 src/main.rs:
//! 和 train_both.py 同一个一维小 CNN、同样的权重和数据,SGD 和 Adam 各训练 100 步,
//! 输出同样结构的 JSON。fc.weight 输出时转回 PyTorch 的 [out, in]。
use burn::module::{Module, Param};
use burn::nn::conv::{Conv1d, Conv1dConfig};
use burn::nn::loss::CrossEntropyLossConfig;
use burn::nn::{Linear, LinearConfig, Relu};
use burn::optim::{AdamConfig, GradientsParams, ModuleOptimizer, SgdConfig};
use burn::tensor::{Device, Int, Tensor, TensorData, s};
use serde_json::{Value, json};
const N: usize = 96;
const LEN: usize = 16;
const CLASSES: usize = 3;
const BATCH: usize = 16;
const STEPS: usize = 100;
const CH: usize = 4;
const K: usize = 5;
const FLAT: usize = CH * (LEN - K + 1); // 4 * 12 = 48
#[derive(Module, Debug)]
struct Cnn {
conv: Conv1d,
fc: Linear,
relu: Relu,
}
impl Cnn {
fn forward(&self, x: Tensor<3>) -> Tensor<2> {
self.fc.forward(self.relu.forward(self.conv.forward(x)).flatten(1, 2))
}
}
/// 先在 f64 里算,最后舍入成 f32 一次,和 Python 逐位相同
fn floats<const D: usize>(v: Vec<f64>, shape: [usize; D], device: &Device) -> Tensor<D> {
let v: Vec<f32> = v.into_iter().map(|a| a as f32).collect();
Tensor::from_data(TensorData::new(v, shape), device)
}
fn data(device: &Device) -> (Tensor<3>, Tensor<1, Int>) {
let mut xs = Vec::with_capacity(N * LEN);
let mut ys = Vec::with_capacity(N);
for k in 0..N {
let c = k % CLASSES;
let f = 0.3 * (c + 1) as f64;
for t in 0..LEN {
let (k, t) = (k as f64, t as f64);
xs.push((f * t + 0.37 * k).sin() + 0.3 * (2.3 * t + 1.1 * k).sin());
}
ys.push(c as i64);
}
let y = Tensor::from_data(TensorData::new(ys, [N]), device);
(floats(xs, [N, 1, LEN], device), y)
}
fn build(device: &Device) -> Cnn {
let mut m = Cnn {
conv: Conv1dConfig::new(1, CH, K).init(device),
fc: LinearConfig::new(FLAT, CLASSES).init(device),
relu: Relu::new(),
};
// conv.weight 和 PyTorch 一样是 [out, in, k];Linear 的权重是 [in, out],按 PyTorch 的 [out, in] 生成再转置
let w1 = (0..CH * K).map(|i| 0.4 * ((i + 1) as f64).sin()).collect();
let b1 = (0..CH).map(|o| 0.02 * o as f64 - 0.03).collect();
let w2 = (0..CLASSES * FLAT).map(|i| 0.15 * ((i + 1) as f64).cos()).collect();
m.conv.weight = Param::from_tensor(floats(w1, [CH, 1, K], device));
m.conv.bias = Some(Param::from_tensor(floats(b1, [CH], device)));
m.fc.weight = Param::from_tensor(floats::<2>(w2, [CLASSES, FLAT], device).transpose());
m.fc.bias = Some(Param::from_tensor(Tensor::zeros([CLASSES], device)));
m
}
/// 转成 f64 再写 JSON(f32 转 f64 是精确的),和 Python 的 tolist() 一样
fn values<const D: usize>(t: Tensor<D>) -> Value {
json!(t.into_data().convert::<f64>().try_to_vec::<f64>().unwrap_or_default())
}
fn params(m: &Cnn) -> Value {
let bias = |b: &Option<Param<Tensor<1>>>| b.as_ref().map(|b| values(b.val())).unwrap_or(Value::Null);
json!({
"conv.weight": values(m.conv.weight.val()),
"conv.bias": bias(&m.conv.bias),
"fc.weight": values(m.fc.weight.val().transpose()),
"fc.bias": bias(&m.fc.bias),
})
}
fn train(mut optim: ModuleOptimizer, lr: f64) -> Value {
let device = Device::flex().autodiff();
let (x, y) = data(&device);
let ce = CrossEntropyLossConfig::new().init(&device);
let mut model = build(&device);
let mut losses = Vec::with_capacity(STEPS);
for step in 0..STEPS {
let i = (step % (N / BATCH)) * BATCH; // 不打乱,6 个批轮流用
let xb = x.clone().slice(s![i..i + BATCH]);
let yb = y.clone().slice(s![i..i + BATCH]);
let loss = ce.forward(model.forward(xb), yb);
losses.push(loss.clone().into_scalar::<f64>());
let grads = GradientsParams::from_grads(loss.backward(), &model);
model = optim.step(lr, model, grads);
}
// 不带 autodiff 的副本和数据,相当于 model.eval() 加 torch.no_grad()
let eval = model.valid();
let (x, y) = data(&device.inner());
let out = eval.forward(x);
let loss: f64 = CrossEntropyLossConfig::new().init(&out.device()).forward(out.clone(), y.clone()).into_scalar();
let correct: i64 = out.argmax(1).squeeze_dim::<1>(1).equal(y).int().sum().into_scalar();
json!({
"losses": losses,
"final": { "loss": loss, "correct": correct },
"params": params(&model),
})
}
fn main() {
let out = json!({
"sgd": train(SgdConfig::new().init(), 0.5),
"adam": train(AdamConfig::new().with_epsilon(1e-8).init(), 0.01),
});
println!("{out}");
}
几处对应关系:
| PyTorch | burn | 见 |
|---|---|---|
torch.optim.SGD(p, lr=0.5) | SgdConfig::new().init(),0.5 在 step 时给 | 一步 |
Adam(p, lr=0.01, eps=1e-8) | AdamConfig::new().with_epsilon(1e-8) | 优化器状态 |
zero_grad / backward / step | backward → from_grads → model = optim.step(..) | 一步 |
argmax(1) 得 [96] | argmax(1) 得 [96, 1],再 squeeze_dim::<1>(1) | 归约与广播 |
with torch.no_grad() | model.valid(),数据放在 device.inner() | 不求导的区域 |
compare.py 在怎么读这一卷里。
动脑筋答案
PyTorch(grad1.py,同一批数据和初值,先 f32 再 float64):
torch.float32 grad 1.003785996545048e-06 update -0.009901359677314758
torch.float64 grad 1.0088567935009896e-06 update -0.009901850779581708
burn(bin grad1,先默认 F32,再 F64=1):
fc.weight[1, 19] grad Some([1.0086492920891033e-6])
configure F64: Ok(())
fc.weight[1, 19] grad Some([1.0088567935017457e-6])
- 相对差远大于几个 ulp。PyTorch f32 的梯度比 f64 小 5.07e-9,相对差 0.5%;burn f32 小 2.08e-10,相对差 2e-4。两份 f64 结果之间的相对差是 7.5e-13。
原因是抵消。这个梯度是 16 个样本的
dlogits[b, 1] · h[b, 19]之和,cancel.py在 float64 里把各项列出来:8 个非零项的绝对值加起来是 0.091,最大一项 0.023,相加后只剩 1.0e-6,抵消掉了五个数量级。0.023 附近一个 f32 ulp 约 1.9e-9,几项的舍入误差加起来就是几个 1e-9,落到 1e-6 的结果上就是千分之几。PyTorch 和 burn 的求和顺序不同,误差落点不同;这一次 burn 离 f64 更近,不代表它一般更准。sum 1.0088567935027243e-06 sum |.| 0.09116814735603485 max |.| 0.023424613166507163 nonzero 8SGD 对这种误差不敏感:更新量是
lr · g,g 差 5e-9,参数只差 2.5e-9。Adam 第 1 步的更新量是lr · g / (|g| + ε),梯度很小时,ε 和 g 比是 1e-8 : 1e-6,g的相对误差会按ε / |g|的比例进到更新量里:0.01 × 0.005 × 1e-8 / 1.009e-6 ≈ 5e-7,和实测的 4.91e-7 对得上。Adam 把梯度归一化,小梯度上的相对误差也就被放大成了参数上的绝对误差。
小结
- 一维小 CNN 两边一行对一行:
Conv1d权重布局两边相同,不用转置;Linear照旧转置;flatten(1)写成flatten(1, 2)。100 步后两边都 96/96 全对。 - SGD:loss 的差 100 步都在 1.79e-7 以内;参数差从 2.98e-8 涨到 7.15e-7。两份 f64 运行只差 2.32e-14,f32 下的差全是两边各自的舍入在攒。
- Adam(ε 对齐成 1e-8):第 1 步就差 4.69e-7,来自一个抵消严重的小梯度被 Adam 归一化放大;之后涨到 3.52e-6,来自 burn 在 f32 里算的偏差修正因子(每步小 2.4 到 6.9 ppm,方向不变),f64 里照抄这些因子能把 burn 逐步复现到 6.94e-16。
- loss 的差在训练后段会变小,参数的差不会;比两边要看参数。
- 容差按优化器和步数定:这个任务 SGD 100 步 1e-6,Adam 100 步 1e-5。ε 没对齐时参数差 5.67e-3,1e-5 的门能挡住;只看准确率看不出来。