Rust in Everything
基于 burn 0.22.1

missing field dropout

主线位置:训练主线开始之前,从文件读回实验配置、按配置建模型。#[derive(Config)] 生成的 Deserialize 和 new 是两条互不相干的路,详见 Config 的「存取:JSON 和缺字段」。serde 这一侧的规则(哪些字段可以缺)见 serde 卷的 missing field x。

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。

💬 关联讨论

发起讨论
加载中...