☰
从反向传播到梯度下降:前馈网络训练原理与排查
2026/10/28 3:13:59 网站建设 项目流程

把前向传播跑通的那一刻,很多人会产生一种错觉:模型已经跑起来了。数据丢进去,输出吐出来,控制台不报错,看起来一切正常。可只要盯一眼输出值——还是那个接近随机的数。前向传播完成的只是“猜”这个动作,它并没有完成“学”。上篇我们把输入一层层推到输出,算出了一串加权和与激活值;这一篇要处理的是剩下那一半,也是最容易被初学者跳过、被老手反复回头补课的那一半:误差怎么沿着原路返回来,变成每一个权重上的一次微小调整。这就是前馈网络真正开始自我学习的地方——反向传播加上梯度下降。链式法则是这条回路的骨架,梯度是流动的血液,而学习率是控制血流速度的阀门。这篇内容适合两类人:一类是刚啃完前向公式、看得懂矩阵乘法但不知道梯度怎么落地的人;另一类是把模型训起来过、但 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 三种优化器在不好的损失面上的表现

同一份代码换优化器,收敛速度和最终结果经常差不少。我把常见的几种放在一起对照:

优化器关键参数峡谷形损失面上的表现对学习率的敏感度适用场景
朴素 SGDlr两侧反复震荡,前进缓慢极高需要精细控制收敛点、追求泛化的场景
SGD + 动量lr,β=0.9震荡被抑制,沿谷底加速高图像分类等成熟任务的主流选择
Adamlr,β₁,β₂前期收敛极快,后期可能在最优点附近抖动中大多数场景的安全默认值
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 的症状是“训练损失降得很好,验证指标一塌糊涂”,而模型本身其实没问题。

最后分享一个我在调试训练时一直保持的习惯:每一轮实验只改一个变量,并且把改动、学习率、批大小、初始损失和几轮后的损失记在一个表里。听起来笨,但它能让你在几十次实验之后还清楚知道哪个改动真的起了作用,而不是靠记忆和感觉归因。前馈网络加反向传播这套东西,理论上早就写清楚了,实际训练里的绝大部分时间花的都是这类归因工作。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询