训练一步
主线位置:训练主线的整条线:forward → loss → backward → 优化器一步,再重复 100 次。本页先对一步,再看两边的差怎么随步数变化。
版本:PyTorch 2.15.0(CPU)、candle 0.11.0(CPU,accelerate;A3 另在 Metal 上跑)
flowchart TB fwd["forward"] --> loss["mse 或交叉熵"] loss --> bw["backward"] bw --> gs["GradStore"] gs --> st["AdamW step"] st -->|"Var set"| fwd st --> cmp["逐步对 PyTorch"] cmp --> r32["f32 舍入"] cmp --> f64["f64 运行 拆来源"]
实验代码:PyTorch 侧 trace_a3.py(A3 按步记录)、burnset.py(burn 卷的两个设置),比较脚本 perstep.py、split.py;candle 侧 crate autodiff-exp 的 bin a3(模板里的 train)、trace、burnset、convgrad、convnc、conv2grad。release 构建,平台 M2 Max、macOS 27、rustc 1.99.0。PyTorch 固定 torch.set_num_threads(1)。优化器内部(Optimizer trait、Var::set、VarMap)见 candle 卷的优化器与训练循环。
两份训练循环
训练主线的 A3:两层 MLP(8→16→4,ReLU),MSE,batch 32,初值和数据在 f64 里生成、舍入成 f32 一次,两边读同一个 safetensors 文件。AdamW 学习率 1e-2、betas (0.9, 0.999)、eps 1e-8、weight_decay 0.01。PyTorch:
p = {n: r[n].to(dtype).clone().requires_grad_() for n in names}
opt = torch.optim.AdamW(p.values(), lr=1e-2, betas=(0.9, 0.999), eps=1e-8, weight_decay=wd)
for step in range(1, 101):
loss = torch.nn.functional.mse_loss(model(x), y)
opt.zero_grad()
loss.backward()
opt.step()
candle:
let p: Vec<Var> = NAMES.iter().map(|n| Ok(Var::from_tensor(&get(n)?)?)).collect::<Result<_>>()?;
let mut opt = AdamW::new(p.clone(), ParamsAdamW { lr: 1e-2, beta1: 0.9, beta2: 0.999, eps: 1e-8, weight_decay: wd })?;
for step in 1..=100 {
let loss = candle_nn::loss::mse(&forward(&p, &x)?, &y)?;
let grads = loss.backward()?;
opt.step(&grads)?;
}
forward 同 Var 与梯度。逐行对过去:
| PyTorch | candle | 差别 |
|---|---|---|
AdamW(params, lr=..., ...) | AdamW::new(vars, ParamsAdamW { .. }) | 优化器构造时拿一组 Var,同 PyTorch |
opt.zero_grad() | 没有 | 梯度不挂在参数上,每次 backward 一张新表 |
loss.backward() | let grads = loss.backward()? | 梯度作为返回值交出来 |
opt.step() | opt.step(&grads)? | 把那张表交给优化器;两步合起来是 backward_step |
model.parameters() | varmap.all_vars() | 这里四个 Var 是手建的;用 candle_nn::linear 建层时从 VarMap 取 |
opt.step 把新值用 Var::set 复制进参数原来的存储,模型下一次前向直接看到,参数的 id 不变。这和 PyTorch 原地改 p.data 是同一个效果;和 burn 把模型按值交给优化器、换回一个新模型不同(一步)。
A3 的结果(bin a3:从初值做一步 SGD,学习率 0.1;再从初值起训练 100 步 AdamW;前面梯度那几行见 Var 与梯度),CPU:
...
sgd.fc1.weight: 2.9802322e-8
sgd.fc1.bias: 0e0
sgd.fc2.weight: 3.7252903e-9
sgd.fc2.bias: 4.656613e-9
sgd worst 2.9802322e-8
adamw.fc1.weight: 2.9802322e-7
adamw.fc1.bias: 2.682209e-7
adamw.fc2.weight: 2.682209e-7
adamw.fc2.bias: 1.1920929e-7
adamw worst 2.9802322e-7
adamw loss diff 2.9802322e-8
Metal 上 SGD 一步 2.98e-8,AdamW 100 步 2.98e-7,loss 差 1.49e-8。参数的最大绝对值是 1.13,2.98e-7 是两三个 f32 ulp,容差 1e-6 通过。这个容差挡得住真错误:candle 侧把 weight_decay 改成 0(WD=0)、PyTorch 侧仍是 0.01,同样的比较变成
adamw worst 9.816974e-3
adamw loss diff 1.5181154e-3
优化器:有什么,默认值是什么
candle-nn 0.11.0 只有两个优化器。SGD 的文档注释写明「Contrary to the PyTorch implementation of SGD, this version does not support momentum」,配置就是一个学习率;AdamW 的参数是 ParamsAdamW。两边打印出来的默认值:
ParamsAdamW { lr: 0.001, beta1: 0.9, beta2: 0.999, eps: 1e-8, weight_decay: 0.01 }
SGD {'lr': 0.001, 'momentum': 0, 'dampening': 0, 'nesterov': False, 'weight_decay': 0}
Adam {'lr': 0.001, 'betas': (0.9, 0.999), 'eps': 1e-08, 'weight_decay': 0}
AdamW {'lr': 0.001, 'betas': (0.9, 0.999), 'eps': 1e-08, 'weight_decay': 0.01}
| PyTorch | candle 0.11.0 |
|---|---|
SGD(params, lr) | SGD::new(vars, lr),学习率必须给 |
SGD(momentum=0.9)、nesterov、weight_decay | 没有,要自己写 |
AdamW() 默认值 | ParamsAdamW::default(),五个数相同 |
Adam(params, ...) | AdamW 配 weight_decay: 0.0 |
Adam(weight_decay=λ)(L2,加到梯度上) | 没有;AdamW 的衰减是解耦的 |
| 学习率调度器 | 每步自己调 opt.set_learning_rate(lr) |
clip_grad_norm_ | 没有;在 GradStore 上自己算 |
| 参数组(每组不同 lr) | 没有;建两个优化器,各拿一部分 Var |
和 burn 不同,candle 的 AdamW 默认值和 PyTorch 一致(burn 的 ε 默认 1e-5,权重衰减默认 1e-4,见优化器状态)。
Adam 写成 weight_decay 为 0 的 AdamW,是因为 PyTorch 自己的 Adam 和 AdamW(weight_decay=0) 逐位相同:
step loss_a loss_b |dloss| max|dparam| where
1 2.20907664 2.20907664 0.00e+00 0.00e+00 fc1.weight[0]
100 0.18017162 0.18017162 0.00e+00 0.00e+00 fc1.weight[0]
(A3 上 torch.optim.Adam 对 torch.optim.AdamW(weight_decay=0),100 步。)candle 的 AdamW(weight_decay: 0.0) 对 torch.optim.Adam,100 步后参数最大差 2.09e-7。PyTorch 的 Adam(weight_decay=λ) 是把 λθ 加进梯度(虚拟环境里 torch 2.15.0 的 _single_tensor_adam 是 grad = grad.add(param, alpha=weight_decay)),再走 Adam 的归一化,和解耦衰减不是一回事,candle 没有它。
逐步对 100 步
A3 的 AdamW,每步记下更新前的 loss 和更新后的 212 个参数,按步比:
step loss_a loss_b |dloss| max|dparam| where
1 2.20907664 2.20907640 2.38e-07 1.49e-08 fc1.weight[7]
2 2.02169561 2.02169561 0.00e+00 2.98e-08 fc1.weight[79]
3 1.86176085 1.86176074 1.19e-07 2.98e-08 fc1.weight[79]
5 1.60629380 1.60629380 0.00e+00 5.96e-08 fc1.weight[53]
10 1.23957324 1.23957324 0.00e+00 5.96e-08 fc1.weight[42]
20 0.94048572 0.94048566 5.96e-08 1.19e-07 fc1.weight[29]
30 0.73502350 0.73502362 1.19e-07 1.19e-07 fc2.weight[62]
40 0.57866812 0.57866812 0.00e+00 1.79e-07 fc2.weight[62]
50 0.46574855 0.46574861 5.96e-08 1.19e-07 fc1.weight[17]
60 0.38534674 0.38534677 2.98e-08 1.79e-07 fc1.weight[33]
70 0.31379634 0.31379640 5.96e-08 2.38e-07 fc2.weight[52]
80 0.25856650 0.25856653 2.98e-08 2.98e-07 fc1.weight[33]
90 0.21576177 0.21576181 4.47e-08 2.38e-07 fc1.weight[33]
100 0.18168497 0.18168503 5.96e-08 2.98e-07 fc1.weight[33]
loss 的差没有随步数变大(第 1 步的 2.38e-7 是 2 附近的一个 ulp)。参数的差从 1.49e-8 涨到 2.98e-7,涨得很慢,而且有升有降(第 50、90 步比前一行小)。Metal 上同一张表第 100 步也是 2.98e-7。
涨的是什么?两边都换成 f64 再跑一遍(PyTorch 侧初值和数据 .to(torch.float64),candle 侧 to_dtype(DType::F64),都从同一组 f32 值出发)。下表第一列是上面那一列,后三列是 PyTorch f32 离自己的 f64 运行、candle f32 离自己的 F64 运行、两份 f64 运行之间的最大参数差:
step max|dparam| torch-f64 candle-f64 f64-f64
1 1.49e-08 2.83e-08 2.83e-08 5.55e-17
2 2.98e-08 5.93e-08 5.93e-08 5.55e-17
3 2.98e-08 7.62e-08 7.62e-08 5.55e-17
5 5.96e-08 1.59e-07 1.59e-07 1.11e-16
10 5.96e-08 2.71e-07 2.71e-07 4.44e-16
20 1.19e-07 3.41e-07 3.41e-07 8.33e-16
30 1.19e-07 4.77e-07 4.77e-07 1.33e-15
40 1.79e-07 6.81e-07 7.11e-07 1.89e-15
50 1.19e-07 7.36e-07 7.92e-07 2.11e-15
60 1.79e-07 8.23e-07 8.59e-07 2.72e-15
70 2.38e-07 1.01e-06 9.48e-07 3.11e-15
80 2.98e-07 1.31e-06 1.31e-06 2.78e-15
90 2.38e-07 1.59e-06 1.59e-06 2.89e-15
100 2.98e-07 1.74e-06 1.74e-06 2.89e-15
- 两份 f64 运行 100 步只差 2.89e-15:两边的 AdamW 是同一个公式,每一步的更新量相同。
- 每一边的 f32 运行离自己的 f64 解都涨到 1.74e-6,这是 f32 舍入随步数的累积。
- 两边 f32 之间却只差 2.98e-7。表里前 30 步「torch-f64」和「candle-f64」两列相同:逐个参数查,两边离 f64 最远的是同一个参数,带符号的误差到第 30 步仍然相同到 1e-15(第 1 步都是
fc2.weight[20]的 -2.828273576449547e-08)。两边的 f32 运行在大部分运算上做了同样的舍入,彼此之间的差只是这份共同误差之外的那一点。
SGD(学习率 0.1)同样拆一遍,第 100 步是 1.19e-7、3.80e-7、4.39e-7、2.22e-16,结论相同。
和 burn 卷比:同一个设置
burn 卷里 Adam 对 PyTorch 的差更大,而且是系统性的:A1 的 MLP 训练 10 步差 4.77e-7,一维小 CNN 训练 100 步差 3.52e-6,两份 f64 运行之间也差 3.78e-7 和 3.71e-6。原因是 burn 在 f32 里算偏差修正因子(优化器状态、同一个小模型训练到收敛)。A3 和 burn 卷的模型、损失、优化器都不同,数字不能直接比。所以这里把 burn 卷的两个设置原样在 candle 上跑:同样的模型、初值、数据、批次顺序,交叉熵,Adam 学习率 0.01、betas (0.9, 0.999)、ε 1e-8,candle 侧是 AdamW 配 weight_decay: 0.0。PyTorch 侧打印的 loss 和 burn 卷那两页 PyTorch 一列相同(例如 A1 十步从 1.14005756 到 0.88104737,CNN 第 100 步 0.07264095)。
A1,10 步:
step loss_a loss_b |dloss| max|dparam| where
1 1.14005756 1.14005744 1.19e-07 1.49e-08 fc1.weight[5]
2 1.10198629 1.10198629 0.00e+00 2.98e-08 fc1.weight[10]
3 1.06721914 1.06721926 1.19e-07 2.98e-08 fc1.weight[10]
4 1.03404224 1.03404224 0.00e+00 2.98e-08 fc1.weight[10]
5 1.00236571 1.00236583 1.19e-07 2.98e-08 fc1.weight[10]
6 0.97519988 0.97519994 5.96e-08 5.96e-08 fc2.weight[19]
7 0.94985896 0.94985902 5.96e-08 5.96e-08 fc2.weight[19]
8 0.92572045 0.92572045 0.00e+00 5.96e-08 fc2.weight[19]
9 0.90281999 0.90281993 5.96e-08 5.96e-08 fc1.weight[13]
10 0.88104737 0.88104743 5.96e-08 5.96e-08 fc1.weight[13]
两份 f64 运行之间第 10 步差 4.44e-16。
一维 CNN,100 步(Conv1d(1→4, kernel 5) → ReLU → 展平 → Linear(48→3),每批 16 条,6 个批轮流用;卷积写成 conv2d,原因见下一节):
step loss_a loss_b |dloss| max|dparam| where
1 1.13687289 1.13687289 0.00e+00 8.27e-07 fc.weight[67]
2 1.11771631 1.11771631 0.00e+00 8.49e-07 fc.weight[67]
5 1.08280766 1.08280766 0.00e+00 8.08e-07 fc.weight[67]
10 1.03940225 1.03940225 0.00e+00 7.64e-07 fc.weight[67]
20 0.97808975 0.97808981 5.96e-08 7.72e-07 fc.weight[67]
30 0.83425587 0.83425605 1.79e-07 7.62e-07 fc.weight[67]
40 0.69612402 0.69612426 2.38e-07 7.45e-07 fc.weight[67]
50 0.58540988 0.58541012 2.38e-07 7.53e-07 fc.weight[67]
60 0.38402307 0.38402340 3.28e-07 7.75e-07 fc.weight[67]
70 0.24789160 0.24789184 2.38e-07 8.05e-07 fc.weight[67]
80 0.17158426 0.17158438 1.19e-07 7.60e-07 fc.weight[67]
90 0.10003939 0.10003950 1.12e-07 7.45e-07 fc.weight[67]
100 0.07264095 0.07264101 5.96e-08 7.75e-07 fc.weight[47]
step max|dparam| torch-f64 candle-f64 f64-f64
1 8.27e-07 4.91e-07 3.36e-07 2.43e-16
10 7.64e-07 4.66e-07 2.98e-07 2.78e-16
50 7.53e-07 4.48e-07 3.04e-07 2.39e-15
100 7.75e-07 4.80e-07 5.64e-07 3.72e-15
(第二张表只摘了四行,其余各步同一量级。)
| burn 对 PyTorch | candle 对 PyTorch | |
|---|---|---|
| A1 Adam 10 步,f32 参数最大差 | 4.77e-7 | 5.96e-8 |
| A1 Adam 10 步,两份 f64 之间 | 3.78e-7 | 4.44e-16 |
| CNN Adam 100 步,f32 参数最大差 | 3.52e-6 | 7.75e-7 |
| CNN Adam 100 步,两份 f64 之间 | 3.71e-6 | 3.72e-15 |
- candle 的两份 f64 运行和 PyTorch 只差 f64 的舍入:candle 的 AdamW 每一步的更新量和 PyTorch 相同,没有 burn 那份每步都在攒的系统偏差。
- CNN 第 1 步就差 8.27e-7,之后不涨。这一份在 burn 卷里也有(第 1 步 4.69e-7):差最大的是
fc.weight[67](PyTorch 布局下的fc.weight[1, 19]),一个梯度抵消严重的参数,burn 卷那一页的动脑筋拆过它。PyTorch 的 f32 运行在它上面比 f64 解大 4.91e-7,candle 的小 3.36e-7,方向相反,加起来正好是 8.27e-7。它不随步数涨,100 步仍是 7.75e-7,容差 1e-6 通过;burn 在同一处要放宽到 1e-5。
差别在偏差修正因子用什么精度算。AdamW::step 开头:
self.step_t += 1;
let lr = self.params.lr;
let lambda = self.params.weight_decay;
let lr_lambda = lr * lambda;
let beta1 = self.params.beta1;
let beta2 = self.params.beta2;
let scale_m = 1f64 / (1f64 - beta1.powi(self.step_t as i32));
let scale_v = 1f64 / (1f64 - beta2.powi(self.step_t as i32));
ParamsAdamW 的字段都是 f64,scale_m、scale_v 在 f64 里算,作为标量乘到张量上时才变成张量的 dtype。PyTorch 在 Python 的 float(f64)里算 1 - beta1 ** step。burn 的 beta_1、beta_2 是 f32 字段,1 − β₂ᵗ 在前几步只有 0.001 到 0.01,f32 里 β₂ᵗ 的舍入落到它上面就成了 ppm 级的相对误差。
反向验证这个解释:在 candle 里照 AdamW::step 的公式手写一个优化器,只把这两个因子改成在 f32 里算((1f32 / (1f32 - b1.powi(t))) as f64,beta 用 black_box 包住),其余不变。先确认因子用 f64 时它和 AdamW 逐位相同(A3 和 CNN 上 100 步都差 0),再把因子换到 f32,在 CNN 上和 PyTorch 比:
step max|dparam| torch-f64 candle-f64 f64-f64
1 7.64e-07 4.91e-07 3.38e-07 6.63e-08
2 8.64e-07 5.05e-07 3.49e-07 1.69e-07
5 6.56e-07 4.82e-07 3.30e-07 5.00e-07
10 1.06e-06 4.66e-07 2.97e-07 1.09e-06
20 2.16e-06 4.95e-07 2.87e-07 2.16e-06
30 3.34e-06 4.84e-07 2.65e-07 3.34e-06
40 4.66e-06 4.64e-07 2.64e-07 4.59e-06
50 6.44e-06 4.48e-07 2.73e-07 6.34e-06
60 8.17e-06 4.61e-07 3.25e-07 7.91e-06
70 9.24e-06 4.66e-07 3.83e-07 9.14e-06
80 1.00e-05 4.36e-07 4.02e-07 9.94e-06
90 1.01e-05 4.45e-07 4.86e-07 1.00e-05
100 1.02e-05 4.80e-07 6.19e-07 1.01e-05
「f64-f64」一列马上开始涨,100 步到 1.01e-5,f32 的差跟着它涨到 1.02e-5。这不是 burn 的原式(burn 的 β 本身也是 f32,两个因子合并的写法也不同),数值不必和 3.71e-6 相同;能说明的是方向:因子一挪到 f32,系统偏差就出现。A3 上同样的改动,第 99 步差到 8.28e-6,第 100 步跳到 2.32e-3:f64 运行里下标 7 的隐藏单元在第 99 步有 16 个样本处在 ReLU 的正半边,改了因子的那份是 17 个,梯度从这里分叉。系统偏差攒到把一个样本推过 ReLU 的拐点,差就不再是舍入量级。
顺带:conv1d 的卷积核梯度是错的
上面的 CNN 先是用 conv1d 写的,第 1 步的 loss 两边相同,更新后的参数就差了 2.00e-2:
step loss_a loss_b |dloss| max|dparam| where
1 1.13687289 1.13687277 1.19e-07 2.00e-02 conv.weight[9]
2 1.11771631 1.12217641 4.46e-03 3.79e-02 conv.weight[7]
100 0.07264095 0.41381985 3.41e-01 1.15e+00 conv.weight[15]
前向对、卷积核的梯度错。缩到最小:batch 2、输出 2 通道,loss = sum(conv1d(x, w) * c):
let x = Tensor::arange(0f32, 12., &dev)?.reshape((2, 1, 6))?;
let w = Var::new(&[[[1f32, -1., 2.]], [[0.5, 0.25, -2.]]], &dev)?;
let c = Tensor::arange(1f32, 17., &dev)?.reshape((2, 2, 4))?;
let loss = (x.conv1d(&w, 0, 1, 1, 1)? * &c)?.sum_all()?;
let g = loss.backward()?;
x = torch.arange(0., 12.).reshape(2, 1, 6)
w = torch.tensor([[[1., -1., 2.]], [[0.5, 0.25, -2.]]], requires_grad=True)
c = torch.arange(1., 17.).reshape(2, 2, 4)
(torch.nn.functional.conv1d(x, w) * c).sum().backward()
loss -84
w.grad Some(Ok([220.0, 256.0, 292.0, 508.0, 608.0, 708.0]))
batch1 w.grad Some(Ok([20.0, 30.0, 40.0, 44.0, 70.0, 96.0]))
loss -84.0
w.grad [340.0, 392.0, 444.0, 484.0, 568.0, 652.0]
batch1 w.grad [20.0, 30.0, 40.0, 44.0, 70.0, 96.0]
只用第一个样本(batch1)时两边相同,两个样本时 candle 错。反向规则 Op::Conv1D 本身没错:卷积核的梯度用一次 conv1d 算,输入是 arg.transpose(0, 1),卷积核是 grad.transpose(0, 1)。batch 和输出通道都大于 1 时,后者不连续。错在前向 conv1d 处理不连续卷积核的那一枝。bin convnc 直接用这两个张量做前向:
xt contiguous true ct contiguous false
both strided [220.0, 256.0, 292.0, 508.0, 608.0, 708.0]
input contiguous [220.0, 256.0, 292.0, 508.0, 608.0, 708.0]
kernel contiguous [340.0, 392.0, 444.0, 484.0, 568.0, 652.0]
both contiguous [340.0, 392.0, 444.0, 484.0, 568.0, 652.0]
卷积核先 contiguous() 就对了。CPU 的 conv1d 里,不连续的卷积核复制成连续的 kernel_c,矩阵乘用的却还是原来的 kernel,再按连续布局去读:
} else {
// Make the kernel contiguous if not already the case.
let mut kernel_c = unsafe {
self.device()
.alloc_uninit(kernel_l.shape(), kernel.dtype())?
};
kernel.copy_strided_src(&mut kernel_c, 0, kernel_l)?;
let kernel_l = Layout::contiguous_with_offset((1, n, k), kernel_l.start_offset())
.transpose(1, 2)?
.broadcast_as((b, k, n))?;
col.matmul(kernel, (b, m, n, k), &col_l, &kernel_l)?
};
Metal 的 conv1d 是同样的写法,Metal 上 convnc 的输出逐字相同。origin/main(2026-10-29)上两处都没有改。conv2d 在 CPU 上已经换成另一份实现。bin conv2grad 做同样的检查(batch 2、输出 2 通道、2×2 卷积核):
cpu conv2d w.grad Some(Ok([4728.0, 4980.0, 5736.0, 5988.0, 6834.0, 7248.0, 8490.0, 8904.0]))
metal conv2d w.grad Some(Ok([3027.0, 3198.0, 3711.0, 3882.0, 7239.0, 7734.0, 9219.0, 9714.0]))
torch w.grad [4728.0, 4980.0, 5736.0, 5988.0, 6834.0, 7248.0, 8490.0, 8904.0]
(前两行是 conv2grad cpu 和 conv2grad metal,第三行是 PyTorch。)CPU 和 PyTorch 相同;Metal 的 conv2d 有同样的写法,卷积核梯度也错。所以上面的 CNN 在 CPU 上把卷积写成 conv2d:
// 输入 [b, 1, 1, 16],卷积核 [4, 1, 1, 5]
xb.unsqueeze(2)?.conv2d(&p[0].unsqueeze(2)?, 0, 1, 1, 1)?.squeeze(2)?
推理一般不受影响:从文件加载的卷积核是连续的,只有自己把卷积核转置、切片后再传进去才会走到那一枝。训练一维卷积时,CPU 上可以这样绕过去;Metal 上 conv1d 和 conv2d 的卷积核梯度都错,本页没有试绕法。
小结
- 训练循环的对应:
zero_grad没有;let grads = loss.backward()?;opt.step(&grads)?(或backward_step)。优化器构造时拿一组Var,step用Var::set原地写回,参数 id 不变。A3 上 SGD 一步差 2.98e-8,AdamW 100 步差 2.98e-7(CPU 和 Metal 相同,容差 1e-6)。 - 只有
SGD(没有动量)和AdamW;AdamW的默认值和 PyTorch 相同;Adam写成weight_decay: 0.0(PyTorch 的Adam和AdamW(weight_decay=0)逐位相同)。调度、裁剪、参数组都要自己写。 - 逐步对 100 步:两份 f64 运行只差 2.89e-15,f32 的差是两边各自的舍入,而两边的舍入大部分相同,所以彼此之间只差 2.98e-7。
- 在 burn 卷的设置上:A1 十步 5.96e-8(burn 4.77e-7),CNN 百步 7.75e-7(burn 3.52e-6),f64 之间分别 4.44e-16 和 3.72e-15。candle 没有 burn 那份系统偏差,原因是偏差修正因子在 f64 里算;改到 f32 里算,偏差马上出现。
- candle 0.11.0 的
conv1d在卷积核不连续时算错(CPU 和 Metal),反向正好传进一个不连续的卷积核,batch 和输出通道都大于 1 时卷积核梯度是错的;CPU 上改用conv2d可以绕过。