☰
用NumPy从零实现BP神经网络:前向传播、反向传播与训练循环
2026/10/1 18:38:20 网站建设 项目流程

简介:面向神经网络导论课程的实验代码包,围绕自适应线性单元(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, seed=42): 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, axis=1, keepdims=True) exp_z2 = np.exp(z2_shifted) self.probs = exp_z2 / np.sum(exp_z2, axis=1, keepdims=True) 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), axis=1))

这段代码有三个参数值得注意。第一,初始化方差用了sqrt(1 / input_size)而不是直接乘 0.01 或 1.0。如果你把方差设成 1,ReLU 的输出在深层传播时方差会越来越大,到输出层时 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, axis=0) / 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, axis=0) / 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_size=64, lr=0.1, epochs=20, batch_size=64, seed=42): input_size = X_train.shape[1] output_size = y_train.shape[1] net = TwoLayerNet(input_size, hidden_size, output_size, seed=seed) 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), axis=1))) val_acc.append(np.mean(np.argmax(probs_val, axis=1) == np.argmax(y_val, axis=1))) return net, train_loss, val_loss, val_acc

lr=0.1对这种两层 ReLU 网络在 minibatch SGD 下是个稳定起点。如果数据量小、batch 大,0.1 可能偏大,Loss 会在某个值附近来回震荡;如果数据噪声大,0.1 又可能偏小,收敛变慢。epochs=20对 MNIST 子集来说足够看到收敛趋势,但不需要一上来就设 100,跑完一轮看曲线形状再决定加不加。

一个注意点:net.forward(X_val)这行不仅算了概率,还会覆盖self.z1、self.a1这些中间变量。如果下一轮你有别的代码想拿这些变量做分析,要注意这个覆盖顺序。训练循环里这是无害的,因为每轮反正会重新 forward。

3. 把数据喂给网络:MNIST的读取、归一化、one-hot与minibatch

3.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, label="train loss") plt.plot(epochs_range, val_loss, label="val 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 个 epoch,train_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 出现 NaN:exp 溢出是第一个怀疑对象

现象:前向传播输出 NaN,loss 也是 NaN,训练直接中断。
原因:z2 里出现大于 700 的值,np.exp(z2)溢出成 inf,inf 除 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, h=1e-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一次跑通。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询