全连接神经网络的公式推导,说难不难,但真能从头到尾手推一遍的人,十个里面可能不到五个。我见过不少同学用框架用得飞起,一行model.fit()就把网络训起来了,可一旦要换损失函数、改网络结构、排查梯度异常,就完全抓瞎。这篇就彻底把全连接神经网络的正向传播(forward propagation)和反向传播(backpropagation)公式掰开揉碎,带你一步一步推完。
我会把推导过程中的每个符号都交代清楚,每一处变形都讲明白为什么这么做。文末还会给一个2-2-1网络的完整数值算例,从权重初始化算到梯度更新,整个过程你可以跟着笔算一遍,算完基本就再也不怕反向传播了。无论你是刚入门深度学习的学生,还是工作中需要手写网络的老手,这篇都值得你从头读到尾,再自己推一遍。
1. 全连接神经网络在做什么:先建立整体直觉
1.1 从感知机到多层网络
在拆公式之前,先把全连接神经网络(Fully Connected Neural Network,也叫多层感知机 MLP)到底在做什么说清楚。单个感知机做的事情非常简单:把输入向量做一次线性加权,再过一层非线性激活函数,输出一个结果。但单层感知机的表达能力极其有限,连异或(XOR)这类简单函数都拟合不了,这就是当年大家说感知机“不行”的原因。
多层网络的出现解决了这个问题。它的思路很朴素:先用第一层把输入做一次线性变换和非线性变换,得到一组“中间表示”;再把中间表示作为第二层的输入,再做一次线性变换和非线性变换,得到更高层的表示。堆叠多层之后,网络就有了拟合任意复杂函数的能力。这就是为什么它叫“全连接”——每一层的每个神经元,都和上一层的所有神经元相连。
每一个连接上都有一个权重(weight),每个神经元还有一个偏置(bias)。整个网络要学的东西,就是这一大堆权重和偏置。那怎么学呢?靠的是梯度下降:算出一个损失函数(loss)对每个参数的偏导数,然后沿着梯度的反方向更新参数。正向传播负责算出损失,反向传播负责高效地算出这些梯度。
1.2 一次完整的“输入到输出”旅程
全连接神经网络的一次完整计算,可以拆成两个阶段。
第一个阶段是正向传播。拿一张手写数字图片来说,输入是一个28×28的灰度图,拉平后变成784维的向量。这个向量被喂进网络,经过若干隐藏层的线性变换和激活函数,最终在输出层得到10个数字(对应0-9的概率)。我们把这些概率和真实标签做比较,得到一个损失值。
第二个阶段是反向传播。从输出层的损失出发,利用链式法则,把“损失对每一层参数的偏导”从后往前一层层传回去。拿到这些梯度之后,用梯度下降把每个参数往损失减小的方向调整一点点。这两个阶段合在一起,就是一次完整的训练迭代。
这里最容易被忽略的点是:反向传播不是一个独立的训练算法,它只是“高效计算梯度”的方法。真正让参数动起来的是梯度下降(Gradient Descent),反向传播为它提供了每个参数所需的梯度值。很多人觉得反向传播很难,其实拆开看,它就是把一个复杂的复合函数求导问题,用链式法则逐层拆解而已。
1.3 为什么公式推导值得亲自动手
市面上深度学习框架太多了,装了环境之后几行代码就能跑一个模型。这种便利带来的副作用是,很多人不知道网络内部到底发生了什么。框架帮你把梯度算好了,但如果不理解梯度是怎么来的,遇到问题就只能干瞪眼。
我自己带过一些新人,发现能熟练手推反向传播的人,调参时的思路完全不一样。比如当模型loss不降,有人会去调学习率、换优化器、加正则化,但本质上他自己都不知道问题出在前向还是反向。而能手推公式的人,第一反应是“检查一下梯度有没有消失”,第二反应是“用梯度检查验证一下实现是否正确”,这完全是两个段位。
还有一点很实际:当你需要自定义一个损失函数、设计一个新的激活函数,或者实现一些框架不直接支持的网络结构时,你必须自己求梯度。如果不懂反向传播的公式推导,这一步就是死路一条。手推公式这件事,越早做越省力。
2. 正向传播公式推导:前进路上的每一步
2.1 符号约定:把网络结构翻译成数学语言
推导公式之前,先把符号统一。不然推着推着就乱了,这是新手最常见的问题。我们以一个L层的全连接网络为例,约定如下:
- 输入向量记为x,维度为 (n_0)。
- 第 (l) 层的权重矩阵记为 (W^{(l)}),形状是 (n_l \times n_{l-1})。(W^{(l)}_{ij}) 表示第 (l) 层的第 (i) 个神经元与第 (l-1) 层的第 (j) 个神经元之间的连接权重。
- 第 (l) 层的偏置向量记为 (b^{(l)}),形状是 (n_l \times 1)。
- 第 (l) 层的线性输出记为 (z^{(l)} = W^{(l)} a^{(l-1)} + b^{(l)}),形状是 (n_l \times 1)。
- 第 (l) 层的激活输出记为 (a^{(l)} = \sigma(z^{(l)})),其中 (\sigma(\cdot)) 是激活函数。
- 特别地,(a^{(0)} = x),即网络的输入就是第0层的激活输出。
- 损失函数记为 (L),是整个网络优化的目标。
很多教材里会把 (z) 和 (a) 混在一起,导致推导的时候搞不清楚到底对谁求导。我的习惯是:凡是还没经过激活函数的,就叫 (z);凡是经过激活函数的,就叫 (a)。这样每一步都清清楚楚。
这里还要强调一个容易踩的坑:权重矩阵 (W^{(l)}) 的行列方向。我上面的约定是 (z = W a^{(l-1)} + b),也就是说 (W) 乘以上一层的激活输出。如果你采用列向量表示,那么 (W^{(l)}) 的第 (i) 行就是第 (l) 层第 (i) 个神经元的所有输入权重。这个约定一定要在推导前定好,否则之后所有的公式都会差一个转置。
2.2 线性变换与激活函数
正向传播的每一步,本质上都是“线性变换 + 非线性激活”的组合拳。
先看线性变换这一部分:
[ z^{(l)} = W^{(l)} a^{(l-1)} + b^{(l)} ]
这个式子展开来看,就是第 (l) 层的第 (i) 个神经元接收到的加权求和输入:
[ z^{(l)}i = \sum{j=1}^{n_{l-1}} W^{(l)}_{ij} a^{(l-1)}_j + b^{(l)}_i ]
这和一维线性回归的形式一模一样,只不过把标量输入换成了向量,把标量权重换成了权重矩阵。偏置项 (b) 的作用是让线性变换不强制过原点,增加模型的拟合能力。如果没有偏置,当输入全为零的时候,输出永远为零,模型能力会大打折扣。
线性变换之后,马上接一个非线性激活函数:
[ a^{(l)} = \sigma(z^{(l)}) ]
为什么要加非线性?如果不加激活函数,不管堆多少层,网络的整体输出都只是输入的线性组合,那“深度”就没有任何意义了。激活函数引入非线性,让网络可以逼近任意复杂函数。
常用的激活函数有这么几个:
- Sigmoid:(\sigma(z) = \frac{1}{1+e^{-z}}),输出范围(0,1),适合作为输出层的概率输出,但隐藏层用它容易梯度消失。
- Tanh:(\tanh(z) = \frac{e^z - e^{-z}}{e^z + e^{-z}}),输出范围(-1,1),均值为0,比sigmoid收敛快一点。
- ReLU:(\text{ReLU}(z) = \max(0, z)),目前隐藏层最常用的激活函数,计算简单,能缓解梯度消失,但要注意“死神经元”问题。
这三种激活函数的导数都很简单,反向传播时会用到,建议直接记住:
- Sigmoid 的导数:(\sigma'(z) = \sigma(z)(1 - \sigma(z)))。这个形式非常优雅,求导结果可以直接用前向传播时算好的激活值来表示,不需要额外计算。
- Tanh 的导数:(\tanh'(z) = 1 - \tanh^2(z))。
- ReLU 的导数:当 (z > 0) 时为1,当 (z \le 0) 时为0(严格来说在0处不可导,但实践中通常把0处的导数定为0)。
正向传播就是从 (a^{(0)} = x) 开始,逐层计算 (z^{(l)}) 和 (a^{(l)}),直到输出层得到最终预测 (\hat{y} = a^{(L)})。这个过程非常机械,但它是反向传播的基础——因为反向传播要用到每一层的 (z) 和 (a) 值。
2.3 损失函数如何把“差距”变为“数字”
正向传播的最后一步,是用输出层的预测值和真实标签计算损失。损失函数的选择取决于任务类型。
回归任务最常用均方误差(MSE):
[ L = \frac{1}{2} | y - \hat{y} |^2 = \frac{1}{2} \sum_{i=1}^{n_L} (y_i - \hat{y}_i)^2 ]
前面为什么要加 (\frac{1}{2})?纯粹是为了求导方便——平方项求导会出来一个2,乘上1/2之后就抵消了,梯度表达式更干净。
分类任务最常用交叉熵(Cross Entropy)。对于二分类,输出层只有一个神经元,用sigmoid激活,损失是:
[ L = -[y \log \hat{y} + (1-y) \log(1-\hat{y})] ]
对于多分类,输出层用softmax激活,损失是:
[ L = -\sum_{i=1}^{n_L} y_i \log \hat{y}_i ]
这里 (y) 是one-hot形式的真实标签向量,(\hat{y}) 是softmax输出。
损失函数的选择会对反向传播的输出层误差项产生直接影响。我在下一节会专门推导这两种常见组合的梯度,很多框架里可以直接调用loss.backward(),但我们手动实现时,必须自己把这一步算对。
3. 反向传播公式推导:梯度是怎么一步步传回来的
3.1 链式法则:反向传播的地基
反向传播的核心工具就是高等数学里的链式法则。链式法则说的其实是一件事:复合函数的导数,等于外层函数对内层函数的导数乘以内层函数对自变量的导数。
举个例子,如果 (y = f(g(x))),那么:
[ \frac{dy}{dx} = f'(g(x)) \cdot g'(x) ]
神经网络就是一个超级大的复合函数。损失函数 (L) 依赖于输出层的激活值 (a^{(L)}),(a^{(L)}) 依赖于 (z^{(L)}),(z^{(L)}) 依赖于权重 (W^{(L)}) 和上一层的 (a^{(L-1)}),而 (a^{(L-1)}) 又继续往前依赖。反向传播要做的,就是沿着这条依赖链从后往前,把所有梯度一层层算出来。
如果不用链式法则,最笨的办法是用数值微分(有限差分法)逐个估计每个参数的梯度。但神经网络参数动辄几百万个,用数值微分算一次梯度慢到没法用。反向传播的聪明之处在于:它只在每层计算一个“中间误差项”,然后把这个误差项往上一层传,每个参数的梯度都可以用这个误差项快速算出来,总计算量几乎只是正向传播的两倍。这就是反向传播在实际训练中能跑得动的原因。
3.2 输出层的误差项推导
反向传播的第一步,是计算输出层的“误差项”。这里我定义一个核心符号:
[ \delta^{(l)} = \frac{\partial L}{\partial z^{(l)}} ]
也就是损失函数对第 (l) 层线性输出 (z^{(l)}) 的偏导。这个 (\delta^{(l)}) 是整个反向传播的“接力棒”——每一层的梯度都由它生成,它又能反向传播给上一层。
先推导输出层的 (\delta^{(L)})。直接套链式法则:
[ \delta^{(L)} = \frac{\partial L}{\partial z^{(L)}} = \frac{\partial L}{\partial a^{(L)}} \cdot \frac{\partial a^{(L)}}{\partial z^{(L)}} = \frac{\partial L}{\partial a^{(L)}} \odot \sigma'(z^{(L)}) ]
这里的 (\odot) 是逐元素相乘(Hadamard积)。注意,因为 (z^{(L)}) 是一个向量,(\frac{\partial L}{\partial z^{(L)}}) 也是一个向量,而后面那个 (\sigma'(z^{(L)})) 是对每个元素分别求导后组成的向量。
具体算一下两个最常见的组合。
第一种:MSE损失 + Sigmoid激活。此时:
[ L = \frac{1}{2}(y - a^{(L)})^2 ] [ \frac{\partial L}{\partial a^{(L)}} = a^{(L)} - y ] [ \sigma'(z^{(L)}) = a^{(L)}(1 - a^{(L)}) ]
所以输出层误差项为:
[ \delta^{(L)} = (a^{(L)} - y) \odot a^{(L)}(1 - a^{(L)}) ]
这个式子很直观:预测和真实值差得越远,误差项越大;同时,sigmoid饱和区(激活值接近0或1)的梯度接近0,会削弱误差信号——这就是后面要讲的梯度消失问题的雏形。
第二种:Softmax激活 + 交叉熵损失。这种情况下有一个非常优雅的结果:
[ \delta^{(L)} = \hat{y} - y ]
这个结果看起来简单,但推导过程却需要小心处理softmax的雅可比矩阵。直接从损失对 (z^{(L)}) 求导,经过一堆化简之后,最终会得到这么简洁的形式。这也是为什么实践中几乎总是用“softmax + 交叉熵”作为分类任务的标配——不仅理论上匹配(交叉熵适合概率分布),梯度表达式也最简洁,数值上最稳定。
如果输出层是“sigmoid + 交叉熵”(二分类常用),那输出层误差项就是 (\hat{y} - y),和softmax版本结果一样,也是一个很简洁的形式。
重点记住了:输出层的 (\delta^{(L)}) 一旦算出来,后面每一层的梯度就都能顺着这个误差项往回推了。
3.3 隐藏层的误差项推导
现在进入反向传播最关键的一步:怎么把输出层的误差项往上一层传?
我们要算的是 (\delta^{(l)}),也就是第 (l) 层的线性输出 (z^{(l)}) 对损失函数的偏导。(z^{(l)}) 影响损失函数的路径只有一个:它先通过激活函数得到 (a^{(l)}),(a^{(l)}) 再通过权重矩阵 (W^{(l+1)}) 影响 (z^{(l+1)}),最终传导到损失函数。
先看 (z^{(l)}) 的各个分量 (z^{(l)}_i) 是怎么影响下一层的。根据正向传播的定义:
[ z^{(l+1)}k = \sum{i=1}^{n_l} W^{(l+1)}_{ki} a^{(l)}_i + b^{(l+1)}_k ]
也就是说,(z^{(l)}_i) 通过 (a^{(l)}_i = \sigma(z^{(l)}_i)) 影响所有下一层的 (z^{(l+1)}_k)。用链式法则,把所有路径加起来:
[ \delta^{(l)}i = \frac{\partial L}{\partial z^{(l)}i} = \sum{k=1}^{n{l+1}} \frac{\partial L}{\partial z^{(l+1)}_k} \cdot \frac{\partial z^{(l+1)}_k}{\partial a^{(l)}_i} \cdot \frac{\partial a^{(l)}_i}{\partial z^{(l)}_i} ]
其中 (\frac{\partial L}{\partial z^{(l+1)}_k}) 正好就是下一层的误差项 (\delta^{(l+1)}_k),而:
[ \frac{\partial z^{(l+1)}_k}{\partial a^{(l)}i} = W^{(l+1)}{ki}, \quad \frac{\partial a^{(l)}_i}{\partial z^{(l)}_i} = \sigma'(z^{(l)}_i) ]
代入之后:
[ \delta^{(l)}i = \left( \sum{k=1}^{n_{l+1}} \delta^{(l+1)}k W^{(l+1)}{ki} \right) \sigma'(z^{(l)}_i) ]
把这个式子写成向量形式,就是反向传播最核心的公式:
[ \delta^{(l)} = (W^{(l+1)})^T \delta^{(l+1)} \odot \sigma'(z^{(l)}) ]
这个公式我要重点强调几点。第一,为什么是转置 ((W^{(l+1)})^T)?因为在正向传播时,信号经过 (W^{(l+1)}) 从 (a^{(l)}) 流向 (z^{(l+1)});反向传播是反着走的,所以权重矩阵也要转置一下,把误差信号从输出端“映射”回输入端。第二,(\sigma'(z^{(l)})) 是逐元素乘上去的,这个操作可以理解为在每一层内部对误差信号做一次“过滤”——如果某个神经元的激活函数导数接近0,那么传过这个神经元的误差信号就会被大幅衰减。第三,这个公式是层层递推的:从输出层的 (\delta^{(L)}) 出发,利用该公式可以依次算出 (\delta^{(L-1)}, \delta^{(L-2)}, \dots, \delta^{(1)}),这就是“反向”二字的由来。
一个容易困惑的地方是:为什么隐藏层的误差项不是直接用损失对激活值的偏导,而是对线性输出 (z) 的偏导?因为我们要算的参数梯度(对 (W) 和 (b) 的偏导)都会经过 (z),所以统一用 (\delta^{(l)}) 作为中间变量最方便。如果你直接算对 (a) 的偏导,回头算 (W) 的梯度时还要再乘以 (\sigma'(z)),绕了一圈反而麻烦。
3.4 参数梯度的完整求解
拿到 (\delta^{(l)}) 之后,计算每个参数的梯度就非常直接了。
先看权重矩阵 (W^{(l)}) 的梯度。(L) 对 (W^{(l)}_{ij}) 的依赖路径是:(z^{(l)}i = \sum_j W^{(l)}{ij} a^{(l-1)}_j + b^{(l)}_i)。所以:
[ \frac{\partial L}{\partial W^{(l)}_{ij}} = \frac{\partial L}{\partial z^{(l)}_i} \cdot \frac{\partial z^{(l)}i}{\partial W^{(l)}{ij}} = \delta^{(l)}_i \cdot a^{(l-1)}_j ]
写成矩阵形式:
[ \frac{\partial L}{\partial W^{(l)}} = \delta^{(l)} (a^{(l-1)})^T ]
注意这里的乘法和前面的逐元素乘法不一样——这是真正的矩阵乘法。(\delta^{(l)}) 的形状是 (n_l \times 1),((a^{(l-1)})^T) 的形状是 (1 \times n_{l-1}),两者相乘得到 (n_l \times n_{l-1}) 的矩阵,正好和 (W^{(l)}) 的形状一致。
偏置的梯度更简单。由于 (\frac{\partial z^{(l)}_i}{\partial b^{(l)}_i} = 1),所以:
[ \frac{\partial L}{\partial b^{(l)}} = \delta^{(l)} ]
到这里,反向传播的全部公式已经齐了。总结一下整个流程:
- 正向传播:逐层计算 (z^{(l)}) 和 (a^{(l)}),得到输出层的预测值,并计算损失 (L)。
- 计算输出层误差:(\delta^{(L)} = \frac{\partial L}{\partial z^{(L)}})(根据损失函数和激活函数的不同,可能是一个简洁表达式,也可能需要逐项计算)。
- 反向传播误差:从 (l = L-1) 到 (1),利用 (\delta^{(l)} = (W^{(l+1)})^T \delta^{(l+1)} \odot \sigma'(z^{(l)})) 逐层计算。
- 计算梯度:(\frac{\partial L}{\partial W^{(l)}} = \delta^{(l)} (a^{(l-1)})^T),(\frac{\partial L}{\partial b^{(l)}} = \delta^{(l)})。
- 更新参数:(W^{(l)} \leftarrow W^{(l)} - \eta \frac{\partial L}{\partial W^{(l)}}),(b^{(l)} \leftarrow b^{(l)} - \eta \frac{\partial L}{\partial b^{(l)}}),其中 (\eta) 是学习率。
这里还有一个小细节:如果需要正则化(比如L2正则化),那么损失函数里会多出一项 (\frac{\lambda}{2} \sum_l |W^{(l)}|^2),对应的梯度项也会多出 (\lambda W^{(l)})。这个附加项加到上面的梯度里即可,非常容易实现。
4. 手把手数值验证:从公式到实数
4.1 前向传播实测
纸上谈兵不如动手算一遍。我构造一个具体的2-2-1网络(2个输入、1个隐藏层含2个神经元、1个输出神经元),把所有参数的初始值设成好算的数字,然后一步一步把前向和反向都算出来。
网络参数初始化如下:
- 输入:(x = [1.0, 2.0]^T)
- 真实标签:(y = 1)
- 隐藏层权重:(W^{(1)} = \begin{bmatrix} 0.1 & 0.2 \ 0.3 & 0.4 \end{bmatrix})
- 隐藏层偏置:(b^{(1)} = [0.5, 0.5]^T)
- 输出层权重:(W^{(2)} = [0.6, 0.7])(形状 (1 \times 2))
- 输出层偏置:(b^{(2)} = 0.8)
激活函数统一用sigmoid,损失函数用MSE(这样算起来每一步都清晰,方便你核对)。
先从正向传播开始。隐藏层的线性输出:
[ z^{(1)}_1 = 0.1 \times 1.0 + 0.2 \times 2.0 + 0.5 = 1.0 ] [ z^{(1)}_2 = 0.3 \times 1.0 + 0.4 \times 2.0 + 0.5 = 1.6 ]
sigmoid激活:
[ a^{(1)}_1 = \sigma(1.0) = \frac{1}{1+e^{-1}} \approx 0.7311 ] [ a^{(1)}_2 = \sigma(1.6) \approx 0.8320 ]
输出层线性输出:
[ z^{(2)} = 0.6 \times 0.7311 + 0.7 \times 0.8320 + 0.8 \approx 1.8211 ]
sigmoid激活:
[ \hat{y} = a^{(2)} = \sigma(1.8211) \approx 0.8608 ]
损失:
[ L = \frac{1}{2}(y - \hat{y})^2 = \frac{1}{2}(1 - 0.8608)^2 \approx 0.00969 ]
这就是一次完整的前向传播。你可能会觉得这一步很机械,但正是这些“中间产物”——(z^{(1)}, a^{(1)}, z^{(2)})——在后面反向传播时全都要用到。所以手推的时候,我建议你把每一步的中间值都写在纸上,后面算梯度的时候直接引用,不然容易乱。
4.2 反向传播实测
现在从损失出发,一层层往回推。
第一步是计算输出层误差项 (\delta^{(2)})。因为用的是MSE + sigmoid,套用3.2节的公式:
[ \delta^{(2)} = (a^{(2)} - y) \cdot a^{(2)}(1 - a^{(2)}) = (0.8608 - 1) \times 0.8608 \times (1 - 0.8608) \approx -0.01668 ]
注意这里只有一个神经元,所以没有逐元素乘法,直接标量相乘。这个误差项是负的,表示预测值偏大(0.8608 > 1),需要把输出往下压。
接下来算 (\delta^{(1)})。用隐藏层误差公式:
[ \delta^{(1)} = (W^{(2)})^T \delta^{(2)} \odot \sigma'(z^{(1)}) ]
先算 ((W^{(2)})^T \delta^{(2)}):
[ \begin{bmatrix} 0.6 \ 0.7 \end{bmatrix} \times (-0.01668) = \begin{bmatrix} -0.01001 \ -0.01168 \end{bmatrix} ]
再算 (\sigma'(z^{(1)})):
[ \sigma'(z^{(1)}_1) = 0.7311 \times (1 - 0.7311) \approx 0.1967 ] [ \sigma'(z^{(1)}_2) = 0.8320 \times (1 - 0.8320) \approx 0.1398 ]
两者逐元素相乘:
[ \delta^{(1)} = \begin{bmatrix} -0.01001 \times 0.1967 \ -0.01168 \times 0.1398 \end{bmatrix} \approx \begin{bmatrix} -0.00197 \ -0.00163 \end{bmatrix} ]
可以看到,误差信号传到隐藏层之后,数值已经小了一个数量级。这就是sigmoid激活的固有毛病:每过一层,误差都会被压缩,网络越深,越靠前的层梯度越小,最后几乎学不动。这个我们下一节重点说。
有了 (\delta^{(2)}) 和 (\delta^{(1)}),参数梯度就手到擒来了。
先看输出层权重:
[ \frac{\partial L}{\partial W^{(2)}} = \delta^{(2)} \cdot (a^{(1)})^T = -0.01668 \times [0.7311, 0.8320] \approx [-0.01220, -0.01388] ]
输出层偏置:
[ \frac{\partial L}{\partial b^{(2)}} = \delta^{(2)} = -0.01668 ]
再看隐藏层权重:
[ \frac{\partial L}{\partial W^{(1)}} = \delta^{(1)} x^T = \begin{bmatrix} -0.00197 \ -0.00163 \end{bmatrix} \times [1.0, 2.0] = \begin{bmatrix} -0.00197 & -0.00394 \ -0.00163 & -0.00326 \end{bmatrix} ]
隐藏层偏置:
[ \frac{\partial L}{\partial b^{(1)}} = \delta^{(1)} = [-0.00197, -0.00163]^T ]
到这里,所有的梯度都算出来了。如果设学习率 (\eta = 0.1),那么参数更新就是:
[ W^{(2)} \leftarrow [0.6, 0.7] - 0.1 \times [-0.01220, -0.01388] = [0.60122, 0.70139] ]
[ W^{(1)} \leftarrow \begin{bmatrix} 0.1 & 0.2 \ 0.3 & 0.4 \end{bmatrix} - 0.1 \times \begin{bmatrix} -0.00197 & -0.00394 \ -0.00163 & -0.00326 \end{bmatrix} = \begin{bmatrix} 0.10020 & 0.20039 \ 0.30016 & 0.40033 \end{bmatrix} ]
所有参数都会在梯度反方向上移动一点点,损失函数就会下降一点。重复这个过程成百上千次,网络就逐渐学会了这个任务。
4.3 梯度检查:验证推导是否正确
手推公式容易出错,尤其是维度一多,很容易在某个地方漏了转置或者乘错了位置。我自己实践中经常用梯度检查(gradient check)来确认实现是否正确。
梯度检查的原理很简单粗暴:利用导数的定义,直接用数值方法近似梯度,和反向传播算出来的梯度做对比。
对于一个参数 (\theta),数值梯度近似为:
[ \frac{\partial L}{\partial \theta} \approx \frac{L(\theta + \epsilon) - L(\theta - \epsilon)}{2\epsilon} ]
其中 (\epsilon) 取一个很小的值,比如 (10^{-7})。用这个近似值去和反向传播算出来的梯度比较,如果相对误差在 (10^{-7}) 到 (10^{-4}) 量级,说明实现基本正确。这个技巧在你自己实现网络时特别有用。
我在上面这个例子里也顺手验一下。以 (W^{(2)}1) 为例,它的解析梯度是 -0.01220。试着把 (W^{(2)}1) 从0.6改为0.6000001,其他参数不变,重新做一遍前向传播得损失 (L+),再改为0.5999999得损失 (L-)。代入数值梯度公式,算出来的值和 -0.01220 对比,通常能对上。这个操作看起来笨,但对排查公式错误极其有效。
5. 实战避坑:这些坑我替你踩过
5.1 维度不匹配问题
反向传播的公式看着简单,动手实现的时候最常见的错误是维度对不上。写成代码的时候,每一步都要检查矩阵形状。
我踩过最蠢的一次坑,是在算 (\frac{\partial L}{\partial W^{(l)}} = \delta^{(l)} (a^{(l-1)})^T) 的时候,把乘法的顺序写反了。如果写成 ((a^{(l-1)})^T \delta^{(l)}),结果就是一个标量或者形状完全不对的矩阵。写代码的时候养成一个好习惯:在每一步注释里标出当前变量的形状。
比如,假设输入维度是 (3),隐藏层是 (4) 个神经元,那么 (a^{(0)}) 的形状是 ((3, 1)),(W^{(1)}) 的形状是 ((4, 3)),(z^{(1)}) 的形状是 ((4, 1)),(\delta^{(1)}) 的形状是 ((4, 1))。那么 (\frac{\partial L}{\partial W^{(1)}} = \delta^{(1)} (a^{(0)})^T) 就是 ((4,1) \times (1,3) = (4,3)),完美匹配。如果你在写代码的时候发现形状对不上,先检查是不是少了转置。
5.2 梯度消失的数学原因
前面数值例子里已经看到了:误差信号从输出层传到隐藏层时,从 -0.01668 衰减到了 -0.00197。如果网络更深,这个衰减会更严重。
背后的数学原因就是 (\delta^{(l)}) 的递推公式里包含了 (\sigma'(z^{(l)}))。对于sigmoid激活函数,导数的最大值只有 0.25(当 (z=0) 时);对于tanh,导数最大值是 1。如果权重 (W^{(l+1)}) 的范数不超过1,那么每传一层,误差信号都会被压缩到原来的0.25倍以下。经过五六层之后,梯度就趋近于零了,前面几层几乎学不到东西。这就是梯度消失问题。
实践中怎么解决?最有效的手段之一是换激活函数。ReLU 在正半轴的导数是1,误差信号传过ReLU时不会衰减,这就从根本上缓解了梯度消失。这也是为什么现在隐藏层几乎不用sigmoid和tanh,而默认用ReLU的原因。
另外,参数初始化方式也很重要。如果权重初始值设得太小,信号在传播过程中很容易被压缩到sigmoid的饱和区;如果设得太大,又容易导致激活值饱和。实践中常用的初始化方法,比如Xavier初始化(也叫做Glorot初始化),就是根据网络层的输入输出维度来合理设定权重初始范围,让信号在网络中传播时方差保持稳定。如果网络参数初始化和激活函数搭配不当,训练一开始就会陷入停滞。
5.3 激活函数和损失函数的最佳搭配
激活函数和损失函数的搭配是有讲究的,不能随便乱配。我见过有人用MSE做分类任务的损失,还配上sigmoid输出层,结果训练慢得让人抓狂。
原因我们前面已经提过:sigmoid在饱和区的导数接近0,而MSE在这里还有一个额外的衰减因子 ((a^{(2)} - y))。这两个乘在一起,会让梯度变得非常小。而如果用交叉熵代替MSE,输出层的误差项就直接是 (\hat{y} - y),去掉了 (\sigma'(z)) 这个衰减项,梯度就大多了。
所以我的建议是:
- 二分类:输出层用sigmoid,损失用交叉熵(binary cross entropy)。
- 多分类:输出层用softmax,损失用交叉熵(categorical cross entropy)。
- 回归任务:输出层不用激活(或者用线性激活),损失用MSE。
这个搭配方案不是我随便说的,而是经过了大量实践检验的标准配置。如果你不理解背后的原因,只是照搬,出了问题时就会无从下手。
5.4 学习率的选择与崩溃现象
有了正确的梯度,如果学习率没设好,训练照样会失败。学习率太大,参数会剧烈震荡,损失可能先降后直接爆掉,变成NaN;学习率太小,训练速度慢到让人怀疑人生。
判断学习率是否合适的一个实用经验:看训练损失的变化曲线。如果损失前几步就开始震荡或者飙升,那大概率是学习率太大。如果损失降得特别慢、几百个epoch还在慢慢磨,那学习率大概率太小。
我在实际项目中一般是先设一个偏大的学习率,比如0.1,观察损失变化;如果震荡,就除以10,变成0.01,再试;如果还是震荡,继续降到0.001。这个“以10倍为单位往下探”的方法虽然简单,但非常有效。
另外一个常见的坑是把学习率设成固定值不变,结果模型收敛到某个局部最优附近时,损失一直在原地打转。这时候可以考虑在训练过程中逐步衰减学习率,或者在损失进入平台期后手动降低学习率。这就是学习率调度(learning rate schedule)的朴素原理。
还有就是优化器的选择。不用非得自己实现SGD,现代框架里Adam这类自适应优化器用起来很方便,它内部会为每个参数维护一个自适应学习率,能自动适应不同参数的不同尺度。但要注意,使用自适应优化器时,初始学习率的选择范围也和纯SGD不太一样,通常更小一些,比如0.001或0.0001起步。
写在最后的一点经验
推完这一整套公式,我个人的体会是:对全连接神经网络的理解会有一个质的飞跃。很多看起来玄乎的概念,比如“梯度消失”“为何用交叉熵”“为何用ReLU”,本质都是在反向传播的公式里。手推一遍之后,你再看框架的报错提示、调参教程,都有一种“原来如此”的豁然开朗感。
如果你还没动手推过,建议先别看别人的笔记,自己找一张白纸,拿我上面的2-2-1例子,盖住推导过程,独立算一遍。算完之后再打开文章对照,找出自己卡住的地方。这样比单纯看十遍文章都管用。
最后再分享一个小技巧:当你实现了一个简单的全连接网络代码时,在正式跑大模型之前,先用梯度检查过一遍。这个习惯能帮你省下无数排查bug的时间,也是我多年实践下来觉得最值得保留的一个流程。