简介:基于Python与PyTorch实现卷积神经网络识别MNIST手写数字的完整课程设计资料,面向深度学习入门学生和课程设计开发者,聚焦CNN基础架构与手写数字分类这一经典实验。资源共11个文件,zip压缩包仅176KB,包含可直接运行的Python训练脚本、图文式设计报告Word文档、训练及测试可视化图片、模型输出文本和说明配置信息,覆盖数据加载、模型搭建、训练评估、结果展示等环节,目录划分清晰便于分模块查阅。MNIST常被视为机器学习界的“果蝇实验”,借助该压缩包可快速理解CNN处理图像分类的思路。已有2027人学习下载,在同类入门资源中较受关注。通过资料,读者既能获得可复用的CNN识别MNIST代码与规范的课程设计报告,也能利用样本数字图、训练周期图和输出文件对比模型效果、分析收敛趋势,并梳理CNN原理与参数调整思路,适合快速搭建实验、完成课程作业或撰写技术文档。
1. 用CNN识别MNIST:为什么这个入门项目最值得复现
反复在Python里调CNN识别MNIST,几乎是每个深度学习从业者绕不开的第一道坎。这个项目虽然只处理28x28的灰度手写数字,却把数据加载、卷积计算、反向传播、过拟合、模型保存这些核心环节全部串起来了。适合刚装好Python环境、想验证CNN到底怎么工作的人,也适合想快速评估一台机器能否跑深度学习的老手。MNIST看起来“玩具”,但它的训练经验可以直接迁移到后续真实图像项目。下面我从数据准备开始,讲清楚整个落地路径。
2. 读懂MNIST与CNN:数据形状、标签和3个结构选型理由
2.1 MNIST数据加载与预处理:两种框架下的最小实现
MNIST包含60000张训练图和10000张测试图,每张是28x28像素。PyTorch的torchvision自带MNIST下载接口。我一般用下面这段代码做第一步加载。
import torch from torchvision import datasets, transforms # 定义数据预处理:先转Tensor,再做标准化 transform = transforms.Compose([ transforms.ToTensor(), # (H, W) -> (C, H, W),像素缩放到[0,1] transforms.Normalize((0.1307,), (0.3081,)) # 对灰度图只有一个通道 ]) # 下载并加载训练集 train_set = datasets.MNIST(root='./data', train=True, download=True, transform=transform) train_loader = torch.utils.data.DataLoader(train_set, batch_size=64, shuffle=True, num_workers=2) # 加载测试集,不需要打乱 test_set = datasets.MNIST(root='./data', train=False, download=True, transform=transform) test_loader = torch.utils.data.DataLoader(test_set, batch_size=256, shuffle=False, num_workers=2)这段代码做了两件事:一是把原始图像变成PyTorch的Tensor格式,二是把每个像素从0~255缩放到约0~1之间。Normalize里的两个数值是MNIST官方统计的均值和标准差,为什么要这样?因为CNN对输入数据的尺度敏感,缩放到均值为0、方差为1的分布能让梯度更稳定。
参数上,train_loader的batch_size=64是保守选择,显存小也能跑;shuffle=True可以打乱训练样本,避免模型学到样本顺序。num_workers=2表示用两个子进程取数据,如果是在Windows上运行,建议把num_workers设为0,否则可能报多线程错误。这些都属于“环境配置”阶段最容易遇到的坑,稍后第5章会细说。
如果你想确认数据到底是什么样子,可以打印一批数据的形状:
images, labels = next(iter(train_loader)) print(images.shape) # torch.Size([64, 1, 28, 28]) print(labels.shape) # torch.Size([64]) print(labels.unique()) # 数字0到9这里的标签是长整型标量,不是one-hot向量。CrossEntropyLoss在PyTorch里期望的就是这种整数标签,如果手动转成one-hot反而会报错。
2.2 卷积层、池化层、全连接层:参数和感受野怎么配合
CNN的核心不是“用卷积代替全连接”,而是通过局部连接和权值共享减少参数。对于28x28的输入,第一个卷积层用3x3卷积核,padding=1,输出通道数从1变成32,特征图尺寸不变;第二个卷积层继续下采样,逐步提取更高层抽象。这里我倾向于先定三个结构原则。
第一,小卷积核堆叠优于大卷积核。两个3x3卷积堆叠的感受野等效于一个5x5,但参数量更少,非线性更强。MNIST字符笔画细,用5x5可能导致边缘细节过早丢失。第二,池化层用最大池化而不是平均池化。手写数字识别更关心笔画是否存在,最大池化能保留最强烈的响应。第三,全连接层要克制。MNIST类别只有10个,全连接层参数动辄上百万,很容易过拟合,所以我在全连接之间加Dropout。
下面是一个典型的LeNet风格网络,把上述原则落地为PyTorch代码:
import torch.nn as nn import torch.nn.functional as F class MNISTCNN(nn.Module): def __init__(self): super().__init__() # 第一层:单通道灰度图 -> 32通道特征图 self.conv1 = nn.Conv2d(in_channels=1, out_channels=32, kernel_size=3, padding=1) # 第二层:32通道 -> 64通道 self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1) # 最大池化后,28x28 -> 14x14 -> 7x7 self.pool = nn.MaxPool2d(kernel_size=2, stride=2) # 经过两轮池化后,特征图尺寸是7x7,通道数64 self.fc1 = nn.Linear(64 * 7 * 7, 128) self.fc2 = nn.Linear(128, 10) self.dropout = nn.Dropout(0.5) def forward(self, x): x = self.pool(F.relu(self.conv1(x))) # 28x28 -> 14x14 x = self.pool(F.relu(self.conv2(x))) # 14x14 -> 7x7 x = x.view(-1, 64 * 7 * 7) # 展平 x = F.relu(self.fc1(x)) x = self.dropout(x) return self.fc2(x)forward里第一行是先卷积、再激活、再池化。第二行同理。view函数把三维特征图拉成一维向量,-1表示自动推导batch维度。fc1输出128维,再经过ReLU和Dropout。最后一层fc2输出10维,对应数字0到9的分数。
这里的几个参数值得记一下:conv1的padding=1保证3x3卷积后尺寸不变;MaxPool2d的stride=2让图像尺寸减半。如果输入不是28x28,最后展平的维度就得跟着改,这是后面换数据集时最容易算错的地方。另外Dropout只在全连接层前使用,训练时生效,推理时PyTorch会自动关闭。
卷积不改变尺寸时padding=1;池化把28→14、14→7。因此最后一个卷积输出的张量形状是(64,7,7),展平是6477=3136。这段计算要自己推一遍,后面改输入尺寸才不会乱。MNIST的输入是(1,28,28),如果batch=128,整个张量形状是(128,1,28,28)。PyTorch的Linear层在实例化时就要求输入维度写死,所以这个3136必须在代码里写对。换成Fashion-MNIST同样28x28没问题;换到CIFAR-10是32x32,最后卷积特征图变成8x8,展平维度就变成6488=4096。
2.3 为什么CNN比全连接更适合MNIST:参数量与平移不变性
全连接网络直接把784像素拼成784维向量,第一个全连接层如果输出256维,参数量就是784256+256约20万。而我们的CNN,conv1有3x3x1x32+32=320个参数,conv2有3x3x32x64+64=18496个,fc1有3136128+128约401k,总计约42万。如果换一个更精简的CNN,只保留前两层,参数量也可以压到15万以下。关键不是绝对参数少,而是卷积核能共享权重,同一个3x3卷积核扫描整个图像,能识别“某个笔画模式出现在任何位置”。
全连接网络对每个位置的权重独立,数字稍微平移就有很大变化,这也是早期模型在MNIST上只能到97%左右的主因。CNN天然对平移更鲁棒,因为同样一个笔画特征,不管出现在左上角还是右下角,都可以被同一个卷积核捕获。MNIST虽然简单,但用CNN跑通,后面改到CIFAR-10或人脸识别数据集时,网络结构只需要加层,不需要换范式。
还有一个容易忽略的点:MNIST是灰度单通道,所以conv1的in_channels=1。如果手边数据是RGB三通道,比如彩色自然图像,这个值必须改成3,否则会直接报错或输出错乱。这也是新手从MNIST起步后再接触真实图像数据集时最常踩的边界条件。
3. 搭建LeNet风格CNN识别MNIST:PyTorch代码与训练流程
3.1 定义网络与损失函数:从模型类到交叉熵
模型类已经在第2章定义好了。这里直接实例化并选择优化器,完整代码合并在一起方便复现。
import torch.optim as optim device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = MNISTCNN().to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=0.001)CrossEntropyLoss在PyTorch中把softmax和负对数似然合并,输入是网络最后一个全连接的原始分数(logits),标签是0~9的整数张量。这里不需要自己在最后一层加softmax,因为损失函数内部已经处理。如果用SGD,收敛慢但泛化好;Adam则对学习率不敏感,在MNIST上更容易一次跑通。lr=0.001是Adam最常用的起始值。
如果想让训练更稳,可以加一个学习率调度器:
scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=5, gamma=0.1)意思是每5个epoch学习率乘以0.1。MNIST通常10个epoch就能到99%,学习率调度不是必须,但在后面的复杂数据集上有用。注意step_size的“5”指的是epoch轮数,不是迭代步数。
3.2 训练循环与评估指标:准确率不是唯一指标
标准训练循环看起来简单,但有几个细节会影响结果。我习惯把训练和评估各写成一个函数,方便反复调用:
def train_epoch(model, loader, criterion, optimizer): model.train() total_loss = 0 correct = 0 total = 0 for images, labels in loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() total_loss += loss.item() * images.size(0) pred = outputs.argmax(dim=1) correct += (pred == labels).sum().item() total += labels.size(0) return total_loss / total, correct / total def evaluate(model, loader, criterion): model.eval() total_loss = 0 correct = 0 total = 0 with torch.no_grad(): for images, labels in loader: images, labels = images.to(device), labels.to(device) outputs = model(images) loss = criterion(outputs, labels) total_loss += loss.item() * images.size(0) pred = outputs.argmax(dim=1) correct += (pred == labels).sum().item() total += labels.size(0) return total_loss / total, correct / totalmodel.train()和model.eval()切换Dropout的状态。训练时每个batch都计算梯度并更新参数;评估时用torch.no_grad()禁止梯度跟踪,省显存也防止参数意外更新。argmax(dim=1)取出每个样本最大分数对应的类别。返回的准确率是“预测正确的样本数/总样本数”。
如果只看准确率,MNIST上几乎所有CNN都能到99%。更需要看的是训练损失和测试损失的差值。如果训练损失一路下降但测试损失在第5个epoch后开始回升,说明模型开始记训练集的噪声。这时候准确率可能还在微涨,但泛化能力已经变差。
多epoch循环这样写:
epochs = 10 for epoch in range(epochs): train_loss, train_acc = train_epoch(model, train_loader, criterion, optimizer) test_loss, test_acc = evaluate(model, test_loader, criterion) print(f"Epoch {epoch+1} train_loss={train_loss:.4f} train_acc={train_acc:.4f} test_acc={test_acc:.4f}") scheduler.step()注意scheduler.step()要放在每个epoch结束后,而不是每个batch内。放错位置会导致学习率衰减太快,后面的epoch基本学不动。
3.3 保存checkpoint与推理脚本:让模型可用起来
训练一次得到的权重只是内存里的张量,要保存到磁盘才能复用。PyTorch推荐只保存state_dict而不是整个模型,因为只保存参数文件更小、跨Python版本更稳定。
torch.save({ 'model_state': model.state_dict(), 'optimizer_state': optimizer.state_dict(), 'epoch': epoch + 1, 'test_acc': test_acc }, 'mnist_cnn.pt')这样保存的是一个字典,后续恢复时能用。推理时不需要优化器和epoch,只加载state_dict:
def inference(image_tensor): model = MNISTCNN() state = torch.load('mnist_cnn.pt', map_location=device) model.load_state_dict(state['model_state']) model.to(device).eval() with torch.no_grad(): logits = model(image_tensor.unsqueeze(0).to(device)) prob = torch.softmax(logits, dim=1) pred = prob.argmax(dim=1).item() return pred, prob[0, pred].item()image_tensor是单张28x28的Tensor,unsqueeze(0)在首维增加一个batch维度,因为网络期望输入形状是(1,1,28,28)。训练好的模型在MNIST测试集上一般能到99%以上。如果连续推理多张图,可以直接输入(bs,1,28,28)。
保存和加载的模型类必须在同一个文件里定义,否则load_state_dict前必须重新导入同一个MNISTCNN类。如果换了Python解释器或改了网络结构,再load旧权重大概率报key不匹配,这个不是bug,是模型结构变了。
4. 把准确率从99%提到99.5%:参数调节与数据增强
4.1 学习率、batch size、epochs的搭配区间
MNIST简单,很多人第一次跑就99%。不过如果追求稳定收敛再加0.5%,几个参数有固定搭配逻辑。
学习率:Adam用0.001起步,SGD用0.01搭配momentum=0.9。如果发现loss在震荡,把学习率除以10;如果在第1个epoch loss就出现nan,多半是学习率太大。
batch size:64到256都能跑。batch太大,梯度平滑、训练快但准确率可能略低,因为模型更早进入稳定区域;batch太小如16,梯度噪声大,需要更多epoch。我常用64做验证,用128做最终训练。
epochs:10个epoch基本能到99%,20个epoch配合学习率调度能到99.5%。再多就可能过拟合。MNIST的数据量有限,训练到30个epoch以上收益很小。
下面是一组可以直接换入的配置:
| 参数 | 推荐范围 | 我的选择 | 说明 |
|---|---|---|---|
| optimizer | Adam / SGD | Adam | 新手优先Adam,复现稳定 |
| learning rate | 0.0005~0.002 | 0.001 | Adam默认范围 |
| batch size | 64~256 | 128 | 显存和速度平衡 |
| epochs | 10~30 | 15 | 超过30无收益 |
| dropout | 0.3~0.5 | 0.5 | 全连接层防过拟合 |
有一点容易被忽略:Adam的默认学习率0.001在迁移到非CNN任务时不一定最优,但MNIST上几乎不用调。SGD需要更大的学习率和动量,如果想用SGD,建议写成optim.SGD(model.parameters(), lr=0.01, momentum=0.9)。两者都能到99%以上,区别只在收敛曲线上。
4.2 数据增强和正则化:MNIST上的边际收益
MNIST数据量不小,但样本之间的形变有限。标准做法是加入随机旋转、随机平移,让模型学会不变性。torchvision的transforms支持组合:
train_transform = transforms.Compose([ transforms.RandomAffine(degrees=5, translate=(0.1, 0.1)), transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ])注意RandomAffine要放在ToTensor之前,因为图像增强操作的对象是PIL图像。degrees=5表示允许正负5度的旋转,translate=(0.1,0.1)表示在纵横两个方向最多平移10%的像素。这个增强量很小,正好用来模拟手写笔画的自然抖动。
在MNIST上,数据增强带来的提升通常只有0.1%~0.3%,因为测试集也是标准化的手写数字。但这不是白干:当迁移到真实手写数据时,增强策略可以防止模型在倾斜、偏移的输入上翻车。除了增强,L2正则化可以加在优化器里:optimizer = optim.Adam(model.parameters(), lr=0.001, weight_decay=1e-4)。weight_decay就是L2系数,值太大会让权重趋近于零,值太小没效果。MNIST上1e-4是一个安全起点。
还有一种常见的误用:对测试集也做RandomAffine。这是错误做法,测试集应该反映真实分布,不需要随机增强,否则你评估的不是模型真实能力,而是模型对数据扰动后的适应能力。
4.3 一组经过验证的训练配置参考
综合上面,我经常使用的最终训练配置如下:
transform_train = transforms.Compose([ transforms.RandomAffine(degrees=3, translate=(0.08, 0.08)), transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) transform_test = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_set = datasets.MNIST(root='./data', train=True, download=True, transform=transform_train) test_set = datasets.MNIST(root='./data', train=False, download=True, transform=transform_test) train_loader = DataLoader(train_set, batch_size=128, shuffle=True, num_workers=0) test_loader = DataLoader(test_set, batch_size=256, shuffle=False, num_workers=0) model = MNISTCNN().to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=0.001, weight_decay=1e-4) scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=8, gamma=0.5)测试集不做过多的随机增强。weight_decay加上之后,训练10~15个epoch,测试准确率一般能到99.5%左右。如果发现测试准确率反而不如不加,可能是正则化过强,把weight_decay降到1e-5或去掉即可。这些参数不是玄学,是可以组合出稳定结果的经验区间。
还有一个实用技巧:如果你在vscode里调试,建议把num_workers保持为0,避免IDE的调试器因为多进程子线程而卡住。真正跑完整训练时再调回2或4。这个细节和环境配置有关,但会直接影响你是否能在编辑器里看到中间输出。
5. MNIST CNN训练避坑与常见问题排查:5个翻车现场
5.1 现象:loss不降或直接nan,训练第一个epoch就崩
原因通常是学习率太大、数据没归一化,或者标签类型不对。MNIST的标签是0~9的整数,如果误写成one-hot形式或float型,CrossEntropyLoss会报错或输出nan。
解决:先把学习率降到0.0001试试;确认transform里包含了ToTensor和Normalize;用labels.dtype检查标签是不是torch.int64。如果用了多GPU并行,还要确认DataParallel没有把标签切成非整数。另外,如果数据不是MNIST而是自己收集的图片,归一化参数里的0.1307和0.3081要替换成你自己数据集的统计值,不要直接套用。
5.2 现象:训练集准确率99.9%、测试集只有97%,过拟合明显
原因:模型学习能力太强、训练样本相对不足,或全连接层参数过多。MNIST有60000张,不算少,但十层深模型照样能背下来。
解决:加Dropout并把概率从0.5调到0.7;加weight_decay;减少fc1的隐藏单元从128降到64;在训练集上用数据增强。如果测试准确率卡在99%以下很长时间,优先考虑过拟合而不是结构错误,因为MNIST的测试集来源与训练集一致,正常CNN不会低于99%。
判断过拟合还有一个信号:训练loss持续下降,测试loss在第5个epoch后开始上升。这时候准确率可能还在微涨,但泛化能力已经变差。止损办法是减少epochs,或者在第5个epoch手动保存一次checkpoint,当作“后悔药”。
5.3 现象:训练时准确率很高,单张图片推理全错
原因:推理时忘了做与训练一致的预处理。最常见的翻车是直接读入PIL图片后没有转Tensor、没有归一化,导致输入像素范围是0~255,而训练时是0~1,CNN输入分布完全错位。
解决:推理代码里,图片先做灰度转换,再ToTensor,再Normalize。注意如果从外部拖入一张28x28的手写图,要保证它已经缩放到28x28,且背景是黑底白字。MNIST原数据是黑底白字,如果拿白底黑字的图片直接测,预测也会全乱。正确预处理如下:
from PIL import Image img = Image.open('digit.png').convert('L') img = img.resize((28, 28)) img_tensor = transform_test(img) # 复用测试集的transform pred, prob = inference(img_tensor) print(pred, prob)这里复用transform_test比自己手写归一化更不容易出错,因为测试集transform里已经包含了ToTensor和Normalize。
5.4 现象:显存不足,或者CPU训练慢到怀疑人生
原因:batch_size设得太大、num_workers开太高、笔记本散热降频。128的batch在2GB显存上也可能爆。
解决:先把batch_size降到32或16;把num_workers设成0(Windows安全)。如果还没有GPU,用CPU训练时可以把fc1从128降到64,epochs降到3,先看能不能跑通。验证代码正确之后,再逐步加batch_size和epochs。CNN识别MNIST本身就是入门任务,CPU上15分钟能跑完10个epoch,不需要为这个项目专门买GPU。
有一点容易忽略:如果使用DataLoader且shuffle=True,每次取batch都重新打乱数据,CPU端会增加额外开销。可以先关闭shuffle跑通,再打开。训练集shuffle对收敛有帮助,但测试集必须保持False。
5.5 现象:MNIST数据集下载失败或者加载报错
原因:网络请求超时,或者./data目录权限不足。torchvision的MNIST会自动从开源数据集服务器下载,但有些网络环境会一直转圈。
解决:下载失败时,手动下载MNIST四个原始文件放到./data/MNIST/raw目录下,文件名要严格匹配:train-images-idx3-ubyte.gz、train-labels-idx1-ubyte.gz、t10k-images-idx3-ubyte.gz、t10k-labels-idx1-ubyte.gz。放置后再设置download=False加载。注意如果之前已经下载了一半,最好把./data/MNIST整个目录删掉重来,避免损坏的压缩包干扰后续解压。
如果报错信息里带着“not a gzip file”之类的字样,多半是下载到了网页而不是真正的数据文件。删除重下是最直接的解决方式。
5.6 现象:PyTorch环境装不上,torchvision版本不匹配
这算环境安装问题,但值得单独说。用conda创建独立环境是目前最稳妥的做法:
conda create -n mnist python=3.9 conda activate mnist conda install pytorch torchvision cpuonly -c pytorchpython版本3.8~3.11均可,关键在torch与torchvision版本配对。如果只用CPU,就选cpuonly;如果有NVIDIA显卡,需要安装对应CUDA版本的pytorch。装完后在vscode里把python解释器切到mnist环境,运行python -c "import torch; print(torch.__version__)"确认没报错。
很多时候所谓的“代码不对”其实是环境不对,import torch直接失败。先确认torch和torchvision能正常导入,再回来跑CNN。这个排查和模型结构无关,但能省下一大块时间。
6. 收尾:混淆矩阵、特征图可视化和迁移到真实数据集
6.1 用混淆矩阵验证漏分类型
准确率掩盖了具体的错误模式。用sklearn的confusion_matrix可以在测试集上可视化哪些数字互相搞混。
from sklearn.metrics import confusion_matrix import numpy as np all_preds = [] all_labels = [] model.eval() with torch.no_grad(): for images, labels in test_loader: images = images.to(device) outputs = model(images) all_preds.extend(outputs.argmax(dim=1).cpu().numpy()) all_labels.extend(labels.numpy()) cm = confusion_matrix(all_labels, all_preds) np.fill_diagonal(cm, 0) # 只看错误 print(cm)混淆矩阵对角线清零后,非零值通常集中在4和9、3和8之间,表示模型对相似形状的理解还有缺陷。查看错误样本的原始图像,往往是笔画倾斜、粗细异常的手写体,这类样本就算人眼也容易看错。
6.2 用hook可视化卷积核和特征图
训练好的卷积核是可以直接画出来的。第一层conv1有32个3x3核,直接reshape成32张3x3图放大看,可以看到横向、纵向和斜向的笔画检测器。
import matplotlib.pyplot as plt import torchvision.utils weights = model.conv1.weight.detach().cpu() grid = torchvision.utils.make_grid(weights, nrow=8, normalize=True) plt.imshow(grid.permute(1, 2, 0)) plt.axis('off') plt.show()这个实践的下一步是查看中间层的特征图。确认第二层在关注数字的整体轮廓还是局部角点。如果某张特征图几乎是黑的,说明该卷积核没有学到有效模式,通常是学习率过大或者初始化问题。
6.3 从MNIST到真实数据集的迁移注意
MNIST跑通后,下一步常见做法是迁移到EMNIST、Fashion-MNIST或自己的手写数字数据。注意三点:输入尺寸变了,展平维度要重新计算;类别数变了,把fc2的输出改成实际类别数;灰度图如果带彩色通道,把conv1的in_channels从1改成3。我自己的习惯是每换一个数据集,先看一眼数据形状,打印images.shape,再动网络。
最后说一句我的习惯:在MNIST上我不会追求超过99.6%的准确率,因为那是数据集本身的边际。我更关注训练损失和测试损失的曲线是否稳定,以及模型在倾斜、噪声图片上的表现。留好checkpoint和推理脚本,这套流程下次接手任何图像分类任务都能复用。希望帮到你。
本文还有配套的精品资源,点击获取