☰
从零手写反向传播:梯度计算、训练排查与工程化实践
2026/10/28 20:05:09 网站建设 项目流程

前馈网络把输入一层一层推到输出,反向传播才决定这套推法值不值得保留。上半部分把网络骨架、激活函数、损失函数这些东西搭完了,剩下的核心问题只有一个:模型凭什么"自己学"?答案落在反向传播上,也就是算出损失对每一个参数的偏导,再沿着负梯度方向把参数挪一小步。这件事听起来玄,落到代码里其实就是几十行矩阵乘法和一次链式法则的复用。这篇集中讲下篇该讲的东西:梯度到底怎么算出来、从零手写一遍要注意什么、训练不收敛时该按什么顺序排查、以及把模型从"能跑"推到"跑得好"需要补哪些工程细节。适合三类人看:写过前向传播但训练 loss 一直不动的人;用惯了自动求导框架、想知道 backward 里发生了什么的人;带新人时被问过"梯度为什么是这个方向"的人。代码用 NumPy 写,不依赖任何框架,跑通一遍再回头看框架文档,很多模糊的地方会一下子清楚。

1. 反向传播的本质:把"错在哪"翻译成"往哪调"

1.1 它不是一个网络层,而是一种求导策略

很多人第一次接触这个词,会以为反向传播是接在输出层后面的某个模块,这是最常见的理解偏差。前馈网络描述的是数据怎么流,反向传播描述的是梯度怎么算,两者不在一个层面上。前者是模型结构,后者是优化手段。

从计算的角度看,前向传播做的就是一次函数求值:给定输入和当前参数,算出输出和损失。反向传播要回答的是另一个问题:如果我把某个参数改动一点点,损失会变化多少。这个问题在数学上就是求偏导,而深度学习里参数动辄百万级,逐个求偏导不现实,所以需要一套高效的复用机制。

复用的是什么?是链式法则。网络是一层套一层的复合函数,复合函数的导数等于各层导数的乘积。关键在于,从输出往输入方向走的时候,每一层只需要知道"我这一层的输出对上一层输入的导数",再乘上"后面所有层累积下来的导数",就能得到这一层的梯度。后一段信息在反向走的过程中是逐层传递并复用的,不需要重复计算。这就是反向模式自动微分的全部精髓。

为什么方向是反的而不是正的?因为输出是标量,参数是向量。正向模式一次只能算出一个输入方向对输出的影响,参数有 N 个就得跑 N 遍;反向模式一次就能把全部 N 个参数的梯度都算出来,成本大约是一次前向传播的常数倍。这个成本差异在参数量大的时候是决定性的,所以主流框架无一例外选择反向。

用工厂打个比方。流水线上最后一道质检挑出次品,要追查是哪台机器出的问题。正向查得从原料端一台一台试,反向查则是每台机器都记录着自己对下一道工序的影响系数,从质检端往回,把系数一路乘回去,一次就能定位到所有环节。反向传播做的就是这件事,只不过"影响系数"换成了每一层的雅可比矩阵。

1.2 两层网络的完整链式推导

抽象说完,落到具体公式上。以一个两层网络为例,输入 X 形状是 (batch, d_in),隐藏层宽度 h,输出类别数 c。前向过程是这样:

第一层线性变换 Z1 = X·W1 + b1,形状 (batch, h);经过激活 A1 = ReLU(Z1);第二层线性变换 Z2 = A1·W2 + b2,形状 (batch, c);softmax 得到概率 P;交叉熵损失 L = -mean(log P[正确类别])。

反向从 L 开始。第一步是 softmax 加交叉熵的联合导数,这一步有个非常干净的结论:dZ2 = P - Y,其中 Y 是 one-hot 标签。为什么这么干净,简单说一下推导思路。softmax 输出的第 i 个分量对第 j 个输入的偏导,在 i 等于 j 时是 p_i(1-p_i),不等时是 -p_i·p_j,写成矩阵形式是 diag(p) - p·pᵀ。交叉熵对 softmax 输出的偏导是 -1/p_i(只在正确类别上非零)。两者相乘,交叉项恰好抵消,剩下 p_i - y_i。这个结果的工程意义很大:梯度的数值范围天然被限制在 [-1, 1] 区间内,不会因为概率接近 0 或 1 而爆掉。如果自己手动实现 softmax 再单独算交叉熵,很容易在 log(0) 上出问题,而把两者合成一个算子就绕开了这个坑。

