简介:中科大自动化系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, train=True): with open(images_path, 'rb') as f: magic, num, rows, cols = struct.unpack('>IIII', f.read(16)) images = np.frombuffer(f.read(), dtype=np.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(), dtype=np.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_size=64一个超参,但 DataLoader 的shuffle和num_workers其实同样影响训练行为。shuffle=True保证每个 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_size=64, shuffle=True, num_workers=2) val_loader = DataLoader(val_set, batch_size=256, shuffle=False, num_workers=2)验证集的 batch 可以设大一些,因为不需要反传梯度,一次前向跑完更多样本能缩短评估耗时。这里还要注意shuffle=False对 BN 层的影响:BatchNorm 在训练时统计每个 batch 的均值和方差,推理时用全局统计量,验证阶段不更新 BN 参数,所以 shuffle 与否不影响结果,只是为了指标可复现性才固定顺序。
3. CNN 结构设计:从 LeNet-5 出发,但别照抄 LeNet-5
3.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_size=3, padding=1, stride=1,输出仍为 28×28;接 2×2 max pooling 后变为 14×14;第二层再用相同的 3×3 卷积保持 14×14,再池化到 7×7。这种“卷积保尺寸、池化降采样”的组合是主流做法,因为它让每一层卷积都能在足够大的特征图上提取模式,而不必像 LeNet-5 那样靠卷积直接缩减尺寸。
参数量计算的推演也很直观:第一层1->32通道的 3×3 卷积,权重为3*3*1*32=288,偏置 32,总共 320 个参数;第二层32->64通道,权重3*3*32*64=18432。真正占参数大头的是后续全连接层:7×7×64=3136 维特征展平后,如果直接映射到 10 类,全连接参数是3136*10=31360,已经超过两层卷积的总和。这说明 CNN 的参数瓶颈通常在分类头而非特征提取层,也因此引出一个作业里常见的进阶考点——用全局平均池化替代展平加全连接,能把尾部参数量压到接近零。
import torch.nn as nn class ConvNet(nn.Module): def __init__(self, num_classes=10): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 32, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2), nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2), ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(64 * 7 * 7, 128), nn.ReLU(inplace=True), nn.Dropout(p=0.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) | 320 | padding=1 保持尺寸 |
| MaxPool2d(2×2) | (32, 14, 14) | 0 | stride=2 尺寸减半 |
| Conv2d(32→64, 3×3) | (64, 14, 14) | 18496 | 含偏置 |
| MaxPool2d(2×2) | (64, 7, 7) | 0 | 尺寸减半 |
| Linear(3136→128) | 128 | 401536 | 参数主要集中处 |
| Linear(128→10) | 10 | 1290 | 输出 logits |
3.3 激活函数与池化:ReLU 的梯度优势与 max pooling 的平移鲁棒性
激活函数的选择影响梯度流动。sigmoid 和 tanh 在输入绝对值较大时梯度趋近于零,深层网络反传时梯度连乘会指数级衰减,这就是梯度消失。ReLU 在正区间梯度恒为 1,负区间输出为 0,既缓解梯度消失又带来稀疏性,但负区间梯度为 0 会导致神经元“死亡”——如果某神经元对所有样本的输入都是负的,它的权重永远不会更新。MNIST 上 Neurons 死亡不是大问题,因为数据分布相对集中,但在作业里如果要展示对深度网络的理解,可以提一下 LeakyReLU 作为备选:nn.LeakyReLU(negative_slope=0.01)让负区间保留一个小梯度,避免永久失活。
池化层的选择上,max pooling 取邻域最大值,保留最显著的激活响应,对笔画位置的小偏移有天然容忍度;average pooling 平滑但会削弱强激活。对于手写数字这种笔画粗细不一的数据,max pooling 是更稳妥的选择。这里有个容易被忽略的细节:nn.MaxPool2d(kernel_size=2, stride=2)等价于nn.MaxPool2d(2),但显式写出 stride 更利于报告里演示特征图尺寸变化。
4. 训练与调参:从 98% 到 99%,瓶颈在训练策略
4.1 损失函数与优化器:交叉熵 + SGD 动量为什么是 MNIST 的保守最优解
分类任务的默认损失函数是交叉熵(CrossEntropyLoss),PyTorch 的nn.CrossEntropyLoss内部已经包含了 Softmax 计算,所以网络最后一层输出的是未归一化的 logits,不要再手动套 Softmax,否则会二次变换导致梯度异常。交叉熵与 MSE 的根本区别在于:MSE 对输出概率的梯度是线性的,在 Softmax 之后会出现梯度饱和,而交叉熵配合 Softmax 反传时,梯度形式是(softmax(z) - y),当预测概率接近真实标签时梯度趋近于零但不会饱和过慢,这在数学上保证了更快的收敛。
优化器选择上,课程作业最常见的是 Adam,因为它免调学习率、上手快。但如果你想在 MNIST 上稳定跑过 99%,我建议用 SGD + momentum:Adam 的自适应学习率在后期容易在极小值附近震荡,而 SGD 的动量项能平滑梯度方向,配合余弦退火能收敛到更平坦的极小值。PyTorch 中的optim.SGD(model.parameters(), lr=0.01, momentum=0.9)是经典配置。关于 momentum 的直观理解:它不改变当前位置的梯度,而是维护一个历史梯度的指数移动平均,相当于给参数更新加了惯性,能有效穿过局部极小和鞍点。
criterion = nn.CrossEntropyLoss() optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30)weight_decay=1e-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(dim=1) 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()在评估时禁用自动求导,省显存且加速。损失函数返回的是 tensor,loss.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(f'early stop at epoch {epoch}') break print(f'epoch {epoch}: loss={train_loss:.4f}, val_acc={val_acc:.4f}')patience=5的语义是:连续 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 精度训练时,如果模型和数据都在 CPU,torch.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'])是否忽略了strict=True参数,默认严格模式下权重尺寸不匹配会直接抛异常,看到size mismatch报错说明网络结构与保存时不一致。
6. 进阶技巧与模型保存:把 99.2% 推到 99.6% 的实战策略
在基础模型跑通后,如果要让作业在横向对比中脱颖而出,整节给出的技巧是按性价比从高到低排列的。
第一优先级是数据增强。MNIST 上的经典增强是RandomAffine(degrees=10, 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(degrees=10, 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,这意味着图像边缘不再代表“空白”。无伤大雅,但如果你要追求极致精度,可以用fill=0, fillcolor=0, interpolation=InterpolationMode.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 之后准确率不再上升”这个看似简单却需要综合判断的问题。
本文还有配套的精品资源,点击获取