MNIST手写识别源码拆解:NumPy手写CNN,彻底搞懂深度学习底层

发布时间:2026/10/12 22:26:42
MNIST手写识别源码拆解:NumPy手写CNN,彻底搞懂深度学习底层
简介这是基于Python深度学习实现MNIST手写数字识别的完整源码与数据包面向计算机、电子信息工程、数学等专业的学生适合用作课程设计、期末大作业或毕业设计的参考资料要求读者具备一定Python与深度学习基础。压缩包共18个文件约19.77MB主要包含5个Python源码文件覆盖卷积网络构建、层定义、激活函数、数据加载及主训练流程另有3个JSON文件用于VSCode任务与调试配置以及MNIST原始图像与标签数据idx格式和预处理后的pkl文件可帮助理解数据准备与模型调用过程。已有511人学习下载对需要快速启动手写数字识别项目或在此基础上做改进的读者具有实用价值目录结构清晰便于按模块查看源码、配置和数据集从而更高效地完成课程设计、期末大作业或毕业设计中的相关实验同时也可作为进一步优化模型结构的起点。1. 这份 MNIST 手写数字识别源码把深度学习黑匣子打开来学做深度学习入门MNIST 手写数字识别几乎是所有人都绕不开的 hello world。但绝大多数教程是用 PyTorch 或 TensorFlow 几行代码调完接口训练完看个准确率就结束了——底层怎么算的、梯度怎么传的全被框架包住了。这份基于 Python 的 MNIST 源码价值在于它绕开深度学习框架用 NumPy 从零实现了一个卷积神经网络CNN源码里包含了完整的训练流程、图层定义、激活函数和损失函数连数据文件都给你打包好了。你能看到每一个矩阵乘法、每一次反向传播、每一层卷积池化的真实形状变化。适合的人群很明确计算机、电子信息工程、数学等专业做课程设计或期末大作业的学生以及那些已经把 PyTorch 用熟、但总觉得底层是个黑匣子的开发者。这个资源不是让你直接拿来交差而是让你跟着代码把 CNN 的每一个环节走一遍踩过坑之后才算真正入门了深度学习。2. 文件清单读出设计意图一套完整的 NumPy 手写神经网络2.1 数据文件与源码文件是怎么分组的打开压缩包能看到一组规整的文件结构。数据侧有四对 UBYTE 二进制文件train-images-idx3-ubyte、train-labels-idx1-ubyte 对应训练集t10k-images-idx3-ubyte、t10k-labels-idx1-ubyte 对应测试集。注意文件名里既有连字符也有小数点两种写法说明原工程做过两种命名兼容代码里读数据时最好两种都判断。代码侧分得更加清楚mnist.py 负责加载数据并生成 mnist.pkl 缓存文件layers.py 实现全连接层、卷积层、池化层、Softmax 等基础模块ConvNet.py 把层组装成网络并实现梯度计算functions.py 提供激活函数和损失函数main.py 是训练入口。这种按层拆文件的风格和《深度学习入门基于 Python 的理论与实现》那套鱼书代码如出一辙每个文件都是单一职责调试时只需要盯住出错的那一层。我把这套结构的基本关系整理成下面的表格后续章节会逐文件拆开讲文件职责关键内容mnist.py数据加载与缓存解析 UBYTE 二进制、生成 pkl、归一化functions.py激活与损失sigmoid、relu、softmax、交叉熵layers.py基础层实现Affine、Relu、Convolution、PoolingConvNet.py网络组装SimpleConvNet 类、forward 与 backwardmain.py训练与评估参数设置、随机梯度下降、准确率输出.vscode 配置调试环境settings/tasks/launch 三件套2.2 从pycache能看出的 Python 环境信息压缩包里还有一组 cpython-37.pyc 后缀的编译文件说明原工程是在 Python 3.7 环境下跑通的。这个信息很重要因为后面涉及 pickle 缓存文件mnist.pkl时Python 3.7 生成的 pkl 在高版本 Python 上可能读取异常一踩一个准。如果你本机是 Python 3.9 以上第一件事就是删除旧的pycache和 mnist.pkl让代码重新生成。.vscode 目录里的 settings.json、tasks.json、launch.json 是 VSCode 调试配置三件套说明作者是用 VSCode 的 F5 调试功能跑的。launch.json 里通常会指向 main.py 作为当前调试目标tasks.json 可能配置了前置任务。我一般会把 launch.json 里的 console 改回 integratedTerminal这样训练的 print 输出不会乱码。2.3 为什么用二进制数据文件而不是图片文件夹这套资源里 MNIST 数据是标准 UBYTE 格式不是一张张 PNG 图片。很多第一次接触的人会有疑问为什么官网给的数据不是图片而是二进制文件原因很简单原始 MNIST 数据集每个样本是 28×28 的灰度像素作为图片存储会产生海量小文件读写效率极低。UBYTE 格式把 60000 张训练图全部平铺成字节流每张图固定 784 个字节读取时只需要按偏移量切片就能还原出完整的图片张量。这个设计思路在后面用的每个深度学习数据集里都能看到理解它你才能看懂为什么 mnist.py 要做那一堆文件指针定位的操作。3. MNIST 数据装载与预处理从 UBYTE 原始字节到可训练的张量3.1 先搞懂 UBYTE 文件里到底是什么MNIST 官网下载的数据实质是 IDX 文件格式头部有三个整数魔数定义格式和数量然后才是像素或标签数据。train-images-idx3-ubyte 的开头四个字节是魔数 2051接着是样本数量、行数、列数train-labels-idx1-ubyte 的魔数是 2049后面只跟样本数量。每个像素值范围是 0 到 2550 代表白色背景255 代表黑色笔迹。理解了这个文件格式mnist.py 的解析逻辑就完全明朗了。我拆过不少 MNIST 相关的源码绝大多数会借助 struct 模块的 unpack 来读取头部再通过 frombuffer 批量转换像素字节而不是用循环逐个读——后者速度慢得离谱。常见做法是像下面这样import struct import numpy as np def load_mnist_images(file_path): 读取 UBYTE 格式的 MNIST 图片数据 with open(file_path, rb) as f: data f.read() # 读取头部魔数(4字节)、样本数(4字节)、行数(4字节)、列数(4字节) magic, num, rows, cols struct.unpack(IIII, data[:16]) print(f魔数: {magic}, 样本数: {num}, 图片尺寸: {rows}x{cols}) # 从第16字节开始是像素数据每张图 784 字节 images np.frombuffer(data, dtypenp.uint8, offset16) images images.reshape(num, rows * cols) return images这段代码先把整个文件读进内存再用 struct 解析文件头。offset16 是跳过四个无符号整数的头部长度np.frombuffer 用数组视图方式读取原始字节速度比逐 byte 处理快一个数量级最后的 reshape 把每张 28×28 的图展平成 784 维向量方便喂给全连接层。如果你要复用这套代码处理其他 IDX 格式数据集只需要改一下头部解析的字段数。3.2 训练集与测试集的归一化处理像素值 0 到 255 直接作为网络输入不是不行但数值范围过大会让梯度传播不稳定。mnist.py 里通常会做归一化把每个像素除以 255缩放到 0 到 1 区间同时把标签从整数转成 one-hot 编码。归一化为什么重要因为后面要用交叉熵损失函数它内部会算 Softmax输入数值大时 Softmax 的指数运算容易溢出或产生饱和梯度。把数据压到 0 到 1 后训练收敛速度会明显变快。我见过不少人在复现时跳过归一化准确率照样能跑到 95%但是训练曲线的震荡幅度会大得多而且调学习率时特别容易翻车。不要图省事跳过这一步。归一化和 one-hot 的实现方式如下def normalize_data(images, labels, num_classes10): # 像素归一化从 [0, 255] 压到 [0, 1] images images.astype(np.float32) / 255.0 # one-hot 编码标签 5 - [0,0,0,0,0,1,0,0,0,0] one_hot np.eye(num_classes)[labels] # 随机打乱避免同类样本扎堆影响训练 perm np.random.permutation(len(images)) return images[perm], one_hot[perm]np.eye 生成单位矩阵用标签数值做索引取一行就是标准的 one-hot 转换。permutation 打乱是关键细节——如果样本顺序按数字类别排列网络在一个 batch 里看到的全是同一类数字梯度估计会严重偏移。很多复现翻车都是因为漏了这一步。3.3 mnist.pkl 缓存到底要不要手动删mnist.py 的加载逻辑通常是第一次运行读 UBYTE 原始文件解析完序列化成 pickle 缓存到 mnist.pkl之后每次运行直接从 pkl 里读省去重复解析的时间。这个设计本身没问题但 pickle 是 Python 版本强相关的格式我在 Python 3.10 环境在读 Python 3.7 生成的 pkl 时遇到过反序列化报错。解决办法很简单在项目目录下把 mnist.pkl 和pycache全删除让代码重新走一遍完整解析流程重新生成匹配当前版本的缓存。这也是我看到压缩包里有旧 pyc 文件时第一个会做的事。删除缓存不是魔改代码是让工程适配新环境的常规操作。4. 网络实现与训练链路从 ConvNet.py 到 main.py 的每一层都做了什么4.1 卷积层 池化层 全连接层的参数量对比mnist 这个任务用全连接网络就能跑到 97% 左右但这份源码选择了卷积神经网络目标是把准确率推到 99% 级别。ConvNet.py 里的 SimpleConvNet 结构大致是卷积层 → ReLU → 池化层 → 全连接层 → Softmax。卷积层用 3×3 或 5×5 的滤波器提取局部特征池化层做下采样降维全连接层将提取到的特征映射到 10 个类别。为什么卷积网络在这个任务上比全连接网络强关键在于参数共享和局部感受野。一张 28×28 的图直接展平成 784 维向量全连接层每个神经元都连接所有像素一张图光第一层就可能上万个参数而卷积层用一个小滤波器滑遍全图同一组权重被所有位置共享参数量骤减。MNIST 数字识别的核心特征——边、角、弧线——都是局部模式卷积层天然匹配这类任务。我给这个网络算过一笔参数量账写出来对比更直观层类型输入形状输出形状参数量约卷积层一1×28×2830×24×2430×3×330300池化层一30×24×2430×12×120卷积层二30×12×1250×8×850×30×3×35013550池化层二50×8×850×4×40全连接层800100800×10010080100输出层10010100×10101010全连接层占了总参数的大头卷积和池化只是把高维像素压缩成低维特征表示。理解这一点你调网络结构时就有方向感如果训练过拟合优先看全连接层的神经元数量如果是欠拟合优先加深卷积层的滤波器数量。pooling 层没有参数它就是做一个固定窗口的取最大或均值操作。4.2 forward 与 backward为什么手写网络必须同时实现两条通路深度学习框架里的 autograd 帮我们自动算了反向传播但这份源码没有框架可用ConvNet.py 和 layers.py 里每个层必须手写 forward正向传播和 backward反向传播两个方法。forward 负责根据输入计算输出backward 负责根据输出梯度计算输入梯度和参数梯度。class Convolution: def __init__(self, W, b, stride1, pad0): self.W W # 滤波器形状 (FN, C, FH, FW) self.b b # 偏置形状 (FN,) self.stride stride self.pad pad # 中间变量用于反向传播 self.x None self.col None self.col_W None def forward(self, x): FN, C, FH, FW self.W.shape N, C, H, W x.shape out_h 1 (H 2 * self.pad - FH) // self.stride out_w 1 (W 2 * self.pad - FW) // self.stride # im2col把输入展开成矩阵卷积变成矩阵乘法 col im2col(x, FH, FW, self.stride, self.pad) col_W self.W.reshape(FN, -1).T out np.dot(col, col_W) self.b out out.reshape(N, out_h, out_w, FN).transpose(0, 3, 1, 2) self.x, self.col, self.col_W x, col, col_W return out这段代码体现了手写卷积的核心技巧im2col。它把输入图片按滤波器窗口拆成若干列向量将卷积运算转换成一次矩阵乘法。这样 forward 走的不是两层循环而是向量化的 GEMM速度提升非常明显。out_h 和 out_w 的计算是卷积输出的尺寸公式1 (H 2×pad - FH) // stride所有 CNN 结构设计都要套这个公式建议背下来。输入图片四周补 pad 个零能保持边缘像素也被滤波器覆盖到。反向传播的代码在 layers.py 里对应的 backward 方法说句实在话手写反向传播最容易出错的就是维度对齐。正向传播中 col 和 col_W 的矩阵乘法反向传播要分别把梯度投影回 col 和 col_W任何一个维度写错都会在运行时报矩阵乘法 shape 不匹配排查起来极其折磨。4.3 main.py 的训练主循环与超参数选择main.py 是整个工程的入口承担训练循环、损失计算、梯度更新和准确率评估。网络用的是随机梯度下降SGD做参数更新代码逻辑大致是每个 epoch 把训练集切分成若干 batch每个 batch 前向传播算损失再反向传播算梯度最后更新参数。import numpy as np from mnist import load_mnist from ConvNet import SimpleConvNet # 训练超参数 learning_rate 0.1 iters_num 10000 batch_size 100 evaluate_interval 500 # 加载数据normalizeTrue 做归一化one_hot_labelTrue 做 one-hot 编码 (x_train, t_train), (x_test, t_test) load_mnist( normalizeTrue, one_hot_labelTrue, flattenFalse ) # flattenFalse 保留 1×28×28 的通道形状卷积层需要这种三维输入 network SimpleConvNet( input_dim(1, 28, 28), conv_param{filter_num: 30, filter_size: 5, pad: 0, stride: 1}, hidden_size100, output_size10 ) train_size x_train.shape[0] batch_mask np.random.choice(train_size, batch_size) x_batch x_train[batch_mask] t_batch t_train[batch_mask] grads network.gradient(x_batch, t_batch) for key in (W1, b1, W2, b2): network.params[key] - learning_rate * grads[key]learning_rate0.1 在 CNN 上看起来偏大但配合这个浅层小网络和较小数据集0.1 反而收敛很快。batch_size100 是权衡显存和梯度噪声后的折中值太小梯度震荡太大收敛变慢。iters_num 是总迭代次数而非 epoch 数1 万次迭代对应大约 16 个 epoch。每次迭代随机抽取 100 个样本的 batch_mask 是关键——随机梯度下降的随机就在这里体现。gradient 方法内部会依次调用各层的 backward把最后一层的梯度逐层传回去得到所有参数的梯度字典。4.4 functions.py 中激活函数与损失函数为什么这样选functions.py 里提供 sigmoid、relu、softmax、交叉熵等基础函数。注意 ConvNet.py 的隐藏层用的是 ReLU而输出层用 Softmax这个搭配不是随意定的。ReLU 在正区间梯度恒为 1能有效缓解深层网络的梯度弥散问题而 sigmoid 的梯度最大只有 0.25层数一多梯度连乘下去就消失了。输出层的 Softmax 把网络输出转换成概率分布配合交叉熵损失函数使用。交叉熵损失比均方误差好在哪交叉熵配合 Softmax 时损失函数对最后一层输入的梯度形式非常简洁——恰好等于网络输出的概率减去真实标签的 one-hot 向量这让输出层的误差信号直接而强烈不会像均方误差那样在概率接近 0 或 1 时梯度趋于 0。手写实现时交叉熵的公式也不再是从定义出发算 log 再取负而是直接用这个差值当梯度省去中间一步。5. 避坑与排查复现这套 MNIST 源码时的常见问题清单5.1 torchvision 下载 MNIST 返回 404本地数据路径配不对现象很多人习惯用 torchvision.datasets.MNIST 加载数据现在跑代码时下载 URL 返回 404或者下载完加载时报文件格式错误。原因MNIST 官方原始数据地址在 2023 年后做了变更旧版 torchvision 内置的下载链接已经失效也有情况是下载到一半断点续传生成了不完整的 gz 文件。解决这套资源已经把数据打包在压缩包里了直接解压后把 train-images-idx3-ubyte 等四个文件放到代码能找到的路径mnist.py 会优先检查本地文件再尝试下载。用我自己的工程习惯我会建一个 data/ 目录专门放原始数据集代码里用绝对路径读取避免在 VSCode 里改了工作目录后找不到文件。如果一定要用 torchvision 下载需要手动指定新的下载地址但这份源码根本不需要那样做。5.2 mnist.pkl 反序列化报错unpickling 失败现象代码跑到 load_mnist 时报 EOFError 或者 UnicodeDecodeError死活读不出来缓存文件。原因前面提过mnist.pkl 是 Python 3.7 下用 pickle 生成的二进制缓存换到高版本 Python 反序列化可能不兼容另一个常见原因是 pkl 文件本身损坏下载资源时被截断。解决直接手动删除 mnist.pkl 文件重新运行 main.py 让它从 UBYTE 原始文件重新生成。如果删除后还是报错检查一下四个 UBYTE 文件是否完整——用 2.1 节里那段结构解析代码打印魔数和样本数对比是否等于 60000 和 10000。我通常会做一个文件大小校验train-images 应该是 47040016 字节左右差太多基本就是文件损坏。5.3 VSCode 调试启动后提示找不到 main.py 或模块导入失败现象按 F5 进入调试launch.json 报文件不存在或者运行到 import mnist 时说 no module named mnist。原因VSCode 的调试工作目录cwd默认是 ${workspaceFolder}如果当前打开的是外层文件夹而源码在子目录Python 解释器找不到模块路径launch.json 里的程序入口配置也可能指向了错误的文件名。解决打开源码所在的那一层目录作为 VSCode 根目录让 mnist.py、main.py 等文件直接在根目录下launch.json 的 program 字段设置为 ${workspaceFolder}/main.pycwd 设置为 ${workspaceFolder} 即可。如果还报导入失败在 settings.json 里给 python.analysis.extraPaths 加上项目根目录这是 VSCode 的 Python 插件做静态分析时用的路径经常被漏掉。5.4 卷积层输出与全连接层输入维度对不上现象正向传播时在 ConvNet.py 的 affine 层报错dot 运算的维度不一致或者 reshape 时的总元素数与矩阵行数不匹配。原因池化层输出的特征图展平后的维度跟全连接层权重 W2 的输入维度不一致。卷积层和池化层会改变空间尺寸如果手工改了滤波器大小或步长池化输出尺寸跟着变但全连接层的权重初始化还在用旧的维度。解决先手动推导一遍维度变化——输入 1×28×28经过 filter_size5、stride1、pad0 的卷积输出是 (28-5)/1124所以是 30×24×24池化窗口 2×2 后变成 30×12×12再卷积得到 50×8×8池化后 50×4×4展平是 800 维。全连接层的输入维度必须等于 800。我在改网络结构之前会先写一行代码打印每层输出的 shape等全部对上再去动手改参数这比报错后猜哪里不匹配高效得多。5.5 训练准确率震荡剧烈或收敛到 90% 就停滞现象损失值反复上下跳动训练了几千次迭代后准确率还在 90% 附近打转上不了 99%。原因最常见的是学习率过大导致参数在最优解附近来回震荡另一个是 batch_size 太小梯度估计噪声太大。900% 卡住则往往是因为网络容量不够30 个滤波器对复杂数字变体的表达能力到了极限。解决把 learning_rate 从 0.1 降到 0.01 或 0.001 试试这是见效最快的操作。如果收敛变慢但稳定再增大 iters_num 或适当提高 batch_size。对于卡在 90% 的情况把第一层卷积滤波器从 30 加到 50训练时间会有一定增加但分类上限会提高。调整策略是先动一个变量跑一遍有效果再动下一个不要同时改三个超参数否则出了效果也说不清是谁的功劳。提示每次修改超参数前先把这次的准确率记录到实验笔记里否则调了三轮后你会彻底忘记最初那组参数是什么。我在这里吃过亏。6. 进阶验证技巧不看日志也能知道模型学得好不好训练代码里每次迭代会打印 loss每隔若干次迭代会打印一次测试集准确率。但我个人经验是只看这两个数字依然不够——loss 降到 0.1 也可能是过拟合到训练集上的假象准确率 99% 也可能只是对绝大多数简单样本有效。想要知道模型是不是真的学到了数字的结构特征需要可视化手段。第一个技巧是把第一层卷积核画出来看。卷积核本质上是 5×5 或 3×3 的权重矩阵训练完成后权重会呈现出某种结构比如某些核专门检测横向边缘某些核专门检测纵向边缘或圆点。如果训练出的卷积核看起来毫无规律、全是一堆噪点说明网络参数根本没有收敛即使测试集准确率碰巧很高泛化能力也存疑。画出卷积核的代码如下import matplotlib.pyplot as plt def show_conv_filter(network, layer_idx0): 把第一层卷积核可视化检查是否学到了有效特征 W network.params[fW{layer_idx 1}] # 形状 (FN, C, FH, FW) filter_num W.shape[0] fig, axes plt.subplots(1, filter_num, figsize(12, 3)) for i in range(filter_num): # s 参数把权重值缩放到 0-255 的灰度范围 axes[i].imshow(W[i, 0], cmapgray, interpolationnearest, vmin0, vmax1) axes[i].axis(off) plt.show()第二个技巧是挑选几个测试样本把预测结果和真实标签并排显示。这看起来很简单很多工程师会跳过但它的价值在于暴露数字 4 和 9、3 和 8 这种易混淆类别的问题。如果模型把 9 系统性识别成 4可能是训练集中这两个类别的样本量不均衡也可能是卷积特征不够表达区分度。把错误样本按类别统计成混淆矩阵比单纯看准确率更能指导调参方向def evaluate_samples(network, x_test, t_test): 输出模型的预测标签、真实标签与置信度定位易混淆类别 y network.predict(x_test) pred np.argmax(y, axis1) true np.argmax(t_test, axis1) confidence np.max(y, axis1) for i in range(len(pred)): if pred[i] ! true[i]: print(f样本 {i}: 真实{true[i]}, 预测{pred[i]}, 置信度{confidence[i]:.3f})这段代码找到所有预测错误的样本并打印置信度。如果某个错误样本的置信度逼近 0.9说明模型非常自信地认错了这种错误比模棱两可的错误更值得深挖——大概率是训练数据分布没有覆盖到那种写法。训练完成后我习惯把最终模型参数保存成 npz 格式同时把测试集的混淆矩阵和典型错误样本图一起归档。下次换网络结构做对比时直接用这组历史结果当基线。从那以后我每次复现一套深度学习源码都强制自己走一遍删缓存、跑通、看卷积核、查混淆矩阵这四道工序少一步都觉得不踏实。MNIST 是深度学习的第一块敲门砖这块砖要是没踩实后面做 CIFAR、ImageNet 的坑只会更多。希望这份拆解能帮到你把源码吃透比收藏一百个教程都有用。本文还有配套的精品资源点击获取