☰
从零手写BP神经网络:用NumPy实现反向传播理解深度学习原理
2026/9/30 10:11:28 网站建设 项目流程

开箱日记系列写到第三篇,这次我想换一种开法:不去安装一个现成软件,而是把“一只会做判断的小狗团子”当作一个学习项目,亲手把它的“脑子”拆开从头写一遍。

标题里的“BP”不是某个宠物品牌,而是反向传播 Backpropagation。很多人在实际项目里都用过 PyTorch、TensorFlow,一行loss.backward()就能更新参数,但心里始终有个疑问:反向传播到底在里面做了什么?为什么这么写就能让模型变聪明?与其停留在“会调接口”的层面,不如用 NumPy 从零实现一个极简 BP 网络,让小狗团子学会一个非常接地气的判断任务:根据尾巴摇动次数、零食袋是否响动、是不是散步时间,来判断它到底应该兴奋出门,还是继续趴着睡觉。

这篇文章会给你三条确定性的收获:第一,理解 BP 网络的前向传播、损失计算、反向传播和参数更新到底是怎么串起来的;第二,拿到一份可以直接运行的 Python 代码,在一组合成数据上完成训练和测试;第三,遇到“loss 不下降”“训练集准确率很高但测试集不行”“输出全为 NaN”这些常见问题时,知道从哪里开始排查。

1. 为什么要手写一次 BP:从“调用者”变成“理解者”

在进入代码之前,先回答一个关键问题:PyTorch、Keras 都已经这么成熟了,为什么还要用 NumPy 去手写一个又慢又简陋的神经网络?

我的判断是:反向传播不是一个“背下来就行”的知识点,而是一个需要亲手推导一遍才能建立直觉的技能。框架把细节封装得太好,很多初学者会陷入一种误区——把模型当黑盒:输入数据、调用 fit、得到准确率、换一组数据继续跑。一旦模型效果不好,能做的只有调整学习率、增加轮数、加大网络层数,然后继续祈祷。

原因在于,当你看不到loss是怎么通过链式法则一层一层传回去的,就无法理解“梯度消失”为什么发生,也说不清“sigmoid 输出层 + 二元交叉熵”这个组合为什么很配。

手写一个最小 BP 网络,至少能帮你搞清楚五件事:

  • 神经网络里的权重矩阵形状到底怎么定;
  • 前向传播的矩阵乘法为什么是X @ W + b;
  • 每一层的梯度为什么是“上一层误差信号 × 当前层激活函数导数”;
  • 学习率在更新公式里到底控制什么;
  • 为什么数据标准化能让训练更稳定。

当然,这不代表生产项目也应该手写 BP。真实工程里,框架的自动微分、GPU 加速、分布式训练和大量预训练模型,都是手写代码无法替代的。手写 BP 的价值不是替代框架,而是帮你理解框架,让你在使用框架时更清楚自己在做什么。

2. BP 神经网络的核心概念与小狗团子任务

为了让后面的代码不变成“魔法”,这里先用小狗团子的场景把概念解释一遍。

小狗团子每天会经历几个时刻,我们可以把它的状态抽象成三个特征:

  • 尾巴摇动次数:0 到 100 之间的一个数;
  • 零食袋响动程度:0 到 1 之间,越大表示主人正在拿零食;
  • 是否散步时间:0 表示不是,1 表示是。

我们想知道的问题是:小狗团子会不会兴奋地准备出门?

这其实是一个典型的二分类问题。神经网络要做的事情,就是根据这三个输入,计算出一个 0 到 1 之间的概率,概率越接近 1,表示“出门意愿越高”。

BP 网络中的几个关键概念可以用下面的方式理解:

概念通俗解释在代码中的体现
输入层喂给模型的特征,本例有 3 个3 维向量
隐藏层模型内部的中间计算层,可以处理非线性关系4 个神经元
输出层最终输出,本例用 sigmoid 压缩到 0~11 个概率值
权重 W每个输入对结果影响的强弱矩阵
偏置 b让模型在输入全为 0 时也能有输出偏移向量
前向传播从输入到输出,逐层计算预测值X @ W1 + b1等
损失函数衡量预测值和真实标签的差距二元交叉熵
反向传播从输出误差出发,逐层计算每个参数的梯度链式法则
参数更新用梯度下降更新 W 和 bW -= lr * dW

