简介:面向深度学习初学者、TensorFlow入门者以及需要完成图像识别课程设计的读者,这份资源以经典MNIST手写数字识别为切入点,用一个紧凑的CNN实现展示从数据输入、卷积层、池化层、全连接层到softmax分类的完整流程。zip压缩包内共2个Python脚本,整体仅5KB:Mnist_cnn.py负责模型搭建、训练与评估,在测试集上达到99.21%准确率;Mnist_cnn_tensorboard.py在原有基础上加入TensorBoard可视化,方便查看网络结构、loss曲线及参数变化,便于理解和调试训练过程。资源文件精简,适合直接阅读源码、对照复现,也可作为后续做参数调优、网络结构修改或扩展其他图像数据集的起点。目前已有9768人学习,对于希望从零动手完成一个图像识别项目,或准备在基础CNN样例上继续深入研究TensorFlow的开发者,是一份轻量且实用的参考脚本。
1. CNN卷积神经网络实现MNIST手写数字识别:一张28×28灰度图背后的技术含量
很多人第一次接触深度学习,就是从 MNIST 手写数字识别开始的。这个任务看起来简单到不像话——把一张 28×28 的灰度图分到 0 到 9 十个类别里,人眼一瞄就能认出来,但用 CNN 卷积神经网络把它跑通,背后涉及数据加载、卷积核设计、损失函数选择、训练节奏把控一整套链路。你以为是在学一个玩具项目,其实是在搭一套可以迁移到任何图像分类任务上的最小可运行框架。这篇笔记会把我做这个项目时踩过的坑、调过的参数、验证过的方法完整拆开,目标是让你看完就能动手复现,并且知道自己每一步在干什么。
2. 搭 CNN 之前必须搞清的三个问题:数据格式、网络选型、环境准备
2.1 MNIST 数据集到底长什么样:不只是「一堆图片」
MNIST 的全称是 Modified National Institute of Standards and Technology 数据库,由 60000 张训练图片和 10000 张测试图片组成。每张图是 28×28 像素的灰度图,像素值范围 0 到 255,标签是 0 到 9 的整数。这里有个关键点:标签不是 one-hot 编码的向量,就是一个整数。PyTorch 的交叉熵损失函数会内部帮你做 one-hot 转换,所以你不需要手动把标签变成 10 维向量。
数据本身虽然是图片,但实际使用时有两种载体。一种是原始 IDX 文件格式,需要自己写解析代码;另一种是通过 PyTorch 的torchvision.datasets.MNIST直接下载并封装好的对象。我强烈建议用第二种,不是因为偷懒,而是torchvision已经帮你处理好了下载、解压、训练集测试集划分这些脏活。第一次跑的时候想弄明白数据长什么样,可以下下来之后用matplotlib画几张看看,确认标签和图片对得上,这个习惯能帮你避免后面很多「模型准确率很高但实际是数据错位」的诡异问题。
数据预处理有一个容易被忽视的步骤:归一化(Normalization)。原始像素值 0 到 255,直接喂给网络会让初始梯度计算变得不稳定,收敛速度明显变慢。常见的做法是把像素值缩放到 [0, 1](除以 255),更推荐的做法是标准化,即减均值再除以标准差。MNIST 的全局均值和标准差是 0.1307 和 0.3081,这两个值是别人算好的,直接用就行。这一行代码对最终准确率的影响,可能比你换一个网络结构还大。
2.2 为什么是 CNN 而不是全连接网络:参数爆炸与平移不变性
很多初学者会问:MNIST 图片这么小,直接用全连接网络把 784 个像素拍平了输入,不是也行吗?确实行,但效果和效率都差很多。假设第一层全连接有 256 个神经元,那这一层就有 784×256 约 20 万个参数。而 CNN 用一个 3×3 的卷积核在整张图上滑动,同一层只有 9 个权重(不算 bias),哪怕用 32 个卷积核,也才 288 个参数。这就是「参数共享」带来的参数爆炸缓解。
更重要的是「平移不变性」。手写数字的笔迹千奇百怪,同一个数字在图片里的位置可能偏左可能偏右,笔画可能粗可能细。全连接网络对每个像素位置都单独学习权重,数字挪了个位置它可能就不认了。CNN 的卷积核是在整张图上共享的,不管数字出现在哪个位置,只要局部纹理特征(比如横线、竖线、圆圈)在,卷积核就能把它提出来。这就是 CNN 对图像任务天然友好的根本原因。
MNIST 这种小图任务,网络深度不需要太深。经典的 LeNet-5 结构(两层卷积加三层全连接)就能跑到 99% 以上的准确率。我一般会在这个基础上稍作调整,用两个卷积层加两个全连接层,参数量控制在几十万级别,CPU 上跑一个 epoch 也就几十秒,非常适合用来理解每个组件的作用。如果你一上来就上 ResNet 这种几十层的结构,反而会因为训练时间太长、调参复杂度太高而失去对核心机制的感知。
2.3 环境怎么备:一个 CPU 也能跑得动的项目
这个项目对硬件的要求低到令人发指。MNIST 单张图 28×28,一个 batch 128 张图也才 1M 左右的数据量,CPU 训练一个 epoch 大约 30 到 60 秒,整个训练流程 10 到 15 个 epoch 也就十几分钟。所以我一般建议入门阶段不用折腾 CUDA,先把 CPU 流程跑通,理解每个模块的作用,之后再切到 GPU 只是改一行代码的事。
环境配置方面,核心依赖就三个:PyTorch、TorchVision、Matplotlib。安装直接用 pip,版本不用追求最新,稳定版就行。Python 版本 3.8 以上基本都没问题。有一个小坑是 Windows 上 PyTorch 的安装命令和 Linux 不太一样,CPU 版本要明确指定cpu的源,不然默认会去下载 CUDA 版本,体积大且装完跑不了。我见过好几个初学者卡在这一步,报错信息是各种 DLL 找不到,其实就是 CPU/GPU 版本装错了。
3. 用 PyTorch 写出第一个 CNN:从网络定义到训练循环
3.1 网络结构怎么搭:两个卷积块加两个全连接层
我用的是 LeNet-5 的改良版,结构非常清晰:第一个卷积块提取低级特征(边缘、线条),第二个卷积块提取高级特征(形状、局部组合),最后接全连接层做分类。下面是完整定义:
import torch import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self, num_classes=10): super(SimpleCNN, self).__init__() # 第一个卷积块:1通道输入 -> 32通道输出 self.conv1 = nn.Sequential( nn.Conv2d(in_channels=1, out_channels=32, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2) ) # 第二个卷积块:32通道输入 -> 64通道输出 self.conv2 = nn.Sequential( nn.Conv2d(in_channels=32, out_channels=64, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2) ) # 全连接层:64*7*7 -> 128 -> 10 self.fc = nn.Sequential( nn.Linear(in_features=64 * 7 * 7, out_features=128), nn.ReLU(inplace=True), nn.Linear(in_features=128, out_features=num_classes) ) def forward(self, x): x = self.conv1(x) # 输入 1x28x28 -> 输出 32x14x14 x = self.conv2(x) # 输出 32x14x14 -> 64x7x7 x = x.view(x.size(0), -1) # 展平:64*7*7=3136 x = self.fc(x) # 3136 -> 128 -> 10 return x这段代码的逻辑是:输入一张 1×28×28 的灰度图,第一个卷积块用 32 个 3×3 卷积核提取局部纹理,经过 ReLU 激活后做 2×2 最大池化,尺寸减半变成 14×14;第二个卷积块把通道数从 32 扩到 64,再次池化后变成 7×7。view操作把 64×7×7 的特征图拉平成 3136 维向量,最后接两层全连接输出 10 个类别的 logits。
几个参数需要说明:kernel_size=3是卷积核尺寸,3×3 是图像任务里最常用的,感受野够用而且参数量小;padding=1是为了让卷积操作不改变特征图尺寸,否则 28×28 经过 3×3 卷积会变成 26×26,后续计算特征图维度容易算错;MaxPool2d的下采样不仅减小计算量,还带来一定的平移不变性。最后一个全连接层不需要额外接 Softmax,因为后面用的交叉熵损失函数自带 Softmax 操作,这一点很多初学者会搞混,在输出层手动加 Softmax 反而可能出问题。
3.2 数据加载:DataLoader 的 shuffle 和 num_workers 怎么设
数据加载是整个流程里最容易被低估的环节。DataLoader的几个参数直接影响训练效果和硬件利用率。
import torch from torch.utils.data import DataLoader from torchvision import datasets, transforms # 数据预处理:转 Tensor + 标准化 transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize(mean=(0.1307,), std=(0.3081,)) ]) # 训练集:下载 + 应用预处理 train_dataset = datasets.MNIST( root='./data', train=True, download=True, transform=transform ) # 测试集:同样预处理,但不需要 shuffle test_dataset = datasets.MNIST( root='./data', train=False, download=True, transform=transform ) train_loader = DataLoader( dataset=train_dataset, batch_size=128, shuffle=True, num_workers=2 ) test_loader = DataLoader( dataset=test_dataset, batch_size=256, shuffle=False, num_workers=2 )shuffle=True只在训练集设置,这是为了让每个 epoch 看到的样本顺序不同,避免模型学到数据排列的「捷径」。测试集不需要 shuffle,因为验证时只看整体准确率,顺序没有意义。num_workers控制数据加载的子进程数量,Windows 上如果设得过高会报错,一般 2 到 4 就够了,Linux 上可以设成 CPU 核心数。
ToTensor做了两件事:把 PIL Image 或者 numpy 数组转成 PyTorch Tensor,同时把像素值从 0-255 缩放到 0-1。Normalize在ToTensor之后做,用公式 (x - mean) / std 对每个像素做标准化。注意Normalize的 mean 和 std 是元组,因为 MNIST 是单通道,所以只传一个值。如果你用的是 RGB 三通道图,这里要传三个值,很多人在这一步报维度错误。
3.3 训练循环:optimizer.zero_grad 为什么不能省
训练循环是整套流程的核心,每个步骤都有明确的职责。新手最容易犯的错误是漏掉optimizer.zero_grad(),导致梯度不断累加。
import torch.optim as optim model = SimpleCNN() device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model.to(device) criterion = nn.CrossEntropyLoss() # 交叉熵损失,多分类标配 optimizer = optim.Adam(model.parameters(), lr=0.001) # Adam 优化器 num_epochs = 10 for epoch in range(num_epochs): model.train() # 切换到训练模式 running_loss = 0.0 correct = 0 total = 0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) # 前向传播:计算预测输出 outputs = model(images) loss = criterion(outputs, labels) # 反向传播:清空旧梯度 -> 计算新梯度 -> 更新参数 optimizer.zero_grad() loss.backward() optimizer.step() running_loss += loss.item() _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() train_acc = 100.0 * correct / total print(f"Epoch [{epoch+1}/{num_epochs}], Loss: {running_loss/len(train_loader):.4f}, Accuracy: {train_acc:.2f}%") # 每个 epoch 结束后在测试集上验证 evaluate(model, test_loader, device)前向传播用model(images)隐式调用forward,输出是一个 128×10 的张量,表示每个样本属于每个类别的得分。CrossEntropyLoss的输入是未经过 Softmax 的 logits,配合整数标签使用,内部会先做 Softmax 再计算负对数似然。optimizer.zero_grad()必须放在loss.backward()之前,因为 PyTorch 的梯度是累积的,不清空的话上一轮的梯度会加到这一轮上,参数更新方向就会错乱。
model.train()和model.eval()这两个状态切换也值得注意。虽然当前网络结构里没有 Dropout 和 BatchNorm,这两个函数看起来没有实际作用,但养成习惯很重要。后面你加 BatchNorm 或者 Dropout 时,忘了切状态会导致训练和验证行为不一致,出问题非常难排查。torch.max(outputs, 1)返回每个样本得分最高的类别索引,predicted == labels得到一个布尔张量,sum()算出的就是本 batch 预测正确的样本数。
3.4 验证函数:torch.no_grad 到底在干什么
验证阶段和训练阶段有一个本质区别:验证不需要计算梯度,因为你不更新参数。通过torch.no_grad()关闭梯度计算,能省掉大量内存和计算时间。
def evaluate(model, test_loader, device): 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() acc = 100.0 * correct / total print(f"Test Accuracy: {acc:.2f}%") return acc注意这里没有model.train()的切换。训练循环里每个 epoch 结束调用这个函数时,先把 model 切到 eval 模式,验证完再回到训练循环。如果验证完忘记切回train(),下一个 epoch 就会以 eval 模式训练,如果网络里有 Dropout 或 BatchNorm,表现会非常奇怪。我之前就在这个点上吃过亏——加了 BatchNorm 之后训练准确率突然掉到 70% 左右,查了半天发现是模式没切回来。
4. 训练启动后最值得调的四组参数:学习率、Batch Size、Epoch、优化器
4.1 学习率:0.001 是默认,但你真的知道它在干嘛吗
学习率决定每一步参数更新迈多大步子。太大,损失函数会在最小值附近震荡甚至发散;太小,收敛慢到你怀疑人生。我用 Adam 优化器时习惯从 0.001 起步,这是 PyTorch 官方文档里的推荐值,也是大量实验验证过的「甜点区」。
但学习率不是设一次就完事的。训练后期,模型已经接近最优解,这时候还保持最初的步长,很容易在最优解附近来回横跳,准确率卡在某个值上不去。常见的做法是用学习率衰减,PyTorch 里用torch.optim.lr_scheduler.StepLR每隔多少个 epoch 把学习率乘以一个系数,比如每 5 个 epoch 乘以 0.1。也可以等 loss 进了平台期再手动调低,这个后面进阶部分细说。
如果你发现 loss 曲线在前几个 epoch 不降反升,或者出现了 NaN,大概率是学习率太大了。这时候不要犹豫,直接除以 10 再试。反过来,如果 loss 降得极其缓慢,一个 epoch 才从 0.3 降到 0.29,说明步长太小,把学习率乘以 10 试试。这种「试错法」听起来很玄学,但其实有章可循:每次调整一个数量级,观察 2 到 3 个 epoch 的变化趋势。
4.2 Batch Size:64、128、256 怎么选
Batch Size 是每次喂给网络多少张图一起计算梯度。MNIST 这种小数据集,我通常用 128,这个值在收敛速度和显存占用之间比较均衡。选 Batch Size 时有三个考量点。
第一个是梯度稳定性。Batch 越大,梯度越接近整个训练集的真实梯度方向,更新越稳定;Batch 太小,梯度噪声大,训练曲线会抖,但也正因为有噪声,有时候反而更容易跳出局部最优。第二个是训练速度,Batch 越大,每次参数更新利用的数据越多,理论上同样 epoch 数下收敛更快,但单次迭代的显存占用和计算时间也更高。第三个是硬件限制,显存不够时不得不调小 Batch Size,但 Batch Size 太小(比如 8 或 16)会导致训练不稳定,这时候需要相应地调低学习率。
具体到 MNIST,我用过 32、64、128、256 四种配置,结论是 64 和 128 差异不大,256 收敛略快但准确率峰值没有明显提升。如果用的是 Adam,Batch Size 设 128 配学习率 0.001,基本不用操心太多。一个实用的检查方法是:观察训练 loss 曲线,如果非常平滑没有波动,说明 Batch 偏大或学习率偏小;如果抖得像心电图,说明 Batch 偏小或学习率偏大。
4.3 Epoch 数量:10 个还是 50 个?用 early stopping 判断
Epoch 是指整个训练集被完整遍历的次数。MNIST 用上面的网络结构,一般 10 到 15 个 epoch 就能收敛到 99% 左右。继续训练可能会出现两种情况:一是训练准确率还在微涨但测试准确率开始波动甚至下降,这是过拟合的信号;二是两条曲线都持平,说明模型已经饱和。
我一般不提前固定死 epoch 数,而是盯着验证集准确率的走势。如果连续 3 个 epoch 测试准确率都没有提升,就停止训练。这就是 early stopping 的朴素实现。在 PyTorch 里可以存一个变量best_acc,每轮验证后如果比best_acc高,就保存当前模型权重,最后用保存的最佳模型做测试。这样既不会欠拟合也不会过拟合,是最省心的策略。
best_acc = 0.0 for epoch in range(num_epochs): # ... 训练代码 ... acc = evaluate(model, test_loader, device) if acc > best_acc: best_acc = acc torch.save(model.state_dict(), "best_model.pt") print(f"New best model saved: {best_acc:.2f}%")model.state_dict()保存的是模型的权重参数,不包含网络结构。加载时需要先实例化一个相同结构的模型,再load_state_dict。这比保存整个模型要灵活得多,因为你可以改完代码后再加载旧权重继续训练。torch.save和torch.load是配套的,记得权重文件放到和代码同一个目录下,不然会报路径错误。
4.4 优化器对比:Adam 还是 SGD?MNIST 这个规模说实话差异不大
初学者一上来用 Adam 基本不会错,它对学习率的敏感程度比 SGD 低不少,收敛也快。但如果你想让模型的泛化性能做到极致,SGD 配合动量(Momentum)在 MNIST 上能达到比 Adam 略高的峰值准确率,代价是需要手动调学习率,训练周期也更长。这个现象在很多图像任务里都存在,Adam 收敛快但可能在泛化边界上略逊,SGD 收敛慢但最终落点往往更好。
PyTorch 里 SGD 加动量就一行代码:
optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=5e-4)momentum=0.9是最常用的动量系数,让参数更新方向兼顾历史梯度方向,能有效抑制震荡。weight_decay是 L2 正则化,约束权重不要过大,对防止过拟合有帮助,但这会让训练 loss 不会降到 0。用 SGD 时学习率要比 Adam 的 0.001 大一些,我一般从 0.01 起步然后逐步衰减。没有绝对的对错,可以两个都跑一遍,观察测试准确率的差异。我自己试下来,在 MNIST 上两者差距通常在 0.1 到 0.3 个百分点之内,普通人肉眼很难分出高下。
5. CNN 训练 MNIST 常见问题与排查:从 loss 不降到准确率卡壳
5.1 报错「mat1 and mat2 shapes cannot be multiplied」:全连接层维度算错了
现象:训练脚本前向传播时报错,提示矩阵乘法维度不匹配,比如mat1 shape [128, 3136]和mat2 shape [128, 3136]对不上。
原因:这是新手最常踩的坑之一。全连接层的输入维度写成64*7*7=3136,但实际经过卷积池化后特征图的尺寸和你预想的不一样。问题通常出在padding或者kernel_size的设置上——你以为是 7×7,实际是 6×6,维度就对不上了。另一种情况是改了卷积层参数(比如 kernel 从 3 改成 5),忘记同步修改全连接层的输入维度。
解决:不要靠心算,直接在view之前打印一下特征图的形状。在forward函数里加一行调试代码:
def forward(self, x): x = self.conv1(x) x = self.conv2(x) print("Shape after conv2:", x.shape) # 调试用,确认维度 x = x.view(x.size(0), -1) x = self.fc(x) return x跑一次看输出,[128, 64, 7, 7]的话,全连接层的输入就是64*7*7,然后把print删掉。我碰到这类问题从来不算,直接打印,几秒钟的事。另外x.view(x.size(0), -1)这种写法是自适应展平的,-1表示自动推断剩下的维度,所以只要你把nn.Linear的第一个参数写对,view这边永远不用改。
5.2 Loss 下降但准确率不变:用了 BCE 而不是 CrossEntropy
现象:训练 loss 从 0.5 降到 0.2,但准确率始终卡在 10% 左右,和随机猜测一个水平。
原因:多分类任务误用了BCELoss(二元交叉熵)。BCELoss期望输入是经过 Sigmoid 的 0 到 1 之间的概率,且每个类别是独立的二分类问题。而你的输出层是 10 个类别的 logits,数值范围可能是负数也可能大于 1,BCELoss 算出来的 loss 虽然也在降,但模型根本不是在学正确的分类边界。另一种可能:你用了CrossEntropyLoss但在输出层手动加了 Softmax,导致梯度方向和数值都出现偏差。
解决:确认损失函数和输出层之间的搭配。用nn.CrossEntropyLoss时,模型最后一层保持 10 维 logits 输出,不要加 Softmax。用BCELoss时,最后一层需要接nn.Sigmoid(),且标签要做成 one-hot 编码。MNIST 这种多分类任务,标准做法就是 CrossEntropy,不要在这上面发挥创造力。
# 正确 ✅ criterion = nn.CrossEntropyLoss() # 模型输出层:nn.Linear(128, 10),无 Softmax # 错误 ❌ # criterion = nn.BCELoss()5.3 训练集准确率 100%,测试集只有 90%:过拟合了
现象:前几个 epoch 测试准确率随训练准确率一起涨,后几个 epoch 训练准确率继续涨到 99.5%,测试准确率反而从 98% 回落到 96%。
原因:典型的过拟合。模型把训练样本的细节(包括噪声和无关特征)都记下来了,但对没见过的样本泛化能力弱。MNIST 虽然简单,12 个 epoch 之后照样会进入过拟合区间,尤其是数据没有做增强的情况下。
解决:三个手段从易到难。第一,减少 epoch 数量,用 early stopping 在测试准确率不再提升时及时停。第二,加 Dropout 层,在两层全连接之间加一个nn.Dropout(0.5),训练时随机丢弃一半神经元,迫使模型学到更鲁棒的特征。第三,做数据增强,对训练图片做随机旋转、随机平移,相当于免费扩充数据集。注意 Dropout 只在训练时生效,这就是为什么model.train()和model.eval()的切换这么重要。
5.4 准确率在 97% 左右上不去:归一化参数填错了
现象:训练 loss 正常下降,但准确率始终卡在 97% 上下,怎么调学习率和网络结构都突破不了。
原因:大概率归一化参数不对。MNIST 数据用transforms.Normalize(mean=(0.1307,), std=(0.3081,))是经过统计验证的标准值。如果你把 mean 和 std 填成 0.5 和 0.5,或者漏了归一化直接用 0-255 的像素值训练,网络虽然在收敛,但特征分布不理想,准确率天花板明显偏低。还有一种情况:测试集和训练集用的是不同预处理,训练用归一化测试用原图,或者反过来,几乎必然导致准确率掉 2 到 3 个百分点。
解决:检查训练和测试的 transform 是否完全一致。把transforms.Compose定义成一份,训练和测试共用一个,从根源上杜绝不一致。然后确认Normalize的 mean 和 std 是对应 MNIST 的那两个值。如果不是 MNIST 而是其他数据集,可以用一个简单的脚本算一下数据的全局均值和标准差,再填进去。这个坑我自己掉过:换了个数据集,忘了更新 Normalize 参数,准确率直接掉了 5 个点。
5.5 GtPU 相关报错但不影响运行:放心忽略还是需要处理
现象:显存不足报错,或者 CUDA 不可用的警告。比如CUDA out of memory或UserWarning: CUDA initialization: The NVIDIA driver on your system is too old。
原因:MNIST 的数据量非常小,显存不足几乎不会出现,除非你把 Batch Size 调到了几千。CUDA 不可用则多半是 PyTorch 装成了 CPU 版本,或者驱动版本太旧。如果你的机器实际上没有独立显卡,直接忽略这个报错,CPU 训练一样能完成。
解决:如果代码里用了device = torch.device("cuda" if torch.cuda.is_available() else "cpu"),PyTorch 会自动回退到 CPU,不需要手动改任何代码。如果报显存不足,先查是不是其他程序占用了显存,把 Batch Size 调小(比如 128 改 64)。如果确认有显卡但torch.cuda.is_available()返回 False,需要重新安装对应 CUDA 版本的 PyTorch。这个问题的排查思路是:先看硬件有没有(设备管理器或nvidia-smi),再看驱动版本,最后看 PyTorch 版本,按顺序来。
6. 从 98% 到 99.5%:数据增强、学习率衰减和结构微调三板斧
模型跑通,准确率卡在 98% 左右,再往上推需要的是精细化调优。这一节说三个我实际验证过的方法,按投入产出比排序:先是数据增强,再是学习率衰减,最后是网络结构调整。合起来可以稳定把 MNIST 推到 99.4% 以上。
第一个是数据增强。MNIST 训练集只有 6 万张图,但对数字识别来说,人写的数字有平移、旋转、缩放、笔画粗细等各种变化,原始数据集不够覆盖。PyTorch 里用transforms组合即可:
transform_train = transforms.Compose([ transforms.RandomAffine(degrees=10, translate=(0.1, 0.1), scale=(0.9, 1.1)), transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) transform_test = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ])RandomAffine的degrees=10表示随机旋转正负 10 度,translate是平移比例,scale是缩放范围。关键点:测试集不要做随机增强,只做 Tensor 化和归一化,否则验证准确率会上下波动,指标失真。增强之后每个 epoch 看到的都是不同的图,等价于数据量扩大了几十倍,过拟合问题也一并缓解。我自己跑了对比:增强前测试准确率最高 98.5%,增强后直接跳到 99.2%。
第二个是学习率衰减。Adam 在训练前期收敛很快,后期容易在最优解附近震荡。用 StepLR 每个 epoch 检查一次,训练到后期自动缩小步长:
scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=5, gamma=0.1) # 每个 epoch 结束调用 scheduler.step() 更新学习率step_size=5是每隔 5 个 epoch 衰减一次,gamma=0.1是乘以 0.1。效果是前 5 个 epoch 用 0.001 快速逼近,后面用 0.0001 精细微调。这一步通常能再提升 0.1 到 0.2 个百分点。ReduceLROnPlateau是更智能的版本,可以设置当测试准确率连续几个 epoch 不提升时自动衰减。
第三个是结构调整。目前的网络在池化之后直接接全连接,可以在第二个MaxPool2d之后加一个 BatchNorm 层。nn.BatchNorm2d(64)会标准化每个 batch 的特征分布,让训练更稳定。另一个微调是第一个卷积层从 32 个卷积核加到 64 个,增加模型容量,但训练时间会翻倍,收益约 0.1 个百分点。权衡下来更划算的做法是加一层nn.Dropout(0.3)在最后一个全连接层之前,增强泛化能力。
我的经验是:数据增强贡献 0.7 个百分点,学习率衰减贡献 0.1 到 0.2,结构微调贡献 0.1,三者叠加之后模型在测试集上能稳定达到 99.4% 以上。调参的过程本质上是在找收益的边际递减点——数据增强收益最大,原因在于它从源头解决了数据量不足;结构微调收益最小,因为现有容量已经足够拟合 MNIST 的复杂度。如果你想让数字准确率再往上走,可以考虑集成学习或者用更多数据,但投入产出比会急剧下降。
最后说一个我的习惯:每次改完一组参数,我会把配置记到实验日志里,包括当时的数据增强方式、学习率、batch size、最终准确率,跑完 8 到 10 组之后回头看,你会发现哪些调整真的有效、哪些只是心理安慰。MNIST 这个项目虽然小,但整套「假设 → 实验 → 验证 → 记录」的流程和你日后做任何图像识别项目完全一致。我第一次跑通它的时候,光维度报错就折腾了两个小时,但正是这些坑让我对每一层输出的形状都了然于胸。希望这篇笔记能帮你在同样的路上少绕几个弯。
本文还有配套的精品资源,点击获取