
简介面向中科大自动化系2022人工智能导论课程大作业三的CNN MNIST手写数字识别项目是一份适合初学者与课程设计者的完整实践代码包。包内共6个文件包括4个Python脚本如cnn.py、train.py、main.py、tt.py、1个Markdown说明文档及1个License文件压缩包仅8KB便于快速获取与运行。该项目覆盖MNIST数据加载与预处理、CNN模型构建、训练评估等关键环节可直接用于理解卷积神经网络在图像识别中的应用也可作为人工智能大作业、课程设计或毕业设计的参考实现。已有142人学习下载对于希望快速掌握深度学习实践流程的读者具有较高的参考价值。1. MINST 这个拼写比 MNIST 更常出现在课程作业里MINST 这个拼写在课程报告的代码注释里出现的频率远高于正确的 MNIST几乎是每个 AI 导论课作业季都会复现的笔误。作为一个做了多年深度学习落地的人我看这类手写数字识别作业的第一反应不是“这题简单”而是先确认提交的代码能不能在一台没有 GPU 的机器上、用 5 分钟跑出 99% 的测试精度。CNN 手写数字识别是卷积神经网络最经典的最小闭环也是几乎每个深度学习入门课都会设置的开胃菜但要把“能跑”变成“能讲清楚、能扛住答辩追问”需要把结构设计、训练策略、评价指标串成一条线。这篇就按交付一份能直接运行、能被追问的课程大作业的标准来写覆盖数据加载、网络构建、参数调优和最终验证适合正在写这类课设的学生也适合想快速过一遍 CNN 结构图和训练细节的工程师。2. CNN 手写数字识别的结构取舍从“堆层数”到看感受野2.1 为什么 784 维输入不能直接接全连接MNIST 的每一张图是 28×28 的灰度图展平之后是 784 维向量。很多人第一次写手写数字识别时会直接用nn.Linear(784, 10)加一个交叉熵损失发现测试精度能到 92% 左右于是觉得卷积是多余的。这个结论只对了一半全连接层确实能记住训练集里的数字形状但它把每个像素当成独立的特征完全没有利用“相邻像素组成笔画”这一图像先验。CNN 的出发点就是像素之间的局部相关性。一个手写数字的“7”和“9”区别在于右上角有没有一个向上的半弧这个信息集中在很小的邻域内不需要看整张图就能判断。卷积核本质上是一个在二维平面上滑动的特征检测器它把“邻近像素的加权和”作为输出这种权重复用让同一个特征比如横线、竖线、圆弧在图像不同位置被识别时共享同一组参数。对于像 MINST 这种白底黑字、几乎没有噪声的数据局部特征极其稳定CNN 对这类任务几乎是降维打击。还有个细节值得注意如果直接展平输入图像的空间结构被打散全连接层要做的事情变成了“从 784 个无序数字里恢复出 28×28 的排列关系”这需要大量参数去隐式记忆位置属于典型的浪费。卷积层通过天然的二维滑动窗口保留了像素的相对位置池化层再逐步压缩空间尺寸把“在哪”的信息弱化、把“是什么”的信息保留。2.2 参数量对比卷积层其实很“便宜”很多人误以为 CNN 参数量一定比全连接大真实情况正好相反。看一组实际数字一个输入 28×28 的单通道图如果直接接全连接层784→128→10第一层参数就是 784×128128≈10 万而一个两卷积加两全连接的小型 CNN总参数量通常在 4 万到 6 万之间。网络结构参数量估算占用显存batch64, float32全连接 784→128→10约 10.05 万约 25 MB单卷积 全连接 1→8→10约 0.7 万约 5 MB双卷积 双全连接LeNet-5 变体约 4 万约 15 MB深层 ResNet-18约 1100 万约 280 MB不含中间激活卷积层的参数量主要由卷积核大小和输入输出通道数决定而不是由输入图像的尺寸决定。一个3×3卷积在 28×28 输入上滑动和在一个 224×224 输入上滑动共享的是一模一样的 9 个权重。真正吃掉参数的是最后的全连接层经过两层池化后特征图尺寸从 28×28 缩到 7×7如果输出通道是 64展平后就是 3136 维再接全连接层就会很肥。所以紧凑网络设计的原则是卷积层负责提取特征全连接层只做最终分类能把 FC 的输入维度压小就压小。2.3 一个可以直接映射到代码的 CNN 结构图手写数字识别最经典的结构是 LeNet-5 的变体也是我在这类课设里最常用的骨架卷积 → 激活 → 池化 → 卷积 → 激活 → 池化 → 展平 → 全连接 → 全连接。第一层卷积用 5×5 或 3×3 都可以MNIST 的笔画宽度只有几个像素3×3 足够看到笔画交叉点5×5 能一次性看到更完整的局部字形两者差距不大。层输入尺寸输出尺寸参数含 bias说明Conv2d(1, 32, 3, padding1)1×28×2832×28×28320提取低级特征padding 保持尺寸ReLU MaxPool(2)32×28×2832×14×140下采样增强平移不变性Conv2d(32, 64, 3, padding1)32×14×1464×14×1418496组合低级特征成笔画/轮廓ReLU MaxPool(2)64×14×1464×7×70空间尺寸缩至 7×7FC(64×7×7, 128)3136128401536全连接融合全局特征FC(128, 10)128101290输出 10 类 logits这个结构总参数量约 42 万主要开销在第一个全连接层。如果机器不差可以再加一个 Dropout(0.5) 层来控制过拟合如果追求简洁去掉全连接改成全局平均池化参数量能再降一个量级但收敛速度会变慢。对于课设而言这个结构图已经足够撑起报告里“网络设计”章节同时它和 PyTorch 代码是一一对应的不会出现“结构图画了一套、代码跑的是另一套”的尴尬。3. 用 PyTorch 跑通 MNIST 的最小完整代码数据加载到权重保存3.1 数据加载与归一化的正确姿势用 torchvision 加载 MNIST 是固定套路但归一化参数不是随便填的。MNIST 数据集的全局均值和标准差是统计好的固定值mean0.1307, std0.3081这和 ImageNet 的mean[0.485, ...]一样是数据本身的属性不是超参数。不归一化直接输入 0~255 的像素值卷积层的权重初始化和 BN 层会互相“打架”表现为 loss 下降非常慢。import torch from torch.utils.data import DataLoader from torchvision import datasets, transforms transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_set datasets.MNIST(root./data, trainTrue, downloadTrue, transformtransform) test_set datasets.MNIST(root./data, trainFalse, downloadTrue, transformtransform) train_loader DataLoader(train_set, batch_size64, shuffleTrue) test_loader DataLoader(test_set, batch_size256, shuffleFalse)ToTensor()会把 PIL 图像从 H×W×C 的 0~255 整数转成 C×H×W 的 0~1 浮点张量这一步不写的话后续维度会一直对不上。Normalize((0.1307,), (0.3081,))的输入必须和通道数匹配MNIST 是单通道灰度图所以传一个标量的元组而不是三个值。shuffleTrue只在训练集打开测试集要保持固定顺序否则每次评估的批次内容都在变没法对比精度波动。有一个 Windows 上的坑值得单独提DataLoader里的num_workers参数如果设置大于 0在 Windows 的 Jupyter Notebook 里经常因为多进程启动方式报错。课程作业环境一般是本地 Windows CPU直接不写num_workers用默认值 0 最稳妥速度差异在 MNIST 这种小数据集上完全无感。3.2 模型定义让结构图和代码逐行对应模型定义建议直接写在nn.Module里而不是用nn.Sequential一把梭。原因是答辩时经常被问“每一层输出尺寸是多少”写在 forward 里能看到张量维度的变化路径。下面这段代码和上面那个结构图完全一致每一层都加了注释。import torch.nn as nn import torch.nn.functional as F class CNN_MINST(nn.Module): def __init__(self): super().__init__() self.conv1 nn.Conv2d(1, 32, kernel_size3, padding1) self.conv2 nn.Conv2d(32, 64, kernel_size3, padding1) self.pool nn.MaxPool2d(2, 2) self.fc1 nn.Linear(64 * 7 * 7, 128) self.fc2 nn.Linear(128, 10) def forward(self, x): x self.pool(F.relu(self.conv1(x))) # 1*28*28 - 32*14*14 x self.pool(F.relu(self.conv2(x))) # 32*14*14 - 64*7*7 x x.view(x.size(0), -1) # 展平: (batch, 64*7*7) x F.relu(self.fc1(x)) x self.fc2(x) # 分类层不加激活 return xkernel_size3, padding1组合会让卷积输出尺寸和输入保持一致28×28 经过两次卷积再由两个池化各减半最终得到 7×7x.view(x.size(0), -1)把(batch, 64, 7, 7)展平成(batch, 3136)注意这里不是 reshape 成(64*7*7)因为 batch 维度必须保留。最后一个全连接层不加 ReLU因为nn.CrossEntropyLoss内部已经做了LogSoftmax如果在 FC 后手动加 softmax 再传给损失函数精度会下降且数值也不稳定。3.3 训练循环一行一行拆开讲训练循环看起来千篇一律但状态切换是最容易被忽略的细节。model.train()和model.eval()会改变 Dropout 和 BN 层的行为很多人训练精度不错、测试时忘记切回 eval 模式结果测试精度掉 2~3 个百分点。import torch.optim as optim device torch.device(cuda if torch.cuda.is_available() else cpu) model CNN_MINST().to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr1e-3) for epoch in range(10): model.train() running_loss 0.0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() * images.size(0) print(fEpoch {epoch1}, Loss: {running_loss / len(train_set):.4f})optimizer.zero_grad()必须在loss.backward()之前否则梯度会在多次迭代中累加导致权重更新方向偏离loss.item()是取出 Python 标量用于打印如果直接拿loss拼接字符串会报类型错误running_loss乘以images.size(0)是把均值还原成该批次的总损失这样除以全部样本数得到的才是真正的 epoch 平均损失而不是所有 batch 损失的直接平均。10 个 epoch 在 CPU 上大约是 3~5 分钟测试精度能到 99% 左右。如果追求更稳可以加一个lr_scheduler.StepLR(optimizer, step_size3, gamma0.1)在 epoch 4 和 7 各降一次学习率最终精度会再往上顶一点点。3.4 测试集评估与模型保存测试评估的核心是在torch.no_grad()上下文里计算精度并把精度最高的权重单独存一份。不要只在训练结束后才存因为最后一轮未必是最优的。def evaluate(model, loader): model.eval() correct 0 total 0 with torch.no_grad(): for images, labels in 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() return 100.0 * correct / total if evaluate(model, test_loader) best_acc: best_acc evaluate(model, test_loader) torch.save(model.state_dict(), best_model.pth)torch.max(outputs, 1)返回两个张量最大值和对应的索引取索引就是预测类别correct (predicted labels).sum().item()中item()把布尔张量累加后的 tensor 转成 Python 数字这在 CPU 上没问题。保存时只存state_dict()而不是整个模型这样换机器加载时不需要依赖模型的类定义路径代码更干净。4. 把测试集准确率从 97% 提到 99%学习率、Dropout 和归一化4.1 学习率是“假收敛”的头号元凶标准 Adam 默认学习率是 1e-3在 MNIST 上从零训练完全够用但如果换成 SGD学习率不调就会出现两种典型症状Loss 卡在 0.3 附近不动或者前几个 epoch 直接变成 nan。一个稳妥的搭配是 SGD momentum0.9 学习率 0.01配合第 10 轮和第 15 轮的学习率衰减精度和 Adam 几乎一样。优化器学习率典型现象Adam1e-3正常收敛约 3 epoch 后精度 97%Adam1e-2前期波动大可能出现 loss 发散SGD0.01 momentum 0.9收敛稳定但偏慢需要 15 epoch 以上SGD0.1无衰减振荡明显精度上不去判断学习率是否过大不用看训练集看验证集曲线更可靠如果 train loss 在下行但 test accuracy 变成一条水平线往往是学习率太大导致权重在小范围内震荡无法进入更优区域。4.2 batch size 与 epoch 的匹配batch size 从 64 调到 256训练时间会缩短但相同 epoch 数下的精度低 0.3~0.5 个百分点。这不是玄学每个 batch 的梯度是全体样本梯度的近似batch 越大梯度越平滑越容易落入尖锐的局部极小值batch 越小噪声越大反而有正则化效果。MNIST 训练集只有 60000 张图batch 设 64 意味着每个 epoch 有 938 次参数更新10 个 epoch 就有近一万次更新对课设来说信息量完全够。如果显存紧张需要调大 batch我的习惯是同步调大学习率即 batch 增大 k 倍学习率也放大大约 sqrt(k) 倍这样收敛步数与原来大致一致。这在 ResNet 论文里叫 linear scaling rule在 MNIST 这种小网络上同样适用。4.3 Dropout 放哪儿、比例选多少Dropout 的作用是让神经元不能过分依赖同伴适合放在全连接层中间不建议放在卷积层后面因为卷积特征图有空间结构随机置零会破坏局部相邻信息。最常见的课设配置是FC(3136, 128) - ReLU - Dropout(0.5) - FC(128, 10)训练集 60000 张、测试集 10000 张过拟合风险不大Dropout 0.3 到 0.5 都可以。有经验的工程师通常会在报告里写一句dropout 只加在全连接层且测试时要确保模型处于 eval 模式。这句话其实就是区分“调参熟练”和“刚学会跑通”的标志。如果训练完发现 test loss 远高于 train loss先确认代码里有没有漏掉model.eval()再谈加 Dropout。4.4 归一化参数写死还是现算MNIST 的均值和标准差是数据集属性和 ImageNet 一样属于“公开常数”。但有同学喜欢自己写transform.Normalize((images.mean(),), (images.std(),))动态计算训练集统计量。这种做法本身没错但它有一个隐性风险直接用全量训练集算 mean 和 std会把测试集信息泄露给训练流程虽然 MNIST 分布一致影响不大但原则上是错的。正确做法是只用训练集统计量来归一化测试集复用同一组参数。4.5 课设环境最容易踩的三个报错第一个是维度不匹配RuntimeError: mat1 and mat2 shapes cannot be multiplied。全连接层输入是 3136如果前向传播里少了一个池化展平后的维度会变成 12544 或 3136 的整数倍报错行会指向 fc1 的矩阵乘法解决方法是检查 forward 里每一步的 shape。第二个是 GPU 显存不足MNIST 单张图极小batch64 显存占用通常不到 200MB如果爆显存先看是不是模型被 resize 到很大或者有别的进程占着显卡。第三个是torchvision.datasets.MNIST下载慢或失败课设里经常断网提前把MNIST/raw下的四个 gz 文件放进./data/MNIST/raw代码会跳过下载直接解压。5. 交付前把随机种子固定住一份能放心交给任何人的代码课程大作业提交前最怕的一件事是评审老师在自己电脑上跑出来的结果和报告里写的不一样。除了确认环境依赖有个几乎万能的招把随机种子固定住让训练过程可复现。在train.py最顶部加入以下代码import random import numpy as np import torch def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark False set_seed(42)cudnn.deterministicTrue会强制 cuDNN 使用确定性算法代价是运行速度下降 5%~10%但对课设来说换来的是多次运行结果完全一致benchmarkFalse则是关闭 cuDNN 的自动调优这俩同时设置才能确保 GPU 上精度一致。答辩和报告里还需要一个可视化证据。常见做法是保存混淆矩阵和错分样本图错分样本通常是那些潦草的、人类自己都难辨认的数字这本身就是 CNN 局限性的一个直观展示。用一个测试集循环收集(image, true_label, predicted_label)对预测失败的前 10 张用 matplotlib 画成网格配标题为“True: 7, Pred: 2”展示在报告结尾比任何描述都有说服力。精度汇报建议按“训练精度 / 测试精度 / 错分数目”三列写在实验表格里。一份能随时重跑、能稳定复现、能在答辩现场快速展示混淆矩阵的代码已经超过大多数只交一个 ipynb 的作业。把这份代码和训练曲线截图一起打包这份大作业三就完成了它的使命。本文还有配套的精品资源点击获取