训练和推理模式
主线位置:训练主线里每个 step 之后的评估,和部署主线的推理:同一个模型切到推理行为,dropout 不丢、BatchNorm 用运行统计。
版本:PyTorch 2.15.0(CPU)、burn 0.22.1(Flex 设备)
flowchart TB
layer["层的 forward"] --> flag{"training 开着"}
flag -->|"否"| inf["推理行为"]
flag -->|"是"| dev{"输入在 autodiff 设备"}
dev -->|"否"| inf
dev -->|"是"| tr["训练行为"]
ptflag["PyTorch 只看标志"] -.-> flag
实验代码:PyTorch 侧 dropout.py、bn.py、bn_sub.py、bn_clone.py,burn 侧 crate module-t 的 bin dropout、mixed、bnsub、bn、bnstale(burn =0.22.1,feature std、flex、ndarray、autodiff、optim,debug)。平台 M2 Max、macOS 27、rustc 1.99.0。
eval() 翻标志,valid() 换一个模型
PyTorch 的 Module.eval() 文档字符串说它「has an effect only on certain modules」,等价于 self.train(False),返回 self:递归把每个子模块的 training 设成 False,原地生效。它不管求导,eval() 之后前向照样记图:
print("eval() returns self:", drop.eval() is drop)
model = a1_model().eval()
print("eval() model output requires_grad:", model(x).requires_grad)
eval() returns self: True
eval() model output requires_grad: True
所以 PyTorch 的推理要两件事一起做:model.eval() 加 torch.no_grad()。burn 0.22.1 把它们合成一个调用,而且返回新值:
| PyTorch | burn | 做了什么 |
|---|---|---|
model.eval(),原地 | let m = model.valid();,借用 &self 返回副本 | 关掉 training 标志,参数去掉 autodiff |
model.train(),原地 | let m = m.train();,按值拿走返回新模型 | 恢复标志,参数抬回 autodiff、按配置要梯度 |
子模块 bn.eval() | freeze() 再 set_require_grad(true),见下文 | 只关这个子模块的训练行为 |
Module::valid 的文档开头是「Returns a validation snapshot without autodiff or active training flags」。去掉 autodiff 那一半的效果(不记图、输出是普通张量)见不求导的区域;这一页讲标志那一半。Module::train 和 PyTorch 的 train() 也不完全一样:在普通 Device::flex() 上建的模型调 train(),参数会被抬到 autodiff 上并要梯度(实测见 burn 卷 Module 派生的「train、valid、freeze」);PyTorch 的 train() 只翻标志。
因为 valid() 返回副本,训练循环里的写法是每次评估取一份,训练继续用原来的模型:
let valid_model = model.valid(); // model 还在,接着训练
Dropout:标志之外还看设备
PyTorch 的 nn.Dropout 只看 self.training。p=0.5,对 ones(1, 10000) 前向,数输出里的 0:
torch.manual_seed(0)
drop = nn.Dropout(0.5)
x = torch.ones(1, 10_000)
def show(label):
out = drop(x)
print(f"{label:34} zeros {int((out == 0).sum()):5} max {out.max().item():g} training={drop.training}")
show("fresh (train mode)")
drop.eval(); show("eval()")
drop.train()
with torch.no_grad():
show("train(), inside no_grad")
with torch.inference_mode():
show("train(), inside inference_mode")
fresh (train mode) zeros 5046 max 2 training=True
eval() zeros 0 max 1 training=False
train(), inside no_grad zeros 5086 max 2 training=True
train(), inside inference_mode zeros 4966 max 2 training=True
burn 侧同样的层,换几种状态和输入设备:
fn show(label: &str, drop: &Dropout, input_device: &Device) {
let out = drop.forward(Tensor::<2>::ones([1, 10_000], input_device));
let zeros: i64 = out.clone().equal_elem(0.0).int().sum().into_scalar();
let max: f32 = out.max().into_scalar();
println!("{label:40} zeros {zeros:5} max {max} training={}", drop.training.is_enabled());
}
let plain = Device::flex();
let ad = Device::flex().autodiff();
ad.seed(0);
let drop = DropoutConfig::new(0.5).init();
show("fresh, autodiff input", &drop, &ad);
show("fresh, plain input", &drop, &plain);
show("valid(), autodiff input", &drop.valid(), &ad);
show("valid(), plain input", &drop.valid(), &plain);
show("valid().train(), autodiff input", &drop.valid().train(), &ad);
show("no_grad(), autodiff input", &drop.clone().no_grad(), &ad);
show("freeze(), autodiff input", &drop.clone().freeze(), &ad);
show("train() on fresh, plain input", &drop.clone().train(), &plain);
fresh, autodiff input zeros 5048 max 2 training=true
fresh, plain input zeros 0 max 1 training=true
valid(), autodiff input zeros 0 max 1 training=false
valid(), plain input zeros 0 max 1 training=false
valid().train(), autodiff input zeros 4969 max 2 training=true
no_grad(), autodiff input zeros 4990 max 2 training=true
freeze(), autodiff input zeros 0 max 1 training=false
train() on fresh, plain input zeros 0 max 1 training=true
两边丢弃的规则一样:丢掉约一半(5046、5048),留下的乘 1/(1-p) = 2。数值不同是随机数生成器不同。决定「丢不丢」的条件不一样,Dropout::forward 的第一行是:
if !self.training.is_enabled() || !input.device().is_autodiff() || self.prob == 0.0 {
return input;
}
- 标志:
valid()和freeze()关掉它,train()恢复它,这部分和 PyTorch 的eval()/train()对应。no_grad()不碰标志,所以照常丢弃,和 PyTorch「requires_grad_(False)不影响 dropout」一致。 - 输入设备:标志开着,输入不在 autodiff 设备上,也不丢(
fresh, plain input)。在普通设备上做推理,忘了valid()也不会有 dropout;这在 PyTorch 里没有对应,PyTorch 的no_grad()、inference_mode()都不关 dropout,上面两行照样丢了一半。 - 看的是到达 Dropout 的那个张量,不是批数据。模型在 autodiff 设备上、批数据建在
Device::flex()上,第一层Linear的输出就已经在 autodiff 设备上了(参数在那里),后面的 Dropout 照常丢弃(binmixed:Linear(4, 1000)→BatchNorm→Dropout(0.5),数据在普通设备上时 8000 个输出里 3960 个是 0,BatchNorm 的运行均值也更新了)。只有直接作用在输入上的 Dropout 会因此失效。
对照起来,从 PyTorch 搬过来的几种写法:
| PyTorch | burn | dropout |
|---|---|---|
model.train() 后前向 | 模型和数据在 Device::flex().autodiff() 上,不调 valid() | 丢 |
model.eval() 后前向 | model.valid() 后前向,数据在哪都行 | 不丢 |
model.train() + torch.no_grad() | 没有对应:普通设备上不丢,autodiff 设备上会记图 | PyTorch 丢 |
训练完直接在训练设备上评估、忘了 eval() | 同样会丢,见 burn 卷评估时结果每次不一样 | 两边都丢 |
0.21.0 的 Dropout::forward 只有一个条件:if !B::ad_enabled(&input.device()) || self.prob == 0.0 { return input; },Dropout 结构体里只有 prob。那时「推理」就是把模型换到不带 autodiff 的后端上(valid() 返回内层后端上的另一个类型),没有可以单独翻的标志。PR #5498(「feat(module): generalize Param with module-owned flags」,首次发布于 v0.22.0-pre.4)给 Dropout、Noise、RRelu、BatchNorm 加了 training: Param<Flag>,条件变成上面那一行。新加的注释说明了为什么设备不够用:「The device says a backward is possible at all; the flag says this layer takes part in one, which a subtree frozen in place on the training device does not.」也就是部分微调时,冻结的子树还留在训练设备上,只看设备就分不出它该不该丢。所以「burn 没有 eval(),靠设备决定 dropout」这句话对 0.21 成立,对 0.22 只对一半。
只让一部分层用推理行为
PyTorch 微调时常见的写法:整个模型 train(),再对 BatchNorm 单独 eval(),让它用运行统计、不再更新,但 weight / bias 照样训练。burn 里 freeze 关掉标志的同时也关掉梯度,要再用 set_require_grad(true) 把梯度打开,它只改参数、不碰标志:
net = nn.Sequential(nn.Linear(4, 3), nn.BatchNorm1d(3))
net.train()
net[1].eval() # 只让 BatchNorm 用运行统计,参数照样训练
x = torch.arange(32.0).reshape(8, 4) / 10
net(x).pow(2).sum().backward()
print("bn.weight.grad is None:", net[1].weight.grad is None, "| running_mean =", net[1].running_mean.tolist())
net.train()
print("after net.train(): bn.training =", net[1].training)
net.norm = match how.as_str() {
"freeze" => net.norm.freeze(),
"freeze+grad" => net.norm.freeze().set_require_grad(true),
_ => unreachable!("freeze or freeze+grad"),
};
let x = Tensor::<1, burn::tensor::Int>::arange(0..32, &ad).float().reshape([8, 4]) / 10.0;
let y = net.norm.forward(net.linear.forward(x));
let grads = y.powi_scalar(2).sum().backward();
// 打印 gamma 有没有梯度、running_mean、training 标志
println!("{how:12} after net.valid().train(): training = {}", net.valid().train().norm.training.is_enabled());
bn.weight.grad is None: False | running_mean = [0.0, 0.0, 0.0]
after net.train(): bn.training = True
freeze gamma grad is None: true | running_mean = [0.0, 0.0, 0.0] | training = false
freeze after net.valid().train(): training = false
freeze+grad gamma grad is None: false | running_mean = [0.0, 0.0, 0.0] | training = false
freeze+grad after net.valid().train(): training = false
freeze().set_require_grad(true)和bn.eval()效果相同:运行统计不动,gamma有梯度。只写freeze()则gamma也不训练了。- 持久性相反。PyTorch 的
net.train()递归地把 BatchNorm 也翻回训练模式,每个 epoch 开头调model.train()的循环要记得再bn.eval()一次。burn 的freeze改的是「配置上要不要训练」,train()不会解开它,要恢复用unfreeze()。
BatchNorm 的运行统计
PyTorch 的 BatchNorm1d(3)(momentum=0.1、eps=1e-5)和 burn 的 BatchNormConfig::new(3) 默认值相同,更新公式也相同:running = (1 - momentum) * running + momentum * batch_stat。同样三批数据(每批 4 个样本、3 个特征)在训练模式下前向,再切到推理,对比训练输出、第 1 批和第 3 批之后的运行统计、推理输出:
bn = nn.BatchNorm1d(3)
out["train_out"] = bn(batch(0)).tolist()
out["running_mean_1"] = bn.running_mean.tolist()
out["running_var_1"] = bn.running_var.tolist()
for k in (1, 2):
bn(batch(k))
out["running_mean_3"] = bn.running_mean.tolist()
out["running_var_3"] = bn.running_var.tolist()
bn.eval()
out["eval_out"] = bn(batch(0)).tolist()
let bn = BatchNormConfig::new(3).init(&ad);
let train_out = rows(bn.forward(batch(0, &ad)));
let (m1, v1) = (mean(&bn), vec1(bn.running_var.value_sync()));
for k in 1..3 {
bn.forward(batch(k, &ad));
}
let (m3, v3) = (mean(&bn), vec1(bn.running_var.value_sync()));
let eval_out = rows(bn.valid().forward(batch(0, &plain)));
train_out n= 12 max|diff|=1.19e-07
running_mean_1 n= 3 max|diff|=0.00e+00
running_var_1 n= 3 max|diff|=1.24e-01
running_mean_3 n= 3 max|diff|=0.00e+00
running_var_3 n= 3 max|diff|=1.88e-01
eval_out n= 12 max|diff|=1.52e-01
worst=1.88e-01 tol=1e-06 MISMATCH
训练输出和运行均值对得上(1.19e-7 和 0,前者是归一化里 sqrt 和除法的舍入),运行方差差了 0.12 到 0.19,推理输出跟着差 0.15。第 1 批之后的运行方差:
PyTorch running_var_1 = [1.1664865, 1.3965679, 0.9717974]
burn running_var_1 = [1.0998648, 1.2724260, 0.9538480]
原因在两边各自的文档里。PyTorch BatchNorm1d 的文档字符串:「At train time in the forward pass, the variance is calculated via the biased estimator, equivalent to torch.var(input, correction=0). However, the value stored in the moving average of the variance is calculated via the unbiased estimator, equivalent to torch.var(input, correction=1).」burn 的 ModuleOps::batch_norm_train 的文档说它返回「the returned mean and biased variance — what a training path feeds its running statistics」,BatchNorm::forward_train 把这个有偏方差直接混进运行方差:
let running_var = running_var
.mul_scalar(1.0 - self.momentum)
.add(result.variance.detach().mul_scalar(self.momentum));
归一化两边都用有偏方差,所以训练输出一致;存进运行方差的,PyTorch 是无偏的(除以 n-1),burn 是有偏的(除以 n)。每批 4 个样本,批方差差 4/3 倍:0.9 + 0.1 × 2.6649 = 1.1665,0.9 + 0.1 × 2.6649 × 3/4 = 1.0999。把 PyTorch 侧的运行方差改成用 var(unbiased=False) 手算一遍,和 burn 的输出再对比:
train_out n= 12 max|diff|=1.19e-07
running_mean_1 n= 3 max|diff|=0.00e+00
running_var_1 n= 3 max|diff|=0.00e+00
running_mean_3 n= 3 max|diff|=0.00e+00
running_var_3 n= 3 max|diff|=0.00e+00
eval_out n= 12 max|diff|=0.00e+00
worst=1.19e-07 tol=1e-06 OK
全部对上,差别只在这一处。影响有多大取决于每个通道的样本数 n(批大小乘空间尺寸):批方差项差 n/(n-1) 倍,n 上千时在千分之一以下,全连接层配小批量时最明显。从 PyTorch 导入训练好的运行统计不受影响(存的就是那个数);在 burn 里接着训练或从头训练,运行方差会系统地偏小,推理输出和 PyTorch 训练出来的不一样。origin/main 上这两处没有改。
哪些情形会更新运行统计(bin bn 的后半段,每种一个新层、前向一次第 0 批):
autodiff init, autodiff input running_mean = [0.009881697, 0.09698707, 0.1868625]
autodiff init, plain input running_mean = [0.0, 0.0, 0.0]
plain init, plain input running_mean = [0.0, 0.0, 0.0]
no_grad(), autodiff input running_mean = [0.009881697, 0.09698707, 0.1868625]
freeze(), autodiff input running_mean = [0.0, 0.0, 0.0]
train() + no_grad: running_mean = [0.009882, 0.096987, 0.186862]
BatchNorm::forward 用和 Dropout 一样的两个条件选路:输入在 autodiff 设备上且标志开着,才走训练路径并更新统计。PyTorch 侧最后一行是训练模式下套 torch.no_grad():照样更新,和 burn 在普通设备上「不更新」相反。
不调 valid() 的推理路径会读到旧统计
训练路径的更新经过 RunningState::update:新值先按线程放进一张表,下一次 update 或 value_sync 时才合并(多线程时取平均)。推理路径 forward_inference 读的是 value,文档写着「The current value might be outdated by one update」。valid() 会先同步,所以走 valid() 没有问题;不调 valid()、只把输入换成普通设备,就会少最近一次更新(bin bnstale,训练前向一次后,用同一层推理第 0 批,打印第一行输出):
let bn = BatchNormConfig::new(3).init(&ad);
bn.forward(batch(&ad)); // one training step: updates the running stats
println!("running_mean.value() = {:?}", vec1(bn.running_mean.value()));
println!("plain input, no valid() = {:?}", rows(bn.forward(batch(&plain)))[0]);
println!("running_mean.value_sync() = {:?}", vec1(bn.running_mean.value_sync()));
println!("plain input, after sync = {:?}", rows(bn.forward(batch(&plain)))[0]);
println!("valid(), plain input = {:?}", rows(bn.valid().forward(batch(&plain)))[0]);
running_mean.value() = [0.0, 0.0, 0.0]
plain input, no valid() = [1.6829334, 2.8185809, 2.2822285]
running_mean.value_sync() = [0.009881697, 0.09698707, 0.1868625]
plain input, after sync = [1.6730518, 2.7215943, 2.095367]
valid(), plain input = [1.595291, 2.4127238, 2.14546]
第二行用的还是初始的均值 0 和方差 1;第四行只同步了均值,方差还是旧的;只有 valid() 两个都同步了。结论和 Dropout 一样:评估和推理一律先 valid(),不要靠「输入放在普通设备上」切换行为。
PyTorch 里 copy.deepcopy(bn)(x) 不影响 bn。burn 里:
let bn = BatchNormConfig::new(3).init(&ad);
let copy = bn.clone();
copy.forward(batch(0, &ad));
println!("forward on a clone: original running_mean = {:?}", mean(&bn));
打印的是 [0.0, 0.0, 0.0] 吗?
动脑筋答案
forward on deepcopy: original running_mean = [0.0, 0.0, 0.0]
forward on the same object: running_mean = [0.009882, 0.096987, 0.186862]
forward on a clone: original running_mean = [0.009881697, 0.09698707, 0.1868625]
- 变了,和对原件本身前向一次的结果一样。
RunningState里是Arc<Mutex<...>>,派生出来的Clone只复制Arc,克隆和原件共用同一份运行统计。这是有意的:BatchNorm::forward只拿&self,多线程各自前向时更新要汇到同一处,update再按线程取平均。 - 参数没有这个问题:
Param的值不可变,优化器step返回的是新模型。会被前向改掉的只有运行统计。 - 想要一份独立的 BatchNorm,用
valid()(它新建RunningState)或者重新init再加载记录;不要指望clone()像deepcopy。
小结
model.eval()原地翻标志、不管求导;burn 的valid()返回副本,同时关标志、去 autodiff;train()按值拿走模型,普通设备上的模型也会被抬到 autodiff。- Dropout 和 BatchNorm 的训练行为要「标志开着」且「到达该层的张量在 autodiff 设备上」;PyTorch 只看标志,
no_grad()不关 dropout。 - 子模块
bn.eval()对freeze().set_require_grad(true);burn 的冻结跨过train()仍有效,PyTorch 的train()会把它翻回去。 - BatchNorm 训练输出一致(1.19e-7);运行方差 burn 用有偏估计(除以
n),PyTorch 用无偏估计(除以n-1),4 个样本一批、三批之后推理输出差 0.15。 - 推理先
valid():不调它时 BatchNorm 读到的运行统计少最近一次更新;clone()共用运行统计。