简介一份面向深度学习和计算机视觉初学者的手写数字识别项目以MNIST手写数字数据集为训练与测试对象完整演示卷积神经网络CNN在图像分类上的应用流程。资源包共20个文件、约11.08MB结构清晰3个Python脚本分别承担数据加载与预处理、CNN模型训练与评估、预测效果展示4个gz压缩文件为现成的MNIST原始图像与标签数据解压后即可直接运行省去额外下载数据的麻烦10张PNG为待预测的单个数字样例另有2个pyc编译缓存和1个txt说明文档。目前已有315人学习/下载适合入门者和高校学生动手实践。通过该项目可以掌握卷积层、池化层、全连接层的搭建技巧理解数据归一化、训练集与测试集划分、准确率评估等关键环节还能直接查看测试集识别精度、替换PNG样例验证手写数字效果并可调整网络结构或超参数观察模型表现变化为进一步开展图像识别课题打下扎实基础。1. CNN 手写数字识别让 mnist.py 先跑起来再谈卷积原理第一次用 CNN 跑 MNIST 手写数字识别的人大半时间不是耗在写卷积层而是耗在数据集上torchvision 下载卡住、四个 ubyte 文件不知道怎么解析、好不容易解压了又是一堆依赖报错。这份 cnn_mnist.zip 把最麻烦的部分收敛成了几个可执行文件核心 mnist.py 负责数据集加载、CNN 模型定义和训练循环理论上解压后装好 PyTorch 就能直接训练。适合刚入门卷积神经网络、想在一个标准数据集上验证效果的 Python 开发者也适合需要拿一套干净基线去做对比实验的人。先把它跑通再看每一行的含义比背十篇原理都见效。2. 拆开 cnn_mnist.zip文件布局、MNIST 数据加载与代码组织2.1 压缩包里通常有哪些文件各自负责什么先别急着双击运行。拿到 zip 的第一步是确认里面到底装了什么、依赖是什么。这类 MNIST 工程包的常见布局如下文件或目录作用使用时机mnist.py主入口脚本包含数据加载、模型定义、训练与测试直接执行python mnist.pyrequirements.txt记录 Python 依赖及版本在干净环境里用pip install -r requirements.txt安装README.md说明运行方式、Python 版本和常见问题换环境前先看一遍data/MNIST 数据集缓存目录通常不随 zip 打包首次运行根据downloadTrue自动生成如果你拿到的压缩包里只有一个 mnist.py也不用慌它一般把下面这些逻辑全收进一个文件load_data()负责下载和解析数据集类定义里写卷积层和全连接层后面跟训练循环和测试评估末尾用if __name__ __main__统一调度。这种单文件结构的好处是换机器跑成本低丢一个 py 文件就能复现坏处是后期改自定义数据集时要自己拆开改。一个习惯解压后先在目标目录里执行tree -L 2或者用文件管理器展开确认有没有 requirements.txt 和 README。两个都没有就按第 4 章的通用依赖来装。2.2 MNIST 数据集的结构60k 张 28×28 灰度图如何变成数组MNIST 是 28×28 的灰度手写数字图训练集 60000 张测试集 10000 张每张图对应 09 十类标签。原生格式不是 PNG 也不是 JPG而是四个 ubyte 二进制文件名字固定为 train-images-idx3-ubyte、train-labels-idx1-ubyte、t10k-images-idx3-ubyte、t10k-labels-idx1-ubyte。这四个文件开头有一段文件头图片文件头 16 字节标签文件头 8 字节。常见做法是用 struct 读取头部信息再用 numpy 整块转换import numpy as np import struct def load_mnist_images(path): with open(path, rb) as f: magic, num, rows, cols struct.unpack(IIII, f.read(16)) data np.frombuffer(f.read(), dtypenp.uint8).reshape(num, rows, cols) return data def load_mnist_labels(path): with open(path, rb) as f: magic, num struct.unpack(II, f.read(8)) labels np.frombuffer(f.read(), dtypenp.uint8) return labels这段代码的逻辑分两层先用struct.unpack(IIII)读掉 16 字节图片文件头拿到魔数 magic、样本数 num、行数 rows、列数 cols再调用np.frombuffer把剩余字节一次性变成 uint8 数组最后 reshape 成(num, rows, cols)。标签文件头只有 8 字节所以用II读两个无符号整数。这里的表示大端字节序I表示无符号 32 位整数这是 MNIST 官方文件固定的头格式。实际动手时如果只改了文件路径而忽略文件头长度最常见的结果就是 shape 错乱运行时输入维度和模型对不上报错信息会指向view或者reshape那一行。2.3 mnist.py 里的数据加载与预处理写法用 torchvision 是最省事的路径现在很多新写的 mnist.py 都放弃手写解析了from torch.utils.data import DataLoader from torchvision import datasets, transforms transform transforms.Compose([ transforms.ToTensor(), # 把 PIL 图像转为 [0,1] 的张量 transforms.Normalize((0.1307,), (0.3081,)) # 用 MNIST 全局均值/标准差做归一化 ]) train_set datasets.MNIST(root./data, trainTrue, downloadTrue, transformtransform) test_set datasets.MNIST(root./data, trainFalse, downloadTrue, transformtransform) train_loader DataLoader(train_set, batch_size128, shuffleTrue, num_workers0) test_loader DataLoader(test_set, batch_size128, shuffleFalse, num_workers0)逻辑说明datasets.MNIST在root指定目录下检查缓存数据没有且downloadTrue时去服务器拉取并解压transform先执行ToTensor把 0255 像素压到 01 区间且通道维度排在前面变成(1, 28, 28)再执行Normalize让每个通道减去均值 0.1307 再除以标准差 0.3081。这两个数字是官方统计出的全局统计量不是随便拍的。DataLoader的参数更直白batch_size128表示每次迭代拿 128 张图shuffleTrue只在训练时开避免同一个数字连续进一个批次导致梯度方向偏斜num_workers0是给 Windows 用户的稳妥值多线程数据加载在 Windows 上容易触发断点保护单核慢一点但不翻车。这里有个容易误解的点downloadTrue只代表“缺数据时去下载”不代表每次运行都会重下。第二次启动时 torchvision 会在./data/MNIST/raw下检测到已存在的 ubyte 文件直接跳过下载。2.4 解压与目录规范先理顺路径再写代码把路径安排错是最不值当的踩坑之一。我一般这样解压mkdir -p ~/workspace unzip cnn_mnist.zip -d ~/workspace/cnn_mnist cd ~/workspace/cnn_mnist tree -L 2参数说明-d指定解压目标目录避免 zip 自带顶层目录和当前目录混在一起tree -L 2只展示两层结构够看到主脚本和依赖文件。系统没有 tree 就用ls -R代替。解压后第一件事是建立三个认知主脚本在哪、数据会在哪生成、当前 Python 环境能不能 import torch。三个都确认了再跑训练。第 5 章会看到很多人翻车就是跳过了这一步直接双击 py 文件最后日志刷了一屏ModuleNotFoundError。3. CNN 卷积网络设计为什么堆两层“卷积池化”参数怎么设3.1 CNN 在 MNIST 上优于全连接的原因局部连接与权值共享把 MNIST 图片平铺成 784 维向量后直接丢进全连接层问题在于“相邻像素之间的关系”被抹掉了。手写数字的笔画是局部连续的横线、圆弧往往只占十几个相邻像素全连接层把第 100 个像素和第 500 个像素一视同仁在同样的权重空间里塞进所有位置的组合模式数据量不够时很容易记住噪点。CNN 用两个机制缓解。一是局部连接每个卷积核只滑过输入的一个小窗口比如 3×3只对窗口内的像素做加权求和相当于在局部提取边缘、角度这些低级特征。二是权值共享同一个卷积核在整张图上滑动参数只存一份不随位置变化。这样即使输入是 28×28一个 3×3 卷积核也只需要 9 个权重加 1 个偏置再配合池化下采样整体参数量比同规模全连接网络小很多同时保留空间结构。MNIST 是单通道灰度图所以第一个卷积层的输入通道固定为 1。如果想把这套代码迁移到彩色图片把Conv2d第一个参数从 1 改成 3 就行第二个卷积层的输入通道则取决于第一个卷积层的输出也就是卷积核数量。3.2 典型的分类网络结构Conv - ReLU - MaxPool 的尺寸变化手写数字识别最常用的骨干是“两层卷积池化再接全连接分类器”这也是 LeNet 家族留下来的惯例。这份资源和大多数 mnist.py 里的模型部分几乎一致import torch.nn as nn class MnistCNN(nn.Module): def __init__(self): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 32, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(64 * 7 * 7, 128), nn.ReLU(inplaceTrue), nn.Linear(128, 10), ) def forward(self, x): return self.classifier(self.features(x))逻辑说明第一层Conv2d(1, 32, 3, padding1)接收 1 通道 28×28 输入输出 32 个特征图尺寸因 padding 保持 28×28MaxPool2d(2)把每个 2×2 区域取最大值特征图变为 14×14。第二层Conv2d(32, 64, 3, padding1)把 32 通道的 14×14 特征图变成 64 通道再池化一次落到 7×7进入全连接层前每个样本是 64×7×73136 维向量。把尺寸变化列成表更清楚层输入尺寸输出尺寸说明Conv1 ReLU1×28×2832×28×28padding1 保持宽高不变MaxPool132×28×2832×14×14下采样减半Conv2 ReLU32×14×1464×14×14通道翻倍宽高不变MaxPool264×14×1464×7×7下采样减半Flatten64×7×73136展平Linear13136128隐层Linear212810输出十个类别的 logits有了这张表改结构就不会碰壁。比如想加深到三层卷积按“通道数等于上层卷积核数、padding1 保持尺寸、后面接一个 MaxPool 让长宽减半”的规律继续堆最后全连接输入维度改成当前特征图通道数乘以长宽乘积。很多人只改卷积不改 Linear跑起来才报mat1 and mat2 shapes cannot be multiplied这是入门翻车现场第一名。3.3 损失函数、优化器和训练循环的参数设置模型结构定了训练循环写法基本固定import torch import torch.optim as optim model MnistCNN() criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr0.001) def train_one_epoch(loader): model.train() total_loss, correct, total 0.0, 0, 0 for images, labels in loader: optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() total_loss loss.item() pred outputs.argmax(dim1) correct (pred labels).sum().item() total labels.size(0) return total_loss / len(loader), correct / total训练参数按这个经验值起步基本一次就能跑出能看的准确率参数典型值说明batch_size128显存小用 64影响梯度平滑程度learning_rate0.001Adam 默认步长SGD 要降到 0.01 以下epochs510MNIST 收敛快10 轮足够到 98% 以上optimizerAdam自带自适应学习率省新手调参lossCrossEntropyLoss多分类标准损失内部已带 softmax一个容易误解的点CrossEntropyLoss内部已经做了 LogSoftmax所以模型最后一层只输出 10 个原始 logits 即可不要再单独加nn.Softmax()。训练时加 Softmax 会让梯度变小收敛变慢预测阶段想要概率值再对输出单独做 softmax 就行。mnist.py 里看到这种“最后一层裸 Linear”的写法不是漏了而是 PyTorch 的惯例。4. 在本地跑通这份资源Python 环境、zip 解压与三步复现4.1 安装 PyTorch 环境版本匹配比想象中重要mnist.py 的依赖集中在 torch 和 torchvision 两个包numpy 是间接依赖一般不用单独装。但版本匹配是这份资源最容易翻车的点torchvision 和 torch 必须由同一个版本发布会产出混着装会在 import 阶段直接报ModuleNotFoundError或OSError。先看 Python 版本再装依赖python --version pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu不需要 GPU 就装 CPU 版安装包小一半以上也不用处理 CUDA 驱动问题。装完做一次体检python -c import torch; print(torch.__version__); import torchvision; print(torchvision.__version__)输出两行版本号且没有红色 Traceback说明环境就绪。--index-url参数是给网络环境用的稳定源纯粹为了让下载快一点。在公司内网还可能要配--proxy放环境变量里解决不改代码。4.2 unzip 解压并运行 mnist.py 的完整命令在 Linux 或 macOS 下解压和运行可以一口气完成unzip cnn_mnist.zip -d cnn_mnist cd cnn_mnist python mnist.pyWindows 用户建议用 PowerShell命令几乎一样Expand-Archive -Path cnn_mnist.zip -DestinationPath cnn_mnist cd cnn_mnist python mnist.py逻辑说明unzip -d与Expand-Archive -DestinationPath都是把内容释放到指定目录。第 5 章会提到Windows 资源管理器自带的“全部解压缩”会把文件名按系统区域编码处理国区系统解压 utf-8 打包的文件时经常出乱码命令行反而没这个问题。跑起来后你会看到两段日志前几行是数据下载进度包含Downloading http://yann.lecun.com/exdb/mnist/...字样下载完成后进入训练每轮打印 loss 和准确率格式大概是Epoch 1/10 ... loss0.22 acc93.5%。正常区间是第一个 epoch 结束 loss 在 0.10.4准确率 92%96%全部 510 轮跑完测试集准确率在 97.5%99.2% 之间。低于这个区间优先去看数据预处理和标签配对而不是急着加大模型。4.3 第一次运行自动下载 MNIST 数据要盯住这三个现象下载逻辑隐藏在datasets.MNIST(downloadTrue)里第一次运行必须盯住三个现象。第一个是下载慢。MNIST 每个文件只有几 MB但服务器在国外断流很常见。看下载失败后的完整报错是超时timeout还是校验失败checksum mismatch前者重试即可后者说明文件损坏需要删掉./data/MNIST/raw下的残留文件再重下。第二个是下载进度不动但程序没退出。这种情况多半走了 HTTP 而不是 HTTPS被中间设备拦截了。有效做法是先downloadFalse手动把镜像站下载好的四个 ubyte 文件放进 raw 目录再正常跑脚本绕开内置下载逻辑。第三个是重复下载。只要raw目录里四个文件齐全downloadTrue会被跳过。如果每次运行都重新下载多半是目录权限问题程序没写进去。要么改root到有权限的路径要么用管理员权限执行。目录里出现过部分下载的临时文件时也要手动清掉否则校验永远失败。另外跑训练前扫一眼 CPU 占用。单机纯 CPU 训练一个 epoch 通常是 20 秒到 2 分钟级别几秒钟就跑完一个 epoch 说明数据大概率没喂进去模型在空 loader 上循环日志里的 loss 会表现为nan或缺失。这种情况先print(len(train_loader.dataset))输出应该是 60000。4.4 完整工程里常见的 requirements.txt 安装套路如果压缩包里带 requirements.txt先按它装依赖python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install -r requirements.txtrequirements.txt每行是一个库加版本约束比如torch2.1.0。本机已有更高版本时直接替换mnist.py的兼容性风险很高。我的经验先建一个干净的虚拟环境在虚拟环境里装 requirements跑通后再考虑合到常用环境。避免全局 torch 被降级导致其他工程一起挂掉。5. 避坑MNIST 下载 404、zip 解压乱码、训练不收敛怎么处理这一章是血泪经验汇总。下面五条都是跑 mnist.py 时真实遇到过的坑现象、原因、解决分开写方便你直接对着排查。5.1 现象torchvision 下载 MNIST 报 404运行 mnist.py 第一轮就报HTTP Error 404: Not Found地址指向yann.lecun.com/exdb/mnist/。原因老域名部分文件访问不稳定torchvision 旧版本内置的下载地址是 http 而非 https网络环境一收紧就 404另一个常见原因是代理缓存了错误的响应。解决不要把时间耗在重试上。手动到可用的镜像源下载train-images-idx3-ubyte.gz等四个文件用gzip -d解压成普通 ubyte 文件放进./data/MNIST/raw/然后代码里保持downloadTrue也不影响因为 raw 目录已经有文件torchvision 会跳过下载。装新版 torchvision 通常也自带更新的镜像地址升级前后对比一下版本号是最快的验证。5.2 现象解压后运行 mnist.py 提示 No module named torchzip 解压正常python mnist.py报ModuleNotFoundError: No module named torch。原因当前终端激活的 Python 环境不是安装 PyTorch 的那个环境。电脑里往往同时存在系统 Python、Anaconda、虚拟环境python命令指向的是环境 A包装在了环境 B。解决先执行python -c import sys; print(sys.executable)看解释器路径再执行pip show torch看包路径两个路径指向同一个环境才算数。确定后用python -m pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu显式装到当前环境比直接敲pip install更不容易装错环境。5.3 现象训练 loss 卡在 2.3 附近不动每个 epoch 的 loss 都在 2.3 左右徘徊准确率一直停留在 10% 上下像随机猜。原因2.3 是 10 分类均匀分布的交叉熵理论值说明模型输出没有学到任何有效特征。最常见的原因是输入没有归一化像素值停留在 0255卷积层输出的激活值太大梯度被压死其次是学习率过高Adam 下 lr 大于 0.01 时也可能震荡。解决检查transform里有没有ToTensor有它像素才是 01再看Normalize的均值标准差是否为 (0.1307,) 和 (0.3081,)。把lr降到 0.001batch_size 降到 64 再试一轮。如果还卡住打印一批images.min()和images.max()数值范围在 01 且接近正态分布才算正常。5.4 现象训练结束测试准确率只有 10% 上下训练过程 loss 在下降但测试集准确率始终在 10% 浮动和随机分类没区别。原因这种“训练正常、测试全错”的情况十有八九是标签错位。比如trainTrue和trainFalse两个数据集互相用错了或者手写解析数据时图片数组和标签数组从文件里读出来的顺序不一致。解决把test_loader里第一个 batch 打出来对照images, labels next(iter(test_loader))再用模型预测一次print 前 10 个pred和labels。如果预测的是数字形状但标签对不上就是数据集加载逻辑的问题如果预测结果本身是乱的再回 5.3 查归一化。另外测试时记得model.eval()它会关闭 Dropout 和 BatchNorm 的训练行为推理结果才稳定。5.5 现象Windows 下解压出来的文件出现乱码用资源管理器右键“全部解压缩”mnist.py 变成mnist.py之外还多出一堆类似锟斤拷.txt的乱码文件。原因zip 里文件名编码是 UTF-8Windows 资源管理器按系统默认编码GBK去解码中文或特殊字符就乱了。这不是数据损坏只是文件系统层面显示错乱。解决优先用命令行工具解压。PowerShell 的Expand-Archive对编码处理更稳也可以用 7-Zip 打开时手动指定代码页为 UTF-8。解压完立刻看一眼文件大小和 Python 语法python -m py_compile mnist.py能编译通过就说明文件本体没问题。5.6 快速定位问题的三段式排查命令遇到组合型故障时我习惯按顺序跑三条命令把问题切分到最小范围python -c import torch, torchvision; print(torch.__version__, torchvision.__version__) python -c from torchvision.datasets import MNIST; dsMNIST(root./data, downloadTrue); print(len(ds)) python mnist.py --dry-run 21 | head -20第一条管环境报错就是 torch 没装好第二条管数据输出 60000 说明下载和缓存正常第三条管模型--dry-run在 mnist.py 里通常是让脚本只初始化模型和加载器、不做完整训练用来快速暴露维度错误。三条命令各有分工哪条挂在哪条修。6. 验证与进阶保存模型、评估准确率并对手写图片做单张推理6.1 用测试集算准确率并输出混淆矩阵训练完只看终端打印的 acc 不够直观把混淆矩阵打出来能看出模型到底在混淆哪些数字import torch from sklearn.metrics import confusion_matrix model.eval() all_preds, all_labels [], [] with torch.no_grad(): for images, labels in test_loader: outputs model(images) preds outputs.argmax(dim1) all_preds.extend(preds.tolist()) all_labels.extend(labels.tolist()) cm confusion_matrix(all_labels, all_preds) print(cm)参数说明torch.no_grad()关闭梯度计算推理时省显存也提速argmax(dim1)取每个样本概率最大的类别下标作为预测。混淆矩阵对角线是正确分类数量第 i 行第 j 列表示“真实数字 i 被预测成 j”。MNIST 上常见的小问题是 4 和 9、3 和 8 之间有几处混淆这属于正常现象不需要强行修。6.2 对自己写的数字图片做推理预处理才是关键模型迁移到自己画的手写数字时翻车基本都翻在预处理尺寸、通道、背景颜色三项对不上。from PIL import Image import torchvision.transforms as T image Image.open(my_digit.png).convert(L) # 转灰度 image image.resize((28, 28)) # 缩放到模型输入尺寸 image T.ToTensor()(image) # 转张量并归一化到 [0,1] image 1 - image # 黑底白字转成白底黑字MNIST 是黑底白字 model.eval() with torch.no_grad(): prob torch.softmax(model(image.unsqueeze(0)), dim1) print(prob.argmax(dim1).item(), prob.max().item())逻辑说明MNIST 训练样本是黑底白字而手机拍的手写数字往往是白底黑字直接送进模型会把笔画当成背景。1 - image这一步不写预测结果会稳定跑偏。.unsqueeze(0)是把 1×28×28 的图片伪装成一个 batch因为模型 forward 接受的第一个维度永远有 batch。跑通这一条你就把这套 CNN 从“能复现的课程作业”升级成了“能自己用的分类工具”。从那以后我每次拿到一个新的 zip 资源包都会强制走一遍固定流程先看依赖版本再确认数据文件落到目录最后跑一个--dry-run验证模型能前向才敢谈改结构。MNIST 这套流程一旦成为肌肉记忆换到 CIFAR 或者自己的业务数据集就只剩下改输入输出层这一步了。希望帮到你。本文还有配套的精品资源点击获取