1. 任务拆解:CIFAR10到底要解决什么
1.1 数据集基本盘
CIFAR10 可以说是计算机视觉入门必经的一个路口。它比 MNIST 更接近真实场景,但又不像 ImageNet 那样动辄上百 GB,刚好卡在“能跑得动”和“有一定挑战”的中间位置。这个数据集由 60000 张 32x32 的彩色图片组成,分成 10 个类别:飞机、汽车、鸟、猫、鹿、狗、青蛙、马、船、卡车。每个类别 6000 张,其中 50000 张用于训练,10000 张用于测试。
说到“彩色图片识别”,CIFAR10 和 MNIST 最本质的区别就在“彩色”这两个字上。MNIST 是单通道灰度图,每个像素只有一个数值,代表亮度;CIFAR10 是三通道 RGB 图,每个像素有三个值,分别代表红、绿、蓝三个通道的强度。这个差异直接决定了神经网络第一层卷积核的维度:输入通道数从 1 变成 3,模型需要同时学习三个通道之间的关联和每个通道内部的空间结构。
训练营把 CIFAR10 放在第 P2 周,我理解是有意为之的。如果你已经能在 MNIST 上跑通 LeNet,那 CIFAR10 就是验证“你是不是真的理解卷积网络”的试金石——同样的网络结构搬到彩色图,效果可能会断崖式下跌,这时候你就知道问题出在模型容量、数据增强还是训练策略上了。
1.2 彩色图片识别的难点在哪
32x32 的分辨率听起来很小,但放到真实场景里,这个尺寸恰恰是很多移动端模型的输入规格。CIFAR10 难在几个地方:
第一,图片分辨率低,细节信息有限。32x32 的尺寸意味着每个类别只有大约 1000 个像素点,想靠“数像素”的方式区分猫和狗几乎不可能,模型必须学到更高层的语义特征。这也是为什么简单的全连接网络在 CIFAR10 上效果很差的原因——它没有空间不变性,换个位置就认不出来了。
第二,类别之间有语义重叠。比如“猫”和“狗”、“汽车”和“卡车”,这些类别的低层特征(边缘、纹理)非常相似,区分它们需要模型捕捉到更抽象的形态差异和上下文信息。
第三,训练样本相对于模型容量来说并不充裕。50000 张图要学 10 类,平均每类只有 5000 张,如果模型参数太多,很容易就过拟合了。训练集准确率能做到 95% 以上,测试集却只有 70% 多,这是很多新手在 CIFAR10 上最先遇到的打击。
搞清楚这三点,后面的模型设计和训练策略才有方向。盲目堆卷积层数或者直接把某个预训练模型搬过来,都不是这个阶段最该做的事情。
2. 环境准备与数据加载
2.1 依赖安装与设备判断
CIFAR10 的实践代码量不大,核心依赖就三个:PyTorch、TorchVision、Matplotlib。TorchVision 不只是用来下载数据集的,它里面的torchvision.transforms是数据预处理的标准工具,后面做标准化、数据增强都靠它。
pip install torch torchvision matplotlib装完之后先确认一下设备,这一步很多人会跳过,但后面训练速度差几倍甚至十几倍,全看这里。
import torch device = torch.device("cuda" if torch.cuda.is_available() else "cpu") print(f"Using device: {device}") print(f"PyTorch version: {torch.__version__}")如果你和我一样用的是 Windows 笔记本,大概率会拿到cpu。这没问题,CIFAR10 用一个小型 CNN 在 CPU 上也能训练,就是慢一些。我的建议是:先跑通全流程,再去想提速的事情。训练营这一周的核心目标是理解模型结构和训练流程,不是为了刷分。
2.2 transform 怎么配比较稳
数据加载的第一步是定义 transform。CIFAR10 原始图片的像素范围是 0 到 255,值域跨度大、分布不均匀,直接喂给网络会导致梯度更新不稳定。标准做法是先转成 Tensor,再按通道做标准化。
CIFAR10 数据集的三个通道有官方统计好的均值和标准差,直接拿来用就行:
from torchvision import transforms transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)) ])这里Normalize的公式是(x - mean) / std,作用是把每个通道的数据拉到均值为 0、方差为 1 的分布。为什么要这样做?拿生活里的例子类比,如果你要比较两个人的身高和体重,肯定先各自标准化,不然体重数值大,会在距离计算里占据主导。神经网络里的梯度更新也类似,数值范围不一致会让优化过程变得很扭巴。
提示:
ToTensor()会自动把像素值从 0~255 缩放到 0~1,所以Normalize里的均值和标准差也是相对于 0~1 这个范围来设计的,不要拿去跟原始像素值做对比。
2.3 加载数据集的小细节
TorchVision 提供了 CIFAR10 的直接下载接口,如果网速不理想,它会卡在下载阶段。我的做法是手动下载数据集压缩包,放到./data目录下,再把download=False传进去。
用 DataLoader 加载训练集和测试集:
from torch.utils.data import DataLoader from torchvision import datasets train_dataset = datasets.CIFAR10( root="./data", train=True, download=False, transform=transform ) test_dataset = datasets.CIFAR10( root="./data", train=False, download=False, transform=transform ) batch_size = 64 train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True, num_workers=2) test_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=False, num_workers=2)shuffle=True只用在训练集,目的是打乱样本顺序,避免模型学到样本排列的伪规律。测试集不需要打乱。num_workers是数据加载的并行进程数,Windows 上设成 2 就够了,设太高反而可能报错。
第一次训练前我先抽了一批数据出来可视化,这一步强烈建议做。随机打印 20 张图,看到图片尺寸、颜色分布是否正常,比直接开训省心很多:
import matplotlib.pyplot as plt import numpy as np def imshow(img): img = img / 2 + 0.5 # 反标准化,把数据映射回 0~1 区间 npimg = img.numpy() plt.imshow(np.transpose(npimg, (1, 2, 0))) plt.show() dataiter = iter(train_loader) images, labels = next(dataiter) imshow(torchvision.utils.make_grid(images[:4]))这里有个容易踩的坑:标准化之后的数据已经不是 0~1 区间了,直接imshow会看到一片奇怪的色块。需要先做反标准化(img / 2 + 0.5是针对均值 0、方差 1 的近似还原),再显示。
3. 模型搭建:从零写一个 CNN
3.1 输入输出的 shape 变化
CIFAR10 的输入是(3, 32, 32),即 3 个通道、宽高各 32 像素。在动手写模型之前,先把张量 shape 的变化过程在脑子里过一遍,这是所有 CNN 编程的基础功。
一个标准的卷积模块包含三层:卷积层(提取特征)、池化层(降采样)、激活函数(加非线性)。以我最常用的小型网络为例:
import torch.nn as nn import torch.nn.functional as F class SimpleCNN(nn.Module): def __init__(self, num_classes=10): super(SimpleCNN, self).__init__() self.conv1 = nn.Conv2d(3, 32, kernel_size=3, padding=1) self.bn1 = nn.BatchNorm2d(32) self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1) self.bn2 = nn.BatchNorm2d(64) self.pool = nn.MaxPool2d(kernel_size=2, stride=2) self.fc1 = nn.Linear(64 * 8 * 8, 256) self.fc2 = nn.Linear(256, num_classes) self.dropout = nn.Dropout(0.5) def forward(self, x): x = self.pool(F.relu(self.bn1(self.conv1(x)))) x = self.pool(F.relu(self.bn2(self.conv2(x)))) x = x.view(x.size(0), -1) x = F.relu(self.fc1(x)) x = self.dropout(x) x = self.fc2(x) return x我按张量 shape 的变化把流程整理成了下表,第一次写模型的人对着这个表检查,很少会出错:
| 层 | 输入 shape | 输出 shape | 说明 |
|---|---|---|---|
| Conv2d(3, 32, 3, padding=1) | (N, 3, 32, 32) | (N, 32, 32, 32) | 卷积不改变空间尺寸 |
| BatchNorm2d + ReLU | (N, 32, 32, 32) | (N, 32, 32, 32) | 标准化+激活 |
| MaxPool2d(2) | (N, 32, 32, 32) | (N, 32, 16, 16) | 宽高减半 |
| Conv2d(32, 64, 3, padding=1) | (N, 32, 16, 16) | (N, 64, 16, 16) | 通道数翻倍 |
| BatchNorm2d + ReLU | (N, 64, 16, 16) | (N, 64, 16, 16) | 同上 |
| MaxPool2d(2) | (N, 64, 16, 16) | (N, 64, 8, 8) | 宽高再减半 |
| view 展平 | (N, 64, 8, 8) | (N, 6488) | 转为二维,送入全连接层 |
| Linear(6488, 256) | (N, 4096) | (N, 256) | 全连接 |
| Dropout + ReLU | (N, 256) | (N, 256) | 防止过拟合 |
| Linear(256, 10) | (N, 256) | (N, 10) | 输出类别分数 |
有个细节要特别说明:padding=1配合kernel_size=3,能保证卷积输出的宽高和输入一致,不需要手动计算输出尺寸。这个“same padding”的写法在搭建多层网络时非常省心。
3.2 各层设计思路与参数量
第一个卷积层输入通道是 3,因为图片是 RGB 三通道。输出通道设成 32,这是经验值——通道数太少了学不到足够的特征,太多了又容易过拟合。每个卷积核都是 3x3 的窗口,它在输入图上滑动时,同时看到三个通道的局部区域,输出一个融合了颜色和空间信息的特征值。
第二个卷积层把通道数从 32 加到 64。通道数逐渐增加是 CNN 设计的通用模式:浅层提取边缘、颜色等低级特征,通道数不需要太多;深层提取纹理、形状等高级特征,需要更多通道来容纳更丰富的语义信息。这就像看一张照片,先看轮廓和颜色,再看局部的细节。
全连接层前面的64 * 8 * 8是展平后的向量长度,由最后一层卷积的输出决定。修改了卷积层配置,这里也要跟着变,这是新手最容易忽略的报错点。如果你改了网络结构之后报维度不匹配的错误,十有八九是这里的数字忘了改。
激活函数我统一用了 ReLU。它有计算简单、缓解梯度消失的优点,虽然在 x<0 时梯度为 0,但对这个小网络来说完全够用。如果你有兴趣,换成 LeakyReLU 也可以,但别指望 CIFAR10 这个量级的数据集能体现出太大差别。
3.3 为什么加 BatchNorm 和 Dropout
BatchNorm2d是训练稳定性的关键。它的作用是在每个 batch 内,把特征图的数据分布拉回到均值为 0、方差为 1。这样做的直接好处是:即使前一层输出的数值范围在训练过程中发生了偏移,也能被及时拉回来,梯度更新不会因为数值过大或过小而抖动得厉害。
Dropout(0.5)的做法是训练时随机让一半的神经元不参与计算,测试时再用全部神经元。这相当于每轮都在训练一个不同的子网络,最后使用时相当于把一群子网络做了集成。用大白话说,就是防止网络“死记硬背”训练集——有人管这叫“断网式学习”,我觉得挺形象。
BatchNorm 和 Dropout 一起用没问题,但要记住:BatchNorm 在训练和测试时的行为不同(训练时用当前 batch 的统计量,测试时用累积的全局统计量),PyTorch 已经处理好了,不需要手动切换。Dropout 同理,model.eval()会自动关闭它。
4. 训练配置与完整流程
4.1 损失函数、优化器、batch size 的选择
分类任务的标准损失函数是交叉熵损失nn.CrossEntropyLoss()。它内部已经整合了 Softmax 和负对数似然损失,所以模型最后一层不需要手动加 Softmax——直接输出 10 个类别的原始分数就行。第一次写代码的同学经常会画蛇添足地在最后一层加 Softmax,导致损失函数算出来的值很奇怪,这里要特别注意。
优化器我选了 Adam,学习率1e-3。Adam 的优势是自适应调整每个参数的学习率,对新手非常友好,不需要像 SGD 那样手动调动量和学习率衰减策略。但 Adam 也有个问题:如果训练后期发现 loss 震荡剧烈,需要手动把学习率降下来。
batch size 设为 64。这个值不能太大也不能太小:太小的话梯度更新方向不稳定,训练过程噪声大;太大会超出显存,而且收敛速度反而变慢。64 在 CIFAR10 上是一个平衡点。
超参数配置:
| 参数 | 取值 | 选择原因 |
|---|---|---|
| batch size | 64 | 平衡梯度稳定性与内存占用 |
| 学习率 | 1e-3 | Adam 的默认推荐范围 |
| 损失函数 | CrossEntropyLoss | 多分类标准选择 |
| 优化器 | Adam | 自适应学习率,对新手友好 |
| 训练轮数 | 20 | CPU 环境可跑完,GPU 可适当增加 |
4.2 训练循环的代码骨架
整个训练流程可以抽象成三层循环:外层是 epoch(遍历整个数据集几遍),中层是 batch(每个 batch 更新一次参数),内层是前向传播、反向传播、参数更新三步。
import torch.optim as optim model = SimpleCNN(num_classes=10).to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3) epochs = 20 train_loss_list = [] train_acc_list = [] for epoch in range(epochs): model.train() running_loss = 0.0 correct = 0 total = 0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() epoch_loss = running_loss / len(train_loader) epoch_acc = correct / total train_loss_list.append(epoch_loss) train_acc_list.append(epoch_acc) print(f"Epoch [{epoch+1}/{epochs}], Loss: {epoch_loss:.4f}, Accuracy: {epoch_acc:.4f}")optimizer.zero_grad()这行代码不是可有可无的。PyTorch 的梯度是累加的,不清零的话,每个 batch 的梯度都会叠加上去,参数更新方向就会乱七八糟。我见过不少新手在这里漏掉一行,结果 loss 忽高忽低,怎么调都调不好。
torch.max(outputs, 1)返回每一行(每个样本)的最大值和对应的索引,索引就是模型预测的类别。
4.3 验证集评估的正确姿势
每个 epoch 结束后,建议跑一遍测试集,这样才能知道模型是不是真的在学有用的特征,而不是只在训练集上“刷分”。评估时要注意区分model.eval()和torch.no_grad()的作用。
def evaluate(model, test_loader, device): model.eval() correct = 0 total = 0 with torch.no_grad(): for images, labels in test_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() accuracy = correct / total return accuracy test_acc = evaluate(model, test_loader, device) print(f"Test Accuracy: {test_acc:.4f}")model.eval()切换 BatchNorm 和 Dropout 到推理模式,torch.no_grad()关闭梯度计算,省内存也省时间。这两个必须在评估时同时使用,缺一不可。如果不加model.eval(),BatchNorm 会用当前 batch 的统计量而不是全局统计量,批大小不同时会导致结果波动;如果不加torch.no_grad(),forward过程中会构建计算图,白白浪费大量内存。
第一轮训练结束,我的训练准确率在 60% 左右,测试准确率在 55% 左右。等到第 10 轮,训练准确率能到 90% 以上,测试准确率大约 78%。这个基线成绩说明模型架构没有问题,再往后提升就需要靠技巧了。
5. 提升识别准确率的实操技巧
5.1 数据增强:少样本情况下最有效的正则化
CIFAR10 只有 50000 张训练图片,对深度学习来说确实不算多。一个简单有效的应对手段是数据增强:在训练时对原始图片做随机变换,相当于用有限的样本生成更多样的训练数据。
常用的增强方式有三种:随机水平翻转、随机裁剪、色彩抖动。它们的原理都是“制造扰动”,让模型学会忽略与分类无关的变化。
from torchvision import transforms transform_train = 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)) ]) transform_test = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)) ])注意两个细节:测试集的 transform 不能加数据增强,因为评估时需要看到一个稳定的结果;RandomCrop(32, padding=4)的意思是先把图片四周各补 4 像素的 0,再随机裁回 32x32,这比直接旋转或缩放更符合照片的自然变化——物品在画面里的位置本来就是有偏移的。
加上数据增强后,同样训 20 轮,我的测试准确率从 78% 提到了 82% 左右。这个提升不依赖任何模型结构的改动,纯粹是数据层面的调整,性价比极高。
5.2 学习率的调整时机
固定学习率训练 20 轮后我发现一个现象:loss 在早期下降很快,但后面会进入一个平台期,上下震荡但不再明显下降。这是学习率过大的典型表现——参数在一个山谷附近来回跳跃,就是跳不进最低点。
解决办法是学习率衰减。最简单的做法是在训练到一半时手动把学习率调低一个数量级:
for epoch in range(epochs): if epoch == 10: for param_group in optimizer.param_groups: param_group["lr"] = 1e-4 print("Learning rate changed to 1e-4")更省事的方案是用 PyTorch 的ReduceLROnPlateau,它在 loss 连续几个 epoch 不下降时自动调低学习率。但我觉得在训练营这个阶段,手动调整反而更有感觉——你能清楚地看到不同学习率下 loss 的表现差异,这对培养调参直觉很重要。
5.3 我踩过的损失震荡和收敛慢问题
第一版代码我用的是原始像素直接输入,没有任何标准化,训练 loss 下降非常慢。后来检查才发现数据范围是 0~255,而模型权重初始化在 0~1 附近,梯度计算出来数值偏大,Adam 虽然能处理,但效率不高。加上标准化之后,loss 下降速度明显加快。
还有一次我误把shuffle=False用在训练集上,结果是模型每个 epoch 看到的样本顺序固定,训练曲线出现周期性的波动。排查了半天才发现是数据打乱的问题——训练数据不打乱,每个 batch 内的类别分布可能严重不均衡,导致 loss 一会高一会低。
如果你的模型光往前跑却不收敛,先检查三个地方:数据标准化做没做、训练集有没有 shuffle、学习率是不是设得太高。这三板斧能解决 80% 的收敛问题。
6. 常见问题与排查技巧实录
6.1 验证集一直卡着不动
训练集准确率涨得好好的,测试集准确率却一直卡在 50% 上下,首先想到的应该是代码问题而不是模型问题。
最常见的 bug 是数据预处理不一致:训练时用了数据增强,测试时忘了用标准化,或者标准化参数写错了。这会导致模型看到的测试图片分布和训练图片不一致,表现自然上不去。我建议把训练和测试的 transform 放在一起定义,对比着看,减少写错的可能。
另一个原因是标签错位。CIFAR10 的类别索引是固定的,如果在某个环节不小心做了重映射,模型学到的映射关系就和测试标签对不上了。检查方法很简单:随机采样几张验证集图片,打印真实标签和模型预测标签,肉眼看看对不对得上。
6.2 显存不够怎么办
很多人以为显存不够只能换显卡,其实有更便宜的解决办法。最直接的是减小 batch size,从 64 减到 32 或 16,观察 loss 和准确率的变化。这是最简单有效的方案,代价是训练速度略降。
还有一个思路是减小输入图片的尺寸,但 CIFAR10 本身就是 32x32,没有再缩小的空间了。如果用的是自定义数据集,这个方案才值得考虑。
6.3 训练集准确率很高、测试集准确率很低
这个现象叫过拟合,在 CIFAR10 上体现得很明显。训练准确率在 95% 以上、测试准确率只有 70% 多,说明模型把训练集的特征“背”了下来,而不是学到了泛化能力。
针对这个问题,我的实验顺序是:
- 先加数据增强,这是性价比最高的手段,通常能带来 3~5 个百分点的提升。
- 再加 Dropout,全连接层之间的 Dropout 最能发挥作用。
- 如果还过拟合,考虑减小模型容量——把卷积通道数从 64 减到 48,或减少全连接层的神经元数量。
- 最后考虑早停策略,即验证集准确率连续多个 epoch 不提升时提前结束训练。
这四步做完,CIFAR10 的测试准确率一般都能稳定到 85% 以上,训练准确率和测试准确率的差距也能从 20 多个百分点缩小到 10 个百分点左右。
6.4 做一张准确率变化曲线图
训练结束后,把每个 epoch 的训练准确率和测试准确率画在一张图上,是判断模型状态最直观的方式。两条线同步上升,说明模型在正常学习;训练线上升而测试线停滞或下降,说明过拟合了;两条线都在震荡,说明学习率偏高或数据有问题。
plt.plot(train_acc_list, label="Train Accuracy") plt.plot(test_acc_list, label="Test Accuracy") plt.xlabel("Epoch") plt.ylabel("Accuracy") plt.legend() plt.title("Training History") plt.show()这张图也是训练营周报里的必备内容,比单独贴一个最终准确率数字更有说服力,因为能完整地展示模型的训练过程和朋友踩坑痕迹。
第 P2 周做下来,我最大的感受是 CIFAR10 这个任务选得非常好——它刚好卡在“调参能力”和“对深度学习的理解”这两个维度的交界处。模型结构本身并不复杂,但要把准确率从 65% 提到 85% 以上,你会被迫去思考数据、优化、正则化之间如何配合,这比单纯调参学到的东西多得多。如果你也正在跑这个任务,建议记录每一轮实验的改动和结果,哪怕只是简单记在备忘录里,回头复盘的时候会发现价值很大。