参数与遍历
主线位置:训练主线 backward 和 optimizer step 之间:优化器要找到每个参数和它的梯度,冻结的参数要被跳过。
版本:PyTorch 2.15.0(CPU)、burn 0.22.1(Flex 设备,autodiff)
flowchart TB
m["模型"] -->|"visit"| v["ModuleVisitor"]
v --> f["visit_float 每个 Param"]
f --> rg{"is_require_grad"}
rg -->|"是"| gp["收下梯度"]
rg -->|"否"| skip["跳过 不更新"]
gp -->|"按 ParamId"| opt["optimizer step"]
实验代码:PyTorch 侧 params.py、freeze.py,burn 侧 crate module-t 的 bin params、freeze、remap、oldleaf(burn =0.22.1,feature std、flex、ndarray、autodiff、optim,debug)。模型是主线的 MLP,权重同 A1,建在 Device::flex().autodiff() 上。平台 M2 Max、macOS 27、rustc 1.99.0。
named_parameters 对一个访问者
PyTorch 的 named_parameters() 是一个生成器,走的是 __init__ 时登记的 _parameters 和 _modules:
model = a1_model()
for name, p in model.named_parameters():
print(f"{name:16} {str(list(p.shape)):8} requires_grad={p.requires_grad}")
print("sum numel =", sum(p.numel() for p in model.parameters()))
burn 没有现成的「列出参数名」的方法,Module 上的遍历入口是 visit,它接一个 ModuleVisitor。派生代码进出每个字段时调 enter_module / exit_module,遇到浮点参数调 visit_float,自己维护一个路径栈就能拼出名字:
#[derive(Default)]
pub struct Named {
path: Vec<String>,
pub count: usize,
}
impl ModuleVisitor for Named {
fn enter_module(&mut self, name: &str, _container_type: &str) {
self.path.push(name.to_string());
}
fn exit_module(&mut self, _name: &str, _container_type: &str) {
self.path.pop();
}
fn visit_float<const D: usize>(&mut self, param: &Param<Tensor<D>>) {
let shape = param.lazy_shape();
self.count += shape.num_elements();
println!(
"{:16} {:8} require_grad={:5} id={}",
self.path.join("."),
format!("{:?}", shape.as_slice()),
param.val().is_require_grad(),
param.id
);
}
}
let model = a1_model(&device);
let mut named = Named::default();
model.visit(&mut named);
println!("visited = {} | num_params = {}", named.count, model.num_params());
fc1.weight [8, 4] requires_grad=True
fc1.bias [8] requires_grad=True
fc2.weight [3, 8] requires_grad=True
fc2.bias [3] requires_grad=True
sum numel = 67
fc1.weight [4, 8] require_grad=true id=6d51aj5v5j8so
fc1.bias [8] require_grad=true id=gjlsmvqg8gitq
fc2.weight [8, 3] require_grad=true id=9onfnu8bfufpc
fc2.bias [3] require_grad=true id=met1ro0600qce
visited = 67 | num_params = 67
- 名字一样。 路径是字段名用
.连起来,Vec元素用下标,Option不占一级(所以是fc1.bias而不是fc1.bias.Some)。这条路径也是存储记录里的键名,从 PyTorch 导入权重时键名能直接对上,靠的就是它(见导入 PyTorch 权重)。 - 形状转置。
Linear的权重是[in, out](见定义模型)。 - 总数一样。
num_params本身也是一个访问者,加的是lazy_shape().num_elements(),不会触发懒参数的初始化。 - 参数靠 id 认,不靠对象身份。 PyTorch 的优化器拿着
Parameter对象本身,梯度挂在对象的.grad上。burn 的Param带一个ParamId(随机的 64 位整数,打印成 base32,每次运行都不同),GradientsParams按它存梯度,优化器按它存动量(机制见 burn 卷 Module 派生的「ParamId」)。
想按 PyTorch 的方式一次拿到 (名字, 张量) 列表,把 println! 换成往 Vec 里推就行;访问者只读(&Param),要改参数用对应的 ModuleMapper。
不是参数的状态
PyTorch 把「随模型保存、但不训练」的张量叫 buffer,和参数分开列:
bn = nn.BatchNorm1d(8)
print("BatchNorm1d(8) params =", sum(p.numel() for p in bn.parameters()),
"| buffers =", [(n, list(b.shape)) for n, b in bn.named_buffers()])
BatchNorm1d(8) params = 16 | buffers = [('running_mean', [8]), ('running_var', [8]), ('num_batches_tracked', [])]
burn 的 BatchNorm 把运行均值和方差存在 RunningState 里,gamma 和 beta 是普通的 Param。访问者分不分得出这两类?
BatchNormConfig::new(8).init(&device).num_params() 是多少?PyTorch 的 parameters() 是 16。burn 侧把同一个 Named 访问者用在 BatchNorm(8) 上,再打印 num_params()(bin params 的后半段)。是 16,还是别的数?
num_batches_tracked 在 burn 里没有对应:BatchNormConfig 只有 num_features、epsilon、momentum 三个字段,没有 PyTorch momentum=None 那种累计平均。从 PyTorch 导入 BatchNorm 时,这个键在 burn 模型里没有对应的位置,多余的键怎么处理见导入 PyTorch 权重。
冻结参数
PyTorch 冻结一部分参数,是对它们调 requires_grad_(False),优化器照常拿全部参数,冻结的那些没有梯度,step 不动它们。主线 MLP 冻结 fc1,训练一步:
model = a1_model()
model.fc1.requires_grad_(False)
opt = torch.optim.SGD(model.parameters(), lr=0.1)
loss = nn.functional.cross_entropy(model(x), y)
opt.zero_grad()
loss.backward()
print({n: (None if p.grad is None else list(p.grad.shape)) for n, p in model.named_parameters()})
fc1_before = model.fc1.weight.clone()
opt.step()
print("fc1 unchanged:", torch.equal(model.fc1.weight, fc1_before))
model.eval(); model.train()
print("after eval()/train():", {n: p.requires_grad for n, p in model.named_parameters()})
burn 有两种写法:对子模块字段调 Module::no_grad,或者在整个模型上按路径选参数,set_require_grad_group 配 ParamGroup::from_predicate(路径里包含这个子串就算):
let mut model = a1_model(&device);
match how.as_str() {
"field" => model.fc1 = model.fc1.no_grad(),
"group" => model = model.set_require_grad_group(ParamGroup::from_predicate("fc1"), false),
_ => unreachable!("field or group"),
}
println!("{how}: require_grad [fc1, fc2] = {:?}", require(&model));
let grads = ce.forward(model.forward(x), y).backward();
println!("fc1 grads {:?} | fc2 grads {:?}", grad_shape(&model.fc1, &grads), grad_shape(&model.fc2, &grads));
let grads = GradientsParams::from_grads(grads, &model);
println!("GradientsParams::len() = {}", grads.len());
let fc1_before = model.fc1.weight.val();
let mut optim = SgdConfig::new().init();
model = optim.step(0.1, model, grads);
let same: bool = fc1_before.equal(model.fc1.weight.val()).all().into_scalar();
println!("fc1 unchanged: {same}");
println!("after valid().train(): require_grad [fc1, fc2] = {:?}", require(&model.valid().train()));
{'fc1.weight': None, 'fc1.bias': None, 'fc2.weight': [3, 8], 'fc2.bias': [3]}
fc1 unchanged: True
after eval()/train(): {'fc1.weight': False, 'fc1.bias': False, 'fc2.weight': True, 'fc2.bias': True}
field: require_grad [fc1, fc2] = [false, true]
fc1 grads (None, None) | fc2 grads (Some([8, 3]), Some([3]))
GradientsParams::len() = 2
fc1 unchanged: true
after valid().train(): require_grad [fc1, fc2] = [false, true]
group 那次的输出和 field 逐行相同。更新后的四组参数两边对比(两种写法各对比一次,结果相同):
params_after.fc1.weight n= 32 max|diff|=0.00e+00
params_after.fc1.bias n= 8 max|diff|=3.73e-09
params_after.fc2.weight n= 24 max|diff|=1.49e-08
params_after.fc2.bias n= 3 max|diff|=9.31e-10
worst=1.49e-08 tol=1e-06 OK
- 冻结的参数两边都没有梯度:PyTorch 是
.grad is None,burn 是grad(&grads)返回None,GradientsParams只收了fc2的 2 个。GradientsParams::from_grads的访问者在visit_float里先看param.is_require_grad(),不要梯度的直接跳过,优化器就不碰它。 fc1.bias冻结了还有 3.73e-9 的差:这是初始值本身的差。PyTorch 侧的0.01 * o先在 f64 里算再转 f32,burn 侧在 f32 里乘,最后一位不同(差在第 6 个元素 0.05 上,0.05 附近 f32 的一个 ulp 正好是 3.73e-9);它没参与更新,差就原样留着。- 冻结两边都是持久的:PyTorch 的
eval()/train()不碰requires_grad;burn 的no_grad改的是参数里记着的「配置上要不要训练」(is_active),valid().train()恢复的正是它,所以fc1仍然不要梯度。解冻用set_require_grad(true)或unfreeze()。 - 写法上的差别:PyTorch 原地改,
model.fc1还是那个对象;burn 的no_grad按值拿走子模块、返回新的,要赋回字段(model.fc1 = model.fc1.no_grad()),整模型的版本要赋回model。
no_grad 只管梯度。同名的 PyTorch torch.no_grad() 是关掉记图的作用域,burn 里对应的是 valid() 和不带 autodiff 的设备(见不求导的区域)。还有一个 freeze:参数不要梯度,同时关掉 dropout、BatchNorm 这些层的训练行为,相当于 PyTorch 里对子模块 requires_grad_(False) 再 eval(),见训练和推理模式。
no_grad 曾经也关 dropoutPR #5498(「feat(module): generalize Param with module-owned flags」)给 Dropout、BatchNorm 加上 training: Param<Flag> 时,Module::no_grad 的文档是「Each tensor in the module tree will not require grad, and each layer with no tensor of its own — dropout, noise, randomized activations — stops behaving as it does during training.」Dropout 字段的文档写的是「Cleared by no_grad and matching freeze_group traversals」。一周后的 PR #5537(「feat(module): separate gradient control from module freezing」)把两件事拆开:no_grad 改成「This is equivalent to set_require_grad(false). Module-owned control flags are left unchanged」,并写明「A later train keeps gradients disabled; it does not undo no_grad」;关掉训练行为的部分交给新的 freeze,Dropout 文档里的 no_grad 换成了 freeze。同一个 PR 的提交列表里还有一条「Fix RunningState param: not an optimizer target」。两个 PR 首次发布都在 v0.22.0-pre.4,所以「no_grad 关 dropout」只存在于两次提交之间,没有进过任何发布版本。
改参数的值
PyTorch 改参数的值(剪枝、缩放、手工初始化)通常是 with torch.no_grad(): 里原地改:
opt = torch.optim.Adam(model.parameters(), lr=0.1)
before = id(model.fc1.weight)
with torch.no_grad():
model.fc1.weight.mul_(0.5)
print("same object after mul_:", id(model.fc1.weight) == before,
"| still in optimizer:", any(p is model.fc1.weight for g in opt.param_groups for p in g["params"]))
new = nn.Parameter(model.fc1.weight * 0.5)
print("nn.Parameter(w * 0.5): is_leaf =", new.is_leaf, "requires_grad =", new.requires_grad)
same object after mul_: True | still in optimizer: True
nn.Parameter(w * 0.5): is_leaf = True requires_grad = True
还是同一个对象,优化器里的状态跟着它。新建一个 nn.Parameter 也没问题,它自己就是叶子。burn 没有原地改,只能换掉 Param,有两个入口,结果不一样:
let old = model.fc1.weight.id;
model.fc1.weight = model.fc1.weight.map(|w| w * 0.5);
println!("map: id {old} -> {}", model.fc1.weight.id);
let old = model.fc1.weight.id;
model.fc1.weight = Param::from_tensor((model.fc1.weight.val() * 0.5).detach());
println!("from_tensor: id {old} -> {}", model.fc1.weight.id);
// 不 detach:w * 0.5 是图里的中间结果
let w = model.fc1.weight.val();
let r = std::panic::catch_unwind(|| Param::from_tensor(w * 0.5));
println!("from_tensor(w * 0.5) without detach: {}", if r.is_ok() { "ok" } else { "panicked" });
map: id gjncvcpccv0p8 -> gjncvcpccv0p8
from_tensor: id gjncvcpccv0p8 -> ad7sarf0usrfo
thread 'main' (8622099) panicked at .../burn-autodiff-0.22.1/src/tensor.rs:190:17:
Can't convert a non leaf tensor into a tracked tensor
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
from_tensor(w * 0.5) without detach: panicked
Param::from_tensor生成一个新的ParamId。在优化器建好、跑过几步之后这样换,Adam 存的动量按旧 id 记着,新参数从零开始(优化器状态见优化器状态)。它还对传进来的张量调require_grad(),在 autodiff 设备上,w * 0.5是带历史的中间结果,于是 panic,信息和打开求导里对中间结果require_grad()的那条一样。nn.Parameter会自己切断历史,from_tensor不会,要先detach()。Param::map保住 id,但不管新值是不是叶子。
map 不报错,所以更要看结果能不能训练(bin remap,三种写法各建一个新模型,前向、backward、GradientsParams::from_grads):
let variants: [(&str, fn(Param<Tensor<2>>) -> Param<Tensor<2>>); 3] = [
("map(w * 0.5)", |p| p.map(|w| w * 0.5)),
("map((w * 0.5).detach())", |p| p.map(|w| (w * 0.5).detach())),
("map((w * 0.5).detach().require_grad())", |p| p.map(|w| (w * 0.5).detach().require_grad())),
];
map(w * 0.5) is_tracked=true is_require_grad=false GradientsParams::len()=3
map((w * 0.5).detach()) is_tracked=false is_require_grad=false GradientsParams::len()=3
map((w * 0.5).detach().require_grad()) is_tracked=true is_require_grad=true GradientsParams::len()=4
前两种写法,fc1.weight 都不再是要梯度的叶子,GradientsParams 只有 3 个,这个参数从此不会被更新,也不报错。第一种的梯度沿着 * 0.5 流回了旧的叶子,而旧叶子已经不在模型里(bin oldleaf:先留住旧的 weight.val(),map(w * 0.5) 之后 backward,旧句柄取到 Some([4, 8]),新参数取到 None)。只有第三种和 PyTorch 的 with torch.no_grad(): w.mul_(0.5) 等价:id 不变、值变了、仍是要梯度的叶子。burn 的优化器自己换参数时做的是同一件事:在内层后端上算出新值,ParameterContext::restore 用 Tensor::from_inner 抬回 autodiff,原来要梯度的再 require_grad()。
动脑筋答案
let bn = BatchNormConfig::new(8).init(&device);
let mut named = Named::default();
bn.visit(&mut named);
println!("BatchNorm(8) num_params = {}", bn.num_params());
gamma [8] require_grad=true id=9ft6hq8vh04n8
beta [8] require_grad=true id=8ddgm7n91esna
running_mean [8] require_grad=false id=u8lofguf05e7g
running_var [8] require_grad=false id=m2497vq8m9tfm
BatchNorm(8) num_params = 32
- 是 32,不是 16。
RunningState的visit把当前值包成一个临时的Param,照样调visitor.visit_float,所以访问者分不出它和gamma,num_params把运行统计也算进去了。拿num_params()和 PyTorch 的sum(p.numel() for p in model.parameters())对数时,模型里每个 BatchNorm 会多出2 × num_features。 - 区分的办法是
require_grad:运行统计永远是false。GradientsParams正是靠这一点跳过它们,优化器不会更新运行统计。 - 访问者也看不到
Dropout、BatchNorm的training标志:它们走visit_flag,Named没有覆盖这个方法。
小结
named_parameters()对一个十几行的ModuleVisitor:路径名和 PyTorch 相同,形状转置,总数用num_params()。num_params()把 BatchNorm 的running_mean/running_var也算进去(BatchNorm(8)是 32,PyTorch 是 16);require_grad=false是区分它们的标记。- 冻结:
requires_grad_(False)对model.fc1 = model.fc1.no_grad()或set_require_grad_group(ParamGroup::from_predicate("fc1"), false);冻结后训练一步,参数两边最大差 1.49e-8。冻结跨过valid().train()仍然有效。 - 改参数值用
param.map(|w| new.detach().require_grad());少了这两步,参数静默退出训练;Param::from_tensor换了 id,对中间结果直接 panic。