news 2026/10/20 4:05:27

PyTorch实战:手写数字识别从零到99%准确率

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PyTorch实战:手写数字识别从零到99%准确率

1. 手写数字识别项目整体设计与思路拆解

手写数字识别这个题目,放在深度学习入门里,地位大概相当于编程语言里的“Hello World”。但别小看它,能把MNIST这个数据集从头到尾跑通、调好、理解透,基本就摸到了图像分类任务的门道。我见过不少朋友,上来就冲ResNet、Transformer,结果连一个简单的全连接网络为什么用交叉熵损失都说不清楚。所以这个项目我建议你认真对待,它值得花时间。

1.1 为什么选择PyTorch而不是其他框架

先说框架选型。做手写数字识别,TensorFlow、PaddlePaddle、PyTorch都能做,但我个人强烈推荐PyTorch,原因有三个。

第一是动态图机制。PyTorch默认是动态计算图,你写一行代码它就执行一行,调试的时候可以直接用Python的print看中间结果。这对新手太重要了。静态图框架你需要先定义整个计算图再运行,中间想看个张量形状都得用特殊接口,调试体验差很多。手写数字识别虽然简单,但你是来学东西的,不是来受罪的。

第二是API设计的一致性。PyTorch的nn.Module、nn.Sequential、optim、DataLoader这套东西,学一次就能用到所有任务上。你做完MNIST,换成CIFAR-10,代码结构几乎不用改,只改数据加载和模型定义就行。这种迁移性对建立知识体系很关键。

第三是社区生态。你在网上搜任何深度学习的问题,PyTorch相关的回答和开源代码是最多的。遇到坑的时候,能快速找到别人踩过的解决方案。这一点在实际开发中比什么都重要。

1.2 手写数字识别到底在解决什么问题

从任务本质来说,手写数字识别是一个10分类问题。输入是一张28x28像素的灰度图,输出是0到9这十个数字的概率分布。听起来简单,但里面包含了图像分类任务的完整流程:数据加载、预处理、模型定义、前向传播、损失计算、反向传播、参数更新、模型评估。

这个任务的核心难点在于手写数字的多样性。同一个人写“7”,可能带横杠也可能不带;不同人写的“1”,倾斜角度、粗细、长短都不一样。模型要做的,是从这些像素级别的变化中,提取出能区分不同数字的特征。这其实就是所有图像分类任务的本质。

1.3 项目整体架构设计

我习惯把整个项目拆成五个模块来写,这样结构清晰,也方便后续扩展。

  • 数据模块:负责加载MNIST数据集,做归一化处理,划分训练集和测试集,用DataLoader做批处理。
  • 模型模块:定义一个卷积神经网络,包含卷积层、池化层、全连接层。
  • 训练模块:定义损失函数、优化器,写训练循环,每个epoch打印损失和准确率。
  • 评估模块:在测试集上评估模型性能,计算准确率,可视化混淆矩阵。
  • 推理模块:加载训练好的模型,对单张图片做预测,输出结果。

这种模块化设计的好处是,你想换模型只改模型模块,想换数据集只改数据模块,其他部分不用动。我见过很多人把所有代码写在一个文件里,几百行堆在一起,改一个地方就出bug,非常痛苦。

提示:项目初期就养成模块化的习惯,哪怕文件多一点,也比后期重构省事。

2. 核心细节解析与实操要点

2.1 MNIST数据集的处理细节

MNIST数据集包含60000张训练图片和10000张测试图片,每张是28x28的灰度图。PyTorch的torchvision.datasets里直接有MNIST的加载接口,用起来很方便。但有几个细节必须注意。

第一个是归一化。原始像素值是0到255的整数,直接输入网络会导致梯度爆炸或者收敛很慢。标准做法是转成0到1的浮点数,再减去均值除以标准差。MNIST的全局均值大约是0.1307,标准差是0.3081。这个数值是统计出来的,直接用就行。

transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ])

第二个是数据增强。MNIST本身比较干净,但如果你想提升模型的泛化能力,可以加一点随机旋转或随机平移。不过要注意,数字“6”和“9”旋转180度就互换了,所以旋转角度不能太大,一般控制在正负10度以内。

第三个是批处理大小。我试过32、64、128、256这几个值,64和128是比较稳的选择。批太小训练慢,批太大内存吃紧而且可能影响收敛。如果你显卡内存够,128是个不错的起点。

2.2 卷积神经网络的结构设计

手写数字识别用全连接网络也能做到97%左右的准确率,但用卷积神经网络可以轻松到99%以上。为什么卷积更好?因为卷积操作能保留空间信息,提取局部特征,而且参数共享大大减少了参数量。

我设计的网络结构是这样的:

  • 第一层卷积:输入1通道,输出32通道,卷积核3x3,padding为1
  • 第一层池化:2x2最大池化
  • 第二层卷积:输入32通道,输出64通道,卷积核3x3,padding为1
  • 第二层池化:2x2最大池化
  • 全连接层:输入64乘以7乘以7,输出128
  • 输出层:输入128,输出10

这个结构不算深,但对手写数字识别足够了。卷积核用3x3是经典选择,两个3x3卷积的感受野相当于一个5x5,但参数更少,非线性更强。padding设为1是为了保持特征图尺寸不变,只在池化时降维。

激活函数用ReLU,这个没什么争议。ReLU计算简单,梯度不会饱和,训练速度快。输出层用LogSoftmax配合负对数似然损失,或者直接输出logits配合交叉熵损失,两种写法等价,我习惯后者。

2.3 损失函数与优化器的选择逻辑

损失函数用交叉熵损失,这是分类任务的标准配置。交叉熵衡量的是预测概率分布和真实标签分布的差异,配合Softmax使用,梯度形式很简洁,就是预测值减去真实值。

优化器我推荐用Adam,学习率设0.001。Adam结合了动量和自适应学习率的优点,对初始学习率不敏感,收敛快。SGD也能用,但需要调学习率和动量参数,对新手不友好。等你把Adam跑通了,可以试试SGD加动量0.9,学习率0.01,感受一下两者的差异。

学习率调度方面,我一般不加复杂的调度器,就固定学习率训练10到15个epoch。MNIST这个任务太简单了,不需要太多花哨的东西。如果你发现损失震荡,可以把学习率降到0.0005试试。

2.4 训练循环中的关键操作

训练循环里有几个操作是必须的,我逐个说明。

optimizer.zero_grad()必须放在反向传播之前。PyTorch的梯度是累加的,不清零的话,这次计算的梯度会加上上次的,导致更新方向错误。这个坑我踩过,当时损失不降反升,排查了半天才发现是忘了清零。

loss.backward()是反向传播,计算所有参数的梯度。optimizer.step()是参数更新,根据梯度和学习率调整权重。这两步的顺序不能颠倒。

还有一个细节是model.train()和model.eval()的切换。训练时用model.train(),启用Dropout和BatchNorm的训练模式。评估时用model.eval(),关闭Dropout,BatchNorm用移动平均。虽然我这个网络没用Dropout和BatchNorm,但养成这个习惯很重要,以后换复杂模型就不会出错。

注意:评估时一定要用torch.no_grad()上下文管理器,不然会构建计算图,浪费内存还拖慢速度。

3. 实操过程与核心环节实现

3.1 环境准备与依赖安装

先确认你的环境。Python版本建议3.8以上,PyTorch版本2.0以上。如果你有NVIDIA显卡,装CUDA版本的PyTorch,训练速度会快很多。没有显卡也没关系,MNIST这个任务用CPU训练也就几分钟一个epoch。

安装命令很简单:

pip install torch torchvision

如果你需要指定CUDA版本,去PyTorch官网查对应的安装命令。我一般用conda管理环境,这样不同项目的依赖不会冲突。

