资讯详情 PyTorch实战CIFAR-10:图像分类的数据管线与调参避坑指南
📅 2026/10/12 21:22:29
简介Kaggle CIFAR-10图像分类实战PyTorch版面向希望从代码入手学习深度学习的开发者与学生尤其适合具备Python基础、正在入门PyTorch的读者。该赛题为十个类别物体的经典图像分类任务资源提供一套可复用的参赛流程从CSV标签文件读取与理解数据经Python脚本完成模型定义与训练再到Jupyter Notebook中分步演示分析过程最终生成CSV格式预测结果用于提交帮助读者将理论代码和实际比赛衔接起来。压缩包共1017个文件核心是1006张CIFAR-10数据集图片png格式便于直接训练与可视化样本同时包含4个Python脚本、3个CSV标注/提交文件、2个Notebook笔记及2个pyc缓存文件整体仅2.34MB体积小巧、利于下载后快速复现环境。资源中还保留了checkpoint运行记录适合复盘模型迭代过程。目前已有180人学习下载适合在真实比赛场景中巩固图像分类与PyTorch实战技能也可作为深度学习教学案例参考。1. 从「本地验证 0.93」到「提交 0.90」CIFAR-10 用 PyTorch 到底该怎么打「本地验证集刷到 0.93提交到 Kaggle 的 CIFAR-10 图像分类比赛只拿了 0.90」这个场景在入门选手里非常常见。CIFAR-10 是 6 万张 32×32 彩色小图的十分类数据集也是很多人第一次用 PyTorch 完整走通 Kaggle 比赛流程的首选题目。这篇笔记不写综述只顺着一条能复现的路线往下走数据怎么读、模型怎么搭、训练参数怎么调、提交前要检查哪些东西。新手可以一节一节照做拿回一份正常的 baseline已经跑过几版的老手直接看第 4 章和第 5 章那几节基本都是踩坑踩出来的土办法。2. 把 CIFAR-10 喂给 PyTorch数据管线的三个关键配置数据管线是新手翻车的重灾区。torchvision 虽然自带 CIFAR10 接口但本地能跑不代表在 Kaggle 离线环境能跑downloadTrue在实训环境里经常卡在下载阶段。我的习惯是先摸清数据集在磁盘上的真实布局再决定加载方式而不是直接抄一段 Dataset 代码。2.1 先花两分钟 os.walk看清数据集目录再写加载代码import os base_path /kaggle/input/cifar-10 for root, dirs, files in os.walk(base_path): depth root[len(base_path):].count(os.sep) if depth 1: # 只看前两层避免刷屏 print(root, -, dirs:, dirs[:8], files:, files[:4])这段代码只做一件事打印前两层的目录结构。CIFAR-10 在 Kaggle 上有多种装配方式有的版本train/下按 0~9 类别分子目录有的版本图片平铺、标签单独放trainLabels.csv。先看布局能避免后面半小时的路径调试。如果发现是平铺加 CSV 的布局我一般会先用几行脚本把它转成按类别分目录的标准结构后面所有代码都按这个结构写。转换逻辑是读 CSV 建立文件名到类别名的映射再创建 0~9 子目录把图片移进去。这一步只执行一次后续 Dataset 就简单很多。import os, csv, shutil base /kaggle/input/cifar-10 src os.path.join(base, train) label_map {} with open(os.path.join(base, trainLabels.csv)) as f: for row in csv.reader(f): if row[0] ! id: label_map[row[0]] row[1] classes [airplane, automobile, bird, cat, deer, dog, frog, horse, ship, truck] for fname in os.listdir(src): fid fname.split(.)[0] cls_id classes.index(label_map[fid]) os.makedirs(os.path.join(src, str(cls_id)), exist_okTrue) shutil.move(os.path.join(src, fname), os.path.join(src, str(cls_id), fname))这段脚本的classes列表顺序必须和提交要求的类别顺序一致好在 CIFAR-10 的类别名单是公开固定的一份。shutil.move在同一分区内是移动不是复制跑起来很快。如果你拿到的目录已经按 0~9 分好直接跳过这段进入下一步。2.2 自定义 Dataset 类几行代码少踩一个环境坑from PIL import Image from torch.utils.data import Dataset class CIFAR10FromDir(Dataset): 读取按类别分子目录的 CIFAR-10 图片返回 (img_tensor, label)。 def __init__(self, root, splittrain, transformNone): self.transform transform self.samples [] base os.path.join(root, split) for cls_id in sorted(os.listdir(base)): if not cls_id.isdigit(): continue cls_dir os.path.join(base, cls_id) for fname in os.listdir(cls_dir): self.samples.append((os.path.join(cls_dir, fname), int(cls_id))) def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label self.samples[idx] img Image.open(path).convert(RGB) if self.transform: img self.transform(img) return img, label这个类假设目录结构是train/0/xxx.png这种形式。函数逻辑很直接构造函数扫描每个类别子目录把图片路径和类别 ID 组成一个样本列表__getitem__负责打开图片、应用 transform、返回张量和标签。注意convert(RGB)不能省有的 PNG 是单通道或带 alpha 通道不转换会让训练时通道数对不上。为什么不直接用torchvision.datasets.CIFAR10因为那个接口在root下找的是cifar-10-batches-py二进制文件不是图片目录Kaggle 环境里还需要先下载或解压。自定义类的好处是你对数据流完全可控换到任何图片分类比赛只改类别数和路径就能复用。2.3 训练集与验证集的 transform先 ToTensor 再 Normalize顺序不能反from torchvision import transforms train_transform transforms.Compose([ transforms.RandomCrop(32, padding4), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)), ]) val_transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)), ])CIFAR-10 的经典增强就两个RandomCrop(32, padding4)和RandomHorizontalFlip()。前者先把图四周补 4 像素再随机裁回 32×32相当于给模型看更多局部后者对绝大多数自然图像有效因为左右翻转不改变语义。验证集不要做任何随机增强只做 ToTensor 和归一化。ToTensor 放在 Normalize 之前是硬性顺序。ToTensor 会把 PIL 图从 0~255 的整数变成 0.0~1.0 的张量Normalize 是对张量做标准化如果顺序反了代码会在运行时报错说输入类型不对。这里的均值方差是 CIFAR-10 数据集的统计值不是 ImageNet 的两者混用会让训练收敛明显变慢。下面是小图上常用的增强参数参考参数建议值说明RandomCrop.padding432×32 小图 padding 太大没意义RandomHorizontalFlip.p0.5默认概率RandomRotation不建议引入边缘空白收益有限2.4 DataLoader 参数batch_size、num_workers、pin_memory 的推荐值from torch.utils.data import DataLoader, random_split full_ds CIFAR10FromDir(/kaggle/input/cifar-10, splittrain, transformtrain_transform) train_ds, val_ds random_split(full_ds, [45000, 5000]) val_ds.dataset.transform val_transform # 改底层 dataset 的 transform train_loader DataLoader(train_ds, batch_size128, shuffleTrue, num_workers2, pin_memoryTrue, drop_lastTrue) val_loader DataLoader(val_ds, batch_size256, shuffleFalse, num_workers2, pin_memoryTrue)这里有个细节坑random_split返回的是Subset对象它自己没有transform属性你要改的是val_ds.dataset.transform。很多人写val_ds.transform val_transform运行不报错但验证集依然带着随机增强导致验证分数虚高只到提交时才暴露。num_workers2在 Kaggle Notebook 上已经够用开太多每个 worker 会复制一份图片数据内存容易爆。pin_memoryTrue在有 GPU 时能减少数据从内存搬运到显存的开销。drop_lastTrue是为了防止最后一个 batch 数量太小影响 BN 层的统计量和梯度估计CIFAR-10 这种数据量下建议保留。3. 模型搭建与训练循环从 ResNet18 到一次完整的 epoch数据管线就绪后下一步是模型。很多教程直接丢一个resnet18()就开训但 CIFAR-10 的输入是 32×32直接套用为 ImageNet 设计的 ResNet18 会有一个明显的结构问题需要先做适配。3.1 为什么选 ResNet18而不是 ResNet50 或更重的网络CIFAR-10 只有 6 万张训练图每张还是 32×32 的小图。ResNet18 在这个规模上属于「深得动、不容易过拟合」的临界点训练速度快单卡就能跑完。ResNet50 或更重的网络在同样轮数下训练时间翻倍验证集分数往往提升不到 0.5 个点还要花更多时间调正则化对入门来说性价比很低。模型选型还有一个隐藏考量32×32 的分辨率决定了第一层卷积不能太大。ImageNet 的 ResNet 第一层是 7×7 stride 2这么做是为了把 224×224 快速降采样但 32×32 的图经过 7×7 stride 2 只剩 16×16再过 maxpool 只剩 8×8信息损失太严重。所以必须替换第一层。3.2 实例化 ResNet18替换 conv1 和 maxpool把输出改成 10 类import torch.nn as nn from torchvision.models import resnet18, ResNet18_Weights model resnet18(weightsResNet18_Weights.DEFAULT) model.conv1 nn.Conv2d(3, 64, kernel_size3, stride1, padding1, biasFalse) model.maxpool nn.Identity() model.fc nn.Linear(model.fc.in_features, 10)替换逻辑是固定的conv1从 7×7 stride 2 改成 3×3 stride 1 padding 1保持 32×32 分辨率maxpool直接置为nn.Identity()不再做降采样最后一层全连接改成 10 分类。这三个改动缺一不可否则模型在 CIFAR-10 上要么收敛慢要么特征图被压得太小。weightsResNet18_Weights.DEFAULT会加载在 ImageNet 上预训练的权重。虽然 CIFAR-10 和 ImageNet 类别差别很大但预训练权重提供了更丰富的底层纹理和边缘特征在小数据集上做微调比随机初始化稳定得多。fc换了之后最后一层是随机初始化的这符合迁移学习的常规做法。损失函数和优化器import torch criterion nn.CrossEntropyLoss() optimizer torch.optim.AdamW(model.parameters(), lr3e-3, weight_decay1e-4)AdamW对新手更友好学习率不用像 SGD 那样反复试3e-3在 CIFAR-10 上是个安全起点。weight_decay1e-4提供一点 L2 正则能压住小数据集上的过拟合。如果后面想冲更高的分再换回SGD(momentum0.9, lr0.1)配合余弦退火那条路调参成本高一些。3.3 训练循环AMP 混合精度的最少必要写法scaler torch.cuda.amp.GradScaler() for epoch in range(epochs): model.train() train_loss 0.0 for images, labels in train_loader: images, labels images.cuda(), labels.cuda() optimizer.zero_grad() with torch.autocast(device_typecuda, dtypetorch.float16): outputs model(images) loss criterion(outputs, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() train_loss loss.item() * images.size(0)这段代码的重点是混合精度的位置。torch.autocast只包 forward 和 loss 计算backward 和 optimizer 的step由GradScaler接管它会把梯度先放大再缩小防止 float16 下的小梯度直接变成 0。如果不做缩放训练后期 loss 会卡在一个平台下不去。train_loss累加方式用的是loss.item() * images.size(0)最后再除以总样本数得到 epoch 平均损失这样不同 batch 大小之间才有可比性。每跑完一个 epoch别急着继续先跑一遍验证集把模型选出来。验证和保存的完整写法在第 4 章。4. 调参与验证把训练过程盯明白分数才不会骗人模型能跑起来之后决定分数上限的是训练策略和验证习惯。这一章给出一套我常用的配置以及为什么这样配。4.1 学习率策略Warmup Cosine 比固定学习率稳定from torch.optim.lr_scheduler import SequentialLR, LinearLR, CosineAnnealingLR total_steps len(train_loader) * epochs warmup_steps int(total_steps * 0.05) scheduler SequentialLR( optimizer, schedulers[ LinearLR(optimizer, start_factor0.1, total_iterswarmup_steps), CosineAnnealingLR(optimizer, T_maxtotal_steps - warmup_steps), ], milestones[warmup_steps], )SequentialLR把两个 schedule 按milestones拼接前 5% 的 step 用线性 warmup 从 10% 的基础学习率爬到设定值之后进入余弦退火学习率沿余弦曲线逐步降到接近 0。warmup 的作用是避免训练一开始就用大学习率冲击预训练权重余弦退火则让模型在后期走小学习率精调。使用时有两点要注意第一这个 scheduler 必须在每个 step 后调scheduler.step()不是每个 epoch第二total_steps必须等于len(train_loader) * epochs否则余弦周期和实际训练步数对不上退火效果会乱。训练循环里记得在optimizer.step()之后补一行scheduler.step()。4.2 验证与模型保存只保留 best_acc 对应的权重best_acc 0.0 for epoch in range(epochs): # ... 训练代码 ... model.eval() correct 0 with torch.no_grad(): for images, labels in val_loader: images, labels images.cuda(), labels.cuda() outputs model(images) preds outputs.argmax(dim1) correct (preds labels).sum().item() acc correct / len(val_ds) if acc best_acc: best_acc acc torch.save(model.state_dict(), cifar10_best.pth) print(fepoch{epoch} acc{acc:.4f} best{best_acc:.4f})不要在训练结束后统一挑权重也不要默认最后一个 epoch 就是最好的。余弦退火把学习率降到很低之后模型会进入一个比较稳定的精调区验证集准确率可能在最后几个 epoch 才刷新但偶尔也会在中间某个 epoch 冲到最高后面反而因为过拟合回落。按 best_acc 保存是保险做法。验证时一定要先model.eval()再把验证代码包在torch.no_grad()里。eval()会切换 BN 和 Dropout 的行为no_grad()关闭自动求导省显存也省时间。如果这两行没写训练时看到的验证分数会非常接近训练分数失去验证意义。训练参数速查表参数推荐区间说明base_lr2e-3 ~ 3e-3AdamW 起步区间batch_size64 ~ 256Kaggle 单卡建议 128epochs30 ~ 6030 轮能看到趋势60 轮收敛weight_decay1e-4 ~ 5e-4防止小数据集过拟合4.3 提交前的推理脚本预测顺序必须跟测试集原始顺序一致import torch, os from PIL import Image model.load_state_dict(torch.load(cifar10_best.pth, map_locationcuda)) model.eval() test_dir /kaggle/input/cifar-10/test ids, preds [], [] with torch.no_grad(): for fname in sorted(os.listdir(test_dir), keylambda x: int(x.split(.)[0])): img Image.open(os.path.join(test_dir, fname)).convert(RGB) img val_transform(img).unsqueeze(0).cuda() out model(img) pred out.argmax(dim1).item() ids.append(int(fname.split(.)[0])) preds.append(pred)这里最容易翻车的是sorted的排序方式。如果直接sorted(os.listdir(test_dir))字符串排序会把10.png排在2.png前面预测结果整体错位到别的图片上而你不会发现因为每个图都有人预测。用keylambda x: int(x.split(.)[0])按数字 ID 排序每一步都带上原始id最后提交前再检查ids sorted(ids)。推理时用的 transform 必须和验证集一致不要带增强。如果验证时用 RandomCrop推理时也带会让同一张图不同 crop 得到不同预测结果不稳定。提交 CSV 的格式按比赛页面要求来有的要求类别 ID有的要求类别名把preds做一次映射再写文件。import pandas as pd sub pd.DataFrame({id: ids, label: preds}) sub.to_csv(submission.csv, indexFalse)5. 避坑CIFAR-10 实战里最常见的 5 个坑这一章写给已经能跑通流程、但分数或稳定性有问题的读者。每一条都是真实出现过的问题按「现象 → 原因 → 解决」写。5.1 提交预测顺序错位本地 0.93提交 0.60 的隐形杀手现象训练和验证都很正常提交分数却比验证集低一大截甚至接近随机猜测。原因测试集文件名在遍历时用了默认字符串排序10.png排到了2.png前面模型输出的预测数组与图片 ID 整体错位。评分系统按 ID 对比真实标签ID 对不上的行全部判错分数直接崩掉。解决遍历时用keylambda x: int(x.split(.)[0])做数字排序并且在生成提交文件前打印ids[:10]和ids[-10:]看一眼是否连续递增。这个检查只需要十秒却是我见过最昂贵的低级错误。5.2 ToTensor 放在 Normalize 之后验证集分数虚高现象代码能正常跑训练 loss 也能降但验证集准确率比预期高提交后分数反而不如验证时好看。原因transforms.Compose里把Normalize写在了ToTensor前面。此时Normalize拿到的输入是 PIL 图像torchvision会先把它隐式转成 Tensor 再标准化顺序虽然没报错但实际执行的标准化时机和预期不同。验证集与训练集 transform 不一致时模型看到的数据分布漂移验证分数失去参考价值。解决坚持「ToTensor 在前Normalize 在后」的顺序。把验证集 transform 单独打印出来检查一遍再开始长期训练。5.3 归一化 mean/std 记错训练 loss 降得慢以为自己代码有 bug现象训练时 loss 从一开始就很高下降也明显比参考代码慢验证集准确率卡在 0.7 附近怎么调都上不去。原因把 ImageNet 的均值和方差抄到了 CIFAR-10 的 Normalize 里。ImageNet 的 mean 是(0.485, 0.456, 0.406)CIFAR-10 是(0.4914, 0.4822, 0.4465)前一套参数对 CIFAR-10 的小图不匹配相当于给所有输入加了一个固定的偏移量模型需要花更多轮次去抵消这个偏移。解决用本文 2.3 节给的 CIFAR-10 标准统计值。最稳的做法是从训练集自己统计一遍把图片转成 Tensor 后逐通道算mean和std写入 transform。这样哪怕换比赛换数据集也不会错。5.4 num_workers 拉满导致内存崩溃Kaggle Notebook 直接重启现象训练刚开始或者第一个 epoch 中途Kernel 突然重启之前的进度全部丢失。原因num_workers设置过大比如 8 或 16。每个 worker 会预加载一批图片并复制内存数据增强本身的随机裁剪、翻转也要额外开销Kaggle Notebook 的内存配额扛不住。解决num_workers2或者直接0。0表示用主进程加载数据虽然慢一点但最稳2在大多数环境里是性能与内存的平衡点。同理prefetch_factor别乱加默认值在 CIFAR-10 场景已经够用。5.5 随机种子没固定同一份代码两次训练结果差 0.5 个点现象参数完全没动只是重新跑了一次训练验证集准确率差了几个百分点你开始怀疑之前的结果是运气。原因PyTorch、NumPy、Python 的 random 三套随机源都没有固定种子数据加载顺序、权重初始化、Dropout 行为都不一样结果自然不同。解决在训练脚本最开头统一固定三套随机源import random, numpy as np, torch def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed)这段代码放在 import 之后、任何随机操作之前。固定之后至少在相同环境下两次训练的结果是基本可复现的。如果你改了一个参数分数有变化才能确定是参数引起的而不是随机波动。6. 想冲榜再加分的两个习惯TTA、提交前检查验证集稳定之后想再往上走先别急着换大网络有两个成本很低的操作可以先做TTA 和提交前检查清单。6.1 TTA推理时多看一眼水平翻转白拿 0.1~0.3 个点import torch.nn.functional as F def predict_with_tta(model, img_tensor): model.eval() preds [] with torch.no_grad(): for flipped in [img_tensor, torch.flip(img_tensor, dims[3])]: out F.softmax(model(flipped), dim1) preds.append(out) avg torch.stack(preds).mean(dim0) return avg.argmax(dim1)torch.flip(img_tensor, dims[3])是对一张图做水平翻转dims[3]是因为 4D 张量形状是(B, C, H, W)最后一维是宽度方向。TTA 的原理很简单训练时模型见过水平翻转后的图推理时把原图和翻转图都送进模型对 softmax 概率求平均相当于让两个视角互相投票能减少单次预测的方差。TTA 在 CIFAR-10 上通常带来稳定的小幅提升但推理时间翻倍。提交时间紧张时先用原图跑一版确保有分数再跑 TTA 版本覆盖提交。我习惯把predict_with_tta单独放进一个inference.py和训练脚本分离。这样每次提交前只需要检查一个文件不用在训练脚本里改来改去。提交前检查单只有三条第一测试集文件名按数字 ID 排序ids单调递增第二验证集 transform 与推理 transform 完全一致第三提交文件的行数和测试集图片数一致缺一行整个文件作废。这三条我在 CIFAR-10 之后一直保留后来打其他图像分类比赛改的只是数据路径和类别数检查习惯原封不动。如果你第一次用 PyTorch 打 Kaggle建议从这套流程走起跑通再优化。希望帮到你。本文还有配套的精品资源点击获取