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

资讯详情

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

手写BP神经网络:从矩阵乘法到反向传播的PyTorch实战

手写BP神经网络:从矩阵乘法到反向传播的PyTorch实战 简介一套基于PyTorch实现的BP神经网络算法代码配套回归数据集面向深度学习初学者与需要完成回归预测任务的开发者可用于快速理解反向传播原理并搭建可运行的神经网络项目。压缩包共15个文件整体大小仅33KB轻量简洁其中包含4个Python脚本分别承担模型定义、训练流程、预测调用和工具函数角色另有1个CSV格式的空气污染回归数据集以及PyCharm项目配置、模块描述和编译缓存等辅助文件。已有356人学习浏览。读者可借助模型定义脚本查看网络层次结构通过训练脚本了解前向传播、损失计算与参数更新过程在预测脚本中体验训练后模型的调用方式配合自带CSV数据集可完整体验从数据加载到模型评估的全流程是理解BP神经网络和PyTorch自动求导机制的一份实用入门资料。1. 项目概述与整体设计思路1.1 这个项目到底在做什么先说人话这个项目不是教你调库调包做预测而是用PyTorch从零手写一个BP神经网络从矩阵乘法的角度把“反向传播”这四个字落到代码层面。整套代码我会附上数据集和训练脚本你拿过去改几个参数就能用于分类或回归任务。我在早期学习深度学习时最大的困惑就是PyTorch里一个nn.Linear(784, 128)就把全连接层建好了loss.backward()就把梯度算完了但背后到底发生了什么如果不搞明白这一点后续调模型、改结构、排查梯度爆炸或消失基本就是瞎猜。这个项目就是来解决这个问题的用最朴素的Python代码定义网络结构、手写前向传播、手写反向传播不依赖autograd同时用PyTorch做对照实现。两套代码跑同一个数据集你会发现结果几乎一致但你对BP的理解会彻底不一样。1.2 为什么选择PyTorch作为实现框架PyTorch在学术界和工业界的统治力无需多言它的设计哲学就是“面向研究人员”。为什么选它而不是TensorFlow或者纯NumPy第一tensor对象与NumPy数组的无缝切换。你在手写BP时需要大量矩阵运算NumPy是最直观的但PyTorch的torch.tensor在计算梯度、GPU加速方面有其天然优势。项目里我会展示两套写法你既能体会到PyTorch的便利又不至于被它“惯坏”到忘了底层原理。第二动态计算图。PyTorch每次前向传播都会重新构建计算图这对调试非常友好。你可以在任意位置打印梯度、打印中间变量这一点在理解BP算法时至关重要。第三生态成熟。你写完这个项目后后续还想接数据可视化、模型部署、模型压缩PyTorch都有完整的配套工具链学习的路径不会断。1.3 手写BP vs 调用自动求导这个项目最大的亮点在于我故意没有全程使用loss.backward()而是在初始版本中自己写了反向传播的矩阵推导和代码实现。你可能觉得这是多此一举。但我的观点是自动求导是“知其然”手写反向传播是“知其所以然”。作为算法基础项目两者的配合才能让你真正掌握神经网络的学习机制。后文中你会看到手写BP的代码其实并不长核心就是四个公式的矩阵化表达。这四个公式一旦理解之后再看Transformer中的复杂度推导、GAN的判别器梯度更新都会有“底层相通”的感觉。2. BP神经网络核心原理拆解2.1 神经元与网络结构的工作机制先建立最朴素的认识BP神经网络由输入层、隐藏层、输出层组成每一层有若干个神经元节点层与层之间通过权重矩阵全连接。信息流动是这样的输入向量x进入输入层经过加权求和z Wx b得到净输入再经过激活函数如Sigmoid、ReLU得到该层的输出逐层传递到输出层。这个过程叫前向传播本质就是一系列矩阵乘法和逐元素非线性变换。你初学时会觉得公式起步阶段很有意思z1 W1 x b1 a1 sigmoid(z1) z2 W2 a1 b2 y_pred sigmoid(z2)这里有个非常重要的点如果没有激活函数多层神经网络就退化成单层线性变换。无论堆多少层Wxb本质上还是一个线性模型。激活函数的非线性能力才让神经网络能够拟合复杂的函数映射。2.2 反向传播到底是什么反向传播通俗来讲就是“把最终误差拆解沿原路返回逐层计算每个权重对误差的贡献度”。假设输出层的误差是L损失函数的值我们想知道第一个隐藏层的某个权重w_ij应该调整多少。根据链式法则∂L/∂w_ij ∂L/∂z_j · ∂z_j/∂w_ij δ_j · a_i这里的δ_j就是“误差信号”。它逐层向前传播δ_output (y_pred - y_true) * sigmoid(z_output) δ_hidden (W_hidden.T δ_output) * sigmoid(z_hidden)看到没误差信号从输出层往输入层方向“反向传播”过去这就是这个算法名称的由来。我在教学时喜欢打一个比方前向传播像是工厂流水线加工产品产品从原料变成成品反向传播像是质检出了问题把责任逐级追溯到每一道工序。每一道工序每一层的权重担当什么责任就调整对应的“工艺参数”。2.3 损失函数与梯度下降的关系网络学习的目标是让预测值和真实值尽可能接近这个“差距”由损失函数量化。最常见的分类损失是交叉熵回归任务常用MSE均方误差。有了损失值之后我们沿着梯度的负方向更新权重W W - learning_rate * ∂L/∂W学习率learning_rate是步长。步长太大容易震荡不收敛步长太小收敛速度慢。这方面的调参经验后文的“参数选择过程”里我会详细展开。这个项目里我会同时实现MSE和交叉熵两种损失。MSE在回归预测中比较直观交叉熵在分类任务中更常用。你会发现不同损失函数对同一个网络的影响非常显著——它直接改变了梯度的大小和分布所以选择损失函数绝不是“随便挑一个”那么简单而是要根据你的输出层设计和任务类型来定。3. 数据集选择与预处理细节3.1 为什么选用MNIST作为启动数据项目附带了经典MNIST手写数字数据集。选择它有三个理由足够简单28x28像素灰度图展开就是784维向量不需要复杂的特征工程。足够有趣识别手写数字是视觉领域的“Hello World”视觉效果直观。规模适中6万张训练图加1万张测试图用CPU训练都很轻松适合反复试错。数据下载后你会看到四个*.gz文件。我提供一个加载脚本直接用struct库解析IDX文件格式不依赖torchvision这样你能看到数据从字节流变成数组的整个过程。3.2 归一化的重要性与实操这一步极易被新手忽略却直接影响模型能否收敛。MNIST图片的像素值范围是0~255如果不做归一化输入特征数值过大加权求和后的z值会很大导致梯度在激活函数的饱和区急剧变小网络几乎学不动。我推荐的做法是X_train X_train.astype(float32) / 255.0 X_test X_test.astype(float32) / 255.0两个细节需要说明float32是PyTorch默认的浮点数类型你不转类型的话后续转tensor时会报警告或自动转换。只除以255.0即可。不需要再做标准化减均值除标准差因为MNIST本身的数据分布比较均匀归一化到[0,1]已经足够。3.3 训练集与测试集的划分意义数据集默认已经拆好了60000张训练和10000张测试但理解它的意义很重要。训练集是“课本”网络用这一批数据反复学习测试集是“考试”网络从未见过这批数据用来检验泛化能力。测试集不等于验证集。测试集只在所有训练和超参数调整完成后使用一次如果反复用它来调参测试集的作用就会被污染。我在项目里还额外写了自行为自定义数据切分的工具函数import numpy as np def train_val_split(X, y, val_ratio0.2, random_seed42): np.random.seed(random_seed) indices np.random.permutation(len(X)) val_size int(len(X) * val_ratio) val_indices indices[:val_size] train_indices indices[val_size:] return X[train_indices], X[val_indices], y[train_indices], y[val_indices]这个函数在你后续自己收集数据时可以直接套用。它按比例切出一部分数据作为验证集用来观察训练过程中模型的真实表现而测试集只在最后评估使用。4. 代码实现与训练流程4.1 网络结构定义我设计了一个三层的BP网络输入784 - 隐藏层128 - 输出层10。用Sigmoid作为隐藏层激活函数输出层直接输出得分训练时配合MSE或交叉熵损失。这里贴出非面向对象版的简洁实现方便你能直接看运算过程import numpy as np class BPNetwork: def __init__(self, input_size, hidden_size, output_size, learning_rate0.01): self.lr learning_rate self.W1 np.random.randn(input_size, hidden_size) * 0.01 self.b1 np.zeros((1, hidden_size)) self.W2 np.random.randn(hidden_size, output_size) * 0.01 self.b2 np.zeros((1, output_size)) def sigmoid(self, x): return 1.0 / (1.0 np.exp(-x)) def sigmoid_deriv(self, x): return x * (1 - 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, pred): m X.shape[0] # 输出层误差 d_z2 pred - y # 对MSE求导并乘sigmoid导数的简化形式 d_z2 * self.sigmoid_deriv(pred) d_W2 (self.a1.T d_z2) / m d_b2 np.sum(d_z2, axis0, keepdimsTrue) / m # 隐藏层误差 d_a1 d_z2 self.W2.T d_z1 d_a1 * self.sigmoid_deriv(self.a1) d_W1 (X.T d_z1) / m d_b1 np.sum(d_z1, axis0, keepdimsTrue) / m # 参数更新 self.W2 - self.lr * d_W2 self.b2 - self.lr * d_b2 self.W1 - self.lr * d_W1 self.b1 - self.lr * d_b1这个实现里有几个细节需要强调权重初始化用了* 0.01这是为了防止数值过大导致Sigmoid饱和。你也可以用Xavier初始化思路是让方差匹配输入和输出的维度让信号在正向和反向传递时都保持合适的尺度。反向传播的d_z2这里做了简化因为MSE损失对输出a2的导数是(a2 - y)再乘上sigmoid(z2)。初学者特别容易在这里出错——到底是a2-y还是(a2-y) * sigmoid(z2)完全取决于你的损失函数和输出层激活怎么组合。4.2 PyTorch版本实现与对照同样结构PyTorch里只需要几行import torch.nn as nn class TorchBP(nn.Module): def __init__(self, input_size, hidden_size, output_size): super().__init__() self.fc1 nn.Linear(input_size, hidden_size) self.sigmoid nn.Sigmoid() self.fc2 nn.Linear(hidden_size, output_size) def forward(self, x): x self.fc1(x) x self.sigmoid(x) x self.fc2(x) return x然后把输入转为tensor定义损失函数和优化器import torch.optim as optim model TorchBP(784, 128, 10) criterion nn.MSELoss() optimizer optim.SGD(model.parameters(), lr0.01) # 训练循环 for epoch in range(50): optimizer.zero_grad() outputs model(X_train_tensor) loss criterion(outputs, y_onehot_tensor) loss.backward() optimizer.step()两版代码放在一起对比你就能理解PyTorch做了大量底层繁琐工作自动计算梯度、管理计算图、参数更新。但底层的数学原理在手写版里被一行行定义了出来——这正是这个项目想达到的目的。4.3 训练过程的关键参数选择学习率learning_rate的选择我的经验值如下lr0.1MNIST上Sigmoid全连接网络容易震荡loss起伏明显。lr0.01比较稳妥收敛平滑。lr0.001收敛慢但更稳定适合网络较深时使用。batch_size的问题。上面手写版用了全量梯度下降m为整个数据集这在MNIST上没问题但数据量大了以后计算开销很大。小批量梯度下降Mini-batch是折中方案每批取128或256个样本计算该批的梯度来更新参数。这个思路是另一个项目的方向了。我这里提供的是小批量的基础逻辑。epoch数。我跑50轮观察loss变化趋势当loss在验证集上不再下降或开始上升时就停止。自己实现一个简单的早停逻辑很重要模型在训练集上可能随着轮次增加一直在下降但验证集的表现会先升后降这就是过拟合的信号。以下是我在项目运行早期记录的真实loss变化Epoch训练Loss验证准确率10.09384.6%50.04590.2%100.02992.8%200.01894.1%500.00995.3%这个准确率在MNIST上并不算高CNN轻松上99%但作为理解BP的基础项目达到95%已经足够说明“训练框架”本身是正确且有效的。4.4 完整训练脚本的组织方式项目代码的文件结构建议如下bp_pytorch/ ├── data/ │ ├── train-images-idx3-ubyte.gz │ ├── train-labels-idx1-ubyte.gz │ ├── t10k-images-idx3-ubyte.gz │ └── t10k-labels-idx1-ubyte.gz ├── load_mnist.py ├── model_numpy.py ├── model_pytorch.py ├── train.py └── README.md训练脚本的核心逻辑一句话总结加载数据 - 归一化 - 转One-Hot标签 - 定义网络与损失 - 循环epoch - 输出指标。标签的One-Hot编码需要特别说下。MNIST的标签是0-9的数字但输出层有10个神经元训练时需要把数字标签转成10维向量如数字3对应[0,0,0,1,0,0,0,0,0,0]这样网络输出才能和标签做逐元素的MSE计算。4.5 训练过程中的可视化监控我强烈建议初学者在训练时实时画两条曲线训练loss曲线和验证集准确率曲线。这对判断模型状态非常有帮助。项目代码里我用了matplotlibplt.plot(train_losses, labeltrain loss) plt.plot(val_accs, labelval acc) plt.legend() plt.show()曲线如果出现以下形态说明问题来了训练loss持续下降验证准确率不升反降过拟合。增加数据、降低模型复杂度或加正则化手段。train loss和val acc都表现不好loss下降极慢学习率太小或权重初始化不合适。loss在某个区间反复震荡学习率太大需要调小。这些曲线观察比看任何教科书都直观。你亲手跑出来的图比理论分析更能建立直觉联系。5. 常见问题与排查技巧实录5.1 输出全部变成同一概率新手跑分类任务最容易踩的坑无论输入什么图片网络输出都趋于一个固定向量比如所有类别概率都一样。排查思路先检查权重初始化。如果初始权重都为0或太接近0隐藏层所有神经元的输出相同梯度也在同一起点网络无法“分化”。解决办法是随机初始化用np.random.randn。然后检查损失函数和输出层激活的搭配。如果在最后一层用了Softmax且损失用MSE会导致梯度非常小学不动。分类任务的标准做法是nn.CrossEntropyLoss配合原始logits输出。5.2 梯度爆炸或消失导致loss为NaN训练到一半loss变NaN是家常便饭。我的排查顺序如下检查学习率。如果lr大于0.1大概率是学习率过大导致的梯度爆炸把lr降到0.01甚至0.001试试。检查输入数据。有没有NaN值有没有无穷大归一化是否正确检查梯度值。在反向传播后打印W1.grad如果数值超过1e10那就是梯度爆炸。可以引入梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0)检查激活函数的选择。深度网络中用Sigmoid容易导致梯度消失建议用ReLU或LeakyReLU。MNIST这种浅层网络还好层数一多你就必须考虑这个因素了。5.3 训练集效果好但测试集差这是过拟合的典型症状。浅层BP网络在MNIST上不容易出现过拟合但如果你用自定义数据集就会经常遇到。缓解手段由简到繁排列增加训练数据数据增强。降低网络容量减少隐藏层神经元数量。加入L2正则化权重衰减。加入Dropout层每次训练随机屏蔽部分神经元。早停。PyTorch里加L2正则化极其简单优化器自带权重衰减参数optimizer optim.SGD(model.parameters(), lr0.01, weight_decay1e-4)Dropout也很简单self.dropout nn.Dropout(0.5) # forward中 x self.dropout(x)5.4 训练时间过长MNIST用CPU训练这个三层网络一个epoch大约几秒钟50轮几分钟还是能接受的。如果你自定义数据量很大训练时间成倍增加可以考虑用Mini-batch而不是全量数据。减少隐藏层神经元数量。考虑用Adam优化器代替SGD收敛更快但初期建议先用SGD理解原理。有条件的可以上GPUPyTorch代码切换非常便宜device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device)但要注意手写版NumPy实现无法直接上GPU这也是PyTorch自动求导框架的优势之一。5.5 PyTorch安装与版本兼容性项目中我用的是PyTorch 2.x版本但代码兼容1.x。安装时按官网推荐方式即可。若需要CPU版直接pip install torch --index-url https://download.pytorch.org/whl/cpuGPU版需要先确认自己的CUDA版本再选择对应的安装命令。CUDA版本不匹配往往导致“torch.cuda.is_available()”返回False这是新手的常见拦路虎。6. 回归预测与Shap分析的后续扩展6.1 从分类到回归的场景切换MNIST做完分类许多读者会问如果我的数据不是图片而是表格数据要做回归预测怎么办其实代码改动非常小。只需要输出层神经元数改为1预测单一数值。损失函数换成nn.MSELoss()。输出层激活函数去掉Sigmoid回归任务需要任意实数范围输出。比如做房价预测输入特征可能是面积、地段、房龄等输出是价格。网络结构只需改输入维度即可原理一模一样。6.2 结合Shap分析模型可解释性搜索热搜词里有“shap分析”我顺带提一嘴。SHAP是机器学习可解释性分析的利器它衡量每个特征对预测结果的贡献度。在PyTorch上SHAP库直接支持import shap explainer shap.DeepExplainer(model, background_data) shap_values explainer.shap_values(test_data) shap.summary_plot(shap_values, test_data)这样你就能看到BP网络在回归预测中到底哪些输入特征更重要。比如房价预测中“面积”贡献度最大、“房龄”次之这对业务决策非常友好的。BP网络虽然被CNN、Transformer等更复杂的架构所取代但在表格数据、小样本场景下依然有实用价值。结合SHAP你手写的模型也能输出高可解释性的业务结论。6.3 更深入的学习路径建议完成这个项目后正确的下一步不是急着搭CNN而是用PyTorch实现一个带有Dropout和BatchNorm的深层BP网络观察效果差异。在BP网络上动手实现梯度下降的各种变体带动量Momentum、RMSProp、Adam。把MNIST改成Fashion-MNIST从风格迁移角度观察模型适应情况。这几步走完你对神经网络的掌控力会比那些只会调包的人扎实得多。之后再进入CNN、RNN、Transformer就会发现一切架构创新都是在这套“权重 传播 梯度更新”的骨架上生长出来的。我个人在实际操作中的体会是BP神经网络的代码实现是深度学习的“站桩功”看似简单但扎不扎实直接决定后面的“花拳绣腿”能有多大威力。很多人一上来就冲着大模型去结果连过拟合、梯度消失都没真正见过遇到问题只能靠“试试运气”来调参那就是没打好基础。希望这份手写代码加数据集能让你踏踏实实迈出这一关。本文还有配套的精品资源点击获取
返回列表