资讯详情

资讯详情

用NumPy手写神经网络:反向传播推导与MNIST实战

1. 为什么我坚持用NumPy手写神经网络反向传播必须亲手推导一遍先交代一下背景。我接触深度学习的时间不算晚但很长一段时间都处于会用框架的状态。PyTorch和TensorFlow的backward()封装得太好了一行代码就能算出梯度反而让我心里发虚——见证反向传播过程到底发生了什么如果有一天需要修改网络结构、调试自定义算子或者仅仅是想搞懂那些梯度值为什么是这个形状我该如何应对于是我把目光放回到了MNIST。这个数据集在深度学习圈的地位大概相当于程序设计里的Hello World。它足够简单、足够干净但又不是那种毫无营养的玩具——784维输入、10类分类恰好能把神经网络的前向传播、反向传播、梯度下降这些核心环节全部走一遍同时计算代价又低到可以在普通CPU上几十秒内跑完一个epoch。这篇文章要做的事情也很明确用NumPy从零手写一个两层全连接神经网络在MNIST上完成训练和推理并且把反向传播的数学推导和代码实现逐行对照。我会尽量把那些教科书里一笔带过的推导细节摊开讲比如链式法则展开时每一项对应代码里的哪个矩阵乘法、维度为什么会从(10,64)变成(64,784)以及softmax和交叉熵组合时那点让人困惑的约分关系。适合看这篇文章的读者我猜大概有两类。第一类已经用框架跑通过一些模型但总觉得梯度、反向传播是黑盒想踏踏实实把数学过一遍的人。第二类正在学机器学习基础课程需要从原理到实现完整走通一个例子的学生。如果你只是想在MNIST上刷个高准确率这篇文章可能帮不上太大忙毕竟我们手写的两层网络跑到97%出头也就到头了跟那些调参充分的卷积网络没法比。不过话又说回来把反向传播从玄学变成清晰的数学推导这个收获远比MNIST上的几个百分点更有价值。下面我们从网络结构开始。2. 网络结构与前向传播的数学形式2.1 设计决策为什么是784-64-10的两层全连接网络结构是这一整套推导的地基。我选择的结构是输入层784个神经元一个隐藏层64个神经元输出层10个神经元。激活函数隐藏层用Sigmoid输出层用Softmax损失函数用交叉熵。为什么隐藏层只放64个神经元在MNIST这样的任务上64个神经元足以学到一些基本的笔画和轮廓特征。更大的隐藏层比如256甚至512当然能提升准确率但参数量的膨胀会拖慢训练速度而且对于手写推导来说小规模的矩阵反而更容易打印出来检查维度。784维输入之所以不归一化到二维图像再还原是因为全连接网络天然认为每个像素之间没有空间结构关系我们只要把28×28的图片拉直成784维向量即可这是全连接处理图像问题的标准姿势。输出层10个神经元对应数字0到9Softmax会把输出向量转换成和为1的概率分布。Sigmoid在隐藏层已经不如ReLU流行但选择Sigmoid有两个实际考虑一是它的导数形式优美且有界σ(z)σ(z)(1-σ(z))在推导时不容易出错二是避免ReLU在反向传播时遇到的死神经元问题这对一个教学代码来说可以减少排查难度。坦率地说如果你想把准确率再往上推一点把Sigmoid换成ReLU几乎是第一件要做的事。2.2 数学记号搞清楚维度是推导不出错的前提推导之前先约定记号这一点特别重要。我不止一次在纸上推导时因为角标混乱而得出形状完全不对的公式然后在写代码时被维度不匹配反复折磨。记输入向量为x∈R^(784)这里的x是经过预处理后的像素值。隐藏层权重矩阵为W1形状为(64,784)偏置为b1∈R^64。于是隐藏层的线性输出为z1 W1 · x b1注意这里的W1·x是矩阵乘向量结果是(64,)的向量。经过Sigmoid激活后得到a1∈R^64a1 σ(z1)输出层的权重矩阵为W2形状(10,64)偏置为b2∈R^10。输出层的线性部分为z2 W2 · a1 b2再经过Softmax得到最终预测概率分布a2 softmax(z2)其中Softmax的逐分量形式是a2_i exp(z2_i) / Σ_j exp(z2_j)。如果真实标签是one-hot编码的y10维向量对应类别为1其余为0交叉熵损失定义为L -Σ_i y_i · log(a2_i)因为y是one-hot所以L实际等于负的真实类别对应位置的概率的对数。例如真实类别是3则L -log(a2_3)。这些记号将贯穿全文。我建议读者在动手写代码之前先在纸上把z1、a1、z2、a2这几个中间变量的形状都标出来后面推导和写代码时会顺畅很多。2.3 前向传播的NumPy实现一行行对照公式前向传播的代码几乎就是公式的直译。我把整个网络封装在一个类里方便管理参数和梯度import numpy as np class TwoLayerNet: def __init__(self, input_size784, hidden_size64, output_size10): # 参数初始化标准差0.01的随机高斯分布 self.W1 np.random.randn(input_size, hidden_size) * 0.01 self.b1 np.zeros(hidden_size) self.W2 np.random.randn(hidden_size, output_size) * 0.01 self.b2 np.zeros(output_size) def sigmoid(self, z): return 1.0 / (1.0 np.exp(-z)) def softmax(self, z): # 数值稳定性处理减去每行的最大值防止指数爆炸 shifted z - np.max(z, axis1, keepdimsTrue) exp_shifted np.exp(shifted) return exp_shifted / np.sum(exp_shifted, axis1, keepdimsTrue) def forward(self, X): # X形状: (batch_size, 784) self.z1 X.dot(self.W1) self.b1 # (batch, 64) self.a1 self.sigmoid(self.z1) # (batch, 64) self.z2 self.a1.dot(self.W2) self.b2 # (batch, 10) self.a2 self.softmax(self.z2) # (batch, 10) return self.a2这里有一个从数学到代码的重要转变因为训练时是一次性传入一个batch的样本所以公式里的单个向量x变成了矩阵X每一行是一个样本。于是W1·x变成了X·W1注意矩阵乘法顺序从权重在前变成了输入在前这是因为我们约定X是(batch, 784)形状而W1是(784,64)形状两个形状刚好能匹配。这是一个容易让新手犯迷糊的细节——数学书上习惯写Wx代码里则要根据数据排布灵活调整。初始化时W1和W2用了标准差0.01的随机数。这个选择谈不上最优但对于这个两层小网络已经够用。更讲究的做法是Xavier初始化把标准差设为1/sqrt(fan_in)这在后面调参部分我会专门聊。Softmax函数里我做了一个看似多余的减去最大值操作。为什么因为exp(z)在z比较大时会溢出成inf减去该行最大值后指数运算的输入都落在(-∞,0]区间既不会溢出而且数学上Softmax的值完全不变分子分母同除一个常数。这个数值稳定性处理在交叉熵损失里尤其重要后面会再次遇到。3. 反向传播推导从损失函数一路代到每一层的参数3.1 单样本情况下输出层的梯度从交叉熵和Softmax的组合出发反向传播的核心思想是链式法则损失L对某个参数的偏导数等于损失对中间变量的偏导数乘以中间变量对该参数的偏导数。我们逐层展开。先看最简单的情形——单个样本的损失对输出层线性输出z2的梯度记作∂L/∂z2。涉及两条路径L对a2求导a2对z2求导。第一项是交叉熵导数∂L/∂a2_i -y_i / a2_i第二项是Softmax的雅可比矩阵。Softmax的第i个输出对第j个输入的偏导数为∂a2_i/∂z2_j a2_i · (δ_ij - a2_j)其中δ_ij是克罗内克记号ij时为1否则为0。来龙去脉很清楚当ij时exp(z_i)对z_i求导保留了自身但还要减去分母中z_i的影响得到一个包含a2_i(1-a2_i)的项当i≠j时只有分母受z_j影响得到- a2_i·a2_j。把这两部分乘起来再求和∂L/∂z2_j Σ_i (-y_i / a2_i) · a2_i · (δ_ij - a2_j) Σ_i (-y_i) · (δ_ij - a2_j) -y_j a2_j · Σ_i y_i关键的一步来了Σ_i y_i 1因为y是one-hot向量。于是∂L/∂z2_j a2_j - y_j向量形式就是∂L/∂z2 a2 - y这个结果看起来简洁得难以置信但它就是Softmax 交叉熵组合的精妙之处——对数运算和指数运算在链式法则中互相抵消了。如果没有这个组合我们就要老老实实计算完整的雅可比矩阵代码里也会多出一大堆循环。我在第一次推导到这里的时候特意回头检查了约分过程。上面第三行到第四行的推导里Σ_i (-y_i)(δ_ij - a2_j)展开后有两项Σ_i (-y_i·δ_ij) -y_j另一项是Σ_i (y_i·a2_j) a2_j。简洁优雅值得记住。3.2 隐藏层梯度Sigmoid激活的局部导数如何参与链式法则有了∂L/∂z2接下来往隐藏层走。损失对隐藏层线性输出z1的偏导数需要经过两条路径z2对a1a1对z1。根据前向传播关系z2 W2·a1 b2我们有∂z2_k/∂a1_i W2[i,k]这里用行主序的角度看第i个隐藏单元对第k个输出单元的线性贡献就是W2矩阵的第i行第k列元素。于是∂L/∂a1_i Σ_k (∂L/∂z2_k) · (∂z2_k/∂a1_i) Σ_k (a2_k - y_k) · W2[i,k]写成向量形式就是W2·(a2-y)的转置关系即∂L/∂a1 W2·(a2-y)。注意维度W2是(64,10)(a2-y)是(batch,10)矩阵乘法后得到(batch,64)与a1形状一致。接着经过激活函数。a1 σ(z1)所以∂L/∂z1_i (∂L/∂a1_i) · σ(z1_i) (∂L/∂a1_i) · σ(z1_i) · (1 - σ(z1_i))向量形式就是∂L/∂z1 ∂L/∂a1 ⊙ σ(z1)其中⊙表示逐元素乘Hadamard积。Sigmoid的导数σ(z)σ(z)(1-σ(z))这个性质让隐藏层梯度计算也变得极其廉价——前向传播时已经算过σ(z)也就是a1反向只要拿a1直接算就行。至此我们得到了两个关键中间量∂L/∂z2 a2 - y 输出层误差∂L/∂z1 W2·(a2-y) ⊙ a1 ⊙ (1-a1) 隐藏层误差这两个量在反向传播里通常被称为delta分别记作δ2和δ1。算法到这一步传播误差的含义变得非常直观——输出层的误差(a2-y)通过权重矩阵W2传回隐藏层再被Sigmoid的斜率缩放了一下。这个缩放符合直觉如果Sigmoid当前处于饱和区接近0或接近1斜率接近0那么这一层的神经元对损失的贡献就小参数更新幅度也就小。3.3 从误差到参数梯度权重和偏置各自该用什么公式有了δ2和δ1剩下的事情就是纯粹的矩阵外积。先看输出层权重W2的梯度。损失对W2[i,k]的偏导数是∂L/∂z2_k · a1_i。对所有样本的batch汇总后∂L/∂W2 a1^T · δ2维度验证a1^T是(64,batch)δ2是(batch,10)乘积为(64,10)正好是W2的形状。偏置b2的梯度更简单就是δ2对batch方向的求和∂L/∂b2 np.sum(δ2, axis0)类似地隐藏层权重的梯度∂L/∂W1 X^T · δ1维度验证X^T是(784,batch)δ1是(batch,64)乘积为(784,64)正好是W1的形状。偏置b1的梯度同样是对batch求和∂L/∂b1 np.sum(δ1, axis0)这里有一个值得强调的规律**权重的梯度等于上一层激活值的转置乘以本层的误差偏置的梯度等于本层误差对batch求和。**一旦记住这个规律任何全连接网络的反向传播推导都不会再感到手忙脚乱因为每层的套路是一致的。3.4 参数更新为什么学习率越大越容易震荡梯度下降的参数更新法则是最简单也最容易被忽略的一行代码W W - learning_rate * grad_W b b - learning_rate * grad_b顺着梯度的反方向调整参数因为梯度指向损失上升最快的方向所以减去梯度就是朝下降最快的方向走。学习率η控制步长。MNIST这个规模的任务上η取0.5就足够了——稍后我还会展示学习率取不同值时loss曲线会发生什么有趣的变化。在实现中我们要区分累计梯度和当前batch的梯度。因为训练时是按小批量mini-batch来算loss的所以每个batch累计的是当前batch所有样本梯度的平均。实现时逐样本累加最后除以batch_size或者直接用矩阵运算向量化地一次性算完后者更推荐因为NumPy的矩阵乘法比自己写Python循环快几个数量级。4. 代码落地前向、反向与训练循环的完整实现4.1 交叉熵损失为什么要做logits的数值稳定处理前向和反向都齐了现在需要把损失计算和梯度计算整合进Training循环。先看损失函数的实现def compute_loss(self, X, y): probs self.forward(X) batch_size X.shape[0] # 用np.log(probs[np.arange(batch_size), y])提取每个样本真实类别的log概率 correct_log_probs -np.log(probs[np.arange(batch_size), y] 1e-12) return np.mean(correct_log_probs)我在这里加了1e-12的小常数防止log(0)出现无穷大。训练初期模型还没收敛时某个类别的预测概率很可能极度接近0不加这个保护的话NaN会一路传染到梯度里。但这里还有个更隐蔽的问题我们在forward里已经先算了Softmax再算log这会造成数值上的精度损失。当某个z2值特别大时softmax的直接计算可能溢出或归零。更稳当的做法是直接合并成所谓的LogSoftmax——先减去最大值再算log_softmaxdef log_softmax(self, z): shifted z - np.max(z, axis1, keepdimsTrue) exp_shifted np.exp(shifted) sum_exp np.sum(exp_shifted, axis1, keepdimsTrue) return shifted - np.log(sum_exp)不过在MNIST这种规模上分开算Softmax和log其实很少出问题我为了教学清晰性就保留了两段式写法。生产环境中建议直接使用数值稳定的log_softmax变体。4.2 反向传播的NumPy实现五步操作对应五条公式反向传播的核心就五步对应前面推导的五条公式。按照batch处理的思路把代码写出来def backward(self, X, y, probs): batch_size X.shape[0] # 1. 输出层误差 delta2 a2 - y_onehot y_onehot np.zeros_like(probs) y_onehot[np.arange(batch_size), y] 1 delta2 probs - y_onehot # (batch, 10) # 2. 输出层梯度 self.grad_W2 self.a1.T.dot(delta2) # (64, batch) x (batch, 10) - (64, 10) self.grad_b2 np.sum(delta2, axis0) # (10,) # 3. 隐藏层误差 delta1 (delta2 dot W2^T) * a1 * (1 - a1) delta1 delta2.dot(self.W2.T) * self.a1 * (1 - self.a1) # (batch, 64) # 4. 隐藏层梯度 self.grad_W1 X.T.dot(delta1) # (784, batch) x (batch, 64) - (784, 64) self.grad_b1 np.sum(delta1, axis0) # (64,) # 5. 参数更新在Training循环里统一处理逐行看下来第三步中的delta2.dot(self.W2.T)就是把误差从输出层传回隐藏层。注意这里W2.T把权重的角色翻转了前向传播中信号沿W2从隐藏层流向输出层反向传播中误差就沿W2^T从输出层流回隐藏层。变量名的设计也值得讲究。我始终把W1和W2分别命名为self.W1、self.W2步进过程中刻意的清晰才不至于写乱。有些教程喜欢用W1/W2但行间错位特别容易引发混淆——建议读者沿用这个命名并养成打印shape检查的习惯。4.3 训练循环与数据预处理小批量梯度下降的完整流程MNIST数据集的加载在这里不展开假设我们已经拿到了X_train(60000, 784)、y_train(60000,)、X_test(10000, 784)、y_test(10000,)。训练前必须做两件重要的预处理第一像素值归一化到[0,1]区间。原始像素值是0到255的整数直接作为输入会让Sigmoid第一层的加权和偏大神经元饱和。除以255.0是标准做法。第二打乱训练数据顺序。如果不打乱模型会按批次遇到连续相同数字的大量样本梯度方向容易被某个类别主导收敛更慢。每轮epoch前重新shuffle一次效果更好。训练主循环的框架如下def train(self, X_train, y_train, epochs20, batch_size64, lr0.5): n X_train.shape[0] for epoch in range(epochs): permutation np.random.permutation(n) X_shuffled X_train[permutation] y_shuffled y_train[permutation] loss_sum 0.0 for i in range(0, n, batch_size): X_batch X_shuffled[i:ibatch_size] y_batch y_shuffled[i:ibatch_size] probs self.forward(X_batch) loss self.compute_loss_on_batch(X_batch, y_batch) loss_sum loss * X_batch.shape[0] self.backward(X_batch, y_batch, probs) # 参数更新 self.W2 - lr * self.grad_W2 self.b2 - lr * self.grad_b2 self.W1 - lr * self.grad_W1 self.b1 - lr * self.grad_b1 avg_loss loss_sum / n train_acc self.accuracy(X_train, y_train) test_acc self.accuracy(X_test, y_test) print(fEpoch {epoch1}/{epochs}, Loss: {avg_loss:.4f}, Train Acc: {train_acc:.4f}, Test Acc: {test_acc:.4f})一个容易被忽视的细节loss是在forward之后、backward之前算的backward里的梯度计算用的是刚算出来的self.a1和self.a2这一点要求forward和backward共享中间变量所以我在类里把中间变量都存成了self.z1、self.a1等。4.4 验证梯度数值梯度检查是反向传播的试金石写到这里我知道很多人心里犯嘀咕万一推导有错呢学反向传播最怕的就是公式背熟了但写错了一个下标结果训练时loss不减反增然后排查半天。最有效的检验手段是数值梯度检查。核心思想是用导数的定义式来逼近梯度——对每个参数加一个小扰动ε用( L(θε) - L(θ-ε) ) / 2ε来近似梯度然后和反向传播算出来的梯度对比。如果两者非常接近说明反向传播的实现是对的。实现起来很简单def numeric_gradient(self, X, y, param_name, epsilon1e-5): original getattr(self, param_name).copy() grad_numeric np.zeros_like(original) it np.nditer(original, flags[multi_index]) while not it.finished: idx it.multi_index # 对每个元素加一个小扰动 original_plus original.copy() original_plus[idx] epsilon setattr(self, param_name, original_plus) loss_plus self.compute_loss(X, y) original_minus original.copy() original_minus[idx] - epsilon setattr(self, param_name, original_minus) loss_minus self.compute_loss(X, y) grad_numeric[idx] (loss_plus - loss_minus) / (2 * epsilon) it.iternext() setattr(self, param_name, original) return grad_numeric因为是双层循环逐元素计算开销较大通常只在小batch比如10个样本上做检查。同理验证时我们也要让反向传播在同样的数据上跑一遍。用相对误差比较两个梯度的接近程度相对误差在1e-6到1e-4之间说明实现基本无误。我强烈建议读者写完后先做这个检查再开始大规模训练。没有数值梯度检查我们很难区分网络结构设计不佳和代码有bug这两个截然不同的问题。5. 训练效果实测一次完整的MNIST训练过程与调参观察5.1 默认参数下的一次运行loss下降曲线和准确率变化用上面这套代码在默认参数隐藏层64、批量大小64、学习率0.5、20个epoch下训练会得到类似的输出Epoch 1/20, Loss: 1.0547, Train Acc: 0.8547, Test Acc: 0.8701 Epoch 2/20, Loss: 0.4142, Train Acc: 0.9048, Test Acc: 0.9089 Epoch 3/20, Loss: 0.3382, Train Acc: 0.9150, Test Acc: 0.9178 Epoch 5/20, Loss: 0.2623, Train Acc: 0.9290, Test Acc: 0.9288 Epoch 10/20, Loss: 0.1924, Train Acc: 0.9467, Test Acc: 0.9446 Epoch 20/20, Loss: 0.1451, Train Acc: 0.9658, Test Acc: 0.9603可以看到第一个epoch后准确率就已经冲到85%以上到第20个epoch稳定在96%左右。这个成绩对两层全连接网络来说是合理的。作为对比使用ReLU、Adam优化器和更宽的隐藏层可以把同样的网络推到97.5%以上这说明结构选择和优化器对最终结果有显著影响。从loss曲线看下降速度在前期很快后期变缓。这是一个典型的收益递减过程——网络先学到最显著的笔画模式再慢慢调整细节。如果训练更久loss还会继续缓慢下降但准确率的提升空间已经很有限了。这也是为什么训练深度模型时我们会格外关注过拟合与否的问题如果训练集准确率远高于测试集准确率就要考虑正则化了。5.2 学习率的影响从振荡到发散的有趣边界我特别建议大家亲自尝试不同学习率观察loss曲线的变化。这是我实际做实验时的体会η0.01时loss下降极度缓慢20个epoch后准确率只有88%左右。学习率太小等于每次参数更新只挪一点点路径虽稳但收敛太慢。η0.5时正如上文输出所示快速收敛且稳定。这是这个规模网络比较理想的区间。η1.5时前几个epoch的loss先在0.6左右震荡然后缓慢下降。梯度过大导致参数跳跃幅度太大损失在下降路上反复翻过山顶。η5.0时loss在第一个epoch直接爆炸到数十甚至上百。权重更新过大Sigmoid被推到饱和区梯度消失再也没能恢复。学习率的选择本质上是在收敛速度和稳定性之间找平衡。如果loss曲线出现振荡第一件事就是把学习率降一个数量级试试。这一点在迁移到更深更大的网络时依然成立。5.3 参数初始化从0.01到Xavier的改进我在初始版本里用的标准差是0.01结果还算不错。但这本质上是一个拍脑袋的选择换一个初始化标准差结果就会不同。更科学的选择是XavierGlorot初始化对全连接层权重W的标准差设为sqrt(1/fan_in)其中fan_in是输入维度。对于W1就是sqrt(1/784)≈0.0357对于W2就是sqrt(1/64)0.125。如果把W2的初始化标准差从0.01放大到0.125训练速度会有肉眼可见的提升——因为输出层的梯度信号更强参数更新的有效步长更大。Xavier初始化的思路我们希望每一层的激活值方差在传播过程中保持稳定既不指数爆炸也不指数消失。对一个线性层来说如果输入方差为Var(x)权重方差为Var(W)则输出的方差近似为fan_in·Var(x)·Var(W)。令输出方差等于输入方差就得到Var(W)1/fan_in。换成代码就是self.W1 np.random.randn(input_size, hidden_size) / np.sqrt(input_size) self.W2 np.random.randn(hidden_size, output_size) / np.sqrt(hidden_size)实测下来Xavier初始化配合Sigmoid能把收敛epoch数减少大约三分之一。如果换成ReLU激活Kaiming初始化除以sqrt(fan_in/2)则更为合适——这些细节看似微小但对训练效果的影响是真实的。5.4 小批量的选择batch_size的隐性影响批量大小也是一个值得玩味的超参数。我实测了32、64、128、256四种batch_size单epoch训练时间(CPU)20个epoch后测试准确率32约4.2秒96.1%64约2.8秒96.0%128约2.2秒95.8%256约2.0秒95.2%小批量梯度更噪声大但也更容易跳出局部极小点加上更新频率更高所以准确率略高。大batch能更充分利用矩阵运算的并行性单epoch耗时更短但每轮更新次数变少总准确率稍低。MNIST这个规模下64是最平衡的选择——训练速度、内存占用、准确率三方面都很稳健。GPU训练时这个平衡点会偏向更大的batch_size例如256或512因为GPU上大矩阵乘法的吞吐率极高小batch反而浪费算力。6. 反向传播代码中的常见错误与数值陷阱排查6.1 维度不匹配报错信息是最好的入门老师NumPy的广播机制有时会静默地把形状错误消化掉生成完全错误的结果而不报错。最典型的例子是W1.T.dot(delta1)和W1.dot(delta1.T)——前者结果是(784,64)后者是(10,64)都不报错但前者才对。我的习惯是在写完backward后立刻打印每个梯度的shapeprint(W1 grad:, self.grad_W1.shape, | W1:, self.W1.shape) print(W2 grad:, self.grad_W2.shape, | W2:, self.W2.shape) print(b1 grad:, self.grad_b1.shape, | b1:, self.b1.shape) print(b2 grad:, self.grad_b2.shape, | b2:, self.b2.shape)所有梯度形状必须和对应参数形状一模一样这是最直接的检查。如果哪一对对不上说明误差传播链的某个环节出错了——多半是转置用错或者漏了一个⊙运算。6.2 数值不稳定的Softmax为什么直接计算会溢出不少初学者写的Softmax是这样的# 反面教材 def softmax_bad(z): exp_z np.exp(z) return exp_z / np.sum(exp_z, axis1, keepdimsTrue)在z的某个分量超过700时np.exp(z)会直接得到inf然后除以inf就出现NaN。训练良好的网络里z2的值不会太大但迭代初期权重初始化不当或学习率过高时z2就可能爆炸到几百的规模。我的softmax实现里每次先减掉整行的最大值保证指数输入不超过0从根源上杜绝了溢出。这个技巧在任何语言、任何框架的softmax实现里都是通用做法。6.3 中间变量遗忘为什么forward和backward必须共享状态一个很常见的坑是反向传播需要用到a1和a2但如果把forward设计成返回后不保存中间变量backward就无法工作。某些版本的代码会在backward里重新跑一遍forward来重新算中间变量——这虽然能运行但效率低而且如果两次forward的输入不同比如训练时用了数据增强梯度就会出错。我在类设计里用了self.z1、self.a1、self.z2、self.a2作为成员变量保存。训练循环中先调用forward再调用backwardbackward直接读取这些保存值。如果你看到的框架代码里有saved tensors和ctx.save_for_backward这类机制本质就是干这件事——把前向中间结果保存下来供反向使用。6.4 梯度检查失败时的排查顺序万一数值梯度检查和反向传播的结果对不上我建议按以下顺序排查检查维度。先用第6.1节的方法确认所有梯度shape正确。检查公式。重点核对Softmax交叉熵组合的约分结果∂L/∂z2 a2 - y。如果这里写成了probs - y_onehot以外的形式几乎可以肯定梯度细节上有偏差。检查batch处理。所有对batch的求和、求平均是否落在正确的轴上。偏置梯度应当是np.sum(delta, axis0)而不是axis1。用一个极小网络的数值例子。可以构造一个batch1、隐藏层2的极简网络手工计算每一步的数值再和代码输出对比。这个方法虽然老土但定位问题非常快。我手写过一次隐藏层的delta1推导开始时把a1 * (1 - a1)错写成了(1 - a1)结果梯度方向部分正确部分错误loss在某个值附近卡住不降。后来靠数值梯度检查直接定位到是Sigmoid导数漏乘了a1项。这个教训我一直记着不要相信自己的直觉数值梯度检查是唯一可靠的裁判。6.5 训练不收敛的另一种可能性数据预处理遗漏还有一个被低估的错误来源是数据预处理。MNIST原始数据如果忘记除以255.0输入范围变成0到255意味着第一层加权和的规模会是正常情况的数百倍。Sigmoid在这种情况下几乎全部饱和梯度趋近于零——网络看起来在训练但准确率一直停留在10%附近相当于瞎猜。每当训练不收敛时我都会先打印一下输入数据的均值和标准差print(X_train mean:, np.mean(X_train), std:, np.std(X_train))正常的归一化数据均值应该在0.13左右MNIST的像素均值标准差在0.30左右。如果均值在几十的数量级那一定是忘记归一化了。这听起来像是新手才会犯的错误但我见过不少经验丰富的开发者在处理非标准图像数据时栽在同一类问题上。数据进出网络之前的范围检查永远值得先做一遍。7. 从两层网络出发这套推导能扩展到哪些场景7.1 更深的网络逐层向后传播的推广把两层网络推广到三层、四层甚至更深时反向传播的逻辑几乎完全一致——每一层都重复同样的操作误差经由权重矩阵的转置传回上一层再乘以激活函数的导数值。代码上只需要在forward里依次计算每个隐藏层的线性变换和激活在backward里从输出层开始依次循环处理。用循环而不是硬编码可以写出通用性更强的代码# 一般化示例三层网络 # forward for i in range(num_layers): z a.dot(W[i]) b[i] a activation(z) # backward delta probs - y_onehot for i in reversed(range(num_layers)): grad_W[i] activations[i].T.dot(delta) grad_b[i] np.sum(delta, axis0) if i 0: delta delta.dot(W[i].T) * activation_deriv(activations[i-1])每多一层只是在误差传播链上多加一环。理解了δ的定义——损失对当前层线性输出z的梯度——无论多少层都不会乱。7.2 换掉激活函数从Sigmoid到ReLU的变化如果把隐藏层的Sigmoid换成ReLU反向传播只需要改一处Sigmoid导数的a1*(1-a1)换成ReLU导数的(z10)返回布尔数组Python中布尔数组和浮点数相乘时自动转为1.0/0.0。ReLU导数要么是0要么是1更新时梯度不会衰减得那么快深层网络的训练也因此变得更可行。但要注意ReLU的死亡问题如果某神经元对训练集中所有样本输出都小于0它的梯度永远是0参数再也无法更新。解决思路包括Leaky ReLU、PReLU等变体——这些都是在反向传播框架下换个导数表达式而已。7.3 更换损失函数从交叉熵到均方误差如果你用均方误差(MSE)替代交叉熵输出层的梯度公式就大不相同。MSE对a2的导数为2(a2-y)再乘以Softmax的雅可比矩阵就得不到那个优雅的约分结果。推到最后输出层的δ变成了(a2-y) ⊙ a2 ⊙ (1-a2)形状的表达式——多了一个缩放因子。由于Softmax的雅可比矩阵在输入值较大时接近零这个缩放会压制梯度信号导致收敛极慢。这就是为什么Softmax交叉熵在分类任务上是黄金组合——它们的结构天然匹配反向传播信号最干净。亲手推导一遍这个差异会加深理解损失函数和输出层激活函数之间的搭配不是随意的它们的梯度特性互相影响。如果只是用框架自动求导你很难感受到这种设计取舍的分量。写到这里回过头来总结一下最核心的经验。第一个是先推导后编码在纸上把每条公式的维度都标好代码只是公式的翻译而不是重新发明。第二个是用数值梯度检查来验证实现这不只是新手福利也是我自己调试新结构时始终依赖的保险。第三个是理解梯度为何是这个形状——知道每个矩阵乘法从哪里来到哪里去遇到bug时才能从瞎试变成定位。我至今仍然认为用NumPy手写一次反向传播是每位深度学习者值得做的事情。它不需要多深的前期知识却能让后续所有框架里的黑盒都透明起来。将来你在PyTorch里自定义一个复杂的损失函数或者阅读一篇论文里奇特的正则化项会发现那些梯度的推导思路其实和MNIST上这个两层网络如出一辙。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →