ARTICLE DETAIL

资讯详情

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

MNIST手写数字识别实战:CNN结构设计与PyTorch训练全解析

MNIST手写数字识别实战:CNN结构设计与PyTorch训练全解析 简介中科大自动化系2022人工智能导论课程大作业三聚焦CNN手写数字识别这份压缩包即为该作业的完整实现面向机器学习、深度学习入门者及需要课程设计、毕业设计参考的学生。项目围绕MNIST数据集展开覆盖数据预处理、卷积神经网络建模、训练和评估等核心环节压缩包共6个文件含4个Python脚本、1个说明文档和1个许可文件总大小仅8KB。各个脚本分别承担网络结构定义、训练执行、辅助工具与程序入口分工清晰便于按模块研读资源上线以来已有142人学习虽然体量轻巧但完整呈现了深度学习的标准流程。通过运行代码可以快速掌握卷积、池化、全连接层的作用以及优化器、损失函数、准确率评估等关键概念对完成类似图像识别项目或撰写实验报告均有直接帮助。1. 从 MNIST 到大作业这门课想让你交出的不止是准确率解压一份标注着“中科大自动化系2022人工智能导论课程大作业三”的 zip里面大概率是main.py、model.py、train.ipynb和一份写满公式的report.pdf。对于后修过不少 CV 课的工程师来说MNIST 手写数字识别早就是“入门即巅峰”的 Hello World一个两层卷积加两层全连接跑十几轮就能到 99% 以上似乎没什么可聊的。但把这门课的作业要求拆开看你会发现它真正考核的不是你能否把准确率刷到 99.6%而是你是否理解 CNN 结构图中每一层特征图尺寸的变化逻辑、为什么用交叉熵而不用 MSE、以及当你把网络加深到 5 层卷积时为什么训练 loss 反而下不去。这份大作业的隐藏考点是把“能跑”变成“可控”数据怎么组织、卷积核与填充怎么配、学习率怎么衰减、过拟合在第几个 epoch 出现每一处都值得落到代码上展开。这篇文按我自己带项目时的做法从数据侧到网络侧再到训练侧把整个 CNN 手写数字识别的最小可复现链路讲透并重点说清楚那些课程报告里不会明写的边界条件和坑。2. 数据准备手写数字识别的输入侧决定网络能学到什么2.1 MNIST 不是“拿来就能用”格式、归一化与验证集划分MNIST 原始数据以 IDX 二进制格式存储训练集 60000 张、测试集 10000 张每张是 28×28 的单通道灰度图。很多初学者直接读入后 reshape 成(60000, 784)喂给全连接网络这在作业里不算错但丢掉了像素间的二维空间关系CNN 的优势也就无从谈起。常见做法是保留(28, 28)的平面结构并在送入网络前增加通道维度变成(1, 28, 28)。PyTorch 中torchvision.datasets.MNIST已经封装好下载与读取但课程作业往往要求从原始 IDX 文件自己解析这也是检验文件 I/O 和二进制处理能力的一部分。归一化策略上我一般把像素值除以 255 缩放到[0, 1]再按数据集的全局均值和标准差做标准化。MNIST 的全局均值约为 0.1307标准差约为 0.3081这个数值可以直接硬编码因为整个数据集的统计特性稳定不需要像 ImageNet 那样按 batch 重新估计。需要强调的是标准化参数只能用训练集计算如果先对全量数据计算再划分会造成轻微的信息泄漏虽然对 MNIST 影响不大但这是一个值得写进作业报告的规范性问题。import torch from torch.utils.data import Dataset, DataLoader import struct import numpy as np class IDXMNIST(Dataset): def __init__(self, images_path, labels_path, trainTrue): with open(images_path, rb) as f: magic, num, rows, cols struct.unpack(IIII, f.read(16)) images np.frombuffer(f.read(), dtypenp.uint8).reshape(num, rows, cols) with open(labels_path, rb) as f: magic, num struct.unpack(II, f.read(8)) labels np.frombuffer(f.read(), dtypenp.uint8) self.images images.astype(np.float32) / 255.0 self.labels labels.astype(np.int64) # 按 9:1 切分训练/验证集 split int(len(self.images) * 0.9) if train: self.images, self.labels self.images[:split], self.labels[:split] else: self.images, self.labels self.images[split:], self.labels[split:] def __len__(self): return len(self.labels) def __getitem__(self, idx): img torch.from_numpy(self.images[idx]).unsqueeze(0) # (1, 28, 28) return img, self.labels[idx]这段代码要注意三个细节struct.unpack(IIII, ...)中的表示大端序IDX 格式规定所有字段都是大端漏掉会读出荒谬的维度np.frombuffer读取后必须 reshape 成(num, rows, cols)否则后续索引会乱掉分割验证集时用切片而非随机采样是因为 MNIST 本身已经打乱过文件顺序直接取前 10% 做验证集不会引入类别分布偏差。验证集的作用不是参与训练而是用来监控每个 epoch 结束时的泛化能力后续所有早停和模型选择都基于它而不是测试集。2.2 DataLoader 的 batch 与 shuffle梯度更新频率背后的收敛差异很多课程作业的代码里只有batch_size64一个超参但 DataLoader 的shuffle和num_workers其实同样影响训练行为。shuffleTrue保证每个 epoch 内样本顺序被打乱避免模型在相邻 batch 中学到连续同类样本的虚假规律num_workers控制数据预取进程数在 MNIST 这种小图上默认值 0 也够用但如果在 Windows 上跑多进程需要把数据加载逻辑放进if __name__ __main__保护块否则会递归创建子进程报错。batch_size 的选择直接影响收敛曲线较小的 batch如 32梯度噪声大可能跳出局部极小收敛后泛化性略好但训练时间更长较大的 batch如 256梯度估计更稳定训练更快但容易收敛到尖锐极小值在验证集上表现稍差。MNIST 上常见的安全区间是 64~128我一般直接取 64 并固定把调参精力留给学习率。train_loader DataLoader(train_set, batch_size64, shuffleTrue, num_workers2) val_loader DataLoader(val_set, batch_size256, shuffleFalse, num_workers2)验证集的 batch 可以设大一些因为不需要反传梯度一次前向跑完更多样本能缩短评估耗时。这里还要注意shuffleFalse对 BN 层的影响BatchNorm 在训练时统计每个 batch 的均值和方差推理时用全局统计量验证阶段不更新 BN 参数所以 shuffle 与否不影响结果只是为了指标可复现性才固定顺序。3. CNN 结构设计从 LeNet-5 出发但别照抄 LeNet-53.1 为什么用卷积局部连接与参数共享的归纳偏置CNN 与全连接网络的核心差异在于两个归纳偏置局部性和平移等变性。手写数字的笔画特征是局部像素组合出来的——横、竖、弧线、交叉点——这些模式在图像任意位置出现都表示同样的语义。卷积核通过在空间上滑动共享权重把参数量从全连接的28*28*28*28≈61万降到3*3*1*C的数量级同时让网络不关心数字在画面中的具体偏移。LeNet-5 是 1998 年针对 MNIST 设计的经典结构包含两个卷积层和三个全连接层但其原始版本用的是 5×5 卷积核和 tanh 激活与现代 PyTorch 工具链的代差很大。直接照搬 LeNet-5 的代码在 MNIST 上仍然能跑到 98% 以上但梯度饱和问题和收敛速度都不如 ReLU 3×3 小卷积核的组合所以课程作业里更常见的做法是保持 LeNet 的层次风格把激活换成 ReLU池化换成 max pooling。3.2 两层卷积还是三层卷积特征图尺寸与参数量计算设计 CNN 结构时最核心的计算是特征图尺寸公式H_out (H_in 2*padding - kernel_size) / stride 1。以 28×28 输入为例若第一层卷积kernel_size3, padding1, stride1输出仍为 28×28接 2×2 max pooling 后变为 14×14第二层再用相同的 3×3 卷积保持 14×14再池化到 7×7。这种“卷积保尺寸、池化降采样”的组合是主流做法因为它让每一层卷积都能在足够大的特征图上提取模式而不必像 LeNet-5 那样靠卷积直接缩减尺寸。参数量计算的推演也很直观第一层1-32通道的 3×3 卷积权重为3*3*1*32288偏置 32总共 320 个参数第二层32-64通道权重3*3*32*6418432。真正占参数大头的是后续全连接层7×7×643136 维特征展平后如果直接映射到 10 类全连接参数是3136*1031360已经超过两层卷积的总和。这说明 CNN 的参数瓶颈通常在分类头而非特征提取层也因此引出一个作业里常见的进阶考点——用全局平均池化替代展平加全连接能把尾部参数量压到接近零。import torch.nn as nn class ConvNet(nn.Module): def __init__(self, num_classes10): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 32, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), nn.Conv2d(32, 64, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(64 * 7 * 7, 128), nn.ReLU(inplaceTrue), nn.Dropout(p0.5), nn.Linear(128, num_classes), ) def forward(self, x): return self.classifier(self.features(x))这个结构的特征图变化链路是(1,28,28) - conv - (32,28,28) - pool - (32,14,14) - conv - (64,14,14) - pool - (64,7,7) - flatten - (3136) - (128) - (10)。两个设计选择值得在报告里解释第一层用 32 个通道而不是 LeNet 的 6 个是为了给梯度提供更宽的传播路径ReLU 的稀疏激活特性在浅层需要足够多的滤波器来覆盖不同方向的笔画模式Dropout 放在最后一个全连接层之前而不是卷积层之后是因为全连接层的参数量最大、最容易过拟合卷积层本身有参数共享的正则化效果叠加 Dropout 反而可能丢失空间邻域的协同激活信息。层输出尺寸参数量备注Conv2d(1→32, 3×3)(32, 28, 28)320padding1 保持尺寸MaxPool2d(2×2)(32, 14, 14)0stride2 尺寸减半Conv2d(32→64, 3×3)(64, 14, 14)18496含偏置MaxPool2d(2×2)(64, 7, 7)0尺寸减半Linear(3136→128)128401536参数主要集中处Linear(128→10)101290输出 logits3.3 激活函数与池化ReLU 的梯度优势与 max pooling 的平移鲁棒性激活函数的选择影响梯度流动。sigmoid 和 tanh 在输入绝对值较大时梯度趋近于零深层网络反传时梯度连乘会指数级衰减这就是梯度消失。ReLU 在正区间梯度恒为 1负区间输出为 0既缓解梯度消失又带来稀疏性但负区间梯度为 0 会导致神经元“死亡”——如果某神经元对所有样本的输入都是负的它的权重永远不会更新。MNIST 上 Neurons 死亡不是大问题因为数据分布相对集中但在作业里如果要展示对深度网络的理解可以提一下 LeakyReLU 作为备选nn.LeakyReLU(negative_slope0.01)让负区间保留一个小梯度避免永久失活。池化层的选择上max pooling 取邻域最大值保留最显著的激活响应对笔画位置的小偏移有天然容忍度average pooling 平滑但会削弱强激活。对于手写数字这种笔画粗细不一的数据max pooling 是更稳妥的选择。这里有个容易被忽略的细节nn.MaxPool2d(kernel_size2, stride2)等价于nn.MaxPool2d(2)但显式写出 stride 更利于报告里演示特征图尺寸变化。4. 训练与调参从 98% 到 99%瓶颈在训练策略4.1 损失函数与优化器交叉熵 SGD 动量为什么是 MNIST 的保守最优解分类任务的默认损失函数是交叉熵CrossEntropyLossPyTorch 的nn.CrossEntropyLoss内部已经包含了 Softmax 计算所以网络最后一层输出的是未归一化的 logits不要再手动套 Softmax否则会二次变换导致梯度异常。交叉熵与 MSE 的根本区别在于MSE 对输出概率的梯度是线性的在 Softmax 之后会出现梯度饱和而交叉熵配合 Softmax 反传时梯度形式是(softmax(z) - y)当预测概率接近真实标签时梯度趋近于零但不会饱和过慢这在数学上保证了更快的收敛。优化器选择上课程作业最常见的是 Adam因为它免调学习率、上手快。但如果你想在 MNIST 上稳定跑过 99%我建议用 SGD momentumAdam 的自适应学习率在后期容易在极小值附近震荡而 SGD 的动量项能平滑梯度方向配合余弦退火能收敛到更平坦的极小值。PyTorch 中的optim.SGD(model.parameters(), lr0.01, momentum0.9)是经典配置。关于 momentum 的直观理解它不改变当前位置的梯度而是维护一个历史梯度的指数移动平均相当于给参数更新加了惯性能有效穿过局部极小和鞍点。criterion nn.CrossEntropyLoss() optimizer torch.optim.SGD(model.parameters(), lr0.01, momentum0.9, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max30)weight_decay1e-4是 L2 正则化的实现方式它对大权重施加惩罚迫使网络使用更小的权重值这对全连接层占大头的模型尤其有效。CosineAnnealingLR让学习率从初始值按余弦曲线下降到接近零T_max 设为总训练轮数。如果你选择固定学习率 0.01 不带衰减在 MNIST 上也能收敛到 98.5% 左右但最后 0.5% 的提升往往依赖学习率在后期变小让参数在极小值附近精细搜索。4.2 训练循环的骨架不记录 loss 的训练都是自欺欺人训练代码本身并不复杂但以下骨架里藏了三个关键动作每个 epoch 交替训练与验证、记录 train loss 和 val acc、用验证集准确率做模型保存的依据。这里要特别强调保存模型时不要只存state_dict而不存结构。课程作业通常要求提交权重文件正确做法是保存checkpoint {model_state_dict: ..., optimizer_state_dict: ..., epoch: ..., val_acc: ...}这样后续无论是断点续训还是推理恢复都能完全还原训练状态。def train_one_epoch(model, loader, criterion, optimizer, device): model.train() total_loss 0.0 for images, labels in loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() total_loss loss.item() * images.size(0) return total_loss / len(loader.dataset) torch.no_grad() def evaluate(model, loader, device): model.eval() correct 0 for images, labels in loader: images, labels images.to(device), labels.to(device) outputs model(images) preds outputs.argmax(dim1) correct (preds labels).sum().item() return correct / len(loader.dataset)代码里的三个关键点optimizer.zero_grad()必须在每个 batch 开始前清空梯度否则 PyTorch 默认累加梯度model.train()与model.eval()切换 Dropout 和 BatchNorm 的行为模式评估时 Dropout 关闭、BN 使用全局统计量忘记切换是新手最常见的问题torch.no_grad()在评估时禁用自动求导省显存且加速。损失函数返回的是 tensorloss.item()取出 Python 标量用于记录直接打印 loss tensor 会带着grad_fn在控制台里难读且浪费内存。4.3 训练轮数与验证集监控早停比加大模型更实在跑多少轮合适直接看验证集准确率的曲线拐点。MNIST 上用上述两层卷积结构一般 5 轮左右能到 98%10 轮左右到 99%继续训练到 30 轮准确率可能会在 99.2%~99.4% 之间反复震荡不再稳定上升。此时如果强制继续在训练集上优化训练 loss 会继续下降但验证集准确率不再提升甚至下降这就是过拟合的信号。当val_acc连续 5 轮不更新时保存当前最佳模型并停止训练这个策略称为早停。best_acc 0.0 patience 5 wait 0 for epoch in range(30): train_loss train_one_epoch(model, train_loader, criterion, optimizer, device) val_acc evaluate(model, val_loader, device) scheduler.step() if val_acc best_acc: best_acc val_acc torch.save({state_dict: model.state_dict(), val_acc: best_acc}, best.pt) wait 0 else: wait 1 if wait patience: print(fearly stop at epoch {epoch}) break print(fepoch {epoch}: loss{train_loss:.4f}, val_acc{val_acc:.4f})patience5的语义是连续 5 个 epoch 验证集准确率没有刷新就停下来。这个值在 MNIST 上不需要太大因为收敛速度快过大的 patience 只会浪费时间。训练完成后加载best.pt再跑测试集最终结果通常与验证集结果非常接近因为 MNIST 测试集和验证集来自同一分布。5. CPU/GPU 适配从device到数据搬运的细节5.1 设备判断与 tensor 搬运的坑大作业运行环境可能是自己的笔记本也可能是课程提供的无 GPU 服务器。代码里最稳妥的写法是自动选择设备device torch.device(cuda if torch.cuda.is_available() else cpu)。但要小心一个隐性问题.to(device)返回的是新 tensor直接调用images.to(device)而不重新赋值不会让数据真正迁移。更隐蔽的坑是 Mixed 精度训练时如果模型和数据都在 CPUtorch.cuda.amp.autocast()会跳过不报错而这在 GPU 上会影响 BatchNorm 的数值稳定性需要单独跑一次前向检查特征图尺寸来确认没有静默错误。CPU 上的训练不需要特别优化MNIST 单张图只有 784 个输入维度两层卷积的前向计算量约 40 万次乘加即使是纯 CPU 一个 epoch 也只需要十几秒。如果发现 CPU 训练异常慢优先检查是否在evaluate中误调了torch.no_grad()——没有禁用自动求导会让验证阶段也构建计算图导致显存和耗时翻倍。5.2 无法复现实验时的排查路径如果训练结果和报告里写的不一致先看三个地方是否设置了随机种子random.seed(0)、np.random.seed(0)、torch.manual_seed(0)缺一不可是否有数据增强——如果验证时也做了随机旋转或平移指标会被拉低常见做法是验证集只做归一化模型加载时model.load_state_dict(torch.load(best.pt)[state_dict])是否忽略了strictTrue参数默认严格模式下权重尺寸不匹配会直接抛异常看到size mismatch报错说明网络结构与保存时不一致。6. 进阶技巧与模型保存把 99.2% 推到 99.6% 的实战策略在基础模型跑通后如果要让作业在横向对比中脱颖而出整节给出的技巧是按性价比从高到低排列的。第一优先级是数据增强。MNIST 上的经典增强是RandomAffine(degrees10, translate(0.1, 0.1), scale(0.9, 1.1))——小角度旋转、上下左右平移、轻微缩放。手写数字存在大量书写偏移训练时经过这些增强等价于隐式扩充数据分布让卷积核对位置和角度变化更鲁棒。增强只应在训练集启用PyTorch 中建议把增强写在Dataset.__getitem__中验证集和测试集路径不调用。几行增强通常能把验证集准确率提升 0.2~0.4 个百分点。from torchvision import transforms train_transform transforms.Compose([ transforms.RandomAffine(degrees10, translate(0.1, 0.1)), transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) val_transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ])注意RandomAffine会让部分像素被移出边界并填充为 0如果配合Normalize填充的 0 在标准化后会变成-0.424这意味着图像边缘不再代表“空白”。无伤大雅但如果你要追求极致精度可以用fill0, fillcolor0, interpolationInterpolationMode.BILINEAR配合边界填充策略让增强后的空白区域更接近原始灰度分布。第二优先级是针对尾部分类头的改进。用全局平均池化GAP替代展平 全连接。具体做法是把self.classifier替换为nn.AdaptiveAvgPool2d(1)特征图从(64, 7, 7)平均池化到(64, 1, 1)再展平为 64 维接一个Linear(64, 10)。这样全连接参数量从 40 万降到 640过拟合风险显著降低训练时对weight_decay的敏感性也变小。GAP 还能约束网络在最后卷积层学到空间平均意义上的类别特征这对可视化类激活图也更有解释力。第三优先级是预测时的技巧。测试阶段把图像做多次轻微偏移或缩放分别预测后取平均这种策略叫 Test-Time Augmentation。对 MNIST 来说 TTA 带来的提升大约在 0.05 个百分点性价比不高但在作业报告的“进一步改进”中值得一笔带过证明你理解推理阶段的数据分布补偿逻辑。关于模型保存课程作业的最终交付物除了权重文件外通常要求同时提交可加载的模型定义代码和推理脚本model.eval()必须在推理开始前调用确保 Dropout 被关闭。如果你是 5 年以上的从业者看到这份大作业时不妨留意一个细节它把深度学习入门最核心的思维链路——数据侧增强、结构侧设计、训练侧调优、推理侧验证——完整走了一遍正是这个领域里从会跑模型到会调模型的必经台阶。做完这份作业比准确率数字更值钱的是你能回答清楚“为什么在某个 epoch 之后准确率不再上升”这个看似简单却需要综合判断的问题。本文还有配套的精品资源点击获取
返回列表