小狗团子第一次做判断时,权重是随机初始化的,它大概率会乱猜。训练过程会反复执行“前向传播 → 计算损失 → 反向传播 → 更新参数”这一循环,每一轮都把参数朝损失下降的方向调整一点。经过几百轮之后,它慢慢就能根据特征做出符合规律判断。

这里有个新手容易混淆的点:反向传播并不是一种新的训练算法。它只是一种计算梯度的高效方式,真正让参数更新的是梯度下降。反向传播负责回答“每个参数应该往哪个方向调”,梯度下降负责执行“调多少幅度”。两者配合,才构成完整的学习过程。

3. 环境准备与前置条件

本次示例只需要 Python 环境和 NumPy,不依赖任何深度学习框架。原因是示例网络足够小,NumPy 完全可以跑通,而且越少依赖越能看清核心逻辑。

建议环境如下:

  • Python 3.8 及以上;
  • NumPy 1.x 或 2.x 均可,版本以当前主流稳定版为准;
  • 操作系统不限,Windows、Linux、macOS 都能运行;
  • 不需要 GPU,CPU 即可;
  • 不需要额外配置深度学习框架。

如果当前环境还没有 NumPy,可以先执行下面的命令安装。

python -m pip install numpy

如果想使用虚拟环境隔离依赖,可以这样操作:

python -m venv bp_dog_env source bp_dog_env/bin/activate # Windows 下使用 bp_dog_env\Scripts\activate python -m pip install numpy

本文所有代码都保存在同一个文件中,为方便阅读,我会按模块拆开讲解,最终形成一份完整可运行的脚本bp_dog_tuanzi.py。

4. 数据准备:给小狗团子一份可学习的日常记录

训练神经网络不能没有数据。为了不涉及任何真实宠物隐私,也为了让示例效果清晰,下面用一段代码合成小狗团子的“日常观察记录”。

每一行数据对应一个时刻的状态:

  • tail:尾巴摇动次数,取值 0 到 100;
  • snack:零食袋响动程度,取值 0 到 1;
  • walk_time:是否散步时间,0 或 1;
  • y:小狗实际行为,1 表示准备出门,0 表示继续睡觉。

理论上,如果小狗的尾巴摇得厉害、零食袋在不断响、又正好是散步时间,那么出门概率显然更高。为了模拟现实中的不确定性,数据中加入少量随机噪声,让标签并不是完全由特征决定,这样训练出来的模型才更有讨论价值。

生成数据的代码如下:

# 文件路径:bp_dog_tuanzi.py(第 1 段:数据生成) import numpy as np def build_dataset(n=800, seed=7): """生成小狗团子的合成观察数据,特征 3 个,标签为二分类。""" rng = np.random.default_rng(seed) tail = rng.uniform(0, 100, size=n) # 尾巴摇动次数 snack = rng.uniform(0, 1, size=n) # 零食袋响动程度 walk_time = rng.integers(0, 2, size=n).astype(float) # 是否散步时间 # 利用一组权重合成标签,并加入噪声,让问题更接近真实情况 score = 0.02 * tail + 0.8 * snack + 0.3 * walk_time + rng.normal(0, 0.05, size=n) y = (score >= 1.55).astype(int).reshape(-1, 1) X = np.column_stack([tail, snack, walk_time]) # 打乱并划分训练集、测试集 idx = rng.permutation(n) train_n = int(n * 0.8) train_idx, test_idx = idx[:train_n], idx[train_n:] X_train, X_test = X[train_idx], X[test_idx] y_train, y_test = y[train_idx], y[test_idx] # 标准化:用训练集的均值和标准差来处理训练集和测试集 mu = X_train.mean(axis=0) std = X_train.std(axis=0) + 1e-8 X_train = (X_train - mu) / std X_test = (X_test - mu) / std return X_train, X_test, y_train, y_test

这里有两个容易被忽视的细节。

第一个是标准化。尾巴摇动次数的取值范围是 0 到 100,而零食袋响动程度只有 0 到 1。如果不做标准化,神经网络在训练初期会把这个差异当成某种“信号强弱差异”,导致梯度更新偏向大数值特征,模型更难收敛。标准化的操作是让每个特征都变成均值接近 0、标准差接近 1 的分布。

