ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

MNIST手写数字识别实战:PyTorch训练模型与完整代码

MNIST手写数字识别实战:PyTorch训练模型与完整代码 简介手写数字识别完整项目包面向深度学习和机器学习入门者解决手写数字数据集上的模型训练与快速部署问题。资源共5个文件含3个Python脚本和2个H5模型文件脚本分别覆盖数据加载、前馈神经网络构造、训练与测试等关键流程H5文件则单独保存模型权重及完整模型结构下载后不必重新训练即可加载已有权重直接对数字图片进行分类推理整体使用门槛较低。整个压缩包仅1.39MB轻量便于下载在个人电脑或Jupyter环境中即可运行适合课程设计、实验复现以及初步体验神经网络训练与反向传播原理。当前已有6116人浏览学习关注度稳定。代码风格直观目录结构简洁方便对照网络设计思路进行二次修改用于快速搭建自己的手写识别原型或改造为其他分类任务能节省大量调试时间。1. 用MNIST训练手写数字识别模型为什么这个经典项目值得你亲手跑一遍“使用MNIST数据集训练手写数字识别模型”这大概是深度学习入门里被提及频率最高的一项任务也是最容易让你产生“我真的在学AI”感觉的一个项目。MNIST是手写数字识别领域最标准的基准数据集包含6万张训练图片和1万张测试图片每张都是28x28的灰度图。很多人以为随便拿个网络跑两轮就能达到99%准确率实际动手后才发现环境装不上、数据下载404、模型不收敛、保存的模型加载又报错每一个环节都能卡住半天。这个标题的另一个价值在于“附完整代码和训练好的模型文件直接用”。这意味着你不用从零推导数学也不需要租显卡把一个能跑的脚本和一份能加载的权重文件拿到手就能在CPU上完成训练和手写数字识别推理。适合课程设计、毕业设计预研、AI入门练习以及需要在最短时间内验证一个想法的人。下面我就按自己实际跑通这个方案的过程把环境、代码、参数和踩坑点一次讲清楚。2. 先把数据和环境备好MNIST数据集与PyTorch环境的最小配置2.1 为什么选MNIST而不是自己拍照收集数据MNIST之所以被反复使用是因为它把“数据准备”这一步压缩到了极致。每张图片是28x28像素的灰度数组数值范围0到255标签是0到9之间的一个整数。不需要自己做标注不需要做清洗也不需要复杂的数据增强下载下来就是标准格式。第一次做手写数字识别如果直接用自己手机拍的数字照片会立刻遇到背景、光照、倾斜、大小不一致的问题模型还没开始训练就被数据搞崩溃了。还有一个很现实的原因MNIST可以在普通CPU上跑完整个训练流程。一个简单的全连接网络训练5个epoch在主流笔记本上只需要几分钟即使换成卷积神经网络10个epoch也基本在十分钟以内。对比现在动辄几GB的图片数据集MNIST更适合用来验证训练流程、理解模型保存和加载以及排查环境问题。等你在MNIST上把整套管道跑通了再去换自己的数据集路径就是可复制的。2.2 用torchvision加载MNIST的完整命令我最常用的做法是用PyTorch配合torchvision的数据集接口。装好依赖后加载MNIST只需要几行代码。先看安装命令pip install torch torchvision如果你用CPU训练安装CPU版就够了如果需要用GPU请根据CUDA版本去PyTorch官网选对应的安装命令。装完之后验证一下python -c import torch; print(torch.__version__) python -c import torchvision; print(torchvision.__version__)能正常输出版本号说明环境可用。然后加载MNIST数据from torchvision import datasets, transforms transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset datasets.MNIST( root./data, # 数据集存放路径 trainTrue, # 加载训练集 transformtransform, # 图像预处理 downloadTrue # 如果本地没有就自动下载 ) test_dataset datasets.MNIST( root./data, trainFalse, transformtransform, downloadTrue ) print(训练集大小:, len(train_dataset)) print(测试集大小:, len(test_dataset))这里有两个参数必须说明。transformToTensor()会把PIL图像从0到255的整数变成0到1之间的浮点数张量同时把形状从28x28变成1x28x28也就是增加一个通道维。Normalize((0.1307,), (0.3081,))是MNIST数据集的全局均值和标准差做了标准化之后训练会更稳定。如果你不标准化也能跑但收敛速度和最终准确率通常会差一些。2.3 数据参数说明batch_size、归一化与数据增强加载完数据还要用DataLoader把数据集切成批次这里涉及几个常用参数from torch.utils.data import DataLoader batch_size 128 train_loader DataLoader( train_dataset, batch_sizebatch_size, shuffleTrue, # 训练时打乱数据 num_workers0 # Windows建议设为0Linux可设为4 ) test_loader DataLoader( test_dataset, batch_sizebatch_size, shuffleFalse, num_workers0 )batch_size决定了一次向前传播和反向传播用多少张图片。我一般先用128如果你的内存吃紧就降到64注意batch太大容易导致训练震荡太小则训练变慢。shuffleTrue只用于训练集目的是让每个batch的数据分布尽量接近整体分布测试集不需要打乱。num_workers是并行加载数据的进程数Windows系统上设置大于0经常出现运行时错误建议就从0开始。MNIST不需要做随机裁剪、旋转这类数据增强因为数字的写法本来就高度标准化如果强行做太多增强反而会让原本很简单的任务变难模型准确率会掉。这一点和做自然场景图片分类的逻辑不太一样新手容易往里加一堆不该有的操作。保持ToTensor加Normalize就够了。3. 训练一个数字识别模型从全连接到CNN的选型与参数3.1 全连接网络还是CNN第一次做怎么选很多教程一上来就推荐卷积神经网络但如果你只是为了跑通MNIST训练流程我建议先从全连接网络开始。全连接网络把28x28的图片展平成784个输入特征经过两到三个隐藏层输出10个类别的得分。结构简单参数少训练快出问题的概率也低对MNIST这种小图全连接网络足够达到97%到98%的准确率。当你把全连接网络跑通再换成CNN你会立刻感受到特征提取能力的差异。MNIST里的数字由局部笔画组成卷积核可以捕捉边缘、弧线和交叉点这些局部结构所以CNN只需要拿到类似参数量就能超过99%。我的建议是第一次训练用全连接网络验证管道后续追求效果再切换到CNN。不要一上来就用很深的ResNet既慢又容易过拟合。3.2 模型定义代码与关键参数下面是一个标准的全连接网络定义我习惯用nn.Sequential把层串起来结构清晰import torch.nn as nn class MLP(nn.Module): def __init__(self): super(MLP, self).__init__() self.net nn.Sequential( nn.Flatten(), # 将28x28x1展平成784 nn.Linear(784, 256), # 输入784隐层256 nn.ReLU(), nn.Linear(256, 128), # 第二隐层128 nn.ReLU(), nn.Linear(128, 10) # 输出10个类别得分 ) def forward(self, x): return self.net(x) model MLP() print(model)nn.Flatten()把形状为[batch_size, 1, 28, 28]的张量变成[batch_size, 784]。中间隐藏层用了256和128个神经元这两个数不需要纠结范围在64到512之间效果差别不大。输出层不接激活函数因为后面要配合CrossEntropyLoss这个损失函数内部已经做了Softmax。如果你在输出层自己加了Softmax再交给CrossEntropyLoss会出现双重Softmax导致的概率分布被压平训练会变慢甚至不收敛。如果你想换成CNN只需要把模型替换成下面的结构class CNN(nn.Module): def __init__(self): super(CNN, self).__init__() self.features nn.Sequential( nn.Conv2d(1, 32, kernel_size3, padding1), # 1通道转32通道 nn.ReLU(), nn.MaxPool2d(2, 2), # 28x28 - 14x14 nn.Conv2d(32, 64, kernel_size3, padding1), nn.ReLU(), nn.MaxPool2d(2, 2) # 14x14 - 7x7 ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(64 * 7 * 7, 128), nn.ReLU(), nn.Linear(128, 10) ) def forward(self, x): return self.classifier(self.features(x))这个CNN用两层3x3卷积加两个2x2最大池化。第一次卷积后特征图变大32通道池化后缩小到14x14第二次卷积变为64通道池化后变成7x7。最后展平得到64 * 7 * 7即3136个特征接一个128维全连接后输出10类。padding1是为了让3x3卷积保持尺寸不变。这个模型在MNIST上的准确率通常能达到99%以上CPU训练也很快。3.3 训练循环损失函数、优化器与学习率训练循环是整套代码的核心。下面这段代码包含了完整的迭代过程我加了关键注释import torch.optim as optim device torch.device(cuda if torch.cuda.is_available() else cpu) print(当前设备:, device) model MLP().to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr0.001) epochs 10 for epoch in range(epochs): model.train() # 进入训练模式 running_loss 0.0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() # 梯度清零 outputs model(images) # 前向传播 loss criterion(outputs, labels) # 计算损失 loss.backward() # 反向传播 optimizer.step() # 更新参数 running_loss loss.item() * images.size(0) epoch_loss running_loss / len(train_dataset) # 在测试集上评估 model.eval() correct 0 total 0 with torch.no_grad(): for images, labels in test_loader: images, labels images.to(device), labels.to(device) outputs model(images) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() accuracy correct / total print(fEpoch {epoch1}/{epochs}, Loss: {epoch_loss:.4f}, Test Acc: {accuracy:.4f})这一段里的每个操作都有讲究。optimizer.zero_grad()必须在每次反向传播前调用否则梯度会累加model.train()和model.eval()要成对出现因为BatchNorm和Dropout在训练和测试时行为不同torch.no_grad()告诉PyTorch在评估阶段不要构建计算图这样既省内存又加快速度。torch.max(outputs, 1)取每个样本10个得分中最大值对应的索引就是预测的类别。优化器我用Adam搭配lr0.001这个组合在绝大多数小型分类任务上都能稳定收敛。如果你想看不同学习率的效果可以试试0.01和0.0001你会发现0.01容易震荡、0.0001收敛很慢0.001是个经验上比较稳妥的起点。训练epoch数全连接网络10个epoch左右足够CNN甚至可以更少如果loss在前3个epoch就降到很低继续训练更多epoch可能只会带来零点几个百分点的提升有些时候反而会过拟合。4. 附完整代码和训练好的模型文件从训练到“直接用”4.1 完整训练脚本可直接复制运行现在把前面的代码串成一个完整的训练脚本。这也是标题里“附完整代码”最核心的部分。下面是可以在本地直接运行的版本import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader from torchvision import datasets, transforms # 1. 数据准备 transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset datasets.MNIST( root./data, trainTrue, transformtransform, downloadTrue ) test_dataset datasets.MNIST( root./data, trainFalse, transformtransform, downloadTrue ) train_loader DataLoader(train_dataset, batch_size128, shuffleTrue) test_loader DataLoader(test_dataset, batch_size128, shuffleFalse) # 2. 模型定义 class MLP(nn.Module): def __init__(self): super(MLP, self).__init__() self.net nn.Sequential( nn.Flatten(), nn.Linear(784, 256), nn.ReLU(), nn.Linear(256, 128), nn.ReLU(), nn.Linear(128, 10) ) def forward(self, x): return self.net(x) # 3. 训练参数 device torch.device(cuda if torch.cuda.is_available() else cpu) model MLP().to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr0.001) epochs 10 # 4. 训练循环 for epoch in range(epochs): model.train() running_loss 0.0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() * images.size(0) epoch_loss running_loss / len(train_dataset) model.eval() correct 0 total 0 with torch.no_grad(): for images, labels in test_loader: images, labels images.to(device), labels.to(device) outputs model(images) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() accuracy correct / total print(fEpoch {epoch1}/{epochs}, Loss: {epoch_loss:.4f}, Test Acc: {accuracy:.4f}) # 5. 保存训练好的模型 torch.save(model.state_dict(), mnist_mlp_model.pt) print(模型已保存到 mnist_mlp_model.pt)这个脚本有几个要点。downloadTrue是第一次运行时自动下载数据集如果网络不稳定会失败到第5章我会讲手动下载的方案。训练完保存的是state_dict也就是模型的所有可训练参数不包含网络结构定义这样文件更小、更通用。保存路径你可以改成自己喜欢的名字注意后缀用.pt或.pth都可以这是PyTorch的惯例。4.2 保存和加载模型.pt文件与推理标题里说“附训练好的模型文件”这里的模型文件通常就是上面保存的.pt文件。要加载它并完成推理需要先重新定义一遍模型结构再把参数加载进去。很多人栽在“直接torch.load一个模型文件”上因为如果只用torch.save(model.state_dict())保存加载时必须先有模型实例。下面是完整的加载和推理代码import torch from PIL import Image from torchvision import transforms # 1. 重新定义模型结构必须和保存时完全一致 class MLP(nn.Module): def __init__(self): super(MLP, self).__init__() self.net nn.Sequential( nn.Flatten(), nn.Linear(784, 256), nn.ReLU(), nn.Linear(256, 128), nn.ReLU(), nn.Linear(128, 10) ) def forward(self, x): return self.net(x) # 2. 加载模型参数 model MLP() model.load_state_dict(torch.load(mnist_mlp_model.pt, map_locationcpu)) model.eval() print(模型加载成功)map_locationcpu这个参数很重要。如果模型文件是用GPU训练的直接在你只有CPU的电脑上加载会报张量设备不匹配的错误加上这个参数后PyTorch会把所有参数映射到CPU上。如果你的电脑有GPU想加载到GPU可以把值改成cuda。load_state_dict要求加载的键和当前模型的键完全匹配如果你保存时用的是完整模型对象而加载时用的是state_dict就会报键名错误。4.3 模型文件怎么用一行命令跑推理模型加载完成后要对一张图片做预测需要把图片转换成模型期望的输入格式。下面这个函数接受一个28x28的灰度图片路径返回预测数字def predict_digit(image_path, model): # 读图并转换为灰度缩放到28x28 img Image.open(image_path).convert(L).resize((28, 28)) # 与训练时一致的预处理转张量 标准化 transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) tensor transform(img).unsqueeze(0) # 增加batch维 with torch.no_grad(): outputs model(tensor) _, predicted torch.max(outputs, 1) return predicted.item()unsqueeze(0)把形状从[1, 28, 28]变成[1, 1, 28, 28]因为模型输入要求带batch维。convert(L)是把彩色图转成灰度resize((28, 28))是强制缩放。这里最常见的问题是你自己手写的数字和MNIST里数字的粗细、位置不一样导致预测不准到第6章我专门讲怎么处理。如果你保存了完整模型而不是state_dict可以这样加载model torch.load(mnist_mlp_full.pt, map_locationcpu)但我不推荐这种保存方式因为完整模型会把网络结构和参数打包在一起升级PyTorch版本后可能加载不了。保存state_dict更干净换版本后只要模型定义代码不变参数就还是能加载。5. MNIST训练避坑与常见问题从404到玄学不收敛5.1 torchvision下载MNIST报404或超时现象运行datasets.MNIST(..., downloadTrue)时进度条一直停在某个百分比最后报HTTP Error 404: Not Found或者连接超时。原因torchvision默认从Yann LeCun维护的官网地址下载MNIST国内网络经常会失败同时PyTorch版本更新后URL也可能变化。这个报错和你的代码逻辑无关纯粹是网络问题。解决手动下载MNIST的四个压缩包放到./data/MNIST/raw/目录下。去MNIST官网或者找可访问的镜像下载train-images-idx3-ubyte.gz、train-labels-idx1-ubyte.gz、t10k-images-idx3-ubyte.gz、t10k-labels-idx1-ubyte.gz这四个文件然后重新运行脚本。注意文件名必须完全一致因为torchvision是根据文件名判断文件是否存在的。如果你已经有了一个下载了一半的损坏文件先删掉再放新的否则torchvision可能跳过下载但解压失败。5.2 训练loss不下降或准确率一直很低现象第一个epoch的loss就很大跑了几个epoch后loss几乎不下降测试准确率停留在10%左右跟随机猜一样。原因最常见的是学习率设置得太大比如lr0.1或lr1导致损失函数在震荡甚至发散另一个原因是交叉熵损失和输出层激活函数冲突比如你在输出层加了Softmax再配合CrossEntropyLoss梯度信号被扭曲。解决把优化器换成Adam学习率设为0.001这是最稳的组合。检查模型最后一层是否没有激活函数并且损失函数用的是nn.CrossEntropyLoss()。如果还是不行把batch中随机挑几张图片可视化确认图片数据有没有变成全黑或全白。还有一个容易被忽略的点不要在训练循环里忘记调用optimizer.zero_grad()否则梯度会累加导致loss周期性飙升。5.3 模型文件加载报错张量尺寸不匹配现象执行model.load_state_dict(torch.load(mnist_mlp_model.pt))时报错说size mismatch for net.2.weight: copying a param with shape torch.Size([256, 784]) ...。原因加载模型时定义的网络结构和保存时不一致。比如保存时隐藏层是256加载时改成128参数个数对不上。或者你保存的是一个nn.Sequential的完整模型加载时却定义了一个类包装的结构导致键名对不上。解决把模型的类定义原封不动复制到加载脚本里确保每一层的名称和保存时一致。如果你不确定保存的是什么格式可以用torch.load(xxx.pt, map_locationcpu)把文件打印出来看一下如果是collections.OrderedDict就是state_dict如果直接是模型对象就必须换加载方式。我一般建议项目里保留一份模型定义文件加载和训练共用同一个类这个“后悔药”能帮你省下大量排查时间。5.4 测试集准确率很高但识别本地图片效果差现象测试集准确率到了98%以上但把自己写或打印的数字图片喂给模型经常识别错误而且错得毫无规律。原因MNIST数据集的图片是28x28灰度、数字基本居中且粗细统一而你自己生成的图片可能是几百x几百的彩色图、数字位置偏移或线宽完全不同。模型学到的分布和你输入图片的分布不一致自然就翻车。解决对输入图片做预处理把它变成MNIST风格的图片转为灰度缩放到28x28把数字部分居中必要时反转颜色MNIST是黑底白字白底黑字要反转。如果你发现图片里数字太细可以先用形态学膨胀加粗太粗就做腐蚀。这部分我也放到第6章展开因为它是“直接用”这个标题落地时最需要动手的环节。5.5 训练时间过长或内存占用爆炸现象在torch的DataLoader设置num_workers8之后程序直接崩溃或者训练到一半内存占用持续上涨。原因num_workers在Windows的spawn方式下经常出问题数值越大越容易踩坑内存爆炸则可能是你每个epoch都在累积计算图比如在评估时忘了加torch.no_grad()。解决Windows上num_workers0最保险Linux可以按CPU核数减一设置。评估时务必使用torch.no_grad()上下文管理器。如果你在训练循环里保存了所有batch的loss到列表那个列表会越来越大但并不影响最终结果建议只保留当前epoch的平均值。另外训练好的模型及时保存因为训练到一半可能因为断电或系统更新把进程杀掉保存的参数文件就是你最大的后悔药。6. 进阶用法用自己的手写数字图片验证模型6.1 把本地图片变成MNIST格式要让训练好的模型识别你自己的数字图片而不是只会在测试集上算准确率需要一套可靠的图片预处理流程。我常用的方法是先用OpenCV读取图片再依次做灰度化、二值化、边框裁剪、缩放和颜色反转。下面是一个可以直接用的示例import cv2 import numpy as np def preprocess_local_image(image_path): # 读图并转灰度 img cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) # 反色MNIST是黑底白字图片通常是白底黑字 img 255 - img # 找到数字区域的轮廓并裁剪 contours, _ cv2.findContours(img, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if contours: x, y, w, h cv2.boundingRect(contours[0]) img img[y:yh, x:xw] # 缩放到20x20再放到28x28中间保持MNIST风格 img cv2.resize(img, (20, 20), interpolationcv2.INTER_AREA) canvas np.zeros((28, 28), dtypenp.uint8) canvas[4:24, 4:24] img # 归一化到0~1并标准化 img canvas.astype(np.float32) / 255.0 img (img - 0.1307) / 0.3081 return torch.from_numpy(img).unsqueeze(0).unsqueeze(0)这个函数的每一步都在模仿MNIST的生成过程。原始MNIST图片里数字约占20x20的区域周围有4像素左右的空白所以这里先缩放成20x20再贴到28x28画布中央。反色步骤容易被忽略但非常重要。如果模型输出准确率低先检查预处理后的图片用cv2.imwrite(debug.png, canvas)保存看看对比一下和MNIST里的数字长相是否接近。6.2 批量测试与错误分析找出模型的薄弱点单个图片预测偶尔会看运气想客观评估模型可以把测试集里预测错误的所有图片收集出来打印出真实标签和预测标签统计错误集中在哪些数字对。下面这段代码可以帮你做混淆分析from collections import defaultdict model.eval() error_stats defaultdict(int) error_samples [] with torch.no_grad(): for images, labels in test_loader: images, labels images.to(device), labels.to(device) outputs model(images) _, predicted torch.max(outputs, 1) mask (predicted ! labels) for img, true_label, pred_label in zip(images[mask], labels[mask], predicted[mask]): error_stats[(true_label.item(), pred_label.item())] 1 if len(error_samples) 5: error_samples.append((img.cpu(), true_label.item(), pred_label.item())) print(错误最多的数字对:) for pair, count in sorted(error_stats.items(), keylambda x: -x[1])[:10]: print(f真实 {pair[0]} - 预测 {pair[1]}: {count} 次)MNIST里最容易混淆的是4和9、7和9、3和5这几组因为它们形状接近。如果你发现错误集中在一两个数字对可以考虑给模型增加数据增强或者换用CNN效果会非常明显。这也是“训练好的模型文件直接用”之后最值得做的一步。6.3 训练时的几个好习惯固定随机种子与记录参数最后一个建议不是技术难点但能帮你少走很多弯路。训练前固定随机种子确保每次运行结果可复现import random def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed)调用set_seed(42)后同一份代码跑两次测试准确率会一致不会出现“昨天99%、今天98.6%”的玄学差异。另外一个习惯是把每次实验的batch_size、学习率、模型结构、最终准确率记录到一个文本文件里。MNIST这个项目不大但当你想对比全连接和CNN、或者调整学习率时一份记录能让你一眼看出哪个参数组合最好而不是靠模糊记忆。这算是我自己踩了多次翻车的坑之后养成的习惯。希望帮到你。本文还有配套的精品资源点击获取
返回列表