BatchNorm已经展示过训练与评估行为的区别。Dropout 也有这个开关,但原因不同:它在训练时引入随机掩码,而不是维护一套运行均值和方差。

“随机丢掉一半神经元”容易漏掉两个关键问题:留下来的值要不要缩放?输出的期望保持不变,是否就意味着损失与梯度也不变?本文从三个数开始,把这几件事逐项算清。

先固定约定

p 是丢弃概率,q=1−p 是保留概率。

再固定掩码

前向与反向使用同一次采样,不能各抽一次。

最后取期望

区分激活、预测、损失和梯度四种对象。

输入2、负1、3经过保留、丢弃、保留的掩码,在保留概率二分之一时输出4、0、6;八种掩码的平均损失为13而评估损失为4.5
单次变稀疏,期望不等于每次。 图中数值来自正文的确定性手算与八掩码枚举。连线只表示逐元素作用,不是完整神经网络结构。查看原图

先把丢弃概率与保留概率分开

记输入激活为 h,丢弃概率为 p,保留概率 q=1−p。每个位置独立采样 m_i∼Bernoulli(q)。本文采用 inverted dropout:

1
2
训练:h̃_i = m_i h_i / q
评估:h̃_i = h_i

把缩放放在训练阶段,评估时这个层就是恒等映射。PyTorch 2.14 Dropout 文档采用这一约定。这里讨论逐元素掩码,不把按通道共享掩码的变体混进来。

原始论文讨论了训练时随机移除单元及测试时的权重缩放。不同写法可以把保留概率的缩放安排在不同阶段;阅读代码时必须先统一约定,不能把训练缩放与测试缩放重复使用。

本文实现只接受 0≤p<1;p=0 是恒等映射。p=1 需要单独定义全零分支,不能代入除以 q 的公式,因此教学实现明确拒绝它;这不是声称框架也不支持 p=1。

保持的是条件均值,不是单次输出

先把 h 看作已固定的数,随机性仅来自当前掩码。因为 E[m_i]=q、m_i²=m_i:

1
2
3
E[h̃_i | h] = h_i
E[h̃_i² | h] = h_i² / q
Var(h̃_i | h) = h_i² p/q

所以训练缩放保留一阶均值,却增加了方差。p 越接近 1,少数保留值被放得越大;“丢得更多”不能直接理解成“训练一定更稳”。以上等式条件于输入 h,不是在声称训练后参数或整网预测无偏。

若不同位置的掩码独立,固定 h 时各位置的掩码噪声协方差为零。若一个掩码被多个位置共享,这个独立性就不成立,后面的方差求和也必须加入协方差项。

一次前向和反向怎样手算

取 h=[2,−1,3],p=0.5,掩码 m=[1,0,1]。假设上游梯度 g=[0.5,−2,1];它是针对这次前向结果计算出来的,不是另外一轮实验的梯度。

位置 h_i m_i m_i/q 输出 h̃_i 输入梯度 g_i m_i/q
1 2 1 2 4 1
2 −1 0 0 0 0
3 3 1 2 6 2

在 已采样且固定的掩码 下,逐元素导数为 m_i/q,雅可比是对角矩阵。掩码本身没有可学习参数;反向不重新采样,也不对离散抽签过程求导。数值差分检查同样必须固定掩码,否则比较的是两个不同的随机函数。

注意 g 通常依赖这次掩码造成的下游输出。因此,不能把 E[g_i m_i/q] 随意拆成 E[g_i]E[m_i/q],更不能据此说训练梯度等于关闭 Dropout 后的梯度。

八种掩码为什么得到不同的期望损失

在随机激活后接一个固定线性头 z=w·h̃,取 w=[1,2,−1],目标 y=0,损失 L=½(z−y)²。p=0.5 时三个独立位置一共有八种等概率掩码:

掩码 m 线性预测 z 平方损失 L
000 0 0
001 −6 18
010 −4 8
011 −10 50
100 4 8
101 −2 2
110 0 0
111 −6 18

平均预测为 −3,正好等于关闭 Dropout 时的 w·h。但八个损失的平均为 104/8=13;评估模式的损失只有 ½(−3)²=4.5。

对固定输入、独立逐元素掩码和这个线性平方损失,可以精确推导:

1
2
3
E[z] = w·h
Var(z) = (p/q) Σ_i w_i² h_i²
E[L] = ½(w·h−y)² + ½(p/q) Σ_i w_i² h_i²

这里额外项是 ½×17=8.5,所以总期望为 13。它看起来像按输入大小加权的二次惩罚,但这个等式依赖刚才的结构;不能把任意深度非线性网络中的 Dropout 都直接等同于固定系数的 L2 权重衰减。

继续对线性头的 w 求导,得到:

1
2
3
∂E[L]/∂w_i = (w·h−y) h_i + (p/q) w_i h_i²
本例:[-2, 5, -18]
评估模式的损失梯度:[-6, 3, -9]

这里只对有限个掩码的加权和求导,交换求和与微分没有无限极限问题。两个梯度不同,正说明我们优化的是带随机掩码的训练目标。

为什么非线性后面不能直接交换期望

再看一个更小的反例:h=[1,−1],w=[1,1],p=0.5。四种掩码给出的 z 为 0、−2、2、0,平均为 0;通过 ReLU 后,四个值为 0、0、2、0,平均为 0.5,而 ReLU(E[z])=0。

因此,评估时关闭 Dropout 不意味着整网输出严格等于所有随机子网输出的平均。线性层的一阶均值结论,不能跨过任意非线性继续搬运。这也解释了为什么应在真实验证集上检查模型行为,而不是只验证一条期望公式。

完整标准库实验

下面实现逐元素前向、保存缩放系数的反向,以及小维度线性头的精确枚举。显式掩码用于复现手算和差分;随机训练时应在多次调用间复用同一个 Random 实例,不要每一步重新设置同一个种子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
import itertools
import json
import math
import random
from dataclasses import dataclass


def vector(values, name):
if not isinstance(values, (list, tuple)) or not values:
raise ValueError(name + " must be a nonempty list or tuple")
out = []
for value in values:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(name + " requires real numbers")
try:
item = float(value)
except OverflowError as error:
raise ValueError(name + " is too large") from error
if not math.isfinite(item):
raise ValueError(name + " must be finite")
out.append(item)
return out


def probability(p):
value = vector([p], "p")[0]
if not 0 <= value < 1:
raise ValueError("require 0 <= p < 1")
return value


@dataclass(frozen=True)
class Cache:
scale: tuple


def dropout(h, p, *, training=True, mask=None, rng=None):
h, p = vector(h, "h"), probability(p)
if type(training) is not bool:
raise ValueError("training must be bool")
if not training:
if mask is not None:
raise ValueError("evaluation does not accept a mask")
return h[:], Cache(tuple(1.0 for _ in h))
if mask is None:
if rng is None:
rng = random.Random()
if not isinstance(rng, random.Random):
raise ValueError("rng must be random.Random")
mask = [int(rng.random() >= p) for _ in h] if p else [1]*len(h)
if (not isinstance(mask, (list, tuple)) or len(mask) != len(h)
or any(type(bit) is not int or bit not in (0, 1) for bit in mask)):
raise ValueError("mask must contain one integer bit per input")
if p == 0 and any(bit == 0 for bit in mask):
raise ValueError("p=0 cannot produce a dropped entry")
scale = tuple(bit/(1-p) for bit in mask)
out = vector([v*s for v, s in zip(h, scale)], "output")
return out, Cache(scale)


def backward(g, cache):
g = vector(g, "g")
if not isinstance(cache, Cache):
raise ValueError("cache must come from dropout")
scale = vector(cache.scale, "scale")
if len(g) != len(scale) or any(s < 0 for s in scale):
raise ValueError("invalid gradient/cache shape or scale")
return vector([v*s for v, s in zip(g, scale)], "gradient")


def enumerate_head(h, w, target, p):
h, w = vector(h, "h"), vector(w, "w")
target, p = vector([target], "target")[0], probability(p)
if len(h) != len(w) or len(h) > 12:
raise ValueError("matching vectors with at most 12 entries required")
q = 1-p
mean = second = loss = 0.0
grad = [0.0]*len(h)
for mask in itertools.product((0, 1), repeat=len(h)):
mass = q**sum(mask) * p**(len(h)-sum(mask))
if mass == 0:
continue
dropped, _ = dropout(h, p, mask=mask)
z = math.fsum(a*b for a, b in zip(w, dropped))
error = z-target
mean += mass*z
second += mass*z*z
loss += mass*0.5*error*error
for i, value in enumerate(dropped):
grad[i] += mass*error*value
deterministic = math.fsum(a*b for a, b in zip(w, h))
evaluation_loss = 0.5*(deterministic-target)*(deterministic-target)
values = [mean, second, loss, deterministic, evaluation_loss, *grad]
vector(values, "enumerated moments")
return {"mean_prediction": mean, "variance_prediction": second-mean*mean,
"mean_loss": loss, "mean_weight_gradient": grad,
"evaluation_loss": evaluation_loss}


if __name__ == "__main__":
out, cache = dropout([2, -1, 3], 0.5, mask=[1, 0, 1])
print(json.dumps({"one_mask": out,
"input_gradient": backward([0.5, -2, 1], cache),
"exact": enumerate_head([2, -1, 3], [1, 2, -1], 0, 0.5)}))

运行后 one_mask 为 [4,0,6],input_gradient 为 [1,0,2];exact 中预测均值 −3、方差 17、平均损失 13、权重梯度 [−2,5,−18]、评估损失 4.5。浮点数的末位差异应使用容差比较。

单次前向与反向均为 O(d) 时间;保存缩放系数占 O(d) 空间。精确枚举为 O(d·2^d) 时间,额外工作空间 O(d),因此主动限制到 12 维;它适合验证小例子,不适合完整网络训练。

验证程序 tests/verify-dropout-article.cjs 使用独立的有理数卷积分布检查 600 组随机线性头,以中心差分核对 5,044 个输入和权重导数,并检查评估模式不消耗随机数、连续采样不重置种子、ReLU 反例及 22 类非法输入。这里验证公式与实现,没有测量真实任务的泛化收益。

失败边界与工程检查

现象 应先检查什么
保留下来的值没有除以 q 是否混用了另一种测试时缩放约定
反向结果每次漂移 是否重新采样了掩码,或差分时改变了随机函数
验证输出仍随机 是否正确切换评估模式;关闭梯度不等于切换模块行为
方差推导不匹配 是否共享了掩码、混入下游非线性或改变了输入条件
大 p 时出现极端值 q 很小会放大激活和梯度,应检查数值范围
与 BatchNorm 混合后结果改变 掩码会改变后续层所见分布,不能机械地按恒等层替换训练过程
训练集指标变差 正则化目标与训练拟合并非同一件事,仍需预先固定验证方案
自检:平均预测保持不变,为什么平均损失还是增大?

平方损失取决于二阶矩。对这个线性头,平均损失等于均值预测的平方损失再加一半预测方差;本例额外增加 8.5。它不是对所有网络和损失都适用的常数惩罚。

本文运行的是标准库教学实现、有限掩码枚举与数值梯度检查,没有运行完整 PyTorch 训练,也没有证明某个任务上一定改善泛化。接下来可对照反向传播的固定计算图,以及配对 Bootstrap对验证差值的不确定性分析。