第二个是测试集必须使用训练集的均值和标准差。很多初学者在预处理数据时,会把训练集和测试集分别做标准化。这在逻辑上是错误的,因为测试集被用来模拟“未来新数据”,它不应该向模型泄漏任何统计信息。正确做法是:先用训练集算出mu和std,再把这组统计量应用到测试集上。

数据中加入了噪声,所以模型的准确率不可能达到 100%。这是刻意为之。现实中的预测问题几乎都存在噪声,模型能做的只是抓住规律,而不是记住每一条特殊的细节。

5. 用 NumPy 从零实现 BP 网络完整代码

现在进入核心部分。我会实现一个隐藏层包含 4 个神经元的简单全连接网络,结构是 3 → 4 → 1。

先定义一个TinyBP类,它需要完成以下几件事:

  • 初始化两个权重矩阵和偏置;
  • 实现 sigmoid 激活函数;
  • 实现前向传播;
  • 实现反向传播;
  • 实现预测方法。

为了数值稳定,sigmoid 函数内部会对输入做裁剪,避免极端数值导致exp溢出。

# 文件路径:bp_dog_tuanzi.py(第 2 段:TinyBP 网络类) class TinyBP: def __init__(self, input_size=3, hidden_size=4, output_size=1, lr=0.2, seed=0): rng = np.random.default_rng(seed) self.lr = lr # 初始化权重为小随机数,避免过大导致梯度不稳定 self.W1 = rng.normal(0, 0.1, (input_size, hidden_size)) self.b1 = np.zeros((1, hidden_size)) self.W2 = rng.normal(0, 0.1, (hidden_size, output_size)) self.b2 = np.zeros((1, output_size)) @staticmethod def _sigmoid(x): """sigmoid 激活函数,加入裁剪防止 exp 溢出。""" x = np.clip(x, -500, 500) return 1.0 / (1.0 + np.exp(-x)) def forward(self, X): """前向传播:从输入到输出逐层计算。""" self.z1 = X @ self.W1 + self.b1 self.a1 = self._sigmoid(self.z1) self.z2 = self.a1 @ self.W2 + self.b2 self.a2 = self._sigmoid(self.z2) return self.a2 def backward(self, X, y): """反向传播:计算各层梯度并进行参数更新。""" m = X.shape[0] # 输出层到隐藏层的误差信号。 # 当输出层使用 sigmoid、损失使用二元交叉熵时,梯度可以简化为 pred - y。 dz2 = self.a2 - y dW2 = self.a1.T @ dz2 / m db2 = np.sum(dz2, axis=0, keepdims=True) / m # 链式法则:隐层误差 = 输出误差乘以 W2,再乘以 sigmoid 导数 a1*(1-a1) dz1 = (dz2 @ self.W2.T) * self.a1 * (1 - self.a1) dW1 = X.T @ dz1 / m db1 = np.sum(dz1, axis=0, keepdims=True) / m # 梯度下降更新参数 self.W2 -= self.lr * dW2 self.b2 -= self.lr * db2 self.W1 -= self.lr * dW1 self.b1 -= self.lr * db1 def predict(self, X): """返回二分类标签和原始概率输出。""" prob = self.forward(X) return (prob >= 0.5).astype(int), prob

这段代码需要重点解释的是反向传播里的三行梯度公式。

输出层先用pred - y代替了复杂的交叉熵导数。这是一个常用的数学技巧:当输出层使用 sigmoid 函数、损失函数使用二元交叉熵时,两者组合后的梯度正好等于模型预测值减去真实值。这一步骤不需要手动拆成两段求导,但在很多教材里会单独推导一次,理解它对后续修改损失函数很有帮助。

隐藏层的误差信号则是(dz2 @ self.W2.T) * self.a1 * (1 - self.a1)。前一部分把输出层误差沿网络回传,后一部分是 sigmoid 函数自身的导数。这里能直观看到“梯度消失”的影子:如果a1非常接近 0 或 1,那么a1 * (1 - a1)会接近 0,上一层梯度也会变小。如果网络更深,误差逐层乘上小于 1 的因子后,浅层参数几乎就得不到有效更新了。

