Rust in Everything
基于 burn 0.22.1

评估时结果每次不一样

主线位置:训练主线里一个 step 的前向,换成评估时。Dropout::forward 在三个条件同时成立时才丢弃元素,其中一个看模块的 training 标志,一个看输入所在的设备;两者的来龙去脉见 nn 模块 的「train / valid 怎么影响 Dropout」和 Module 派生 的「train、valid、freeze」。

flowchart TB
  fwd["Dropout forward"] --> flag{"training 开着"}
  flag -->|否| same["原样返回"]
  flag -->|是| dev{"输入在 autodiff 设备"}
  dev -->|否| same
  dev -->|是| drop["随机丢弃 并放大"]
  drop --> diff["两次结果不同"]

实验 crate exp-burn/pb-pit,bin dropout,debug,M2 Max、macOS 27、rustc 1.99.0。

现象

训练脚本里的模型在 Device::flex().autodiff() 上,训练完直接拿它评估。同一个输入前向两次:

#[derive(Module, Debug)]
struct Net {
  linear: Linear,
  dropout: Dropout,
}

impl Net {
  fn forward(&self, x: Tensor<2>) -> Tensor<2> {
    self.dropout.forward(self.linear.forward(x))
  }
}

fn eval(label: &str, net: &Net, device: &Device) {
  let x = Tensor::<2>::ones([1, 4], device);
  let a: Vec<f32> = net.forward(x.clone()).to_data().to_vec().unwrap();
  let b: Vec<f32> = net.forward(x).to_data().to_vec().unwrap();
  println!("{label:<34} {a:?}\n{:<34} {b:?}", "");
}

fn main() {
  let device = Device::flex().autodiff();
  device.seed(3);
  let net = Net {
    linear: LinearConfig::new(4, 4).init(&device),
    dropout: DropoutConfig::new(0.5).init(),
  };
  eval("eval, no valid()", &net, &device);
  let valid = net.valid();
  eval("net.valid(), input on autodiff dev", &valid, &device);
  eval("net.valid(), input on Device::flex()", &valid, &Device::flex());
}
eval, no valid()                   [-0.0, -0.0, 0.0, 0.0]
                                   [-1.3714509, -0.0, 0.0, 0.0]
net.valid(), input on autodiff dev [-0.68572545, -1.1446606, 0.64534307, 0.2881403]
                                   [-0.68572545, -1.1446606, 0.64534307, 0.2881403]
net.valid(), input on Device::flex() [-0.68572545, -1.1446606, 0.64534307, 0.2881403]
                                   [-0.68572545, -1.1446606, 0.64534307, 0.2881403]

没调 valid() 时,两次输出不同:大部分元素被置 0,留下的那个是真实值的两倍(-1.3714509 = 2 × -0.68572545,1 / (1 - 0.5) 的放大)。评估指标因此每跑一次都不一样,而且整体偏离模型的真实表现。valid() 之后两次相同,并且和输入放在哪个设备上无关。

出处

Dropout::forward:

        if !self.training.is_enabled() || !input.device().is_autodiff() || self.prob == 0.0 {
            return input;
        }

        let prob_keep = 1.0 - self.prob;
        let random = input.random_like(Distribution::Bernoulli(prob_keep));
        let x = input * random;

        x * (1.0 / prob_keep)

training 是一个 Param<Flag>,DropoutConfig::init() 建出来时是开的,valid() 和 freeze() 把它关掉。第二个条件看的是这次前向的输入在不在 autodiff 设备上:所以在普通 Device::flex() 或 Metal 上直接推理,即使忘了 valid() 也不会丢弃(nn 模块 的动脑筋答案里,普通 Flex 和普通 Metal 都是 0 个零)。这个坑只在「训练和评估共用 autodiff 设备上的同一个模型」时出现,而手写的训练循环通常正是这样。

同样两个条件也决定 BatchNorm 走哪条路:训练路径用本批的统计量并更新运行均值和方差,评估时如果还走这条路,模型的运行统计会被评估数据改掉。

修法

评估前调 valid(),用它返回的副本做评估,训练继续用原来的模型:

let valid = net.valid();
eval("net.valid(), input on autodiff dev", &valid, &device);

就是上面输出的后两组。valid() 同时去掉了参数的 autodiff;评估数据最好也放在不带 autodiff 的设备上(第三组),否则前向仍会在 autodiff 层登记记录,见 推理时内存一直涨。Learner 的验证阶段用的就是 model.valid() 的副本(见 Learner 与数据)。

只想冻结参数、但让 Dropout 继续起作用(部分微调),用 no_grad() 而不是 freeze():后者连 training 一起关掉。

怎么确认没中招

  • 同一个输入、同一个模型连续前向两次,比较输出是否逐位相同;不同就说明还有随机层在训练状态。
  • 打印 model.dropout.training.val()(实验 valid 在 Module 派生 里打印的就是它):评估时应为 disabled。
  • 评估函数的签名接收的模型最好就是 valid() 之后的那个,而不是训练中的模型引用。

小结

  • Dropout 在 training 标志开着、且输入在 autodiff 设备上时才丢弃;训练完直接在同一个 autodiff 设备上评估,两条都满足。
  • 结果每次不同,留下的值被放大 1 / (1 - p) 倍。
  • 评估用 model.valid() 的副本;BatchNorm 同理。

💬 关联讨论

发起讨论
加载中...