missing field dropout
主线位置:训练主线开始之前,从文件读回实验配置、按配置建模型。
#[derive(Config)]生成的Deserialize和new是两条互不相干的路,详见 Config 的「存取:JSON 和缺字段」。serde 这一侧的规则(哪些字段可以缺)见 serde 卷的 missing fieldx。
flowchart TB attr["config default = 0.1"] --> newfn["new 里写 dropout: 0.1"] attr -.->|"不传给"| shadow["影子结构体 TrainConfigSerde"] json["旧 train.json 无 dropout"] --> shadow shadow --> errn["missing field dropout"]
实验 crate exp-burn/pb-pit,bin config(和故意编译失败的 config_serde),debug,M2 Max、macOS 27、rustc 1.99.0。
现象
上一版的训练配置存成了 train.json。这一版加了一个带默认值的 dropout 字段,旧文件就读不进来了:
// Version 1, as it was when the experiment was saved.
#[derive(Config, Debug)]
struct TrainConfigV1 {
hidden: usize,
#[config(default = 1e-3)]
lr: f64,
}
// Version 2: a new field with a default.
#[derive(Config, Debug)]
struct TrainConfig {
hidden: usize,
#[config(default = 1e-3)]
lr: f64,
#[config(default = 0.1)]
dropout: f64,
}
TrainConfigV1::new(64).save("out/cfg/train.json").unwrap();
println!("{}", std::fs::read_to_string("out/cfg/train.json").unwrap());
println!("TrainConfig::load = {:?}", TrainConfig::load("out/cfg/train.json"));
{
"hidden": 64,
"lr": 0.001
}
TrainConfig::load = Err(InvalidFormat("missing field `dropout` at line 4 column 1"))
字段上明明写了默认值,错误却说缺字段。实验目录里攒了一堆旧配置的时候,每加一个超参数就会把它们全部作废。
出处
Config::load 读文件后调 serde_json::from_str,用的是派生宏生成的 Deserialize。gen_deserialize_fn 在函数体里定义一个影子结构体(TrainConfigSerde),对它 #[derive(Deserialize)],再把字段逐个搬回 TrainConfig;影子结构体由 gen_serde_struct 生成,只有字段名和类型:
fn gen_serde_struct(&self, names: &[TokenStream]) -> TokenStream {
let struct_name = self.serde_struct_ident();
quote! {
struct #struct_name {
#(#names),*
}
}
}
#[config(default = 0.1)] 只在生成 new 的时候用到(new 的函数体里写下 dropout: 0.1),影子结构体上没有 #[serde(default)],于是 serde 的规则生效:非 Option 的字段缺了就是 missing field。字面上是 Option<T> 的字段 serde 会补 None,这是唯一的例外。
顺手想在字段上补一个 #[serde(default)],过不了编译,因为 TrainConfig 本身没有派生 serde 的宏,属性也不会被搬到影子结构体上(bin config_serde):
error: cannot find attribute `serde` in this scope
--> src/bin/config_serde.rs:6:5
|
6 | #[serde(default)]
| ^^^^^
|
= note: `serde` is an attribute that can be used by the derive macros `Deserialize`, `Schema` and `Serialize`, you might be missing a `derive` attribute
= note: `serde` is in scope, but it is a crate, not an attribute
error: could not compile `pb-pit` (bin "config_serde") due to 1 previous error
exit=101
修法
两种,取决于能不能改字段类型。
新字段声明成 Option,在使用处补默认值:
// Version 2 written to stay compatible: the new field is an Option.
#[derive(Config, Debug)]
struct TrainConfigCompat {
hidden: usize,
#[config(default = 1e-3)]
lr: f64,
dropout: Option<f64>,
}
let compat = TrainConfigCompat::load("out/cfg/train.json").unwrap();
println!("TrainConfigCompat::load = {compat:?}");
println!("dropout used = {}", compat.dropout.unwrap_or(0.1));
字段类型不动,读之前用当前版本的默认值把 JSON 补齐。Config 实现了 Serialize,用一个 new(..) 出来的实例当模板,只补文件里没有的键:
let mut value: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string("out/cfg/train.json").unwrap()).unwrap();
let defaults = serde_json::to_value(TrainConfig::new(0)).unwrap();
for (k, v) in defaults.as_object().unwrap() {
value.as_object_mut().unwrap().entry(k.clone()).or_insert(v.clone());
}
println!("merged with defaults = {:?}", TrainConfig::load_binary(value.to_string().as_bytes()));
TrainConfigCompat::load = TrainConfigCompat { hidden: 64, lr: 0.001, dropout: None }
dropout used = 0.1
merged with defaults = Ok(TrainConfig { hidden: 64, lr: 0.001, dropout: 0.1 })
第二种写法里,必填字段(hidden)的模板值(这里是 0)只在文件缺它时才会被用上;旧文件一定有必填字段,所以不会混进去。这段补齐的代码要写在项目里,burn 不提供。
怎么确认没中招
- 仓库里保留一份旧版本的配置文件做测试数据,每次改配置结构体都用新代码
load一次。 - 给已有的
#[derive(Config)]加字段时,看一眼它是不是Option:不是的话,旧文件一定读不进来。 - 反过来,多出来的键会被悄悄忽略(Config 的实验里
"dropout": 0.1多出来没有报错),删掉的字段不会让旧文件失败,但旧文件里那个值也就无声地丢了。
小结
#[config(default = ..)]只用于new;反序列化走的影子结构体没有 serde 默认值,缺字段就报missing field。- 不能在字段上补
#[serde(default)],编译不过。 - 需要兼容旧文件的新字段写成
Option,或在读之前用当前默认值补齐 JSON。