接着定义损失函数和训练入口。选用二元交叉熵作为损失,因为它在二分类问题中比均方误差更合适。交叉熵能更大幅度地惩罚“高置信且错误”的预测,让模型优化方向更集中在错误样本上。

# 文件路径:bp_dog_tuanzi.py(第 3 段:损失函数与训练流程) def binary_cross_entropy(y_true, y_pred): """二元交叉熵损失。""" y_pred = np.clip(y_pred, 1e-12, 1 - 1e-12) return -np.mean(y_true * np.log(y_pred) + (1 - y_true) * np.log(1 - y_pred)) def main(): X_train, X_test, y_train, y_test = build_dataset() model = TinyBP(input_size=3, hidden_size=4, output_size=1, lr=0.2) epochs = 500 for epoch in range(epochs + 1): pred = model.forward(X_train) loss = binary_cross_entropy(y_train, pred) if epoch % 50 == 0 or epoch == epochs: train_acc = (model.predict(X_train)[0] == y_train).mean() test_acc = (model.predict(X_test)[0] == y_test).mean() print(f"epoch {epoch:4d} | loss {loss:.6f} | train_acc {train_acc:.4f} | test_acc {test_acc:.4f}") model.backward(X_train, y_train) if __name__ == "__main__": main()

从这段代码可以看到完整的训练时序:

  • 前向传播得到预测值;
  • 根据预测值和真实标签计算损失;
  • 反向传播计算梯度并更新参数;
  • 每隔一定轮数打印当前指标。

本示例使用的是全量批量梯度下降,也就是每一轮更新都基于全部训练样本。真正工程中很少这样用,因为全量梯度计算成本高,更多使用 mini-batch 或 Stochastic Gradient Descent。但作为理解工具,全量更新可以让 loss 曲线更平滑,也更容易判断网络是否正确实现。

6. 运行结果与效果验证

代码全部写入bp_dog_tuanzi.py后,可以直接运行:

python bp_dog_tuanzi.py

正常情况下,控制台会输出类似下面的内容:

epoch 0 | loss 0.693147 | train_acc 0.5094 | test_acc 0.5125 epoch 50 | loss 0.513247 | train_acc 0.6547 | test_acc 0.6500 ... epoch 500 | loss 0.062145 | train_acc 0.9625 | test_acc 0.9550

数值会受随机种子、初始化和数据分布影响,如果你修改了网络结构或学习率,结果会有所不同,但判断成功的关键是看以下三个信号:

第一,loss 必须整体呈下降趋势。如果 loss 在前面几轮就变成 0,反而要警惕,可能是数据泄漏或网络把训练数据背下来了。训练后期 loss 应该降到比较低的水平,但不必追求 0。

第二,训练集准确率和测试集准确率都要稳定在一个较高水平。如果训练集准确率很高而测试集准确率很低,说明出现了过拟合。本示例的合成数据规律明显,样本量尚可,一般不会出现严重过拟合。

第三,输出概率应该在 0 和 1 两个方向都比较有区分度。也就是说,对大多数样本,模型要么输出接近 0.9 的正向结果,要么输出接近 0.1 的反向结果。如果所有输出都集中在 0.5 附近,说明模型没有学到有效区分能力。

运行失败是一个值得认真对待的场景。如果控制台一堆 NaN,第一步要去看损失值是否从第一次迭代开始就是 NaN,如果是,优先检查学习率是否设置过大,或者权重初始化是否产生了过大的激活值。如果程序直接报AttributeError,优先检查文件是否把前面几个代码段的函数都完整粘贴进去。

7. BP 网络常见问题与排查思路

训练手写 BP 网络最容易遇到的问题,我整理成了一张排查表。这些问题并不只出现在教学代码里,在实际使用 PyTorch 等框架时同样会出现,区别只是框架把报错信息包装得更友好一些。