conda create -n mnist python=3.9 conda activate mnist pip install torch torchvision matplotlib

matplotlib是用来画图可视化的,后面看训练曲线和预测结果会用到。

3.2 数据加载与预处理代码实现

数据加载这部分,我直接上代码,然后逐段解释。

import torch from torchvision import datasets, transforms from torch.utils.data import DataLoader # 定义预处理 transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) # 加载训练集 train_dataset = datasets.MNIST( root='./data', train=True, download=True, transform=transform ) # 加载测试集 test_dataset = datasets.MNIST( root='./data', train=False, download=True, transform=transform ) # 创建DataLoader train_loader = DataLoader( train_dataset, batch_size=128, shuffle=True, num_workers=2 ) test_loader = DataLoader( test_dataset, batch_size=128, shuffle=False, num_workers=2 )

root='./data'指定数据存放路径,第一次运行会自动下载。download=True只在第一次需要,后面会检查本地有没有数据。shuffle=True对训练集很重要,每个epoch打乱顺序,防止模型学到样本顺序相关的虚假特征。测试集不需要打乱。

num_workers=2是数据加载的进程数。Windows上如果报错,改成0就行。这个参数影响数据加载速度,设成CPU核心数的一半左右比较合适。

3.3 模型定义的完整代码

模型定义我写成一个类,继承nn.Module。

import torch.nn as nn import torch.nn.functional as F class Net(nn.Module): def __init__(self): super(Net, self).__init__() self.conv1 = nn.Conv2d(1, 32, 3, padding=1) self.conv2 = nn.Conv2d(32, 64, 3, padding=1) self.pool = nn.MaxPool2d(2, 2) self.fc1 = nn.Linear(64 * 7 * 7, 128) self.fc2 = nn.Linear(128, 10) self.dropout = nn.Dropout(0.25) def forward(self, x): x = self.pool(F.relu(self.conv1(x))) x = self.pool(F.relu(self.conv2(x))) x = x.view(-1, 64 * 7 * 7) x = F.relu(self.fc1(x)) x = self.dropout(x) x = self.fc2(x) return x

x.view(-1, 64 * 7 * 7)是展平操作,把多维特征图拉成一维向量,才能输入全连接层。-1表示自动推断batch大小。28x28输入经过两次2x2池化,变成7x7,通道数是64,所以展平后是64乘以7乘以7等于3136。

Dropout我加了0.25,这是为了防止过拟合。MNIST数据量不算大,加一点Dropout能提升泛化能力。但Dropout率不能太高,0.5以上会导致欠拟合。

3.4 训练过程的完整实现

训练代码我习惯写成一个函数,参数包括模型、数据加载器、优化器、epoch数。

def train(model, device, train_loader, optimizer, epoch): model.train() for batch_idx, (data, target) in enumerate(train_loader): data, target = data.to(device), target.to(device) optimizer.zero_grad() output = model(data) loss = F.cross_entropy(output, target) loss.backward() optimizer.step() if batch_idx % 100 == 0: print(f'Epoch: {epoch} [{batch_idx * len(data)}/{len(train_loader.dataset)}]' f'\tLoss: {loss.item():.6f}')

data.to(device)是把数据搬到GPU或CPU上。如果你用GPU训练,模型也要.to(device)。设备和模型必须在同一个设备上,不然会报错。

每100个batch打印一次损失,方便观察训练过程。损失应该是逐渐下降的,如果震荡或者上升,说明学习率太大或者有bug。

3.5 测试评估与结果分析

测试函数和训练函数结构类似,但不需要反向传播和参数更新。

