☰
PyTorch实战CIFAR-10:图像分类的数据管线与调参避坑指南
2026/10/12 21:22:26 网站建设 项目流程

简介: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 离线环境能跑,download=True在实训环境里经常卡在下载阶段。我的习惯是:先摸清数据集在磁盘上的真实布局,再决定加载方式,而不是直接抄一段 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_ok=True) 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, split="train", transform=None): 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, padding=4), 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, padding=4)和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", split="train", transform=train_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_size=128, shuffle=True, num_workers=2, pin_memory=True, drop_last=True) val_loader = DataLoader(val_ds, batch_size=256, shuffle=False, num_workers=2, pin_memory=True)

这里有个细节坑:random_split返回的是Subset对象,它自己没有transform属性,你要改的是val_ds.dataset.transform。很多人写val_ds.transform = val_transform,运行不报错,但验证集依然带着随机增强,导致验证分数虚高,只到提交时才暴露。

num_workers=2在 Kaggle Notebook 上已经够用,开太多每个 worker 会复制一份图片数据,内存容易爆。pin_memory=True在有 GPU 时能减少数据从内存搬运到显存的开销。drop_last=True是为了防止最后一个 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(weights=ResNet18_Weights.DEFAULT) model.conv1 = nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1, bias=False) 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 上要么收敛慢,要么特征图被压得太小。

weights=ResNet18_Weights.DEFAULT会加载在 ImageNet 上预训练的权重。虽然 CIFAR-10 和 ImageNet 类别差别很大,但预训练权重提供了更丰富的底层纹理和边缘特征,在小数据集上做微调比随机初始化稳定得多。fc换了之后,最后一层是随机初始化的,这符合迁移学习的常规做法。

损失函数和优化器:

import torch criterion = nn.CrossEntropyLoss() optimizer = torch.optim.AdamW(model.parameters(), lr=3e-3, weight_decay=1e-4)

AdamW对新手更友好,学习率不用像 SGD 那样反复试,3e-3在 CIFAR-10 上是个安全起点。weight_decay=1e-4提供一点 L2 正则,能压住小数据集上的过拟合。如果后面想冲更高的分,再换回SGD(momentum=0.9, lr=0.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_type="cuda", dtype=torch.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_factor=0.1, total_iters=warmup_steps), CosineAnnealingLR(optimizer, T_max=total_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(dim=1) 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(f"epoch={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 单卡建议 128
epochs30 ~ 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_location="cuda")) model.eval() test_dir = "/kaggle/input/cifar-10/test" ids, preds = [], [] with torch.no_grad(): for fname in sorted(os.listdir(test_dir), key=lambda 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(dim=1).item() ids.append(int(fname.split(".")[0])) preds.append(pred)

这里最容易翻车的是sorted的排序方式。如果直接sorted(os.listdir(test_dir)),字符串排序会把10.png排在2.png前面,预测结果整体错位到别的图片上,而你不会发现,因为每个图都有人预测。用key=lambda 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", index=False)

5. 避坑:CIFAR-10 实战里最常见的 5 个坑

这一章写给已经能跑通流程、但分数或稳定性有问题的读者。每一条都是真实出现过的问题,按「现象 → 原因 → 解决」写。

5.1 提交预测顺序错位:本地 0.93,提交 0.60 的隐形杀手

现象:训练和验证都很正常,提交分数却比验证集低一大截,甚至接近随机猜测。

原因:测试集文件名在遍历时用了默认字符串排序,10.png排到了2.png前面,模型输出的预测数组与图片 ID 整体错位。评分系统按 ID 对比真实标签,ID 对不上的行全部判错,分数直接崩掉。

解决:遍历时用key=lambda 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_workers=2或者直接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(seed=42): 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), dim=1) preds.append(out) avg = torch.stack(preds).mean(dim=0) return avg.argmax(dim=1)

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,建议从这套流程走起,跑通再优化。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询