🏠 总目录📚 本教程 07 · train 和 eval 改了什么 ← →
📑 本页目录(点开跳转)

07 · train() / eval() 到底改了什么

⏱ 68 分钟 | ⭐ 实测:BN 的 running_mean 在【前向】里就更新了 —— no_grad() 拦不住,requires_grad=False 也拦不住


🎯 一句话

model.train() 和 model.eval() 只做一件事:递归地把每个子模块的 self.training 设成 True 或 False。 其余全是各层自己看着这个布尔量分叉。站内讲过 Dropout 在测试时会缩放(ml_md/10 和附录C 第 6 题),但没有一页说过 BatchNorm 的 running_mean 是什么时候被改的 —— 而那正是「模型加载回来指标不对」的最隐蔽的一个来源。


🔘 一、它真的只改了一个布尔

import torch.nn as nn

net = nn.Sequential(nn.Linear(4, 4), nn.BatchNorm1d(4), nn.Dropout(0.5))
print("默认 training =", net.training, [m.training for m in net])
net.eval()
print("eval() 之后   =", net.training, [m.training for m in net])
print("eval() 的返回值是它自己吗:", net.eval() is net)

net.train()
net[1].eval()                                   # ⭐ 可以只关一层
print("只 net[1].eval():", [m.training for m in net])

实跑输出:

要点

默认 training = True [True, True, True]

eval() 之后 = False [False, False, False]

eval() 的返回值是它自己吗: True

只 net[1].eval(): [True, False, True]

四个结论:

⚠️ 注意它没做的事:eval() 不碰梯度。

import torch
import torch.nn as nn

lin = nn.Linear(3, 2)
lin.eval()
y = lin(torch.randn(1, 3))
print("eval() 之后 requires_grad =", y.requires_grad, " grad_fn =", type(y.grad_fn).__name__)

输出 eval() 之后 requires_grad = True grad_fn = AddmmBackward0 —— 图照建,显存照占。 这就是附录 A 那条「验证时两个都要」的原因,第五节会把这两个开关摆成一张表。


🎲 二、Dropout:eval() 下它是恒等函数

import torch
import torch.nn as nn

torch.manual_seed(0)
d = nn.Dropout(p=0.5)
inp = torch.ones(8)
d.train(); print("Dropout train:", d(inp).tolist())
d.eval();  print("Dropout eval :", d(inp).tolist())

实跑输出:

Dropout train: [0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 2.0, 2.0]
Dropout eval : [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]

⭐ 输入全是 1.0,训练模式下活下来的变成了 2.0,不是 1.0。这就是 inverted dropout:训练时除以 $1-p$(这里 $p=0.5$,所以乘 2),推理时零成本原样通过。 数学和 8 行手写实现在附录C 第 6 题,这里不重复。

框架侧要记的只有两条:


📊 三、⭐ BatchNorm:running_mean 是在【前向】里更新的

这是本章的正题,也是站内此前完全没有的一节。

import torch
import torch.nn as nn

bn = nn.BatchNorm1d(2)
x = torch.tensor([[1.0, 2.0], [3.0, 4.0]])
print("初始    running_mean =", bn.running_mean.tolist(), " num =", bn.num_batches_tracked.item())

bn.train()
bn(x)                       # ⭐ 只做前向,没有 backward,没有 optimizer.step()
print("train 前向一次后     =", bn.running_mean.tolist(), " num =", bn.num_batches_tracked.item())

with torch.no_grad():       # ⭐ no_grad 拦不住
    bn(x)
print("no_grad 里再前向一次 =", bn.running_mean.tolist(), " num =", bn.num_batches_tracked.item())

for p in bn.parameters():   # ⭐ 冻结参数也拦不住
    p.requires_grad_(False)
bn(x)
print("requires_grad=False  =", bn.running_mean.tolist(), " num =", bn.num_batches_tracked.item())

bn.eval()
bn(x)
print("eval() 之后前向       =", bn.running_mean.tolist(), " num =", bn.num_batches_tracked.item(), "(不动了)")
print("本批均值 =", x.mean(0).tolist(), " momentum =", bn.momentum)

实跑输出:

