十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

从零手写BP神经网络:用NumPy实现反向传播理解深度学习原理

从零手写BP神经网络:用NumPy实现反向传播理解深度学习原理 开箱日记系列写到第三篇这次我想换一种开法不去安装一个现成软件而是把“一只会做判断的小狗团子”当作一个学习项目亲手把它的“脑子”拆开从头写一遍。标题里的“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 都能运行不需要 GPUCPU 即可不需要额外配置深度学习框架。如果当前环境还没有 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 到 100snack零食袋响动程度取值 0 到 1walk_time是否散步时间0 或 1y小狗实际行为1 表示准备出门0 表示继续睡觉。理论上如果小狗的尾巴摇得厉害、零食袋在不断响、又正好是散步时间那么出门概率显然更高。为了模拟现实中的不确定性数据中加入少量随机噪声让标签并不是完全由特征决定这样训练出来的模型才更有讨论价值。生成数据的代码如下# 文件路径bp_dog_tuanzi.py第 1 段数据生成 import numpy as np def build_dataset(n800, seed7): 生成小狗团子的合成观察数据特征 3 个标签为二分类。 rng np.random.default_rng(seed) tail rng.uniform(0, 100, sizen) # 尾巴摇动次数 snack rng.uniform(0, 1, sizen) # 零食袋响动程度 walk_time rng.integers(0, 2, sizen).astype(float) # 是否散步时间 # 利用一组权重合成标签并加入噪声让问题更接近真实情况 score 0.02 * tail 0.8 * snack 0.3 * walk_time rng.normal(0, 0.05, sizen) 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(axis0) std X_train.std(axis0) 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_size3, hidden_size4, output_size1, lr0.2, seed0): 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, axis0, keepdimsTrue) / 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, axis0, keepdimsTrue) / 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_size3, hidden_size4, output_size1, lr0.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(fepoch {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 一开始就很大且几乎不变学习率过小或权重初始化太小导致梯度很弱打印每一层参数梯度均值适当增大学习率检查梯度是否为 0loss 快速变成 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
返回列表