简介: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之间。这意味着一张图有28x28=784个输入特征,这正是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(), dtype=np.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(), dtype=np.uint8) return labels这里有个细节:>IIII表示按大端序读取四个无符号整数,这是IDX格式的规定。很多新手直接用小端序<读,就会得到完全错乱的数据。我之前就栽在这上面,读出来的图片全是黑白雪花。后来查了官方文档才发现是字节序的问题。
读取之后,像素值除以255进行归一化。然后把标签做One-Hot编码:
def one_hot_encode(labels, num_classes=10): 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, axis=1, keepdims=True)) return exp_x / np.sum(exp_x, axis=1, keepdims=True) 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_lambda=0.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, axis=0, keepdims=True) / m dW1 = X.T.dot(delta1) / m + reg_lambda * W1 db1 = np.sum(delta1, axis=0, keepdims=True) / 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, epochs=20, batch_size=128, learning_rate=0.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:i+batch_size] y_batch = y_train[i:i+batch_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, axis=1) train_acc = np.mean(train_pred == np.argmax(y_train, axis=1)) print(f"Epoch {epoch+1}/{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, epochs=20) _, test_a2 = forward(X_test) test_pred = np.argmax(test_a2, axis=1) test_acc = np.mean(test_pred == y_test) print(f"Test 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的情况。如果某个样本的真实类别对应的预测概率恰好是0,log(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, size=a1.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', train=True, download=True)结果报错,提示404 Not Found,因为默认下载地址是https://yann.lecun.com/exdb/mnist/,这个地址已经不被维护了,现在官方推荐的是https://ossci-datasets.s3.amazonaws.com/mnist/。解决办法是修改下载的镜像地址。一种方式是在代码中手动指定下载链接,通过torchvision.datasets.MNIST的download=True机制,它内部会从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', train=True, transform=transform, download=False) test_dataset = datasets.MNIST(root='./data', train=False, transform=transform, download=False) train_loader = DataLoader(train_dataset, batch_size=128, shuffle=True) test_loader = DataLoader(test_dataset, batch_size=128, shuffle=False) 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(), lr=0.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(f'Epoch {epoch+1}, 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。
| 实验组合 | 隐藏层神经元 | 学习率 | 正则化/优化器 | 测试准确率 |
|---|---|---|---|---|
| 基线 | 128 | 0.3(SGD) | 无 | 97.6% |
| 增大宽度 | 256 | 0.3(SGD) | 无 | 97.9% |
| 增大宽度到512 | 512 | 0.3(SGD) | 无 | 98.1% |
| 深度加深 | 128+64(两层隐藏层) | 0.3(SGD) | 无 | 97.7% |
| 使用Adam | 128 | 0.001(Adam) | 无 | 98.3% |
| 使用Adam+Dropout | 128 | 0.001(Adam) | Dropout=0.2 | 98.1% |
从实验结果来看,几个有意思的结论:
- 单纯增加隐藏层宽度对准确率有提升,但收益递减。从128到256提升0.3%,到512再提升0.2%。这是因为MNIST本身信息量有限,参数量太大容易过拟合,所以提升到一定程度就饱和了。
- 加深一层隐藏层并没有比加宽更有效,反而需要更细致的调参。深层BP网络在反向传播时更容易出现梯度消失问题,所以训练策略需要调整,比如使用残差连接、更好的初始化。
- Adam优化器的效果非常明显,在相同epoch数下比SGD收敛更快。如果你追求快速验证效果,直接用Adam。但如果你想理解梯度下降的基础,SGD更适合学习。
- Dropout在这个任务上甚至会略微降低准确率,因为模型本来就没那么过拟合,添加Dropout相当于削弱了模型容量。这说明正则化也不是越多越好,要看模型的过拟合程度。
如果你想冲击99%以上的准确率,光靠全连接BP网络很难,这时候就需要卷积神经网络(CNN)登场了。CNN能够利用图像的局部空间结构,参数量更少,效果更强。我做过一组对比:一个只有两个卷积层的简单CNN,20个epoch后测试准确率能到99.2%。所以BP网络适合打基础和入门,但真正解决实际问题时,还是应该优先考虑CNN。
回顾整个项目,我觉得最有价值的不是那个"98%"的准确率数字,而是在从零实现的过程中理解到:模型不是魔法,它本质上就是一组可学习的参数,当你看到MNIST图片时,那些权重已经学会了如何组合像素来形成数字笔画的特征。当你亲手改了学习率,亲眼看到损失曲线从震荡变得平滑,那种"原来如此"的感觉,是反复看教程永远得不到的。
最后再分享一个小技巧:训练结束后,可以把每个隐藏层神经元的权重矩阵可视化。方式是把W1的每一列(128个,每个长度784)重塑成28x28的图片。你会看到这些"滤波器"学到了一些类似笔画和边缘的特征。这是非常直观的神经网络可解释性练习,建议你做一次,你会对"网络到底学到了什么"有更深刻的理解。
本文还有配套的精品资源,点击获取