ARTICLE DETAIL

资讯详情

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

用NumPy从零实现BP神经网络:前向传播、反向传播与训练循环

用NumPy从零实现BP神经网络:前向传播、反向传播与训练循环 简介面向神经网络导论课程的实验代码包围绕自适应线性单元Adaline及其LMS学习算法展开采用Matlab实现适合正在学习神经网络基础、需要动手验证经典模型的学生。压缩包共5个文件包含3个Matlab脚本与2个Mat数据文件整体仅10KB轻量易用脚本分别处理随机权重初始化、LMS核心迭代和样本集随机选取数据文件则提供训练集与测试集。资源完整覆盖Adaline模型定义、LMS误差最小化原理、随机权重初始化策略与训练/测试集划分方法重点演示了LMS权重更新公式、随机初始化对避免局部最优的作用以及通过随机划分数据评估模型泛化性能的方式学习者可逐段阅读、调整学习率等参数直观观察Adaline的收敛过程。目前已有299人学习使用对入门理解神经网络早期模型及误差修正机制很有价值。1. 神经网络导论课程实验1到底卡在哪用numpy把BP网络从零写出来这门课的实验1绝大多数时候不是让你import torch跑一个现成模型而是让你用 numpy 从零搭一个前馈神经网络亲手把正向传播、反向传播和残差计算实现出来。真正卡人的地方不是“看不懂公式”而是“写出了代码但梯度算不对”——sigmoid 饱和、学习率偏大、初始化方差、标签编码任何一处细节出错损失曲线都会给你一张难看的脸。这篇按“实验1最常见形态”来写用 python 代码实现一个能做手写数字识别的前馈网络从数据准备讲到训练循环再讲验证代码对错的数值梯度检查新手能照抄熟手能直接拿去对照排查。2. 从零搭一个前馈神经网络BP网络的代码骨架与前向计算2.1 为什么实验1值得手写BP而不是直接import框架第一次接触神经网络的人最容易犯的错是一上来就用深度学习框架跑 LeNet训练完看准确率还挺高但问他“损失函数对 W2 的梯度长什么样”答不上来。实验1想解决的正是这个“黑匣子”问题——它要求你只依赖 numpy 这类基础库把网络每一层的中间变量和梯度都显式算出来。手写一遍之后你对下面三件事会有肌肉记忆第一反向传播里流动的核心量是残差也就是损失对当前层输出的偏导数第二每一层参数 W、b 的梯度都是由残差和上一层输入或激活值相乘得到的第三softmax 和交叉熵放在一起用的时候梯度形式会化简得非常简单。这套逻辑一旦亲手写过一遍以后再去看 PyTorch 的loss.backward()就不会觉得它是魔法。实验1的常见形态是一个三层全连接网络输入层 → 隐藏层 → 输出层。激活函数用 ReLU输出层用 softmax 加交叉熵损失训练数据用手写数字。这个配置是“理论上最简单但训练效果能明显看到收敛”的组合。2.2 前向传播代码骨架初始化、加权和、ReLU与softmax下面这段代码定义一个两层网络输入层不算层隐藏层大小hidden_size是可调参数。前向传播要做的事很简单输入 X 经过第一层线性变换得到 z1再过 ReLU 得到 a1再经过第二层线性变换得到 z2最后对 z2 做 softmax 得到每个类别的预测概率。import numpy as np class TwoLayerNet: def __init__(self, input_size, hidden_size, output_size, seed42): rng np.random.default_rng(seed) # 权重初始化标准正态乘以 sqrt(1/n) # 这样做的目的是让各层输入的方差在传播过程中不要快速膨胀或收缩 self.W1 rng.standard_normal((input_size, hidden_size)) * np.sqrt(1 / input_size) self.b1 np.zeros(hidden_size) self.W2 rng.standard_normal((hidden_size, output_size)) * np.sqrt(1 / hidden_size) self.b2 np.zeros(output_size) def forward(self, X): # 第一层线性变换 ReLU self.z1 X self.W1 self.b1 self.a1 np.maximum(0, self.z1) # ReLU 激活 # 第二层线性变换得到 logits self.z2 self.a1 self.W2 self.b2 # softmax先减最大值防止 exp 溢出 z2_shifted self.z2 - np.max(self.z2, axis1, keepdimsTrue) exp_z2 np.exp(z2_shifted) self.probs exp_z2 / np.sum(exp_z2, axis1, keepdimsTrue) return self.probs def compute_loss(self, y_onehot): # 交叉熵损失加 eps 防止 log(0) eps 1e-12 return -np.mean(np.sum(y_onehot * np.log(self.probs eps), axis1))这段代码有三个参数值得注意。第一初始化方差用了sqrt(1 / input_size)而不是直接乘 0.01 或 1.0。如果你把方差设成 1ReLU 的输出在深层传播时方差会越来越大到输出层时 logits 动辄上百softmax 出来的分布几乎是 one-hot梯度直接消失。第二softmax 之前先减去该行最大值是数值保护的常规操作不这样做当 z2 里的元素超过 700 时np.exp会溢出成 inf。第三compute_loss里用y_onehot * np.log(probs)而不是np.log(probs[range(n), y])前一种写法在 y 是 one-hot 时等价于取出正确类别的对数概率但向量化程度更高。2.3 反向传播的实现残差从输出层往输入层传反向传播是全篇的重点。这里直接给出代码然后逐行说明它对应公式里的哪一项。def backward(self, X, y_onehot): m X.shape[0] # 当前 batch 的样本数 # 输出层残差softmax 交叉熵的梯度恰好是 probs - y_onehot dz2 self.probs - y_onehot # 第二层参数的梯度 上一层激活值的转置 残差 dW2 (self.a1.T dz2) / m db2 np.sum(dz2, axis0) / m # 残差往第一层传残差乘 W2 转置再乘 ReLU 的导数 dz1 (dz2 self.W2.T) * (self.z1 0) # z1 0 的位置导数为1其余为0 # 第一层参数的梯度 输入 X 的转置 第一层残差 dW1 (X.T dz1) / m db1 np.sum(dz1, axis0) / m return {dW1: dW1, db1: db1, dW2: dW2, db2: db2}为什么输出层残差是probs - y因为 softmax 和交叉熵组合后损失对 logits z2 的偏导数在数学上就是预测概率减真实标签这是实验1里最值得亲手推一遍的公式。推完你会发现代码和推导完全对得上而不是从网上抄一个“看起来能跑”的版本。ReLU 的导数在 z1 大于 0 时为 1小于等于 0 时为 0所以self.z1 0直接当掩码用。这里有个初学者常踩的坑反向传播用的必须是你前向传播时实际算出的 z1而不是重新算一遍。所以forward里把 z1、a1、z2 都存为实例属性backward直接拿这些中间量来用次序不能乱。每个梯度除以 m是因为 loss 是按 batch 平均的梯度也要保持同一量级。如果不除 m梯度会被 batch size 放大隐藏层稍微大一点训练直接发散。2.4 训练循环SGD、学习率与epoch的配合实验1的训练循环一般用随机梯度下降SGD也就是每个 batch 算完梯度就更新参数。下面这段代码把训练过程完整封装起来返回训练后的网络和损失、准确率曲线数据。def train(X_train, y_train, X_val, y_val, hidden_size64, lr0.1, epochs20, batch_size64, seed42): input_size X_train.shape[1] output_size y_train.shape[1] net TwoLayerNet(input_size, hidden_size, output_size, seedseed) train_loss, val_loss, val_acc [], [], [] m X_train.shape[0] for epoch in range(epochs): # 每个 epoch 先打乱训练集顺序 perm np.random.permutation(m) Xs, ys X_train[perm], y_train[perm] for i in range(0, m, batch_size): Xb Xs[i:i batch_size] yb ys[i:i batch_size] probs net.forward(Xb) grads net.backward(Xb, yb) # 参数更新W W - lr * dW net.W1 - lr * grads[dW1] net.b1 - lr * grads[db1] net.W2 - lr * grads[dW2] net.b2 - lr * grads[db2] # 每个 epoch 结束后记录整体指标 train_loss.append(net.compute_loss(y_train)) probs_val net.forward(X_val) val_loss.append(-np.mean(np.sum(y_val * np.log(probs_val 1e-12), axis1))) val_acc.append(np.mean(np.argmax(probs_val, axis1) np.argmax(y_val, axis1))) return net, train_loss, val_loss, val_acclr0.1对这种两层 ReLU 网络在 minibatch SGD 下是个稳定起点。如果数据量小、batch 大0.1 可能偏大Loss 会在某个值附近来回震荡如果数据噪声大0.1 又可能偏小收敛变慢。epochs20对 MNIST 子集来说足够看到收敛趋势但不需要一上来就设 100跑完一轮看曲线形状再决定加不加。一个注意点net.forward(X_val)这行不仅算了概率还会覆盖self.z1、self.a1这些中间变量。如果下一轮你有别的代码想拿这些变量做分析要注意这个覆盖顺序。训练循环里这是无害的因为每轮反正会重新 forward。3. 把数据喂给网络MNIST的读取、归一化、one-hot与minibatch3.1 数据从哪里来npz或csv的常见处理方式课程实验1的数据集通常是老师给好的.npz或.csv文件里面就两组数据像素矩阵 X 和标签 y。很少要求你自己去写数据爬取所以重点不在下载而在“加载之后怎么洗”。常见的读法如下data np.load(mnist_subset.npz) # 假设文件里只有 X 和 y X data[X].astype(np.float64) # 形状 (N, 784)像素值 0~255 y data[y] # 形状 (N,)取值为 0~9 # 归一化把像素缩到 [0, 1] X X / 255.0 # one-hot 编码把标签变成 10 维向量 y_onehot np.eye(10)[y] # 先打乱再切分顺序不能反过来 perm np.random.permutation(X.shape[0]) X, y_onehot X[perm], y_onehot[perm] X_train, X_val X[:5000], X[5000:6000] y_train, y_val y_onehot[:5000], y_onehot[5000:6000]这里最容易被忽略的是“先归一化再切分”。如果你的数据集中某些样本像素整体偏大或偏小不归一化直接喂给网络第一层的梯度会被大数值输入放大容易在训练初期就震荡。np.eye(10)[y]是 numpy 里做 one-hot 最简洁的写法y 里的每个数字作为行索引从单位矩阵里取对应行得到的就是一个 N 行 10 列的 0/1 矩阵。关于切分MNIST 原始数据本身已经打乱过但你自己处理数据时未必能确定顺序稳妥做法是永远先 shuffle 再切避免某个类别全部落在验证集里。3.2 为什么归一化到[0,1]而不是用均值方差标准化很多人会把图像数据和表格数据搞混。图像做均值方差标准化z-score不是不行但对实验1这种简单网络X / 255.0就够了。原因有两层。第一ReLU 网络对输入尺度敏感0~255 的输入乘上W1的初始权重大约 0.03 量级后得到 z1 的数值范围大致在 0~8 左右这个范围对 ReLU 来说不算坏但如果不归一化z1 的方差会随输入值波动导致某些神经元一开始就饱和。第二X / 255.0保留了像素的相对幅度对灰度图像来说是信息无损的。如果你用的是 MNIST 这类灰度图255.0是固定值不要写成X.max()因为不同数据集的 max 不一样测试时会引入不一致。3.3 minibatch和shuffle稳定训练的隐藏前提batch_size 的选择会直接影响训练稳定性。实验1里常见的是 32 或 64太小梯度噪声大太大每个 epoch 更新次数少。64 的另一个好处是矩阵乘法在 numpy 里效率高(64, 784) (784, 64)这种形状对内存带宽非常友好。shuffle 的作用被很多人低估。如果不做 shuffle每个 batch 里全是相近的样本——比如全是数字“0”——梯度会周期性偏向某个类别损失曲线呈现明显的锯齿。加上 shuffle 之后每个 batch 近似代表整体分布梯度方向更稳定。一个容易踩的坑是 shuffle 的维度。np.random.permutation(m)拿到的是行索引数组要用它同时对 X 和 y 做行维度的重排而不是列维度。上面的示例里Xs X_train[perm]是对行打乱这没问题如果你写成X_train[:, perm]打乱的就是像素列训练出来的模型准确率会直接崩到随机水平。4. 训练与评估损失曲线怎么读准确率怎么验证超参数怎么调4.1 损失曲线三种典型形态下不去、震荡、先降后升实验1跑完之后第一件事不是看准确率而是看训练损失曲线。曲线本身能告诉你网络处于什么状态。第一种形态损失从一开始就不降横着走。原因通常是学习率偏大导致梯度更新过冲损失在“大值区域”来回弹或者是初始化方差过大激活值饱和梯度接近零。第二种形态损失下降但剧烈震荡。这是学习率偏大的典型症状尤其出现在 batch size 较小的训练里。第三种形态训练损失先降后升说明学习率太大参数跳过了最优点并越走越远。正常的曲线应该是指数式的快速下降后趋于平缓前 3 个 epoch 降得最明显之后每轮只有小幅下降。如果前 5 个 epoch 损失几乎没动直接考虑调学习率而不是继续加 epoch——加 epoch 只会延长你看到失败结论的时间。4.2 用验证集而不是训练集判断模型好坏实验1里最常犯的一个判断错误拿训练准确率当模型好坏的标准。训练准确率在过拟合时可以达到 99% 以上但验证集上可能只有 70%。所以训练循环里要同时记录验证集损失和验证集准确率判断标准以验证集为准。import matplotlib.pyplot as plt epochs_range range(1, len(train_loss) 1) plt.plot(epochs_range, train_loss, labeltrain loss) plt.plot(epochs_range, val_loss, labelval loss) plt.xlabel(epoch) plt.ylabel(loss) plt.legend() plt.show()验证集损失比训练损失更有参考价值。如果训练损失持续下降验证损失在某个 epoch 后开始回升说明已经过拟合应该早停。对 MNIST 子集这种规模两层网络 20 轮以内通常不会严重过拟合但如果你的隐藏层开到 256 以上过拟合就会出现得很快。4.3 超参数的一组稳妥起点与调参顺序下面这张表是我在实验1里常用的起点跑通之后再去微调参数起点值说明hidden_size64对 MNIST 子集足够表达又不容易过拟合lr0.1ReLU SGD 下的稳定起点batch_size64梯度噪声和计算效率的折中epochs20足够观察收敛趋势可根据曲线调整初始化方差sqrt(1/n)防止激活值方差逐层膨胀调参顺序建议固定一个顺序先固定 epochs 和 batch_size只调 lr确定 lr 能收敛后再调 hidden_size最后才动 batch_size。一次性同时调三个参数出了问题时你根本不知道是哪个造成的最后只能靠玄学。hidden_size 从 64 调到 128验证准确率可能提升一个点但训练时间翻倍从 128 调到 256效果未必更好过拟合风险反而增加。lr 从 0.1 改成 0.01训练变稳但收敛变慢需要更多 epochs。这属于正常现象不要因为一轮效果不好就全盘否定。5. 实验1避坑指南5个让新手半夜翻车的细节5.1 损失完全不动多半是学习率太大或初始化方差太大现象训练了 5 个 epochtrain_loss 一直停留在 2.3 左右准确率稳定在 10%上下和随机猜测没区别。原因lr 设成 1.0 或更大时参数更新步长过大网络在损失函数的高原区域反复横跳永远走不到低处。另一个常见原因是权重初始化方差过大比如直接用np.random.randn不乘任何系数导致 ReLU 输出饱和。解决把 lr 降到 0.1 或 0.01重新初始化网络。如果降 lr 后仍不动检查 X 和 y 是否对得上——y_onehot 的行数、索引顺序必须和 X 完全一致任何错位都会让网络无法收敛。5.2 梯度消失sigmoid 网络传到第一层时梯度几乎为零现象使用 sigmoid 激活时第一层的梯度dW1数量级在 1e-8 左右第一层权重几乎不更新损失下降极慢。原因sigmoid 函数在输入绝对值较大时导数为零误差经过输出层、隐藏层两层反传每层都要乘一次 sigmoid 的导数最大只有 0.25两层相乘后梯度大幅衰减。解决换用 ReLU 激活这是实验1最简单有效的改法。ReLU 的导数为 0 或 1不会引入指数级的衰减。如果实验要求必须用 sigmoid那就把权重初始化方差调小比如sqrt(1/n)再除以 2并保证输入数据归一化到 [0,1]。5.3 softmax 出现 NaNexp 溢出是第一个怀疑对象现象前向传播输出 NaNloss 也是 NaN训练直接中断。原因z2 里出现大于 700 的值np.exp(z2)溢出成 infinf 除 inf 得到 NaN。出现这种情况通常和初始化方差过大或学习率过高有关网络在第一步更新后梯度爆炸logits 瞬间飞到极大值。解决softmax 实现里先减每行最大值再取 exp这是标准的数值稳定写法。修改后如果还有 NaN把学习率降到 0.01 并重新初始化基本能解决。5.4 验证集准确率震荡shuffle 没做干净现象训练损失在下降但验证集准确率每轮都在 ±10% 来回跳没有任何稳定上升的趋势。原因训练集没做 shuffle相邻 batch 里的数字类别分布严重不均比如一个 batch 全是 0 和 1下一个 batch 全是 8 和 9模型被数据带着周期性偏转。解决在每个 epoch 开始前重新打乱数据用np.random.permutation生成索引并用同一索引数组重排 X 和 y。注意只对行做重排别写成X[:, perm]。5.5 训练集准率很高但验证集拉胯过拟合的早期信号现象训练损失降到 0.05 以下训练准确率 99%验证准确率却卡在 75% 左右不再上升。原因隐藏层单元数太多加上训练轮数太多网络开始记住训练样本的个体特征。实验1的数据量通常不大64 个隐藏单元就够用了。解决把 hidden_size 降到 32 或 64减少 epochs或增加一个简单的 L2 正则。实验1阶段做个朴素正则就够——在损失函数里加lambda * (np.sum(W1**2) np.sum(W2**2))lambda 取 1e-3 量级然后观察验证损失是否回升得更慢。6. 给代码上保险用数值梯度检查验证反向传播的每个参数写 BP 网络最痛苦的时刻是模型能跑但效果差你分不清是梯度算错还是超参数没调好。数值梯度检查是一个后悔药方案用中心差分近似出梯度和你的反向传播梯度做比较。如果两者一致说明反向传播实现是对的剩下的问题都是超参数练习如果不一致直接定位到具体参数。def numerical_gradient(net, X, y_onehot, param_name, h1e-5): param getattr(net, param_name) grad np.zeros_like(param) it np.nditer(param, flags[multi_index], op_flags[readwrite]) while not it.finished: idx it.multi_index # 当前参数的坐标 old param[idx] param[idx] old h net.forward(X) # 修改参数后必须重新前向传播 loss_plus net.compute_loss(y_onehot) param[idx] old - h net.forward(X) loss_minus net.compute_loss(y_onehot) grad[idx] (loss_plus - loss_minus) / (2 * h) param[idx] old # 恢复原值 it.iternext() return grad比较时用相对误差而不是绝对差值因为不同参数的梯度量级差异很大net.forward(X) grads_analytic net.backward(X, y_onehot) grads_numeric numerical_gradient(net, X, y_onehot, W1) eps 1e-8 rel_error np.abs(grads_analytic[dW1] - grads_numeric) / \ (np.abs(grads_analytic[dW1]) np.abs(grads_numeric) eps) print(W1 max rel error:, np.max(rel_error))相对误差小于 1e-4反向传播实现基本正确。在 1e-3 到 1e-1 之间多半是某处梯度公式差了一个缩放系数或者求导时漏了 ReLU 的掩码。大于 1e-1基本可以确定是代码错误逐个参数去检查会比较快。注意数值梯度检查需要在网络未训练或仅单步更新后做并且使用单一小批量数据比如 32 个样本全量数据会让检查慢到让人失去耐心。这里有个值得养成的习惯对 W1、b1、W2、b2 全部做一遍检查而不是只验一个 W1。输出层的梯度公式相对简单错误往往藏在往 hidden 层传的那一段。我第一次写这个实验时只检查了 W2结果训练一直不收敛后来才发现是 dz1 那里漏乘了 ReLU 的导数掩码——这四个参数全查一遍才能确认整个反传链路是通的。数值梯度检查通过后再去调学习率、隐藏层大小所有的现象才有解释的依据。希望这篇能帮你把实验1一次跑通。本文还有配套的精品资源点击获取
返回列表