资讯动态

NumPy手写神经网络:从零实现前向传播与反向传播

发布时间:2026/10/13 8:09:51 来源:尧图企业网站定制
1. 为什么用NumPy手写神经网络而不是直接调框架先说结论如果你只是想把MNIST跑出个98%的准确率用PyTorch或TensorFlow半小时就够了。但如果你真正想知道训练过程中每一条数据是怎么从输入跑到输出、误差又是怎么一层层传回去的那用框架反而成了黑盒。我自己在带新人时反复强调一个观点框架能告诉你怎么做但只有手写一遍才能告诉你为什么这么做。这个系列的前两篇里我们已经完成了MNIST数据的加载预处理和网络参数的初始化。到了这一篇终于要进入神经网络最核心的部分——前向传播和反向传播。这两个词听起来很吓人但拆开来看其实就是两个非常朴素的数学操作前向传播就是一组矩阵乘法和加法反向传播就是微积分里的链式法则在计算图上的具体展开。用NumPy来实现的好处非常直接每一步计算你都能亲眼看到张量的形状变化每一个梯度都能拿笔验算一遍。我记得在初学阶段我看框架文档里写的loss.backward()这一行完全不知道背后发生了什么。直到自己用NumPy把每一层的梯度算出来才真正建立起对深度学习的直觉。这个手写实现的代码量不大核心部分大概200行左右但每一行都是有意义的。我建议你跟着文章的思路一边读一边建一个.py文件把代码敲进去。一点一点跑观察每一层输出张量的shape变化。相较而言看十遍公式不如自己把shape打印出来看一遍。2. 前向传播数据如何在网络中流动2.1 一个三层网络的完整计算流程这个模拟项目X用的网络结构很经典输入层784个节点对应28x28像素图像接一个128维的隐藏层再到64维的第二隐藏层最后输出10个节点对应0-9十个数字。从维度上看这个过程就是784→128→64→10每一次变换都是一次矩阵乘法。以第一个隐藏层为例。假设输入X是形状为(batch_size, 784)的矩阵权重W1形状为(784, 128)偏置b1形状为(128,)那么这一层的线性输出就是Z1 np.dot(X, W1) b1这个时候很多人会问一个问题为什么矩阵乘法里X放在前面W1放在后面其实约定俗成的规则是让输入数据的维度走在前面权重矩阵的维度对齐在后面。如果你习惯用另一种写法比如np.dot(W1, X.T)也没有问题但要保证整个网络里所有层的行列关系保持一致不然后面反向传播时会疯掉。线性变换之后是激活函数。我们这里在隐藏层用ReLU公式是np.maximum(0, Z1)。ReLU的作用是给网络引入非线性。如果不加激活函数多层线性变换叠加起来还是线性变换那这个网络做不了什么复杂分类。第二隐藏层的计算逻辑和第一层一模一样Z2 np.dot(A1, W2) b2 A2 np.maximum(0, Z2)最后的输出层有一个关键区别用Softmax而不是ReLU。Softmax把输出层的10个值转换成一个概率分布所有值加起来等于1。这样网络输出的就可以直接解读为“这张图片属于哪个类别的概率”。2.2 动手写完整前向传播代码把上面这些整理成一段完整的前向传播函数大概是这个样子的def forward(X, params): W1, b1, W2, b2, W3, b3 params Z1 np.dot(X, W1) b1 A1 np.maximum(0, Z1) Z2 np.dot(A1, W2) b2 A2 np.maximum(0, Z2) Z3 np.dot(A2, W3) b3 probs softmax(Z3) cache (Z1, A1, Z2, A2, Z3, probs) return probs, cache这里我把中间结果都放在cache元组里因为这些值在反向传播时都要用到。为什么因为反向传播计算梯度的公式里包含前向传播的中间值比如权重更新时需要用到激活输出A1、A2梯度传递时需要用到特征矩阵Z的导数。如果这些值不在前向传播时存下来反向传播就得重新算一遍浪费计算量。Softmax的代码值得单独写一下def softmax(Z): exp_Z np.exp(Z - np.max(Z, axis1, keepdimsTrue)) return exp_Z / np.sum(exp_Z, axis1, keepdimsTrue)这里有一个非常重要的细节np.max(Z)。这是数值稳定的标准处理。如果Z里某个元素很大比如20甚至30直接算np.exp(30)会得到一个天文数字可能导致内存溢出上溢。而Softmax对输入的平移是不变的因为分子分母同时除以相同倍数。减去每行的最大值后再算指数既不会改变概率分布结果又能保证计算稳定。2.3 向量化与批处理矩阵运算背后的性能哲学一个新手常犯的错误是用for循环把每个样本单独过一遍网络。比如for i in range(batch_size): z1 np.dot(X[i], W1) b1 ...这种做法虽然结果正确但性能非常差。NumPy的优势在于底层调用了高度优化的线性代数库矩阵乘法远比Python循环快。一个直观类比用for循环处理60000张MNIST图像就像逐个走路去取快递而矩阵运算相当于用一辆卡车一次全部拉回来。量级差着几个数量级。正确做法是让批量数据并行流动。假设我们一次送进去256张图那么输入的X形状就是(256, 784)。经过第一层矩阵乘法后得到(256, 128)激活后形状不变再经过第二层得到(256, 64)最后输出(256, 10)。在这个过程中每一列对应的是一张图片的中间计算256行各自独立互不干扰。批处理还有另一个好处计算出的梯度是所有样本的平均梯度方向更稳定不会因为单个样本的噪声让参数更新来回抖动。这一点的深入影响我们到后面讲反向传播和参数更新时再展开。我把前向传播的各个阶段整理成一张表方便你对照检查自己的实现是否正确阶段输入Shape操作输出Shape输入层(256, 784)展平的图像数据(256, 784)第一隐藏层(256, 784)线性映射ReLU(256, 128)第二隐藏层(256, 128)线性映射ReLU(256, 64)输出层(256, 64)线性映射Softmax(256, 10)如果打印出来发现和自己预期不符合先检查输入形状再检查权重形状。大部分维度错误都能在这张表里对出来。3. 反向传播误差如何指挥网络调整3.1 损失函数选择交叉熵的直觉理解训练网络的第一步是量化“当前网络表现有多差”。这个量化函数就是损失函数。MNIST分类任务用的是交叉熵损失。对于单张图像如果真实标签是y一个one-hot向量而网络输出概率是p交叉熵的定义是loss -np.log(p[y_index])这是什么意思假设这张图真实标签是5而网络算出类别5的概率是0.9那-log(0.9)约等于0.105损失很小。如果网络算出类别5的概率只有0.1-log(0.1)约等于2.3损失就很大。而且这个函数不是线性的概率越接近0惩罚增长得越快。这是一种奖励自信且正确、严厉打击错误且自信的机制。为什么不用均方误差MSE因为在分类问题上交叉熵通常收敛更快。用一张图理解MSE对输出的梯度是(p - y)当p接近1或0时梯度都变小可能产生梯度消失而交叉熵的梯度恰好是(p - y)乘以一个系数即使在p接近1时仍然有足够的梯度信号让网络改进。实践里面对分类网络用交叉熵几乎是默认选择。对应整个批次的交叉熵损失是def cross_entropy_loss(probs, y_true): batch_size probs.shape[0] log_probs -np.log(probs[np.arange(batch_size), y_true] 1e-8) return np.mean(log_probs)加上1e-8是个防御措施。如果某个样本被网络分到了接近100%的正确概率那log(probs)会接近0但不会出问题可如果网络完全不自信某个正确类别的输出概率可能是1e-15log(1e-15)会得到巨大的负数数值上会造成梯度爆炸。加一个小常数是为了兜底。3.2 从输出层往回推交叉熵与Softmax的简化梯度反向传播的第一步也是最经典的一步计算损失对输出层线性输出Z3的梯度。这里有一个很妙的数学结果。让我们一步步推。设真实标签用one-hot向量y表示网络输出概率为p向量交叉熵就是L -sum(y_i * log(p_i))其中p来自Softmaxp_i exp(Z_i) / sum(exp(Z_j))。要求dL/dZ3数学上用到链式法则和Softmax的导数结果会复杂。但巧的是交叉熵和Softmax组合在一起梯度有一个极为简洁的形式dZ3 probs - y_true_onehot这里probs是前向传播里算出的概率矩阵y_true_onehot是真实标签的one-hot编码矩阵。形状都是(batch_size, 10)。用交叉熵时的Softmax梯度恰好是预测值减去真实值。这是一个反复被提起的结论但我鼓励你亲自推一遍就三行纸的事。对代码实现来说这就是y_onehot np.zeros_like(probs) y_onehot[np.arange(batch_size), y_true] 1 dZ3 probs - y_onehot注意这里没有除以batch_size。我们在最后统一处理所有梯度都除以batch_size得到的是平均梯度。这样每一步参数更新的方向更平滑不会因为某一两张特殊图片产生大幅度抖动。3.3 中间层的梯度链式法则的完整展开拿到dZ3之后我们就要向回传递逐层计算每一层权重和偏置的梯度。以第二个隐藏层为例步骤分两步。第一步把误差从Z3传到A2。由于Z3 A2 * W3 b3那么损失对A2的梯度就是dA2 np.dot(dZ3, W3.T)这里W3.T就是W3的转置形状从(64, 10)变成(10, 64)。矩阵乘法本身就在做梯度的累加和分配每个A2节点通过W3连接到多个输出节点这个节点的梯度是所有路径上梯度的总和。第二步把误差从A2传到Z2。由于A2 max(0, Z2)这是在逐元素应用ReLU函数梯度规则很简单Z2大于0的位置梯度不变Z2小于等于0的位置梯度为0。代码写法dZ2 dA2 * (Z2 0)这里Z2 0是一个布尔矩阵True被当作1False被当作0直接在数组乘法的过程中起到掩码作用。这一步非常直观地体现了ReLU的稀疏性和梯度行为被激活的神经元梯度正常没被激活的直接封锁梯度信号。第三层和第四层的推导完全一样形成一种非常规律的节奏dA1 np.dot(dZ2, W2.T) dZ1 dA1 * (Z1 0)整个过程看起来像一串多米诺骨牌从末端推一下一层接一层传递下去。每一层的梯度计算都只依赖当前层的值和下一层的梯度不用回溯到更远端这就是链式法则带来的计算局部性。深度学习框架里说的backward()本质上就是执行这么一串操作。3.4 参数梯度的计算与更新公式有了每一层的dZ权重和偏置的梯度就非常好算了。仔细想一下Z和参数之间的关系Z3 np.dot(A2, W3) b3则dW3 np.dot(A2.T, dZ3)Z2 np.dot(A1, W2) b2则dW2 np.dot(A1.T, dZ2)Z1 np.dot(X, W1) b1则dW1 np.dot(X.T, dZ1)矩阵A2.T的形状是(64, batch_size)dZ3的形状是(batch_size, 10)乘出来就是(64, 10)正好对得上W3的形状。维度一举数得也帮我们验证了推导是否正确。要是某个梯度shape和对应权重不一致基本就是前面的转置搞错了。偏置的梯度就更简单了因为偏置对输出的贡献是每个batch内独立的广播求和所以db3 np.sum(dZ3, axis0, keepdimsTrue) db2 np.sum(dZ2, axis0, keepdimsTrue) db1 np.sum(dZ1, axis0, keepdimsTrue)keepdimsTrue不是必须的加它的目的是保持维度一致方便调试时检查形状。最后所有梯度除以batch_size然后应用梯度下降更新learning_rate 0.1 W3 - learning_rate * dW3 / batch_size b3 - learning_rate * db3 / batch_size # 同理更新W2, b2, W1, b1关于学习率这个参数值我第一次用0.1的时候训练得很稳损失下降平滑。要是换成0.01训练会慢换成0.5很可能直接发散。学习率本质上控制的是每一步参数移动的步幅步子太大容易跨过最优点甚至越过到损失很大的一侧步子太小又在有限迭代里走不到合适的解。这个参数需要结合损失曲线来调。我梳理一下整个反向传播的三个核心原则方便你记忆梯度的形状与对应的参数形状完全一致这是检查实现对不对的硬性标准。梯度是沿着计算图往回传递每一层只需要接收“更高层传来的梯度”和“本层前向激活值”不需要知道无关层的任何信息。所有梯度最终乘以学习率并除以batch_size合成一次参数更新。4. 组合完整的训练流程从单步到多轮4.1 训练循环的整体结构把前向传播、损失计算、反向传播、参数更新组合在一起就得到一个完整的训练循环。基本骨架是epochs 10 batch_size 256 learning_rate 0.1 for epoch in range(epochs): for i in range(0, num_samples, batch_size): X_batch X_train[i:i batch_size] y_batch y_train[i:i batch_size] probs, cache forward(X_batch, params) loss cross_entropy_loss(probs, y_batch) grads backward(cache, y_batch, params) params update(params, grads, learning_rate, batch_size) print(fEpoch {epoch 1}, Loss: {loss:.4f})在训练过程中每个epoch结束后可以在验证集上算一下准确率用来判断模型有没有过拟合。通常MNIST这种相对简单的数据集上10个epoch足够看到损失从2.3一路降到0.1以下的漂亮曲线。4.2 验证准确率验证模型真正学到了什么损失下降只能说明模型在拟合训练数据但我们要的是泛化到新数据上。验证集准确率的计算很简单前向传播拿到概率矩阵后取每行最大值的下标作为预测类别和真实标签比一下算平均正确率。def predict(X, params): probs, _ forward(X, params) return np.argmax(probs, axis1) def accuracy(X, y, params): preds predict(X, params) return np.mean(preds y)这里np.argmax(probs, axis1)是沿每行找最大值的下标返回的是长度为batch_size的一维数组。在MNIST上训练完后在测试集上一般能拿到97%左右的准确率用的还是全连接网络。这个数字比卷积网络低一些但作为理解原理的练习已经合格了。4.3 梯度检查用数值差分验证反向传播的正确性反向传播代码写完之后第一件事不要急着训练先做梯度检查。这个检查方法极其简单也正是“使用NumPy手写”这一做法能带给我们研究优势的地方。梯度检查利用的就是导数定义对某个参数做微小扰动用前后损失的变化量来近似梯度再和反向传播算出的梯度比较def check_gradient(params, X, y, epsilon1e-7): probs, _ forward(X, params) grads backward(cache, y, params) for i in range(W3.size): W3.flat[i] epsilon loss_plus cross_entropy(forward(X, params)[0], y) W3.flat[i] - 2 * epsilon loss_minus cross_entropy(forward(X, params)[0], y) W3.flat[i] epsilon num_grad (loss_plus - loss_minus) / (2 * epsilon) # 对比 num_grad 和 grads[W3].flat[i]如果差距在1e-5量级以上说明反向传播某个环节写错了需要回头检查转置或ReLU的掩码。我第一次实现时就在这里抓住了一个隐蔽的问题我忘了在A2的ReLU掩码里减去dA1的(A1 0)对应导致第一层梯度完全算错。梯度检查当场就会暴露这种错误。分析原因数值差分梯度会受到浮点误差的影响epsilon选得太小反而会淹没在舍入误差里选得太大又是粗糙的近似。1e-7是个比较合适的取值能覆盖常见精度需求。最省事的方法是抽取一批随机的参数位置做检查不用全部遍历能显著节省时间而且足够暴露绝大多数实现错误。4.4 学习率衰减与进一步调优方向训练到后面你会发现损失下降变慢这是常见的收敛现象。此时可以使用学习率衰减策略随着epoch增加逐步减小学习率让参数在小范围内精细调整。最简单的衰减方式是每轮乘以一个系数learning_rate * 0.95就好比我们在目标点附近时大步幅容易来回震荡改为小步幅能更平稳地走入谷底。MNIST上很多人会在epoch 8之后把学习率降到1e-2让损失曲线进一步走低。除了学习率衰减还可以扩展的方向包括加入L2正则化项抑制过拟合、用Adam优化器替代标准SGD加速收敛、把全连接换成卷积结构提升图像特征提取能力。这些内容可以留到后面的系列文章单独拆。5. 训练过程的踩坑实录与排查技巧5.1 损失不降反升最先检查学习率这个坑几乎每个手写网络的人都踩过。损失值在前几步反而飙升常见原因有三种学习率过大、梯度计算错误、数据归一化没做好。MNIST数据在输入时如果忘记除以255像素值分布在0到255之间输入过大可能导致初始梯度巨大核动力式更新步幅很容易把参数推到失控区域。处理方法很简单X_train X_train / 255.0。我自己手写实现时因为这一步漏了前几次迭代损失直接飞出图表范围。确认学习率是否过大有个很实用的方法打印第一个batch更新后的loss和更新前的loss比一比。如果更新后loss变大很多先把学习率除以10再跑一次。一般两步就能定位问题。5.2 权重初始化与梯度消失、梯度爆炸的关系如果只用零矩阵初始化权重训练过程彻底瘫痪。原因很简单所有神经元的输出相同反向传播时梯度也完全相同每一层学到的东西一样网络丧失表达能力都是对称性导致的。个体神经元也没法分化出自己的功能。MNIST这个网络不深不太会出现严重的梯度消失或爆炸。但如果你往深了扩展网络比如加到8层就可能碰到ReLU带来的死神经元问题如果某个神经元的输入长期为负ReLU梯度是0这个神经元就永远不再被激活梯度传不回来参数再也更新不动。这就是所谓的死ReLU问题。解决办法包括改用Leaky ReLU、适当调低学习率、用更好的初始化如He初始化。He初始化的核心思想是让每一层输出的方差保持在可接受范围内。一般实现是W1 np.random.randn(784, 128) * np.sqrt(2.0 / 784)如果我们用的是Sigmoid激活初始化会用Xavier系数是sqrt(1.0 / 输入维度)因为Sigmoid的方差特性与ReLU不同这就是“不同激活函数配不同初始化”的原因。训练初期如果发现某些层的激活值矩阵里有大量0或大量极大值基本可以判定是初始化造成的。5.3 batch size的选择为什么256是个不错的默认值batch size这个超参数对训练的影响非常微妙。太小比如batch size 1每个样本单独更新一次参数梯度噪声大损失曲线像心电图一样乱跳太大比如整个训练集60000张一次全放进去计算一次梯度虽然方向准但计算开销大而且每次方向更新机会少收敛会很慢。实际感受是256在MNIST上是一个好用起点。用过32的曲线波动明显试过512的收敛平稳但慢。batch size还会影响梯度下降的收敛路径不同取值下模型的最终精度会有1-2个百分点的波动不算大。在心智上把batch size理解成“用多少个样本来估计真实梯度方向”就好越小越不准但更新快越大越准但每个该做的计算量都更高。5.4 远观模拟项目X的整体训练结果把前面讲的代码串起来跑完10轮epoch后我这边在没有做任何数据增强的前提下测试集准确率在96.8%左右。整个训练过程大概耗时三到四分钟期间数万行代码纯靠CPU就能扛下来。这个准确率跟目前SOTA级模型动辄99.7%没法比但这里有一个本质区别SOTA模型吃的是卷积结构、数据增强、更深的网络、更成熟的优化器我们的目标从来不是比赛刷分而是彻底搞懂训练机制。用NumPy手写出来的网络每一个准确率提升背后你都清清楚楚知道是哪一层调整了什么参数带来的。我个人在实操中还有一个习惯每次训练完把前32张预测出错的图片可视化出来。MNIST里有些手写数字人眼都很难辨认模型预测错误也在合情合理之中。这种观察能帮你形成一种“模型是有学习能力和失败模式的机器”的直觉而不仅仅是拿个准确率数字了事。6. 前向与反向传播之后下一步还能怎么玩写完这个全连接网络的前向和反向传播你已经亲手建起了一个完整的神经网络训练闭环。很多概念比如链式法则在计算图中的应用、梯度的形状约束、激活函数对梯度流的影响都在这个实现里留下了具象的对应物。紧接着可以做的扩展方向我把优先级排一下给第二隐藏层换成Dropout在训练时随机丢一部分神经元看验证集准确率如何变化这是理解正则化最直观的实验。把交叉熵损失换成MSE对比训练收敛速度亲手体会为什么分类问题不用MSE。把ReLU换成Sigmoid或tanh观察梯度消失问题在深层网络里的表现。把两层全连接改造成两层卷积加池化就跨入了图像建模更有效的结构设计中。每个扩展实验都不需要多大代码量但都能显著加深你对训练流程的理解。我建议你至少做掉前两个它们能帮你把损失函数和激活函数的感性认识固化成直观而基于经验的体验。回到最初的问题用NumPy手写神经网络麻烦吗麻烦训练慢代码也不长但这些代价换来的是对的内部机制的认知。现在再回去看PyTorch的nn.Linear和loss.backward()你会知道它们的内部要么就是矩阵乘法加广播要么就是那道干净利落的梯度回传链路你对框架的关注会从“它怎么用”提升到“它里面大概发生了哪些计算”。这个系列到这一篇核心的计算过程已经全部讲完。后面如果继续写我大概率会做两件事一是把现在这个两层全连接网络改造成卷积网络用同样的NumPy思路再推一遍卷积和池化的前向反向二是引入Adam优化器通过手写实现来看自适应学习率到底优化了什么。这两块任选其一展开水分都不会少而且都能复用我们今天搭好的这个框架。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