def test(model, device, test_loader): model.eval() test_loss = 0 correct = 0 with torch.no_grad(): for data, target in test_loader: data, target = data.to(device), target.to(device) output = model(data) test_loss += F.cross_entropy(output, target, reduction='sum').item() pred = output.argmax(dim=1, keepdim=True) correct += pred.eq(target.view_as(pred)).sum().item() test_loss /= len(test_loader.dataset) accuracy = 100. * correct / len(test_loader.dataset) print(f'\nTest set: Average loss: {test_loss:.4f}, ' f'Accuracy: {correct}/{len(test_loader.dataset)} ({accuracy:.2f}%)\n') return accuracy

output.argmax(dim=1)取概率最大的那个类别作为预测结果。pred.eq(target.view_as(pred))比较预测和真实标签,相等为1,不等为0,求和就是正确数量。

正常训练10个epoch,测试准确率应该能到99%以上。如果只有97%左右,检查一下归一化参数对不对,或者模型结构有没有写错。

3.6 主训练流程与超参数设置

把上面的模块串起来,主流程如下:

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = Net().to(device) optimizer = torch.optim.Adam(model.parameters(), lr=0.001) for epoch in range(1, 11): train(model, device, train_loader, optimizer, epoch) test(model, device, test_loader)

学习率0.001,batch size 128,训练10个epoch。这套参数我跑过很多次,稳定在99.2%到99.5%之间。如果你想更高,可以训练15个epoch,或者加一点数据增强。

提示:训练结束后记得保存模型参数,用torch.save(model.state_dict(), 'mnist_cnn.pt'),下次直接加载就能用,不用重新训练。

4. 常见问题与排查技巧实录

4.1 训练损失不下降怎么办

这是最常见的问题。可能的原因有四个。

第一,忘了optimizer.zero_grad()。梯度累加导致更新方向错误,损失会震荡甚至上升。检查你的训练循环,确保清零操作在反向传播之前。

第二,学习率太大。损失来回震荡,不收敛。把学习率降到0.0001试试,如果损失开始稳定下降,说明就是学习率的问题。

第三,数据没有归一化。原始像素值0到255,输入网络后梯度非常大,参数更新幅度过大,损失直接爆炸。检查transform里有没有Normalize。

第四,模型输出层和损失函数不匹配。比如输出层用了Softmax,损失函数又用CrossEntropyLoss,相当于做了两次Softmax,梯度会出问题。CrossEntropyLoss内部已经包含LogSoftmax,输出层直接输出logits就行。

4.2 测试准确率远低于训练准确率

这是过拟合的典型表现。训练集准确率99.9%,测试集只有97%,说明模型记住了训练样本,但没有学到通用特征。

解决方法有三个。加Dropout层,我上面代码里加了0.25,你可以试试0.3或0.5。加L2正则化,在优化器里设weight_decay=1e-4。加数据增强,随机旋转或平移。这三个方法可以组合使用,但不要一次全加上,逐个试,看哪个效果好。

还有一种可能是测试集和训练集分布不一致。MNIST的测试集和训练集是同一批人写的,分布应该一致。如果你自己收集了手写数字图片做测试,准确率低是正常的,因为书写风格不同。

4.3 GPU内存不足的排查思路

如果你用GPU训练,报CUDA out of memory,有几个处理方式。

降低batch size,从128降到64或32。这是最直接的方法,但训练会慢一点。减小模型规模,比如把卷积通道数从32、64降到16、32。用torch.cuda.empty_cache()清理缓存,但这个只能缓解,不能根本解决。检查有没有在训练循环里累积张量,比如把每个batch的loss存到列表里,时间长了会占满内存。

我一般用nvidia-smi命令查看GPU占用情况,确认是模型太大还是数据太多。

4.4 常见问题速查表

问题现象可能原因解决方法
损失不下降忘记zero_grad在backward前加optimizer.zero_grad()
损失震荡学习率太大降低学习率到0.0001
损失爆炸数据未归一化加Normalize预处理
测试准确率低过拟合加Dropout或weight_decay
GPU内存不足batch太大降低batch size
训练速度慢没用GPU检查device设置
准确率卡在10%输出层维度错误检查fc2输出是否为10

