HashMap<String, Linear>: Module is not satisfied
主线位置:训练主线的起点,定义模型:子模块要被
#[derive(Module)]认出来,参数才会进 backward、optimizer step 和存取。
版本:PyTorch 2.15.0(CPU)、burn 0.22.1(Flex 设备,autodiff)
flowchart TB dict["nn.ModuleDict"] --> hm["HashMap 字段"] hm --> e["E0277"] e -->|"照 note 加 skip"| skip["num_params 0"] skip --> silent["不训练 不加载"] dict -->|"具名字段"| named["heads.cls.weight"] dict -->|"Vec 加名字表"| vec["heads.0.weight 要改键名"]
实验代码:PyTorch 侧 dict.py、dict_plain.py,burn 侧 crate pitb-dict 的 bin dict_err、dict(burn =0.22.1,feature std、flex、autodiff、optim、store、safetensors,debug)。平台 M2 Max、macOS 27、rustc 1.99.0。
PyTorch 的写法
多任务模型按名字放几个输出头,PyTorch 用 nn.ModuleDict:
class Heads(nn.Module):
def __init__(self):
super().__init__()
self.heads = nn.ModuleDict({"cls": nn.Linear(8, 3), "reg": nn.Linear(8, 1)})
def forward(self, x):
return {name: head(x) for name, head in self.heads.items()}
heads.cls.weight [3, 8]
heads.cls.bias [3]
heads.reg.weight [1, 8]
heads.reg.bias [1]
num params = 36
saved keys: ['heads.cls.bias', 'heads.cls.weight', 'heads.reg.bias', 'heads.reg.weight']
dict.py 用确定的数值填参数,把 state_dict 存成 heads.safetensors,下面 burn 侧读的就是它。用 ModuleDict 而不是普通 dict,是因为普通 dict 里的层不会被登记(dict_plain.py):
plain dict: num params = 0 | state_dict keys = []
搬到 burn 的现象
直译成 HashMap(bin dict_err):
#[derive(Module, Debug)]
struct Heads {
heads: HashMap<String, Linear>,
}
error[E0277]: the trait bound `HashMap<String, burn::nn::Linear>: Module` is not satisfied
--> src/bin/dict_err.rs:5:10
|
5 | #[derive(Module, Debug)]
| ^^^^^^ the trait `Module` is not implemented for `HashMap<String, burn::nn::Linear>`
|
= note: If `HashMap<String, burn::nn::Linear>` is used as configuration or runtime state in a `#[derive(Module)]` type, mark that field with `#[module(skip)]`.
= help: the following other types implement trait `Module`:
(L0, L1)
(L0, L1, L2)
...
and 124 others
...
error: could not compile `pitb-dict` (bin "dict_err") due to 10 previous errors
编译器给的路只有一条:#[module(skip)]。照做之后能编译,能 forward,能 backward,什么都不报(bin dict 的 skip 模式):
#[derive(Module, Debug)]
struct SkipHeads {
#[module(skip)]
heads: HashMap<String, Linear>,
}
let mut model = SkipHeads { heads: HashMap::from([("cls".to_string(), cls), ("reg".to_string(), reg)]) };
println!("num_params = {}", model.num_params());
match model.load_from(&mut store()) { // 读 heads.safetensors,带 PyTorchToBurnAdapter
Ok(r) => println!("load: Ok\n{r}"),
Err(e) => println!("load: Err\n{e}"),
}
let before = model.heads["cls"].weight.val().detach();
let x = input(&device);
let loss = model.heads["cls"].forward(x.clone()).mean() + model.heads["reg"].forward(x).mean();
let grads = GradientsParams::from_grads(loss.backward(), &model);
println!("GradientsParams::len() = {}", grads.len());
let model = optim.step(0.1, model, grads);
// 打印 cls 的权重变没变,以及 valid() 之后还在不在 autodiff 上
num_params = 0
load: Ok
┌─ Tensor Loading Summary ─────────────────────────
│
│ ✓ Successfully applied: 0 tensors
│ ⊘ Skipped (filtered): 0 tensors
│ ✗ Missing in source: 0 tensors
│ ? Unused in target: 4 tensors
│ ! Errors: 0 errors
│
├─ Unused Tensors (in source but not used by model)
│
│ First 10 unused tensors:
│ • heads.cls.bias
│ • heads.cls.weight
│ • heads.reg.bias
│ • heads.reg.weight
│
└───────────────────────────────────────────────────
GradientsParams::len() = 0
heads[cls].weight unchanged after step: true
after valid(): heads[cls].weight is_autodiff = true
四件事同时发生,没有一个报错:参数个数是 0;读 PyTorch 的权重返回 Ok,一个张量都没用上,两个头还是随机初始化;训练一步没有梯度、权重不动;valid() 之后这两层还在 autodiff 设备上。这和 PyTorch 普通 dict 的「参数 0 个」是同一个坑,区别是 PyTorch 那边有 ModuleDict 可换,burn 这边编译器把人引向了它。
原因
#[derive(Module)] 把认不出的字段类型都当子模块,要求它实现 Module;HashMap 没有实现。0.22.1 里实现了 Module 的容器是 Option<T>、Vec<T>、[T; N] 和元组,没有任何按名字索引的容器。origin/main 在同一个文件里加了 Box<T>(#6059),仍然没有 HashMap / BTreeMap。
E0277 的 note 来自 Module 上的 #[diagnostic::on_unimplemented]。它对所有没实现 Module 的字段说同一句话,写的是配置和运行状态的情形(burn 卷the trait bound X: Module is not satisfied讲的就是那种),不区分字段里装的是不是层。
skip 字段在派生代码里是透明的:num_params、visit、map 不进去(所以没有参数、优化器和 burn-store 看不见它),gen_valid 对它只生成 self.#name.clone(),to_device、train 原样搬过去。所以 valid() 之后里面的 Linear 还在 autodiff 上(上面输出的最后一行),换设备也不会跟着走。
加载返回 Ok 是因为 burn-store 默认只把「模型要、文件里没有」算错(Missing in source);文件里多出来的(Unused in target)只记在报告里。机制见导入 PyTorch 权重。
改法
每个头一个具名字段
头的名字在写代码时就知道,就写成结构体字段。路径是字段名用 . 连起来,heads.cls.weight 和 ModuleDict 的键一模一样,PyTorch 的权重直接能读:
#[derive(Module, Debug)]
struct Named {
cls: Linear,
reg: Linear,
}
#[derive(Module, Debug)]
struct NamedHeads {
heads: Named,
}
num_params = 36
load: Ok
┌─ Tensor Loading Summary ─────────────────────────
│
│ ✓ Successfully applied: 4 tensors
│ ⊘ Skipped (filtered): 0 tensors
│ ✗ Missing in source: 0 tensors
│ ? Unused in target: 0 tensors
│ ! Errors: 0 errors
│
└───────────────────────────────────────────────────
valid() 之后对同一个输入前向,和 PyTorch 的输出比:
cls n= 6 max|diff|=0.00e+00
reg n= 2 max|diff|=1.49e-08
worst=1.49e-08 tol=1e-06 OK
reg 的 1.49e-8 是 8 项点积的求和顺序不同(为什么不是 0 见怎么读这一卷)。
名字在运行期才知道:Vec 加名字表
头的个数和名字来自配置,用 Vec<Linear> 装层,名字另存一张表,表本身标 skip(它确实是配置):
#[derive(Module, Debug)]
struct VecHeads {
heads: Vec<Linear>,
#[module(skip)]
names: Vec<String>,
}
Vec 的路径用下标,heads.0.weight,和 ModuleDict 的 heads.cls.weight 对不上。直接读:
load: Err
Tensor not found:
┌─ Tensor Loading Summary ─────────────────────────
│
│ ✓ Successfully applied: 0 tensors
│ ⊘ Skipped (filtered): 0 tensors
│ ✗ Missing in source: 4 tensors
│ ? Unused in target: 4 tensors
│ ! Errors: 0 errors
│
├─ Missing Tensors (requested by model but not found in source)
│
│ First 10 missing tensors:
│ • heads.0.bias
│ Did you mean: 'heads.reg.bias'?
│ • heads.0.weight
│ Did you mean: 'heads.cls.weight'?
...
这回是硬错误,而且「Did you mean」把 heads.0.bias 猜成了 heads.reg.bias,按相似度猜的建议不能照抄。按名字表的顺序写键名映射,with_key_remapping:
let mut st = store()
.with_key_remapping(r"^heads\.cls\.", "heads.0.")
.with_key_remapping(r"^heads\.reg\.", "heads.1.");
num_params = 36
load: Ok
...
│ ✓ Successfully applied: 4 tensors
...
cls n= 6 max|diff|=0.00e+00
reg n= 2 max|diff|=1.49e-08
worst=1.49e-08 tol=1e-06 OK
forward 里按名字取下标:model.heads[model.names.iter().position(|n| n == "cls")...]。
怎么检查
- 定义完模型先打印
num_params(),和 PyTorch 的sum(p.numel() for p in model.parameters())比(BatchNorm 会多算运行统计,见参数与遍历)。 - 只在装配置或运行状态的字段上用
#[module(skip)];字段类型里出现Linear、Conv2d这类层,或者Param、Tensor,就不该 skip。 - 读完权重打印
load_from返回的报告,Unused in target不是 0 就说明有权重没用上,Ok不代表读进去了。 - 训练第一步打印
GradientsParams::len(),和参数张量的个数比。
小结
- burn 0.22.1 没有
ModuleDict的对应,HashMap<String, Linear>字段报 E0277,note 建议#[module(skip)]。 - 照做后
num_params为 0,读权重返回Ok但 4 个张量都没用上,训练不更新,valid()不把它移出 autodiff。 - 名字固定:每个头一个具名字段,键名和 PyTorch 相同,输出最大差 1.49e-8。
- 名字来自配置:
Vec<Linear>加#[module(skip)] names,加载时用with_key_remapping把heads.cls.映射成heads.0.。