继续往前。dW2 = A1ᵀ·dZ2,形状和 W2 一致,是 (h, c)。这一步的直觉是:权重的梯度等于它的输入乘以它收到的误差信号,输入越大、误差越大,这个权重该背的责任就越大。db2 = dZ2 在 batch 维度上求和,因为偏置对每个样本都加同一个值,梯度要累加。

再往前的 dA1 = dZ2·W2ᵀ,形状 (batch, h),表示隐藏层每个神经元收到的误差。然后穿过 ReLU:dZ1 = dA1 ⊙ 1[Z1 > 0],逐元素乘以一个掩码,前向时输出为负的位置梯度直接置零,因为 ReLU 在那一段是平的。最后 dW1 = Xᵀ·dZ1,db1 同理求和。

这六行公式就是反向传播在这个网络上的全部内容。写代码的时候建议先把每一行的形状标注在注释里,形状对不上一定是某一步转置写反了,这是排查反向传播问题最有效的手段,比打印数值快得多。

1.3 参数更新与学习率扮演的角色

拿到梯度之后,更新规则是 θ ← θ - η·∂L/∂θ,η 就是学习率。这里有个认知需要纠正:学习率不是"精度"参数,它决定的是你信不信当前这批数据算出来的梯度方向。梯度是在一个小批量样本上估出来的,样本不同,方向就会有抖动。学习率大,相当于大步流星地跟着这批样本走,容易在峡谷地形里来回横跳;学习率小,相当于小碎步,方向稳但走不完。

一个常见的误区是把学习率调小当成万能药。loss 不降就调小学习率,结果训练变慢但依然不降,问题可能根本不在学习率上,而是梯度根本没传回去,或者数据标签对不上。后面第 3 章会讲怎么区分这几种情况。

小批量带来的梯度噪声不是纯粹的坏事。全量梯度虽然方向准,但每次都一样,容易卡在鞍点或者平坦区域出不来。随机梯度的抖动相当于给优化过程加了一点探索性,这也是为什么实践中几乎没人用全量梯度训练。批量大小的选择本质是在"梯度估计的方差"和"单步计算效率"之间找平衡,这个后面还会展开。

2. 从零手写一遍:一次能跑通的最小实现

2.1 参数初始化与前向传播

先把结构定下来。为了避免依赖,初始化用 He 初始化,也就是方差取 2/fan_in 的正态分布。这个选择的理由在第 3 章会详细说,这里先记住它对 ReLU 网络是合适的。

import numpy as np def init_params(n_in, n_hidden, n_out, seed=42): rng = np.random.default_rng(seed) W1 = rng.normal(0, np.sqrt(2.0 / n_in), size=(n_in, n_hidden)) b1 = np.zeros(n_hidden) W2 = rng.normal(0, np.sqrt(2.0 / n_hidden), size=(n_hidden, n_out)) b2 = np.zeros(n_out) return [W1, b1, W2, b2] def forward(X, params): W1, b1, W2, b2 = params Z1 = X @ W1 + b1 A1 = np.maximum(0, Z1) Z2 = A1 @ W2 + b2 return Z1, A1, Z2

偏置初始化为零是安全的,因为对称性已经被随机权重打破了,不需要再给偏置加随机值。这一点和"权重不能全零初始化"是两回事,经常有人混淆。权重全零会导致同一层的所有神经元前向输出完全一样、梯度也完全一样,训练完还是对称的,等于白搭;偏置全零没有这个问题。

