不知道大家有没有这个感觉:深度学习入门的时候,跟着经典教程跑通了LeNet,输入是32×32,感觉一切还挺顺利。然后想挑战一下更“现代”的网络,第一个想到的往往是AlexNet——毕竟它是2012年把ImageNet准确率拉高一大截的功臣,是深度学习的“开山之作”之一。于是满怀信心地下载了MNIST数据集,把AlexNet的代码从网上复制下来,一跑,傻了:输入尺寸对不上,灰度图通道对不上,GPU显存还动不动爆掉,最后准确率甚至还不如用LeNet来得高。我当初就被这几个问题折腾过一整天,最后干脆静下心来,把“AlexNet识别MNIST手写数据集”这个项目彻底拆开,从数据适配、模型改版、训练调参到故障排查,全部过了一遍,才跑出满意的效果。这篇文章就是那个过程的完整复盘,给同样想拿MNIST练手AlexNet的同学们一点参考。
说实话,用AlexNet跑MNIST本身是有点“大炮打蚊子”的感觉,但作为学习项目,它非常有价值。因为AlexNet的很多设计思想——局部响应归一化(LRN,后来被证明没用)、重叠池化、ReLU激活、Dropout、数据增强——都是在后来的网络里反复出现的基础组件。而且在实际操作中,你必然会遇到“原有架构和实际数据不匹配”的经典问题,怎么权衡、怎么改,正是做工程的人天天要面对的事。这个项目适合刚学完基础CNN、想深入理解经典网络结构,以及想练手模型改造和数据pipeline的人。就算你对神经网络已经比较熟了,看看别人怎么踩坑,多少也能有点收获。
1. 项目整体设计与思路拆解
1.1 为什么偏偏用AlexNet来跑MNIST
我们先说清楚一个容易让人困惑的点:AlexNet是为ImageNet设计的。ImageNet的图像是RGB三通道,分辨率统一resize到256×256之后随机裁剪成224×224(原始论文用的是256×256裁剪到224×224,但后面很多实现直接resize到224×224)。而MNIST是28×28的灰度单通道图像,像素值范围0到255,内容是非常细的手写数字线条。两者从数据分布到分辨率,差异都相当大。
所以直接用原始AlexNet结构是行不通的。你需要做三件事:解决通道数问题、解决输入尺寸问题、重新设计全连接层的尺寸。这三件事看起来简单,但每一步背后都牵扯到训练效率、模型参数量、是否容易过拟合这些问题。我当时的第一版代码就是简单地调用现成AlexNet网络结构,把输入改成MNIST的28×28,结果第一层卷积之后特征图尺寸就对不上了。后来我换了个思路:不如先搞清楚AlexNet到底长什么样,再来确定怎么改。
经典的AlexNet结构是这样的:输入224×224×3,经过第一个卷积层96个大小为11×11的卷积核,stride为4,然后做局部响应归一化和最大池化;第二个卷积层256个5×5卷积核,pad为2,同样池化;第三个卷积层384个3×3卷积核,pad为1;第四个卷积层384个3×3卷积核,pad为1;第五个卷积层256个3×3卷积核,pad为1,然后池化。最后接三个全连接层,分别有4096、4096、1000个神经元(最后一个对应ImageNet的1000个类别),每个全连接层之间夹着Dropout。
这个结构在2012年算得上庞大,放到现在也很能打。但对MNIST来说,它就显得有点“用力过猛”了——MNIST只有10类,图像信息也很简单。如果我们把原来的1000类改成10类,其他保持原样,那么模型参数量大约是5800万(具体到MNIST改过之后也差不多),用这么大规模的网络拟合只有6万张训练图片的MNIST,过拟合是大概率事件。
1.2 核心矛盾:数据尺寸与网络设计的错配
稍微懂一点卷积原理的人都知道,每一层卷积输出的特征图尺寸会随着层数变化。AlexNet假设输入是224×224,这样第一层卷积之后是54×54,池化后是27×27,第二层卷积加池化后是13×13,第三、四、五层卷积后尺寸不变,第五层池化后是6×6,最后全连接层把6×6×256展平成9216维向量。这一整套尺寸是环环相扣的。
如果我们直接把MNIST的28×28扔进去,第一层卷积加池化后特征图大概变成了6×6,再到第二个卷积层加池化后直接就变成了2×2,第三层卷积后还是2×2,第五层池化后变成1×1,那么后面的全连接层接收的维度就完全对不上了。也就是说,你不仅要改输入,还要重新推算每一层的尺寸,或者干脆改变网络结构。这是所有用现有网络跑不同尺寸数据的人都会遇到的一个基本问题,搞懂了它,后面换其他模型就轻车熟路了。
解决这个错配通常有两条路。第一条是把MNIST数据resize到224×224(或者227×227,因为原始AlexNet第一层卷积核是11×11,stride是4,224×224会得到54×54,但如果用227×227则得到55×55,这是另一个历史细节,不必纠结),然后保持整个网络结构不变,只改最后的输出类别数。这种方式的好处是能完整体验AlexNet的原汁原味,缺点是:图像被放大了8倍,原先一个像素变成8×8个像素,看起来会非常糊,等于给网络灌入了大量由插值产生的“平滑噪声”;而且训练速度很慢,模型很大,用CPU几乎跑不动。第二条是把网络按MNIST的尺寸重新设计,主要是减小第一层的步长、卷积核尺寸,以及压缩后面全连接层的神经元数量。这种方式更合理,训练效率也高,但严格来说你用的已经不是“原版AlexNet”,而是“AlexNet风格的MNIST适配版”。
我的建议是:如果你只是想验证AlexNet在MNIST上能跑通,用第一种方式,一次性把模型推到底,看看损失能不能下降,理解整个前向传播的流程。如果你想真正体验项目成熟度,用第二种,把网络改得像个“小AlexNet”,然后仔细调参,把准确率刷到99%以上。
当时我做了个折中方案:先把输入图像resize到96×96,然后再将第一层卷积核改成7×7、stride为2,后面的结构适当压缩。这样既保留了AlexNet的设计风格,又不会因为输入太大导致显存爆炸。但后来我试过更极端的做法,直接resize到224×224,在GPU上也能训练,就是慢,而且准确率并没有明显更高。原因很简单:MNIST的特征在原始28×28分辨率下已经足够清晰,把它放大之后信息量并没有增加,反而让网络把更多的注意力放在了插值产生的虚假纹理上,属于典型的无用功。
1.3 从项目目标反推技术方案
最后我在正式版本里选择了“修改输入尺寸为1×28×28,同时重写网络第一层和全连接层”的方案。为了让大家能够直接复现,我用了PyTorch,因为它支持动态图,打印每一层的输出形状非常方便。我定义了一个名为AlexNetMNIST的类,保留了原版AlexNet的五个卷积层和三个全连接层的基本骨架,但把第一层卷积核从11×11改为5×5,stride从4改为1,padding设为2,这样输入28×28的灰度图像经过第一层卷积后仍能得到28×28的特征图;第二层卷积后是14×14,第三、四、五层尺寸不变,第五层池化后是7×7,展平后是7×7×256=12544。全连接层我压缩成[1024, 512, 10],Dropout保留0.5。这个结构保留了AlexNet的深层次风格,同时又适配了MNIST。
之所以保留三个全连接层而不是像一些教程里那样把后面的全连接改成全局平均池化,是因为我想体验原始AlexNet设计中的那种“卷积层提特征、全连接层做分类”的思路。全局平均池化是后来Network in Network提出的,把AlexNet改成那样虽然能大幅减少参数量,但就学不到原版的精髓了。
2. 核心细节解析与实操要点
2.1 数据加载与预处理:这些细节常常决定成败
MNIST的下载和使用在PyTorch里非常简单,torchvision.datasets.MNIST内置了数据集,但有几个细节很多人不注意。
第一是归一化。MNIST的原始像素值范围是0到255,通常我们会把它除以255缩放到0到1,再进一步做标准化。标准做法是计算训练集的均值和标准差。MNIST全量数据的灰度均值大约是0.1307,标准差是0.3081,这是很多人直接用的一组值。但如果你用了数据增强(比如随机旋转、平移),均值标准差会略有变化,不过影响不大,直接用常数值就行。需要说明的是,在归一化之前,数据是PIL.Image或numpy数组,归一化要在ToTensor之后做,ToTensor本身会把图像从HWC格式变成CHW格式,并除以255。如果你先手动除以255再ToTensor,等于除两次,数值会变得非常小,网络就很难收敛。
第二是数据增强。说实话,MNIST本身是相对简单的数据集,LeNet不加增强也能跑到99%以上。但AlexNet这种大规模网络在MNIST上很容易过拟合,适当的增强很有必要。我当时用了随机旋转(角度范围±10度)、随机平移(±2像素)和随机缩放。不要做水平翻转,因为数字翻转之后没有意义,比如“7”水平翻转之后可能还是像“7”,但“6”翻转就变成像“9”了,这会给分类造成错误引导。这是手写数字识别特有的禁忌。
第三是batch size的选择。MNIST训练集有6万张,常用的batch size从64到256都有。我用的是128。这个值不算大,也不算小,在ResNet、AlexNet这类网络下训练速度很快,梯度噪声也适中。如果你用256,每个epoch更迭次数少,收敛速度会快一点,但可能陷入尖锐极小值,泛化性能下降;如果你用64,收敛会更稳,但每个epoch花的时间变长。经过对照,128在MNIST上是一个比较平衡的选择。
2.2 模型搭建的三种方案与最终选择
我在实际操作中一共尝试了三种网络适配方案,下面把它们的差异和效果列出来。
| 方案 | 输入尺寸 | 第一层配置 | 全连接层 | 参数量 | 训练后准确率(大致范围) |
|---|---|---|---|---|---|
| A:原版AlexNet + 全图resize | 3×224×224 | 11×11, stride 4 | 4096×4096×10 | 约5800万 | 99.2% ~ 99.4% |
| B:单通道 + 中等输入 | 1×96×96 | 7×7, stride 2 | 2048×512×10 | 约1500万 | 99.5% ~ 99.6% |
| C:单通道 + 保留原输入尺寸 | 1×28×28 | 5×5, stride 1, pad 2 | 1024×512×10 | 约400万 | 99.6% ~ 99.7% |
方案A的准确率其实并不低,因为MNIST本身太简单了,就算网络很大,只要充分训练,准确率依然能上去。但它的缺点非常明显:训练一个epoch的时间大约是方案C的4到5倍,GPU显存占用也高了一截,而且参数量很大,很容易“背”住训练集。方案B是一个中间档,精度不错,但模型结构改动比较复杂,第一层输出尺寸为48×48,后面要重新计算每个池化的尺寸,计算工作量不小。方案C是我最终推荐的:既保留了AlexNet的结构骨架,又完全贴合MNIST原生分辨率,训练速度最快,准确率也最高。
当然,方案C并不是“毫无代价”。它丧失了AlexNet首次使用大卷积核和大步长的设计特点。5×5卷积核、stride 1是LeNet风格的东西,但AlexNet的深层思想——多层小卷积核堆叠来获得更大的感受野——实际上是保留了的。比如第二层用5×5卷积,第三层先做3×3卷积,第四层再做3×3,这样第三层到第五层其实相当于一个7×7的等效感受野,和原版AlexNet后三层的设计逻辑一致。所以方案C从精神上仍然是AlexNet。
2.3 训练配置中的几个关键参数
优化器:原版AlexNet用的SGD,momentum=0.9,weight_decay=0.0005。我在项目里沿用这个配置,因为它对于这个任务效果确实不错。SGD虽然比Adam收敛慢,但泛化能力通常更好,尤其在MNIST这样的小数据集上,SGD配合合适的学习率,更容易跑到高准确率。学习率我初始设置为0.01,训练过程中采用StepLR——每20个epoch将学习率乘以0.1。这是一个很经典的做法。MNIST的简单性决定了我们不需要很精细的warmup策略,从0.01直接降到0.001、0.0001就够用了。如果使用Adam,初始学习率设置为0.001效果就很好,因为它内部已经做了自适应调整,但它的最终准确率通常比SGD低0.1到0.2个百分点,所以如果追求极致的准确率,SGD不二之选。
损失函数用交叉熵。注意PyTorch的nn.CrossEntropyLoss已经包含了softmax部分,所以在模型最后一层直接输出10个神经元即可,不要在forward里再手动加softmax。这是很多新手常犯的错误,会造成数值不稳定和收敛变慢。
此外还有一点容易被忽略:权重初始化。原版AlexNet使用的是均值为0、标准差为0.01的高斯分布初始化,并给偏置初始化为1(后面两层为2)。在PyTorch里,如果我们用nn.Conv2d,默认的初始化方式是Kaiming均匀分布,这种初始化方式适配ReLU激活,效果通常比原始AlexNet的初始化更好。所以不需要特意去模仿原版的初始化,直接用默认就行。如果你用Keras/TensorFlow,默认的glorot均匀分布也够用。
2.4 评估指标与训练过程可视化
对于MNIST这种分类任务,最直接的指标就是准确率。但最好同时记录每个epoch的训练集损失、验证集损失和验证集准确率,这样才能判断模型是否过拟合。我习惯在每个epoch训练结束后,用验证集做一次完整的前向传播,统计准确率和损失,然后打印出来。一个好的训练曲线是这样的:训练损失和验证损失同时下降,最终验证损失不再明显降低,准确率稳定在99.5%左右。如果训练损失持续下降,验证损失开始回升,就是典型的过拟合信号。
可视化方面,如果想快速看结果,用PyTorch的话可以直接torchsummary打印模型结构,用tensorboard记录loss曲线。我当时更偏好直接打印,因为简单,并且能直观看到每个epoch的变化。还可以随机挑9张测试集的图像,把预测标签和真实标签展示出来,看看错在哪里。通常错误集中在“4”和“9”、“3”和“8”这种形状相近的数字上,这是手写字体本身的模糊性导致的,模型很难完全避免。
3. 实操过程与核心环节实现
3.1 环境准备与依赖
这个项目我用的是Python 3.10 + PyTorch 2.1版本,cu118或cu121都行,CPU也能跑但速度会让人崩溃。建议有NVIDIA GPU,显存不需要很大,4G以上就够了。安装依赖的命令非常简单:
pip install torch torchvision matplotlib numpy如果使用GPU版本,建议从PyTorch官网复制对应的安装命令,避免版本不匹配。对于没有GPU的读者,我也试过用CPU跑方案C,大约10分钟一个epoch(在比较新的CPU上),跑30个epoch要5个小时,虽然能出结果,但调试过程会非常痛苦。所以能用GPU还是尽量GPU。
3.2 数据加载与增强的完整代码
下面这段代码是我最终使用的数据加载部分。先定义训练集和测试集,然后构造DataLoader:
import torch from torch.utils.data import DataLoader from torchvision import datasets, transforms # 训练数据增强 transform_train = transforms.Compose([ transforms.RandomRotation(degrees=10), transforms.RandomAffine(translate=(0.05, 0.05)), transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) # 测试集只需要ToTensor和Normalize,不需要随机增强 transform_test = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset = datasets.MNIST( root='./data', train=True, download=True, transform=transform_train ) test_dataset = datasets.MNIST( root='./data', train=False, download=True, transform=transform_test ) train_loader = DataLoader(train_dataset, batch_size=128, shuffle=True, num_workers=4) test_loader = DataLoader(test_dataset, batch_size=128, shuffle=False, num_workers=4)这里有两个细节。第一,RandomRotation(degrees=10)本身会改变数字的角度,但如果旋转角度太大,数字可能会被裁剪掉边缘,反而让模型困惑,10度是一个比较合理的范围。第二,RandomAffine(translate=(0.05,0.05))允许横向和纵向平移5%的像素范围,MNIST图像中数字本身只占中间区域,平移5%不会切掉关键部分,还能提升模型的平移鲁棒性。
如果你不想下载数据集,也可以用datasets.MNIST自动下载,但要保证网络通畅。也可以把数据集提前放在root目录下,PyTorch会检查是否存在,存在就不会重复下载。
3.3 AlexNet适配版的模型定义
模型定义我按照前面方案C实现,代码如下:
import torch.nn as nn class AlexNetMNIST(nn.Module): def __init__(self, num_classes=10): super().__init__() self.features = nn.Sequential( # 输入 1x28x28 nn.Conv2d(1, 96, kernel_size=5, stride=1, padding=2), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=3, stride=2), # 输出 13x13 nn.Conv2d(96, 256, kernel_size=5, stride=1, padding=2), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=3, stride=2), # 输出 6x6 nn.Conv2d(256, 384, kernel_size=3, stride=1, padding=1), nn.ReLU(inplace=True), nn.Conv2d(384, 384, kernel_size=3, stride=1, padding=1), nn.ReLU(inplace=True), nn.Conv2d(384, 256, kernel_size=3, stride=1, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=3, stride=2), # 输出 2x2 ) self.classifier = nn.Sequential( nn.Dropout(p=0.5), nn.Linear(256 * 2 * 2, 1024), nn.ReLU(inplace=True), nn.Dropout(p=0.5), nn.Linear(1024, 512), nn.ReLU(inplace=True), nn.Linear(512, num_classes), ) def forward(self, x): x = self.features(x) x = torch.flatten(x, 1) x = self.classifier(x) return x等等,如果你跟着我上面的注释算一下会发现,第一层池化后:输入28×28,5×5卷积padding=2,输出28×28,之后MaxPool2d的kernel=3、stride=2,输出尺寸是floor((28-3)/2)+1=13;第二层卷积后13×13,池化后floor((13-3)/2)+1=6;第三、四、五层卷积后都是6×6,最后的池化之后是floor((6-3)/2)+1=2。所以特征图是2×2×256。展平后就是1024维,正好接第一个全连接层。
这里要提醒一下,原版AlexNet的MaxPool2d用的是kernel_size=3, stride=2,也就是重叠池化。这个设计可以提取更丰富的特征,也起到了一定正则效果。我保留了这一点。但在MNIST很小的分辨率下,池化次数太多会把特征图压得特别小,所以有些人希望少池化一层。不过在我的实验里,池化到2×2依然能取得99.6%的准确率,说明特征提取能力足够强,空间信息被压缩一点问题不大。如果你想保守一点,可以把第三层池化去掉,这样最后特征图是4×4,全连接层要改,但模型会更庞大一些,没必要。
3.4 训练循环的写法与注意事项
训练代码中,需要注意几个地方。第一是model.train()和model.eval()切换,对Dropout层和BatchNorm层是必须的。第二是每个epoch结束用验证准确率来判断是否保存最优模型。第三是学习率调度器要放在epoch循环里调用。
保持强制格式输出的时候,我用的是基于普通列表,非代码块里的伪代码。现在直接给完整代码:
import torch import torch.nn as nn device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = AlexNetMNIST().to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=5e-4) scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=20, gamma=0.1) def train_one_epoch(model, loader, criterion, optimizer): model.train() total_loss = 0.0 correct = 0 total = 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) pred = outputs.argmax(dim=1) correct += (pred == labels).sum().item() total += labels.size(0) return total_loss / total, correct / total def evaluate(model, loader, criterion): model.eval() total_loss = 0.0 correct = 0 total = 0 with torch.no_grad(): for images, labels in loader: images, labels = images.to(device), labels.to(device) outputs = model(images) loss = criterion(outputs, labels) total_loss += loss.item() * images.size(0) pred = outputs.argmax(dim=1) correct += (pred == labels).sum().item() total += labels.size(0) return total_loss / total, correct / total best_acc = 0.0 for epoch in range(60): train_loss, train_acc = train_one_epoch(model, train_loader, criterion, optimizer) val_loss, val_acc = evaluate(model, test_loader, criterion) scheduler.step() print(f"Epoch {epoch+1:02d} | Train Loss {train_loss:.4f} | Train Acc {train_acc:.4f} | Val Loss {val_loss:.4f} | Val Acc {val_acc:.4f}") if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), "best_alexnet_mnist.pth") print("Best acc:", best_acc)这段代码的epoch数我设置为60,实际训练中会发现,在20个epoch左右准确率已经能到99%以上,但损失还在缓慢下降,所以跑满60个epoch可以更稳定地超过99.5%。SGD在MNIST上的收敛速度不快,前20个epoch用0.01,之后降到0.001,最后30个epoch你能看到准确率的微小爬升。
注意weight_decay=5e-4是原版AlexNet的正则化参数,在MNIST上同样适用。这个值不算大,主要作用是让权重向零收缩,抑制过拟合。有的人在MNIST上去掉weight_decay,验证集准确率也差不多,但加了之后训练曲线会更平滑,所以建议保留。
3.5 测试集评估与推理可视化
模型训练好之后,可以用测试集做最终评估。测试集有1万张图像,数据分布和训练集一致但独立。最终我的项目在测试集上达到了99.7%的准确率。如果你想看看哪些图片被分错了,可以记录下错误样本的索引:
import matplotlib.pyplot as plt import numpy as np model.load_state_dict(torch.load("best_alexnet_mnist.pth")) model.eval() misclassified = [] with torch.no_grad(): for images, labels in test_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) pred = outputs.argmax(dim=1) for i, (p, lab) in enumerate(zip(pred, labels)): if p != lab: misclassified.append((images[i].cpu(), lab.item(), p.item()))然后把错样本画出来:图片、真实标签、预测标签。错误通常发生在比较潦草的字迹上,特别是那种连体“9”像“4”、带斜杠的“7”像“1”的情况。如果连人都很难判断,模型错了也情有可原。
还有一个有趣的实验是使用训练好的模型提取中间层特征,用t-SNE降维可视化。你会发现前三层卷积特征之后,不同数字的样本已经聚成比较清晰的簇,说明AlexNet的特征提取能力确实很强。这也是加深对网络理解的好方法,不过不是这个项目的核心,有兴趣可以自己试。
4. 常见问题与排查技巧实录
4.1 维度不匹配报错:99%的新手第一道坎
很多人把网上现成的AlexNet代码直接拿过来,输入MNIST,报错信息通常是“size mismatch”。这非常正常。解决的办法也很简单:在你定义好模型后,先用一副假的输入数据跑一次前向传播,把每层输出尺寸打印出来,确认形状符合预期。
一个常用的PyTorch调试技巧:
fake_data = torch.randn(1, 1, 28, 28) model = AlexNetMNIST() # 注册钩子或者临时打印 from torchsummary import summary summary(model, input_size=(1, 28, 28))torchsummary会显示每一层的输出形状和参数量,非常方便。如果提示某个线性层的输入维度不是预期值,那就反向计算一下最后一个池化层的输出尺寸,然后修改nn.Linear(in_features)。这是最麻烦的一步,但只要算清楚,后面就很顺畅了。
有时候你还需要检查卷积之后特征图的尺寸。可以自己写个小工具:
def get_size(x, conv): return conv(x).shape然后针对每一层逐步推进。千万不要抱着“改了全连接层就能跑”的侥幸心理,因为每一层的输入输出都会连锁影响最终展平的向量长度。
4.2 收敛很慢甚至loss不下降
如果你发现训练了十几个epoch,损失还在0.69左右(对应随机猜测水平),那大概率是学习率太低或数据归一化出了问题。先检查输入数据的分布:用images.min()和images.max()看一下,确保数据范围接近0均值、单位方差。如果发现数据范围是0到0.0039那种,说明做了两次缩放,要修正transform。
另一种可能是模型最后一层忘了去掉手动softmax,会导致梯度不稳定。检查一下网络输出端,直接输出logits,不要加torch.softmax。如果你真的需要输出概率,在推理时单独调用softmax即可。
还有一个常见坑是权重初始化过小。如果使用自定义初始化把卷积核设置为接近全零,网络初期会输出几乎恒定的概率分布,梯度接近于零,学习就会非常慢。PyTorch默认初始化一般没有问题,不要手贱去改。
4.3 过拟合:训练集准确率99.9%但测试集96%
AlexNet在MNIST上过拟合的典型表现就是训练损失一路降到0.01以下,验证损失却开始回升,准确率涨不动。缓解方法按重要性排序:增加数据增强、增大Dropout比率、减小模型参数量、增加weight decay。
我做的实验表明,Dropout从0.5提高到0.8,测试集准确率能提升约0.2个百分点,但训练收敛变慢。数据增强的加入提升更明显,能把测试集准确率从99.2%拉到99.6%。如果你用原版AlexNet(方案A)加数据增强,准确率也能达到99.3%以上,但需要更长的训练时间。
另外,batch size过小也会加剧过拟合,因为梯度噪声大,模型更容易记住单个样本的细节。把batch size从32改成128,配合稍减的学习率,能看得到验证集准确率更稳定。
4.4 准确率上下震荡,loss不走常规BD
这类问题多半出在学习率太大或者batch size太小。如果学习率是0.1,MNIST任务很容易发散,因为每个batch的梯度方向差异太大,参数更新幅度过大。我当时遇到过一次准确率在95%上下抖动,检查后发现是最初把学习率设成了0.05,而SGD对学习率非常敏感。把它降到0.01之后,十来个epoch就稳定收敛了。
另外,数据加载时shuffle=False也会导致震荡。如果不打乱数据,那么每个batch都来自连续的一段数据,相互之间相关性高,梯度方向会出现周期性的偏置,表现为损失波动。记得训练DataLoader要设shuffle=True。
如果震荡发生在学习率改变之后,可能是StepLR的衰减幅度太大,比如从0.01直接跳到0.0001,模型参数调整步长突变,也会造成短暂波动。把gamma改成0.1但step_size设小一点,比如每15个epoch衰减一次,会更平滑。
4.5 GPU显存不足与训练速度缓慢
原版AlexNet在MNIST上训练显存占用通常不会超过2G(batch_size=128),所以一般不太会爆显存。但如果你把batch_size调到了512甚至1024,加上torchvision的默认AlexNet实现,就可能出现CUDA out of memory。最简单的办法是降低batch_size,或者在模型训练循环中临时把resize放在GPU上处理。如果是在CPU上训练,方案C一个epoch大概5到10分钟,还算能接受,但要跑60个epoch就要漫长的等待了。建议先在CPU上跑5个epoch验证网络没问题,再换GPU正式跑。
我个人的一个经验是,在不改模型的情况下,把torch.backends.cudnn.benchmark = True加上,能让卷积运算自动选择最优算法,对于固定输入尺寸的MNIST训练,能带来20%左右的提速。
另外还有一个看起来很蠢但很实用的建议:训练时把数据加载的num_workers设置为4或8,尤其是数据增强操作比较耗时,能让GPU不会因为等数据而空闲。如果你发现GPU利用率不满,多半是数据加载瓶颈。
4.6 训练到后期精度上不去的瓶颈
很多人发现在99%、99.4%之后就很难往上走了。这是MNIST任务本身的饱和效应。在1万张测试集里,有相当一部分图像本身存在模糊、连笔、噪声,即使人去标也容易出错。所以99.7%已经是非常好的结果。如果你想继续刷分,可以试试下面几个方向:
- 使用更多年的历史标准方法:比如在MNIST上常用的像素“位移+缩放”组合增强,或者提出“随机擦除”增强。
- 集成模型:训练5个网络做投票,准确率可以提高到99.8%以上。
- 使用更大的模型(但要注意正则化)。
- 把全连接层改成全局平均池化,减少参数量,往往能带来微小提升。
但这些方法对理解和训练AlexNet的核心价值并不大,反而是花费大量时间去做边际收益很小的事情,不建议为了精确到小数点后两位数而钻牛角尖。
最后再说几句亲身体会
这个项目我从头到尾做了大概三天,中间踩了不少坑,最大的感受是:经典网络之所以经典,是因为它的每一个设计在当时的环境下都有合理动机,但我们落地到具体任务时,不能不加思考地直接套用。手写数字识别和ImageNet的图像复杂度差了不止一个量级,如果原封不动地用原版AlexNet,反而会因为模型容量过大而承担不必要的计算开销和过拟合风险。但反过来说,正因为MNIST足够小、足够简单,我们才有机会把网络结构一层层拆开细看,把尺寸推算、特征图变化、过拟合抑制、学习率策略这些基本功练扎实。这些能力在你以后处理真实项目时,都是必须的底子。
如果你正准备复现这个项目,我真心建议你不要直接从别人完整的代码粘贴后一跑了之。先手动推导一遍从输入到全连接层的尺寸变化,再让自己写一遍模型定义,遇到报错就根据错误信息一步步修正。这个过程中犯的每一个错,都会比跑通100个现成项目更让你长经验。等你自己把“AlexNet识别MNIST手写数据集”完整地从数据处理、模型设计、训练评估做出来一遍之后,你对CNN的理解绝对会上一个台阶。