训练开关与梯度开关影响不同状态
状态running_meannum / 备注
初始[0.0, 0.0]0
train 前向一次后[0.20000000298023224, 0.30000001192092896]1
no_grad 里再前向一次[0.3799999952316284, 0.5700000524520874]2
requires_grad=False[0.5419999957084656, 0.8130000233650208]3
eval() 之后前向[0.5419999957084656, 0.8130000233650208]3 (不动了)

本批均值 = [2.0, 3.0] momentum = 0.1

⭐ 四条,每一条都是反直觉的:

观察 结论
只调了 bn(x) 就变了 更新发生在 forward 里,不在 backward(),也不在 optimizer.step()
with torch.no_grad(): 里照样变 no_grad 管的是建不建图,不管 buffer 写不写
requires_grad_(False) 之后照样变 running_mean 是 buffer 不是参数,它压根不走梯度那条路
eval() 之后不动了 ⭐ 唯一能停住它的开关就是 eval()

算一下就知道公式:初始 0.0,本批均值 [2.0, 3.0],momentum=0.1:

$$\text{running} \leftarrow (1-m)\cdot\text{running} + m\cdot\text{batch}$$

第一次 $0.9\times0 + 0.1\times2 = 0.2$ ✓;第二次 $0.9\times0.2+0.1\times2 = 0.38$ ✓。

⚠️ PyTorch 的 momentum 和别处的 momentum 是反的:这里 momentum=0.1 表示新 batch 占 10%, 而优化器里的 momentum=0.9 表示历史占 90%。同一个词,两个方向 —— 抄参数的时候特别容易搞反。

num_batches_tracked 是干什么的:它只在 momentum=None 时有用 —— 那时改用累积平均(所有见过的 batch 等权)。

import torch
import torch.nn as nn

bn = nn.BatchNorm1d(1, momentum=None)
bn.train()
for v in [0.0, 10.0, 20.0]:
    bn(torch.tensor([[v], [v]]))
    print(f"喂 {v:5} 后 running_mean = {bn.running_mean.item():.4f}  num = {bn.num_batches_tracked.item()}")

bn2 = nn.BatchNorm1d(1)                     # 默认 momentum=0.1
bn2.train()
for v in [0.0, 10.0, 20.0]:
    bn2(torch.tensor([[v], [v]]))
print("默认 momentum=0.1 喂同样三批:", round(bn2.running_mean.item(), 4))

实跑输出:

累计平均的三次更新
输入running_meannum
0.00.00001
10.05.00002
20.010.00003

默认 momentum=0.1 喂同样三批: 2.9

⭐ 累积平均给的是 (0+10+20)/3 = 10.0,指数滑动只给到 2.9 —— 同样的数据,两种平均差了三倍多。 momentum=None 在「统计量必须准、而 batch 又少」的场景(比如量化前的校准)才用。


🛑 读到这里可以停 —— 前半章讲完了(约 23 分钟)。 后半章还有:事故:忘了 eval() 跑验证,统计量被永久污染 · eval() 和 no_grad() 是两个正交的开关 · 到底哪些层在看 training · 三个最容易漏掉 eval() 的地方 回来的时候不用重读,直接从下一节接着看就行。


💀 四、事故:忘了 eval() 跑验证,统计量被永久污染

⚠️ 本节输出块没固定种子,running_mean 的具体数值每次运行不同(下面是某次实跑);稳定可复现的是「train 下会动、eval 下不动」这两个结论。

前三节合起来会产生一个非常难查的 bug:验证集的分布被写进了模型权重文件。

import torch
import torch.nn as nn

torch.manual_seed(0)
bn = nn.BatchNorm1d(1)
train_x = torch.randn(32, 1)                 # 训练数据 ~ N(0,1)
bn.train()
for _ in range(50):
    bn(train_x)
bn.eval()
probe = torch.tensor([[0.5]])
print("训练完 running_mean =", round(bn.running_mean.item(), 4),
      " → eval(0.5) =", round(bn(probe).item(), 4))

val_x = torch.randn(32, 1) + 10.0            # 验证数据分布偏移
bn.train()                                   # ⚠️ 忘了切回 eval()
with torch.no_grad():                        # ⚠️ 而且包在 no_grad 里,看起来很安全
    bn(val_x)
bn.eval()
print("忘 eval 跑一批验证后 running_mean =", round(bn.running_mean.item(), 4),
      " → eval(0.5) =", round(bn(probe).item(), 4))

实跑输出:

信息关系

