简介这是一套基于卷积神经网络的MNIST手写数字识别项目在期末大作业中获得98分主要面向计算机专业正在准备课程设计或期末大作业的学生也适合希望入门图像分类的深度学习初学者。压缩包共6个文件整体仅1.32MB包含Python源码.py、Jupyter Notebook.ipynb、实验报告.pdf、依赖与说明文档.txt、.md既可直接运行复现又能对照查看训练流程和依赖环境。目前已有162人学习浏览适合作为从零搭建CNN识别项目的参考模板。从内容预览来看项目保留了notebook训练记录、readme说明和报告正文读者可以借此快速理解MNIST数据加载、卷积网络设计、训练与评估等完整环节同时参考高分报告的写作结构用于自己的大作业答辩或项目拓展。1. 一份 98 分的 MNIST 手写数字识别源码赢在实验设计MNIST 手写数字识别常被称为深度学习的 Hello World但期末大作业想拿 98 分靠的不是把网络堆深而是把“为什么这样设计”讲清楚。这套卷积神经网络源码和配套报告正好是这样一个完整样本cvlab1.py 负责训练与评估history.ipynb 保留调参过程report.pdf 呈现实验对比。整个项目基于 PyTorch结构上可以看作 LeNet-5 的现代改版用两个 3×3 卷积堆叠替代 5×5 大核在几乎不损失精度的情况下把参数压下来。适合正在做课程设计、期末大作业的计算机专业学生也适合想快速复现一套可解释 CNN 流程的工程师。2. 卷积神经网络结构设计从 28×28 输入到 10 类输出2.1 为什么 MNIST 选择 CNN 而不是全连接网络全连接网络在 MNIST 上也能跑出 95% 以上但放到 28×28 的输入上第一层 784×256 就有约 20 万参数且把每个像素视为独立特征完全没有利用“相邻像素构成笔画”这一先验。卷积神经网络通过局部感受野提取边缘、弧线、交叉点等局部模式再用权值共享把参数压缩一个数量级。对 MNIST 这类灰度数字笔画平移几个像素不应改变分类结果CNN 的平移等变性恰好契合这个需求。这里有一个报告中值得写的点MNIST 原图是 28×28而经典 LeNet-5 的输入是 32×32直接把 LeNet-5 搬过来需要补 padding 或者 resize。我在类似项目中一般保留 28×28用 padding1 的 3×3 卷积卷积层不改变特征图尺寸尺寸变化全部交给池化层。这样输入输出尺寸容易推导写报告时参数表也更好算。2.2 卷积核、池化与参数量推导一个可复现性强的结构是层输出尺寸核/填充/步长说明Conv1(N, 32, 28, 28)3×3, pad1, stride1输入 1 通道ReLU(N, 32, 28, 28)-激活函数MaxPool1(N, 32, 14, 14)2×2, stride2下采样Conv2(N, 64, 14, 14)3×3, pad1, stride1输出 64 通道ReLU(N, 64, 14, 14)-激活函数MaxPool2(N, 64, 7, 7)2×2, stride2下采样FC1(N, 128)6477 - 128展平后全连接FC2(N, 10)128 - 10输出 logits计算一下参数量Conv1 有 1×32×3×332320 个参数Conv2 有 32×64×3×36418496 个参数两个全连接分别约 40 万和 1290。总参数约 42 万远小于用两个 5×5 卷积的经典 LeNet-5。为什么用两个 3×3 堆叠两个 3×3 卷积堆叠后第二层每个单元能看到原始输入的 5×5 区域感受野和单个 5×5 卷积一致但参数量是 2×918 而不是 25而且中间多了一次 ReLU非线性表达能力更强。这个推导写在报告里是很扎实的得分点。在 PyTorch 中定义这个模型只需几十行import torch.nn as nn class MNISTCNN(nn.Module): def __init__(self): 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, 10), ) def forward(self, x): return self.classifier(self.features(x))代码逻辑features负责从原始图像中提取空间特征前两个卷积层都不改变特征图分辨率只有两个MaxPool2d把 28×28 依次降到 14×14、7×7。classifier先把特征图展平成 3136 维向量再经过 128 维隐层输出 10 类 logits。Dropout(p0.5)只在训练时生效评估时会自动关闭用于抑制过拟合。需要注意inplaceTrue在部分 PyTorch 版本和自动求导场景下可能引起问题尤其是后面要用 Hook 可视化中间特征时。如果实验中发现梯度异常把inplaceTrue去掉即可对最终精度影响可以忽略。卷积层涉及的关键参数是 kernel_size、padding、stride三者共同决定输出尺寸。输出尺寸公式为(W - kernel_size 2*padding)/stride 1MNIST 输入 28×28kernel3、padding1、stride1 时结果仍是 28×28。池化层不改变通道数只把高度和宽度减半。报告里只要列一张这样的参数表老师就能看出你不是在调包而是真的算过每一层。2.3 损失函数与优化器选型分类任务默认使用nn.CrossEntropyLoss()它内部把log_softmax和负对数似然合在一起所以模型最后一层不需要手动加 Softmax。评估时如果想输出概率再用torch.softmax(logits, dim1)。这里有个常见误区训练时用CrossEntropyLoss评估时却把model.eval()忘了导致 Dropout 仍然生效验证集准确率忽高忽低。model.eval()和with torch.no_grad():必须成对出现前者切换 BatchNorm/Dropout 状态后者关闭梯度计算减少显存占用。优化器方面期末项目我建议先用 Adam学习率 1e-3收敛快、不用手动调动量。如果报告需要“对比实验”可以再补一组 SGD Momentum0.9学习率 0.01 的数据说明 Adam 收敛更快但 SGD 在充分训练后精度接近。这一组对比放在实验部分比单纯堆网络层数更能体现对优化算法的理解。3. cvlab1.py 实战走读数据加载、训练与评估3.1 DataLoader 与归一化参数cvlab1.py 里第一步通常是构造数据集和 DataLoader。MNIST 的标准做法如下from torchvision import datasets, transforms from torch.utils.data import DataLoader 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, num_workers2) test_loader DataLoader(test_set, batch_size256, shuffleFalse, num_workers2)这段代码的四个关键点第一ToTensor()把 PIL 图像从 0-255 转成 0-1 的浮点张量并调整通道维度为 (C, H, W)第二Normalize((0.1307,), (0.3081,))用 MNIST 训练集的全局均值和标准差做标准化让输入分布接近标准正态避免 Conv 输出过大或过小第三shuffleTrue只用于训练集打乱顺序能消除样本排列带来的梯度偏差第四测试集batch_size可以设大一些比如 256因为评估阶段不需要反向传播没必要小批量迭代那么多次。DataLoader 参数直接影响训练速度和收敛稳定性具体作用见下表参数取值作用batch_size64每个 batch 的样本数影响梯度估计稳定性shuffleTrue每个 epoch 重新打乱避免样本顺序引入偏差num_workers2数据加载子进程数Windows 下易报错可改为 0pin_memoryTrue锁页内存GPU 训练时减少传输开销如果训练时发现num_workers2在 Windows 下反复报 DataLoader worker 错误先把它改成 0问题通常是多进程 fork 和本机开发环境冲突。这类环境问题在报告“运行环境”一节写一句即可不要浪费太多时间。3.2 训练循环的标准写法训练部分看起来简单但很多大作业丢分就丢在流程不完整。一个合格的训练循环至少包含损失打印、验证集评估、模型保存、训练曲线记录。import torch device torch.device(cuda if torch.cuda.is_available() else cpu) model MNISTCNN().to(device) criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3) epochs 10 best_acc 0.0 for epoch in range(epochs): model.train() total_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() total_loss loss.item() * images.size(0) avg_loss total_loss / len(train_loader.dataset) acc evaluate(model, test_loader, device) print(fepoch {epoch1}/{epochs} loss{avg_loss:.4f} acc{acc:.4f}) if acc best_acc: best_acc acc torch.save(model.state_dict(), best_mnist.pt)zero_grad()必须放在每次反向传播之前否则梯度会跨 batch 累加这里累加的是loss.item() * images.size(0)用样本数加权得到整个 epoch 的平均损失而不是简单对所有 batch 的 loss 取平均因为最后一个 batch 可能不足 64 张。evaluate函数需要单独实现在验证集上统计正确样本数。best_acc acc判断保存的是测试集上准确率最高的权重。如果想要更保险可以同时监测验证集 loss连续 3 个 epoch 不下降就提前停止训练也就是 early stopping。MNIST 上一般不需要 early stopping因为 10-12 个 epoch 内过拟合不严重但代码里留一个 patience 参数报告里提一句“用 early stopping 防止过拟合”评分观感会更好。3.3 评估函数与易错点评估函数很容易踩坑用torch.max(outputs, dim1)取预测类别若写成outputs.argmax(dim1)则更简洁。注意返回值是values和indices不要搞混。def evaluate(model, loader, device): 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) preds outputs.argmax(dim1) correct (preds labels).sum().item() total labels.size(0) return correct / totalmodel.eval()放在循环外面而不是每个 batch 都切换能减少不必要的状态切换。with torch.no_grad()包裹整个循环避免为评估图额外构建计算图。如果漏掉model.eval()Dropout 层会让每次前向结果随机同一张图预测两次可能得到不同类别这是初学阶段最容易出现“训练 99%、测试 60%”的原因之一。3.4 把训练历史存成 JSON 便于报告画图history.ipynb 通常负责画曲线和做可视化。为了不让训练脚本和绘图脚本耦合我习惯在训练循环里把每个 epoch 的avg_loss和acc追加到一个字典结束后写 JSONimport json history {loss: [], acc: []} history[loss].append(avg_loss) history[acc].append(acc) json.dump(history, open(history.json, w), indent2)报告里的 loss 曲线、准确率曲线都来自这个文件。这样做的好处是训练和画图分离你可以在不重新训练的情况下反复调整图表样式报告排版也更灵活。如果用 history.ipynb 直接跑完所有代码图是能看但每次改样式都要重训一遍浪费时间不说随机种子一变结果也跟着变。4. 训练调参与 torchvision 下载 MNIST 404 问题处理4.1 一组可稳定到达 98 分以上的超参数MNIST 本身不难随机初始化不加数据增强普通 CNN 也能到 99%。但大作业想要 98 分评分点通常在“实验是否完整、分析是否到位”而不是绝对精度。下面是一组我常用的参数稳定在 99% 以上参数值说明优化器Adamlr1e-3beta(0.9, 0.999)损失函数CrossEntropyLoss内部含 softmaxbatch_size64太小收敛慢太大精度略降epochs10-12超过后准确率基本不涨数据增强无 / RandomAffine 可选加增强后会慢一点学习率衰减StepLR(step_size5, gamma0.5)后期微调精度 0.1-0.2%想写数据增强对比时可以在 transform 里加transforms.RandomAffine(degrees5, translate(0.05, 0.05))。但要记住一个关键点数据增强只加在训练集 transform 上测试集必须保持和原始评估一致否则会稀释真实精度。报告里写清楚“训练集增强、测试集不增强”比堆叠 20 层网络更有说服力。Adam 和 SGD 的对比是报告里性价比很高的一张图。SGD Momentum 在 lr0.01、momentum0.9 时前 3 个 epoch 收敛很慢但 10 个 epoch 后准确率能追到 99.1% 附近Adam 在 lr1e-3 下第 2 个 epoch 就超过 98%。把这个对比画成两条 acc 曲线体现出你知道优化器之间的 trade-off比换网络结构更打动人。学习率衰减不是必须的。对 10 个 epoch 的训练Adam 在 lr1e-3 下已经收敛得不错。如果想把指标再往上推增加带 StepLR 的对比实验可以看到最后几个 epoch 验证集 loss 曲线从轻微震荡转为平稳。4.2 torchvision 下载 MNIST 报 404 的真正原因搜索“torchvision下载mnist会404”的同学多半是栽在数据集下载这一环。这不是代码写错而是 torchvision 内置的 MNIST 下载地址随数据集官方迁移而变化旧版本 torchvision 仍然指向老的yann.lecun.com/exdb/mnist路径服务器返回 404或者 SSL 证书校验失败。遇到这类问题我的处理顺序是先升级 torchvision 到较新版本新版本已经切换到https://ossci-datasets.s3.amazonaws.com/mnist/镜像如果环境不允许升级就手动下载四个 gz 文件放进./data/MNIST/raw/然后把download参数改为False。四个文件分别是train-images-idx3-ubyte.gz、train-labels-idx1-ubyte.gz、t10k-images-idx3-ubyte.gz、t10k-labels-idx1-ubyte.gz。放置好后的校验代码import os raw ./data/MNIST/raw required [ train-images-idx3-ubyte.gz, train-labels-idx1-ubyte.gz, t10k-images-idx3-ubyte.gz, t10k-labels-idx1-ubyte.gz, ] for name in required: path os.path.join(raw, name) print(name, os.path.exists(path), os.path.getsize(path) if os.path.exists(path) else -)如果四个文件都存在把downloadTrue改成downloadFalse再跑。Dataset 类会自动检查原始 gz 文件存在就不会重新下载也会自动解压到processed目录。这里有一个容易混淆的点downloadFalse只表示不触发下载并不会绕过文件完整性检查如果 gz 文件损坏或缺失会抛出RuntimeError这和 404 错误是两回事。提示手动下载后最好看一下文件大小train-images-idx3-ubyte.gz 约 9.9MBt10k-images-idx3-ubyte.gz 约 1.6MB。如果文件只有几十 KB大概率是下载到了错误页面需要删除重新下载。如果你用的是 conda 环境可以更保险地把 torchvision 和 pytorch 一起升级避免出现 torchvision 与 CUDA 版本不匹配。升级后第一次运行会自动到新地址下载不需要改任何代码。4.3 实验环境记录与随机种子98 分的报告通常会附一张环境表Python 3.10、PyTorch 2.x、CUDA 12.1、显卡型号。如果代码在 CPU 上训练每个 epoch 约 30-60 秒10 个 epoch 也能接受。为了结果可复现训练脚本开头固定随机种子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)固定种子之后同一台机器两次训练的最终准确率差异应该在 0.1% 以内。需要注意DataLoader 的num_workers多进程会引入随机性即使固定种子严格可复现还需要在generator参数里传入torch.Generator()。对于期末大作业固定到 PyTorch 层的随机种子已经足够报告里说明“相同种子下可复现”即可。5. 用错误样本分析给报告“上分”5.1 把错误样本单独挑出来训练结束后除了一张测试集准确率最能体现工程能力的是错误样本分析。实现方式是在评估循环里记录(图片, 真实标签, 预测标签, 预测概率)最后统一可视化error_samples [] model.eval() with torch.no_grad(): for images, labels in test_loader: outputs model(images) preds outputs.argmax(dim1) probs torch.softmax(outputs, dim1) for i in range(len(labels)): if preds[i] ! labels[i]: error_samples.append({ image: images[i], true: labels[i].item(), pred: preds[i].item(), prob: probs[i, preds[i]].item(), })prob是模型对错误类别的置信度。如果错误样本的置信度普遍低于 0.3说明模型只是在边界模糊处“犹豫”如果出现置信度超过 0.9 还分错说明训练集里可能存在相似度极高的不同数字对比如手写 7 和 9、3 和 8。5.2 在报告里用一张图说明失败模式把错误样本拼接成一整张网格图旁边标注真实→预测放在报告的“局限性分析”一节。用 matplotlib 实现import matplotlib.pyplot as plt fig, axes plt.subplots(3, 5, figsize(12, 8)) for ax, sample in zip(axes.ravel(), error_samples[:15]): img sample[image].squeeze() ax.imshow(img, cmapgray) ax.set_title(f{sample[true]}-{sample[pred]}\nconf{sample[prob]:.2f}) ax.axis(off) plt.tight_layout() plt.savefig(error_analysis.png, dpi200)图片保存时设置dpi200直接嵌入报告 PDF 也不会糊。分析维度有三个样本本身的书写风格是否潦草、预测分布里第二高概率的是哪个类别、错误集中发生在哪些数字对上。写清楚这三条比单独贴十个混淆矩阵更能展示对模型行为的理解也正好回应了前面所有预处理的细节。另外还可以把错误样本的预测概率做成直方图横轴是置信度纵轴是数量能直观看出模型是在“低置信度犯错”还是“高置信度犯错”。这组图可以直接复用测试集的输出不需要重新训练。把error_analysis.png和history.json一起放进项目目录评审打开就能看到完整实验链。本文还有配套的精品资源点击获取