评估时结果每次不一样
主线位置:训练主线里一个 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() 之后两次相同,并且和输入放在哪个设备上无关。
出处
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同理。