训练完 running_mean = 0.0066→eval(0.5) = 0.4827
忘 eval 跑一批验证后 running_mean = 1.0238→eval(0.5) = -0.5091

同一个输入 0.5,输出从 0.4827 变成 -0.5091 —— 换了个号。 而全程:

⚠️⚠️ 站内那条自检技巧对这个 bug 无效。 ml_md/10 给过一句:「同样的输入跑两次,如果输出不同 → 你忘了 eval()」。它对 Dropout 有效,对 BN 完全无效:

import torch
import torch.nn as nn

x = torch.randn(8, 4)
drop = nn.Dropout(0.5).train()
print("Dropout  两次输出相同?", torch.allclose(drop(x), drop(x)))

bn = nn.BatchNorm1d(4).train()
o1 = bn(x); o2 = bn(x)
print("BatchNorm 两次输出相同?", torch.allclose(o1, o2), "  ⚠️ 自检抓不到")
print("          但 running_mean 已经动了:", [round(v, 4) for v in bn.running_mean.tolist()])

实跑输出:

对照

Dropout 两次输出相同? False

BatchNorm 两次输出相同? True ⚠️ 自检抓不到

但 running_mean 已经动了: [-0.0139, 0.0247, -0.0498, 0.1255]

⭐ 原因:train 模式下 BN 用本 batch 的统计量做归一化 —— 同一个 batch 进去,输出当然一样。 变的是 buffer,不是输出。 所以:

⭐ 更靠得住的自检:验证跑完之后断言 model.training is False, 或者更狠一点,记下 bn.num_batches_tracked 在验证前后有没有变。 这个数只增不减,它变了就说明有 batch 在训练模式下走过前向。


🚦 五、eval() 和 no_grad() 是两个正交的开关

它们经常一起出现,于是被当成一回事。实测一下:

import torch
import torch.nn as nn

torch.manual_seed(0)
d = nn.Dropout(0.5)
x = torch.ones(8)
d.train()
with torch.no_grad():
    print("no_grad 里 Dropout 还丢吗:", d(x).tolist())

输出 no_grad 里 Dropout 还丢吗: [0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 2.0, 2.0] —— 照丢不误。

training=True training=False(eval())
建图(默认) 训练:正常反传 ⚠️ 图照建、显存照占,但 Dropout/BN 已切到推理行为
不建图(no_grad()) ⚠️ Dropout 照丢、BN 统计照更新 —— 最危险的一格 ✅ 验证 / 推理应该待的那一格

⭐ 一句话记法:eval() 管「层的行为」,no_grad() 管「要不要建图」。两件事,两个开关,验证时都要。 (no_grad / detach / requires_grad 三者的区别是第 4 章的正题。)


🧯 六、到底哪些层在看 training

别猜,直接测:同一个输入在两种模式下各跑一次,看输出一不一样。

import torch
import torch.nn as nn

cases = {
    "Linear":      (nn.Linear(4, 4),      torch.randn(8, 4)),
    "ReLU":        (nn.ReLU(),            torch.randn(8, 4)),
    "Conv2d":      (nn.Conv2d(1, 1, 3),   torch.randn(2, 1, 5, 5)),
    "LayerNorm":   (nn.LayerNorm(4),      torch.randn(8, 4)),
    "GroupNorm":   (nn.GroupNorm(2, 4),   torch.randn(8, 4, 3, 3)),
    "Dropout":     (nn.Dropout(0.5),      torch.ones(8, 4)),
    "BatchNorm1d": (nn.BatchNorm1d(4),    torch.randn(8, 4)),
    "InstanceNorm1d": (nn.InstanceNorm1d(4), torch.randn(8, 4, 6)),
}
torch.manual_seed(0)
for name, (mod, x) in cases.items():
    mod.train(); a = mod(x)
    mod.eval();  b = mod(x)
    print(f"{name:16} train/eval 输出相同? {torch.allclose(a, b)}")

实跑输出:

对照

Linear train/eval 输出相同? True

ReLU train/eval 输出相同? True

Conv2d train/eval 输出相同? True

LayerNorm train/eval 输出相同? True

GroupNorm train/eval 输出相同? True

Dropout train/eval 输出相同? False

BatchNorm1d train/eval 输出相同? False

InstanceNorm1d train/eval 输出相同? True