前向里的Z1需要缓存下来,因为反向过 ReLU 的时候要用它判断哪些位置是正的。如果只存A1,虽然也能用A1 > 0判断,但 ReLU 在恰好等于零的位置导数定义有歧义,存原始Z1更稳妥,也方便调试时观察激活前的分布。

2.2 反向传播的四步骨架

损失用带数值稳定处理的 softmax 交叉熵,减最大值这一步不能省,否则 exp 在数值大时会溢出成 inf。

def softmax_cross_entropy(Z2, y): Z2 = Z2 - Z2.max(axis=1, keepdims=True) exp = np.exp(Z2) P = exp / exp.sum(axis=1, keepdims=True) n = y.shape[0] loss = -np.log(P[np.arange(n), y] + 1e-12).mean() return loss, P def backward(X, y, params, cache): W1, b1, W2, b2 = params Z1, A1, Z2 = cache n = X.shape[0] _, P = softmax_cross_entropy(Z2, y) dZ2 = P.copy() dZ2[np.arange(n), y] -= 1.0 dZ2 /= n # 对齐 mean 损失 dW2 = A1.T @ dZ2 # (h, c) db2 = dZ2.sum(axis=0) # (c,) dA1 = dZ2 @ W2.T # (batch, h) dZ1 = dA1 * (Z1 > 0) # ReLU 掩码 dW1 = X.T @ dZ1 # (d_in, h) db1 = dZ1.sum(axis=0) return [dW1, db1, dW2, db2]

dZ2 /= n这一步是最容易漏的。损失用了mean,梯度也必须除以 batch 大小,否则梯度会随批量大小线性放大,换个批量就得重新调学习率。这个错误非常隐蔽,因为 loss 曲线看起来正常,只是学习率"莫名其妙"要调小十倍。

dZ2 = P.copy()之后再减一是必要的,不能直接对P原地修改,因为P后面可能还要用来算准确率或者做日志。这类原地修改引发的 bug 在 NumPy 里很常见,养成先 copy 的习惯能省下不少调试时间。

2.3 梯度数值校验:最该做但最常被跳过的一步

手写的反向传播对不对,靠肉眼看公式是靠不住的,一定要用数值梯度对一遍。原理是中心差分:对某个参数加上一个极小的 eps,算一次损失,再减去 eps 算一次,两者之差除以 2eps 就是这一点的数值导数。

def numeric_grad(loss_fn, x, eps=1e-5): grad = np.zeros_like(x) it = np.nditer(x, flags=['multi_index'], op_flags=['readwrite']) while not it.finished: idx = it.multi_index orig = x[idx] x[idx] = orig + eps f_plus = loss_fn() x[idx] = orig - eps f_minus = loss_fn() grad[idx] = (f_plus - f_minus) / (2 * eps) x[idx] = orig it.iternext() return grad def rel_error(a, b): return np.max(np.abs(a - b) / np.maximum(1e-8, np.abs(a) + np.abs(b)))

判断标准大致是:相对误差小于 1e-7 说明实现正确;1e-7 到 1e-4 之间基本可用,可能是 eps 选得不合适;大于 1e-3 就得回去查公式了。

几个实操要点。第一,校验必须用 float64,float32 的精度不够,差分会被舍入误差淹掉,算出来的相对误差永远是 1e-2 量级,让你误以为实现错了。第二,eps 取 1e-5 左右比较合适,太大会引入截断误差,太小会被浮点精度吃掉。第三,样本量要小,取 3 到 5 个样本、隐藏层 4 到 8 个神经元就够,全量跑数值校验慢得没有意义。第四,ReLU 在零点不可导,如果某个Z1恰好接近零,那一项的数值梯度和解析梯度会对不上,这属于正常现象,多跑几个随机种子看整体误差即可。

我自己的习惯是把这段校验写成一个独立脚本,每次改动网络结构或者损失函数就跑一遍,两三秒的事,比后面追一天的 bug 划算太多。

3. 训练现场的六类故障:反向传播链路上的排查顺序

3.1 梯度消失与梯度爆炸:先看量级再看分布

