4. 经典模型训练实战:用PyTorch训练ResNet-18 on CIFAR-10
好,咱们进入第一个真正的实战环节。前面几章讲了理论、环境、数据,现在该让模型跑起来了。
我选ResNet-18搭配CIFAR-10,原因很简单:ResNet足够经典,CIFAR-10足够小。你可以在笔记本上完整跑一遍训练流程,不用等GPU等半天。这个组合,是我个人觉得入门模型移植最好的起点。
4.1 为什么是ResNet-18?
ResNet解决了深层网络退化问题。说白了,就是网络太深反而效果变差。ResNet通过残差连接(skip connection)让梯度能直接回传到浅层,解决了这个问题。
ResNet-18是ResNet家族里最轻量的版本。它只有18层,参数量约11M,在CIFAR-10上能达到约95%的Top-1准确率。这个精度对于后续的模型移植来说,已经足够有说服力了。
4.2 整体训练流程
先看下整个流程长什么样。我习惯在写代码前先画个图,把逻辑理清楚。
4.3 数据准备与增强
CIFAR-10有10个类别,每个类别6000张32x32的彩色图片。训练集50000张,测试集10000张。数据量不大,但直接拿来训练容易过拟合。
我建议做以下增强:
- 随机裁剪:把32x32的图片随机裁剪成28x28,再resize回32x32。相当于给图片加了点位移噪声。
- 随机水平翻转:一半概率左右翻转。对CIFAR-10这种物体居中的数据集很有效。
- 归一化:用CIFAR-10数据集的均值和标准差做标准化。均值是(0.4914, 0.4822, 0.4465),标准差是(0.2023, 0.1994, 0.2010)。
import torch
import torchvision
import torchvision.transforms as transforms
# 数据增强
transform_train = transforms.Compose([
transforms.RandomCrop(32, padding=4),
transforms.RandomHorizontalFlip(),
transforms.ToTensor(),
transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010))
])
transform_test = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010))
])
# 加载数据集
trainset = torchvision.datasets.CIFAR10(root='./data', train=True,
download=True, transform=transform_train)
trainloader = torch.utils.data.DataLoader(trainset, batch_size=128,
shuffle=True, num_workers=2)
testset = torchvision.datasets.CIFAR10(root='./data', train=False,
download=True, transform=transform_test)
testloader = torch.utils.data.DataLoader(testset, batch_size=100,
shuffle=False, num_workers=2)
4.4 模型定义
ResNet-18的结构我直接用了torchvision的实现。但要注意,CIFAR-10的图片是32x32,而ImageNet是224x224。所以第一个卷积层需要改一下。
原版ResNet-18第一个卷积层是7x7,stride=2。对32x32的图片来说,这太大了。我改成3x3,stride=1,padding=1。这样能保留更多空间信息。
import torch.nn as nn
import torchvision.models as models
# 加载预训练模型,修改第一层
model = models.resnet18(pretrained=False, num_classes=10)
# 修改第一层卷积
model.conv1 = nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1, bias=False)
# 去掉第一层之后的池化
model.maxpool = nn.Identity()
# 如果有GPU,把模型搬上去
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = model.to(device)
pretrained=False。因为CIFAR-10和ImageNet的图片尺寸、类别数都不一样,直接加载预训练权重反而可能起反作用。我建议从零训练。
4.5 损失函数与优化器
分类任务,损失函数用交叉熵。优化器我选SGD + Momentum,学习率用余弦退火调度。
为什么不用Adam?我个人经验是,SGD + Momentum在CIFAR-10上比Adam稳定,最终精度高0.5-1%。Adam收敛快,但容易陷入局部最优。
import torch.optim as optim
from torch.optim.lr_scheduler import CosineAnnealingLR
criterion = nn.CrossEntropyLoss()
optimizer = optim.SGD(model.parameters(), lr=0.1, momentum=0.9, weight_decay=5e-4)
scheduler = CosineAnnealingLR(optimizer, T_max=200)
4.6 训练循环
训练循环的代码其实很模板化。但有几个细节我踩过坑,分享给你:
- 梯度清零:每次反向传播前一定要
optimizer.zero_grad(),不然梯度会累加。 - 模型模式:训练时用
model.train(),验证时用model.eval()。BatchNorm和Dropout的行为会不一样。 - 不计算梯度:验证时用
torch.no_grad(),省显存也省时间。
def train_one_epoch(model, trainloader, criterion, optimizer, device):
model.train()
running_loss = 0.0
correct = 0
total = 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()
_, predicted = outputs.max(1)
total += labels.size(0)
correct += predicted.eq(labels).sum().item()
epoch_loss = running_loss / len(trainloader)
epoch_acc = 100. * correct / total
return epoch_loss, epoch_acc
def evaluate(model, testloader, criterion, device):
model.eval()
running_loss = 0.0
correct = 0
total = 0
with torch.no_grad():
for inputs, labels in testloader:
inputs, labels = inputs.to(device), labels.to(device)
outputs = model(inputs)
loss = criterion(outputs, labels)
running_loss += loss.item()
_, predicted = outputs.max(1)
total += labels.size(0)
correct += predicted.eq(labels).sum().item()
epoch_loss = running_loss / len(testloader)
epoch_acc = 100. * correct / total
return epoch_loss, epoch_acc
4.7 完整训练脚本
把上面这些拼起来,就是完整的训练脚本。我习惯把训练过程打印出来,方便观察。
num_epochs = 200
best_acc = 0.0
for epoch in range(num_epochs):
train_loss, train_acc = train_one_epoch(model, trainloader, criterion, optimizer, device)
test_loss, test_acc = evaluate(model, testloader, criterion, device)
scheduler.step()
print(f'Epoch {epoch+1:3d}/{num_epochs} | '
f'Train Loss: {train_loss:.4f} | Train Acc: {train_acc:.2f}% | '
f'Test Loss: {test_loss:.4f} | Test Acc: {test_acc:.2f}%')
# 保存最佳模型
if test_acc > best_acc:
best_acc = test_acc
torch.save(model.state_dict(), 'resnet18_cifar10_best.pth')
print(f'Best Test Accuracy: {best_acc:.2f}%')
4.8 训练结果分析
跑完200个epoch,大概需要2-3小时(看你的GPU)。我实测的结果如下:
| 指标 | 数值 |
|---|---|
| 最佳测试准确率 | 95.12% |
| 训练损失(最后) | 0.02 |
| 测试损失(最后) | 0.18 |
| 模型大小 | 44.7 MB |
| 训练时间 | 2小时15分(RTX 3060) |
4.9 避坑指南
训练过程中我遇到过几个坑,列出来你注意一下:
- 学习率太大:一开始用0.1没问题,但如果用Adam,建议从0.001开始。我曾经用Adam+0.1的学习率,loss直接炸了。
- Batch Size太小:Batch Size小于64时,BatchNorm的统计量不稳定,训练会震荡。我建议至少128。
- 忘记shuffle:训练集一定要shuffle,不然模型会学到数据的顺序。我刚开始学的时候犯过这个错,训练了50个epoch准确率只有60%。
- 验证集没做增强:验证集和测试集不要做随机增强,只做归一化。不然每次评估结果都不一样,没法对比。
4.10 小结
这一章我们完整跑通了ResNet-18在CIFAR-10上的训练。你拿到了一个95%准确率的模型,也学会了数据增强、模型定义、训练循环这些基本功。