
1. 从零手写数字识别为什么选择PyTorch而不是其他框架很多人第一次接触深度学习都是从MNIST手写数字识别这个Hello World级别的任务开始的。但奇怪的是网上大部分教程要么直接甩一段代码让你跑要么把重点放在数学推导上真正把为什么这么写讲清楚的不多。我自己带过不少刚入门的朋友发现他们卡住的地方往往不是反向传播的公式而是这个张量的形状为什么是[64, 1, 28, 28]卷积核到底在滑什么这类具体问题。这篇内容就是想把PyTorch实现MNIST这件事彻底讲透。从数据加载、网络结构设计、训练循环的每一个参数到实际跑起来之后遇到的各种报错和调参经验我都会按真实操作的顺序展开。适合刚学完Python基础、想动手跑通第一个深度学习项目的人也适合跑过Demo但说不清楚每一步在干什么的读者。先说框架选择。PyTorch在学术圈和工业界的占有率这几年一直往上走核心原因是它的动态计算图机制。你可以像写普通Python代码一样写网络结构调试的时候直接用print看中间结果不需要先定义静态图再填充数据。对于MNIST这种小任务TensorFlow的Keras接口确实更简洁但PyTorch的调试体验更接近写代码而不是配置系统。另一个实际原因是PyTorch的官方文档和社区示例质量很高遇到问题搜索时更容易找到准确的答案。MNIST数据集本身也值得说几句。它包含60000张训练图像和10000张测试图像每张是28x28像素的灰度图对应0到9十个数字。这个规模小到可以在普通笔记本的CPU上几分钟跑完一轮但又足够展示深度学习的完整流程。很多人觉得MNIST太简单了没意思但我的经验是能把MNIST的每一个环节都讲清楚的人迁移到CIFAR-10或者更复杂的任务时理解成本会低很多。注意本文所有代码基于PyTorch 2.x版本如果你用的是1.x版本部分API可能有差异建议先升级到2.x。2. 环境搭建与数据准备那些教程不会告诉你的细节2.1 安装PyTorch时最容易踩的版本坑安装PyTorch看起来简单但实际动手时第一个坑就在这里。官方推荐的安装命令会根据你的操作系统、包管理器和CUDA版本生成不同的命令。如果你直接复制网上的pip install torch很可能装到一个不带GPU支持的版本或者和你的Python版本不兼容。我的建议是先确定你的Python版本python --version然后去PyTorch官网的安装页面选择对应的配置。如果你没有NVIDIA显卡直接选CPU版本就行MNIST这个任务CPU完全够用。如果你有显卡但不确定CUDA版本可以在命令行输入nvidia-smi查看驱动支持的CUDA版本。安装完成后用下面这段代码验证import torch print(torch.__version__) print(torch.cuda.is_available())如果第二行输出True说明GPU可用输出False也没关系CPU跑MNIST大概每轮训练30秒左右完全可以接受。另一个常见问题是torchvision的版本匹配。torchvision是PyTorch的计算机视觉工具库MNIST的数据加载和图像变换都靠它。安装时最好和torch一起装避免版本冲突pip install torch torchvision2.2 MNIST数据加载为什么需要Normalize加载MNIST数据用torchvision.datasets.MNIST但这里有一个容易被忽略的细节transforms.Normalize((0.1307,), (0.3081,))这两个数字是怎么来的这两个值是MNIST训练集的全局均值和标准差。Normalize的作用是把图像像素值从[0,1]范围标准化到均值为0、标准差为1的分布。为什么要这么做因为神经网络对输入数据的尺度很敏感如果输入值都在0到1之间而权重初始化是围绕0的小随机数那么第一层的输出会非常小梯度也会很小训练会变慢。标准化之后输入分布更接近标准正态分布梯度传播更稳定。那为什么不用其他数据集的均值和标准差因为MNIST的像素分布比较集中大部分像素接近0背景只有少数像素是笔画。用全局统计值做标准化是一个经验性的选择实际效果比不做好很多。数据加载的完整代码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, trainTrue, downloadTrue, transformtransform ) test_dataset datasets.MNIST( root./data, trainFalse, downloadTrue, transformtransform ) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue) test_loader DataLoader(test_dataset, batch_size1000, shuffleFalse)这里batch_size64是一个经验值。太小比如8会导致每次更新梯度的方差大训练不稳定太大比如512会降低参数更新频率收敛变慢。64在MNIST上是一个比较平衡的选择。测试集的batch_size设成1000是因为测试时不需要反向传播可以一次多处理一些数据加快评估速度。提示第一次运行downloadTrue会从网络下载MNIST数据集大约11MB。如果下载失败可以手动下载后放到./data/MNIST/raw/目录下。3. 网络结构设计从全连接到卷积的演进逻辑3.1 为什么不能只用全连接层最朴素的MNIST分类器是一个多层感知机把28x28的图像展平成784维向量然后接几个全连接层。这种结构在MNIST上能跑到97%左右的准确率但有两个问题。第一展平操作丢失了空间信息。图像中相邻的像素是有关联的比如数字1的笔画是连续的但展平之后第0个像素和第28个像素下一行的第一个在向量里是相邻的而它们在实际图像中并不相邻。全连接层需要从这种混乱的排列中重新学习空间关系效率很低。第二全连接层的参数量太大。784维输入到512维隐藏层参数量是784x512401408个。如果输入换成224x224的彩色图像参数量会爆炸到无法训练。卷积层通过权值共享解决了这个问题一个3x3的卷积核只有9个参数但可以扫描整张图像。3.2 卷积层到底在卷什么卷积核是一个小矩阵比如3x3。它在图像上滑动每次覆盖3x3的区域把区域内的像素值和卷积核的权重相乘再求和得到一个输出值。这个过程就像用一个放大镜在图像上逐块检查每一块都提取一个特征。对于MNIST第一层卷积通常用5x5或3x3的核。5x5的感受野更大能捕捉到笔画的基本走向3x3的参数量更少堆叠两层也能达到类似效果。我个人的习惯是第一层用5x5后面用3x3。这样第一层快速提取边缘和拐角后面的层组合成更复杂的形状。池化层的作用是降维。最大池化取2x2区域中的最大值把图像尺寸减半。这样做有两个好处一是减少计算量二是提供一定的平移不变性。比如数字1稍微向左偏了一点池化后的特征图可能还是相似的。3.3 一个实测效果稳定的网络结构下面是我在MNIST上反复调整后觉得比较稳的结构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, 5, padding2) self.conv2 nn.Conv2d(32, 64, 5, padding2) 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逐层解释一下。第一层卷积输入1个通道灰度图输出32个通道卷积核5x5padding2。padding2的作用是让输出尺寸和输入保持一致28x28这样池化后变成14x14。如果不加padding5x5卷积后变成24x24池化后12x12再经过第二层卷积和池化会变得很小。第二层卷积输入32通道输出64通道卷积核5x5padding2。池化后变成7x7。此时特征图大小是64x7x73136。全连接层3136维输入到128维隐藏层再输出10维对应0-9十个类别。Dropout层以25%的概率随机丢弃神经元防止过拟合。MNIST数据量不大不加Dropout的话训练集准确率能到99.5%以上但测试集可能只有98%左右过拟合明显。注意x.view(-1, 64 * 7 * 7)中的-1表示自动推断batch_size。如果前面的维度计算不对这里会报错。调试时可以先print一下x.shape。4. 训练循环的每一个参数从损失函数到学习率4.1 损失函数为什么选交叉熵MNIST是十分类问题输出层有10个神经元。最直接的损失函数是均方误差但交叉熵更适合分类任务。原因在于交叉熵的梯度在预测错误时更大在预测正确时更小这让模型能快速纠正错误。而均方误差在输出经过softmax后梯度会变得很小训练很慢。PyTorch的nn.CrossEntropyLoss内部已经包含了softmax操作所以网络的最后一层不需要加softmax。如果你手动加了softmax再用CrossEntropyLoss相当于做了两次softmax结果会不对。4.2 优化器的选择与学习率设置SGD和Adam是两种最常用的优化器。SGD每次用一批数据计算梯度然后按固定学习率更新参数。Adam则根据梯度的一阶矩和二阶矩自适应调整每个参数的学习率。在MNIST上SGD配合动量momentum0.9通常能跑到99%以上的测试准确率但需要手动调整学习率。Adam的默认学习率1e-3就能工作得很好收敛更快但最终精度可能略低一点。我的建议是先用Adam快速验证网络结构是否正确然后用SGD动量做精细调优。学习率是最重要的超参数。太大比如0.1会导致损失震荡不下降太小比如1e-5会导致收敛极慢。MNIST上常用的学习率是0.01SGD或1e-3Adam。如果你不确定可以从1e-3开始试。import torch.optim as optim model Net() criterion nn.CrossEntropyLoss() optimizer optim.SGD(model.parameters(), lr0.01, momentum0.9)4.3 训练循环的完整代码与逐行解读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 criterion(output, target) loss.backward() optimizer.step() if batch_idx % 100 0: print(fTrain Epoch: {epoch} [{batch_idx * len(data)}/{len(train_loader.dataset)}] f\tLoss: {loss.item():.6f})model.train()的作用是启用Dropout和BatchNorm的训练模式。如果你忘了写这一行Dropout不会生效训练和测试的行为会不一致。optimizer.zero_grad()必须放在反向传播之前。PyTorch默认会累积梯度如果不清零这次计算的梯度会加上之前的梯度导致更新方向错误。这是新手最容易犯的错误之一。loss.backward()计算梯度optimizer.step()更新参数。这两个的顺序不能反。loss.item()把张量转成Python浮点数方便打印。如果直接print(loss)会输出包含梯度信息的张量对象不直观。4.4 测试评估与模型保存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 criterion(output, target).item() pred output.argmax(dim1, keepdimTrue) correct pred.eq(target.view_as(pred)).sum().item() test_loss / len(test_loader) accuracy 100. * correct / len(test_loader.dataset) print(fTest set: Average loss: {test_loss:.4f}, fAccuracy: {correct}/{len(test_loader.dataset)} ({accuracy:.2f}%)) return accuracymodel.eval()切换到评估模式关闭Dropout。torch.no_grad()禁用梯度计算减少内存消耗并加速计算。测试时不需要反向传播所以不需要计算梯度。output.argmax(dim1)取每个样本10个输出中最大值的索引就是预测的类别。keepdimTrue保持维度方便后续比较。模型保存用torch.save(model.state_dict(), mnist_cnn.pt)只保存参数不保存网络结构。加载时先实例化网络再model.load_state_dict(torch.load(mnist_cnn.pt))。这样做的好处是代码和参数分离修改网络结构时不会影响已保存的参数。5. 实测中的意外情况与调参经验5.1 损失不下降的几种可能原因第一次跑的时候如果发现loss一直在2.3左右不下降2.3是10分类随机猜测的交叉熵大概率是以下几个原因之一。第一忘了optimizer.zero_grad()。梯度累积导致参数更新方向混乱loss不降反升。第二学习率太大。SGD用0.1的学习率在MNIST上可能会震荡试试降到0.01。第三数据没有标准化。如果输入是0到255的原始像素值第一层的输出会非常大梯度爆炸。检查transform里有没有Normalize。第四标签和输出维度不匹配。CrossEntropyLoss要求输出是[batch_size, 10]标签是[batch_size]的整数。如果标签是one-hot编码需要先转成索引。5.2 过拟合的识别与处理训练集准确率99.5%测试集只有97%这就是过拟合。MNIST上过拟合不算严重但如果你把网络加得很深很宽过拟合会很明显。处理方法有几个。增加Dropout层我一般在全连接层后面加0.25到0.5的Dropout。使用数据增强比如随机旋转10度、随机平移2个像素但MNIST的数字位置比较固定增强幅度不能太大。减小网络容量如果全连接层有1024个神经元试试降到128。早停法在测试集准确率不再提升时停止训练。5.3 训练轮数与学习率调度MNIST上SGD动量通常10到15轮就能收敛到99%以上。Adam可能5轮就够了。但这不是绝对的取决于网络结构和batch_size。学习率调度可以让训练更稳定。比如每5轮把学习率乘以0.5scheduler optim.lr_scheduler.StepLR(optimizer, step_size5, gamma0.5)在每轮训练结束后调用scheduler.step()。这样前期学习率大快速下降后期学习率小精细调整。5.4 GPU与CPU的切换注意事项如果你有GPU把模型和数据都移到GPU上device torch.device(cuda if torch.cuda.is_available() else cpu) model Net().to(device)注意模型和数据必须在同一个设备上。如果模型在GPU上数据在CPU上会报Expected all tensors to be on the same device。训练循环里每次取batch都要data, target data.to(device), target.to(device)。测试时如果GPU内存不够可以换回CPU评估。但要注意模型也要.to(cpu)。6. 从MNIST到真实项目可以复用的经验跑通MNIST之后很多人会问接下来学什么我的建议是先把MNIST的代码改一改试试Fashion-MNIST。这个数据集和MNIST格式完全一样但内容是10类衣物图片分类难度更高。你不需要改任何数据加载代码只需要把datasets.MNIST换成datasets.FashionMNIST。如果准确率从99%掉到90%左右说明你的网络结构还有优化空间。另一个方向是可视化。用matplotlib把卷积层的输出画出来看看网络到底学到了什么。第一层卷积通常会学到边缘检测器第二层会学到更复杂的纹理。这个过程能帮你直观理解卷积神经网络的工作原理。还有一个实际经验MNIST上表现好的网络结构不一定适合其他数据集。比如MNIST的图像是居中的所以全局池化效果不错但自然图像中目标位置不固定需要更复杂的注意力机制。把MNIST当作学习工具而不是最终目标。最后分享一个调试技巧如果训练loss正常下降但测试准确率很低先检查数据预处理。训练集和测试集的transform必须完全一致。我曾经因为测试集忘了加Normalize导致准确率只有10%左右排查了半天才发现是这个问题。