⭐ 只有两类层在乎 training:Dropout 家族,和「带 running stats 的归一化」家族。

顺带一条只在 train 模式出现的报错:

import torch
import torch.nn as nn

bn = nn.BatchNorm1d(2)
bn.train()
bn(torch.tensor([[1.0, 2.0]]))          # batch = 1
ValueError: Expected more than 1 value per channel when training, got input size torch.Size([1, 2])

⭐ 一个样本算不出方差。eval() 下同样的输入完全正常(用的是 running stats)。 💀 典型触发点:drop_last=False 时最后一个 batch 恰好只剩 1 条 —— 训练跑了几百步之后突然崩,而你什么都没改。


📋 七、三个最容易漏掉 eval() 的地方

场景 漏了会怎样 怎么写
冻结主干微调 ⚠️ requires_grad_(False) 只冻参数,冻不住 BN 统计量(第三节实测)→ 主干的 running stats 被新数据带跑,预训练的好处被吃掉一半 参数冻结之后再补一句 backbone.eval();且每个 epoch 开头写 model.train() 之后要重新关掉
训练中途插一段验证 BN 统计被验证集污染(第四节),Dropout 让验证指标偏低且每次不同 用 try/finally 保证切回来,或者把验证包成一个函数、开头 model.eval()、结尾 model.train()
EMA / 目标网络这类「影子模型」 影子模型也是 nn.Module,它也有自己的 training 标志;拿它做评估时同样要 eval() 影子模型建好就 .eval(),反正它不参与训练

⚠️ 第一格那条最值钱,因为它同时踩了两个坑:以为冻结参数就冻住了整层,以及以为 model.train() 只影响需要梯度的层。 附录C 第 5 题提过一句「微调冻结主干时记得 bn.eval()」—— 第三节那段实测就是它的证据。


🔗 这一章连到哪里

相关的地方 为什么
06 · state_dict 到底装了什么 running_mean 是 buffer,会跟着 state_dict 存盘 —— 所以第四节那个污染是持久的、会被带进部署环境
04 · detach / no_grad / requires_grad 第五节那张四象限表里「不建图」那一列的机制,正题在那一章
08 · DataLoader 第六节末尾那条 Expected more than 1 value per channel 的诱因是 drop_last=False,DataLoader 那边还有一串同类的「最后一个 batch」问题
《机器学习与深度学习基础》10 · 正则化全家桶 那里讲 Dropout 和 BN 为什么有用、以及「同一输入跑两次」的自检技巧;⚠️ 本章第四节实测出了那条技巧的盲区(对 BN 无效)
《机器学习与深度学习基础》附录C 第 5、6 题 BatchNorm / Dropout 的手写实现和数学(inverted dropout 除 $1-p$、running_var 存无偏方差);本章只讲框架什么时候替你调用它们
《机器学习与深度学习基础》15 · PyTorch 实战手册 训练模板里「训练用 train / 验证用 eval / 验证包 no_grad」三行的完整理由,在本章第五节那张表
《AI基础设施》18 · 量化 第三节的 momentum=None(累积平均)正是校准场景要的形态:batch 少、但统计量必须准

✅ 检查点

  1. model.eval() 具体改了什么?它是递归的吗?返回值是什么?
  2. eval() 会不会让前向不建计算图?用什么一行代码能证明?
  3. nn.Dropout(p=0.5) 在 train 模式下,输入全 1.0 的 8 个数会变成什么?为什么活下来的是 2.0 不是 1.0?
  4. BN 的 running_mean 是在哪一步被更新的?torch.no_grad() 和 requires_grad_(False) 能不能拦住它?
  5. PyTorch 的 BatchNorm(momentum=0.1) 里,0.1 是历史的权重还是新 batch 的权重?
  6. momentum=None 时 BN 用什么方式平均?同样喂 0 / 10 / 20 三批,它和默认值分别得到多少?
  7. 「同一个输入跑两次,输出不同就是忘了 eval()」这个自检,对 BN 有效吗?为什么?换成什么更可靠?
  8. 哪几类层完全不看 training?为什么 Transformer 用 LayerNorm 就不会踩这个坑?
  9. 冻结主干微调时,for p in backbone.parameters(): p.requires_grad_(False) 够不够?
  10. Expected more than 1 value per channel when training 是什么触发的?
