1. 项目缘起与整体设计思路
CIFAR-10 这个数据集在图像分类领域里的地位,大概相当于编程入门时的 "Hello World"。它包含 10 个类别、共 60000 张 32×32 的彩色小图,训练集 50000 张、测试集 10000 张,类别覆盖飞机、汽车、鸟、猫、鹿、狗、青蛙、马、船、卡车。很多人第一次接触卷积神经网络(CNN)就是拿它练手,因为它足够小,单卡就能跑;又足够难,能真实反映模型设计的好坏——随机猜的准确率是 10%,一个像样的 CNN 能轻松做到 75% 以上,调得好可以摸到 93% 左右。
我这次做这个项目,目标很明确:不是刷一个好看的准确率数字就完事,而是把从数据加载、模型搭建、训练循环、学习率调度到结果可视化的完整链路走一遍,把每个环节里容易踩的坑都标出来。因为在实际工作中,我见过太多人能把模型结构背得滚瓜烂熟,却在数据归一化、验证集划分、过拟合判断这些基础环节翻车。CIFAR-10 恰好是一个能把这些基础问题全部暴露出来的绝佳载体。
整体方案上,我选择用 PyTorch 原生实现,不依赖任何高层封装框架。原因有三点:第一,PyTorch 的动态图机制让调试变得直观,你可以在任意位置打印张量形状,这对理解 CNN 的数据流至关重要;第二,自己写训练循环能让你真正搞清楚optimizer.zero_grad()、loss.backward()、optimizer.step()这三步到底在干什么,而不是被model.fit()一句话掩盖过去;第三,后续要做迁移学习、自定义损失函数、混合精度训练时,原生代码的扩展性远好于封装框架。
模型结构上,我准备了两套方案做对比:一套是经典的 VGG 风格小网络(自己堆卷积层),另一套是带残差连接的 ResNet 简化版。前者用来理解 CNN 的基本构件,后者用来体会残差连接为什么能训练更深的网络。两套方案都会给出完整的代码和实测结果,你可以直接抄作业,也可以在此基础上改。
提示:CIFAR-10 的图片尺寸只有 32×32,这意味着你不应该直接套用为 ImageNet(224×224)设计的网络结构。那些网络第一层往往有 7×7 大卷积核加 stride 2,用在 32×32 的图上会把信息压缩得太狠,导致准确率上不去。这是新手最容易犯的错误之一。
2. 数据准备与预处理的核心细节
2.1 数据加载与归一化参数的计算
PyTorch 的torchvision.datasets.CIFAR10可以直接下载并加载数据,但有一个细节很多人忽略:归一化用的均值和标准差应该基于训练集计算,而不是拍脑袋写(0.5, 0.5, 0.5)。虽然用 0.5 也能跑,但基于真实统计值做归一化能让训练更稳定、收敛更快。
我实测计算出来的 CIFAR-10 训练集三个通道的均值约为(0.4914, 0.4822, 0.4465),标准差约为(0.2470, 0.2435, 0.2616)。这三个数字建议你记下来,后面做迁移学习微调时也用得上。
import torch import torchvision import torchvision.transforms as transforms # 先加载原始数据,不做归一化,用来统计均值和标准差 train_raw = torchvision.datasets.CIFAR10( root='./data', train=True, download=True, transform=transforms.ToTensor() ) # 计算每个通道的均值和标准差 imgs = torch.stack([img for img, _ in train_raw], dim=0) # [50000, 3, 32, 32] mean = imgs.mean(dim=(0, 2, 3)) # 按 N, H, W 求均值 std = imgs.std(dim=(0, 2, 3)) print(f"mean: {mean}") # tensor([0.4914, 0.4822, 0.4465]) print(f"std: {std}") # tensor([0.2470, 0.2435, 0.2616])计算完统计值后,正式的数据管道就可以加上归一化和数据增强了。这里有个关键点:训练集要做数据增强,测试集绝对不做。训练时的随机翻转、随机裁剪是为了增加样本多样性、抑制过拟合;测试时你需要的是稳定、可复现的评估结果,任何随机变换都会让评估指标产生波动。
# 训练集变换:增强 + 归一化 train_transform = transforms.Compose([ transforms.RandomCrop(32, padding=4), # 先四周补0再随机裁剪 transforms.RandomHorizontalFlip(p=0.5), # 50%概率水平翻转 transforms.ToTensor(), transforms.Normalize(mean=(0.4914, 0.4822, 0.4465), std=(0.2470, 0.2435, 0.2616)) ]) # 测试集变换:只做归一化 test_transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize(mean=(0.4914, 0.4822, 0.4465), std=(0.2470, 0.2435, 0.2616)) ])RandomCrop(32, padding=4)这个操作值得展开说。它先在图片四周各补 4 个像素的零,变成 40×40,然后随机裁回 32×32。这样每张图每次被看到的"视角"都略有不同,模型不会死记硬背某个固定位置的特征。这是 CIFAR-10 上性价比最高的增强手段之一,几乎零成本就能带来 2-3 个百分点的提升。
2.2 验证集划分的必要性与实现
torchvision提供的 CIFAR-10 只有训练集和测试集两个划分。但测试集不能用来调参,否则你就是在间接过拟合测试集,最终报告的准确率会虚高。正确做法是从 50000 张训练集里切出 5000 张作为验证集,用来监控训练过程、选择最佳模型、调整超参数。
from torch.utils.data import random_split, DataLoader full_train = torchvision.datasets.CIFAR10( root='./data', train=True, download=True, transform=train_transform ) test_set = torchvision.datasets.CIFAR10( root='./data', train=False, download=True, transform=test_transform ) # 固定随机种子,保证每次划分一致 torch.manual_seed(42) train_set, val_set = random_split(full_train, [45000, 5000]) train_loader = DataLoader(train_set, batch_size=128, shuffle=True, num_workers=4, pin_memory=True) val_loader = DataLoader(val_set, batch_size=256, shuffle=False, num_workers=4, pin_memory=True) test_loader = DataLoader(test_set, batch_size=256, shuffle=False, num_workers=4, pin_memory=True)这里有几个参数值得解释。num_workers=4表示用 4 个子进程并行加载数据,能显著减少 GPU 等待数据的时间;pin_memory=True会把数据锁在页锁定内存里,加速 CPU 到 GPU 的传输。这两个参数在单卡训练时几乎必开,实测能让每个 epoch 的训练时间缩短 15%-30%,具体取决于你的磁盘速度和 CPU 核心数。
注意:
random_split划分出来的train_set和val_set共享同一个底层数据集对象,所以它们用的是同一套train_transform。这意味着验证集也带了随机增强,严格来说不够干净。如果你追求严谨,应该为验证集单独建一个只做归一化的数据集。我在快速实验阶段通常不纠结这个,但在最终报告结果时会用干净的验证集重新评估。
3. 模型结构设计与选型对比
3.1 VGG 风格小网络的搭建
先来看第一套方案:一个 VGG 风格的小型卷积网络。它的设计哲学是"卷积-卷积-池化"重复堆叠,通道数逐层翻倍,最后接全连接层分类。这个结构简单直白,非常适合用来理解 CNN 的基本数据流。
import torch.nn as nn import torch.nn.functional as F class VGGSmall(nn.Module): def __init__(self, num_classes=10): super().__init__() # 特征提取部分:3组 卷积-卷积-池化 self.features = nn.Sequential( # Block 1: 32x32 -> 16x16 nn.Conv2d(3, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.Conv2d(64, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), # Block 2: 16x16 -> 8x8 nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.Conv2d(128, 128, kernel_size=3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(2), # Block 3: 8x8 -> 4x4 nn.Conv2d(128, 256, kernel_size=3, padding=1), nn.BatchNorm2d(256), nn.ReLU(inplace=True), nn.Conv2d(256, 256, kernel_size=3, padding=1), nn.BatchNorm2d(256), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) # 分类部分 self.classifier = nn.Sequential( nn.AdaptiveAvgPool2d(1), # 4x4 -> 1x1,全局平均池化 nn.Flatten(), nn.Linear(256, num_classes) ) def forward(self, x): x = self.features(x) x = self.classifier(x) return x这个网络有几个设计决策需要说明。第一,每个卷积后面都跟了 BatchNorm。BatchNorm 在 CIFAR-10 这种小数据集上效果非常明显,它让每层的输入分布保持稳定,允许你用更大的学习率,还能起到轻微的正则化作用。我实测过去掉 BatchNorm 的版本,收敛速度慢了一倍不止,最终准确率也低了 3 个百分点左右。
第二,用全局平均池化替代了传统的展平+大全连接层。传统 VGG 在最后会有一个 512×4×4 到 512 的全连接层,参数量巨大,在 CIFAR-10 上很容易过拟合。全局平均池化把每个通道的 4×4 特征图直接求平均变成一个数,参数量为零,还能保留通道维度的语义信息。这个技巧从 Network in Network 那篇工作开始流行,现在几乎是标配。
第三,没有用 Dropout。因为网络本身不大,加上 BatchNorm 和数据增强,过拟合风险已经控制得不错。如果你发现训练集准确率远高于验证集,再考虑在全连接层前加nn.Dropout(0.5)。
3.2 残差网络简化版的实现
第二套方案是带残差连接的 ResNet 简化版。残差连接解决的是深层网络的退化问题:理论上网络越深表达能力越强,但实际上超过一定深度后,训练误差反而会上升,这不是过拟合,而是优化困难。残差连接通过让网络学习"残差映射"而不是"完整映射",使得梯度能更顺畅地回传。
class BasicBlock(nn.Module): def __init__(self, in_channels, out_channels, stride=1): super().__init__() self.conv1 = nn.Conv2d(in_channels, out_channels, 3, stride=stride, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(out_channels) self.conv2 = nn.Conv2d(out_channels, out_channels, 3, stride=1, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_channels) # 捷径连接:如果输入输出维度不一致,需要用1x1卷积调整 self.shortcut = nn.Sequential() if stride != 1 or in_channels != out_channels: self.shortcut = nn.Sequential( nn.Conv2d(in_channels, out_channels, 1, stride=stride, bias=False), nn.BatchNorm2d(out_channels) ) def forward(self, x): out = F.relu(self.bn1(self.conv1(x))) out = self.bn2(self.conv2(out)) out += self.shortcut(x) # 残差相加 out = F.relu(out) return out class ResNetSmall(nn.Module): def __init__(self, num_classes=10): super().__init__() self.in_channels = 64 self.conv1 = nn.Conv2d(3, 64, 3, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(64) # 三个stage,每个stage两个BasicBlock self.layer1 = self._make_layer(64, 2, stride=1) # 32x32 self.layer2 = self._make_layer(128, 2, stride=2) # 16x16 self.layer3 = self._make_layer(256, 2, stride=2) # 8x8 self.avgpool = nn.AdaptiveAvgPool2d(1) self.fc = nn.Linear(256, num_classes) def _make_layer(self, out_channels, num_blocks, stride): strides = [stride] + [1] * (num_blocks - 1) layers = [] for s in strides: layers.append(BasicBlock(self.in_channels, out_channels, s)) self.in_channels = out_channels return nn.Sequential(*layers) def forward(self, x): x = F.relu(self.bn1(self.conv1(x))) x = self.layer1(x) x = self.layer2(x) x = self.layer3(x) x = self.avgpool(x) x = x.flatten(1) x = self.fc(x) return x这个 ResNet 简化版只有 6 个 BasicBlock,参数量约 280 万,比 VGGSmall 的 150 万略多,但训练起来更稳定。关键点在于shortcut的处理:当残差块的输入输出通道数不同,或者 stride 不为 1 导致空间尺寸变化时,捷径连接必须用 1×1 卷积加 BatchNorm 做投影,否则张量形状对不上,加法无法进行。
提示:
bias=False在卷积层里是个小优化。因为后面紧跟 BatchNorm,BatchNorm 本身会减去均值,卷积的偏置项会被抵消掉,设成 False 既省参数又省计算,还不影响精度。这个细节在 PyTorch 官方 ResNet 实现里也是这么处理的。
3.3 两套方案的选型对比
| 对比维度 | VGGSmall | ResNetSmall |
|---|---|---|
| 参数量 | 约 150 万 | 约 280 万 |
| 训练稳定性 | 一般,对学习率敏感 | 好,残差连接缓解梯度问题 |
| 收敛速度 | 较慢,约 40 epoch 收敛 | 较快,约 25 epoch 收敛 |
| 最终验证准确率 | 约 89%-90% | 约 92%-93% |
| 代码复杂度 | 低,适合入门 | 中,需要理解残差机制 |
| 扩展性 | 一般,加深后退化明显 | 好,可继续堆叠更多 block |
如果你刚开始学,建议先用 VGGSmall 跑通全流程,理解每个环节;等有感觉了再换 ResNetSmall,体会残差连接带来的差异。两套代码我都实测过,下面的训练配置对两者都适用,但 ResNet 可以用稍大一点的学习率。
4. 训练循环与关键配置详解
4.1 损失函数、优化器与学习率调度
损失函数用标准的交叉熵nn.CrossEntropyLoss(),它内部已经包含了 Softmax 操作,所以模型最后一层不要加 Softmax,直接输出 logits 就行。这是新手常犯的错误:在模型里加了 Softmax,又用 CrossEntropyLoss,等于做了两次 Softmax,梯度会变得很小,训练几乎不动。
优化器选 SGD with Momentum 还是 Adam?这是个经典问题。我的实测结论是:在 CIFAR-10 上,调好学习率和权值衰减的 SGD 最终准确率略高于 Adam,但 Adam 收敛更快、对学习率更不敏感。如果你追求极致精度且愿意花时间调参,用 SGD;如果你想快速看到结果,用 Adam。
import torch.optim as optim device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = ResNetSmall(num_classes=10).to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.SGD(model.parameters(), lr=0.1, momentum=0.9, weight_decay=5e-4) # 余弦退火学习率调度 scheduler = optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max=100, eta_min=1e-5 )weight_decay=5e-4是 L2 正则化系数,用来抑制过拟合。这个值在 CIFAR-10 上是个经验值,太大欠拟合,太小过拟合。momentum=0.9让优化器在梯度方向一致时加速,在方向震荡时减速,是 SGD 的标配。
学习率调度我选了余弦退火,它让学习率从 0.1 平滑下降到 1e-5,前期快速下降、后期精细调整。相比阶梯式下降(比如每 30 epoch 降 10 倍),余弦退火不需要手动设置下降节点,而且实测最终精度通常更高。T_max=100表示 100 个 epoch 完成一个完整的余弦周期。
4.2 完整训练循环与验证逻辑
训练循环的骨架很固定,但魔鬼在细节里。下面是我实际用的版本,包含了训练、验证、最佳模型保存三个部分。
def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss, correct, total = 0, 0, 0 for imgs, labels in loader: imgs, labels = imgs.to(device), labels.to(device) optimizer.zero_grad() # 清空上一轮梯度 outputs = model(imgs) # 前向传播 loss = criterion(outputs, labels) loss.backward() # 反向传播,计算梯度 optimizer.step() # 更新参数 total_loss += loss.item() * imgs.size(0) _, preds = outputs.max(1) correct += preds.eq(labels).sum().item() total += imgs.size(0) return total_loss / total, correct / total @torch.no_grad() def evaluate(model, loader, criterion, device): model.eval() total_loss, correct, total = 0, 0, 0 for imgs, labels in loader: imgs, labels = imgs.to(device), labels.to(device) outputs = model(imgs) loss = criterion(outputs, labels) total_loss += loss.item() * imgs.size(0) _, preds = outputs.max(1) correct += preds.eq(labels).sum().item() total += imgs.size(0) return total_loss / total, correct / total这里有几个必须强调的点。第一,model.train()和model.eval()一定要切换。它们影响 BatchNorm 和 Dropout 的行为:训练时 BatchNorm 用当前 batch 的统计量并更新滑动平均,Dropout 随机丢弃;评估时 BatchNorm 用滑动平均统计量,Dropout 关闭。忘了切换会导致评估结果完全不可信。
第二,验证函数上的@torch.no_grad()装饰器不能省。它告诉 PyTorch 不要构建计算图,能省下大量显存,评估速度也能快 30% 以上。评估阶段不需要反向传播,构建计算图纯属浪费。
第三,loss.item() * imgs.size(0)这个写法是为了正确计算整个 epoch 的平均损失。因为最后一个 batch 可能不满,直接对 batch loss 求平均会有偏差,乘以样本数再除以总样本数才准确。
主训练循环把上面两个函数串起来,加上最佳模型保存:
best_acc = 0.0 for epoch in range(100): train_loss, train_acc = train_one_epoch( model, train_loader, optimizer, criterion, device) val_loss, val_acc = evaluate(model, val_loader, criterion, device) scheduler.step() # 保存验证集上表现最好的模型 if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), 'best_model.pth') print(f"Epoch {epoch+1:3d} | " f"Train Loss {train_loss:.4f} Acc {train_acc:.4f} | " f"Val Loss {val_loss:.4f} Acc {val_acc:.4f} | " f"LR {scheduler.get_last_lr()[0]:.6f}")4.3 训练过程的实测记录与现象解读
我用 ResNetSmall 在单张消费级显卡上跑了 100 个 epoch,batch size 128,总耗时约 50 分钟。下面是几个关键节点的记录:
| Epoch | 训练损失 | 训练准确率 | 验证损失 | 验证准确率 | 学习率 |
|---|---|---|---|---|---|
| 1 | 1.8234 | 32.1% | 1.4521 | 45.3% | 0.1000 |
| 10 | 0.7823 | 72.5% | 0.6891 | 76.2% | 0.0976 |
| 25 | 0.4123 | 85.6% | 0.4234 | 85.1% | 0.0854 |
| 50 | 0.2341 | 91.8% | 0.3123 | 89.7% | 0.0500 |
| 75 | 0.1234 | 95.7% | 0.2876 | 91.8% | 0.0146 |
| 100 | 0.0678 | 97.8% | 0.3012 | 92.9% | 0.00001 |
从这张表能读出几个重要现象。第一,验证准确率在第 50 个 epoch 左右就基本饱和了,后面 50 个 epoch 只提升了 3 个百分点,但训练准确率从 91.8% 涨到了 97.8%。这说明模型在后半程主要在记忆训练集的细节,泛化能力提升有限,过拟合已经开始。
第二,验证损失在第 75 个 epoch 后开始回升,而验证准确率还在缓慢上升。这是过拟合的典型信号:模型对正确类别的置信度越来越高,但对错误类别的惩罚也在加大,导致损失上升。如果你只看准确率,可能会忽略这个信号。
第三,学习率降到很低时(最后 10 个 epoch),验证准确率还有小幅提升。这说明余弦退火的后期精细调整确实有用,模型在损失曲面的平坦区域找到了更好的解。
提示:如果你发现验证损失持续上升而验证准确率停滞,可以考虑提前停止(early stopping)。具体做法是记录验证损失,如果连续 10 个 epoch 没有下降就终止训练。这能省下大量时间,还能避免过拟合。
5. 常见问题排查与避坑经验
5.1 训练不收敛或准确率卡在 10%
这是最让人抓狂的问题:损失不下降,准确率一直在 10% 附近晃悠,说明模型根本没学到东西。我排查过多次,原因基本集中在以下几个地方。
学习率太大。SGD 配 0.1 的学习率对 ResNet 是合适的,但如果你用的是更深的网络或者没有 BatchNorm,0.1 可能直接导致梯度爆炸。表现是损失变成 NaN 或者剧烈震荡。解决办法是把学习率降到 0.01 试试,或者加梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0)。
数据标签错位。这个坑很隐蔽。如果你自定义了 Dataset 类,在__getitem__里返回的图片和标签必须一一对应。我见过有人把return img, label写成了return label, img,训练时损失正常下降但准确率永远上不去,因为模型在学"根据标签预测图片"。
归一化参数用错。如果你用了 ImageNet 的均值标准差(0.485, 0.456, 0.406)来归一化 CIFAR-10,虽然不会完全不收敛,但会拖慢收敛速度。CIFAR-10 的统计分布和 ImageNet 不一样,用错参数相当于给模型增加了额外的分布偏移。
忘了切换 train/eval 模式。如果评估时忘了model.eval(),BatchNorm 会用当前 batch 的统计量,而验证集 batch size 通常比训练集大,统计量差异会导致评估结果异常。更严重的是 Dropout 还在随机丢弃,评估结果会非常不稳定。
5.2 过拟合的识别与应对
过拟合的表现是训练准确率远高于验证准确率,比如训练 98%、验证 88%,差距超过 10 个百分点。应对手段按性价比排序如下。
加数据增强是最有效的手段。除了 RandomCrop 和 RandomHorizontalFlip,还可以加 ColorJitter(颜色抖动)、RandomRotation(小角度旋转)。但要注意,CIFAR-10 的图片只有 32×32,旋转角度太大会引入黑边,一般控制在 ±15 度以内。
加正则化。Weight decay 从 5e-4 加到 1e-3,或者在分类层前加 Dropout。Dropout 的概率从 0.3 开始试,太高会导致欠拟合。
减小模型容量。如果数据增强和正则化都试过了还是过拟合,说明模型太大了。把 ResNetSmall 的通道数从 64/128/256 降到 32/64/128,参数量减半,过拟合会明显缓解。
早停。最简单粗暴的方法,验证损失连续多个 epoch 不下降就停。我一般设 patience=15,即连续 15 个 epoch 验证损失没创新低就终止。
5.3 显存不足与训练速度优化
显存不足的报错是CUDA out of memory。解决办法按优先级排列:第一,减小 batch size,从 128 降到 64 或 32;第二,用混合精度训练torch.cuda.amp,能省约 40% 显存;第三,用梯度累积,即多个小 batch 累积梯度后再更新一次参数,等效于大 batch。
训练速度优化方面,除了前面提到的num_workers和pin_memory,还可以用torch.backends.cudnn.benchmark = True。这个设置会让 cuDNN 自动寻找最适合当前卷积配置的算法,在输入尺寸固定的情况下能提速 10%-20%。但注意,如果你的输入尺寸经常变化,这个设置反而会拖慢速度,因为每次都要重新搜索算法。
# 混合精度训练示例 scaler = torch.cuda.amp.GradScaler() for imgs, labels in train_loader: imgs, labels = imgs.to(device), labels.to(device) optimizer.zero_grad() with torch.cuda.amp.autocast(): outputs = model(imgs) loss = criterion(outputs, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()混合精度训练的核心是用 float16 做前向和反向计算,用 float32 保存模型参数。GradScaler负责在反向传播前放大损失,防止 float16 下梯度下溢变成 0,更新参数前再缩回去。实测在支持 Tensor Core 的显卡上,训练速度能提升 1.5-2 倍,显存占用减少约 40%。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 损失为 NaN | 学习率过大、梯度爆炸 | 打印每层梯度范数 | 降低学习率、加梯度裁剪 |
| 准确率卡在 10% | 标签错位、模型未训练 | 检查 Dataset 返回值 | 修正数据管道、确认 train 模式 |
| 验证准确率远低于训练 | 过拟合 | 对比两条准确率曲线 | 加增强、加正则、早停 |
| 显存溢出 | batch 太大、模型太大 | 打印显存占用 | 减小 batch、混合精度 |
| 训练速度慢 | 数据加载瓶颈 | 看 GPU 利用率 | 增加 num_workers、pin_memory |
| 验证结果波动大 | 忘了 eval 模式 | 检查 model.training | 评估前调用 model.eval() |
| 收敛慢 | 学习率太小、归一化不当 | 观察损失下降速度 | 调大学习率、用正确统计值 |
6. 结果可视化与模型评估
6.1 混淆矩阵与分类别准确率
整体准确率只是一个数字,真正能告诉你模型哪里不行的是混淆矩阵。我用测试集跑了一遍,发现几个有意思的现象:猫和狗的混淆率最高,约 18% 的猫被误判成狗,15% 的狗被误判成猫;汽车和卡车的混淆率次之,约 12%。这其实很符合直觉,猫和狗在 32×32 的低分辨率下,轮廓和纹理确实相似;汽车和卡车都是四个轮子的金属物体,颜色分布也接近。
from sklearn.metrics import confusion_matrix import numpy as np model.eval() all_preds, all_labels = [], [] with torch.no_grad(): for imgs, labels in test_loader: imgs = imgs.to(device) outputs = model(imgs) _, preds = outputs.max(1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.numpy()) cm = confusion_matrix(all_labels, all_preds) # 计算每个类别的准确率 per_class_acc = cm.diagonal() / cm.sum(axis=1) classes = ['plane', 'car', 'bird', 'cat', 'deer', 'dog', 'frog', 'horse', 'ship', 'truck'] for cls, acc in zip(classes, per_class_acc): print(f"{cls:6s}: {acc:.4f}")实测下来,船、汽车、青蛙的准确率最高,都在 95% 以上;猫的准确率最低,只有 86% 左右。这提示我们,如果实际应用中对猫的识别要求高,需要针对性地补充猫的样本,或者用更强的数据增强(比如 CutMix、MixUp)来提升困难类别的表现。
6.2 错误样本分析与改进方向
把预测错误的样本可视化出来,能发现一些系统性问题。我随机抽了 20 张错分图片,发现几类典型错误:第一类是目标太小或太偏,比如一只鸟只占了图片角落的几个像素;第二类是光照极端,比如背光的马几乎成了剪影;第三类是多目标混杂,比如一张图里既有船又有海面反光,模型被背景干扰。
针对这些发现,改进方向也很明确。对于小目标,可以在数据增强里加随机缩放,让模型适应不同尺度的目标;对于光照问题,ColorJitter 里的 brightness 和 contrast 参数可以调大一些;对于背景干扰,可以尝试注意力机制或者 Cutout(随机遮挡),强迫模型关注目标的判别性区域而不是背景。
提示:Cutout 在 CIFAR-10 上效果很好,实现也简单:在图片上随机挖一个 8×8 或 16×16 的黑色方块。这能模拟遮挡场景,还能起到正则化作用。我实测加 Cutout 后,验证准确率提升了约 1.5 个百分点,尤其是对猫狗这类容易混淆的类别。
7. 从 CIFAR-10 到真实项目的迁移经验
CIFAR-10 跑通只是起点,真正有价值的是把这套流程迁移到自己的数据上。我踩过的坑里,最值得分享的是数据量不足时的处理策略。CIFAR-10 有 50000 张训练图,而很多实际项目可能只有几百张。这时候从头训练必然过拟合,正确做法是用预训练模型做迁移学习。
具体操作是:加载在 ImageNet 上预训练的 ResNet,把最后的全连接层换成你的类别数,然后分两阶段训练。第一阶段冻结卷积层,只训练全连接层,学习率设 1e-3;第二阶段解冻所有层,用很小的学习率(1e-4 或更低)做微调。这样能在小数据集上快速达到不错的精度。
另一个经验是输入尺寸的适配。CIFAR-10 是 32×32,但实际项目里图片尺寸五花八门。如果直接 resize 到 32×32,很多细节会丢失。我的建议是:如果原图较大(比如 224×224 以上),直接用预训练模型的标准输入尺寸;如果原图较小(比如 64×64),可以适当放大到 128×128 再送入网络,比强行压缩到 32×32 效果好得多。
最后说一个关于评估的体会。CIFAR-10 的测试集是平衡的,每个类别 1000 张。但真实项目的数据往往类别极不平衡,这时候整体准确率会误导你。比如 90% 的样本是 A 类,模型全预测 A 也能有 90% 准确率,但 B 类完全没学到。这种情况下要看每个类别的召回率和 F1 分数,或者用加权交叉熵来处理类别不平衡。
这套 CIFAR-10 的完整流程我反复跑过很多遍,每次都能发现新的细节可以优化。它就像一面镜子,能照出你在数据处理、模型设计、训练调参各个环节的薄弱点。建议你不要只跑一遍就完事,试着改改超参数、换换模型结构、加加数据增强,观察每个改动带来的影响。这种"控制变量"的实验习惯,比记住任何具体技巧都重要。