简介:面向机器学习初学者和深度学习课程学生的一份完整实验资源,围绕手写数字识别这一经典分类任务,采用PyTorch框架搭建卷积神经网络(CNN),并在测试集上达到99%的准确率,能够帮助使用者理解从数据加载到模型评估的完整流程。资源包共17个文件,压缩后大小35.15MB,主要包含4个Python源文件(模型结构、训练与预测脚本)、2个Word实验文档(实验报告与指导书)、预训练模型文件(pt)、MNIST原始数据集(idx格式)以及用于展示的PPT等,类型覆盖代码、文档、模型与数据,便于按需取用。该资源目前已有12134人学习下载,广受认可。除了可直接运行的代码和训练好的模型,还配有详细实验报告与指导书,可辅助理解CNN设计思路、数据预处理细节及调参过程;同时附带的数据集文件支持离线复现实验,适合课程作业参考、论文复现或个人入门实践,是系统掌握手写数字识别任务的有力资料。
1. 为什么我推荐用CNN做手写数字识别,而不是全连接网络
我想先从一个多数人都经历过的挫折说起。刚开始接触深度学习课程时,我照着经典的案例用全连接网络去跑手写数字识别(MNIST),折腾了半天,训练集准确率卡在90%上下就再也上不去了。后来我把全连接层换成了卷积神经网络(CNN),同样只是跑十几个epoch,准确率直接冲到了99%以上。这个反差让我意识到,选对模型结构比调参重要得多。
手写数字识别是深度学习入门阶段最有代表性的图像分类任务,几乎每一门深度学习课程都会拿它当第一个像样的实验。它对应的数据集就是MNIST,包含6万张训练图片和1万张测试图片,每张是28x28的灰度图,内容是0到9的十种数字。对课程实验来说,这个任务难度适中,训练速度快,单卡CPU也能跑得动,但又能把卷积、池化、全连接、损失函数、优化器这些核心概念全部串起来。
这篇文章就是围绕这个实验的完整复盘,从模型选型思路、数据准备、PyTorch代码实现,到实验报告怎么写、常见坑怎么避,我会把我在国科大这门课上的实操经验全部写出来。适合正在选修深度学习课程、需要完成手写数字识别作业的同学参考,也适合刚入门深度学习、想做第一个完整图像分类项目的自学者。
需要说明的是,我这里用的框架是PyTorch,这是当前学术和教学场景下最主流的深度学习库。如果你们的课程指定用TensorFlow,思路完全一致,只是API写法不同。原始实验报告里的核心图表包括损失曲线、准确率曲线、混淆矩阵、部分预测样本可视化,这些在PyTorch里都有对应的实现方案,我会逐一讲到。
2. 整体方案设计:从任务分析到模型选型的完整链路
2.1 为什么图像分类不用全连接网络,而是用卷积神经网络
在动手写代码之前,要先想清楚一个核心问题:为什么图像分类任务要用CNN?
一张28x28的灰度图片,如果把它拉平成一个一维向量,长度是784。如果构建一个两层的全连接网络,第一层784个输入神经元、第二层256个神经元,那么这一层的参数数量就是784×256再加上偏置项,大约是20万个参数。这还只是一个全连接层,整个网络的参数量会非常可观。更关键的是,全连接层把每个像素都当作独立的特征来处理,完全丢弃了像素之间的空间位置关系——而图像的本质恰恰是相邻像素之间存在强相关性,比如数字“1”是一条竖直的笔画,这种结构信息是需要卷积操作来提取的。
卷积神经网络通过局部感受野、权值共享和空间下采样三大特性来解决这个问题。卷积核每次只看一个小区域,比如3x3,通过滑动窗口在整张图上提取局部特征;同一个卷积核在整个图像上共享权重,大幅减少参数量;池化层则对特征图进行下采样,保留主要特征的同时降低计算量。这三者结合,让CNN天然适合处理图像这种具有空间结构的数据。
从实验结果来看,我在同一份代码里做过对比实验:用两层全连接网络训练10个epoch,测试准确率约为91%;换上卷积神经网络同样训练10个epoch,测试准确率能到99%左右。这个差距不是调参能弥补的,是模型结构对图像数据本质规律的拟合能力差异。所以这个实验虽然叫“手写数字识别”,真正的核心考点是理解“为什么CNN比全连接网络更适合图像分类”。
2.2 数据集分析与预处理策略
MNIST数据集本身是经过处理的,每张图片已经做了尺寸归一化到28x28,灰度值范围是0到255,其中0代表黑色背景,255代表白色前景笔画。虽然MNIST的图片质量已经很规整,但还是有几个点需要注意。
第一是数据归一化。把像素值从0到255缩放到0到1或者-1到1的区间,能帮助梯度下降更快收敛。我在代码里使用了均值0.1307、标准差0.3081这两个MNIST官方统计值做标准化,这是PyTorch官方示例的推荐参数,训练效果比较稳定。如果懒得查这些统计值,直接除以255也可以,只是收敛速度会稍慢一些。
第二是数据增强。MNIST数字识别的准确率已经很高,一般不需要做复杂的增强操作,因为数字识别的关键挑战在于书写风格多样,而MNIST已经包含了大量不同人群的书写样本。不过如果你们的实验要求扩展到其他数据集,或者希望进一步提高模型的泛化能力,可以尝试随机旋转、随机平移、随机缩放等操作。
第三是数据加载方式。PyTorch的DataLoader支持自动分批(batch)、随机打乱(shuffle)和多进程加载(num_workers),这些看起来不起眼的配置会直接影响训练速度和模型收敛效果。训练集必须设置shuffle=True,否则模型会按顺序看到同一个类别的样本,破坏梯度更新的随机性;测试集不需要shuffle。batch size的选择上,我用的64,这是一个在收敛速度和内存占用之间比较平衡的值。
2.3 网络结构设计:一个精简版LeNet-5
我最终选用的网络结构参考了经典的LeNet-5,但做了适当的精简和调整,让它更适配PyTorch的实现方式,同时也更容易在实验报告中解释清楚每一层的作用。网络的详细结构如下:
第一层是卷积层,输入通道为1,输出通道为32,卷积核大小为3x3,padding为1。这一层负责提取低级特征,比如边缘、角点、笔画方向等。因为MNIST只有灰度图,输入通道为1;输出32个通道意味着用32个不同的卷积核从不同角度提取特征。
第二层是ReLU激活函数和2x2最大池化。ReLU的作用是引入非线性,让网络能够拟合更复杂的函数。最大池化在2x2窗口内取最大值,把特征图的尺寸从28x28降到14x14,同时保留最显著的特征。
第三层是卷积层,输入通道为32,输出通道为64,卷积核大小仍为3x3,padding为1。这一层负责组合低级特征,提取更高级的抽象特征,比如数字的轮廓结构、交叉点、封闭区域等。经过这一层后的ReLU和池化操作后,特征图从14x14降到7x7。
接下来是展平操作,把64个7x7的特征图拉平,变成3136维的向量。后面接两个全连接层:第一层是3136到128,中间加ReLU和dropout(丢弃概率0.5);第二层是128到10,直接输出10个类别的得分。
dropout层的设计是防止过拟合的关键。MNIST虽然数据集规模够大,但模型本身也有一定的容量,训练后期容易出现训练集准确率接近100%、测试集接近98%的过拟合现象。我在训练时打开了dropout,推理时PyTorch会自动关闭dropout,这一行的效果往往被很多初学者忽略,但它在实验报告里是很值得展开讨论的一个设计决策。
最后一层全连接输出10个值,通过交叉熵损失函数(CrossEntropyLoss)处理。这里有一个容易搞混的点:PyTorch的CrossEntropyLoss内部已经包含了softmax操作,所以模型最后一层不需要额外加softmax,直接输出原始得分即可。如果你在模型末尾加了softmax再输入CrossEntropyLoss,等于做了两次softmax,会导致梯度数值变小、训练变慢,属于新手非常容易踩的坑。
3. 核心代码实现与关键参数讲解
3.1 环境准备与依赖库
我完成这个实验用的环境是Python 3.9 + PyTorch 2.0 + CUDA 11.7,但如果你没有GPU,纯CPU训练也完全够用。MNIST这个规模的数据集,CPU训练一个epoch大约需要30到60秒,跑10个epoch也就是10分钟左右,完全在可接受范围内。
需要安装的依赖库如下:
pip install torch torchvision matplotlib numpytorchvision是PyTorch官方的视觉库,自带MNIST数据集的下载接口和常见的数据预处理工具。这个库非常关键,很多教程会让你手动下载MNIST的四个gz文件然后自己写解析代码,完全没有必要,torchvision的datasets.MNIST接口一行代码就能搞定数据加载。
3.2 数据加载与预处理代码
import torch import torchvision import torchvision.transforms as transforms from torch.utils.data import DataLoader transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset = torchvision.datasets.MNIST( root='./data', train=True, transform=transform, download=True ) test_dataset = torchvision.datasets.MNIST( root='./data', train=False, transform=transform, download=True ) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True, num_workers=2) test_loader = DataLoader(test_dataset, batch_size=256, shuffle=False, num_workers=2)这里有几个细节值得放到实验报告里说明:
transforms.ToTensor()会把PIL图像或者numpy数组的HWC格式转换为CHW格式的张量,同时把像素值从0到255缩放到0到1,这个缩放和后面的标准化不同,ToTensor是除以255,Normalize是减去均值再除以标准差。
normalize的均值0.1307和标准差0.3081是MNIST全数据集的统计值。标准化之后的像素值大致落在-1到1之间,这能帮助优化器在不同维度上有相似的尺度,从而加速收敛。我在实验中发现,不做标准化直接训练,模型在第3个epoch时准确率约96%,而标准化后第3个epoch就能到98%左右,收敛速度的差异肉眼可见。
num_workers这个参数控制数据加载的子进程数量。在Windows系统上如果设置为大于0可能会报多进程相关的错误,建议在Windows上设置为0。在Linux或者macOS上设置2到4可以加速数据加载,因为GPU训练时数据加载往往成为瓶颈。
3.3 模型定义代码
import torch.nn as nn import torch.nn.functional as F class CNN(nn.Module): def __init__(self): super(CNN, self).__init__() self.conv1 = nn.Conv2d(1, 32, kernel_size=3, padding=1) self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1) self.pool = nn.MaxPool2d(2, 2) self.fc1 = nn.Linear(64 * 7 * 7, 128) self.fc2 = nn.Linear(128, 10) self.dropout = nn.Dropout(0.5) def forward(self, x): x = self.pool(F.relu(self.conv1(x))) x = self.pool(F.relu(self.conv2(x))) x = x.view(-1, 64 * 7 * 7) x = F.relu(self.fc1(x)) x = self.dropout(x) x = self.fc2(x) return x这段代码最需要理解的是x.view(-1, 64 * 7 * 7)这一行。view操作相当于把三维特征图展平成二维矩阵,-1表示自动推断这一维的大小,实际就是batch size。如果不做这个展平操作,全连接层无法接收三维数据。
关于全连接层第一层的输入维度64 * 7 * 7,这个数字不是拍脑袋定的,而是通过计算得出的。28x28的输入经过第一次2x2池化变成14x14,再经过第二次池化变成7x7,通道数为第二个卷积层设置的64,所以展平后的长度为64乘以7乘以7,即3136。
有些同学会问,两个卷积层的输出通道为什么选32和64,而不是16和32,也不是128和256。这属于典型的工程经验值:通道数太少,特征提取能力不足;通道数太多,MNIST这个简单任务会出现不必要的过拟合和计算开销。32和64的组合在MNIST上是经过大量实践验证的性价比之选。
3.4 训练和测试代码
import torch.optim as optim device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = CNN().to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=0.001) epochs = 10 train_losses = [] train_accs = [] test_accs = [] for epoch in range(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) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() _, predicted = torch.max(outputs.data, 1) total += labels.size(0) correct += (predicted == labels).sum().item() train_loss = running_loss / len(train_loader) train_acc = 100.0 * correct / total test_acc = evaluate(model, test_loader, device) train_losses.append(train_loss) train_accs.append(train_acc) test_accs.append(test_acc) print(f'Epoch {epoch+1}/{epochs}, Loss: {train_loss:.4f}, ' f'Train Acc: {train_acc:.2f}%, Test Acc: {test_acc:.2f}%')这里有几个容易出问题的操作,逐个说清楚。
optimizer.zero_grad()出现在每次参数更新之前,作用是清空上一步计算的梯度。PyTorch的梯度是累加的,如果不手动清零,梯度会在多个batch之间累积,导致参数更新方向错误。这是一行看起来多余但绝对不能省的代码。
model.train()和model.eval()这两个模式切换非常关键。我的模型里有dropout层,train模式下dropout生效,随机丢弃部分神经元;eval模式下dropout不生效,使用全部神经元。如果不切换到eval模式,测试时模型的输出会带有随机性,每次预测结果可能不一样。养成在训练前写train、在验证或测试前写eval的习惯。
选Adam优化器而不是传统的随机梯度下降(SGD),理由是Adam自适应调整每个参数的学习率,对初始学习率的敏感度低,收敛速度快,更适合课程实验这种需要快速出结果的场景。如果要在实验报告中展示更深的分析,可以额外跑一组SGD加动量(momentum=0.9)的对比实验,SGD+momentum在MNIST上最终准确率往往略高一些,但需要更精细地调学习率,这也是一个可以写进报告的技术细节。
3.5 测试与可视化代码
测试阶段的代码和训练有较大的区别,关键是使用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.data, 1) total += labels.size(0) correct += (predicted == labels).sum().item() return 100.0 * correct / total最终我运行10个epoch得到的测试准确率在99.1%左右,最好的一次是99.24%。这个数字在MNIST上属于常规水平,LeNet-5原论文报的准确率在99%左右,我们的精简版能达到同样的水平,说明模型结构是合理有效的。
可视化部分,我画了三张图放进实验报告:第一张是训练损失随epoch下降的曲线,第二张是训练集和测试集准确率随epoch上升的曲线,第三张是模型在测试集上随机抽取的16张图片预测结果。画图用matplotlib,代码不复杂,核心是subplot的布局和imshow的用法。
import matplotlib.pyplot as plt import numpy as np def visualize_predictions(model, test_loader, device, num_images=16): model.eval() images, labels = next(iter(test_loader)) images, labels = images[:num_images].to(device), labels[:num_images] with torch.no_grad(): outputs = model(images) _, predicted = torch.max(outputs, 1) fig, axes = plt.subplots(4, 4, figsize=(8, 8)) for i, ax in enumerate(axes.flat): ax.imshow(images[i].cpu().squeeze(), cmap='gray') ax.set_title(f'True: {labels[i].item()}, Pred: {predicted[i].item()}') ax.axis('off') plt.tight_layout() plt.savefig('predictions.png', dpi=150) plt.show()在实验报告里引用图片时,重点突出那些预测错误的样本。我在测试集上统计过,10000张测试图片中大约有80到90张会被预测错,错误率不到1%,但每一张错误的图片都值得单独拿出来分析。比如数字4被预测成9,数字7被预测成2,这些错误在人类看来似乎不应该发生,但仔细看图片就会发现这些手写数字的笔画确实写得比较潦草,边界特征模糊。这种分析能体现你对模型局限性的理解,在评分中是很加分的亮点。
4. 实验报告核心模块拆解:从摘要到结论的写作要点
4.1 摘要和引言怎么写
实验报告和普通的课程作业不一样,它有固定的结构要求。按照国科大深度学习课程实验报告的通用格式,第一页需要写摘要和引言。摘要部分用三到五句话概括实验的核心内容:基于PyTorch框架实现了卷积神经网络模型,使用MNIST数据集进行手写数字识别实验,最终测试准确率达到99.1%。同时简要说明模型结构的特点和实验的主要结论。
引言部分需要交代实验背景,从深度学习在计算机视觉领域的应用切入,说明手写数字识别作为经典分类问题的研究意义。这里不需要长篇大论,写三到四段即可。写引言时有一个技巧:引用一两篇经典的文献,比如LeCun等人1998年发表的Gradient-Based Learning Applied to Document Recognition,能让报告看起来更专业。LeNet-5就是在这篇论文中提出的,和我们的实验直接相关。
4.2 实验设计与结果分析怎么写
实验设计部分要包含数据集描述、模型结构说明、训练参数配置和评估指标。我建议用一个表格整理模型结构,列名分别为层类型、输出尺寸、参数量,这样一目了然。
结果分析部分是评分最看重的环节,不只是贴训练日志或准确率数字,而是要对实验结果做出解释。比如训练准确率从第一个epoch的94%迅速上升到第五个epoch的98.7%,这说明模型在前期快速学习了数字的基本结构特征。测试准确率的上升速度略慢于训练准确率,这属于正常现象,因为测试集上的数据模型从未见过。最终训练准确率99.4%和测试准确率99.1%非常接近,说明过拟合程度很低,dropout和池化在控制过拟合方面起到了作用。
4.3 结论与实验心得怎么写
结论部分总结实验结果,突出模型的有效性和实验中的核心发现。实验心得部分要写真实的学习体会,比如我一开始直接用全连接网络,准确率只有91%,后来改用了CNN,准确率提升到99%,这个对比让我直观感受到了卷积操作在图像特征提取上的强大能力。还可以写一写踩过的坑,比如刚开始忘记在测试时切换model.eval(),导致预测结果不稳定,排查了很久才发现问题。这种真实的经验描述比任何空泛的“通过这次实验我学到了很多”都有说服力。
4.4 实验报告常见扣分点提醒
根据我自己的经验,实验报告最容易被扣分的几个地方如下:
运行结果的截图不完整,只截了准确率数字,没有截损失曲线和预测可视化。最好把训练过程的打印输出、损失曲线图、混淆矩阵图都保存完整,统一打包放进附录。
代码注释太少。大部分同学提交的代码几乎没有注释,这在大作业评分中很吃亏。核心代码段至少要有中文或英文注释说明每一层的作用,尤其是卷积层的输入输出通道变化和全连接层的维度计算。
没有分析错误案例。测试准确率99%不代表模型完美,剩余的1%恰恰是值得深入分析的对象。在报告中加入错误预测的可视化和原因分析,能体现你对模型的理解深度。
5. 训练过程实录与常见问题排查
5.1 一次完整的训练日志分析
我在这里放一段真实训练过程的前几个epoch输出,方便你对正常的训练节奏有一个直观的预期。
Epoch 1/10, Loss: 0.3102, Train Acc: 93.45%, Test Acc: 97.21% Epoch 2/10, Loss: 0.1085, Train Acc: 97.23%, Test Acc: 97.98% Epoch 3/10, Loss: 0.0714, Train Acc: 98.05%, Test Acc: 98.51% Epoch 4/10, Loss: 0.0521, Train Acc: 98.67%, Test Acc: 98.57% Epoch 5/10, Loss: 0.0420, Train Acc: 98.94%, Test Acc: 98.75% Epoch 6/10, Loss: 0.0346, Train Acc: 99.12%, Test Acc: 98.91% Epoch 7/10, Loss: 0.0299, Train Acc: 99.26%, Test Acc: 99.01% Epoch 8/10, Loss: 0.0261, Train Acc: 99.35%, Test Acc: 99.10% Epoch 9/10, Loss: 0.0223, Train Acc: 99.47%, Test Acc: 99.13% Epoch 10/10, Loss: 0.0198, Train Acc: 99.54%, Test Acc: 99.15%这组数据有几个特征值得注意。训练集的准确率在持续上升,但测试集的准确率在第七个epoch之后上升幅度变得很缓慢,从99.01%到99.15%只提升了0.14个百分点。损失值还在下降,从0.0299降到0.0198,说明模型在训练集上仍在拟合更细的特征,但这对测试集的泛化帮助已经很小了。这就是模型接近过拟合的边缘信号。
很多同学会问,要不要训练更多epoch,让准确率再高一点?从我的实践经验来说,对于课程实验,10个epoch已经足够了。MNIST的难度上限摆在那里,再往上提升准确率需要更复杂的结构或者集成方法,收益很低,而且容易过拟合。实验报告里能解释清楚“为什么在10个epoch停止训练”,本身就是加分项。
5.2 训练不收敛的排查方法
最典型的故障是损失值在一两个epoch后直接变成nan,或者准确率一直停留在10%左右,相当于随机猜测。根据我实际遇到的问题,最可能的原因是学习率设置不当。Adam优化器的默认学习率是0.001,这个值对MNIST来说是有效的。如果你手动把学习率调到0.01以上,模型可能会发散,损失直接变成nan。排查方法是打印前几个batch的损失值,如果是nan,先降低学习率。
第二个常见问题是数据加载不对。PyTorch的DataLoader返回的形状是(batch_size, 1, 28, 28),也就是说第二维是通道数1。如果你在网络定义时把第一个卷积层的输入通道写成3(因为习惯了RGB三通道),就会报维度不匹配的错误。排查方法很简单,在第一个batch数据进入模型前打印images.shape,确认维度和模型输入一致。
第三个问题是类别不平衡或者标签错位。MNIST数据集本身的类别分布是均匀的,每个数字约6000张训练图片,不存在类别不平衡问题。如果自己做数据集,就要检查标签是否从0开始连续编号,因为CrossEntropyLoss期望的标签是0到9的整数。
5.3 测试准确率波动的原因和解决办法
有些同学会发现,每次运行训练代码得到的测试准确率都不一样,波动范围在98.8%到99.2%之间。这是正常的,因为训练的初始权重是随机初始化的,数据打乱的顺序也不同。要想得到可复现的结果,可以设置随机种子:
torch.manual_seed(0) np.random.seed(0)手动固定随机种子之后,多次运行的结果会完全一致。这个设置在做对比实验时尤其重要,只有在相同初始条件下对比不同模型或不同超参数,结论才是公平的。
如果测试准确率在98%以下,通常意味着模型欠拟合或者数据预处理有问题。检查的顺序是:先看训练集准确率,如果训练集准确率也低,说明模型容量不足或者学习率太低;如果训练集准确率很高而测试集准确率低,说明过拟合,需要增加dropout比例或者减少模型参数量。
5.4 数据增强的适用边界
MNIST数据增强有一个容易犯的错误:使用随机旋转时,角度不宜设置过大,否则会把数字6旋转成9,或者把2旋转成7,反而引入错误的标签信息。我建议旋转角度控制在10度以内,平移控制在2个像素以内。做不做数据增强对MNIST的最终准确率影响很小,因为MNIST本身已经足够多样,但对于理解数据增强的作用机制很有帮助。
如果你想把实验做深一点,可以在实验报告增加一个对照实验:同一模型分别在有无数据增强的情况下训练,比较测试准确率。正常情况下,数据增强可能会让测试准确率略有下降或持平,因为测试集中的数字基本是正的,旋转生成的数据反而可能干扰模型学习。这个反直觉的发现写进报告里,比只是简单地说“加了数据增强效果更好”要有价值得多。
6. 如何让实验成绩更亮眼:进阶优化方向与实用心得
6.1 三个值得尝试的进阶方向
如果你的课程实验要求比较高,或者你想争取一个更好的成绩,可以在基础实验之外做三个方向的延伸。
第一个方向是混淆矩阵分析。torchmetrics库提供了ConfusionMatrix接口,也可以手动实现。混淆矩阵能展示每个类别的误判情况,我运行的结果中混淆最集中的是4和9之间的误判,以及3和5之间的误判。这类误判的根源在于手写体中4的顶部开口和9的底部圆圈在某些人书写时非常相似,3和5则都可能带有明显的曲线底部。结合具体样本分析误判原因,能体现你对分类问题的深入理解。
第二个方向是梯度类激活图可视化。Grad-CAM能够显示出模型在分类时重点关注图像的哪些区域,对于手写数字来说,模型通常会重点关注笔画的中心区域和转折点。这个可视化需要写相对复杂的代码,但效果图放在实验报告中非常有冲击力。如果你的课程没有教到这一块,可以量力而行。
第三个方向是超参数敏感性实验。固定其他条件,单独改变卷积核数量、学习率、dropout比例,观察准确率的变化,绘制成曲线或表格。这类实验的工作量不大,但能让实验报告显得很有深度,因为这体现的是一种系统的实验方法论,而不只是跑通一条代码。
6.2 给初学者的几条实操建议
第一,不要一上来就复制网上的完整代码。先自己搭一个最简单的网络,哪怕只有两个卷积层和一个全连接层,跑通之后在逐步加深。这个过程能帮你建立对每个组件作用的直觉,尤其是对维度变化的计算。
第二,学会用print调试维度。每一层之后打印输出张量的形状,这是排查维度错误最高效的方法。很多初学者看到维度报错就慌,但实际上几乎所有的维度错误都可以通过逐层打印来定位。使用PyTorch时,可以在forward函数中加print(x.shape),跑完一个batch后删除。
第三,把代码写到模块化。模型定义、训练函数、评估函数、画图函数分开写,不要全部堆在一个文件里。这不仅让代码更清晰,更重要的是方便做实验对比,你需要反复切换不同模型结构和超参数来跑实验。我最终提交的代码结构是data_loader.py、model.py、train.py、evaluate.py、visualize.py五个文件,每个文件只负责一件事。
第四,保存模型的checkpoint。在训练过程中定期保存模型权重,这样即使训练中断也不需要重新开始。保存方式很简单,torch.save(model.state_dict(), 'model_epoch_{}.pth'.format(epoch)),加载时用model.load_state_dict(torch.load('model_epoch_10.pth'))。
6.3 这个实验做完之后还能做什么
手写数字识别虽然看起来只是一个入门实验,但它代表了一整套标准的图像分类问题解决框架。换一个数据集,比如CIFAR-10或者Fashion-MNIST,只需要修改数据加载部分的代码和最后一个全连接层的输出维度,其他部分几乎不用动。换一个模型结构,从LeNet-5换到ResNet或者VGG,只需要修改模型定义部分。
我在完成这个实验之后,沿着同样的思路做了一个Fashion-MNIST的分类实验,模型结构几乎没变,只是把数据改成十个类别的服装图片。结果发现相同结构的CNN在Fashion-MNIST上的准确率只有90%左右,比MNIST低了将近9个百分点。这个对比非常直观地说明了一个问题:任务的难度不在于模型结构有多复杂,而在于数据集本身的特征区分度。MNIST的数字笔画结构清晰、类间差异大,所以简单模型就能有很高准确率;Fashion-MNIST中有些类别比如衬衫和外套,外观上就很相似,需要更强的特征提取能力。这个认识对你后续选择模型和评估实验结果会很有帮助。
最后一件事,也是我认为这个实验最有价值的地方:它会逼着你完整体验一遍从数据加载、模型设计、训练调参到结果分析的全流程。很多人在课程结束后很久都记不清那些理论公式,但绝不会忘记自己第一次看到训练准确率稳定上升、最终从测试集上输出99%数字时的那种成就感。把这个过程完整记录下来,包括踩坑和修复的每一个细节,这份实验报告的价值就远远不止一个分数了。
本文还有配套的精品资源,点击获取