👀 答案
  1. 只把每个子模块的 self.training 布尔量设成 True/False,是递归的(nn.Sequential 三层实测一起翻),返回值是模块自己(net.eval() is net 为 True)。粒度可以到单层:net[1].eval() 只关那一层。
  2. 不会。lin.eval() 之后 y.requires_grad = True、grad_fn = AddmmBackward0 —— 图照建、显存照占。
  3. 实测 [0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 2.0, 2.0]。活下来的是 2.0 是因为 inverted dropout 训练时就除以 $1-p$($p=0.5$ 即乘 2),好让推理时零成本原样通过。
  4. 在 forward 里更新,不在 backward() 也不在 optimizer.step()。两个都拦不住:实测 no_grad() 里前向一次 running_mean 从 [0.2, 0.3] 变到 [0.38, 0.57],把参数 requires_grad_(False) 之后再前向又变到 [0.542, 0.813]。唯一能停住它的是 eval()。
  5. 新 batch 的权重(新的占 10%)。⚠️ 和优化器里的 momentum=0.9(历史占 90%)方向相反。
  6. 用累积平均(所有见过的 batch 等权),靠 num_batches_tracked 计数。实测:momentum=None 得 10.0(即 (0+10+20)/3),默认 momentum=0.1 只得 2.9。
  7. 对 BN 无效。实测 Dropout 两次输出不同(False),BN 两次输出相同(True)—— 因为 train 模式下 BN 用本 batch 的统计量归一化,同一个 batch 进去输出当然一样,变的是 buffer 不是输出。更可靠的做法:验证后断言 model.training is False,或检查 num_batches_tracked 在验证前后有没有变(这个数只增不减)。
  8. Linear / ReLU / Conv2d / LayerNorm / GroupNorm 实测 train/eval 输出完全相同;只有 Dropout 家族和带 running stats 的归一化(BatchNorm、开了 track_running_stats 的 InstanceNorm)在乎。LayerNorm 只用当前样本的统计量、没有 buffer,所以不存在「忘了 eval()」这个 bug。
  9. 不够。requires_grad_(False) 只冻参数,冻不住 BN 的 running stats(它是 buffer,不走梯度那条路)。必须再补一句 backbone.eval(),而且每个 epoch 开头 model.train() 之后要重新关掉。
  10. train 模式下 batch=1(一个样本算不出方差),实测报 ValueError: Expected more than 1 value per channel when training, got input size torch.Size([1, 2])。💀 典型触发点是 drop_last=False 时最后一个 batch 恰好只剩 1 条 —— 训练几百步之后突然崩。

🛑 可以停在这里

⚡ 走神救援

train() / eval() ⭐ 只做一件事:递归地把每个子模块的一个布尔标志翻过来,粒度可以到单层。⚠️ 它完全不管梯度——图照建、显存照占。

⭐ 本章正题是归一化层的滑动统计量什么时候更新:实测 ⭐⭐ 只做一次前向它就变了——不在反向、也不在参数更新那一步。⚠️ 包在无梯度上下文里照变(那管建图、不管写 buffer),⚠️ 把参数设成不要梯度也照变(buffer 压根不走梯度那条路)。⭐ 只有切换到评估模式能停住它。

⚠️ 一个方向相反的命名坑:这里的动量参数指的是新 batch 占多少,和优化器里那个动量正好反着。

💀 事故:忘了切换就跑了一批验证,滑动统计被推走,⭐ 同一个输入的输出从正数变成负数——全程零报错,代码还好好地包在无梯度上下文里。⭐⭐ 而且污染是持久的:buffer 会跟着权重字典存盘,一路带进部署环境。

⚠️⭐ 站内那条「同一输入跑两次输出不同就是忘了切模式」的自检,对归一化层无效:随机丢弃那类两次不同,而归一化层两次完全相同——⭐ 变的是 buffer,不是输出。⭐ 靠得住的自检是直接断言那个标志,或者看已统计的 batch 数有没有变。

⭐ 只有随机丢弃家族和带滑动统计的归一化在乎这个标志——⭐ 这就是 Transformer 用 LayerNorm 的一个额外好处:根本不存在这个 bug。 ⚠️ 最危险的一格是「训练模式 + 无梯度」:丢弃照丢、统计照更新。

下一节 👉 08-DataLoader与多进程.md

打卡记录保存在你的浏览器里,首页能看到总进度