这周的任务从MNIST切到了CIFAR10,难度一下子上来了。之前用黑白28x28的手写数字练手,网络随便搭一搭,准确率都能冲到99%;换成CIFAR10之后,同样的一套思路直接失效,我第一次跑出来的准确率只有五成出头,相当于瞎蒙。后来把这周踩的坑、调参的思路、模型结构调整的整个过程捋了一遍,才把测试集准确率稳定在75%左右。这篇就把第P2周的完整过程写下来,包括数据集的脾气、环境准备、CNN结构推演、训练里的细节,以及精度上不去时的排查思路,给正在做同一任务的人一个参考。
1. CIFAR10的“脾气”:彩色图分类和灰度分类本质上是两码事
1.1 从MNIST到CIFAR10,变的不仅是通道数
MNIST的图是28x28灰度图,一个像素只有一个数值,0到255之间,代表亮度。CIFAR10是32x32的彩色图,每个像素有三个通道(R、G、B),所以一张图实际是32x32x3的数据。多出来的两个通道意味着什么?意味着信息量直接翻了三倍,但样本总量只有60000张,其中训练集50000张、测试集10000张,和MNIST的规模差不多。
当时我犯的第一个错误,就是把MNIST的网络原封不动搬过来,只改了输入通道从1到3,结果训练到第10轮准确率还卡在60%左右。原因很直接:CIFAR10里的事物是猫、狗、鸟、鹿这类自然物体,它们没有固定的位置、朝向和背景,同类物体的外观差异极大。比如两张都是猫的图,一张是白猫在沙发上,一张是黑猫在草丛里,像素层面的相似度可能比“猫”和“狗”两张图的相似度还低。灰度手写数字则简单得多——笔画就是笔画,背景干净,数字形态虽然有变化,但变化的自由度很小。
所以CIFAR10这个任务,本质上考验的是模型能不能在大量无关的视觉变化(背景、光照、拍摄角度)中,提取出真正区分类别的语义特征。这也就是为什么它总被当作深度学习入门里第一个“真正有点难度”的基准。
1.2 32x32这个分辨率,其实很微妙
32x32在今天的图像任务里算“低分辨率”,放到手机相册里连缩略图都算不上。但低分辨率反而让这个任务适合练习:它能把训练时间控制在可接受范围内,同时又保留了彩色图识别的核心难点。
我算过一笔账:一张32x32x3的图,如果直接展平成向量,是3072个数值。用全连接网络做分类,输入层就得有3072个神经元,第一层隐藏层如果是512个神经元,光这一层就有157万个参数。在5万张训练集上,这个参数量很容易过拟合,模型会把训练集背下来,测试集上表现一塌糊涂。而卷积神经网络通过权值共享,把参数量压缩了几个数量级,同时还能自动提取局部特征(边缘、纹理、形状),这正是彩色图识别需要的归纳偏置。所以CIFAR10入门练习的核心,其实是理解“为什么图像要用CNN而不是全连接”。
1.3 数据划分和类别分布,训练前要心里有数
CIFAR10自带的划分是50000张训练、10000张测试,而且是按类别均匀分配的:10个类别(飞机、汽车、鸟、猫、鹿、狗、青蛙、马、船、卡车),每个类别训练集5000张、测试集1000张。这个“均匀”很重要,意味着准确率可以直接作为衡量指标,不用考虑类别不平衡问题。
拿到数据后我第一件事是统计训练集和测试集的类别分布,确认确实是均匀的,然后检查有没有图片损坏、标签索引是否超出范围。torchvision的CIFAR10接口会做基本校验,但养成先看数据再训练的习惯,后面处理自己整理的数据集时能省不少事。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与数据加载:越简单的环节越容易卡人
2.1 依赖版本对齐:torch和torchvision必须匹配
这周的实验环境是Windows + CUDA 11.8 + Python 3.10。一开始我图省事,用pip直接装了最新的torch和torchvision,结果torchvision的CIFAR10下载接口报了一串ATen错误,仔细排查才发现是torch 2.1和torchvision 0.16之间的兼容问题。
这里给一个最稳妥的做法:不要单独装,用官方推荐的组合命令一次性装好。比如CUDA 11.8对应的组合是:
bash复制pip install torch==2.1.0+cu118 torchvision==0.16.0+cu118 --index-url https://download.pytorch.org/whl/cu118
没有NVIDIA显卡的也不用慌,CPU版本同样能跑这个任务,只是每轮训练会慢一些。我实测过,一个不到10万参数的CNN,在GTX 3060上跑一个epoch大概20秒,纯CPU大概需要3-5分钟。训练轮数一多,差距就出来了。
2.2 首次下载CIFAR10时的网络问题和验证方式
torchvision的datasets.CIFAR10第一次执行时会自动从服务器下载数据集压缩包,存到root指定的目录。这一步在国内经常遇到下载超时或速度极慢的问题,卡半小时都下不完。
我的办法是手动下载。先从数据集官网把压缩包下载到本地,放到root目录下,然后把download=True保留,torchvision检测到本地已有文件就不会再走网络了。注意压缩包的命名必须严格匹配,cifar-10-python.tar.gz,位置要放在root下的cifar-10-batches-py目录不存在的前提下,torchvision才能正确识别并解压。
数据加载的完整代码:
python复制import torch
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize(mean=(0.4914, 0.4822, 0.4465),
std=(0.2470, 0.2435, 0.2616))
])
train_dataset = datasets.CIFAR10(root='./data', train=True,
transform=transform, download=True)
test_dataset = datasets.CIFAR10(root='./data', train=False,
transform=transform, download=True)
train_loader = DataLoader(train_dataset, batch_size=64,
shuffle=True, num_workers=2)
test_loader = DataLoader(test_dataset, batch_size=64,
shuffle=False, num_workers=2)
2.3 Normalize的均值标准差:为什么不能用ImageNet那套
CIFAR10最容易忽略的细节是Normalize那组参数。很多人直接翻ImageNet的预处理:mean=(0.485, 0.456, 0.406),std=(0.229, 0.224, 0.225),效果其实也能跑,但不如用CIFAR10自己的统计值。
这两个数据集的像素分布差别很大。ImageNet是从互联网抓取的自然图像,色彩更丰富、整体偏亮;CIFAR10的物品种类有限,背景更简单。我算过CIFAR10在所有训练集上的RGB均值,大约是(0.4914, 0.4822, 0.4465),标准差是(0.2470, 0.2435, 0.2616)。用这套参数做标准化,相当于把每个通道的像素分布拉到接近均值为0、方差为1的标准正态分布,模型收敛更稳定。
如果你不想手算,也可以用一行代码从数据里统计:
python复制import numpy as np
from torchvision import datasets
train_data = datasets.CIFAR10(root='./data', train=True, download=True)
data = train_data.data / 255.0
mean = data.mean(axis=(0, 1, 2))
std = data.std(axis=(0, 1, 2))
print(mean, std)
注意train_data.data是numpy数组,/255.0是必须的,因为torchvision读取时原始像素值范围是0-255,要先缩放到0-1再去算统计量。
2.4 看一眼数据长什么样:别急着训练
加载完数据,我习惯先画一个小网格,看看图像内容、检查transform之后图片有没有变形。这一步不是为了仪式感,而是排查问题:比如图像是否翻转了通道顺序、Normalize之后像素范围是否正确、标签和图像内容是否对得上。
python复制import matplotlib.pyplot as plt
import torchvision
def imshow(img):
img = img / 2 + 0.5 # 反Normalize
npimg = img.numpy()
plt.imshow(np.transpose(npimg, (1, 2, 0)))
dataiter = iter(train_loader)
images, labels = next(dataiter)
imshow(torchvision.utils.make_grid(images[:8]))
plt.show()
print(' '.join(f'{train_dataset.classes[labels[j]]:>5}' for j in range(8)))
反Normalize那行img / 2 + 0.5是假设我们用的std=0.5, mean=0.5;如果用真实统计值,要改成img * std + mean。这个小细节经常有人看半天发现图是花的,其实就是索引顺序和反标准化没对上。
3. 搭建CNN:从输入通道到全连接维度的完整推演
3.1 三个卷积块加一个分类头:入门阶段最稳妥的结构
第P2周我用的是这个结构:输入尺寸从3x32x32开始,经过三个卷积模块,每个模块包含卷积层、BatchNorm、ReLU、最大池化,最后接一个自适应平均池化层和全连接分类头。
python复制import torch.nn as nn
import torch.nn.functional as F
class CIFAR10CNN(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
self.features = nn.Sequential(
# Block 1: 3 -> 32
nn.Conv2d(3, 32, kernel_size=3, padding=1),
nn.BatchNorm2d(32),
nn.ReLU(inplace=True),
nn.MaxPool2d(2, 2), # 32x32 -> 16x16
# Block 2: 32 -> 64
nn.Conv2d(32, 64, kernel_size=3, padding=1),
nn.BatchNorm2d(64),
nn.ReLU(inplace=True),
nn.MaxPool2d(2, 2), # 16x16 -> 8x8
# Block 3: 64 -> 128
nn.Conv2d(64, 128, kernel_size=3, padding=1),
nn.BatchNorm2d(128),
nn.ReLU(inplace=True),
nn.MaxPool2d(2, 2), # 8x8 -> 4x4
)
self.classifier = nn.Sequential(
nn.AdaptiveAvgPool2d(1),
nn.Flatten(),
nn.Linear(128, num_classes)
)
def forward(self, x):
x = self.features(x)
x = self.classifier(x)
return x
这个设计的核心思路很简单:逐步把32x32的图降采样到4x4,同时把通道数从3升到128。空间分辨率越来越低,但通道数越来越多,信息被“挤压”到通道维度里,最后用全局平均池化把所有空间位置聚合,再送入全连接分类。
3.2 padding=1的作用:保持空间尺寸不被卷积吞掉
第一次搭CNN的人经常对padding=1感到困惑。用3x3卷积核在32x32的图上滑动,如果不做padding,输出尺寸会变成30x30。每层都丢掉2个像素,三层下来就变成26x26,这还没算池化。这样不仅计算量变大,图像边缘信息也被过早丢弃。
padding=1是在输入四周各补一圈0,这样3x3卷积的输出尺寸和输入保持一致:32x32输入,输出仍然是32x32。尺寸不变意味着网络可以设计得更深,每一层都专注提取更抽象的特征,而不是被迫压缩尺寸。
尺寸变化可以用公式验证:output_size = (input_size + 2 * padding - kernel_size) / stride + 1。带入32 + 2 - 3 = 31,除以步长1再加1就是32。MaxPool的2x2、步长2会把尺寸减半,所以三个池化层让32x32 -> 16x16 -> 8x8 -> 4x4。
3.3 为什么用AdaptiveAvgPool2d而不是直接Flatten
第四个维度是4x4x128,如果直接展平,就是2048个特征,接全连接层需要2048x10个参数,也就是约2万个参数,对于这个小模型来说占比不小。更重要的是,直接展平的维度依赖输入尺寸:如果哪天你换了输入大小为64x64,这个全连接层就废了。
AdaptiveAvgPool2d(1)不管你输入是几乘几,都输出1x1的图,128个通道就变成128维向量,再送入Linear(128, 10)。这样模型对输入尺寸不再敏感,代码更灵活。全局平均池化还有轻微的正则化作用,因为它强制每个通道的全局信息参与分类,而不是让某些空间位置主导。
3.4 参数量的概念:先感受一下量级
把这个模型的总参数量打印出来:
python复制def count_parameters(model):
return sum(p.numel() for p in model.parameters() if p.requires_grad)
model = CIFAR10CNN()
print(f"Total trainable parameters: {count_parameters(model)}")
输出大概是5.7万左右。作为对比,VGG16有1.38亿参数,是这个小模型的2400倍。5万参数在CIFAR10这种5万训练样本的任务上,模型容量刚刚好——太小了欠拟合,大到百万级又容易过拟合。入门阶段在这个量级上做实验,单卡工作站几十秒就能跑一个epoch,进可攻退可守。
4. 训练细节:损失、优化器、batch size和训练轮数
4.1 损失函数为什么还是交叉熵
图像分类任务里,不管灰度还是彩色,损失函数基本都用交叉熵。它做的事情很直白:对模型输出的10个类别得分做Softmax,变成概率分布,然后计算真实标签这个概率的负对数。
用PyTorch实现时,nn.CrossEntropyLoss自带Softmax,所以模型最后一层不需要再单独加Softmax。如果加了Softmax再用CrossEntropyLoss,等于算了两次,训练会变慢且梯度不稳定。这是新手很容易犯的错:forward里最后一层写了F.softmax(x, dim=1),然后loss用的是CrossEntropyLoss,结果模型怎么训都收敛得很差。
4.2 优化器和学习率:Adam省心,但SGD调好了更强
第P2周我两种优化器都试了。Adam收敛快,默认学习率0.001就能跑出不错的精度,特别适合快速验证模型结构;但到了后期,Adam的精度天花板往往比调好的SGD低一些。SGD+Momentum需要手动调学习率,初始值一般取0.01或者0.1,配合余弦退火学习率,最终测试精度能再涨两三个点。
训练代码:
python复制import torch.optim as optim
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)
scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.5)
如果你用的是SGD,可以这样设置:
python复制optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9,
weight_decay=5e-4)
scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30)
4.3 训练轮数怎么定:不是越多越好
“深度学习 训练轮数 精度”是热搜里出现过的词,说明很多人盯着epoch数不知道怎么定。我的经验是:先设定一个保守的轮数,比如30轮,然后观察验证准确率的变化曲线。如果第30轮准确率还在明显上涨,就延长到50轮;如果第15轮就开始过拟合,反而要减少轮数、加强正则化。
CIFAR10在这个小模型上,30轮是个分界线。前10轮准确率爬升快,从25%涨到65%;10到20轮涨速放缓;20到30轮基本进入平台期,每轮只涨0.2%-0.5%。我实测Adam在30轮能达到74%-76%,SGD调好后能到78%-79%。
完整训练循环:
python复制def train_one_epoch(model, loader, criterion, optimizer, epoch):
model.train()
running_loss = 0.0
correct = 0
total = 0
for images, labels in 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() * images.size(0)
_, predicted = torch.max(outputs, 1)
total += labels.size(0)
correct += (predicted == labels).sum().item()
epoch_loss = running_loss / total
epoch_acc = correct / total
return epoch_loss, epoch_acc
4.4 训练日志怎么读:Loss下降但准确率不动是怎么回事
第二轮训练的时候我遇到过一个问题:loss在下降,但准确率几乎不涨。原因是刚开始模型输出比较均匀,置信度低,loss主要是“不太确定”的惩罚;随着训练推进,模型逐渐学出偏向,loss下降,但正确类别的概率还没明显拉开差距,准确率自然涨得慢。
遇到这种情况不用急着调模型,可以多观察几个epoch。真正的危险信号是:训练准确率持续上升,但测试准确率停滞甚至下降——这是过拟合的典型特征。这时候要看训练和测试的差距,而不是单看某一条曲线。
5. 精度上不去的排查:我在第P2周踩过的坑和修法
5.1 准确率卡在50%:先查数据预处理
第一次搭好网络训练,准确率从25%慢慢涨到50%就死活不动了。查了很多可能性,最后定位到Normalize算错了。当时我偷懒用了ImageNet的均值标准差,导致输入分布和模型初始化假设不匹配,训练后期梯度更新变得非常钝。
把Normalize参数换成CIFAR10自己的统计值后,同一套模型在30轮内涨到了73%。这个问题的排查方式很朴素:把训练日志里的loss和测试集上随机猜的准确率(10%)对比,如果loss一直在降但准确率卡在线性模型附近,先怀疑预处理。
5.2 过拟合的早期信号:训练和测试差距超过8%
这个模型在20轮左右会出现一个典型现象:训练准确率接近90%,测试准确率只有70%,差距接近20个百分点。这就是过拟合。此时模型开始记住训练集里的噪声和背景细节,而不是学习泛化的类别特征。
我的处理方案按优先级排列:
- 加数据增强:随机裁剪加水平翻转,这是对CIFAR10最有效的手段。
- 加Dropout:在全连接层之前加
nn.Dropout(0.5),随机丢弃一半神经元,强制模型不依赖单个路径。 - 加权重衰减:Adam里设置
weight_decay=1e-4,SGD里设置5e-4,限制权重增长速度。
三者组合之后,测试准确率能提升3到5个百分点。
5.3 数据增强:CIFAR10最有效的开胃菜
CIFAR10训练集只有5万张,对深度学习来说偏少。数据增强的作用是“凭空”增加训练样本的多样性:同一张猫的图片,随机裁剪出不同位置、水平翻转后,模型看到的是不同的输入,但标签都是“猫”。这样学到的特征对平移和镜像变化更不敏感。
我的增强策略:
python复制train_transform = 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不要加RandomCrop和RandomHorizontalFlip,只做ToTensor和Normalize。否则每张测试图都要预测多次,结果不稳定,测试准确率也会虚高。
5.4 一次有效的改进路径:BatchNorm和Dropout的位置
BatchNorm不要放在ReLU后面,要放在卷积之后、激活之前。因为BatchNorm是对卷积输出做标准化,让每个通道的分布稳定在均值为0、方差为1,然后ReLU再引入非线性。顺序反了效果会差一些。
Dropout的位置也很讲究。放在卷积层里会破坏空间特征,效果不佳;放在最后的全连接分类层之前效果最好。因为卷积层提取的特征已经足够鲁棒,全连接层才是最容易过拟合的地方。我的最终模型是在AdaptiveAvgPool2d之后、Linear之前加Dropout。
6. 测试集上的最终检验:正确率之外还要看错在哪
6.1 测试评估的正确姿势:model.eval()和torch.no_grad()不能少
训练结束后评估模型,有两点容易忽略。一是要调用model.eval(),把BatchNorm和Dropout切到推理模式;二是用torch.no_grad()包住评估代码,不启用梯度计算,省内存也加速。代码:
python复制def evaluate(model, loader, device):
model.eval()
correct = 0
total = 0
all_preds = []
all_labels = []
with torch.no_grad():
for images, labels in 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()
all_preds.extend(predicted.cpu().numpy())
all_labels.extend(labels.cpu().numpy())
print(f'Test Accuracy: {100 * correct / total:.2f}%')
return all_preds, all_labels
6.2 混淆矩阵:哪些类容易互相搞混
只看整体准确率会掩盖很多问题。我画了混淆矩阵之后发现,这模型最大的问题集中在“猫”和“狗”之间、“汽车”和“卡车”之间。前者是因为这两个类别的外观本来就高度相似(四条腿、毛、眼睛位置接近),后者是因为都是机动车,轮廓接近。
这个发现说明模型学到的主要是形状和纹理特征,而不是高级的语义概念。后续改进的方向是加深网络、增加感受野,或者引入注意力机制,让模型关注到猫和狗更细微的区别(耳朵形状、尾巴姿态等)。不过那已经是后续任务的内容了。
6.3 随机挑几张错的图看一眼
混淆矩阵之外,我建议再随机选8张预测错误的图,把“真实标签、预测标签、置信度”一起打印出来。看错图有两个好处:一是确认错误的合理性,比如把鹿看成马,那是真的像;二是发现数据集的标注噪声,CIFAR10里确实存在个别标签和内容不符的情况,看错图能让你心态不崩——有些图模型错了,人眼都救不了。
python复制import matplotlib.pyplot as plt
import numpy as np
def show_misclassified(model, loader, device, class_names, num_images=8):
model.eval()
misclassified = []
with torch.no_grad():
for images, labels in loader:
images, labels = images.to(device), labels.to(device)
outputs = model(images)
_, preds = torch.max(outputs, 1)
for i in range(len(labels)):
if preds[i] != labels[i]:
misclassified.append((images[i], labels[i], preds[i]))
if len(misclassified) >= num_images:
break
fig, axes = plt.subplots(2, 4, figsize=(12, 6))
for i, (img, true_label, pred_label) in enumerate(misclassified[:num_images]):
ax = axes[i // 4][i % 4]
img = img.cpu().numpy().transpose(1, 2, 0)
img = img * np.array([0.2470, 0.2435, 0.2616]) + np.array([0.4914, 0.4822, 0.4465])
img = np.clip(img, 0, 1)
ax.imshow(img)
ax.set_title(f'True: {class_names[true_label]}\nPred: {class_names[pred_label]}')
ax.axis('off')
plt.show()
第P2周做完CIFAR10,我对“图像分类为什么难”有了更具体的认识。真正的难点不在模型本身,而在于怎么让模型在有限的训练数据里学到足够泛化的特征。如果你这周也卡在精度上不去,我的建议是先别急着换更深的网络,把数据预处理、Normalize参数、增强策略、训练日志这几个环节逐个确认一遍,改完之后同一套小模型精度通常能再涨一截。这周把基础打扎实,下一周再上残差结构,你会发现理解起来顺很多。