训练不收敛,第一件事不是改学习率,是打印每一层梯度的范数。做法很简单,在 backward 之后对每个梯度数组取np.linalg.norm,按层输出。健康的网络,各层梯度范数应该在同一个数量级或者相差不超过一两个数量级。如果dW1的范数是 1e-9 而dW2是 1e-2,那就是典型的梯度消失,靠近输入的层基本没在学。

成因是连乘。假设每层回传时梯度都被乘上一个小于 1 的因子,层数一多就是指数衰减。经典的 sigmoid 导数最大值只有 0.25,十层下来 0.25 的十次方是百万分之一量级,前面几层的梯度直接归零。反过来,如果每层的放大因子大于 1,就会指数增长,几层之后就溢出成 inf 或者 NaN。

判别方法上,梯度消失表现为 loss 下降极慢、前面的层参数几乎不动;梯度爆炸表现为 loss 突然跳到 NaN、梯度范数在几个 step 内从 1 涨到 1e6。两者的处理方向完全相反,所以必须先分清。

梯度爆炸的处理相对直接:先降学习率试,再看有没有加梯度裁剪。裁剪的实现是算全局范数,超过阈值就等比例缩放,阈值一般取 1.0 到 5.0 之间,对中小网络足够用。

梯度消失处理起来麻烦一些,常见手段是换激活函数(ReLU 系列替代 sigmoid/tanh)、改初始化、加归一化层、用残差连接让梯度有短路通道。

3.2 初始化、激活函数与归一化的组合选择

这三者是绑在一起的,单独调其中一个往往效果有限。

初始化方面,He 初始化方差取 2/fan_in,专为 ReLU 设计,考虑了一半神经元被置零的事实,把方差补回来。Xavier 初始化方差取 2/(fan_in+fan_out),适合 tanh 这类两侧饱和的激活。用 ReLU 配 Xavier,前几层方差会偏小;用 tanh 配 He,方差会偏大。这个对应关系搞反了,训练初期就会不稳。

激活函数方面,ReLU 的问题是"死亡神经元":如果某个神经元的输入长期为负,它的梯度恒为零,权重再也更新不了,永久失活。学习率设得过大时,一次更新就可能把一大片神经元推到负区。判别方法是统计每层激活值中零的比例,如果某一层超过 80% 都是零,基本可以确认死亡。缓解手段是换 LeakyReLU(负区给一个小的斜率,比如 0.01)或者把学习率降下来。

归一化方面,批归一化的作用是把每层输入的分布拉回均值 0、方差 1,让后面那层的权重不用去适应不断变化的输入尺度。它带来的直接好处是允许用更大的学习率,收敛更快。代价是训练和推理的行为不一致(推理时用的是滑动统计量),小批量时统计量不稳,序列长度不一的场景也不好用,所以现在很多结构改用层归一化。用不用归一化、用哪种,得结合具体任务和数据规模判断,不是无脑加上就一定好。

一个实践中的顺序建议:先确认初始化和激活匹配,再看要不要加归一化层,最后才调学习率。这个顺序反了,你会一直在学习率上打转。

3.3 学习率与批量大小的联动调法

学习率和批量大小不是独立的两个旋钮,它们之间存在经验性的缩放关系。批量放大 k 倍,梯度估计的方差大约降到 1/k,噪声变小了,步长就可以相应放大。线性缩放规则是 lr 也放大 k 倍,平方根规则是放大 √k 倍。前者更激进,配合 warmup 使用效果比较稳;后者更保守,大批量时往往更安全。

warmup 的必要性来自训练初期:此时参数是随机的,梯度方向不稳定,一上来就用大学习率容易把网络带偏。常见做法是前 5% 到 10% 的步数里让学习率从很小线性升到目标值,之后再按余弦或者阶梯衰减。

批量大小的选择有几个现实约束。太小(比如 8 以下)梯度噪声大、硬件利用率低;太大(超过几千)单次迭代的计算收益递减,而且有研究观察到泛化性能会略有下降,需要更多 epoch 才能达到同样效果。经验上,中小规模任务从 32 到 256 之间试起比较稳。

