
手写数字识别MNIST在深度学习里的地位基本等同于编程界的Hello World。我当年第一次用PyTorch把这个案例完整跑通的时候最大的感受不是“我又调通了一个demo”而是终于搞明白了一整套流程——数据怎么进来、模型怎么搭、训练循环怎么转、结果怎么评估。这个案例看着简单其实把深度学习落地所涉及的核心环节全走了一遍一点没偷懒。这篇文章想把整个手写数字识别案例从头到尾拆开讲包括方案设计、网络结构、每一行关键代码的含义、参数选择的逻辑以及我实际跑训练时踩过的坑。内容会偏实操但不会只贴代码还会解释“为什么这么写”和“换一种写法会有什么后果”。不管你是刚接触PyTorch的新手还是已经跑过一些demo但想搞懂细节的开发者这篇都值得读完。1. 整体方案与设计思路1.1 为什么是MNIST为什么从手写数字开始MNIST数据集是深度学习领域最经典的入门数据集包含0到9共10类手写数字训练集6万张测试集1万张每张图片是28x28像素的灰度图。虽然现在有不少人批评它太简单、被刷爆了但作为学习材料它有一个不可替代的优势训练快、出错直观、成本低。我在CPU环境下用一个小型卷积网络训练一个epoch大概是几十秒几分钟就能看到一个完整的训练曲线。这个特点对初学者极其重要因为深度学习里的很多概念比如损失下降、过拟合、学习率不收敛都需要你反复“试错”才能有体感。如果你第一个项目就跑ImageNet级别的数据集一次训练好几小时你连调参的机会都没有。还有一个很重要的点MNIST的标签是明确的训练目标非常清晰。它不像自然语言处理任务那样需要复杂的预处理也不像目标检测任务那样需要标注框。你只需要把28x28的像素矩阵喂给网络输出是一个10维向量取最大值对应的索引就是预测的数字。这个输入输出的映射关系非常直观适合把注意力集中在理解框架本身上。1.2 技术选型为什么用CNN而不是全连接网络很多人第一次接触深度学习最先写的模型可能是一个全连接网络。理论上全连接网络也能做手写数字识别但效果明显不如卷积神经网络CNN。我自己测试过一个两层的全连接网络在MNIST上大概能到97%左右的准确率而一个简单的CNN轻松到99%以上。差距来自两类网络对图像信息的处理方式完全不同。全连接网络会把28x28784个像素值全部展平成一维向量然后每个神经元和所有输入都相连。问题在于它把每个像素当成独立的特征完全忽略了“相邻像素之间有空间关联”这个重要信息。比如数字“7”的横和竖在图像上是连在一起的这种局部结构对全连接网络来说需要大量参数才能隐式学到。CNN的设计正是为了解决空间信息的利用问题。卷积核每次只关注一个局部区域比如3x3然后在整张图上滑动通过参数共享机制提取局部特征。这样既能识别边缘、角点、纹理这样的低级特征又能通过堆叠多层把低级特征组合成高级语义特征。同时卷积参数远比全连接少训练更快也不容易过拟合。在本案例中我选择了经典的卷积池化全连接的组合结构这是LeNet风格的一种现代变体。用两个卷积层提取特征池化层缩小特征图尺寸最后接全连接层做分类。这个组合结构在MNIST上是经过无数人验证的稳定方案几乎没有调参风险很适合作为“第一个吃螃蟹”的模型。1.3 环境准备与依赖安装我用的是PyTorch 2.x版本Python 3.9以上都能跑。安装方式很简单如果只用CPU训练直接执行pip install torch torchvision如果要用GPU训练需要根据你的CUDA版本安装对应版本的PyTorch建议直接去PyTorch官网首页用配置向导生成安装命令不要手动指定版本号否则经常会遇到CUDA和cuDNN版本不匹配的问题。除了PyTorch本体还需要两个辅助库NumPy用于数组操作Matplotlib用于图片可视化。这两个用pip一起装上就行。pip install numpy matplotlib关于CPU和GPU的选择MNIST这种小尺寸灰度图CPU完全能跑只是每个epoch多花几十秒而已。但如果你的电脑有NVIDIA独立显卡建议还是用GPU跑因为后面你大概率会接触到更大更复杂的模型提前学会怎么看GPU利用率、怎么处理显存不足都是省钱买不到的实战经验。另外强烈建议在所有代码开头固定随机种子import torch import numpy as np torch.manual_seed(42) np.random.seed(42)这样每次训练的结果是可复现的。虽然调试时不追求绝对一致但当你怀疑“我改了某个参数到底有没有效果”的时候固定种子能保证你观察到的差异确实来自参数改动而不是随机波动。2. 核心组件与参数逐个拆解2.1 数据加载与预处理Dataset、DataLoader、transform的配合PyTorch处理数据的标准姿势是三个组件配合Dataset负责定义“怎么读取一条数据”DataLoader负责“怎么把多条数据打包送进模型”transform负责“在送入模型之前对数据做什么处理”。MNIST数据集在torchvision里可以直接下载不需要自己写Dataset类。核心代码如下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, trainTrue, transformtransform, downloadTrue ) test_dataset datasets.MNIST( root./data, trainFalse, transformtransform, downloadTrue ) train_loader DataLoader( train_dataset, batch_size64, shuffleTrue, num_workers2, pin_memoryTrue ) test_loader DataLoader( test_dataset, batch_size64, shuffleFalse, num_workers2, pin_memoryTrue )这里有两个细节必须讲清楚。第一个是transforms.ToTensor()的作用。它把PIL Image或者NumPy数组从形状(H, W, C)转换成PyTorch张量同时把像素值从0-255缩放到0-1。这一步很关键因为神经网络对输入数据的尺度非常敏感如果直接输入0-255的原始像素值梯度更新容易震荡训练很难收敛。第二个是transforms.Normalize((0.1307,), (0.3081,))。这两个值分别是MNIST数据集的像素均值和标准差是从整个数据集统计出来的。归一化之后每个像素的分布近似为标准正态分布均值为0方差为1这能显著加速梯度下降的收敛过程。有人会问ToTensor已经把像素缩到0-1了为什么还要再标准化因为0-1区间只是让数值变小但数据分布的“形状”没变标准化是让数据分布居中且尺度统一两者解决的问题不同。DataLoader里的batch_size64意味着每次迭代从数据集中取64张图送进模型。这个参数直接影响训练速度和显存占用我后面会单独讲怎么调。shuffleTrue只对训练集启用作用是每个epoch重新打乱样本顺序避免模型因为固定顺序学到样本之间的虚假关联。测试集不需要打乱所以设成False。2.2 网络结构从Conv2d到Linear的每一层设计我用的网络结构是一个经典的小型CNN完整定义如下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, kernel_size3, padding1) self.conv2 nn.Conv2d(32, 64, kernel_size3, padding1) self.pool nn.MaxPool2d(2) self.fc1 nn.Linear(64 * 7 * 7, 128) self.fc2 nn.Linear(128, 10) def forward(self, x): x F.relu(self.conv1(x)) x self.pool(x) x F.relu(self.conv2(x)) x self.pool(x) x x.view(-1, 64 * 7 * 7) x F.relu(self.fc1(x)) x self.fc2(x) return x用已经训练好的模型跑真实数据时你就能理解每层的含义Conv2d(1, 32, kernel_size3, padding1)输入为1个通道灰度图输出32个通道卷积核3x3padding为1。padding的目的是保持特征图尺寸不变28x28输入经过这层后仍是28x28只是通道数从1变成32。MaxPool2d(2)在2x2的窗口内取最大值把特征图尺寸减半从28x28变成14x14。池化的作用不只是减少计算量更重要的是它能带来一定程度的平移不变性——数字稍微偏移几个像素池化后的特征仍然相似。第二次卷积池化后特征图从14x14变成7x7通道数变成64。此时特征图展开就是64773136个值这个数值就是全连接层输入维度。fc1把3136维向量压缩到128维fc2再压缩到10维对应10个数字类别。有一个新手经常犯的错修改了卷积层参数之后忘了同步修改fc1的输入维度运行时报错提示矩阵维度不匹配。计算特征图尺寸的方法很简单只要记住公式输出尺寸 (输入尺寸 2 * padding - kernel_size) / stride 1这个公式值不值得死记值得因为你不只在MNIST上用得到任何图像任务都要算这个。但更实用的做法是在模型forward里临时打印每一层的输出形状用实际输出来验算这样永远都不会算错。关于forward中的x.view(-1, 64 * 7 * 7)这行代码是把多维特征图展平成二维矩阵。-1表示该维度自动推导实际值就是batch_size。如果你对这个自动推导逻辑不放心也可以写x.size(0)来显式获取batch_size效果相同。2.3 损失函数与优化器选择的内在逻辑分类任务的标准损失函数是交叉熵损失在PyTorch里对应nn.CrossEntropyLoss()。这里有一个非常重要、但很多人一开始不知道的细节nn.CrossEntropyLoss内部已经包含了Softmax操作所以你的模型最后一层输出不需要再额外加Softmax。什么概念我的模型最后一个fc2直接输出10个原始数值logits没有经过Softmax归一化。训练时直接把这些logits传给损失函数即可。如果在最后一层手动加了nn.Softmax或nn.LogSoftmax再配合CrossEntropyLoss就会造成两次Softmax导致梯度计算出现问题训练效果变差。优化器我选的是Adamoptimizer torch.optim.Adam(model.parameters(), lr1e-3)Adam几乎是人人都推荐的默认选择因为它自适应调整每个参数的学习率对初始学习率不太敏感收敛也比较平稳。相比之下SGD随机梯度下降虽然理论上更“正统”但对学习率的选择非常挑剔需要更多经验来调参。是不是说SGD没用不是。很多人在MNIST上测试发现带momentum的SGD比如torch.optim.SGD(model.parameters(), lr0.01, momentum0.9)最终准确率和Adam差不多甚至0.9以上的准确率更稳。SGD的优势是泛化性能好Adam的优势是收敛快、省心。初学者建议先用Adam跑通流程再尝试换SGD做对比实验感受两者的差异。优化器还有一个容易忽略的参数weight_decay。它相当于L2正则化约束网络权重不要太大从而减轻过拟合。我在训练时一般设weight_decay1e-4对MNIST来说效果还行。你可以去掉它对比看看测试集准确率的变化这个实验会帮你直观理解正则化到底在做什么。3. 完整实操全流程与代码实现3.1 数据查看与可视化验证写训练代码之前我习惯先做一次数据可视化。这一步不是浪费时间它能在第一时间发现数据加载环节的问题。比如数据是否正常下载、transform是否生效、标签是否对齐。盲写代码跑训练如果效果差你很难判断是数据问题还是模型问题。可视化代码很简单import matplotlib.pyplot as plt import torchvision grid torchvision.utils.make_grid( next(iter(train_loader))[0], nrow8, padding2 ) plt.imshow(grid.permute(1, 2, 0).numpy()) plt.axis(off) plt.show()这里需要解释grid.permute(1, 2, 0)。PyTorch的图像张量格式是(C, H, W)也就是通道在前。而Matplotlib的imshow期望的格式是(H, W, C)通道在后。所以显示之前要把通道维度移到最后一维否则图像颜色通道会乱套。对MNIST灰度图来说你可能看到的只是颜色变奇怪但对三通道彩色图来说不改permute直接显示会导致图像完全无法辨认。同时打印一下标签和图像尺寸images, labels next(iter(train_loader)) print(images.shape) # torch.Size([64, 1, 28, 28]) print(labels.shape) # torch.Size([64]) print(labels[:8]) # tensor([5, 0, 4, 1, 9, 2, 1, 3]) 之类的3.2 训练循环的精髓训练循环是深度学习代码的核心也是新手最需要理解透彻的部分。我见过很多人在论坛上抱怨“模型不收敛”结果问题不在于模型设计而在于训练循环写错了。完整的训练代码如下def train_epoch(model, device, train_loader, optimizer, epoch): model.train() running_loss 0.0 correct 0 total 0 for batch_idx, (data, target) in enumerate(train_loader): data, target data.to(device), target.to(device) optimizer.zero_grad() output model(data) loss criterion(output, target) loss.backward() optimizer.step() running_loss loss.item() _, predicted output.max(1) total target.size(0) correct predicted.eq(target).sum().item() if batch_idx % 100 0: print( fEpoch {epoch} [{batch_idx * len(data)}/{len(train_loader.dataset)}] fLoss: {loss.item():.6f} ) avg_loss running_loss / len(train_loader) accuracy 100.0 * correct / total return avg_loss, accuracy训练循环固定有五步任何深度学习任务都逃不开这个套路第一步optimizer.zero_grad()。PyTorch的梯度是累积的也就是说每次反向传播计算出的梯度会累加到参数的.grad属性上。如果不在每个batch开始前清零上一次batch的梯度会和当前batch的梯度相加导致梯度方向错误训练直接崩掉。第二步前向传播output model(data)。数据从输入层经过所有网络层得到输出。第三步计算损失loss criterion(output, target)。第四步反向传播loss.backward()。这一步会自动计算损失对每个参数的梯度并存储到参数的.grad属性中。PyTorch用自动微分机制跟踪了前向传播过程中的所有操作所以不需要手动推导梯度公式。第五步更新参数optimizer.step()。优化器根据.grad中的梯度值按照设定的学习率更新每个参数。这五步的顺序一次都不能乱。我见过有人把zero_grad()放到backward()之后那等于把刚算好的梯度清理掉了参数永远不更新损失自然也不下降。model.train()和model.eval()这两种模式也要分清楚。调用model.train()后模型开启训练模式BatchNorm层会使用当前batch的统计量Dropout层会随机丢弃神经元调用model.eval()后模型使用运行时统计量Dropout停止丢弃。如果忘了调model.eval()测试集上的准确率会偏低且不稳定因为Dropout在推理时仍然随机丢弃信息。我们这种小型网络没有加Dropout但你早晚会用到这个习惯现在就得养成。3.3 测试评估、准确率统计与错误样本分析训练完成后必须在测试集上做评估测试集和训练集不重叠这样做出的准确率才有意义。评估代码def evaluate(model, device, test_loader): model.eval() correct 0 total 0 test_loss 0.0 with torch.no_grad(): for data, target in test_loader: data, target data.to(device), target.to(device) output model(data) test_loss criterion(output, target).item() _, predicted output.max(1) total target.size(0) correct predicted.eq(target).sum().item() accuracy 100.0 * correct / total avg_loss test_loss / len(test_loader) print(fTest set: Average loss: {avg_loss:.4f}, fAccuracy: {correct}/{total} ({accuracy:.2f}%)) return accuracywith torch.no_grad():这个上下文管理器在评估时是必须写的。它告诉PyTorch不需要构建计算图、不需要存储中间变量来支持反向传播。效果有两个一是大幅减少显存占用二是加快推理速度。如果不写代码也能跑但显存里会多一堆没用的中间结果大一点的模型直接就“显存不足”了。output.max(1)的作用是取每个样本在10个类别上的最大值的索引。比如某样本输出是[0.1, 0.2, 7.5, 0.3, ...]那么max(1)返回索引2意味着模型预测这个数字是2。predicted.eq(target)逐个比较预测值和真实值是否相等返回布尔张量sum()统计其中True的数量也就是预测正确的样本数。只关心总准确率是不够的。我习惯训练完之后做一次错误样本分析——把预测错的图片和对应的真实标签、预测标签打出来看看。这一步能帮你判断模型的错误到底长什么样。比如数字“4”被误判成“9”可能是因为手写体的笔画弧度太接近数字“0”被误判成“6”可能是因为图像的边缘部分有一个明显的下弯小尾巴。了解模型的犯错模式你才知道后续是加数据增强还是调整网络结构还是换损失函数。4. 常见问题与排查技巧实录4.1 四个最典型的训练翻车现场我在跑这个案例的过程中也自己制造过不少问题有些问题特别典型群里几乎每天都有新人踩一遍。典型问题一损失函数一直不下降。如果你看到loss在0.69左右横盘不动准确率也只有10%左右和随机猜测没什么区别那基本可以判断是模型输出没有正确连接到损失函数。最常见的原因是最后一层多加了Softmax但损失函数又用了CrossEntropyLoss等于做了两次概率归一化梯度信号被扭曲。排查方法很简单把模型最后一层完全改成线性输出不要加任何激活函数。典型问题二train和test模式切换导致结果不稳定。有人训练完直接测试不打model.eval()发现准确率忽高忽低。对于没有Dropout的模型影响可能不明显但一旦模型里有Dropout或BatchNorm误差会很大。正确做法是训练和测试都必须显式调用对应的模式函数。典型问题三GPU显存不足。奥恩数据集的单张图片只有28x28按道理不会爆显存但如果你在别处租的云服务器显存本来就很小比如只有2GB而batch_size又设成了256甚至512照样会报CUDA out of memory。解决办法很简单调小batch_size。如果调得很小了还报错检查代码里是不是忘记了torch.no_grad()或者是不是把测试集数据也放到了GPU上却忘了及时释放。典型问题四Windows下DataLoader报多进程错误。Windows上num_workers如果设置大于0有时会报RuntimeError: DataLoader worker (pid(s) XXXX) exited unexpectedly。这不是你的代码有问题是Windows对多进程支持的限制。最省事的方案是把num_workers设为0如果你确实需要多进程加速把数据加载代码放到if __name__ __main__:里面这样可以规避一部分问题。4.2 关键参数速查与选择逻辑以下是我在这个项目中使用的核心参数和使用建议整理成表格方便对照参数/组件我的设置选择理由常见误区batch_size64折中训练速度和梯度稳定性设得太大导致显存溢出或收敛震荡学习率1e-3 (Adam)Adam对学习率不敏感1e-3是安全范围盲目换成0.1导致loss爆炸卷积核尺寸3x3有效提取局部特征参数少过度使用大卷积核增加计算负担卷积层数2层对28x28小图足够提取有效特征堆太多层数反而难以收敛池化方式MaxPool2d(2)缩小特征图增强平移不变性忽略池化导致全连接层输入维度计算错误优化器Adam自适应学习率收敛稳定Adams配大学习率照样发散评估模式model.eval() no_grad关闭Dropout/BN统计节省显存忘记调用导致测试准确率不稳定关于batch_size我想多插一句。很多人以为batch_size越大越好毕竟大batch可以利用GPU并行能力跑得更快。但实际上batch_size影响的是梯度估计的精度batch越小每个batch的梯度方向越“粗糙”但正因为粗糙反而有可能跳出局部极小值batch越大梯度越接近真实梯度但容易收敛到尖锐的极小值泛化性能可能更差。MNIST上64是个丰俭由人的中间值如果你显存够大可以试试128训练时间能压缩一下准确率不会有太大变化。4.3 训练时长与硬件配置的关系我分别用CPU和GPU跑过这个案例。CPU是普通的多核处理器跑一个epoch大概50秒左右5个epoch不到5分钟GPU是普通的入门级显卡一个epoch大约5秒差距非常明显。如果是完全的新手电脑没有独立显卡也不用担心这个项目用CPU跑完全可行你甚至可以趁机把手动梯度、自动梯度的关系想得更清楚。有一点要提醒国内下载MNIST数据集经常非常慢甚至卡住不动。torchvision会自动从公共数据集服务器下载但对于网络条件不太好的环境超时是常见现象。解决办法是直接从镜像站点手动下载四个文件train-images-idx3-ubyte.gz、train-labels-idx1-ubyte.gz、t10k-images-idx3-ubyte.gz、t10k-labels-idx1-ubyte.gz放到./data/MNIST/raw/目录下再运行代码即可识别。这一步对新手来说是很实用的技巧省去不少等待时间。5. 几经测试后的一些心得与扩展方向这个项目跑通之后我其实又反复折腾了很多版本比如去掉卷积层看全连接网络的表现、换成SGD调参、在卷积层之间插入BatchNorm、把最后一层换成分数更高的输出层。每一次改动都让“手写数字识别”这个看似老掉牙的案例变得更有教学价值。我个人的一点体会是不要因为MNIST准确率已经99%就觉得没东西可学了。它的价值不在于刷高一个指标而在于你通过它完整地理解了深度学习流水线的每个环节并且提炼出一套可复用的调试方法论。以后你去跑目标检测、语义分割、文本分类数据加载的思路、训练循环的结构、评估与可视化方法依然是一脉相承的。给想要继续深入的朋友几个扩展方向一是把模型换得更现代一些比如加入残差连接或注意力机制看看在MNIST上是否能进一步提升二是把数据集从MNIST换成Fashion-MNIST任务类似但更难能够更清楚地暴露模型泛化能力的差异三是把训练完成的模型导出成ONNX格式再用推理引擎部署成本地小工具这样你就能从“写代码训练模型”迈入“把模型做成产品”的阶段了。最后分享一个我调试网络时候常用的小技巧如果你觉得模型总是学不好先不急着加复杂结构而是用一个小数据集比如只取1000张图跑一到两个epoch如果loss能快速下降说明流程没问题如果loss降不下来那问题大概率出在代码逻辑而不是模型设计。这个“小规模验证”的习惯帮我少走了很多弯路对你应该也会有帮助。