ARTICLE DETAIL

资讯详情

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

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

从零手写BP神经网络:Python与NumPy实现反向传播 简介Python实现BP神经网络算法资源包面向机器学习初学者、算法爱好者和需要完成课程设计的开发者。包内先用通俗说明梳理BP神经网络的架构再从输入层到输出层的前向传播、误差计算、反向传播和参数更新逐层拆解同时给出初始化、训练循环和停止条件的具体实现代码部分直接对应理论步骤便于边读边运行。压缩包总共3个文件由Python源文件、Markdown说明文档和一张bmp图片构成整体仅337KB源文件演示核心算法说明文档写明训练过程与停止条件图片辅助展示网络结构。已有296人学习浏览借助该资源可快速理解网络初始化、损失函数如均方误差选择以及批量梯度下降、随机梯度下降等优化方法并在此基础上动手实现自己的BP神经网络。1. 从零手写BP神经网络为什么这件事值得你亲自做一遍如果你只想调包跑模型用sklearn或PyTorch一行代码就能搞定BP神经网络但那样你永远不会理解反向传播到底在干什么。我的观点是用 Python 从零实现一遍 BP 神经网络算法是打通数学公式→矩阵运算→可运行代码最短路的一次练习。它能解决的核心问题是当你的网络不收敛、loss 震荡、准确率上不去时你能直接定位到是梯度算错了、学习率不合适还是数据没归一化而不是对着黑匣子瞎调参。这篇笔记适合已经会 Python 基础语法、装好了 numpy 库、想真正看懂神经网络底层原理的从业者。我会给你可直接复现的完整代码、参数设置逻辑以及我踩过的坑。2. BP神经网络的数学基础与NumPy实现从公式到矩阵2.1 先搞懂三件事前向传播、损失函数、反向传播BP 神经网络的核心不是那个多层感知机的结构而是误差反向传播这个学习规则。网络结构本身很简单输入层、隐藏层可以有多层、输出层层与层之间全连接每层先做线性变换加权求和加偏置再过激活函数引入非线性。前向传播就是输入 (x) 经过每一层的矩阵乘法 (z Wx b)然后经过激活函数 (a f(z))逐层传到输出层。输出层和真实标签之间算出一个误差比如均方误差 (E \frac{1}{2}\sum (y - \hat{y})^2)。反向传播则是把这个误差从输出层往输入层方向逐层回传用链式法则算出每个权重对误差的偏导数梯度然后沿梯度负方向更新权重。这里有一个容易绕晕的点为什么叫反向传播因为误差是输出层的函数要计算隐藏层权重的梯度必须先得到输出层的梯度再通过链式法则往回收。这个过程的矩阵形式非常紧凑但初学时很容易把维度搞混。我的经验是永远不要只盯着公式看动手推导一个 2-2-1 的小网络2输入、2隐藏、1输出把每个中间变量的维度写出来再对照代码看瞬间就通了。2.2 用NumPy搭建一个3层BP网络前向传播代码先说明我一般不会用面向对象做过度封装但一个简单的类会让代码更清晰。下面是最小可用的 BP 网络实现包含初始化、前向传播两个部分。反向传播在下一节单独讲。import numpy as np class BPNetwork: def __init__(self, n_input, n_hidden, n_output, lr0.1): # 初始化权重这里用了小随机数不能全零初始化 self.W1 np.random.randn(n_input, n_hidden) * 0.5 self.b1 np.zeros((1, n_hidden)) self.W2 np.random.randn(n_hidden, n_output) * 0.5 self.b2 np.zeros((1, n_output)) self.lr lr def sigmoid(self, x): # 为了防止 exp 溢出做了裁剪 x np.clip(x, -500, 500) return 1.0 / (1.0 np.exp(-x)) def forward(self, X): # X: (batch_size, n_input) self.z1 np.dot(X, self.W1) self.b1 # 隐藏层线性输出 self.a1 self.sigmoid(self.z1) # 隐藏层激活 self.z2 np.dot(self.a1, self.W2) self.b2 self.a2 self.sigmoid(self.z2) # 输出层激活 return self.a2参数说明n_input、n_hidden、n_output分别对应输入特征数、隐藏层神经元数、输出类别数。lr是学习率这里默认 0.1。权重初始化用np.random.randn乘以 0.5是为了让初始值落在 sigmoid 的非饱和区附近避免一开始梯度就消失。sigmoid里的np.clip是防抖措施你可能会遇到exp溢出导致nan的坑后面章节细说。前向传播的代码逻辑很直白每一层先做线性变换再过激活函数并把中间结果z1, a1, z2存成实例变量这是为了让反向传播时能直接复用不用重新算前向的值。2.3 反向传播四个梯度公式的代码落地反向传播是 BP 的核心也是新手最容易写错的部分。对单个样本、均方误差损失、sigmoid 激活的情况输出层误差项是delta2 (a2 - y) * sigmoid_derivative(z2)隐藏层误差项是delta1 delta2.dot(W2.T) * sigmoid_derivative(z1)。然后权重梯度就是上一层激活或输入与误差项的外积。def sigmoid_derivative(self, x): s self.sigmoid(x) return s * (1 - s) def backward(self, X, y): m X.shape[0] # 样本数量 # 输出层误差项 (a2 - y) * sigmoid(z2) d2 (self.a2 - y) * self.sigmoid_derivative(self.z2) # 隐藏层误差项 d2 * W2^T * sigmoid(z1) d1 np.dot(d2, self.W2.T) * self.sigmoid_derivative(self.z1) # 梯度对 W2 的梯度是 a1^T * d2对 W1 的梯度是 X^T * d1 grad_W2 np.dot(self.a1.T, d2) / m grad_b2 np.sum(d2, axis0, keepdimsTrue) / m grad_W1 np.dot(X.T, d1) / m grad_b1 np.sum(d1, axis0, keepdimsTrue) / m # 更新权重沿梯度负方向 self.W2 - self.lr * grad_W2 self.b2 - self.lr * grad_b2 self.W1 - self.lr * grad_W1 self.b1 - self.lr * grad_b1 return np.mean((self.a2 - y) ** 2) # 返回MSE方便观察代码逻辑说明d2和d1是误差项grad_W2那行用a1.T做矩阵乘法是因为 a1 是(m, n_hidden)d2 是(m, n_output)转置后得到(n_hidden, n_output)正好和 W2 的维度一致。这里除以m是做批量梯度下降时的平均梯度如果不除loss 的绝对值会随 batch 大小变化学习率就很难统一。参数注意如果隐藏层用了 tanh 或 ReLU只需替换激活函数和导数即可BP 的骨架完全不变。这也是为什么我建议初学者先写 sigmoid 版本因为它的导数s(1-s)特别容易验证算错了用数值梯度一查就暴露。3. 训练循环与关键参数让网络真正收敛3.1 训练循环的骨架epoch、batch 与学习率衰减有了前向和反向训练就是重复以下三步喂数据前向计算 loss反向计算梯度更新权重。但裸的循环很容易陷入两个极端要么 loss 震荡不止要么收敛极慢。常见做法是用小批量mini-batch训练而不是每次只传一个样本或一次性传全部数据。def train(self, X, y, epochs1000, batch_size32, verboseTrue): n X.shape[0] for epoch in range(epochs): # 每个epoch打乱数据顺序避免模型学到样本顺序 indices np.random.permutation(n) for i in range(0, n, batch_size): batch_idx indices[i:i batch_size] X_batch X[batch_idx] y_batch y[batch_idx] self.forward(X_batch) loss self.backward(X_batch, y_batch) if verbose and epoch % 100 0: print(fepoch {epoch}, loss {loss:.6f})这段代码里有两个容易被忽略的细节。第一是np.random.permutation(n)打乱了每个 epoch 的样本顺序如果样本本身有顺序性比如前一半是类别A后一半是类别B不打乱会导致每个 batch 的梯度方向单一训练曲线像锯齿。第二是在同一个 epoch 内self.forward和self.backward是对每个 batch 调用的所以self.a1、self.z2都是当前 batch 的结果反向传播更新权重时不会串到上一个 batch。学习率衰减我一般这样处理lr initial_lr * (1 / (1 decay * epoch))或者更简单的每训练几百个 epoch 把学习率减半。对小数据集和浅层网络固定学习率 0.1 通常足够但如果你发现 loss 前 200 个 epoch 下降很快之后在某个值附近来回震荡下不去那就是学习率偏大了需要衰减。3.2 激活函数怎么选sigmoid、tanh 与 ReLU 的边界BP 网络不一定非要用 sigmoid。手写实现里换激活函数只改两处forward里的激活计算和backward里的导数计算。我做过对比实验结论是对浅层网络1个隐藏层且输出是二分类时sigmoid 完全够用但如果你加了多个隐藏层sigmoid 会很快让梯度消失loss 卡在 0.5 附近不动。tanh 的输出范围是 (-1, 1)均值为 0相比 sigmoid 能稍微缓解收敛慢的问题。ReLU 在正区间梯度恒为 1不会饱和但输出无上界BP 手写时要注意学习率不能太大否则某些神经元会死亡永远输出 0。下面给一个换 ReLU 的最小改动示例def relu(self, x): return np.maximum(0, x) def relu_derivative(self, x): return (x 0).astype(np.float64) # forward中 # self.a1 self.relu(self.z1) # backward中 # d1 np.dot(d2, self.W2.T) * self.relu_derivative(self.z1)注意relu_derivative里的(x 0)返回布尔数组要转成浮点型否则后续乘法会出错。这是很容易踩的一个坑Python 的布尔数组在np.dot里会被当成 0/1但如果你做了np.mean之类的操作类型不匹配会报错或得到错误结果。我建议统一用astype(np.float64)转换。3.3 参数初始化与归一化影响收敛的两个隐形变量权重初始化是新手最不重视、却最影响结果的一环。我见过有人全零初始化结果所有神经元输出相同反向传播时梯度相同网络变成只有一个神经元在训练。正确做法是在的小范围内随机初始化比如np.random.uniform(-0.5, 0.5, size)或randn * 0.5。更科学的叫 Xavier 初始化W np.random.randn(n_in, n_out) * np.sqrt(2.0 / (n_in n_out))这个公式保证了信号在前向传播时方差不会逐层放大或缩小适合 sigmoid/tanh。数据归一化同样关键。BP 对输入特征的尺度非常敏感如果特征 A 取值 [0, 1000]特征 B 取值 [0, 1]那么权重梯度的量级会被特征 A 主导训练过程就像在拉长的碗里走梯度会来回震荡。常见做法是标准化Z-score或缩放到 [0,1]。我一般用标准化def standardize(X): mean np.mean(X, axis0) std np.std(X, axis0) std[std 0] 1 # 防止特征方差为0导致除零 return (X - mean) / std注意那行std[std 0] 1如果某个特征在所有样本里是常数标准差为 0直接除会得到 inf 或 nan。这类防御性代码建议一开始就写上不然后期调试时你会对着 nan 的 loss 发呆很久。4. 用BP网络解决鸢尾花分类完整训练与评估4.1 数据集准备Iris 分类与特征归一化我们用经典的 Iris 数据集来验证手写 BP 网络。这个数据集有 150 个样本4 个特征3 个类别。为了简单起见我们先把问题简化成二分类只取前两个类别setosa 和 versicolor这样输出层只需要 1 个神经元标签用 0 和 1 表示。from sklearn.datasets import load_iris from sklearn.model_selection import train_test_split iris load_iris() # 只取前两类共100个样本 X iris.data[:100] y iris.target[:100] # 二分类标签是0/1 # 划分训练集和测试集 X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42, stratifyy ) # 标准化 mean X_train.mean(axis0) std X_train.std(axis0) std[std 0] 1 X_train (X_train - mean) / std X_test (X_test - mean) / std # 用训练集的均值和标准差 # 把y转成 (m,1) 形状方便和输出层计算 y_train y_train.reshape(-1, 1).astype(np.float64) y_test y_test.reshape(-1, 1).astype(np.float64)这里有个新手常犯的错对测试集做标准化时应该使用训练集上计算出的mean和std而不是重新对测试集计算。原因很简单——测试集模拟的是未来新数据我们不能偷看它的分布来调整自己。重新计算会导致模型对测试集的归一化有偏差评估结果虚高。4.2 训练与评估准确率、混淆矩阵与决策边界把前面写的BPNetwork实例化用标准化后的训练数据训练。由于数据本身线性可分程度较高隐藏层神经元设 4 个就够了。# 创建网络4个输入特征4个隐藏神经元1个输出 bp BPNetwork(n_input4, n_hidden4, n_output1, lr0.1) bp.train(X_train, y_train, epochs500, batch_size16) # 预测时输出值大于0.5判为正类 train_pred (bp.forward(X_train) 0.5).astype(int) test_pred (bp.forward(X_test) 0.5).astype(int) train_acc np.mean(train_pred y_train.astype(int)) test_acc np.mean(test_pred y_test.astype(int)) print(f训练集准确率: {train_acc:.4f}) print(f测试集准确率: {test_acc:.4f})评估很简单但有几个细节值得说。第一bp.forward会覆盖内部缓存的z1, a1等值所以你在训练结束后再调用forward做预测没问题不必担心影响权重。第二用 0.5 作为阈值是输出层 sigmoid 时的自然选择如果你把 sigmoid 换成 tanh阈值要改为 0。第三准确率只能说明总体情况如果样本不平衡准确率会骗人这时要看混淆矩阵或精确率/召回率。如果你把类别扩展到 3 类输出层就要改成 3 个神经元标签用 one-hot 编码[1,0,0]、[0,1,0]、[0,0,1]预测时取输出层最大值索引作为类别。这个改动涉及 loss 函数的选择——多分类一般用交叉熵而不是均方误差后者的梯度在输出层会变钝收敛变慢。这也是为什么框架里多分类默认用CrossEntropyLoss的原因。4.3 与 sklearn MLPClassifier 对比手写实现的准确率能到多少为了确认手写代码没算错我习惯和sklearn的MLPClassifier做一个基准对比。这不丢人反而是验证反向传播正确性的有效手段——如果你的实现和 sklearn 在相同数据上性能差距过大说明代码里一定有 bug。from sklearn.neural_network import MLPClassifier # sklearn默认用relu激活adam优化器我们调整成和手写版本接近的设置 mlp MLPClassifier( hidden_layer_sizes(4,), activationlogistic, # sigmoid solversgd, # 随机梯度下降 learning_rate_init0.1, max_iter500, batch_size16, random_state42 ) mlp.fit(X_train, y_train.ravel()) mlp_acc mlp.score(X_test, y_test) print(fsklearn MLP测试准确率: {mlp_acc:.4f})在我跑过的多次实验中手写 BP 在 Iris 二分类上测试准确率大约在 0.951.0和 sklearn 基本持平。如果你的手写实现只有 0.6 或 0.7问题几乎一定出在反向传播的维度或梯度符号上。我有个土办法把学习率设成极小的值比如 1e-7跑一个 epoch观察每个权重更新的方向是否与理论一致。也可以做数值梯度校验对每个权重加上一个微小扰动 eps计算(loss(Weps)-loss(W-eps))/(2*eps)和反向传播算出的梯度比对。如果两者误差在 1e-6 量级说明你的 BP 写对了。5. BP神经网络的避坑指南5个让新手翻车的细节5.1 梯度消失深层网络为什么训练不动现象增加第二个隐藏层后loss 下降非常缓慢训练几百个 epoch 后 loss 仍然高居不下。原因sigmoid 的导数最大值为 0.25且输入在远离 0 时导数趋近于 0。反向传播时每经过一层梯度就要乘一次小于 1 的数层数越多传到浅层的梯度越小导致浅层权重几乎更新不动。解决换用 ReLU 激活函数它的正区间导数恒为 1不会反复衰减。如果一定想用 sigmoid那就减少隐藏层数量或用残差连接ResNet 的思路。对浅层 BP 网络而言最直接的办法是别盲目加深先用 1 个隐藏层把神经元数量增大效果通常不差。5.2 学习率过大导致 loss 发散现象、诊断与解决现象训练输出loss nan或者 loss 在某个 epoch 后突然跳到 1e8然后一直不降。原因权重更新步长太大跳过了损失函数的低谷甚至在更新后导致激活函数输入进入饱和区梯度变小或变为 0再配合下一次更新数值膨胀。解决把学习率从 0.1 下调到 0.01 或 0.001。不要只盯着 loss 下降慢的问题学习率不是越大越好。诊断方法打印每一层的梯度范数如果梯度范数超过 10就说明学习率偏大如果梯度范数在 1e-6 以下说明梯度过小可能需要更大的学习率或更好的初始化。我习惯在训练前先跑几个 epoch观察 loss 曲线平滑下降就是好信号上下剧烈震荡就是学习率太大。5.3 过拟合训练集准确率 99%测试集只有 75%现象训练集 loss 降得很低准确率接近满分但测试集表现差。原因隐藏层神经元数量太多比如 40 个模型把训练数据的噪声也学到了。Iris 这类小数据集尤其容易过拟合。解决减少隐藏层神经元数、增加训练数据、使用正则化L2。L2 正则化的实现很简单在反向传播计算梯度后给权重梯度加上lambda * W更新时变成W - lr * (grad_W lambda * W)。这个lambda我一般取 0.001 到 0.01。另外早停early stopping是更实用的做法每个 epoch 后看测试集 loss如果测试集 loss 连续 N 个 epoch 不降反升就停止训练并回退到之前的最好权重。5.4 数据归一化不当loss 震荡不收敛现象loss 曲线像锯齿200 个 epoch 后准确率还在 50% 左右徘徊。原因输入特征量纲不一致比如一个特征在 01000另一个在 01导致梯度方向被大尺度特征主导优化路径曲折。解决用标准化或 min-max 缩放到 [0,1]。注意只在训练集上计算参数然后用相同参数去变换测试集。另外如果标签也很大比如回归问题中 y 在 10000 量级输出层激活函数的选择和损失函数的数值都会受影响常见做法是把标签也做归一化或改用线性输出层。5.5 随机种子为什么同一份代码每次结果不同现象每次运行结果不一样有时准确率 0.98有时 0.91你以为是代码有 bug其实不是。原因np.random.randn每次都生成不同的随机初始权重训练时的np.random.permutation也导致数据顺序不同。BP 算法本身对初始权重敏感不同的起点会收敛到不同的局部最优。解决在训练前设置np.random.seed(42)或任意固定整数。但要注意固定种子不等于保证结果可复现因为 numpy 和 Python 的随机数生成器独立。如果你用了多个依赖随机数的库建议在每个关键步骤前都调用对应库的 seed 方法。调试时固定种子调通了再放开这是血泪经验。6. 改进BP网络的三个进阶技巧动量、学习率衰减与正则化验证当你的手写 BP 能稳定跑通 Iris 之后下一步是让它面对更真实的数据时不至于太脆弱。我常用的三个改进是动量、学习率衰减和早停它们加起来只需要改动十几行代码。动量momentum的思路是让权重更新带上惯性从而抑制震荡。在__init__里给每个权重缓存速度变量初始为零self.v_W1 np.zeros_like(self.W1) self.v_b1 np.zeros_like(self.b1) self.v_W2 np.zeros_like(self.W2) self.v_b2 np.zeros_like(self.b2) self.momentum 0.9更新时修改为self.v_W1 self.momentum * self.v_W1 - self.lr * grad_W1 self.W1 self.v_W1注意这里的符号先按当前梯度更新速度再让权重加上速度。动量超参数 0.9 是比较典型的取值越大越平滑但过大会导致冲过头。我之前把动量设成 0.99在某个数据集上 loss 直接飞了后来降到 0.9 才稳定。学习率衰减可以这样实现def train(self, X, y, epochs, lr_decay0.999): original_lr self.lr for epoch in range(epochs): self.lr original_lr * (lr_decay ** epoch) # ... 原有训练逻辑衰减率lr_decay0.999意味着 100 个 epoch 后学习率变为原来的 0.9041000 个 epoch 后约为 0.368。这个收敛方式比固定学习率更平滑也不用手动调整。验证改进是否有效不需要精心设计实验只要在同一个随机种子下分别跑「原始版」和「改进版」对比测试集准确率和 loss 曲线的平稳度即可。最后一个技巧是早停。在训练循环里记录测试集 loss如果连续 50 个 epoch 没有下降就保存当前权重并终止训练。手写实现可以用一个临时变量存最佳权重等训练结束后再恢复。这个习惯能救命你永远不确定某个数据集要训练多少 epoch 合适早停比拍脑袋定epochs10000靠谱得多。如果你真想验证自己的 BP 实现有没有隐藏 bug数值梯度校验是最硬核的一招。对所有参数包括偏置都执行一遍对每个参数 p分别计算eps和-eps的 loss得到数值梯度再和反向传播的梯度对比相对误差。误差小于 1e-5 就说明反向传播完全正确。我每次改完激活函数或者正则化项都会跑一遍数值梯度校验这样改代码时心里很有底。以上就是我用手写 BP 神经网络算法走完的完整路径从数学到代码从调参到排雷。希望帮到你。本文还有配套的精品资源点击获取
返回列表