优化器方面,带动量的 SGD 在很多视觉任务上泛化更好,但需要仔细调学习率和动量系数;Adam 系收敛快、对学习率不敏感,适合快速验证想法。需要注意 Adam 和 L2 正则的配合方式,标准 Adam 里的自适应缩放会把 L2 惩罚也一起缩放掉,导致正则效果打折,所以现在更推荐用解耦权重衰减的版本。

4. 让模型真正学会:训练流程的工程化处理

4.1 数据管道:打乱、分批与最后一个不完整的批次

模型学得好不好,一半取决于数据管道。最基础的一条是每个 epoch 开始前必须打乱样本顺序。不打乱的话,如果数据按类别排序,连续几个批次都是同一类,梯度方向会剧烈摆动,模型实际上在几个类别之间来回横跳,收敛极慢。打乱时注意随机种子要单独控制,别和数据增强的随机性搅在一起,否则复现实验会很痛苦。

分批的时候,最后一个批次往往不满。有两种处理方式:丢弃(drop_last)或者保留。丢弃的好处是每个批次大小一致,批归一化的统计量稳定,训练曲线好看;坏处是每个 epoch 会丢掉一点数据,数据量小的时候损失明显。保留则相反,要注意小批次算出来的统计量噪声大,如果最后一批只有一两个样本,归一化层的方差估计会很离谱。我的做法是数据量大于一万时丢弃,小于一万时保留但把批次大小设成能整除的接近值。

输入的归一化也放在这一层做。把特征缩放到均值 0、方差 1,或者缩放到 [0,1] 区间,目的是让不同量纲的特征在梯度上处于同一量级。如果不做,量纲大的特征对应的权重梯度也会大,学习率对它是合适的,对量纲小的特征就太小了,训练会呈现"一部分参数在动、一部分不动"的怪象。归一化用的均值和方差必须从训练集统计,然后原样应用到验证集和测试集,用验证集自己的统计量是数据泄露,会让指标虚高。

4.2 监控指标与早停策略

训练过程中至少要同时盯三条线:训练损失、验证损失、验证集上的业务指标。三条线的走势组合能直接指向问题类型。

训练损失降、验证损失也降,但都降得很慢,通常是学习率偏小或者模型容量不足。训练损失快速降到很低、验证损失在某个点之后开始上升,是典型的过拟合,此时验证指标往往在验证损失拐点附近达到峰值。两条线都在剧烈震荡,学习率偏大或者批次太小。训练损失完全不降,回到第 3 章排查梯度。验证损失出现 NaN,先查数据里有没有异常值或者标签越界。

早停的实现逻辑是:每个 epoch 结束后比较验证指标,如果连续 patience 个 epoch 没有改善就停,同时保存历史最优的那份权重。patience 取多少取决于训练总时长和数据噪声,一般 5 到 20 之间。数据噪声大、指标抖动厉害的时候取大一点,否则容易在正常波动里被误判为不再改善。保存最优权重这一步别省,训练结束时手上的模型很可能不是最好的那个。

还有一个容易被忽略的细节:如果用了学习率衰减策略,验证指标在衰减点附近会有台阶式变化,早停的 patience 要能覆盖这个周期,否则会在衰减带来提升之前就停了。

4.3 正则化手段的取舍与叠加

过拟合的应对手段不少,但不是叠加得越多越好,加多了会欠拟合,训练集都拟合不上。

权重衰减是最基础的,在损失里加上参数平方和的一半乘以系数,等价于每次更新时把权重往零的方向拉一点。系数一般从 1e-4 开始试,量级跨度可以到 1e-2。注意只对权重做衰减,偏置和归一化层的缩放参数不加,加了反而有害。

Dropout 是在训练时按概率随机把一部分神经元的输出置零,逼着网络不依赖某几个特定神经元。推理时必须关掉,并且训练时要按保留概率做缩放,保证训练和推理的期望输出一致。常见比例是 0.1 到 0.5,全连接层用得多,卷积层用得少。

