交叉熵代价函数下反向传播的完整推导与代码实战
1. 从代价函数到梯度一次完整的反向传播拆解很多初学者学反向传播卡住的地方往往不是链式法则本身而是“到底对谁求导、求完导又往哪儿放”。我之前带过好几个零基础转算法的朋友发现只要把“交叉熵代价函数”和“吴恩达机器学习课程里的神经网络部分”放在一起看反而更容易理清脉络——因为吴恩达在课程里把反向传播拆成了“误差项”和“参数更新”两层而交叉熵作为代价函数时误差项的表达式会变得非常简洁。这篇文章我打算从代价函数的设计动机讲起一步步把交叉熵下的反向传播推导全过程写清楚。内容包括为什么不用二次代价函数、交叉熵的偏导数怎么算、输出层误差项如何化简、隐藏层误差如何反向传递、参数更新公式怎么落到代码里以及我在实际调试中遇到过的一些经典问题。适合正在看吴恩达机器学习课程、或者刚接触神经网络但被数学推导劝退的读者。先说一个整体印象交叉熵代价函数配合反向传播最大的好处是梯度表达式里不出现激活函数的导数项。这意味着当神经元输出接近饱和区时梯度不会因为激活函数导数的消失而变得极小学习速度能保持稳定。这个特性在分类任务里非常实用尤其是输出层用sigmoid或softmax的场景。顺带说明一点这篇文章的推导环境是“输出层用sigmoid隐藏层用sigmoid”的经典全连接网络这也是吴恩达课程中神经网络章节的主线配置。理解了这套后续换成tanh、ReLU或者softmax版本只需要替换局部表达式即可。2. 为什么交叉熵能治“学习缓慢”的病2.1 二次代价函数的问题所在假设我们有一个最简单的二分类网络单个输入、单个输出神经元输出层用sigmoid激活函数。如果用二次代价函数代价定义为[ C \frac{1}{2}(y - a)^2 ]其中 (a \sigma(z))(z wx b)。现在看一下权重 (w) 的偏导数[ \frac{\partial C}{\partial w} (a - y) \cdot \sigma(z) \cdot x ]这里 (\sigma(z)) 是sigmoid函数对 (z) 的导数。sigmoid函数的导数有一个特性当 (z) 很大正饱和或很小负饱和时(\sigma(z)) 趋近于0。比如 (z 5) 时sigmoid输出大约是0.9933它的导数大约是0.0066(z 10) 时导数已经小于0.00005了。如果这时输出值和真实标签差距还很大按照直觉我们希望梯度大一些、权重更新快一些。但现实是因为sigmoid导数压得很低梯度反而很小学习变得非常慢。这就是所谓的“学习缓慢”问题误差越大梯度越小越学不动。2.2 交叉熵的构造思路交叉熵代价函数的形式是[ C -\frac{1}{n} \sum_{i1}^{n} \left[ y \ln a (1 - y) \ln(1 - a) \right] ]先不看求和和平均单看单个样本的贡献[ C -y \ln a - (1-y) \ln(1-a) ]这个式子为什么能解决学习缓慢直接用导数的角度看对 (w) 求偏导时链式法则会经过 (a) 再到 (z)但我们看看交叉熵求导后得到的梯度形式[ \frac{\partial C}{\partial w} \frac{\partial C}{\partial a} \cdot \frac{\partial a}{\partial z} \cdot \frac{\partial z}{\partial w} ]其中[ \frac{\partial C}{\partial a} -\frac{y}{a} \frac{1-y}{1-a} ][ \frac{\partial a}{\partial z} \sigma(z) a(1-a) ]于是[ \frac{\partial C}{\partial w} \left( -\frac{y}{a} \frac{1-y}{1-a} \right) \cdot a(1-a) \cdot x ]化简一下这个乘法。括号里的项与 (a(1-a)) 相乘后会发生非常巧妙的抵消[ -\frac{y}{a} \cdot a(1-a) -y(1-a) ][ \frac{1-y}{1-a} \cdot a(1-a) a(1-y) ]加起来就是[ -y(1-a) a(1-y) a - y ]所以[ \frac{\partial C}{\partial w} (a - y) \cdot x ]同样的方法可以得到偏置的梯度[ \frac{\partial C}{\partial b} a - y ]看到了吗梯度表达式里没有 (\sigma(z)) 了。误差越大(a - y) 的绝对值越大梯度就越大学习速度也就越快。这就是交叉熵代价函数在分类问题中更受青睐的根本原因。注意这个“抵消”能够成立的前提是输出层配合sigmoid激活函数。如果把sigmoid换成其他激活函数交叉熵梯度的形式会相应变化不一定还有这么简洁的表达式。3. 符号约定与网络结构定义3.1 网络结构为了推导过程清晰我定义一个三层全连接网络输入层2个神经元隐藏层3个神经元输出层2个神经元这个规模刚好够展示所有推导细节又不会让符号淹没在多层结构里。3.2 符号说明我用一组谁都看得懂的符号和吴恩达课程里的表示法做一下对应符号含义说明(x_i)第 (i) 个输入特征(i1,2)(w_{jk}^{[l]})第 (l) 层中连接第 (k) 个神经元到上一层第 (j) 个神经元的权重注意下标顺序(b_j^{[l]})第 (l) 层第 (j) 个神经元的偏置(z_j^{[l]})第 (l) 层第 (j) 个神经元的加权输入(z w x b)(a_j^{[l]})第 (l) 层第 (j) 个神经元的激活输出(a \sigma(z))(y_j)输出层第 (j) 个神经元对应的真实标签对于分类任务通常用one-hot编码这里有个容易搞混的地方权重下标 (w_{jk}^{[l]}) 中(j) 是当前层的神经元编号(k) 是上一层神经元的编号。也就是说计算当前层第 (j) 个神经元的加权输入时要遍历上一层所有 (k)[ z_j^{[l]} \sum_k w_{jk}^{[l]} a_k^{[l-1]} b_j^{[l]} ]如果你看的是吴恩达课程早期版本的笔记他有时会用 (Theta_{ji}^{(l)}) 这种表示本质是一样的只是下标顺序不同。3.3 前向传播前向传播就是按层计算[ z_1^{[1]} w_{11}^{[1]} x_1 w_{12}^{[1]} x_2 b_1^{[1]} ][ a_1^{[1]} \sigma(z_1^{[1]}) ]隐藏层的每个神经元都这样算得到 (a_1^{[1]}, a_2^{[1]}, a_3^{[1]})。然后作为输出层的输入[ z_1^{[2]} w_{11}^{[2]} a_1^{[1]} w_{12}^{[2]} a_2^{[1]} w_{13}^{[2]} a_3^{[1]} b_1^{[2]} ][ a_1^{[2]} \sigma(z_1^{[2]}) ]同理得到 (a_2^{[2]})。4. 核心推导交叉熵下的反向传播四公式4.1 开局思路先定义误差项反向传播的核心不是直接算 (\partial C / \partial w)而是先算一个中间量——第 (l) 层第 (j) 个神经元的误差项[ \delta_j^{[l]} \frac{\partial C}{\partial z_j^{[l]}} ]这个量代表代价函数对该神经元加权输入的敏感度。算出 (\delta) 之后权重和偏置的梯度就非常容易得到[ \frac{\partial C}{\partial w_{jk}^{[l]}} \delta_j^{[l]} \cdot a_k^{[l-1]} ][ \frac{\partial C}{\partial b_j^{[l]}} \delta_j^{[l]} ]这两个式子是怎么来的用链式法则[ \frac{\partial C}{\partial w_{jk}^{[l]}} \frac{\partial C}{\partial z_j^{[l]}} \cdot \frac{\partial z_j^{[l]}}{\partial w_{jk}^{[l]}} \delta_j^{[l]} \cdot a_k^{[l-1]} ]同理因为 (z_j^{[l]}) 对 (b_j^{[l]}) 的偏导数是1所以偏置梯度就是 (\delta_j^{[l]}) 本身。所以整个反向传播就变成了两件事先算每一层的 (\delta)再用 (\delta) 更新参数。4.2 输出层的误差项输出层误差项的定义[ \delta_j^{[2]} \frac{\partial C}{\partial z_j^{[2]}} ]用链式法则展开经过 (a_j^{[2]}) 中转[ \delta_j^{[2]} \frac{\partial C}{\partial a_j^{[2]}} \cdot \frac{\partial a_j^{[2]}}{\partial z_j^{[2]}} ]在交叉熵代价函数下对于单个样本代价函数对输出层第 (j) 个神经元输出的偏导是[ \frac{\partial C}{\partial a_j^{[2]}} -\frac{y_j}{a_j^{[2]}} \frac{1-y_j}{1-a_j^{[2]}} ]而sigmoid的导数[ \frac{\partial a_j^{[2]}}{\partial z_j^{[2]}} a_j^{[2]}(1 - a_j^{[2]}) ]两者相乘[ \delta_j^{[2]} \left( -\frac{y_j}{a_j^{[2]}} \frac{1-y_j}{1-a_j^{[2]}} \right) \cdot a_j^{[2]}(1 - a_j^{[2]}) ]化简过程跟前面单神经元的计算完全一样结果就是[ \delta_j^{[2]} a_j^{[2]} - y_j ]这个结果非常优雅输出层误差项等于“预测值减真实值”。在多分类场景下如果把输出层换成softmax 交叉熵形式会变成 (a_j^{[2]} - y_j) 的向量形式本质上也是同一个味道——预测与真实的差值。4.3 隐藏层的误差项现在要算隐藏层的 (\delta_j^{[1]})。隐藏层的误差项不能直接对输出求偏导获得因为代价函数是输出层的函数隐藏层的输出并不直接出现在代价函数里。但可以通过输出层的误差项继续往回流。隐藏层第 (j) 个神经元的误差项[ \delta_j^{[1]} \frac{\partial C}{\partial z_j^{[1]}} ](z_j^{[1]}) 影响的是隐藏层输出 (a_j^{[1]})而 (a_j^{[1]}) 通过权重 (w_{kj}^{[2]}) 影响输出层的所有加权输入 (z_1^{[2]}, z_2^{[2]})。所以要用全导数公式[ \delta_j^{[1]} \frac{\partial C}{\partial z_j^{[1]}} \frac{\partial C}{\partial a_j^{[1]}} \cdot \frac{\partial a_j^{[1]}}{\partial z_j^{[1]}} ]而[ \frac{\partial C}{\partial a_j^{[1]}} \sum_k \frac{\partial C}{\partial z_k^{[2]}} \cdot \frac{\partial z_k^{[2]}}{\partial a_j^{[1]}} \sum_k \delta_k^{[2]} \cdot w_{kj}^{[2]} ]这里的 (k) 遍历输出层的所有神经元。也就是说隐藏层某个神经元的“贡献度”等于输出层所有误差项按权重加权求和。然后继续[ \frac{\partial a_j^{[1]}}{\partial z_j^{[1]}} \sigma(z_j^{[1]}) a_j^{[1]}(1 - a_j^{[1]}) ]于是隐藏层误差项为[ \delta_j^{[1]} \left( \sum_k \delta_k^{[2]} \cdot w_{kj}^{[2]} \right) \cdot a_j^{[1]}(1 - a_j^{[1]}) ]注意这里出现了 (\sigma(z_j^{[1]}))这是因为输出层的交叉熵化简只抵消了输出层的sigmoid导数隐藏层的导数还是要乘的。这个区别很多初学者会忽略以为用了交叉熵就所有层都不用管激活函数的导数了实际上不是。4.4 参数梯度与更新公式有了 (\delta)参数梯度就齐了。输出层权重梯度[ \frac{\partial C}{\partial w_{jk}^{[2]}} \delta_j^{[2]} \cdot a_k^{[1]} (a_j^{[2]} - y_j) \cdot a_k^{[1]} ]输出层偏置梯度[ \frac{\partial C}{\partial b_j^{[2]}} \delta_j^{[2]} a_j^{[2]} - y_j ]隐藏层权重梯度[ \frac{\partial C}{\partial w_{jk}^{[1]}} \delta_j^{[1]} \cdot a_k^{[0]} \delta_j^{[1]} \cdot x_k ]隐藏层偏置梯度[ \frac{\partial C}{\partial b_j^{[1]}} \delta_j^{[1]} ]梯度下降更新时用学习率 (\alpha) 控制步长[ w_{jk}^{[l]} \leftarrow w_{jk}^{[l]} - \alpha \cdot \frac{\partial C}{\partial w_{jk}^{[l]}} ][ b_j^{[l]} \leftarrow b_j^{[l]} - \alpha \cdot \frac{\partial C}{\partial b_j^{[l]}} ]到这里一波完整的推导就算走完了。下面用一个具体数字例子把它落地。5. 手算实战一个具体例子的完整走查5.1 初始值设定直接用最简单的方式初始化输入样本(x_1 1, x_2 0)真实标签二分类(y 1)即 (y_1 1, y_2 0)学习率(\alpha 0.5)权重初始化小随机数方便手算我给固定值隐藏层[ w_{11}^{[1]} 0.1, \quad w_{12}^{[1]} -0.2 ][ w_{21}^{[1]} 0.3, \quad w_{22}^{[1]} 0.4 ][ w_{31}^{[1]} -0.5, \quad w_{32}^{[1]} 0.6 ]偏置都设为0。输出层[ w_{11}^{[2]} 0.2, \quad w_{12}^{[2]} -0.3, \quad w_{13}^{[2]} 0.5 ][ w_{21}^{[2]} 0.4, \quad w_{22}^{[2]} 0.1, \quad w_{23}^{[2]} -0.2 ]偏置也都设为0。5.2 前向传播计算隐藏层第1个神经元[ z_1^{[1]} 0.1 \times 1 (-0.2) \times 0 0.1 ][ a_1^{[1]} \sigma(0.1) \approx 0.52498 ]隐藏层第2个神经元[ z_2^{[1]} 0.3 \times 1 0.4 \times 0 0.3 ][ a_2^{[1]} \sigma(0.3) \approx 0.57444 ]隐藏层第3个神经元[ z_3^{[1]} (-0.5) \times 1 0.6 \times 0 -0.5 ][ a_3^{[1]} \sigma(-0.5) \approx 0.37754 ]输出层第1个神经元[ z_1^{[2]} 0.2 \times 0.52498 (-0.3) \times 0.57444 0.5 \times 0.37754 ][ z_1^{[2]} 0.104996 - 0.172332 0.188770 0.121434 ][ a_1^{[2]} \sigma(0.121434) \approx 0.53032 ]输出层第2个神经元[ z_2^{[2]} 0.4 \times 0.52498 0.1 \times 0.57444 (-0.2) \times 0.37754 ][ z_2^{[2]} 0.209992 0.057444 - 0.075508 0.191928 ][ a_2^{[2]} \sigma(0.191928) \approx 0.54783 ]5.3 反向传播误差项输出层误差项直接用“预测减真实”[ \delta_1^{[2]} a_1^{[2]} - y_1 0.53032 - 1 -0.46968 ][ \delta_2^{[2]} a_2^{[2]} - y_2 0.54783 - 0 0.54783 ]隐藏层误差项先算隐藏层第1个神经元[ \delta_1^{[1]} \left( \delta_1^{[2]} \cdot w_{11}^{[2]} \delta_2^{[2]} \cdot w_{21}^{[2]} \right) \cdot a_1^{[1]}(1 - a_1^{[1]}) ]代入数值[ \delta_1^{[1]} ( (-0.46968 \times 0.2) (0.54783 \times 0.4) ) \times 0.52498 \times (1 - 0.52498) ][ \delta_1^{[1]} ( -0.093936 0.219132 ) \times 0.52498 \times 0.47502 ][ \delta_1^{[1]} 0.125196 \times 0.24936 0.031219 ]隐藏层第2个神经元[ \delta_2^{[1]} \left( \delta_1^{[2]} \cdot w_{12}^{[2]} \delta_2^{[2]} \cdot w_{22}^{[2]} \right) \cdot a_2^{[1]}(1 - a_2^{[1]}) ]代入[ \delta_2^{[1]} ( (-0.46968 \times (-0.3)) (0.54783 \times 0.1) ) \times 0.57444 \times 0.42556 ][ \delta_2^{[1]} ( 0.140904 0.054783 ) \times 0.57444 \times 0.42556 ][ \delta_2^{[1]} 0.195687 \times 0.24446 0.047839 ]隐藏层第3个神经元[ \delta_3^{[1]} \left( \delta_1^{[2]} \cdot w_{13}^{[2]} \delta_2^{[2]} \cdot w_{23}^{[2]} \right) \cdot a_3^{[1]}(1 - a_3^{[1]}) ]代入[ \delta_3^{[1]} ( (-0.46968 \times 0.5) (0.54783 \times (-0.2)) ) \times 0.37754 \times 0.62246 ][ \delta_3^{[1]} ( -0.234840 - 0.109566 ) \times 0.37754 \times 0.62246 ][ \delta_3^{[1]} -0.344406 \times 0.23499 -0.080932 ]手算保留6位小数末尾几位有舍入误差不影响理解。5.4 参数梯度计算输出层到隐藏层的权重梯度[ \frac{\partial C}{\partial w_{11}^{[2]}} \delta_1^{[2]} \cdot a_1^{[1]} -0.46968 \times 0.52498 -0.24658 ][ \frac{\partial C}{\partial w_{12}^{[2]}} \delta_1^{[2]} \cdot a_2^{[1]} -0.46968 \times 0.57444 -0.26980 ][ \frac{\partial C}{\partial w_{13}^{[2]}} \delta_1^{[2]} \cdot a_3^{[1]} -0.46968 \times 0.37754 -0.17733 ][ \frac{\partial C}{\partial w_{21}^{[2]}} \delta_2^{[2]} \cdot a_1^{[1]} 0.54783 \times 0.52498 0.28760 ][ \frac{\partial C}{\partial w_{22}^{[2]}} \delta_2^{[2]} \cdot a_2^{[1]} 0.54783 \times 0.57444 0.31470 ][ \frac{\partial C}{\partial w_{23}^{[2]}} \delta_2^{[2]} \cdot a_3^{[1]} 0.54783 \times 0.37754 0.20682 ]偏置梯度[ \frac{\partial C}{\partial b_1^{[2]}} \delta_1^{[2]} -0.46968 ][ \frac{\partial C}{\partial b_2^{[2]}} \delta_2^{[2]} 0.54783 ]隐藏层到输入层的权重梯度以 (w_{11}^{[1]}) 为例[ \frac{\partial C}{\partial w_{11}^{[1]}} \delta_1^{[1]} \cdot x_1 0.031219 \times 1 0.031219 ][ \frac{\partial C}{\partial w_{12}^{[1]}} \delta_1^{[1]} \cdot x_2 0.031219 \times 0 0 ]这里注意因为输入 (x_2 0)所有接入 (x_2) 的权重梯度都是0。这其实也说明一个问题如果某个输入特征一直是0那它对应的权重根本不会被更新——这也是做特征归一化时避免全0特征的一个重要理由。5.5 参数更新用学习率 (\alpha 0.5) 更新部分参数[ w_{11}^{[2]} 0.2 - 0.5 \times (-0.24658) 0.2 0.12329 0.32329 ][ w_{12}^{[2]} -0.3 - 0.5 \times (-0.26980) -0.3 0.13490 -0.16510 ][ b_1^{[2]} 0 - 0.5 \times (-0.46968) 0.23484 ]可以看到输出层第1个神经元预测值比真实值小(0.53032 1)所以误差项为负梯度为负权重更新后变大这会推动下一次预测值变大。方向完全正确。手算的意义在于建立“每个数字从哪来”的直观感受。实际工程中不可能靠手算更新参数但理解了这个流程你调试网络时看到梯度值、损失值异常能更快定位问题出在前向还是反向、出在哪一层。6. 代码落地用Python实现交叉熵反向传播6.1 一个最小实现下面这段代码是我按教学目的写的最小实现不依赖任何深度学习框架只用到numpy。它复现了上面手算例子的全部逻辑。import numpy as np def sigmoid(z): return 1 / (1 np.exp(-z)) def sigmoid_derivative(a): return a * (1 - a) def forward(x, W1, b1, W2, b2): z1 W1 x b1 a1 sigmoid(z1) z2 W2 a1 b2 a2 sigmoid(z2) return z1, a1, z2, a2 def backword(x, y, z1, a1, z2, a2, W2): m x.shape[1] # 输出层误差 delta2 a2 - y # 隐藏层误差 delta1 (W2.T delta2) * sigmoid_derivative(a1) # 梯度 dW2 delta2 a1.T / m db2 np.sum(delta2, axis1, keepdimsTrue) / m dW1 delta1 x.T / m db1 np.sum(delta1, axis1, keepdimsTrue) / m return dW1, db1, dW2, db2, delta1, delta2 def update(W1, b1, W2, b2, dW1, db1, dW2, db2, lr): W1 - lr * dW1 b1 - lr * db1 W2 - lr * dW2 b2 - lr * db2 return W1, b1, W2, b2 def cross_entropy_loss(y, a2, eps1e-12): m y.shape[1] # 防止log(0) loss -np.mean(y * np.log(a2 eps) (1 - y) * np.log(1 - a2 eps)) return loss注意一个关键细节代价函数在代码里通常带上 (1/m) 的平均但吴恩达课程里的推导有时针对单个样本有时针对整个批量。两种写法本质没有冲突只是梯度数值上差一个倍数学习率需要相应调整。我在实际写代码时习惯用批量平均这样损失值和学习率在不同批量大小下更稳定。6.2 跑一遍训练循环# 构造数据这里随便造两个样本 X np.array([[1, 0.5], [0, 1.5]]) # 2个特征2个样本 Y np.array([[1, 0], [0, 1]]) # one-hot标签2个类别2个样本 # 初始化 np.random.seed(42) W1 np.random.randn(3, 2) * 0.1 b1 np.zeros((3, 1)) W2 np.random.randn(2, 3) * 0.1 b2 np.zeros((2, 1)) lr 0.1 for epoch in range(1000): z1, a1, z2, a2 forward(X, W1, b1, W2, b2) dW1, db1, dW2, db2, _, _ backword(X, Y, z1, a1, z2, a2, W2) W1, b1, W2, b2 update(W1, b1, W2, b2, dW1, db1, dW2, db2, lr) if epoch % 100 0: loss cross_entropy_loss(Y, a2) print(fepoch {epoch}, loss {loss:.6f})跑起来后你会看到一个很直观的现象前几百轮损失下降很快后面逐渐平缓。这正是交叉熵配合sigmoid的特性——误差大的时候梯度大误差小的时候梯度自然变小不会出现二次代价函数那种“误差大反而学不动”的尴尬。6.3 把推导和代码对应起来如果你对照着前面的推导看这段代码会发现代码比你想象的简单因为numpy把矩阵运算都封装好了。对照关系如下delta2 a2 - y对应公式 (\delta_j^{[2]} a_j^{[2]} - y_j)delta1 (W2.T delta2) * sigmoid_derivative(a1)对应公式 (\delta_j^{[1]} \left( \sum_k \delta_k^{[2]} w_{kj}^{[2]} \right) \cdot a_j^{[1]}(1 - a_j^{[1]}))dW2 delta2 a1.T / m对应公式 (\frac{\partial C}{\partial w_{jk}^{[2]}} \delta_j^{[2]} a_k^{[1]})矩阵乘法里的转置关系如果手推时会晕建议拿一个很小的矩阵手动乘一遍比死记“要不要转置”有效得多。7. 数值梯度检验验证反向传播有没有写错7.1 为什么要做梯度检验反向传播代码写完后最常见的错误是矩阵维度对不上或转置方向错了。与其一行行检查代码不如直接用数值方法验证用定义法近似计算梯度和反向传播算出的梯度比较。如果两者差异在合理范围内说明反向传播实现基本正确。数值梯度的原理就是导数的定义[ \frac{\partial C}{\partial \theta} \approx \frac{C(\theta \epsilon) - C(\theta - \epsilon)}{2\epsilon} ]中心差分比单侧差分的精度高一个量级所以推荐用中心差分。7.2 检验代码def numerical_gradient(x, y, W1, b1, W2, b2, eps1e-4): grads [] params [W1, b1, W2, b2] shapes [p.shape for p in params] sizes [p.size for p in params] # 展平所有参数 flat_params np.concatenate([p.ravel() for p in params]) for i in range(len(flat_params)): orig flat_params[i] flat_params[i] orig eps W1_i, b1_i, W2_i, b2_i recover_params(flat_params, shapes) _, _, _, a2_plus forward(x, W1_i, b1_i, W2_i, b2_i) loss_plus cross_entropy_loss(y, a2_plus) flat_params[i] orig - eps W1_i, b1_i, W2_i, b2_i recover_params(flat_params, shapes) _, _, _, a2_minus forward(x, W1_i, b1_i, W2_i, b2_i) loss_minus cross_entropy_loss(y, a2_minus) grads.append((loss_plus - loss_minus) / (2 * eps)) flat_params[i] orig return np.array(grads) def recover_params(flat_params, shapes): idx 0 recovered [] for shape in shapes: size np.prod(shape) recovered.append(flat_params[idx:idxsize].reshape(shape)) idx size return recovered[0], recovered[1], recovered[2], recovered[3]跑一遍对比目标z1, a1, z2, a2 forward(X, W1, b1, W2, b2) dW1, db1, dW2, db2, _, _ backword(X, Y, z1, a1, z2, a2, W2) analytic_grads np.concatenate([dW1.ravel(), db1.ravel(), dW2.ravel(), db2.ravel()]) numeric_grads numerical_gradient(X, Y, W1, b1, W2, b2) diff np.abs(analytic_grads - numeric_grads) rel_diff diff / (np.abs(analytic_grads) np.abs(numeric_grads) 1e-12) print(最大相对差异:, np.max(rel_diff))如果最大相对差异在 (10^{-4}) 到 (10^{-7}) 之间说明反向传播实现基本正确。如果差异很大优先检查矩阵乘法的维度、转置操作、以及是否把 (a) 和 (z) 弄混了。一个实际经验梯度检验时(\epsilon) 别选太大也别选太小。太大中心差分的截断误差明显太小浮点数精度会干扰结果。通常 (10^{-4}) 到 (10^{-6}) 比较稳妥。8. 吴恩达课程里的反向传播符号 vs 本文的符号看过吴恩达机器学习课程的读者会发现课程里神经网络章节的符号体系和本文有一定差别。课程中常用的表示是权重用 (\Theta^{(l)})其中 (\Theta_{ji}^{(l)}) 表示第 (l) 层到第 (l1) 层的连接权重激活项用 (a_i^{(l)})偏置项 (b) 有时被吸收进权重矩阵有时单独表示误差项用 (\delta^{(l)})含义和我这里完全一致从数学本质上讲符号不同不影响推导和代码实现只是“翻译”问题。我自己做笔记时习惯把课程符号和独立符号对照着列一遍含义课程符号本文符号第 (l) 层输出(a^{(l)})(a^{[l]})第 (l) 层权重矩阵(\Theta^{(l)})(W^{[l]})第 (l) 层误差项(\delta^{(l)})(\delta^{[l]})加权输入(z^{(l)})(z^{[l]})吴恩达课程里反向传播的起点也是先算输出层误差 (\delta^{(L)})然后用后一层的 (\delta^{(l1)}) 推前一层的 (\delta^{(l)})。课程里输出的核心迭代公式是[ \delta^{(l)} (\Theta^{(l)})^T \delta^{(l1)} \odot g(z^{(l)}) ]这里 (\odot) 表示逐元素相乘。当输出层配合sigmoid且代价函数是交叉熵时起始项 (\delta^{(L)}) 恰好等于 (a^{(L)} - y)然后用上面这个公式逐层向前推。课程里还提到一个实际实现技巧用矩阵形式把多个样本的梯度同时算出来而不是写for循环。这一点我在第6节的backword函数里已经反映了——矩阵运算同时处理所有样本代码简洁而且快。9. 常见问题与排查技巧实录9.1 输出层误差项公式到底什么时候能用最常被问的问题就是“(\delta^{(L)} a^{(L)} - y) 是随便用吗”不是。这个简洁公式成立的两个前提一是输出层激活函数是sigmoid二是代价函数是交叉熵。两者配合sigmoid导数恰好抵消。如果你把输出层换成softmax公式仍然是 (\delta^{(L)} a^{(L)} - y)但这是另一个推导过程的巧合——softmax交叉熵的偏导也会化简到这个形式。如果你把输出层换成线性激活回归任务交叉熵本身就不适用了用的是二次代价函数输出层误差项会变成 (\delta^{(L)} a^{(L)} - y) 乘以一个线性系数形式也接近但来源完全不同。所以看到别人代码里直接写delta a - y先确认网络结构再参考不然容易误用。9.2 隐藏层误差里为什么还有sigmoid导数用交叉熵后输出层的sigmoid导数被抵消了但隐藏层的反向传播误差公式里仍然带着 (\sigma(z))。很多人在这里犯迷糊觉得“既然交叉熵能消除导数项那是不是所有层都不需要激活函数导数了”。不是。交叉熵化简的是“代价函数对输出层激活值的偏导”与“输出层激活函数导数”的乘积。隐藏层的误差项是通过链式法则从输出层误差继续往下传的它经过的是隐藏层自身的激活函数导数这个导数不会被代价函数的选择抵消。如果你在隐藏层用ReLU那这里的 (\sigma(z)) 就换成ReLU的导数如果隐藏层也用sigmoid那就是 (a(1-a))。这点搞错反向传播算出来的梯度会在中间层出问题输出层的灵感全被带偏。9.3 梯度消失与学习率过大表现不同交叉熵解决了sigmoid饱和导致的学习缓慢但没解决深层网络中的梯度消失问题。如果你搭了一个层数很多的全连接网络发现损失下降非常慢先别急着怀疑交叉熵公式更可能是网络太深梯度流不过去。我实际调试时的一个经验先看每一层的梯度的量级。如果越靠近输入层梯度的数量级越小比如从 (10^{-2}) 一路降到 (10^{-8})那大概率是梯度消失如果所有层的梯度都很大而且波动剧烈那多半是学习率太大、参数在震荡。用数值梯度检验配合打印每一层的梯度范数能快速定位问题。9.4 矩阵维度对不上怎么办反向传播代码最常见的报错就是矩阵乘法维度不匹配。我的排查顺序是先手写单样本的前向传播确认每一步输出维度把代码里的前向传播形状打出来对照检查误差项反向传播时用到的转置是否正确如果用了批量数据留意每个矩阵的第0维是样本数还是特征数很多框架的矩阵布局是(样本数, 特征数)但吴恩达课程中手写公式时习惯(特征数, 样本数)。两种布局在推导时都成立但代码里混用就会维度爆炸。我的建议是选一种布局从头统一到最后不要来回切换。9.5 代价函数计算时报log(0)如果标签是one-hot且预测值恰好为0或1log(0)就会触发警告。实际训练中预测值很少精确等于0或1但为了防止意外代码里给log的参数加上一个极小值如1e-12是最稳妥的做法。这个细节看起来小实际训练中如果不加偶尔会遇到loss nan直接中断训练。加上eps之后损失值不会炸梯度也不会被污染。9.6 学习率选多大合适交叉熵梯度的大小和二次代价梯度不同同样的网络、同样的初始化交叉熵给出的初始梯度往往更大一些。如果你刚把二次代价换成交叉熵发现同样学习率下Loss曲线震荡加剧可以把学习率调小一半甚至一个数量级再试。我在实际任务中习惯先用 (0.01) 或 (0.1) 起手观察前几十轮损失下降是否平滑如果震荡就降学习率如果下降太慢就升。没有万能值但有了这个调整方法就不会被某个固定值困住。10. 扩展从二分类到多分类上面的推导假设输出层每个神经元对应独立的sigmoid用于二分类或多标签任务。如果要做多分类通常把输出层换成softmax配合交叉熵代价函数。softmax版的推导结果非常相似[ \delta^{(L)} a^{(L)} - y ]这跟sigmoid版的输出层误差项形式一模一样。区别体现在隐藏层的传递公式上softmax的Jacobian矩阵比sigmoid复杂但交叉熵和softmax配合时Jacobian会被化简掉最终依然能得到这个简洁结果。因此很多框架中的多分类交叉熵损失函数反向传播的起点都是output - label。这跟本文推导的核心理念一脉相承代价函数的选择和激活函数的选择是互相配合的不能孤立看待。如果你之后要去看深度学习框架的源码会发现每层网络的反向传播函数里第一个梯度往往就是“预测值减真实值”后面接的才是逐层传递的矩阵运算。理解了本文的推导你再看这些源码就不会觉得它们是黑魔法了。最后说一个我个人经验中的小技巧学反向传播不要只看三遍推导就完事最好自己拿着笔、选一个很小的网络、用固定的初始值把每一个数字都算一遍。算完后再把这个过程写成一版不依赖框架的代码用数值梯度检验验证。完整来回两轮之后反向传播在你眼里就再也不是抽象公式而是一套有手感、有直觉的流程了。