ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

CIFAR-100分类实战:PyTorch数据管线、ResNet调参与避坑指南

CIFAR-100分类实战:PyTorch数据管线、ResNet调参与避坑指南 简介这是一份基于PyTorch构建的CIFAR-100图像分类完整代码包适合深度学习初学者、科研人员以及需要快速复现经典模型的开发者。资源围绕CIFAR-100数据集整合了多种主流网络结构与训练流程可用来对比不同算法的分类效果也可作为课程设计或论文实验的基础框架。压缩包共28个文件其中26个为Python脚本覆盖模型定义、数据加载、训练、测试、学习率查找等环节另含1个Markdown说明文档和1个gitignore配置文件整体仅43KB轻量易部署。模型实现包括ResNet、VGG、MobileNet、DenseNet、SENet、ShuffleNet、ResNeXt等多种结构基本可以满足常见的对比实验需求。目前已有1727人学习下载适合希望系统掌握图像分类算法实现细节的用户参考。1. 面对这份 CIFAR-100 分类代码先弄清它能给你什么如果你手里刚好有一份 pytorch-cifar100-master 的项目包大概率是这么来的PyTorch 环境搭好了CIFAR-10 也跑通了想换个更难的 100 类任务试试手。CIFAR-100 和 CIFAR-10 长得一样都是 32×32 彩色小图但类别从 10 变成 100每类只有 500 张训练图分类难度不是 10 倍而是量级跳跃。这份代码的卖点是“多种算法实现”同一套数据管线、同一个训练流程能切换 ResNet、VGG、DenseNet 等主干网络。它对三类人最有用想把 PyTorch 训练流程完整跑通的新手想对比不同模型在百类小图任务上表现的同学以及拿 CIFAR-100 当基线验证新 idea 的研究者。这篇拆解不打算吹这个项目多好用而是把它拆开数据怎么喂、算法怎么切、参数怎么定、坑在哪。按这个顺序读完至少能省掉两轮报错排查的循环。2. 把 CIFAR-100 数据管线搭稳加载、归一化与增强的 3 个关键选择2.1 CIFAR-100 的数据结构从数据集下载到 DataLoader 的完整链路CIFAR-100 在 torchvision 里是内置数据集下载后根目录下是 1 个 train 批和 1 个 test 批总共 60000 张 32×32 彩色图其中 50000 张训练、10000 张测试。100 个类别又按语义分成 20 个超级类coarse label每个类 500 张训练图。和 CIFAR-10 最大的区别就在这里数据量没变类别数涨了 10 倍每类平均样本数从 5000 掉到 500这对模型泛化能力的考验要大得多。用 torchvision 加载的代码很简单import torchvision import torchvision.transforms as transforms transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.5071, 0.4867, 0.4408), (0.2675, 0.2565, 0.2761)), ]) trainset torchvision.datasets.CIFAR100( root./data, trainTrue, downloadTrue, transformtransform ) testset torchvision.datasets.CIFAR100( root./data, trainFalse, downloadTrue, transformtransform ) trainloader torch.utils.data.DataLoader( trainset, batch_size128, shuffleTrue, num_workers4, pin_memoryTrue ) testloader torch.utils.data.DataLoader( testset, batch_size256, shuffleFalse, num_workers4, pin_memoryTrue )这里参数有几个值得说。downloadTrue 只在你第一次运行的时候生效第二次以后会自动跳过但如果你在离线环境里跑就得先在有网机器上把数据下好把 data 目录整个拷贝过去。num_workers4 是入门级配置Windows 上如果报 BrokenPipeError把 num_workers 改成 0 先跑通流程之后再慢慢加。pin_memoryTrue 在 GPU 训练时能减少 Host 到 Device 的拷贝时间CPU 训练时这个参数没有意义可以关掉。torchvision 返回的 dataset 里内置了两个属性训练时你几乎不会直接用到但调试时非常有用。trainset.classes 是 100 个类名的字符串列表trainset.class_to_idx 是类名到索引的映射。如果你后面要做混淆矩阵可视化和错误样本分析这两样东西绕不开。2.2 归一化参数别照搬 CIFAR-10均值方差与训练/测试不一致的坑这里有个很常见的翻车点网上大量 CIFAR-10 教程用的归一化参数是 (0.4914, 0.4822, 0.4465) 和 (0.2470, 0.2435, 0.2616)很多人拿到 CIFAR-100 直接复制粘贴。但 CIFAR-100 的 RGB 通道均值和方差是不同的正确的一组是MEAN (0.5071, 0.4867, 0.4408) STD (0.2675, 0.2565, 0.2761)这两个值怎么来的就是拿全部训练集图像逐通道算出来的。如果你偷懒用了 CIFAR-10 的参数模型不是完全不能训练但收敛会变慢尤其是前期 epoch 的 loss 下降曲线很难看。原因是归一化参数偏差会导致输入分布和 BN 层的初始化假设不一致网络需要花额外的 epoch 去自适应。训练集和测试集的 transform 必须分开定义这是一个容易被忽略的细节。测试集不参与数据增强如果测试集也用了 RandomCrop 和 RandomHorizontalFlip你评估出来的准确率就是“带随机性的”两次评估结果可能差 0.5 个点以上。通常的做法是训练用 Compose 里带增强的 transform测试只用 ToTensor Normalize。这也是为什么上面代码里我故意只写了一个 transform实际项目里你应该拆成 transform_train 和 transform_test 两个变量这是后来无数人踩过却又反复踩的坑。2.3 数据增强放多狠从 RandomCrop 到 Cutout 的取舍CIFAR-100 每类只有 500 张图数据增强不是可选项而是决定 ResNet18 最终是 70% 还是 75% 的关键因素。最保守的一组是 RandomCrop(32, padding4) 加 RandomHorizontalFlip这组增强几乎不会引入错误样本能稳定带来 23 个点的提升。如果想让指标再往上走常见做法是加 Cutout 或 RandAugment。transform_train transforms.Compose([ transforms.RandomCrop(32, padding4), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize(MEAN, STD), # Cutout 需要在 ToTensor 和 Normalize 之后做因为它是作用于 Tensor 的 transforms.RandomErasing(p0.5, scale(0.02, 0.33), ratio(0.3, 3.3)), ])注意 RandomErasing 这个接口它对应的是 Cutout 的思路但参数语义略有不同p0.5 表示每张图有一半概率执行 erasescale 控制被擦除区域占原图面积的比例ratio 控制擦除框的宽高比。这几个参数我试下来的经验是p 别超过 0.5scale 上限 0.33 就够了擦得太狠模型会欠拟合。还有一个细节RandomErasing 默认 value0在 Normalize 之后执行意味着擦除区域会变成归一化后的 0而不是黑色像素效果反而更好。顺序上它必须放在 Normalize 后面很多新手把它放在 ToTensor 之前会因为输入类型是 PIL Image 直接报错。RandAugment 是 torchvision 从 0.13 开始内置的用法是 transforms.RandAugment(num_ops2, magnitude9)。它的好处是不用手动调每种增强的幅度坏处是会让训练变慢而且在小数据集上偶发增强过度。我的习惯是先跑通 RandomCrop Flip 的基线确定模型没有 bug再叠加 RandomErasing 或 RandAugment每一步只改一个变量。这样指标掉了你知道是哪个环节的问题。2.4 先写一个最小验证脚本把脏数据挡在训练之前数据管线最容易出的问题不是下载失败而是标签和图像对不上、归一化参数填错、Transform 顺序写错。这些错误往往要等到训练几十个 epoch 之后才在指标上暴露排查成本极高。所以我一般会在训练之前先跑一个小脚本把第一个 batch 的输入和标签打印出来看一眼。import matplotlib.pyplot as plt import numpy as np dataiter iter(trainloader) images, labels next(dataiter) print(images shape:, images.shape) # torch.Size([128, 3, 32, 32]) print(labels shape:, labels.shape) # torch.Size([128]) print(labels unique:, torch.unique(labels)) # 一个 batch 里一般能看到 20-40 个不同类别 # 反归一化后显示 8 张图 inv_normalize transforms.Normalize( mean[-m / s for m, s in zip(MEAN, STD)], std[1 / s for s in STD] ) grid torchvision.utils.make_grid(images[:8], nrow4) grid inv_normalize(grid).permute(1, 2, 0).numpy() plt.imshow(np.clip(grid, 0, 1)) plt.axis(off) plt.show()这里的核心逻辑是反归一化不是把 Normalize 删掉而是用一个反向的 Normalize 把数据还原到 01 区间再显示。如果显示出来的图像颜色是正常的彩色而不是偏红偏蓝说明归一化参数写对了。labels unique 的输出如果都是 0 或 1说明你的 dataset 参数写错了比如 trainFalse 时拿到的其实只是某个固定子集。这个脚本总共不到 20 行但它能挡住后面至少两个小时的排错时间。3. 多种算法实现怎么选ResNet 为什么是默认起点VGG 和 DenseNet 差在哪3.1 主干网络的选型逻辑ResNet18、VGG16、DenseNet121 的对比“多种算法实现”落到代码层面就是不同的主干网络。常见的有三种ResNet18、VGG16、DenseNet121它们代表了三种设计思路。VGG16 是纯卷积堆叠结构最直观但参数多、计算量大在 32×32 小图上优势不明显。ResNet18 用残差连接解决了深层网络退化问题参数量适中是大多数人的默认起点。DenseNet121 用稠密连接让每层都能看到前面所有层的特征参数效率高但显存占用大、训练速度慢适合在 ResNet 已经跑通之后做第二轮实验。从实际指标看ResNet18 在 CIFAR-100 上从零训练配合前面的增强和 200 个 epoch准确率能到 75% 左右。VGG16 通常比 ResNet18 低一到两个点原因是 32×32 的输入对 VGG 这种大卷积核堆叠不友好。DenseNet121 能到 78% 上下但代价是训练时间几乎是 ResNet18 的一点五倍。如果你的机器只有一张普通显卡我建议先跑 ResNet18它是性价比最平衡的基线。选型还有一个容易忽略的问题torchvision 里 VGG16 和 DenseNet121 的默认输入尺寸是 224×224而 CIFAR-100 是 32×32。ResNet 系列通过替换第一层卷积可以适配VGG 的第一层是 Conv2d(3, 64, kernel_size3, padding1)刚好能接受 32×32 输入DenseNet 第一层是 Conv2d(3, 64, kernel_size7, stride2, padding3)同样需要替换。这就是为什么“下载即用”的预训练模型不能直接套在小尺寸图像上必须看网络第一层的卷积核配置。3.2 CIFAR-100 上的 ResNet 改造替换第一层卷积别让 32×32 输入直接撞上 7×7ResNet18 原始第一层是 7×7 卷积加 stride2紧跟着一个 3×3 的 maxpool。这个设计是针对 ImageNet 224×224 输入来的32×32 的图走一遍 7×7 stride2特征图直接缩到 16×16空间信息损失严重。标准的做法是删掉 maxpool并把第一层替换成 3×3 stride1 的卷积。import torch import torch.nn as nn import torchvision.models as models model models.resnet18(weightsNone, num_classes100) # 关键把 7x7 stride2 的 conv1 换成 3x3 stride1去掉 maxpool model.conv1 nn.Conv2d(3, 64, kernel_size3, stride1, padding1, biasFalse) model.maxpool nn.Identity() total_params sum(p.numel() for p in model.parameters()) print(fTotal params: {total_params / 1e6:.2f}M)这里有两个参数细节。第一padding1 保证输入输出尺寸都是 32×32空间信息不丢失。第二biasFalse 是因为后面接的是 BN 层BN 自带偏置项卷积层再加 bias 是冗余的这也是 PyTorch 官方实现里所有卷积层都默认 biasFalse 的原因。替换之后ResNet18 的参数量从约 11.2M 变成约 11.1M差异可以忽略但训练速度会有可感知的提升因为 32×32 输入走 3×3 卷积的 FLOPs 比走 7×7 stride2 少得多。如果你的代码包里的“多种算法”是拿 torchvision.models 直接实例化的改造第一层就是每次换网络都必须做的事。VGG16 可以不改DenseNet121 需要把第一个卷积从 7×7 stride2 改成 3×3 stride1并删掉后面的 maxpool。统一的做法是写一个 get_model(name, num_classes) 的工厂函数内部做适配这样实验脚本里就不用反复改模型定义。3.3 预训练权重的使用边界ImageNet 权重在 CIFAR-100 上到底值不值关于预训练权重这个话题在 CIFAR-100 上争议不小。用 ImageNet 预训练的 ResNet18 初始化再微调 CIFAR-100通常能比从零训练快很多收敛最终准确率也能高几个点。代价是 ImageNet 预训练模型的输入尺寸是 224×224第一层卷积学到的是针对大图的边缘和纹理特征迁移到 32×32 上效果会打折扣。而且一旦替换第一层卷积预训练权重里对应的那一层参数就没了。我的建议是分场景看。如果你的目标是发论文或者对比算法创新点从零训练更公平因为所有模型都在同一个起点上微调会掩盖模型本身的学习能力。如果你的目标是工程落地比如快速验证一个新想法用预训练权重是划算的因为收敛快、最终精度高。torchvision 里通过 weightsmodels.ResNet18_Weights.DEFAULT 加载预训练权重代码里如果用 weightsNone 就是从零初始化。建议在项目里把选择权显式化避免不同实验之间模型初始化不一致。另外提一句在 CIFAR-100 这个任务上从零训练的 ResNet18 配合 200 个 epoch 已经能到 75% 左右预训练微调可能到 78% 上下。为了这 3 个点你付出的代价是迁移适配的复杂度。如果只是跑通这个项目感受一下分类流程从零训练就够了没必要一开始就上预训练。3.4 用 PyTorch 配置 ResNet18从模型定义到参数统计的代码这里给一个可以直接放进项目的模型工厂函数def build_model(nameresnet18, num_classes100, pretrainedFalse): if name resnet18: if pretrained: model models.resnet18(weightsmodels.ResNet18_Weights.DEFAULT) model.fc nn.Linear(model.fc.in_features, num_classes) else: model models.resnet18(weightsNone, num_classesnum_classes) model.conv1 nn.Conv2d(3, 64, kernel_size3, stride1, padding1, biasFalse) model.maxpool nn.Identity() elif name vgg16: model models.vgg16(weightsNone, num_classesnum_classes) # VGG16 第一层卷积是 3x3 padding1天然适配 32x32不需改动 elif name densenet121: model models.densenet121(weightsNone, num_classesnum_classes) model.features.conv0 nn.Conv2d(3, 64, kernel_size3, stride1, padding1, biasFalse) # DenseNet 的 maxpool 在 features.transition0 之前通常需要删掉或换成 Identity model.features.pool0 nn.Identity() else: raise ValueError(fUnknown model: {name}) return model这段代码有几个细节。第一weights 参数是 torchvision 0.13 之后替代旧版 pretrainedTrue 的写法如果你用的是老版本需要把 weights 和 weightsNone 换成 pretrainedTrue 和 pretrainedFalse。第二DenseNet121 的第一个卷积在 model.features.conv0 下面但不同 torchvision 版本里它的池化层位置不一样用之前先打印 model 结构确认不要凭记忆改。第三改造完模型之后建议用一个随机输入 forward 一遍确认输出形状是 [batch, 100]这一步能挡掉大部分模型定义层面的错误。4. 训练全流程与关键参数把 100 类准确率跑上去的调参顺序4.1 超参数表学习率、batch size、weight decay 与 epoch 的搭配CIFAR-100 从零训练 ResNet18一套经过大量实践检验的默认超参如下参数推荐值说明epoch200少于 100 个 epoch 很难收敛到 70% 以上batch size128单卡 1080Ti/3090 都够用显存不足降为 64初始学习率0.1SGD 的标准起点Adam 系列要降到 1e-3动量 momentum0.9SGD 标配weight decay5e-4L2 正则化权重CIFAR-100 比 CIFAR-10 更需要它学习率调度CosineAnnealingT_max 设为 epoch 总数比 StepLR 稳定warmup epoch5前 5 个 epoch 从 0 线性升到 0.1weight_decay 就是常说的 L2 正则化在 PyTorch 里的落地形式也就是优化器构造时传的 weight_decay 参数。5e-4 这个值在 CIFAR-100 上是经过大量验证的调成 1e-3 可能会让模型欠拟合调成 1e-4 则正则化不足测试集准确率下降 0.51 个点。Adam 系的优化器不建议配 5e-4 的 weight decay因为 Adam 本身对权重衰减的处理方式与 SGD 不同容易导致训练不稳定。常见做法是直接用 AdamW 并设置 0.05 附近的 decay 系数。4.2 训练循环与 Checkpoint 管理最好模型与最后一个模型的区别训练循环本身不难难的是 checkpoint 怎么存。我见过太多人只保存最后一个 epoch 的模型结果最后 10 个 epoch 因为学习率过低过拟合测试准确率反而掉了 2 个点但“最后模型”已经把最优模型覆盖掉了。正确的做法是同时保存 val_acc 最高的模型和最后一个模型best_acc 0.0 for epoch in range(epochs): model.train() running_loss 0.0 for inputs, labels in trainloader: inputs, labels inputs.to(device), labels.to(device) optimizer.zero_grad() outputs model(inputs) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() scheduler.step() val_acc evaluate(model, testloader, device) if val_acc best_acc: best_acc val_acc torch.save({ epoch: epoch, model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), best_acc: best_acc, }, best_model.pth) torch.save(model.state_dict(), last_model.pth) print(fEpoch {epoch1}/{epochs}, Loss: {running_loss/len(trainloader):.4f}, Val Acc: {val_acc:.2f}%)这里的关键是 optimizer_state_dict 也要存。后续想从断点继续训练单有 model_state_dict 不够因为优化器的动量信息和学习率调度器的状态都丢了。恢复代码是 torch.load 之后分别 load_state_dict然后记得把 scheduler 的状态也恢复。如果只做推理只需要 model_state_dict不需要整个 checkpoint 文件。还有一个容易忽略的点模型在 train 模式和 eval 模式下行为不同。evaluate 函数里第一件事就是 model.eval()然后用 with torch.no_grad() 包住推理。如果不切 eval 模式BN 层会继续用当前 batch 的统计量做归一化推理结果会有轻微波动。CIFAR-100 这种小图分类任务上波动可能只有 0.1 个点但换到推理部署时会被放大。4.3 分类评估别只看准确率混淆矩阵与类别召回率怎么读CIFAR-100 的整体准确率是主要指标但它掩盖了很多细节。100 个类别里有“苹果”和“梨”这种易混淆的语义类别也有“公交车”和“卡车”这种视觉相似类别。只看 top-1 准确率你可能已经 75% 了但其实有十几个类别完全是瞎猜的。所以分类评估要做两层第一层是整体指标第二层是每个类别的准确率和召回率。from sklearn.metrics import confusion_matrix, classification_report import numpy as np all_preds, all_labels [], [] model.eval() with torch.no_grad(): for inputs, labels in testloader: inputs inputs.to(device) outputs model(inputs) _, preds torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.numpy()) cm confusion_matrix(all_labels, all_preds) # shape: [100, 100] report classification_report(all_labels, all_preds, target_namestrainset.classes, output_dictTrue) # 找出准确率最低的 10 个类别 class_acc {c: report[c][recall] for c in report if c not in (accuracy, macro avg, weighted avg)} worst sorted(class_acc.items(), keylambda x: x[1])[:10] for class_name, acc in worst: print(f{class_name}: {acc:.2%})这段代码的输出会很有价值。你多半会发现最难的是细粒度的动物类别比如“兔子”和“仓鼠”。如果某个类别准确率接近 0%先别急着加数据去查一下这个类别的原始图像本身是不是标注质量有问题。CIFAR-100 的标注质量总体不错但确实存在少量模糊样本。classification_report 需要 sklearn没有安装的话用 pip install scikit-learn 补上。4.4 训练脚本的最小骨架从数据加载到保存权重的完整代码把前面的内容串起来一个可以直接跑的最小训练脚本如下import torch import torch.nn as nn import torch.optim as optim import torchvision import torchvision.transforms as transforms MEAN (0.5071, 0.4867, 0.4408) STD (0.2675, 0.2565, 0.2761) DEVICE torch.device(cuda if torch.cuda.is_available() else cpu) transform_train transforms.Compose([ transforms.RandomCrop(32, padding4), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize(MEAN, STD), ]) transform_test transforms.Compose([ transforms.ToTensor(), transforms.Normalize(MEAN, STD), ]) trainset torchvision.datasets.CIFAR100(root./data, trainTrue, downloadTrue, transformtransform_train) testset torchvision.datasets.CIFAR100(root./data, trainFalse, downloadTrue, transformtransform_test) trainloader torch.utils.data.DataLoader(trainset, batch_size128, shuffleTrue, num_workers4, pin_memoryTrue) testloader torch.utils.data.DataLoader(testset, batch_size256, shuffleFalse, num_workers4, pin_memoryTrue) model torchvision.models.resnet18(weightsNone, num_classes100) model.conv1 nn.Conv2d(3, 64, kernel_size3, stride1, padding1, biasFalse) model.maxpool nn.Identity() model.to(DEVICE) criterion nn.CrossEntropyLoss() optimizer optim.SGD(model.parameters(), lr0.1, momentum0.9, weight_decay5e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max200)这个骨架里有几个参数值得强调。第一batch size128 和 num_workers4 要跟机器内存匹配如果只有 8G 内存num_workers2 或 4 都可能出现内存不足降到 2 先跑起来。第二CosineAnnealingLR 的 T_max 要等于总 epoch 数这样学习率才会在最后一个 epoch 降到接近 0。第三如果换用 Adamlearning rate 要从 0.1 降到 0.001否则第一个 epoch loss 就会爆炸。这段代码跑通之后你就有了一个符合“多种算法实现”要求的可扩展训练基础。5. CIFAR-100 训练的 5 个坑现象、原因与解决办法5.1 标签错位把 fine 标签当 coarse 用模型学到的是洗牌后的映射现象训练 loss 能正常下降但 val acc 一直徘徊在 1%2% 附近和随机猜测差不多。检查标签发现某个 batch 里 labels 的取值在 019 之间而不是 099。原因CIFAR-100 每个样本同时有两个标签fine label 是 100 个细类之一coarse label 是 20 个超类之一。torchvision 的 CIFAR100 数据集默认返回 fine labeltarget 字段但有一些从老代码改造过来的项目会自己写读取逻辑把 coarse 那列当成 target 用了。20 类标签喂给 100 类分类头模型当然学不出正确映射。解决检查数据加载代码里有没有手动指定 superclass 的逻辑。如果是直接使用 torchvision.datasets.CIFAR100默认 target 就是 fine label。想确认的话打印一次 trainset[0] 看看返回的 target 是否在 099 区间内。另一个相关坑是类名顺序torchvision 的 classes 列表是固定顺序的不要自己去重排。5.2 精度卡在 50% 附近学习率贪心导致 loss 震荡现象训练 loss 下降很快前 20 个 epoch 就降到 1.0 以下但之后 loss 开始周期性震荡val acc 卡在 50% 上下不再提升。原因初始学习率 0.1 配 SGD 是对的但如果缺少 warmup 阶段前几个 epoch 的梯度范数特别大容易让 BN 层的 running_mean 和 running_var 被污染后续怎么调都回不到正常状态。另外CosineAnnealing 如果 T_max 设置小于总 epoch 数学习率会在中途降到很低又跳回高位造成 loss 周期震荡和“卡在 50%”。解决加上 warmup最简单的方式是用 LinearLR 和 CosineAnnealingLR 串联成 SequentialLRfrom torch.optim.lr_scheduler import LinearLR, CosineAnnealingLR, SequentialLR warmup LinearLR(optimizer, start_factor0.01, end_factor1.0, total_iters5) cosine CosineAnnealingLR(optimizer, T_max200) scheduler SequentialLR(optimizer, schedulers[warmup, cosine], milestones[5])这里 start_factor0.01 表示第一个 epoch 的学习率是 0.001经过 5 个 epoch 线性升到 0.1milestones[5] 表示第 5 个 epoch 之后切换到 Cosine。这个组合我能稳定地把 ResNet18 在 CIFAR-100 上的表现从 50% 附近拉回 70% 以上属于性价比最高的修复。5.3 显存溢出batch size 与 DataLoader worker 的平衡现象训练到第 N 个 epoch 时突然报 CUDA out of memory之前都好好的。或者是一开始就 OOM。原因CIFAR-100 是 32×32 的小图单张图显存占用不大但 batch size128 加上 ResNet18 的激活值在 8G 显存的显卡上已经逼近极限。另一个更隐蔽的原因是 num_workers 开太大DataLoader 的预取机制会让 CPU 内存先爆掉然后连锁导致 GPU 显存分配失败。解决优先把 batch size 从 128 降到 64这对最终准确率影响很小大约 0.5 个点以内。如果还 OOM把 ResNet18 每个 stage 的通道数减半。还有一个有效手段是开 torch.cuda.amp 的混合精度训练显存占用直接减半代价是代码复杂度增加一点点。用 autocast 包住 forward 和 loss 计算loss.backward() 之前调用 scaler.scale(loss)。5.4 多卡训练时 BN 统计不一致SyncBN 的配置方式现象单卡训练 val acc 正常换成 DataParallel 多卡之后模型收敛变慢val acc 反而比单卡低 12 个点。原因DataParallel 默认不整合 BN 层的统计量每张卡各自用自己 batch 的样本计算 running_mean 和 running_var。当总 batch size 被均分到各卡之后单卡的 batch 可能只有 32BN 统计量方差变大模型就不稳定了。解决如果模型里有 BN 层多卡训练时用 torch.nn.SyncBatchNorm.convert_sync_batchnorm(model) 把普通 BN 转成同步 BN让所有卡共享均值方差。这个操作要在模型移到 Device 之前做。代价是 SyncBN 需要卡间通信训练速度会慢一些但换来的稳定性值得。另一个思路是保持一个较大的总 batch size避免单卡 batch 太小。5.5 复现性随机种子与 cuDNN 的确定性设置现象同一个训练脚本今天跑和明天跑val acc 差 1 个点以上。代码没改环境没变结果就是不一样。原因PyTorch 的训练流程里至少有三处随机源Python 的 random、NumPy 的 random、PyTorch 的 CUDA 随机数生成器。如果都不设置种子每次运行的模型初始化、数据打乱顺序都不一样。另外 cuDNN 为了性能会使用非确定性的卷积算法同样是浮点累加顺序不同结果就有细微差异。解决在训练脚本开头加一段固定的种子设置代码import random import numpy as np def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark Falsetorch.backends.cudnn.deterministic True 和 torch.backends.cudnn.benchmark False 这两行是必须的。benchmarkFalse 会牺牲一点性能换取确定性。对比实验一定要设置种子否则你无法判断精度变化来自模型改动还是随机噪声。6. 一个进阶技巧用快照集成把单模型精度再抬 12 个点6.1 快照集成的思路与保存策略当单模型已经稳定在某个水平时与其换更大的网络不如试试快照集成。思路很简单Cosine 学习率调度会让模型周期性沉到不同的局部最优在每个余弦周期的低点保存一次 checkpoint推理时把多个 checkpoint 的预测概率做平均或多数投票。这和训练多个独立模型不同快照集成几乎不增加训练时间只增加了保存 checkpoint 的数量。实现上把 CosineAnnealingLR 的 T_max 改成 50200 个 epoch 就是 4 个周期。在每个周期末尾保存一个模型最后推理时加载这 4 个模型对输出概率取平均。注意不要对 logits 直接平均后再 softmax而是先 softmax 再平均这样更符合概率平均的语义。6.2 推理验证与 ONNX 导出把集成模型落地def predict_ensemble(model_paths, model, dataloader, device): probs [] for path in model_paths: ckpt torch.load(path, map_locationdevice) model.load_state_dict(ckpt[model_state_dict]) model.to(device).eval() p [] with torch.no_grad(): for inputs, _ in dataloader: outputs torch.softmax(model(inputs.to(device)), dim1) p.append(outputs.cpu()) probs.append(torch.cat(p)) avg_probs torch.stack(probs).mean(dim0) return avg_probs.argmax(dim1) # 导出单个模型为 ONNX model.eval() dummy torch.randn(1, 3, 32, 32).to(device) torch.onnx.export(model, dummy, cifar100_resnet18.onnx, input_names[input], output_names[output], dynamic_axes{input: {0: batch}, output: {0: batch}})快照集成的提升幅度一般有 12 个点代价是推理时间变成原来的 N 倍。工程上如果对推理延迟敏感可以考虑只对最后两个周期做集成。ONNX 导出前记得把模型切成 eval 模式并且用真实的输入尺寸导出一遍确认输出形状是 [1, 100] 之后再做 dynamic batch 的优化。另外导出 ONNX 到部署环节之间还有精度对齐验证要做这个环节跑一遍离线数据比较耗时间但能避免部署后模型行为不一致。我自己的习惯是把所有训练记录和 checkpoint 按日期归档每次实验先看指标再看 loss 曲线先看单模型再看集成收益。如果你跑这个过程也会发现调参的成就感不在某个惊天动地的 trick而在把每个环节的细节都对齐之后模型稳定地给出预期分数。希望这些经验能帮你在 CIFAR-100 上少走几个弯路。本文还有配套的精品资源点击获取
返回列表