ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从零手写神经网络第十六期:反向传播原理与纯Python实现

从零手写神经网络第十六期:反向传播原理与纯Python实现 1. 从零手写神经网络第十六期为什么反向传播才是真正的分水岭写这个系列写到第十六期说实话我自己都有点意外能坚持这么久。前面十五期我们陆陆续续把前馈神经网络的结构搭起来了权重初始化、激活函数、损失函数这些零件也都一个个手搓过一遍。但每次到反向传播这一块总有人在评论区问为什么不能直接调库为什么非要手写我理解这种心情毕竟sklearn几行代码就能跑通的东西手写要折腾大半天。但如果你真的想搞明白神经网络到底在干什么反向传播这一关绕不过去。这一期我们就专门啃这块硬骨头。核心目标很明确不依赖任何深度学习框架纯Python加NumPy把反向传播的每一个梯度推导过程落实到代码里。读完之后你应该能清楚地知道一个样本进来误差是怎么一层一层传回去的每个参数的梯度到底是怎么算出来的以及为什么有些看似合理的写法会导致梯度消失或者梯度爆炸。这篇文章适合谁如果你已经跟着前面几期把前向传播写完了现在卡在反向传播不知道从哪下手那正好。如果你是完全零基础建议先回去把前十五期的内容过一遍至少要知道什么是权重矩阵、什么是激活函数、什么是损失函数。另外文章里涉及的所有代码都是可以直接复制运行的Python 3.8以上版本加NumPy就能跑不需要装PyTorch或者TensorFlow。我个人的习惯是每写一个模块都要能用手算验证一遍。反向传播尤其需要这样因为链式法则稍微写错一个符号整个训练就废了而且报错信息往往不会告诉你哪里错了只是loss不下降而已。所以这一期我会带着大家一步一步推导每一步都配上数值验证的方法。2. 反向传播的数学底子链式法则到底怎么用2.1 从最简单的复合函数说起反向传播的本质就是链式法则这个大家都知道。但很多人卡住的地方在于当函数嵌套层数多了之后到底谁对谁求导、中间结果怎么保存脑子就乱了。我们先从一个最简单的例子入手把思路理顺。假设有一个复合函数 y f(g(h(x)))我们想求 y 对 x 的导数。链式法则告诉我们dy/dx (dy/df) * (df/dg) * (dg/dh) * (dh/dx)这个公式看起来简单但实际计算的时候有个关键点你不能直接把符号代进去算因为每一层的导数可能依赖于中间变量的值。所以实际计算时我们通常是从最外层开始先算 dy/df然后乘上 df/dg再乘上 dg/dh最后乘上 dh/dx。这个从外往里的顺序就是“反向”传播这个名字的由来。在神经网络里这个链条会更长因为每一层都有权重矩阵和激活函数。但核心逻辑完全一样从损失函数开始一层一层往回推每推一层就把当前层的梯度传给前一层。2.2 计算图视角下的反向传播如果你觉得纯数学推导太抽象可以换一个视角把整个前向传播过程画成一张计算图。每个节点代表一个运算比如矩阵乘法、加法、激活函数每条边代表数据流动的方向。前向传播就是沿着边从左到右算一遍反向传播就是从右到左算一遍。这个视角的好处是你不需要记住复杂的链式法则公式只需要记住每个基本运算的局部梯度就行了。比如加法节点的局部梯度是1所以梯度直接传过去乘法节点的局部梯度是另一个乘数所以梯度要乘以另一个乘数的值矩阵乘法节点的局部梯度涉及转置操作ReLU激活函数的局部梯度是0或1取决于输入是否大于0把这些基本运算的局部梯度组合起来就能得到整个网络的梯度。这也是为什么现代深度学习框架都采用计算图的方式来实现自动微分因为这样既通用又高效。2.3 为什么必须保存前向传播的中间结果这里有一个非常关键的细节反向传播需要用到前向传播过程中保存的中间值。比如对于ReLU激活函数反向传播时需要知道前向传播时的输入是大于0还是小于0才能决定梯度是1还是0。对于Sigmoid激活函数反向传播时需要用到前向传播的输出值。这就意味着你不能只保存最终的输出必须把每一层的输入、输出、激活前的值都存下来。这也是为什么训练神经网络比推理更耗内存的原因之一。推理的时候只需要保存当前层的输入输出训练的时候需要保存所有层的中间结果直到反向传播完成才能释放。我见过不少人为了省内存在前向传播的时候不保存中间值结果反向传播的时候只能重新算一遍反而更慢。所以除非你的内存真的非常紧张否则老老实实把中间值存下来是最稳妥的做法。3. 手写反向传播的核心代码结构3.1 整体架构设计在动手写代码之前我们先规划一下整体结构。一个完整的神经网络训练过程包括以下几个部分前向传播从输入层开始逐层计算加权和与激活值直到输出层损失计算根据输出层的预测值和真实标签计算损失值反向传播从损失函数开始逐层计算每个参数的梯度参数更新根据梯度和学习率更新权重和偏置这四步循环执行直到损失收敛或者达到预设的训练轮数。在代码组织上我习惯把每一层封装成一个类每个类负责自己的前向传播和反向传播。这样结构清晰也方便后续扩展。下面是一个简化的层基类设计class Layer: def forward(self, input): raise NotImplementedError def backward(self, grad_output): raise NotImplementedError def update_params(self, learning_rate): pass这个基类定义了三个方法forward负责前向传播backward负责反向传播并返回对输入的梯度update_params负责更新参数。对于没有参数的层比如激活函数层update_params可以留空。3.2 全连接层的反向传播实现全连接层是神经网络中最核心的层它的前向传播是output input W b其中input是形状为(batch_size, input_dim)的矩阵W是形状为(input_dim, output_dim)的权重矩阵b是形状为(output_dim,)的偏置向量。反向传播时我们需要计算三个梯度对输入的梯度grad_input grad_output W.T对权重的梯度grad_W input.T grad_output对偏置的梯度grad_b sum(grad_output, axis0)这三个公式的推导过程如下对于grad_input因为output input W b所以d_output/d_input W根据链式法则grad_input grad_output W.T。注意这里用了转置因为矩阵乘法的梯度涉及到维度匹配。对于grad_W因为output input W b所以d_output/d_W input根据链式法则grad_W input.T grad_output。同样用了转置来匹配维度。对于grad_b因为output input W b所以d_output/d_b 1根据链式法则grad_b grad_output。但因为b是向量而grad_output是矩阵所以需要对batch维度求和。这些公式看起来简单但实际写代码的时候很容易搞错维度。我的经验是每次写完都要用一个小例子手动验证一下确保维度对得上。3.3 激活函数层的反向传播实现激活函数层没有参数只需要计算对输入的梯度。以ReLU为例前向传播output max(0, input) 反向传播grad_input grad_output * (input 0)这里的关键是反向传播需要用到前向传播时的输入值。所以在前向传播的时候必须把input保存下来。对于Sigmoid激活函数前向传播output 1 / (1 exp(-input)) 反向传播grad_input grad_output * output * (1 - output)注意这里用的是前向传播的输出值而不是输入值。所以在前向传播的时候需要保存output。对于Tanh激活函数前向传播output tanh(input) 反向传播grad_input grad_output * (1 - output ** 2)同样用的是输出值。这里有一个常见的坑如果你在前向传播的时候只保存了输出没有保存输入那么对于ReLU来说就没法计算梯度了。所以我的建议是每个层都同时保存输入和输出虽然会多占一点内存但省去了很多麻烦。4. 完整训练流程的代码实现与逐行解析4.1 数据准备与网络初始化我们先构造一个简单的二分类数据集用sklearn的make_moons函数生成两个交错的半圆这样能直观地看到神经网络是如何学会非线性决策边界的。import numpy as np from sklearn.datasets import make_moons from sklearn.model_selection import train_test_split # 生成数据 X, y make_moons(n_samples1000, noise0.2, random_state42) y y.reshape(-1, 1) # 划分训练集和测试集 X_train, X_test, y_train, y_test train_test_split(X, y, test_size0.2, random_state42) # 数据标准化 mean X_train.mean(axis0) std X_train.std(axis0) X_train (X_train - mean) / std X_test (X_test - mean) / std这里做标准化很重要因为神经网络的权重初始化通常是在0附近的小随机数如果输入数据的量级差异很大会导致训练不稳定。标准化之后所有特征的均值为0标准差为1这样梯度下降的收敛速度会快很多。接下来定义网络结构。我们用一个三层的网络输入层2个神经元隐藏层1有16个神经元隐藏层2有8个神经元输出层1个神经元。# 网络结构 layer_dims [2, 16, 8, 1] # 初始化参数 def init_params(layer_dims): params {} for l in range(1, len(layer_dims)): params[W str(l)] np.random.randn(layer_dims[l-1], layer_dims[l]) * np.sqrt(2.0 / layer_dims[l-1]) params[b str(l)] np.zeros((1, layer_dims[l])) return params这里用了He初始化因为我们的隐藏层用的是ReLU激活函数。He初始化的核心思想是让每一层的输出方差保持一致避免梯度消失或爆炸。具体来说权重从均值为0、标准差为sqrt(2/n_in)的正态分布中采样其中n_in是输入维度。4.2 前向传播的完整实现前向传播的过程就是逐层计算加权和与激活值。我们把每一层的中间结果都保存下来方便反向传播使用。def forward_propagation(X, params): caches [] A X L len(params) // 2 for l in range(1, L): A_prev A W params[W str(l)] b params[b str(l)] Z np.dot(A_prev, W) b A np.maximum(0, Z) # ReLU激活 caches.append((A_prev, W, b, Z)) # 输出层用Sigmoid激活 W params[W str(L)] b params[b str(L)] Z np.dot(A, W) b A 1 / (1 np.exp(-Z)) # Sigmoid激活 caches.append((A_prev, W, b, Z)) return A, caches这里有一个细节需要注意隐藏层用ReLU输出层用Sigmoid。这是因为我们的任务是二分类输出需要是一个0到1之间的概率值。如果是多分类任务输出层应该用Softmax。另外caches列表保存了每一层的输入、权重、偏置和激活前的值。这些值在反向传播时都会用到。4.3 损失函数与反向传播二分类任务常用的损失函数是二元交叉熵def compute_loss(A, y): m y.shape[0] loss -1/m * np.sum(y * np.log(A 1e-8) (1 - y) * np.log(1 - A 1e-8)) return loss这里加了一个很小的常数1e-8防止log(0)的情况出现。虽然理论上Sigmoid的输出不会正好是0或1但浮点数运算可能会有精度问题加上这个常数更保险。反向传播是这一期的核心我们一步一步来def backward_propagation(params, caches, X, y): grads {} L len(caches) m X.shape[0] # 输出层的梯度 A_prev, W, b, Z caches[-1] A 1 / (1 np.exp(-Z)) dZ A - y # Sigmoid 交叉熵的梯度 grads[dW str(L)] 1/m * np.dot(A_prev.T, dZ) grads[db str(L)] 1/m * np.sum(dZ, axis0, keepdimsTrue) dA_prev np.dot(dZ, W.T) # 隐藏层的梯度 for l in reversed(range(L-1)): A_prev, W, b, Z caches[l] dZ dA_prev * (Z 0) # ReLU的梯度 grads[dW str(l1)] 1/m * np.dot(A_prev.T, dZ) grads[db str(l1)] 1/m * np.sum(dZ, axis0, keepdimsTrue) dA_prev np.dot(dZ, W.T) return grads这里有几个关键点需要解释第一输出层的dZ A - y这个公式是Sigmoid激活函数加交叉熵损失函数的组合梯度。推导过程是dL/dZ dL/dA * dA/dZ其中dL/dA -y/A (1-y)/(1-A)dA/dZ A(1-A)两者相乘化简后得到A - y。这个组合梯度非常简洁也是为什么分类任务喜欢用交叉熵损失的原因之一。第二隐藏层的dZ dA_prev * (Z 0)这是ReLU的梯度。当Z 0时梯度为1当Z 0时梯度为0。注意这里用的是Z而不是A因为ReLU的梯度取决于激活前的值。第三所有梯度都除以了m这是对batch中所有样本的梯度取平均。这样做的好处是梯度的大小不会随着batch size的变化而变化学习率可以保持相对稳定。4.4 参数更新与训练循环参数更新就是最简单的梯度下降def update_params(params, grads, learning_rate): L len(params) // 2 for l in range(1, L1): params[W str(l)] - learning_rate * grads[dW str(l)] params[b str(l)] - learning_rate * grads[db str(l)] return params训练循环就是把上面的步骤串起来def train(X, y, layer_dims, learning_rate0.01, num_iterations5000): params init_params(layer_dims) losses [] for i in range(num_iterations): A, caches forward_propagation(X, params) loss compute_loss(A, y) grads backward_propagation(params, caches, X, y) params update_params(params, grads, learning_rate) if i % 100 0: losses.append(loss) print(fIteration {i}, Loss: {loss:.4f}) return params, losses跑一下这个训练循环你应该能看到loss从0.7左右逐渐下降到0.1以下。如果loss不下降或者下降很慢那多半是反向传播的梯度算错了。5. 梯度检查确保反向传播正确性的终极武器5.1 数值梯度与解析梯度的对比反向传播的代码写完之后怎么确认它是对的最可靠的方法就是梯度检查。核心思想是用数值方法近似计算梯度然后和反向传播算出来的解析梯度对比如果两者非常接近说明反向传播是正确的。数值梯度的计算公式是grad_numerical (f(x epsilon) - f(x - epsilon)) / (2 * epsilon)其中epsilon是一个很小的数通常取1e-7。这个公式叫做中心差分比单侧差分更精确。在神经网络中我们需要对每个参数分别计算数值梯度。因为参数数量可能很多所以梯度检查通常只在少量样本上做而且只检查部分参数。def gradient_check(params, grads, X, y, epsilon1e-7): params_vec [] grads_vec [] # 把所有参数和梯度展平成一维向量 for key in params: params_vec.append(params[key].flatten()) grads_vec.append(grads[d key].flatten()) params_vec np.concatenate(params_vec) grads_vec np.concatenate(grads_vec) num_params params_vec.shape[0] numerical_grads np.zeros_like(params_vec) # 对每个参数计算数值梯度 for i in range(num_params): original params_vec[i] params_vec[i] original epsilon # 需要把展平的参数还原回去重新计算loss # 这里省略还原过程实际使用时需要写一个unflatten函数 loss_plus compute_loss(forward_propagation(X, params)[0], y) params_vec[i] original - epsilon loss_minus compute_loss(forward_propagation(X, params)[0], y) numerical_grads[i] (loss_plus - loss_minus) / (2 * epsilon) params_vec[i] original # 计算相对误差 diff np.linalg.norm(numerical_grads - grads_vec) / (np.linalg.norm(numerical_grads) np.linalg.norm(grads_vec) 1e-8) return diff如果相对误差小于1e-7说明反向传播完全正确。如果在1e-7到1e-5之间说明基本正确可能有轻微的数值误差。如果大于1e-5那肯定有地方写错了需要仔细检查。5.2 梯度检查的常见坑梯度检查虽然强大但也有一些坑需要注意第一梯度检查必须在训练之前做而且要用随机初始化的参数。如果参数已经训练了很久梯度可能非常小数值误差会掩盖真正的错误。第二梯度检查时不要用正则化。如果损失函数里加了L2正则化项数值梯度和解析梯度都会包含正则化的贡献对比起来更复杂。建议先关掉正则化确认反向传播正确后再加回来。第三梯度检查时要用双精度浮点数。单精度浮点数的精度不够epsilon取1e-7的时候可能会被舍入误差淹没。第四如果网络里有ReLU激活函数要注意在0附近的不可导点。如果某个神经元的输入正好在0附近数值梯度和解析梯度可能会有较大差异。这种情况下可以多试几个随机种子或者暂时换成Sigmoid激活函数做检查。我自己的习惯是每写一个新的层或者新的激活函数都会单独做一次梯度检查。虽然麻烦一点但能省去后面调试的几个小时。6. 训练过程中常见问题与排查技巧6.1 Loss不下降的排查思路Loss不下降是训练神经网络最常见的问题原因可能有很多。我一般按照以下顺序排查首先检查学习率。学习率太大会导致loss震荡甚至发散太小会导致loss下降极慢。可以尝试把学习率调小10倍或者调大10倍看看loss的变化趋势。如果调小之后loss开始下降说明之前的学习率太大了。然后检查数据预处理。如果输入数据的量级差异很大或者没有做标准化梯度下降会很难收敛。确保所有特征都在相似的量级上均值接近0标准差接近1。接着检查权重初始化。如果权重初始化得太小信号在传播过程中会逐渐衰减导致梯度消失。如果初始化得太大信号会逐渐放大导致梯度爆炸。可以尝试不同的初始化方法比如Xavier初始化或者He初始化。最后检查反向传播的代码。用梯度检查确认梯度计算是否正确。如果梯度检查不通过那问题肯定出在反向传播上。6.2 梯度消失与梯度爆炸的应对梯度消失和梯度爆炸是深层网络的经典问题。梯度消失是指梯度在反向传播过程中逐渐变小导致浅层参数几乎不更新。梯度爆炸是指梯度逐渐变大导致参数更新步长过大loss震荡甚至变成NaN。对于梯度消失常用的解决方法包括使用ReLU激活函数代替Sigmoid或Tanh因为ReLU在正区间的梯度恒为1不会衰减使用残差连接让梯度可以绕过某些层直接传回去使用Batch Normalization让每一层的输入分布保持稳定使用LSTM或GRU等门控机制专门解决长序列的梯度消失问题对于梯度爆炸常用的解决方法包括梯度裁剪把梯度的范数限制在一个阈值以内使用更小的学习率使用权重正则化限制权重的大小使用梯度归一化把梯度缩放到合适的范围在实际操作中我通常会先加梯度裁剪因为这是最简单也最有效的方法。具体做法是在反向传播得到所有梯度之后计算所有梯度拼接起来的范数如果超过阈值就按比例缩放。def clip_gradients(grads, max_norm5.0): total_norm 0 for key in grads: total_norm np.sum(grads[key] ** 2) total_norm np.sqrt(total_norm) if total_norm max_norm: scale max_norm / total_norm for key in grads: grads[key] * scale return grads这个函数应该放在反向传播之后、参数更新之前调用。6.3 过拟合与欠拟合的判断与处理过拟合是指模型在训练集上表现很好但在测试集上表现很差。欠拟合是指模型在训练集和测试集上都表现不好。判断过拟合还是欠拟合最直接的方法是看训练loss和验证loss的曲线。如果训练loss持续下降但验证loss开始上升说明过拟合了。如果两个loss都居高不下说明欠拟合了。对于过拟合常用的解决方法包括增加训练数据使用L2正则化在损失函数里加上权重的平方和使用Dropout训练时随机丢弃一部分神经元使用早停在验证loss开始上升时停止训练减小网络规模减少参数数量对于欠拟合常用的解决方法包括增加网络层数或每层的神经元数量训练更长时间减小正则化强度检查数据预处理是否正确检查反向传播是否正确我个人的经验是先确保模型能过拟合再考虑正则化。如果模型连训练集都拟合不了那说明模型容量不够或者训练过程有问题加正则化只会让情况更糟。7. 从手写反向传播到理解深度学习框架7.1 自动微分的核心思想手写反向传播最大的收获是理解了自动微分的核心思想。现代深度学习框架比如PyTorch、TensorFlow之所以能自动计算梯度本质上就是构建了一张计算图然后沿着图反向传播。计算图的每个节点是一个张量操作每条边是张量之间的依赖关系。前向传播时框架记录下所有的操作和中间结果。反向传播时框架从损失节点开始沿着边反向遍历每个节点根据自己记录的局部梯度函数计算梯度然后传给前驱节点。这个过程和我们在这一期手写的反向传播完全一样只是框架把它自动化了而且支持更复杂的操作和更高效的并行计算。7.2 手写实现与框架实现的差异虽然核心思想一样但手写实现和框架实现还是有一些差异第一框架支持动态图可以在运行时改变网络结构。手写实现通常是静态的网络结构在训练前就固定了。第二框架对GPU加速做了深度优化矩阵运算比NumPy快很多。手写实现通常只能在CPU上跑。第三框架支持自动广播和更复杂的张量操作手写实现需要自己处理维度匹配。第四框架有更完善的数值稳定性处理比如对log和exp的数值稳定实现。手写实现需要自己加小常数防止溢出。但这些差异不影响我们理解反向传播的本质。实际上如果你能手写一遍反向传播再用框架的时候会更有底气遇到问题也知道从哪里排查。7.3 后续学习方向建议如果你跟着这一期把反向传播搞明白了接下来可以往几个方向深入一是卷积神经网络的反向传播。卷积层的反向传播比全连接层复杂一些涉及到卷积运算的梯度、池化层的梯度等。但核心逻辑还是一样的都是链式法则加局部梯度。二是循环神经网络的反向传播。RNN的反向传播需要沿着时间维度展开也就是所谓的BPTTBackpropagation Through Time。这里会遇到梯度消失和梯度爆炸的问题需要用到LSTM或GRU等门控机制。三是优化算法的改进。除了最基本的梯度下降还有动量法、AdaGrad、RMSProp、Adam等优化算法。这些算法都是在梯度下降的基础上对学习率或者更新方向做了一些调整目的是加快收敛速度或者提高稳定性。四是正则化技术的深入。除了L2正则化和Dropout还有Batch Normalization、Layer Normalization、Weight Decay等技术。这些技术都是为了解决训练过程中的各种问题提高模型的泛化能力。我个人在实际操作中的体会是手写一遍反向传播之后再看那些深度学习框架的源码会发现很多之前看不懂的地方突然就明白了。因为你知道每一步在干什么知道哪些地方容易出错知道为什么要那样设计。这种底层的理解是调库调不出来的。最后再分享一个小技巧如果你在调试反向传播的时候卡住了不妨把网络缩小到只有一层输入维度设为2隐藏层设为1个神经元输出层设为1个神经元。这样整个网络只有几个参数你可以手动把每一步的梯度算出来和代码的输出对比。虽然麻烦但这是最可靠的调试方法。我当年就是靠这个方法在一个下午的时间里把反向传播彻底搞明白了。
返回列表