简介:面向深度学习初学者的课程实验资源,利用卷积神经网络实现十类彩色图像数据集(CIFAR-10)的分类器。压缩包包含十七个文件,其中两个Python脚本负责模型构建与训练数据的保存,一个Markdown文档说明环境配置与运行步骤,另有十四张PNG图展示网络结构、损失变化和分类效果,整体大小约二百四十千字节,便于快速下载。核心代码涵盖数据预处理、卷积层/池化层/批量归一化设置、训练与验证流程;配套CSV生成脚本可记录每轮损失和准确率,图片则直观呈现训练过程中的指标曲线和混淆矩阵,帮助学习者评估模型表现。适用于课程作业和实验报告撰写,已有一百五十三人学习参考。通过实践,可以掌握图像分类任务的完整建模思路、正则化与数据增强技巧,以及基于测试集反馈调整超参数的方法,为后续更复杂的视觉任务提供基础。
1. CIFAR-10 分类器到底难在哪:一张 32×32 小图的前世今生
用卷积神经网络实现 CIFAR-10 数据集的分类器,是很多人的第一道“彩色门槛”:从 MNIST 黑白手写体切换到 6 万张 32×32 的彩色图片,输入从单通道变成三通道,分辨率还低,物体小,纹理和背景常常纠缠在一起。你眼里是“一只趴在树上的猫”,模型眼里往往是“一团分布在 1024 个像素里的噪声”。这个任务能让你练熟数据集加载、卷积结构、正则化、训练曲线管理这一整条链路,也适合刚端起 PyTorch、写过几轮线性回归的从业者确认自己不是只会调包。
CIFAR-10 的十个类别里,汽车和卡车、猫和狗这类混叠非常明显,所以网上很多教程跑 MNIST 能刷到 99%,换到这里立刻打回原形。这篇笔记不打算只贴一个网络结构,而是把从数据到训练、排错、导出模型的完整路径走一遍,让你不仅跑得通,还能在换数据集时少把旧坑再踩一遍。
2. 把 CIFAR-10 数据喂给卷积神经网络:加载、归一化和增强的落地写法
CIFAR-10 数据集的加载在 PyTorch 生态里已经有现成封装,真正需要你决策的是三件事:数据进来时是什么形态、像素值怎么归一化、训练和测试是否共用同一条 transform 流水线。很多人直接复制一份训练用的 transform 去跑测试,结果验证集准确率忽高忽低,还以为是模型随机性太大,其实问题出在数据入口。
2.1 从 torchvision 加载 CIFAR-10:标签含义和 DataLoader 参数
from torchvision import datasets, transforms from torch.utils.data import DataLoader transform_train = transforms.Compose([ transforms.RandomCrop(32, padding=4), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize( mean=(0.4914, 0.4822, 0.4465), std=(0.2470, 0.2435, 0.2616), ), ]) transform_test = transforms.Compose([ transforms.ToTensor(), transforms.Normalize( mean=(0.4914, 0.4822, 0.4465), std=(0.2470, 0.2435, 0.2616), ), ]) trainset = datasets.CIFAR10( root="./data", train=True, download=True, transform=transform_train ) testset = datasets.CIFAR10( root="./data", train=False, download=True, transform=transform_test ) trainloader = DataLoader( trainset, batch_size=128, shuffle=True, num_workers=4, pin_memory=True ) testloader = DataLoader( testset, batch_size=256, shuffle=False, num_workers=4, pin_memory=True )datasets.CIFAR10 下载后会把数据组织成trainset.data和trainset.targets,前者形状是 (50000, 32, 32, 3),像素值范围 0 到 255,通道排在最后;后者是 0 到 9 的整数标签。你要记住这个形状:ToTensor 会把通道维从末尾挪到前面,变成 (3, 32, 32),同时把像素除以 255 压到 0 到 1 区间。如果你后续自己写数据加载,最容易犯的错就是拿到 (32, 32, 3) 直接喂给卷积层,模型能训练,但准确率永远上不去。
shuffle=True是训练集必须维持的,不洗牌的话,前几百个 batch 可能全是同一类别,优化器会被带偏。测试集反而要shuffle=False,这样你每轮评估的结果可以在固定顺序上做对比。batch_size=128是一张 4G 显存显卡上比较划算的起点,显存吃紧就降到 64,学习率也要跟着动。num_workers=4通常够用,在机械硬盘和老 CPU 上开 8 个 worker 反而可能让每个 epoch 更慢。pin_memory=True只在用 GPU 训练时有意义,纯 CPU 训练开了也白开。
为了确认数据没坏,加载后我习惯先看一眼标签分布:
from collections import Counter print(trainset.data.shape) # (50000, 32, 32, 3) print(Counter(trainset.targets))CIFAR-10 天然是均衡的,每个类正好 5000 张训练图,所以分类器不需要做重采样。但你要把十个类名记清楚,顺序是 airplane、automobile、bird、cat、deer、dog、frog、horse、ship、truck。后面画混淆矩阵时类型顺序错位会让结果完全没法读。
2.2 归一化与数据增强:训练集和测试集为什么不能共用一条流水线
训练集 transform 里比测试集多了两个操作:RandomCrop(32, padding=4) 和 RandomHorizontalFlip()。先说归一化的数学含义,再说增强为什么只给训练集加。
Normalize 执行的是逐通道的(x - mean) / std,这套 mean 和 std 是 CIFAR-10 训练集的全局统计量。(0.4914, 0.4822, 0.4465) 是 RGB 三通道各自的均值,(0.2470, 0.2435, 0.2616) 是标准差。这些数不是我拍脑袋写的,是 torchvision 社区里跑 CIFAR-10 时的通行配置,你也可以用trainset.data.mean(axis=(0,1,2))/255自己算一遍,结果基本一致。如果你明明加载的是 0 到 1 区间,却用 125 和 255 去做归一化,输入分布会整体偏移,模型得花更多 epoch 才能把 BN 层的统计量拉回来。
RandomCrop 在 32×32 图像边缘补 4 像素再随机裁回 32×32,等于把物体的位置抖动显式教给网络,防止模型只对“正中间一辆汽车”响应。RandomHorizontalFlip 对猫、狗、鸟这种对称生物很友好,对数字这种不对称图形反而不合适,CIFAR-10 里没有必须保持朝向的类别,所以放心用。
测试集不能加 RandomCrop,也不能加 HorizontalFlip,因为评估必须在一个确定性分布上做。你每次跑测试都随机裁掉不同区域,得到的准确率自然忽上忽下。这是我见过最隐蔽的数据坑:训练集和测试集用了同一段 transform,代码没报错,指标却不讲道理。测试集唯一保留的是 ToTensor 和 Normalize,保证数值范围跟训练时一致。
2.3 关于 batch、num_workers 和 shuffle 的三个边界问题
第一个边界是 batch 大小对归一化和 BN 的影响。BatchNorm 用的是当前 batch 内的均值和方差,batch 越小,统计量越抖。如果你显存只够跑 batch=32,学习率要从 0.1 降到 0.025 左右,否则 loss 曲线会像锯齿一样来回翻。如果 batch 开得很大,比如 512,学习率适当上抬,但 128 到 256 之间已经能覆盖大多数场景,没必要硬冲。
第二个边界是num_workers。在 Windows 上开太多 worker 容易触发重复 fork 的报错,在 Linux 上则要留意内存占用。我一般遵循“先 2 后 4,再试 8,取最高且不崩的那个”。如果训练时数据加载变成了瓶颈,你会在 GPU 利用率上看到一段段空白,这时候调大num_workers立竿见影;如果 GPU 利用率已经 90% 以上,调大 worker 没有任何意义。
第三个边界是 shuffle=False 的使用场景。除了测试集,有时候你想用整个训练集做一次可视化,把某个类别的图片全部拿出来画网格,这时 shuffle 会把你想要的有序数据打乱,反而麻烦。更常见的是你想检查模型的错误样本,这时在 DataLoader 里保留原始索引会很有用,可以用dataset.targets配合预测结果做对齐,别依赖 batch 内顺序。
3. CNN 结构选型与实现:从 VGG 式堆叠到 ResNet 风格
模型结构是整个分类器里最容易被“抄作业”带偏的部分。很多人一上来就搬 ImageNet 用的 ResNet18,结果第一层 7×7 卷积加 stride 2 直接把 32×32 的输入砍成 16×16,后面的池化层再一压,图像早早就变成 4×4,细粒度特征全部丢失。CIFAR-10 的输入太小,必须选择为小图设计的结构。
3.1 为什么 32×32 小图不适合直接搬大模型:选型先选 stem
CIFAR-10 的完整图像只有 32×32,一个 7×7 卷积的有效感受野几乎覆盖整张图的一半,这等于让网络第一层就看到全图,丧失局部纹理建模能力。常见做法是像 ResNet-20 那样,第一个卷积用 3×3、stride 1、padding 1,尽量保持 32×32 的空间分辨率,让前几层还有机会学习边角和细节。
我自己会先想清楚一个问题:到底要证明“我能调出一个 CNN”,还是“我要刷高 CIFAR-10 准确率”。如果只是前者,VGG 风格的三段 3×3 堆叠加全局平均池化就够,训练集准确率能上 95%,测试准确率在 85% 上下。如果要后者,残差结构值得上,靠 shortcut 把梯度从最后一层直通到前面,能让网络在几十层深度上依然稳定训练,测试准确率可以摸到 90% 以上。
VGG 风格和 ResNet 风格的差异不在单层能力,而在训练过程中的梯度传播。VGG 一旦堆到十几层,中间某层梯度消失,前几层参数就学不动;ResNet 的 shortcut 让每一层都可以选择“什么都不做”或者“只学残差”,网络退化问题被明显缓解。CIFAR-10 上的任务是图像分类不是超分辨率,不需要太宽的网络,所以我给每个阶段的通道数按 16、32、64 翻倍,而不是一开始就上 64 起步。
3.2 ResNet 风格模型的最小实现:BasicBlock 与三个阶段
import torch import torch.nn as nn import torch.nn.functional as F class BasicBlock(nn.Module): expansion = 1 def __init__(self, in_ch, out_ch, stride=1): super().__init__() self.conv1 = nn.Conv2d(in_ch, out_ch, 3, stride=stride, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(out_ch) self.conv2 = nn.Conv2d(out_ch, out_ch, 3, stride=1, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_ch) if stride == 1 and in_ch == out_ch: self.shortcut = nn.Identity() else: self.shortcut = nn.Sequential( nn.Conv2d(in_ch, out_ch, 1, stride=stride, bias=False), nn.BatchNorm2d(out_ch), ) def forward(self, x): out = F.relu(self.bn1(self.conv1(x))) out = self.bn2(self.conv2(out)) out = out + self.shortcut(x) return F.relu(out) class CifarResNet(nn.Module): def __init__(self, num_classes=10): super().__init__() self.in_ch = 16 self.conv1 = nn.Conv2d(3, 16, 3, stride=1, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(16) ch = [16, 32, 64] block_nums = [3, 3, 3] self.stage1 = self._make_stage(ch[0], block_nums[0], stride=1) self.stage2 = self._make_stage(ch[1], block_nums[1], stride=2) self.stage3 = self._make_stage(ch[2], block_nums[2], stride=2) self.gap = nn.AdaptiveAvgPool2d(1) self.fc = nn.Linear(64, num_classes) def _make_stage(self, out_ch, n_blocks, stride): layers = [] for i in range(n_blocks): s = stride if i == 0 else 1 layers.append(BasicBlock(self.in_ch, out_ch, s)) self.in_ch = out_ch return nn.Sequential(*layers) def forward(self, x): x = F.relu(self.bn1(self.conv1(x))) x = self.stage1(x) x = self.stage2(x) x = self.stage3(x) x = self.gap(x) x = torch.flatten(x, 1) return self.fc(x)BasicBlock 的 shortcut 设计是整个结构的关键:当 stride 等于 1 且输入输出通道相同时,shortcut 直接用 Identity,不引入额外参数;当 stride 等于 2 或通道数变化时,用 1×1 卷积把输入投影到目标通道数,同时完成空间下采样。这个写法还能避免一种常见错误:有人把 shortcut 写成nn.Conv2d但 stride 忘记对齐,相加时空间尺寸不匹配,直接报 RuntimeError。
两段卷积都是 3×3、padding 1,第一段卷积使用 stride 控制下采样。第一个 stage 不动分辨率,32×32 保持到 stage1 结束;stage2 的第一个 block 用 stride=2,空间降到 16×16;stage3 再降一次到 8×8,最后由全局平均池化压到 1×1。整个模型参数量只有约 27 万,比动辄上千万参数的 ImageNet 模型小一个数量级,在单张消费级显卡上十几分钟就能训完。
注意我在__init__里先写了self.in_ch = 16,后面_make_stage才能正确记录上一阶段输出通道。这种状态变量在递推建层时很简单,但也很容易被忽略,结果就是 stage2 的输入通道对不上。写完代码建议立刻做一次形状自检:
model = CifarResNet() x = torch.randn(2, 3, 32, 32) print(model(x).shape) # torch.Size([2, 10])3.3 结构参数表:从 32×32 到 8×8 的输出尺寸变化
| 网络部件 | 输出空间尺寸 | 输出通道 | 说明 |
|---|---|---|---|
| Conv1 + BN | 32×32 | 16 | 3×3 卷积,stride=1,不降分辨率 |
| Stage1 | 32×32 | 16 | 3 个 BasicBlock,全部 stride=1 |
| Stage2 | 16×16 | 32 | 第一个 block stride=2,之后两个 stride=1 |
| Stage3 | 8×8 | 64 | 第一个 block stride=2,之后两个 stride=1 |
| GlobalAvgPool | 1×1 | 64 | AdaptiveAvgPool2d(1) |
| FC | 1×1 | 10 | 输出 10 个类别的 logits |
这张表值得你盯一下的数值是通道数 16、32、64 的增长速度。通道翻倍的同时空间分辨率减半,理论计算量大致保持不变,这是卷积网络里很经典的“宽高减半、深度加倍”设计。32×32 的小图走到 8×8 后已经不能再压了,再往下连 4×4 会丢掉太多空间信息。如果你想加宽模型,把ch改成 [32, 64, 128] 即可,但要预计显存占用和训练时间翻倍。
有个可选优化是移除最后的 GlobalAvgPool 前再接一层 stride=2 的卷积,让特征图直接进 FC,但这种做法在 CIFAR-10 上没有明显收益,反而增加过拟合风险。我的判断是:先跑通这个最小残差结构,确认它能在验证集上稳定逼近 90%,再考虑加宽加深。
4. 训练一个 CIFAR-10 分类器:优化器、学习率和 checkpoint 的完整配置
网络结构只占成功的一半,另一半在训练配置。很多人结构抄得一模一样,准确率却低五六个点,差别基本都在学习率、weight decay 和模型切换 train/eval 的时机上。这一章给的配置是按 200 epoch、batch 128、单卡环境校准过的,你可以直接抄,但要知道每个参数为什么这样设。
4.1 训练循环的关键词是 mode:train/eval 要分开
def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss = 0.0 correct = 0 total = 0 for x, y in loader: x, y = x.to(device), y.to(device) optimizer.zero_grad() out = model(x) loss = criterion(out, y) loss.backward() optimizer.step() total_loss += loss.item() * x.size(0) correct += (out.argmax(1) == y).sum().item() total += y.size(0) return total_loss / total, correct / total def evaluate(model, loader, device): model.eval() correct = 0 total = 0 with torch.no_grad(): for x, y in loader: x, y = x.to(device), y.to(device) out = model(x) correct += (out.argmax(1) == y).sum().item() total += y.size(0) return correct / totalmodel.train()和model.eval()切换的是 BatchNorm 的工作方式。训练时 BN 使用当前 batch 的均值和方差,评估时应该使用训练阶段累计的运行统计量。如果你忘了切回 eval,测试时 BN 又拿当前 batch 重新算统计量,小 batch 下结果会抖得非常厉害。torch.no_grad()必须包住评估循环,否则模型会为每一张测试图构建计算图,显存白白翻倍。
训练循环里的loss.item() * x.size(0)是我比较坚持的写法。最后一个 batch 的样本数可能不足 128,如果直接对所有 batch 的 loss 求平均,小 batch 就会被高估。按样本数加权后得到的平均 loss 才跟准确率是同一套口径。out.argmax(1)取每个样本预测概率最大的类别,和 y 比得到正确个数,再除以总样本数。
4.2 优化器与学习率:SGD、momentum、weight_decay 和余弦退火怎么配
import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR criterion = nn.CrossEntropyLoss() optimizer = optim.SGD( model.parameters(), lr=0.1, momentum=0.9, weight_decay=5e-4, ) scheduler = CosineAnnealingLR(optimizer, T_max=200)在 CIFAR-10 上,SGD 加 momentum 的效果通常比 Adam 更加牢靠。Adam 收敛前期很快,但后期容易出现精度上不去的现象;SGD 配合余弦退火到训练后半段时学习率降得很低,能让模型在局部最优点附近把细节磨得更好。这不是玄学,是很多图像分类实验里的普遍观察。
lr=0.1是 batch 128 下的常用起点。你如果换到 batch 64,我一般会同步把 lr 折半到 0.05;换到 batch 256,可以试 0.2 但未必更好。学习率大了,训练一开始 loss 不降反升或者卡在 2.3 附近不动;学习率小了,前十个 epoch 的 loss 下降像爬坡。momentum=0.9是默认值,一般不动。weight_decay=5e-4在 CIFAR-10 的小模型上非常常见,它等价于对每个参数加一个小的 L2 惩罚,能明显压住测试集和训练集准确率之间的差距。调大 weight decay 会让训练集准确率掉到 90% 以下,调小则过拟合回来。
余弦退火T_max=200表示 200 个 epoch 内把学习率从 0.1 一路平滑降到接近 0。如果你只打算训 100 epoch,T_max 一定要改成 100,否则学习率还没降到位训练就结束,准确率会差一截。CosineAnnealingLR 的调用位置在每轮 epoch 结束后,不能在每个 batch 后调用,否则学习率会被断成碎片。
4.3 checkpoint:不只存 model,把 optimizer 和 val_acc 一起留下
best_acc = 0.0 epochs = 200 for epoch in range(epochs): train_loss, train_acc = train_one_epoch( model, trainloader, optimizer, criterion, device ) val_acc = evaluate(model, testloader, device) scheduler.step() print(f"epoch {epoch:3d} | loss {train_loss:.4f} | " f"train_acc {train_acc:.4f} | val_acc {val_acc:.4f}") if val_acc > best_acc: best_acc = val_acc torch.save( { "epoch": epoch, "model": model.state_dict(), "optimizer": optimizer.state_dict(), "val_acc": val_acc, }, "cifar10_best.pth", ) torch.save(model.state_dict(), "cifar10_last.pth")保存成字典而不是只存model.state_dict(),是为了让 checkpoint 具有“后悔药”功能。中断训练重启时,你可以把 optimizer 的状态也恢复,学习率调度器的位置也能接着算;只存权重就得从零开始调学习率。cifar10_best.pth记录验证集准确率最高的那个时刻,cifar10_last.pth保留最后一个 epoch 的结果,两个文件配合能让你判断“早停点”和“终点”到底哪个更好。
我在实验里发现,有时候最后一个 epoch 的准确率反而比 mid 阶段最高点低 0.3 到 0.5 个百分点,这是正常的,因为余弦退火后期学习率太小,模型会在非常小的邻域内徘徊。最终对外演示时直接加载 best 即可,不用纠结 last 的曲线。
5. CIFAR-10 分类器常见问题排查:五条踩坑记录
跑通这条链路不难,难的是跑到一半发现指标不对,还不知道该查代码还是查参数。下面是五条我反复见过的坑,全部按“现象”、“原因”、“解决”的顺序讲,每条都值得你在项目里做个记号。
5.1 Loss 卡在 2.30 附近不动,看起来像在猜
现象:训练刚开始,loss 一直保持在 2.3026 左右,十几个 epoch 过去仍然纹丝不动,训练准确率始终在 10% 附近。
原因:交叉熵对 10 分类的随机猜的期望值就是 log(10),约等于 2.3026。loss 卡在这里说明模型完全没在学,最常见的是学习率太大导致梯度在参数空间里来回震荡,或者学习率太小导致参数几乎不更新,还有一种可能就是输入归一化写错,图像数值范围不对,卷积层的输出全被 BN 拉成了同一个分布。
解决:先打印optimizer.param_groups[0]["lr"]确认学习率确实是你设置的值;再用一小批数据过一遍网络,打印model(x)的数值范围,如果 logits 全部接近 0,可能是初始化或 BN 有问题。最直接的排除法是拿一个 batch 反复过拟合:清空 DataLoader 的 shuffle,固定 128 张图训 20 步,如果 loss 降到 0.5 以下说明反向传播链路是通的,问题出在数据入口或学习率策略。
5.2 训练集 99%、测试集七成出头,直接过拟合
现象:训练准确率七八轮就冲到 95% 以上,测试集准确率却一直贴在 70% 到 75%,涨得非常慢。
原因:CIFAR-10 总共才五万张训练图,模型容量足够把训练集背下来。如果数据增强只有 ToTensor 和 Normalize,网络很容易把“某一张图属于某类”当成特征,泛化自然崩塌。另一种可能是 weight_decay 设成了 0,模型对高频噪声完全不过滤。
解决:优先把 RandomCrop 和 RandomHorizontalFlip 加进训练 transform,这两个操作能直接让有效样本量成倍增加。然后把 weight_decay 设为 5e-4,配合 BN 通常能把测试准确率拉到 85% 以上。如果还要继续压过拟合,可以试 CutOut 或 RandomErasing,在 32×32 的小图上随机遮掉一块区域,让模型不能依赖单个局部特征。
5.3 测试准确率忽高忽低,和训练曲线对不上
现象:训练时每个 epoch 打印的 val_acc 在 89% 到 91% 之间来回跳,中间没有明显的上升趋势,提交到独立脚本里复算时准确率又比训练时低了两个点。
原因:最常见的是评估时没有调用model.eval(),BatchNorm 使用了当前 batch 的统计量,batch 越小抖动越大;另一种可能是测试集也保留了数据增强,每次评估的结果都建立在随机裁剪上,指标自然没法复现。
解决:在evaluate()函数开头强制model.eval(),并把torch.no_grad()包住完整循环。测试 transform 只保留ToTensor和Normalize,任何增强操作都不要放进去。如果这两个地方都对了,val_acc 曲线就会变得平滑很多,同一份代码跑两次的准确率也应该在 0.1% 以内。
5.4 训练中途 CUDA out of memory,调到 batch=16 还是爆
现象:训练启动正常,几个 epoch 后突然报RuntimeError: CUDA out of memory,把 batch 降到 16 仍然会炸。
原因:显存爆炸往往不是 batch 一个因素决定的。激活值、BN 的中间统计、优化器状态都会占显存;如果num_workers开得过高,数据预取队列也会挤占显存。另一个隐藏杀手是训练循环里忘了optimizer.zero_grad(),梯度不断累加导致计算图失控。
解决:先在训练循环里确认zero_grad()放在backward()之前。然后检查显存占用,可以使用torch.cuda.max_memory_allocated()看峰值。真到需要压缩显存的时候,我建议把 batch 降到 64,学习率同步折半,再把torch.cuda.amp混合精度打开,显存通常能砍掉约三分之一。如果这些做完还爆,那要考虑你的模型是不是把通道数从 16、32、64 手动改成了 128、256、512。
5.5 同一个代码跑两次,结果差异大到没法比较
现象:固定了所有超参数,第一次跑测试集准确率 90.2%,第二次只有 89.4%,连训练 loss 曲线都不同。
原因:PyTorch 的默认行为里包含随机初始化、数据加载顺序、CUDA 卷积算法的自动选择。torch.backends.cudnn.benchmark=True时,cuDNN 会根据输入形状选择最快的卷积算法,但不同算法在不同运行里可能产生微小浮点差异,累积后影响最终准确率。
解决:在训练脚本最前面固定随机源。
import random import numpy as np import torch torch.manual_seed(42) np.random.seed(42) random.seed(42) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False def seed_worker(worker_id): worker_seed = torch.initial_seed() % 2**32 np.random.seed(worker_seed) random.seed(worker_seed) trainloader = DataLoader( trainset, batch_size=128, shuffle=True, num_workers=4, pin_memory=True, worker_init_fn=seed_worker, )cudnn.deterministic=True会牺牲一点速度换取可复现性,cudnn.benchmark=False则关闭自动算法搜索。由于每个 DataLoader worker 是独立进程,光设主进程的种子不够,还要通过worker_init_fn给每个 worker 单独播种。这套配置做完,相同代码跑两次的验证集准确率差应该控制在 0.1% 以内。
6. 把分类器做厚:混淆矩阵、单文件推理与模型导出
模型训到 90% 之后,分类器这个“demo”还不算结束,真正要用起来,得能回答两个问题:它到底错在哪、它能不能脱离训练脚本独立运行。我自己每次在 CIFAR-10 上折腾新结构,都会固定把 best 和 last 两个 checkpoint 留好,再导出一个单独推理文件,方便后续对比和部署。
6.1 用混淆矩阵找最容易混的类别
import torch from sklearn.metrics import confusion_matrix, classification_report all_y, all_pred = [], [] model.load_state_dict(torch.load("cifar10_best.pth")["model"]) model.eval() with torch.no_grad(): for x, y in testloader: out = model(x.to(device)).argmax(1).cpu() all_y.extend(y.tolist()) all_pred.extend(out.tolist()) classes = ["airplane", "automobile", "bird", "cat", "deer", "dog", "frog", "horse", "ship", "truck"] print(classification_report(all_y, all_pred, target_names=classes)) cm = confusion_matrix(all_y, all_pred)混淆矩阵能明确告诉你模型把“汽车”和“卡车”混成了什么比例,也能看“猫”和“狗”的边界到底在哪。我见过最典型的结果是 bird 和 deer 互相串,因为 CIFAR-10 里这两类样本的背景都是大片绿色植被,模型最后很可能依赖的是背景色而不是目标轮廓。看到这种模式,你的下一步改进方向就不该是继续调学习率,而是去做更好的目标定位或更针对性的数据增强。
6.2 导出 TorchScript 并写一个单文件推理脚本
model.eval() traced = torch.jit.trace(model.cpu(), torch.randn(1, 3, 32, 32)) traced.save("cifar10_classifier.pt")用 TorchScript 导出的模型不依赖原来的类定义,也不依赖训练脚本里的字典结构,换机器时只要 torch 环境匹配就能直接跑。推理脚本保持最小化,只保留图像读取、预处理和输出:
import torch from PIL import Image from torchvision import transforms classes = ["airplane", "automobile", "bird", "cat", "deer", "dog", "frog", "horse", "ship", "truck"] transform = transforms.Compose([ transforms.Resize((32, 32)), transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)), ]) def infer(path): model = torch.jit.load("cifar10_classifier.pt", map_location="cpu") model.eval() img = transform(Image.open(path).convert("RGB")).unsqueeze(0) with torch.no_grad(): logits = model(img) idx = logits.argmax(1).item() prob = torch.softmax(logits, dim=1)[0, idx].item() print(f"{classes[idx]}: {prob:.4f}") if __name__ == "__main__": infer("test.png")推理时Resize((32,32))是为了兼容外部传进来的任意尺寸图片,如果输入本来就是 32×32,这段操作是幂等的。convert("RGB")统一通道数,避免 RGBA 图片带透明通道导致形状不匹配。TorchScript 导出后再转 ONNX 或 TensorRT 也只是多加一步,这条路径打通后,分类器才算真正落到了一个能交付的状态。
我自己每次都会用一张测试图走完“加载 best 模型、trace、独立脚本推理”三步,确认脚本不依赖 Jupyter 环境也不依赖训练时定义的变量。这比记一堆 API 更可靠,也让后续换网络结构时有了一条不变的基准线。希望帮到你。
本文还有配套的精品资源,点击获取