4.5 独家避坑经验分享

第一个坑是数据下载失败。MNIST数据集从外网下载,有时候会超时。解决办法是手动下载四个gz文件,放到./data/MNIST/raw/目录下,然后设置download=False。文件在网上搜“MNIST数据集”就能找到。

第二个坑是Windows上num_workers报错。Windows的DataLoader多进程实现和Linux不同,设num_workers大于0可能会卡死。解决办法是设成0,或者把代码放到if __name__ == '__main__':里面。

第三个坑是模型保存和加载不匹配。保存的时候用了torch.save(model, 'model.pth'),加载的时候用torch.load('model.pth'),这样保存的是整个模型对象,换环境可能加载失败。正确做法是保存model.state_dict(),加载时先实例化模型再load_state_dict()。

第四个坑是忘了切换train和eval模式。训练时用model.train(),评估时用model.eval()。如果评估时忘了切换,Dropout还在随机丢弃神经元,准确率会偏低。这个坑很隐蔽,因为不会报错,只是结果不对。

第五个坑是学习率设得太大导致梯度爆炸。我见过有人设0.1,损失直接变成nan。Adam的默认学习率0.001是经过大量实验验证的,新手不要随便改。要改也是往小了改,不要往大了改。

4.6 模型推理与单张图片预测

训练好模型后,你可以写一个推理脚本,对单张图片做预测。

def predict(model, device, image_tensor): model.eval() with torch.no_grad(): image_tensor = image_tensor.to(device) output = model(image_tensor.unsqueeze(0)) pred = output.argmax(dim=1, keepdim=True) return pred.item()

unsqueeze(0)是增加一个batch维度,因为模型输入要求是4维张量。预测结果是一个0到9的整数。

如果你想看模型对哪些数字容易混淆,可以画混淆矩阵。用sklearn的confusion_matrix函数,传入真实标签和预测标签,就能看到每个数字的误分类情况。我跑下来发现,4和9、3和5、7和1比较容易混。这其实和人的视觉感受也一致,说明模型学到的特征和人类似。

提示:推理时一定要用torch.no_grad(),不然会构建计算图,内存占用大而且速度慢。

5. 项目扩展与进阶方向

5.1 从MNIST到真实场景的迁移

MNIST太干净了,真实场景的手写数字识别要复杂得多。比如银行支票上的数字,有背景干扰、有印章遮挡、有书写潦草。如果你想做真实场景,需要做几件事。

数据方面,收集真实场景的图片,做标注。用OpenCV做预处理,包括灰度化、二值化、去噪、倾斜校正。模型方面,可以先用MNIST预训练,然后在真实数据上微调。这叫迁移学习,能大大减少对标注数据的需求。

5.2 模型压缩与部署

如果你想把模型部署到移动端或嵌入式设备,需要考虑模型大小和推理速度。我上面那个模型大概几百KB,已经很小了。但如果要更小,可以做量化,把浮点参数转成8位整数,模型大小减少四分之三,推理速度提升两三倍,准确率只降零点几个百分点。

PyTorch提供了量化工具,torch.quantization.quantize_dynamic可以动态量化,用起来很简单。还有剪枝,把不重要的权重置零,减少计算量。这些技术在实际部署中很常用。

5.3 用TensorBoard可视化训练过程

训练的时候只看打印的损失不够直观,用TensorBoard可以看损失曲线、准确率曲线、权重分布。PyTorch集成了TensorBoard,用torch.utils.tensorboard.SummaryWriter就能写日志。

from torch.utils.tensorboard import SummaryWriter writer = SummaryWriter('runs/mnist_experiment') writer.add_scalar('Loss/train', loss.item(), epoch) writer.add_scalar('Accuracy/test', accuracy, epoch)

然后命令行运行tensorboard --logdir=runs,浏览器打开就能看。这个工具我强烈推荐,调参的时候特别有用,能直观看到哪个参数效果好。

