把前向传播跑通的那一刻,很多人会产生一种错觉:模型已经跑起来了。数据丢进去,输出吐出来,控制台不报错,看起来一切正常。可只要盯一眼输出值——还是那个接近随机的数。前向传播完成的只是“猜”这个动作,它并没有完成“学”。上篇我们把输入一层层推到输出,算出了一串加权和与激活值;这一篇要处理的是剩下那一半,也是最容易被初学者跳过、被老手反复回头补课的那一半:误差怎么沿着原路返回来,变成每一个权重上的一次微小调整。这就是前馈网络真正开始自我学习的地方——反向传播加上梯度下降。链式法则是这条回路的骨架,梯度是流动的血液,而学习率是控制血流速度的阀门。这篇内容适合两类人:一类是刚啃完前向公式、看得懂矩阵乘法但不知道梯度怎么落地的人;另一类是把模型训起来过、但 loss 一平就只会换优化器瞎试的人。我会把计算图上的每一步拆到能笔算的程度,给出一个不带任何框架依赖的手写实现,再把训练不收敛时的排查顺序按我自己的习惯排一遍。
1. 前向只报了个数:反向传播补的到底是哪一环
1.1 一个标量损失,怎么给几十万个参数分配责任
先把这个问题的形状说清楚。一次前向走完,我们手上只有一个标量:损失值。而模型里可能有几十万甚至上亿个可调参数。一个数字要反过来告诉每一个参数“你该往哪儿挪、挪多少”,这在直觉上是很别扭的。
拿工厂做类比最贴切。整条流水线出了次品,最终质检只报了一个“不合格”,但你要追溯到具体是哪台机床的进刀量偏了 0.02 毫米。追溯的依据不是感觉,是每一道工序对最终结果的敏感度。在数学上,这个“敏感度”就是偏导数:把某一个参数挪动一个极小的量,其他参数全部按住不动,看最终损失变化多少。这个偏导数的值,就是那个参数在这次错误里应该承担的责任份额,也直接就是它下一步该调整的方向和幅度。
所以反向传播不是一个“新算法”,它只是求这批偏导数的一种高效组织方式。真正定义“学什么”的是损失函数,真正决定“怎么学”的是梯度。
1.2 为什么不能挨个参数试一遍:数值微分的成本账
有人会想,偏导数的定义不就是“挪一点点看变化”吗?那就每个参数挨个挪一遍好了,简单直接。
可以,但代价得先算清楚。假设模型有 100 万个参数,做一次完整前向需要 10 毫秒。逐个参数用中心差分求梯度,每个参数要算两次前向(加一次、减一次),总共 200 万次前向,也就是 20000 秒,接近 5.5 个小时——而这只是走完一步梯度下降,模型还没开始学。
| 求梯度方式 | 单步计算量 | 100 万参数模型单步耗时(按一次前向 10ms 估) | 是否可用于训练 |
|---|---|---|---|
| 前向差分(单侧) | N+1 次前向 | 约 2.8 小时 | 不能用 |
| 中心差分 | 2N 次前向 | 约 5.5 小时 | 不能用 |
| 反向传播 | 约 2~3 次前向的等效开销 | 约 25 毫秒 | 唯一可行 |
反向传播的聪明之处在于复用。前向过程中每个节点算过的中间结果全部留着,反向时直接从最后一层往回乘链式法则的局部导数,一次遍历就能把所有参数的梯度同时算出来。它把“每个参数各算一遍”变成了“所有参数共享一次回溯”,复杂度从 O(N) 次前向降到常数倍前向。
这个差价不是优化,是生死线。没有它,现代规模的网络一步都训不动。
1.3 计算图:把一段算式变成一条可回溯的路线
要理解反向传播怎么复用,得先把前向过程看成一张图,而不是一串公式。
每个运算(加法、乘法、激活、矩阵乘)都是一个节点,节点之间用张量连着。关键在于,每个节点只需要记住自己的局部导数——就是“我的输出变化一点点,我的输入该怎么变”。它不需要知道整个网络长什么样。
这条规则在三种最常见的节点上表现得很直观:
- 加法节点:输出对任一输入的偏导都是 1,所以上游传来的梯度原样分发给所有输入。如果有两个分支汇合到一个加法上,梯度就在汇合点相加。这条“汇合相加”规则解释了为什么残差连接能缓解梯度消失——它给梯度开了一条畅通的直连通道。
- 乘法节点:输出对某个输入的偏导等于另一个输入的值,所以梯度传回去时要和“对面那个数”相乘,两个输入的梯度互换着算。
- 取最大值节点(比如 ReLU 在 0 处之外的形态):只把梯度给那个“赢”的输入,其他输入拿到 0。这就是 ReLU 在负半轴梯度为零、可能“杀死”神经元的根源。
把这套规则串起来,从损失往回走一遍,得到的每一站都是“上游梯度 × 本地局部导数”。这个乘法连乘的结构,正是链式法则在计算图上的具体形状。
2. 链式法则落到纸上:两层网络梯度的完整手推
2.1 先定一个能笔算的最小模型
不手推一遍,反向传播永远是一团雾。我挑一个最小但结构完整的模型:2 个输入、3 个隐藏单元、1 个输出,隐藏层用 ReLU,输出层线性,损失用平方误差。
前向公式写全:
z1 = W1 · x + b1,其中W1是 3×2,x是 2 维列向量,b1是 3 维h = ReLU(z1),逐元素取max(0, ·)y_hat = W2 · h + b2,其中W2是 1×3,b2是标量L = 0.5 · (y_hat - y)²
那个 0.5 是为了求导时把平方的 2 抵消掉,纯属书写便利,不影响最优点位置。
2.2 第一跳:从损失回到输出层
从损失往回走第一跳,对y_hat求偏导:
∂L/∂y_hat = (y_hat - y)
就这么简单。这个量有个专门的名字叫输出误差信号,记作δ_out。它同时携带了两重信息:符号告诉你预测偏大还是偏小,绝对值告诉你偏了多少。
继续往回,对W2的梯度是外积:
∂L/∂W2 = δ_out · hᵀ
形状检查一下:δ_out是 1 维(单输出),hᵀ是 1×3,结果 1×3,和W2的形状一致。这一步的直觉是:某个隐藏单元激活值越大,说明它在这次预测里“出力”越多,那么它对应的权重就应该承担越大的调整量。激活为 0 的隐藏单元,权重梯度也是 0,这次更新跟它无关。
对b2的梯度直接就是δ_out,因为偏置的导数系数是 1。
2.3 第二跳:穿过激活函数这道闸门
真正有意思的是梯度过隐藏层这一跳。先把梯度从输出层传回隐藏层激活值:
∂L/∂h = W2ᵀ · δ_out
这一步是矩阵乘法反向传播的核心:前向用W2乘h,反向就用W2ᵀ乘回去。形状上W2ᵀ是 3×1,乘上 1 维的δ_out,得到 3 维向量,正好对上h。
接下来要穿过 ReLU。ReLU 的导数是分段常数:输入大于 0 时导数为 1,小于等于 0 时导数为 0。所以:
∂L/∂z1 = ∂L/∂h ⊙ 1[z1 > 0]
⊙表示逐元素相乘。这一步的物理含义非常干脆:前向时如果这个神经元没被激活(z1 ≤ 0),反向时它就完全收不到梯度,这次更新跟它彻底无关。
注意:这个“闸门”机制正是 ReLU 死神经元问题的来源。如果某个神经元的
z1长期落在负半轴,它就永远收不到梯度,权重永远不更新,等于被永久废弃。学习率设太大、初始化偏置设成较大的负数,都会加速这个过程。我见过一个案例,训练中期打印每层激活为 0 的比例,某一层直接到了 92%,那层基本等于废了。
继续往回,W1和b1的梯度:
∂L/∂W1 = ∂L/∂z1 · xᵀ(外积,形状 3×2,对上)∂L/∂b1 = ∂L/∂z1
到这里整条链路走完了。回头看这七行推导,结构极其规整:每一层的模式都是“上游传来的误差 × 本层输入的转置”。这也是为什么框架里一个Linear层只需要实现forward和backward两个方法,backward拿到的永远是上游梯度加自己缓存的输入。
2.4 形状对不上就是写错了:一张逐层的张量对齐表
手写反向传播 90% 的 bug 出在形状和转置上,而不是数学理解。我习惯在动手前先把形状表列出来,写完逐行核对。
| 步骤 | 表达式 | 输出形状 | 常见错误 |
|---|---|---|---|
| 输出误差 | δ_out = y_hat - y | (1,) | 忘记 reshape,变成标量 |
| 输出层权重梯度 | δ_out · hᵀ | (1,3) | 写成h · δ_outᵀ,方向反了 |
| 回传隐藏层 | W2ᵀ · δ_out | (3,) | 忘记转置,形状变 (1,) |
| 过 ReLU | · 1[z1>0] | (3,) | 用z1 >= 0还是> 0影响边界 |
| 隐藏层权重梯度 | ∂L/∂z1 · xᵀ | (3,2) | 内外积搞混,得到 (2,3) |
还有一个容易被忽略的细节:批处理维度。上面推的是单样本,实际训练时x会变成(batch, 2),此时∂L/∂W1要写成∂L/∂z1ᵀ · x,并且损失要对 batch 求平均。批大小一变,梯度的量级就跟着变,这也是“换个 batch size 就要重调学习率”的直接原因。
3. 三十行手写一个反向传播引擎,再用数值梯度验一遍
3.1 不依赖任何框架的完整实现
理论讲完必须落到代码,否则永远是纸上谈兵。下面这份实现只依赖基础的数组运算库,把前向、反向、参数更新全部摊开写,方便逐行对照上面的推导。
import numpy as np def init_params(seed=0): rng = np.random.default_rng(seed) W1 = rng.normal(0, np.sqrt(2.0 / 2), size=(3, 2)) # He 初始化,fan_in=2 b1 = np.zeros(3) W2 = rng.normal(0, np.sqrt(2.0 / 3), size=(1, 3)) # fan_in=3 b2 = np.zeros(1) return [W1, b1, W2, b2] def forward(params, x): W1, b1, W2, b2 = params z1 = W1 @ x + b1 h = np.maximum(z1, 0.0) y_hat = W2 @ h + b2 return y_hat, (x, z1, h) def loss_and_grad(params, x, y): W1, b1, W2, b2 = params y_hat, (x, z1, h) = forward(params, x) L = 0.5 * float((y_hat - y) ** 2) delta_out = (y_hat - y) # (1,) dW2 = np.outer(delta_out, h) # (1,3) db2 = delta_out.copy() # (1,) dh = W2.T @ delta_out # (3,) dz1 = dh * (z1 > 0) # (3,) dW1 = np.outer(dz1, x) # (3,2) db1 = dz1.copy() # (3,) return L, [dW1, db1, dW2, db2]十几行,把第 2 节推的东西全部对上了。np.outer(delta_out, h)算的就是δ_out · hᵀ,np.outer(dz1, x)算的就是∂L/∂z1 · xᵀ。
3.2 数值梯度校验:中心差分、步长与相对误差阈值
代码写完不敢直接训,得先验证反向写对了没有。做法就是回到最朴素的定义,用中心差分算一份“笨办法梯度”,和自己写的反向结果比对。
def numeric_grad(params, x, y, eps=1e-4): grads = [] for p in params: g = np.zeros_like(p) it = np.nditer(p, flags=['multi_index']) while not it.finished: idx = it.multi_index old = p[idx] p[idx] = old + eps lp, _ = loss_and_grad(params, x, y) p[idx] = old - eps lm, _ = loss_and_grad(params, x, y) p[idx] = old g[idx] = (lp - lm) / (2 * eps) it.iternext() grads.append(g) return grads def rel_error(a, b): return abs(a - b) / max(1e-12, abs(a) + abs(b))几个必须交代的细节。
为什么用中心差分而不是单侧差分:单侧差分的截断误差是 O(h),中心差分的截断误差是 O(h²),精度高一个量级,代价只是多算一次前向。在验证梯度这种“只做一次、必须可信”的场景里,这点代价完全可以接受。
步长为什么取 1e-4 而不是更小的 1e-8:中心差分的总误差由两部分组成,截断误差 O(h²) 和浮点舍入误差 O(ε/h),其中 ε 是机器精度(双精度约 2.2e-16)。理论最优步长大约在 1e-5 到 1e-6 之间。但实际写的时候,损失函数值本身可能有 1e-8 量级的数值噪声(尤其是经过多层累加之后),步长太小会被噪声完全淹没,算出毫无意义的差值。取 1e-4 是一个偏保守但很稳的选择,我一般先用它跑一遍,如果误差偏大再往 1e-5 降一档复测。
相对误差判据:绝对值误差在梯度量级差异大的时候完全不可用。判据一般按这个经验区间看:
| 相对误差 | 判断 |
|---|---|
| < 1e-7 | 基本可以放心,反向实现正确 |
| 1e-7 ~ 1e-4 | 大概率正确,但 ReLU 边界或浮点噪声可能掺进来 |
| 1e-4 ~ 1e-2 | 有 bug,逐层往下查 |
| > 1e-2 | 反向几乎肯定写错了 |
ReLU 的网络做梯度校验天然会有一点点误差,因为微小扰动可能让某个神经元的激活状态翻转,导致校验本身不连续。这种情况可以临时把激活换成 tanh 做验证,确认梯度的数学结构对不对,再换回 ReLU 跑实际训练。
3.3 校验不通过时的三种典型症状
我第一次写反向的时候,相对误差是 0.6,卡了整整一个晚上。事后总结,失败基本集中在三类。
症状一:误差不大不小,稳定在 1e-3 到 1e-2。这通常是漏了一项或者多了一项,比如忘了乘上游梯度,或者把某一层的梯度覆盖写成了赋值而不是累加。最典型的场景是“一个变量被两个分支用到”,反向时两路梯度必须相加,如果直接赋值,就只有最后一路生效。
症状二:误差极大,接近 1。符号写反了,或者该转置的地方没转置。梯度符号反了的表现特别容易识别——训练时损失不降反升,而且升得很稳定。
症状三:大部分参数误差正常,个别参数误差爆表。用np.argmax定位到具体下标,再看这个参数属于哪一层、参与哪个运算。我遇到过一次是偏置的梯度漏了,因为在手推时下意识觉得“偏置嘛,导数就是 1,直接复制上游就行”,但实际写代码时把上游变量在别处改过之后再复制,复制到的是改后的值。这种属于典型的“缓冲区被意外污染”,解法是反向里对每个中间量都做.copy()。
4. 梯度有了,参数怎么迈步:从朴素 SGD 到 Adam 的取舍
4.1 学习率是整场训练里最贵的那个数字
梯度给出了方向,但没说走多远。这个“走多远”由学习率控制,它是所有超参数里对结果影响最大的一个,也是最值得花时间去试的一个。
它的行为很好判断。设太大会看到什么:损失曲线在前几十步剧烈震荡,甚至直接跳到NaN,因为参数一步跨过了最优点,落到对面更高的地方,下一步再跨回来更远,来回放大直到溢出。设太小呢:损失确实在降,但降得极其缓慢,看着像一条缓慢下滑的直线,训练预算花完了模型还没收敛。
给一个可操作的量级参考:全连接网络配 Adam,从1e-3起手基本没错;换 SGD 的话从1e-2起手;如果是 Transformer 那类结构,1e-4到3e-4更常见。这个不是绝对数值,是一个“起手范围”,先在这个范围里找到损失能稳定下降的值,再做精调。
有个小技巧我一直在用:学习率扫描。从1e-7开始,每步乘以一个固定倍数(比如 1.3),每个学习率只训一两百步,把损失画出来。曲线通常在某个点之前是平的,然后突然开始下降,接着又急剧上升。最优学习率一般在这个拐点往下一点的位置。这个方法几分钟就能给出一个靠谱的初值,比瞎猜快得多。
4.2 动量:给梯度加一点惯性
朴素 SGD 的问题是它只看当前这一步的梯度,遇到损失面形状不好——好比一条又窄又长的峡谷——就会在两侧壁之间反复横跳,沿着谷底方向前进得很慢。
动量的做法是维护一个速度量:
v = β·v + gθ = θ - lr·v
β一般取 0.9。它把历史梯度的方向累积起来:那些在同一个方向上反复出现的梯度分量越积越大,而左右横跳的分量因为符号交替会互相抵消。效果就是沿着真正该走的方向加速,在振荡方向上被压住。
有个直观的解释我比较喜欢:SGD 像一个每一步都重新判断方向的登山者,动量像一颗滚下山的球,它保留了之前的速度,不会被一块小石头(局部噪声)立刻改变方向。这也意味着它可能冲过一个小坑——这是优点还是缺点,取决于那个坑是不是你想要的解。
4.3 Adam 的自适应步长与偏差修正
Adam 在动量之外又加了一层:它不只累积梯度的一阶矩,还累积梯度的平方(二阶矩),然后用二阶矩去归一化步长。
m = β₁·m + (1-β₁)·gv = β₂·v + (1-β₂)·g²m̂ = m / (1 - β₁ᵗ)v̂ = v / (1 - β₂ᵗ)θ = θ - lr · m̂ / (√v̂ + ε)
β₁取 0.9,β₂取 0.999,ε取 1e-8 量级。
这个结构的效果是:梯度一直很大的参数,v大,实际步长被压小;梯度一直很小的参数,v小,步长被放大。相当于给每个参数配了一个独立的学习率,在稀疏梯度、参数量级差异大的场景里优势明显。
那段偏差修正值得单独说一下,因为它经常被当成“可选细节”跳过,实际上没有它前期会出问题。初始化时m和v都是 0,第一步之后m = 0.1g,v = 0.001g²。如果不修正,m/√v = 0.1g / (0.0316·|g|) ≈ 3.16,符号方向对,但幅度被严重放大。除以1 - β^t之后,第一步m̂ = g,v̂ = g²,更新量正好是lr·sign(g),干净利落。所以修正项本质上是在补偿“零初始化让早期估计偏小”这件事。
4.4 三种优化器在不好的损失面上的表现
同一份代码换优化器,收敛速度和最终结果经常差不少。我把常见的几种放在一起对照:
| 优化器 | 关键参数 | 峡谷形损失面上的表现 | 对学习率的敏感度 | 适用场景 |
|---|---|---|---|---|
| 朴素 SGD | lr | 两侧反复震荡,前进缓慢 | 极高 | 需要精细控制收敛点、追求泛化的场景 |
| SGD + 动量 | lr,β=0.9 | 震荡被抑制,沿谷底加速 | 高 | 图像分类等成熟任务的主流选择 |
| Adam | lr,β₁,β₂ | 前期收敛极快,后期可能在最优点附近抖动 | 中 | 大多数场景的安全默认值 |
| AdamW | 同上 + 权重衰减 | 同 Adam,但泛化通常更好 | 中 | 带正则的训练,现代默认 |
我自己现在的习惯是:先上 Adam 快速验证模型结构和数据管线没问题,确认能训起来之后,如果对最终精度有要求,再考虑换带动量的 SGD 慢慢磨。Adam 前期那股快劲儿用来做实验迭代非常舒服,但它在最优点附近抖动的问题确实存在,需要配合学习率衰减来收尾。
5. loss 不降的时候,我按这个顺序排查
5.1 先别怀疑模型,先查数据与标签
损失一动不动,第一反应往往是“网络结构有问题”或者“反向传播写错了”。我踩过几次之后形成了一个固定的排查顺序,第一位永远是数据。
最值得做的具体检查:取一个 batch,把输入张量和标签手工对一遍。输入归一化了没有?标签的类别索引和输出的类别顺序对得上吗?有没有样本标签全错位一位的经典事故?数据加载器在多进程下有没有重复采样或漏采?这些听起来很低级,但它们导致的症状和“模型不学习”几乎一模一样——损失平着不动,梯度很小但确实不为零,训练看起来在跑,实际上在拿噪声拟合噪声。
有一个很快的验证手段:把标签和输入建立一条人为的确定映射,比如让标签恒等于输入的第一个特征的阈值化结果,然后训练。如果模型能很快把损失压到接近 0,说明整条训练链路是通的,问题在原始数据上。
5.2 单 batch 过拟合测试:我最依赖的一个动作
这一步能筛掉一大半问题,而且成本极低。做法是:只取 8 到 16 个样本,关掉所有正则和随机增强,把学习率调大一点,训几百步。
理论上,一个参数量远大于样本数的模型,应该能把这些样本的损失压到几乎为零。如果它做不到,那不用再往下查别的了,问题一定出在“表达能力”或“梯度传递”这两件事上。要么网络太浅或者太窄,要么反向传播的某一步是错的(这就回到第 3 节的数值梯度校验)。
这个测试的另一个好处是它把随机性剥掉了。全量训练时损失不降可能有很多解释,但小样本过拟合失败只有一个解释:模型压根没在学。
5.3 梯度消失与爆炸:打印每层的梯度范数
梯度在反向传播中是一层一层连乘出来的。每过一层乘上一个导数项,如果这些项普遍小于 1,连乘几十层之后梯度就变成天文数字分之一;普遍大于 1,就炸到无穷。这是前馈网络堆深之后最经典的困境。
诊断方法很直接:在每次反向之后,打印每个参数梯度的范数。
for name, p, g in zip(names, params, grads): print(f"{name:8s} | grad_norm={np.linalg.norm(g):.3e} | " f"param_norm={np.linalg.norm(p):.3e}")判断依据:如果从后往前各层梯度范数呈现出清晰的数量级递减(比如每层掉一个数量级),那就是消失;如果逐层暴涨,那就是爆炸。正常训练中,各层梯度范数应该是同一个量级范围内的波动,不该有系统性的单调趋势。
爆炸的常规解法是梯度裁剪,把梯度向量的总范数限制在一个阈值内(常用 1.0 或 5.0),超出就按比例缩放。这不会改变方向,只控制幅度。消失的解法是换激活函数(把 sigmoid 换 ReLU 系列)或者换初始化(见下一节)。
5.4 NaN 的六种来源与对应动作
NaN出现的时候训练就彻底废了,而且一旦出现就会沿着参数传播,后面的所有结果都不可信。我遇到过的情况归一下类:
| 来源 | 表现 | 处理 |
|---|---|---|
| 学习率过大导致参数发散 | 先出现 loss 暴涨再变 NaN | 学习率降一个量级重跑 |
| 对数里出现 0 | 分类损失突然 NaN | 在 log 内部加 1e-8 量级的小量 |
| 除零 | 归一化层或自定义损失 | 分母加 epsilon |
| 半精度溢出 | 混合精度训练中途 NaN | 关键累加用单精度,开损失缩放 |
| 输入含 NaN 或无穷 | 一开始就 NaN | 数据清洗,加断言检查 |
| 梯度爆炸 | 梯度范数逐层暴涨后 NaN | 梯度裁剪 |
一个很实用的小习惯:在训练循环里加入断言,一旦检测到非有限值就立刻中断并保存当前状态。让程序在出事的那一步停住,比跑完一整个 epoch 之后发现全是 NaN 有价值得多。
6. 从“能收敛”到“收敛得漂亮”的几个训练细节
6.1 初始化不是玄学:方差为什么必须稳住
很多人初始化直接normal(0, 0.01)一拍脑袋。这个做法在浅层网络上勉强能用,层数一多就出问题。
推导一下:假设输入各维独立、均值 0、方差为Var(x),权重独立同分布、均值 0、方差为Var(w),那么一层线性输出的方差是:
Var(z) = n_in · Var(w) · Var(x)
因为求和有n_in项,每一项的方差是Var(w)·Var(x)。要让输出方差等于输入方差(这样信号传播几十层都不放大不缩小),必须让Var(w) = 1/n_in。这就是 Xavier 初始化。
ReLU 会砍掉一半的激活值(负半轴归零),实际保留下来的方差大约减半,所以需要乘个 2 补回来:Var(w) = 2/n_in,这就是 He 初始化。第 3 节代码里用的np.sqrt(2.0/2)和np.sqrt(2.0/3)就是按这个公式算的。
初始化做对了,训练初期的损失下降会明显更平顺,也大大降低了梯度消失的风险。这个改动成本几乎为零,收益却很实在。
6.2 batch size 和学习率是绑在一起调的
批大小变大会带来两个后果:梯度估计的噪声变小(方向更准),以及同样的学习率下每步实际移动的有效距离变大。所以常见的经验法则是“批大小翻倍,学习率也适当放大”,具体倍数在 1 倍到 2 倍之间试,不是严格的线性关系。
但批大小不是越大越好。太小的批噪声大,训练不稳定;太大的批容易陷入尖锐的极小值,泛化性能有时反而下降。我一般的习惯是从 32 或 64 起步,显存允许的情况下往上加,同时观察验证集指标而不是只看训练损失。
6.3 预热和退火:让学习率走一条曲线
固定学习率是个妥协,两个阶段的需求其实是矛盾的:训练初期参数离最优解很远,需要大步走;训练后期已经在最优点附近,需要小步微调,否则会一直在坑边弹跳。
预热解决的是另一件事:训练最开始那几步,参数完全是随机的,梯度方向非常不可靠,此时用正常学习率可能一步就把参数带到很糟糕的区域。所以前几百步用一个从接近 0 线性爬升到目标值的曲线,等统计量稳定了再放开。
余弦退火是我现在最常用的收尾方案:
lr_t = lr_min + 0.5·(lr_max - lr_min)·(1 + cos(π·t/T))
曲线从lr_max平滑降到lr_min,前期降得慢、中期快、后期又慢下来。后期慢这一点特别重要,它给了模型足够的时间在最优点附近精修。相比阶梯式衰减,余弦曲线没有突变点,训练过程更平滑。
6.4 权重衰减和 Dropout 是两种不同性质的正则
这两个经常被混为一谈,但机制完全不同。
权重衰减(L2 正则)直接作用在参数上,在损失里加一项λ/2·||W||²,等价于每步更新时把权重往 0 的方向拉一点点。它抑制的是“权重过大”这件事,效果是让模型对输入的微小扰动不那么敏感。
Dropout作用在激活值上,训练时随机把一部分神经元输出置零,逼着网络不能依赖某几个特定神经元。它抑制的是“共适应”,效果类似每次都在训练一个不同的子网络,推理时相当于做了一次集成。
两者的取舍:权重衰减几乎无副作用,默认就可以开,λ从 1e-4 起手试。Dropout 在小数据集上效果好,但在大模型加大数据量时经常拖慢收敛,甚至会损害最终精度,我现在更多是在明确看到过拟合迹象时才加,而不是无脑开。
有一个细节特别容易踩:Dropout 在训练和推理时行为不同。训练时随机置零并按比例放大保留的输出,推理时不做置零。如果忘了切换模式,或者自己手写实现时漏了那个缩放因子,推理结果的量级会整体偏小。这类 bug 的症状是“训练损失降得很好,验证指标一塌糊涂”,而模型本身其实没问题。
最后分享一个我在调试训练时一直保持的习惯:每一轮实验只改一个变量,并且把改动、学习率、批大小、初始损失和几轮后的损失记在一个表里。听起来笨,但它能让你在几十次实验之后还清楚知道哪个改动真的起了作用,而不是靠记忆和感觉归因。前馈网络加反向传播这套东西,理论上早就写清楚了,实际训练里的绝大部分时间花的都是这类归因工作。