数据增强对视觉、语音这类任务往往比前两者都有效,因为它直接扩充了数据分布。但增强策略要和任务匹配,把图像上下翻转用在文字识别上就是灾难。标签平滑是另一个性价比很高的手段,把 one-hot 标签里的 1 换成 0.9、0 换成 0.1/(类别数-1),能抑制模型对单个样本过度自信,训练更稳。代价是损失曲线的绝对值不再有明确意义,不要拿来做跨实验对比。

5. 常见问题速查与实战经验

5.1 现象到原因的对排查表

把上面几章的内容压缩成一张表,出问题时按顺序对:

现象首选排查项常见原因处理方向
训练损失完全不降每层梯度范数反向传播实现错误、标签与输出错位数值梯度校验、检查标签映射
损失下降极慢学习率、初始化学习率过小、初始化方差偏小学习率放大 10 倍试、核对初始化与激活匹配
损失突然变 NaN梯度范数、输入范围梯度爆炸、输入未归一化含极端值梯度裁剪、输入标准化、降学习率
前几层参数几乎不动各层梯度量级梯度消失换 ReLU、加归一化、残差连接
某层激活大量为零激活值中零的比例ReLU 死亡降学习率、换 LeakyReLU
训练损失低但验证差训练/验证损失曲线过拟合权重衰减、数据增强、早停
两条损失曲线都震荡学习率、批量大小步长过大、批内样本太少降学习率、增大批次
验证指标反复横跳验证集规模、评估方式验证集太小、指标对阈值敏感增大验证集、换稳定指标

这张表不是穷举,但覆盖了绝大多数初次训练会遇到的情况。排查顺序的原则是"从下往上":先确认梯度确实传回去了,再谈学习率和正则,否则上层怎么调都是盲调。

5.2 几条踩坑换来的经验

第一条,先在小数据上过拟合。拿 100 个样本、关掉所有正则,训练到损失接近零。如果做不到,说明代码有 bug,别急着上全量数据。这个"过拟合测试"能筛掉大部分实现问题,包括标签错位、梯度算错、更新方向写反。

第二条,把形状检查写成断言。反向传播里每一行的输出形状都是确定的,写成 assert 放在代码里,比事后对着报错信息猜快十倍。转置写反是这类代码最高频的错误,形状断言能第一时间抓住。

第三条,学习率扫描比精细调参有效。不确定学习率时,用 1e-4、3e-4、1e-3、3e-3 各跑几十步看损失曲线,选下降最快又不震荡的那个量级,再在这个量级附近细化。这比凭感觉设一个值然后反复微调效率高得多。

第四条,随机种子要固定但要换着试。单次实验的结果可能只是某个种子的运气,关键决策至少跑三个种子看均值。但日常调试时固定种子便于对比,改动一处看变化,这个习惯能帮你把因果关系理清楚。

第五条,训练日志里记下所有超参。包括学习率、批量、初始化方式、正则系数、数据增强策略。两三周后回看某次实验效果特别好,却想不起来当时设了什么,这种懊恼我经历过不止一次。日志里多写一行,未来少走一段弯路。

第六条,不要相信单点的 loss 数值。小批量训练的 loss 曲线天然带噪声,看趋势不看单点。判断收敛与否,用滑动平均之后的曲线,窗口取 20 到 50 个 step。很多人因为某一步 loss 突然升高就以为出问题了,其实只是那个批次比较难。

这几个习惯里,第一条和第 2.3 节的数值梯度校验是我认为收益最高的两个。它们花的时间加起来不到半小时,但能挡掉后续大量说不清原因的调试。反向传播这套东西,公式看懂了和代码写对了之间隔着一道坎,代码写对了和训练跑得好之间还隔着一道,两道坎都得自己踩一遍才踏实。我个人的体会是,与其反复读推导,不如把这几十行代码敲一遍,然后故意改错一个地方,看数值校验能不能抓出来,这个反向验证的过程对理解深度的提升,比看十篇讲解都直接。

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

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

立即咨询