问题现象可能原因排查方式解决方案
loss 一开始就很大且几乎不变学习率过小,或权重初始化太小导致梯度很弱打印每一层参数梯度均值适当增大学习率,检查梯度是否为 0
loss 快速变成 NaN学习率过大导致梯度爆炸检查输出和梯度中是否出现 inf降低学习率,对激活值做 clip
训练集准确率高、测试集准确率低模型过拟合对比 train_acc 与 test_acc增加训练数据、减少隐藏层节点、添加正则化
loss 不下降但训练准确率还正常损失函数与激活函数不匹配检查损失计算方式推荐 sigmoid 配二元交叉熵,softmax 配多分类交叉熵
输出概率永远接近 0.5模型容量不足或特征没经过标准化查看训练中 loss 变化增加隐藏层神经元,检查数据标准化
loss 在某轮异常反弹学习率偏大或数据噪声过大减小学习率重新训练采用更小的学习率,或加入学习率衰减
换 seed 后结果波动巨大初始化不合理或数据太少固定 seed 做多组实验使用更小的随机初始化范围,增加数据量

这里最值得强调的还是学习率。很多初学者出现 NaN 后的第一反应是修改网络结构或损失函数,实际上 80% 的情况只要把学习率调小就能解决。

此外,反向传播推导出错时,最常见表现并不是程序报错,而是 loss 下降受阻或者出现不正常的准确率。建议在自己设计新网络时,先把网络写得很小,隐藏层节点设为 2 或 3,用一份极小的数据验证梯度是否在正确下降。无报错不代表梯度正确,只有 loss 稳定下降才是有效信号。

8. 从手写 BP 到工程实践:几条关键建议

把小狗团子跑通之后,你应该再往前走一步:把这次手写代码的经验沉淀成工程判断。

第一,生产环境务必使用成熟框架。手写 BP 是为了理解原理,而 PyTorch、JAX、TensorFlow 在自动微分、算子优化、分布式训练、模型部署等维度上有完整设施。不要在真实项目里重新造一个不完整的训练轮子。

第二,进入框架之后,不需要担心自己“走后门”。用 PyTorch 写出的loss.backward()底层同样按照链式法则计算梯度,和手写 BP 的核心逻辑一脉相承。理解这一步之后,你更能懂得为什么requires_grad、梯度清零、优化器 step 这些操作是必要的。

第三,数据预处理的重要性高于模型结构选择。在表格型数据上,一个合理标准化后的线性模型可能比一个未处理数据的深层神经网络更稳定。如果特征分布相差大,最常见的问题不是模型不够强,而是预处理不够好。

第四,不要只盯着准确率。对不平衡的二分类任务,准确率会产生很强的误导。比如 95% 的样本都是“不兴奋”,全预测“不兴奋”也能得到 95% 准确率,但这个模型毫无用处。实践时应综合考虑精确率、召回率、F1 值,以及业务本身对两类错误的代价判断。

第五,训练过程要留痕。无论是验证集 loss、训练集 loss、每轮准确率,还是超参数配置,都应该记录下来。很多实验前几次跑出的结果不错,但后来不知道改了什么就再也回不去了。最小做法是让日志包含固定的随机种子、学习率和网络结构。

第六,数据使用要谨慎。如果未来你在真实业务训练中用到宠物行为、用户行为等数据,要确认是否有合法采集授权,并做好脱敏处理。不要在未授权的前提下把个人或企业的原始记录直接送入在线训练服务。

第七,在团队协作中,模型不仅仅是代码文件。还需要同步记录数据来源、预处理规则、模型版本、评估结果和部署路径。否则几个月后回到项目,面对一个.npy权重文件,连它是在什么数据上训练的都说不清楚。

9. 下一步实践方向

如果你完整运行了上面的代码并理解了每一行,恭喜你,你已经不是只在model.fit层面理解神经网络的人了。你已经见过一次神经网络从随机猜测到逐步收敛的全过程。

但这个“小狗团子”距离真正的深度学习还差很远。下一步可以从四个方向继续深入:

一是把损失函数从二元交叉熵替换成均方误差,重新推导并体会不同损失函数的梯度差异。这个过程能帮助你理解为什么分类任务中交叉熵往往是更自然的选择。

二是把隐藏层从一层扩展到多层,观察不同层数对收敛速度和结果的影响,并尝试解释梯度消失现象为什么会随网络加深而凸显。

三是把全量批量梯度下降改成真正的小批量随机梯度下降,观察随机采样带来的 loss 曲线波动,并思考 batch size 对训练的影响。

四是引入 PyTorch,把同一份数据用torch.nn.Module

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

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

立即咨询