做深度学习的朋友一定都遇到过这个迷惑场面:训练完一个模型,推理前也喊了model.eval(),结果每次跑出的结果还是不一样,或者训练集上 loss 死活降不下去。这些破事,十有八九是 Dropout 的“第二人格”没管好。
torch.nn.Dropout和torch.nn.functional.dropout,名字就差一个点,行为却可能在 eval 模式下南辕北辙。而且这套坑不光 PyTorch 有,Paddle 那边长得一模一样,换汤不换药。今天我就把这两种 API 的差异、model.eval()的作用机制、还有几类我实测过的诡异现象全掰开揉碎讲一遍,顺便附上 Paddle 下的对应写法,希望你能一次避坑。
1. Dropout 的前世今生:模块版与函数版到底差在哪
1.1 为什么同一个功能要提供两种 API
在 PyTorch 里,你想给网络加一招“随机失活”,官方给了两条路:torch.nn.Dropout(下面简称nn.Dropout)和torch.nn.functional.dropout(下面简称F.dropout)。很多新手不理解,明明是同一个功能,为什么官方要搞两个接口,这不是徒增学习成本吗?
其实这是 PyTorch 的一贯设计哲学:高度封装的“模块”与轻量灵活的“函数”并存。模块适合用来搭模型,因为它会把自身状态(比如失活概率 p、是否原地操作 inplace)全都封装进去,还能自动响应model.train()/model.eval()的切换,不需要调用者反复传参。函数则适合做临时性的、一次性的操作,比如在自定义损失函数里偶尔加一个随机失活,没必要为了它特地建模块。
用生活里的例子理解:nn.Dropout像一个带遥控器的自动感应灯,你只需要设定一次“亮度范围”,之后它自己会根据“现在是白天还是晚上”决定亮不亮;F.dropout则像一盏手动开关的灯泡,你必须亲手决定“现在该不该亮”,如果忘了关,那半夜它照样亮瞎你眼睛。
问题也就出在这。自动感应灯固然省心,但手动的灯泡一旦用错场合,坑起来非常隐蔽——尤其在 eval 模式下。
1.2 nn.Dropout 的封装逻辑与自我管理
nn.Dropout实现原理并不复杂,它是一个nn.Module子类,核心的 forward 逻辑大致可以理解为:
class Dropout(nn.Module): def __init__(self, p=0.5, inplace=False): super().__init__() self.p = p self.inplace = inplace def forward(self, input): if self.training: return 带随机失活的处理(input) else: return input # 推理时直接返回原样注意看最后一行:当self.training为 False 时,模块版 Dropout 直接当恒等映射处理,什么都不做。那么self.training怎么变?正是通过model.eval()和model.train()全局切换的。
这种封装带来的好处很明显:只要你在模型定义里写了self.dropout = nn.Dropout(0.5),然后在推理前调用一次model.eval(),所有 Dropout 层都会自动“哑火”,你完全不用去记哪里用了 Dropout、哪里没有。这也是我推荐大家在搭建模型时优先使用nn.Dropout的原因:少一个需要操心的细节,就少一分出错的机会。
1.3 functional.dropout 的直白与隐雷
再来看F.dropout。它的签名在 PyTorch 中是这样:
torch.nn.functional.dropout(input, p=0.5, training=True, inplace=False)你仔细看training这个参数,默认值死死地写在True上。这意味着什么?意味着你写F.dropout(x, p=0.5)的时候,不管你在训练还是推理,它都默认执行随机失活,除非你手动改成training=False或传入self.training。
我自己刚用 PyTorch 那会儿就因为这个默认值吃过亏。有次我图方便,在一个模块的 forward 里写了:
def forward(self, x): x = F.dropout(x, p=0.5) ...模型训练时倒是正常,可后来要推理,我发现输出结果每次都不一样,像见了鬼。排查了半天,最后发现就是这行代码搞的鬼。它使用默认的training=True,所以即使模型已经eval()了,它照样以 50% 的概率把神经元的输出随机清零。
正确写法是把它跟当前模型的状态绑定起来:
def forward(self, x): x = F.dropout(x, p=0.5, training=self.training) ...或者干脆在推理时直接传F.dropout(x, p=0.5, training=False)。总之,别让 F.dropout 的默认参数替你决定当前是训练还是推理。
2. model.eval() 到底做了什么,以及 Dropout 的生死开关
2.1 eval() 切换的是训练与推理的“上下文”
要理解上面这些差异,就得先搞清楚model.eval()的本质。它并不是一层魔法,它的作用其实很朴素:递归地把模型里所有nn.Module的training属性都置为 False。同理,model.train()就是把training属性都置为 True。
在 PyTorch 中,nn.Module.eval()的注释里写着:这个操作对 Dropout 和 BatchNorm 这类“在训练与推理中行为不同”的模块有影响。BatchNorm 在训练时用 batch 的均值方差做归一化,并滑动更新全局统计量;在 eval 时则用全局统计量来归一化。Dropout 则简单粗暴:训练时随机失活,eval 时直接放行。
所以,model.eval()相当于一个总开关,把模型从“训练模式”拧到“推理模式”。但注意,这个开关只能遥控那些“接入了遥控系统”的模块——也就是基于nn.Module的模块。对于F.dropout这种裸函数,它自身并不存在于模块树里,也没有 self.training 一说,它只会傻傻地听你传给它的training参数。如果你不给它传,那它永远按默认值True来执行,完全无视外面的model.eval()。
这就是“模块”和“函数”在模式联动上的本质差异。
2.2 两种 API 在 eval 下的四种组合
为了让大家一眼看懂,我整理了一张行为对照表,包含了nn.Dropout和F.dropout在不同写法、不同模式下的效果:
| 使用方式 | 调用处状态 | 评估/推理时行为 | 是否正常 |
|---|---|---|---|
nn.Dropout(p) | model.eval() | 不做任何操作,原样输出 | 正常 |
nn.Dropout(p) | model.train() | 按概率 p 随机失活 | 正常 |
F.dropout(x, p) | model.eval() | 仍按概率 p 随机失活(默认 training=True) | 异常 |
F.dropout(x, p, training=self.training) | model.eval() | 不做任何操作,原样输出 | 正常 |
这张表请大家务必存一下,因为第三行就是最常见翻车现场。很多人写完F.dropout就忘了管最后一个参数,结果测试集和部署时被随机失活坑得一头包。
2.3 手写一个最小实验验证行为
不亲手验证一下,总感觉不踏实。我用一个最简单的例子测试过,效果非常直观:
import torch import torch.nn as nn import torch.nn.functional as F class MyNet(nn.Module): def __init__(self): super().__init__() self.dropout = nn.Dropout(0.5) # 模块版 def forward(self, x, use_function=False, use_training_flag=False): x = self.dropout(x) if use_function: if use_training_flag: x = F.dropout(x, p=0.5, training=self.training) else: x = F.dropout(x, p=0.5) # 默认 training=True return x net = MyNet() x = torch.ones(1, 10) net.eval() print("模块版输出:", net(x)) print("函数版默认输出:", net(x, use_function=True)) print("函数版绑定模式输出:", net(x, use_function=True, use_training_flag=True))我跑了一次,结果类似这样:
模块版输出: tensor([[1., 1., 1., ..., 1.]]) 函数版默认输出: tensor([[0., 2., 0., ..., 2.]]) 函数版绑定模式输出: tensor([[1., 1., 1., ..., 1.]])第一个和第三个都是全 1,说明 eval 模式下 Dropout 被正确关闭了;第二个因为F.dropout用了默认的training=True,即使model.eval()也照样把部分 1 置成了 0,并且按1/(1-p)=2放大了其他值。如果这种随机失活出现在你的推理链路里,每次结果都不一样,那基本可以断定是这一处漏传参数导致的。
3. 迁移到 Paddle:paddle.nn.Dropout 与 functional.dropout 也一样
3.1 Paddle 的对应 API 和相似设计
PyTorch 的这套 Dropout 设计,在国产框架 PaddlePaddle 里几乎原样复刻了一版。Paddle 对应提供了paddle.nn.Dropout和paddle.nn.functional.dropout,两者在设计思路上和 PyTorch 完全一致:模块版自动感知model.eval()/model.train(),函数版需要手动传training参数。
所以在 PyTorch 踩过的坑,换到 Paddle 里一个都躲不掉。我个人体验是,Paddle 官方文档对paddle.nn.functional.dropout的说明里同样标注了training=True这个默认值。你要是把它直接写进网络里跑推理,一样会遇到随机输出。
3.2 实际代码对比:Paddle 下的正确写法
我们直接对比一下两种框架的正确和错误写法,你会发现惊人相似:
# PyTorch 正确写法 class Net(nn.Module): def __init__(self): super().__init__() self.drop = nn.Dropout(0.5) def forward(self, x): return self.drop(x) # Paddle 正确写法 import paddle import paddle.nn as nn class Net(nn.Layer): def __init__(self): super().__init__() self.drop = nn.Dropout(0.5) def forward(self, x): return self.drop(x)两者都用了模块版,所以只要在推理前调用对应框架的eval()(Paddle 里是net.eval()),Dropout 就会被关闭。
再看不推荐的函数版写法:
# 不推荐:PyTorch 中 F.dropout 默认 training=True x = F.dropout(x, p=0.5) # 不推荐:Paddle 中 paddle.nn.functional.dropout 默认 training=True x = paddle.nn.functional.dropout(x, p=0.5)这两种方法在 eval 模式下都会继续随机失活。正确做法同样是绑定当前模块的模式标志:
# PyTorch x = F.dropout(x, p=0.5, training=self.training) # Paddle x = paddle.nn.functional.dropout(x, p=0.5, training=self.training)Paddle 的模块类基于nn.Layer,但self.training的语义和 PyTorch 保持一致,所以这套写法在两边都能跑通。
3.3 一个小差异:Paddle 的 dropout 实现细节
虽然整体设计如出一辙,但 Paddle 在 Dropout 的实现细节上有一个值得一提的差异:它默认使用的是mode='upscale_in_train',翻译过来就是“训练时放大、推断时不缩放”。
具体来说,Paddle 的 Dropout 是在训练时随机置零,并且会把保留下来的元素乘以1 / (1 - p),这样可以让输出在训练时保持期望不变;在 eval 时直接原样输出。PyTorch 的nn.Dropout也是类似策略。所以从数学行为上看,两者是可以平替的。
但如果你习惯用paddle.nn.functional.dropout,一定要留意这个mode参数。有些老代码可能用了mode='downscale_in_infer',那是另一种旧策略,推理时会走“对输出做缩放”的分支。这种边角差异在跨框架迁移模型时最容易踩中,我的建议是:用模块版paddle.nn.Dropout并保持默认 mode,别给自己找不痛快。
4. 实战踩坑:这些诡异现象都是 dropout 惹的祸
4.1 场景一:明明 eval 了,推理结果仍然随机
这个场景我见过不止一次,甚至在一些开源项目的 issue 里也反复出现。现象就是:模型训练完,保存、加载、eval()一条龙全做了,但推理结果每次都不一样。
排查步骤很简单,先看代码里有没有出现F.dropout或者paddle.nn.functional.dropout;再看这些函数有没有把training参数绑定到self.training上;最后看是不是有某些地方忘了调用eval(),比如只对部分子网络调用,或者调用了之后再加载权重导致状态被重置。
这里有个小技巧:你可以在eval()之后打印任意一个 Dropout 模块的training属性确认状态:
# PyTorch print(model.dropout.training) # 期望 False # Paddle print(model.drop.training) # 期望 False如果输出是True,那说明 eval 调用不到位。如果模块版是正确的,那就要考虑是不是自己在 forward 里混用了函数版 Dropout。
4.2 场景二:训练 loss 不下降反而波动大
另一种常见的坑是训练阶段就出问题。有些朋友喜欢在 forward 里用F.dropout(x, p=0.5)省事,结果这个 Dropout 完全不受model.train()/model.eval()控制,导致 eval 的时候它还在随机失活,于是评估集上表现忽高忽低,看起来像训练没收敛,或者模型泛化特别差。
还有一种情况是在某些不该加 Dropout 的地方加了太高的失活概率。比如在 LSTM/Transformer 的 hidden state 上,p=0.5可能会导致梯度信号被过分冲淡,loss 迟迟降不下去。这时候你可以把 p 调小到 0.1 ~ 0.3 试试,或者换成nn.Dropout模块,至少能确保 eval 时行为正确。
我的习惯是:模型定义里的 Dropout 一律用模块版,函数版只用来处理临时性的单独调用。这样训练和推理之间不会出现诡异的“状态割裂”。
4.3 场景三:模型导出——inference.json 与 nb 的困惑
最近热搜词里有个挺有意思的问题:“paddle 训练的模型怎么是 inference.json 转为 nb”。估计很多人都被 Paddle 的模型导出搞懵过。我在这里说下我理解的来龙去脉。
Paddle 官方现在推荐使用paddle.jit.save将动态图模型导出为推理模型,导出后通常会得到.pdmodel(结构文件)和.pdiparams(参数文件)。但在一些上层套件(比如 PaddleX、PaddleDetection 等)中,导出过程除了生成模型结构文件,还会额外生成inference.yml或inference.json这类配置文件,里面记录的是输入输出名、均值方差预处理参数、标签列表等内容。
而问题里提到的“nb”,我猜有两种可能:一种是某些可视化工具(如 Netron 导入模型后可以保存为 notebook 形式的实验记录)的扩展名;另一种是部署环境中自定义的模型打包格式,类似 aistudio 里的.nb模型包。不管哪种,核心思路都是用真正的模型文件去转换,而不是拿着 json 文件去转。json 只是配置,不是可训练、可推理的模型权重,它本身无法单独转为可部署的模型包。
如果你想把 Paddle 训练好的模型转成一个部署可用的“nb 包”或其他格式,建议按这个流程走:
- 在训练代码中通过
paddle.jit.to_static将动态图模型转为静态图程序; - 使用
paddle.jit.save(model, path)将模型保存为model.pdmodel和model.pdiparams; - 检查同目录下是否生成了
inference.yml或inference.json,这些文件保留给部署工具读取; - 根据你的部署目标,选择使用 Paddle Inference 直接加载,或继续转换为 ONNX、TensorRT 等格式;
- 如果目标平台要求特定的模型包扩展名(如 nb),通常需要在对应部署框架的转换工具里导入之前的
.pdmodel与.pdiparams,再执行打包命令,而不是拿inference.json去转。
说得再直白一点:inference.json是“说明书”,模型文件才是“产品”。你照着说明书去做产品转换,但别指望把说明书本身变成产品。
4.4 排查 Dropout 问题的检查清单
最后整理一份我把这么多年踩坑经验浓缩成的排查清单,遇到诡异问题可以照着过一遍:
| 检查点 | 操作方法 | 达标标准 |
|---|---|---|
| 模块版 Dropout | 模型定义中使用nn.Dropout或paddle.nn.Dropout | eval 后training为 False,Dropout 不生效 |
| 函数版 Dropout | 检查是否绑定training=self.training或手动传training=False | eval 后没有随机失活 |
eval()调用位置 | 在模型加载权重之后、推理之前调用model.eval()/net.eval() | 所有子模块的training都为 False |
| 模型导出文件 | 确保导出的是.pdmodel+.pdiparams,而非只有 json 配置 | 部署工具能成功构建推理引擎 |
| 随机种子 | 设置torch.manual_seed/paddle.seed,并固定推理种子 | 相同输入多次推理结果一致 |
关于随机种子的问题,也有不少人忽略。就算你正确关了 Dropout,如果网络里还有其他随机采样操作(比如可变形卷积、随机 padding 等),推理结果依然可能有微小波动。设置好推理阶段的随机种子,可以进一步保证结果可复现。
最后聊点我的实操体会
Dropout 这类“训练专用”模块,看起来简单,真正玩明白还是得靠踩坑积累。我在 PyTorch 和 Paddle 两边都折过腰以后,现在写代码形成了一套习惯:模型层级的 Dropout 一律用nn.Dropout/paddle.nn.Dropout;自定义损失或者辅助网络里的临时失活用函数版,但一定显式传入training=self.training。每次推理前,我一定会打印一个 Dropout 层的training属性做状态确认,哪怕多花两秒也值。
另外,模型导出这块,千万别把 json 配置和真正模型文件搞混。以前我也干过把inference.json当成模型到处打听怎么转格式的蠢事,后来用paddle.jit.save一次性把.pdmodel和.pdiparams导出后,部署工具一读就通,根本没有想象中复杂。
如果你现在也被“eval 模式但结果随机”折磨,先别急着调包调参,回去翻翻代码里所有 Dropout 的长相。这年头吧,能在model.eval()面前依旧我行我素进行随机失活的,十有八九是那个没传training参数的函数版 Dropout。