📑 本页目录(点开跳转)
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]
四个结论:
- 模块默认是 train 模式(
training=True)。你什么都不写就是在训练模式下推理。 - 递归生效:
net.eval()把整棵子模块树都翻过去了。 - ⭐ 返回值是它自己,所以
model.eval()(x)这种写法能跑通(但别这么写)。 - ⭐ 粒度可以到单层:
net[1].eval()只关那一层,其余不动 —— 微调冻结主干时会用到(第七节)。
⚠️ 注意它没做的事: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 题,这里不重复。
框架侧要记的只有两条:
p是丢弃概率,nn.Dropout(0.5)丢一半。eval()下nn.Dropout返回输入本身,不做任何事 —— 所以它在推理时既不慢也不占显存。
📊 三、⭐ 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_mean | num / 备注 |
|---|---|---|
| 初始 | [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_mean | num |
|---|---|---|
| 0.0 | 0.0000 | 1 |
| 10.0 | 5.0000 | 2 |
| 20.0 | 10.0000 | 3 |
默认 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))
实跑输出:
信息关系
同一个输入 0.5,输出从 0.4827 变成 -0.5091 —— 换了个号。 而全程:
- 没有任何报错、没有任何警告;
- 代码包在
no_grad()里,看起来是标准的验证写法; - 参数一个都没变,
torch.save存出来的权重是对的,只有 buffer 被污染了; - ⭐ 污染是持久的:
running_mean是 buffer,会跟着state_dict一起存盘(上一章第一节), 所以这个错误会随着 checkpoint 一路传下去,在部署环境里重现。
⚠️⚠️ 站内那条自检技巧对这个 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 的归一化」家族。
LayerNorm/GroupNorm完全不看 —— 它们只用当前样本的统计量,没有 buffer,没有 train/eval 之分。 ⭐ 这就是 Transformer 用 LN 的一个额外好处:不存在「忘了eval()」这个 bug。InstanceNorm1d默认track_running_stats=False,所以也不看;把它开成True就会和 BN 一样分叉(实测输出会变成False)。- ⚠️ 反过来说,一个只有 Linear + ReLU 的模型,忘不忘
eval()结果完全一样 —— 这也是很多人从没被这个 bug 咬过、直到第一次用 BN 的原因。
顺带一条只在 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 少、但统计量必须准 |
✅ 检查点
model.eval()具体改了什么?它是递归的吗?返回值是什么?eval()会不会让前向不建计算图?用什么一行代码能证明?nn.Dropout(p=0.5)在 train 模式下,输入全 1.0 的 8 个数会变成什么?为什么活下来的是 2.0 不是 1.0?- BN 的
running_mean是在哪一步被更新的?torch.no_grad()和requires_grad_(False)能不能拦住它? - PyTorch 的
BatchNorm(momentum=0.1)里,0.1 是历史的权重还是新 batch 的权重? momentum=None时 BN 用什么方式平均?同样喂 0 / 10 / 20 三批,它和默认值分别得到多少?- 「同一个输入跑两次,输出不同就是忘了
eval()」这个自检,对 BN 有效吗?为什么?换成什么更可靠? - 哪几类层完全不看
training?为什么 Transformer 用 LayerNorm 就不会踩这个坑? - 冻结主干微调时,
for p in backbone.parameters(): p.requires_grad_(False)够不够? Expected more than 1 value per channel when training是什么触发的?
👀 答案
- 只把每个子模块的
self.training布尔量设成True/False,是递归的(nn.Sequential三层实测一起翻),返回值是模块自己(net.eval() is net为True)。粒度可以到单层:net[1].eval()只关那一层。 - 不会。
lin.eval()之后y.requires_grad = True、grad_fn = AddmmBackward0—— 图照建、显存照占。 - 实测
[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),好让推理时零成本原样通过。 - 在 forward 里更新,不在
backward()也不在optimizer.step()。两个都拦不住:实测no_grad()里前向一次running_mean从[0.2, 0.3]变到[0.38, 0.57],把参数requires_grad_(False)之后再前向又变到[0.542, 0.813]。唯一能停住它的是eval()。 - 新 batch 的权重(新的占 10%)。⚠️ 和优化器里的
momentum=0.9(历史占 90%)方向相反。 - 用累积平均(所有见过的 batch 等权),靠
num_batches_tracked计数。实测:momentum=None得 10.0(即 (0+10+20)/3),默认momentum=0.1只得 2.9。 - 对 BN 无效。实测 Dropout 两次输出不同(
False),BN 两次输出相同(True)—— 因为 train 模式下 BN 用本 batch 的统计量归一化,同一个 batch 进去输出当然一样,变的是 buffer 不是输出。更可靠的做法:验证后断言model.training is False,或检查num_batches_tracked在验证前后有没有变(这个数只增不减)。 - Linear / ReLU / Conv2d / LayerNorm / GroupNorm 实测 train/eval 输出完全相同;只有 Dropout 家族和带 running stats 的归一化(BatchNorm、开了
track_running_stats的 InstanceNorm)在乎。LayerNorm 只用当前样本的统计量、没有 buffer,所以不存在「忘了 eval()」这个 bug。 - 不够。
requires_grad_(False)只冻参数,冻不住 BN 的 running stats(它是 buffer,不走梯度那条路)。必须再补一句backbone.eval(),而且每个 epoch 开头model.train()之后要重新关掉。 - 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