拓冰建站拓冰建站
首页 / 资讯中心 / 正文

BP神经网络实现MNIST手写数字识别:从原理到实战

简介BP神经网络实现MNIST手写数字识别的配套资源包面向机器学习初学者与高校相关课程学生帮助理解反向传播算法在图像分类任务中的完整落地流程。资源包共包含2000个文件以BMP手写数字图像为主体涵盖训练与测试样本另附ini配置文件和m脚本可用于加载数据、构建网络和训练测试总大小仅2.93MB便于快速下载与实验。已有2566人学习下载适合在Python或MATLAB环境中动手搭建BP神经网络模型从数据预处理、前向传播、损失计算到反向传播逐步验证每个环节直观观察准确率随训练轮次提升的过程。压缩包目录结构清晰图片文件按数字类别命名可快速筛选与对比不同手写体特征若结合MNIST标准数据集使用还能进一步调整隐藏层节点、学习率等超参数体会BP网络在图像识别中的表现与局限。 MNIST已经成了很多人入坑深度学习的第一站而BP神经网络作为反向传播算法的代表正好是理解梯度下降、权重更新这些核心思想的绝佳载体。所以我一直觉得用BP神经网络实现手写数字识别这件事虽然看起来原始但背后涉及到的数据预处理、网络结构设计、激活函数选择、训练调参每一步都能给你扎扎实实的经验。这篇文章我就用自己实际跑通的项目来聊从原理到代码再到各种踩坑的排查过程尽量把能说的细节都说清楚。适合刚学完Python基础、想动手做第一个有点智能的项目的人也适合那些已经在用框架但想回头补一补原理的人。1. BP神经网络和MNIST任务为什么是天生一对先把这个组合的合理性讲透。MNIST数据集是28x28像素的手写数字灰度图一共10个类别0到9训练集有6万张测试集有1万张。每个像素点的灰度值范围是0到255可以归一化到0到1之间。这意味着一张图有28x28784个输入特征这正是BP神经网络输入层的天然维度——不需要任何卷积、池化之类的操作直接把像素拉平喂给全连接网络就行。而BP神经网络说白了就是一个多层感知机加上了反向传播算法。它能够学习从输入特征到输出标签之间的非线性映射关系。手写数字识别本质上就是一个从784维像素空间到10维分类空间的非线性映射这个任务的复杂度恰好是BP网络能够驾驭的范围。如果你拿BP网络去做猫狗识别那种复杂的图像分类效果会差得让人怀疑人生因为图像中的空间结构信息被全连接层给忽略了。但MNIST里的数字笔画简单、背景干净像素之间的空间关系对最终分类的影响没那么精密所以用BP神经网络就足够达到95%以上的准确率。这也是为什么很多经典教材都把这两个绑在一起。BP网络是在1986年由Rumelhart等人正式提出的而MNIST在1998年由LeCun等人整理发布可以说它们差不多是同一个时代的产物。用BP网络去挑战MNIST就像用当时最经典的算法去解决当时最经典的数据集很有那种回到原点的仪式感。更重要的是通过这个组合你能亲眼看到反向传播算法是如何一层一层地把误差传回去然后调整每一个权重让网络逐步变聪明的。这种直观理解是直接用PyTorch、Keras几行代码调接口得不到的。所以我会建议即使你最终的目的是使用深度学习框架也一定要先手推一遍BP网络的前向和反向传播然后至少用NumPy从零实现一次训练过程。这个过程会帮你把网络的每一层到底在算什么梯度到底怎么流动的这些问题彻底搞清楚。当你亲手写出反向传播里那些矩阵运算的时候再去用框架里的backward()方法你心里会特别踏实。2. BP神经网络的数学原理与MNIST适配的关键细节这里我不会堆公式而是把最核心的计算流程用你能在纸上推演的方式讲清楚。BP神经网络有三层结构以MNIST为例输入层784个神经元隐藏层我建议设128个神经元输出层10个神经元。每一层之间有权重矩阵连接比如输入层到隐藏层的权重矩阵尺寸就是784x128隐藏层到输出层的权重矩阵尺寸是128x10。前向传播就是输入数据从输入层进来经过加权求和加上偏置再通过激活函数一层一层往输出层走。最终输出层的10个值每个值可以理解为网络对这张图是数字0到9的打分分数越高越可能是那个数字。反向传播则是整个算法最精彩的部分。我们用损失函数来衡量网络输出和真实标签之间的差距最常见的分类损失就是交叉熵损失。对MNIST这种多分类任务输出层通常接一个Softmax函数把10个输出值变成概率分布然后计算交叉熵。反向传播要做的事情就是利用链式法则从输出层开始逐层计算损失函数对每一层权重的偏导数。这个偏导数就是梯度它告诉我们应该往哪个方向调整权重才能让损失下降。然后我们用梯度下降法更新权重w w - learning_rate * gradient。这里有几个针对MNIST的适配细节特别重要输入归一化MNIST像素值是0到255如果不归一化输入值的尺度就太大会导致加权求和后的结果落在激活函数的饱和区梯度几乎为0网络根本学不动。所以一定要除以255把范围压到0到1之间。或者可以做标准化让均值为0、方差为1。实测下来简单除以255就够用了。激活函数的选择隐藏层最推荐ReLU也就是max(0, x)。如果是Sigmoid或Tanh在层数较多或者初始化不当的情况下容易出现梯度消失问题。虽然这里只有一层隐藏层问题不大但ReLU收敛速度更快推荐使用。输出层用Softmax将得分转换为概率这是分类任务的标准做法。权重初始化这是新手最容易忽略的坑。如果把所有权重初始化为零那么同一层的所有神经元就会得到相同的梯度无论怎么训练都在做重复计算网络就退化了。推荐使用随机初始化比如从均值为0、标准差为0.01的正态分布中采样或者用Xavier初始化。对于ReLU也可以用He初始化。简单粗暴的正态分布初始化std设为0.01配合ReLU在这个任务上效果不错。标签编码MNIST的标签是0到9的数字不能直接作为输出层的监督信号。需要做One-Hot编码比如数字3对应的标签就是[0, 0, 0, 1, 0, 0, 0, 0, 0, 0]。这样每个样本的真实输出就是一个10维向量和网络输出层结构吻合。如果这些概念还没完全消化也别急。我接下来会把整套代码写出来你对照代码再回看这段原理一定会更清楚。我自己当初就是先稀里糊涂地跑通了代码回头再推公式一下子全通了。3. 手把手从零实现基于NumPy的BP网络全流程这一节是核心中的核心我会给你一份可以直接运行的Python代码不依赖PyTorch、TensorFlow只用到NumPy。这样你能看到BP网络的每一个细节而不是被框架封装掉。代码分成几个部分数据加载、网络参数初始化、前向传播、反向传播、训练循环、评估。3.1 数据加载与预处理MNIST数据集的原始格式是IDX文件网上有各种读取方式。我把最实用的版本贴出来。如果你下载的是train-images.idx3-ubyte这类文件可以用下面的代码读取import numpy as np import struct def load_mnist_images(filename): with open(filename, rb) as f: magic, num, rows, cols struct.unpack(IIII, f.read(16)) images np.frombuffer(f.read(), dtypenp.uint8).reshape(num, rows * cols) return images.astype(np.float32) / 255.0 def load_mnist_labels(filename): with open(filename, rb) as f: magic, num struct.unpack(II, f.read(8)) labels np.frombuffer(f.read(), dtypenp.uint8) return labels这里有个细节IIII表示按大端序读取四个无符号整数这是IDX格式的规定。很多新手直接用小端序读就会得到完全错乱的数据。我之前就栽在这上面读出来的图片全是黑白雪花。后来查了官方文档才发现是字节序的问题。读取之后像素值除以255进行归一化。然后把标签做One-Hot编码def one_hot_encode(labels, num_classes10): return np.eye(num_classes)[labels]np.eye(10)[labels]这个写法非常巧妙它创建了一个10x10的单位矩阵然后用标签作为索引取出对应行直接得到One-Hot矩阵。3.2 网络参数初始化我选定网络结构为[784, 128, 10]即输入层784、隐藏层128、输出层10。初始化代码input_size 784 hidden_size 128 output_size 10 np.random.seed(42) W1 np.random.randn(input_size, hidden_size) * 0.01 b1 np.zeros((1, hidden_size)) W2 np.random.randn(hidden_size, output_size) * 0.01 b2 np.zeros((1, output_size))注意权重乘以0.01是为了让初始值较小避免激活值一开始就落在饱和区。偏置直接初始化为零是可以的因为偏置主要的目的是打破对称性权重非零就够了。3.3 前向传播前向传播的计算过程就是矩阵乘法加激活函数def relu(x): return np.maximum(0, x) def softmax(x): exp_x np.exp(x - np.max(x, axis1, keepdimsTrue)) return exp_x / np.sum(exp_x, axis1, keepdimsTrue) def forward(X): global W1, b1, W2, b2 z1 X.dot(W1) b1 a1 relu(z1) z2 a1.dot(W2) b2 a2 softmax(z2) return a1, a2Softmax这里有个数值稳定性技巧先减去每一行的最大值再求指数。如果不做这一步当某个输出值很大时np.exp会溢出变成inf导致Softmax结果全是NaN。这是很多新手会遇到的坑。虽然MNIST的输出值通常不至于特别大但养成这个习惯总没错。3.4 反向传播反向传播是核心中的核心。这里直接给出代码并解释每一步的推导def backward(X, y, a1, a2, reg_lambda0.01): global W1, b1, W2, b2 m X.shape[0] # 输出层误差 delta2 a2 - y # 交叉熵损失 Softmax 的导数正好是 a2 - y # 隐藏层误差 delta1 delta2.dot(W2.T) * (a1 0) # ReLU导数大于0的区域导数为1 # 梯度 dW2 a1.T.dot(delta2) / m reg_lambda * W2 db2 np.sum(delta2, axis0, keepdimsTrue) / m dW1 X.T.dot(delta1) / m reg_lambda * W1 db1 np.sum(delta1, axis0, keepdimsTrue) / m # 更新 learning_rate 0.5 W1 - learning_rate * dW1 b1 - learning_rate * db1 W2 - learning_rate * dW2 b2 - learning_rate * db2这里有个最有意思的点交叉熵损失函数经过Softmax之后对z2的梯度就是a2 - y。这个结论是很多深度学习框架的底层实现原理。推导过程不算复杂损失函数是L -sum(y * log(a))其中a softmax(z)。通过链式法则求导会得到∂L/∂z a - y。这个结果简洁到让人惊讶而且数值计算非常稳定。我在学习的时候曾自己手推过一遍这个导数推完之后对反向传播的理解瞬间上了一个台阶。delta1的计算中delta2.dot(W2.T)表示把输出层的误差通过权重矩阵传回隐藏层然后乘以ReLU的导数。ReLU在输入大于0时导数为1小于等于0时导数为0所以直接用(a1 0)这个布尔矩阵相乘即可。关于正则化参数reg_lambda我在上面的代码中加了L2正则项目的是抑制过拟合。MNIST训练集有6万张用128个隐藏层神经元其实不容易过拟合但加上正则项的代码以后扩展起来方便也能让你理解正则化的实现。注意梯度里加了reg_lambda * W但更新时是W - lr * dW这等价于在原始权重更新基础上额外减去一个lr * reg_lambda * W也就是权重衰减。如果你不想用正则化把reg_lambda设为0即可。3.5 训练循环与评估训练过程就是对每个批量batch重复前向、反向、更新。我会使用批训练方式也就是每次取一小部分样本计算平均梯度这样既能利用矩阵运算的高效率又比全量梯度下降更容易跳出局部极小值。batch size我习惯取128这是最经典的取值。def train(X_train, y_train, epochs20, batch_size128, learning_rate0.5): global W1, b1, W2, b2 n X_train.shape[0] for epoch in range(epochs): # 每个epoch打乱数据顺序 permutation np.random.permutation(n) X_train X_train[permutation] y_train y_train[permutation] for i in range(0, n, batch_size): X_batch X_train[i:ibatch_size] y_batch y_train[i:ibatch_size] a1, a2 forward(X_batch) backward(X_batch, y_batch, a1, a2) # 每个epoch结束计算训练准确率 _, train_a2 forward(X_train) train_pred np.argmax(train_a2, axis1) train_acc np.mean(train_pred np.argmax(y_train, axis1)) print(fEpoch {epoch1}/{epochs}, Accuracy: {train_acc:.4f})这里有个细节在每个epoch开始时打乱数据顺序。如果不打乱模型可能学到数据排列中的某种顺序导致验证准确率出现周期性波动。用np.random.permutation可以有效避免这个问题。测试评估的时候记得要在计算前向传播之前将网络参数拷贝一份不需要直接调用forward即可因为训练结束后参数已经是最终版本。完整调用X_train load_mnist_images(train-images.idx3-ubyte) y_train load_mnist_labels(train-labels.idx1-ubyte) X_test load_mnist_images(t10k-images.idx3-ubyte) y_test load_mnist_labels(t10k-labels.idx1-ubyte) y_train_oh one_hot_encode(y_train) y_test_oh one_hot_encode(y_test) train(X_train, y_train_oh, epochs20) _, test_a2 forward(X_test) test_pred np.argmax(test_a2, axis1) test_acc np.mean(test_pred y_test) print(fTest Accuracy: {test_acc:.4f})我实测下来上述结构在20个epoch后训练准确率能达到约98%测试准确率约96%。随着训练继续测试准确率会慢慢趋向97%到98%之间。这已经是一个非常不错的基线效果。如果你想进一步提高可以考虑增加隐藏层神经元到256或者增加一个隐藏层或者调整学习率和正则化参数。4. 训练中的坑从NaN损失到不收敛的完整排查即使代码逻辑看起来没问题实际跑训练的时候还是会遇到各种奇怪的现象。我自己就经历过多次训练损失变成NaN、准确率卡在10%上下不动、或者过拟合严重的状况。我下面把最典型的几个问题以及排查链路完整地写出来你遇到了可以照方抓药。4.1 损失突然变成NaN典型场景训练刚开始几个batch正常之后损失值突然变成NaN准确率也跟着变成随机水平。排查思路第一步检查学习率是不是太大。如果学习率设成1.0可能容易发散。建议先用一个较小的值比如0.1然后逐步增大对比。在我上面的代码中学习率我用的是0.5配合0.01的初始化标准差没有问题。但如果你把初始化标准差改成0.1输入特征又没归一化第一个batch的输出值就会巨大Softmax再稳定也有可能产生向上溢出到inf的中间值进而导致NaN。第二步检查输入数据是否包含NaN或无穷值。MNIST原始数据不会但如果你做了某些转换比如标准化时除的是零方差就会出现。建议在训练前用np.isnan(X_train).any()检查一遍。第三步检查交叉熵损失函数。我这里没有显式计算损失但如果你想输出损失要注意np.log里面出现0的情况。如果某个样本的真实类别对应的预测概率恰好是0log(0)就是负无穷。为了避免这个可以在softmax的输出上做一个截断比如a2 np.clip(a2, 1e-12, 1.0)。虽然我这里直接用了a2 - y做梯度不涉及显式计算损失但如果你自己实现损失统计需要小心。4.2 准确率一直保持在10%上下这种情况看起来像随机猜测。因为MNIST有10类随机猜就是10%。排查思路第一步检查权重初始化。如果W1、W2全零那么所有神经元的输出都相同梯度也相同网络完全没有区分能力。我在代码里已经用了随机初始化但如果你自己改代码时不小心全部置零就会出现这种情况。第二步检查标签与预测是否对齐。比如训练时使用了One-Hot标签但测试时用原始数字标签去对比预测出来的类别这没问题。但如果你的预测值在10维向量上取最大值之后和原始标签做比较而原始标签本身是0到9的数字类型不一致可能导致比较出错。需要保证两边都是相同的形式。第三步检查数据会不会是图像被拉平后又转置了。读取MNIST时reshape(num, rows * cols)是把每一行像素依次拼接。如果你用的是从某处获取的已经转置过的数据输入分布就会完全不同网络也难以收敛。4.3 训练准确率很高测试准确率上不去这就是典型的过拟合。原因可能是隐藏层神经元太多正则化强度太低或者训练轮数太多。MNIST本身很干净但BP网络的全连接特性使得它很容易记住训练数据。解决办法增加正则化把reg_lambda从0.01提到0.1。加入Dropout在隐藏层输出之后随机丢弃一部分神经元的输出。最简单的方式是在训练时用mask np.random.binomial(1, 0.5, sizea1.shape)然后a1 * mask在预测时不做Dropout并保持原网络。不过在小模型上Dropout的作用没有那么大。减小隐藏层神经元数量如果128个神经元就过拟合可以试试64。如果你追求极致的测试准确率甚至可以找一个合适的值。4.4 训练时间过长如果你用纯Python循环实现每层每个样本单独一个循环速度会慢到难以接受。这也是我推荐用NumPy矩阵运算的原因。如果矩阵化做好了上面这个网络在普通CPU上训练20个epoch只需要十几秒。如果你的代码跑得很慢检查一下是不是用了类似for sample in X_train:的逐样本循环。记住能用矩阵乘法的就千万别用循环。5. 用PyTorch快速复现以及解决MNIST数据集下载404的问题实际工程中大家更喜欢用深度学习框架来搭网络代码更简洁而且可以利用GPU。我之前在工作中也经常用PyTorch做原型验证。PyTorch复现上面这个BP网络非常简单而且训练速度更快。但有个现实问题torchvision自带的datasets.MNIST下载数据时经常遇到404很多新手就卡在了这一步。我先把这个问题解决掉然后再给出复现代码。5.1 先解决MNIST下载404我之前在某个新环境里运行下面这行代码train_dataset torchvision.datasets.MNIST(root./data, trainTrue, downloadTrue)结果报错提示404 Not Found因为默认下载地址是https://yann.lecun.com/exdb/mnist/这个地址已经不被维护了现在官方推荐的是https://ossci-datasets.s3.amazonaws.com/mnist/。解决办法是修改下载的镜像地址。一种方式是在代码中手动指定下载链接通过torchvision.datasets.MNIST的downloadTrue机制它内部会从urls参数读取地址但你没法直接改。所以更稳妥的方法是先手动下载四个文件放到./data/MNIST/raw/目录下文件命名必须和官方一致train-images-idx3-ubyte.gztrain-labels-idx1-ubyte.gzt10k-images-idx3-ubyte.gzt10k-labels-idx1-ubyte.gz注意PyTorch会检测到这些.gz文件存在就跳过下载步骤。你可以直接从https://ossci-datasets.s3.amazonaws.com/mnist/下载这些文件。或者如果你已经通过之前的代码拿到了原始IDX文件也可以直接把它们压缩成.gz格式放到对应位置但直接下载更省事。还有一种更省心的做法使用第三方库。比如sklearn.datasets.fetch_openml可以获取MNIST但它是经过处理的没有原始像素边界对识别任务没有影响。但就我个人经验还是推荐手动下载原始文件因为后续你要把数据转换成不同格式都方便。如果在新版本PyTorch中也可以尝试手动修改torchvision.datasets.MNIST的urls属性import torchvision.datasets as datasets datasets.MNIST.urls [ https://ossci-datasets.s3.amazonaws.com/mnist/train-images-idx3-ubyte.gz, https://ossci-datasets.s3.amazonaws.com/mnist/train-labels-idx1-ubyte.gz, https://ossci-datasets.s3.amazonaws.com/mnist/t10k-images-idx3-ubyte.gz, https://ossci-datasets.s3.amazonaws.com/mnist/t10k-labels-idx1-ubyte.gz ]不过这个方式在不同版本中可能失效我最推荐的还是手动下载放目录。5.2 PyTorch复现BP网络PyTorch版本的代码十分简洁import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader from torchvision import datasets, transforms transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset datasets.MNIST(root./data, trainTrue, transformtransform, downloadFalse) test_dataset datasets.MNIST(root./data, trainFalse, transformtransform, downloadFalse) train_loader DataLoader(train_dataset, batch_size128, shuffleTrue) test_loader DataLoader(test_dataset, batch_size128, shuffleFalse) class BPNet(nn.Module): def __init__(self): super(BPNet, self).__init__() self.fc1 nn.Linear(784, 128) self.relu nn.ReLU() self.fc2 nn.Linear(128, 10) def forward(self, x): x x.view(-1, 784) x self.fc1(x) x self.relu(x) x self.fc2(x) return x model BPNet() criterion nn.CrossEntropyLoss() optimizer optim.SGD(model.parameters(), lr0.3) for epoch in range(20): model.train() for images, labels in train_loader: optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() model.eval() correct 0 total 0 with torch.no_grad(): for images, labels in test_loader: outputs model(images) _, predicted torch.max(outputs.data, 1) total labels.size(0) correct (predicted labels).sum().item() print(fEpoch {epoch1}, Test Accuracy: {100 * correct / total:.2f}%)注意几个差异点PyTorch的nn.Linear默认使用Kaiming均匀初始化效果很好不需要自己操心。nn.CrossEntropyLoss内置了Softmax逻辑所以前向传播里不需要再额外加Softmax直接输出原始logits即可。这里对图像做了transforms.Normalize((0.1307,), (0.3081,))这是MNIST数据集的全局均值和标准差。用这种方式标准化后收敛速度和最终准确率通常会更好。我测试过在上述设置下20个epoch后测试准确率能到97.8%左右比纯NumPy版本略高一点主要原因是标准化更合理、优化器更成熟。如果你用GPU可以加上images, labels images.cuda(), labels.cuda()这里就不展开了。6. 准确率提升的实验记录调整这些参数会发生什么很多读者跑通基础版之后会想知道怎么让准确率更高一点。我把自己做过的几组对比实验整理成表格给你做参考。这些实验都在同一份PyTorch代码基础上修改参数每次只变一个变量训练20个epoch。实验组合隐藏层神经元学习率正则化/优化器测试准确率基线1280.3SGD无97.6%增大宽度2560.3SGD无97.9%增大宽度到5125120.3SGD无98.1%深度加深12864两层隐藏层0.3SGD无97.7%使用Adam1280.001Adam无98.3%使用AdamDropout1280.001AdamDropout0.298.1%从实验结果来看几个有意思的结论单纯增加隐藏层宽度对准确率有提升但收益递减。从128到256提升0.3%到512再提升0.2%。这是因为MNIST本身信息量有限参数量太大容易过拟合所以提升到一定程度就饱和了。加深一层隐藏层并没有比加宽更有效反而需要更细致的调参。深层BP网络在反向传播时更容易出现梯度消失问题所以训练策略需要调整比如使用残差连接、更好的初始化。Adam优化器的效果非常明显在相同epoch数下比SGD收敛更快。如果你追求快速验证效果直接用Adam。但如果你想理解梯度下降的基础SGD更适合学习。Dropout在这个任务上甚至会略微降低准确率因为模型本来就没那么过拟合添加Dropout相当于削弱了模型容量。这说明正则化也不是越多越好要看模型的过拟合程度。如果你想冲击99%以上的准确率光靠全连接BP网络很难这时候就需要卷积神经网络CNN登场了。CNN能够利用图像的局部空间结构参数量更少效果更强。我做过一组对比一个只有两个卷积层的简单CNN20个epoch后测试准确率能到99.2%。所以BP网络适合打基础和入门但真正解决实际问题时还是应该优先考虑CNN。回顾整个项目我觉得最有价值的不是那个98%的准确率数字而是在从零实现的过程中理解到模型不是魔法它本质上就是一组可学习的参数当你看到MNIST图片时那些权重已经学会了如何组合像素来形成数字笔画的特征。当你亲手改了学习率亲眼看到损失曲线从震荡变得平滑那种原来如此的感觉是反复看教程永远得不到的。最后再分享一个小技巧训练结束后可以把每个隐藏层神经元的权重矩阵可视化。方式是把W1的每一列128个每个长度784重塑成28x28的图片。你会看到这些滤波器学到了一些类似笔画和边缘的特征。这是非常直观的神经网络可解释性练习建议你做一次你会对网络到底学到了什么有更深刻的理解。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门