5.4 我个人的实操体会

这个项目我做过很多遍,每次都有新收获。最开始我只关心准确率,后来发现理解每一步为什么这么做更重要。比如为什么用卷积不用全连接,为什么用Adam不用SGD,为什么归一化参数是0.1307和0.3081。这些问题想清楚了,换个数据集、换个任务,你也能快速上手。

还有一个体会是,不要怕犯错。我训练的时候遇到过损失变nan,遇到过准确率卡在10%,遇到过GPU内存爆掉。每次排查都是学习的机会。现在我看到报错信息,基本能猜到问题在哪,这就是经验积累的结果。

最后分享一个小技巧:训练前先跑一个epoch,确认流程能跑通,再跑完整训练。这样能快速发现代码bug,不用等半天才发现问题。还有,随机种子要固定,不然每次结果不一样,没法对比实验。torch.manual_seed(42)和np.random.seed(42)都设上,保证可复现。

这个项目后续还可以这样扩展:换成FashionMNIST数据集,同样是28x28灰度图,但类别是衣服、鞋子、包包,难度稍大一点。或者换成CIFAR-10,彩色图像,10个类别,挑战更大。再或者,把模型改成ResNet结构,感受一下残差连接的作用。每一步扩展都是对知识的巩固和深化。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 4:05:24

一站式AI能力接入平台:统一接口与模型动态路由实战

做AI应用开发这一年多,我最大的感受是:时间根本没花在写业务逻辑上,而是全耗在对接各种大模型API上了。今天聊的“老张API”这个项目,就是冲着这个痛点去的——它把自己定位成一站式AI能力接入平台,核心就一句话&#…

作者头像 李华
网站建设 2026/10/20 4:05:21

.NET SDK 工程化改造:Artifacts输出与集中包管理实战

最近在帮团队把几个老的 .NET 解决方案往现代 SDK 风格迁移,越干越觉得,.NET 这些年构建和发布方式的变化,真的被很多人低估了。前两篇聊了聊整体趋势和发布模型,这篇作为系列第三篇,我打算把重点放在最实在的工程化改…

作者头像 李华
网站建设 2026/10/20 4:04:38

SSM+Flask双引擎驱动的服装搭配推荐系统设计与实现解析

1. 项目核心拆解:这套“穿戴搭配系统”到底在解决什么问题先聊两句题外话。我在很多场合看过类似的项目,标题长得像购物车一样——Java、SSM、Flask、搭配、穿搭、配饰、调试文档、讲解视频,加起来一长串。说实话,这不是单纯的“服…

作者头像 李华
网站建设 2026/10/20 4:02:41

前端运行时性能巡检:帧率滑坡与监听器泄漏排查实践

如果你排查过线上页面卡顿,大概会有一种很深的体会:常规性能分析工具一开就是一整套报告,数据全得让人眼花,但真正落到自家业务逻辑上,反而不知道先盯哪个指标。尤其当问题只在灰度环境复现、只在特定操作路径上触发时…

作者头像 李华
网站建设 2026/10/20 4:02:34

Halcon NCC模板匹配在药片抓取项目中的实战解析

做药片抓取这个项目,是我接触Halcon以来最考验“匹配稳定性”的一单活。振动盘把白底红字的药片打进传送带,相机要在300毫秒内给出每一粒药片的中心和角度,机械臂的真空吸嘴再按顺序把它吸走——整个过程里,视觉部分用的就是Halco…

作者头像 李华
网站建设 2026/10/20 4:01:43

基于Q-learning与ε-greedy的随机迷宫生成与Matlab实现

最近几年但凡做强化学习入门,不管是课程设计还是自己练手,十个人里有八个都会碰一次"用Q-learning走迷宫"。这个题目我也带人做过好几轮,说实话,它确实是强化学习里最容易讲清楚、最容易出效果、也最容易埋坑的一个Demo…

作者头像 李华