这篇记录一次完整的图像分类练习:用 PyTorch 定义一个 LeNet 风格的网络,在 CIFAR-10 上训练,再加载权重预测一张图片。代码按 2022 年的教程环境整理,但核心 API 现在仍然通用。
CIFAR-10 的图片是 32 x 32 的 RGB 图像,共有 10 个类别。它比 MNIST 稍微复杂一些,正好可以用来观察彩色图像经过两次卷积和池化后,尺寸是怎样变化的。
参考资料:
目录结构
1 2 3 4 5
| project/ ├── model.py ├── train.py ├── predict.py └── data/
|
第一次运行 train.py 时,CIFAR-10 会下载到 data 目录。Windows 下先把 num_workers 设为 0,确认能运行后再考虑增加数据加载进程。
定义模型:model.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| import torch import torch.nn as nn import torch.nn.functional as F
class LeNet(nn.Module): def __init__(self, num_classes=10): super().__init__() self.conv1 = nn.Conv2d(3, 16, kernel_size=5) self.pool1 = nn.MaxPool2d(kernel_size=2, stride=2) self.conv2 = nn.Conv2d(16, 32, kernel_size=5) self.pool2 = nn.MaxPool2d(kernel_size=2, stride=2) self.fc1 = nn.Linear(32 * 5 * 5, 120) self.fc2 = nn.Linear(120, 84) self.fc3 = nn.Linear(84, num_classes)
def forward(self, x): x = self.pool1(F.relu(self.conv1(x))) x = self.pool2(F.relu(self.conv2(x))) x = torch.flatten(x, start_dim=1) x = F.relu(self.fc1(x)) x = F.relu(self.fc2(x)) return self.fc3(x)
if __name__ == "__main__": model = LeNet() sample = torch.rand(4, 3, 32, 32) print(model(sample).shape)
|
输入尺寸的变化是 32 -> 28 -> 14 -> 10 -> 5,最后得到 32 x 5 x 5 的特征图。这里的 32 是通道数,不是 batch size。
训练模型:train.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68
| import torch import torch.nn as nn import torch.optim as optim import torchvision import torchvision.transforms as transforms
from model import LeNet
CLASSES = ( "plane", "car", "bird", "cat", "deer", "dog", "frog", "horse", "ship", "truck", )
def main(): device = torch.device("cuda" if torch.cuda.is_available() else "cpu") transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5)), ])
train_set = torchvision.datasets.CIFAR10( root="./data", train=True, download=True, transform=transform ) test_set = torchvision.datasets.CIFAR10( root="./data", train=False, download=True, transform=transform ) train_loader = torch.utils.data.DataLoader( train_set, batch_size=32, shuffle=True, num_workers=0 ) test_loader = torch.utils.data.DataLoader( test_set, batch_size=100, shuffle=False, num_workers=0 )
model = LeNet(num_classes=len(CLASSES)).to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.SGD(model.parameters(), lr=0.001, momentum=0.9)
for epoch in range(2): model.train() running_loss = 0.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()
print(f"epoch {epoch + 1}: loss={running_loss / len(train_loader):.4f}")
model.eval() correct = total = 0 with torch.no_grad(): for images, labels in test_loader: images, labels = images.to(device), labels.to(device) predictions = model(images).argmax(dim=1) total += labels.size(0) correct += (predictions == labels).sum().item()
print(f"accuracy: {100 * correct / total:.2f}%") torch.save(model.state_dict(), "lenet-cifar10.pth")
if __name__ == "__main__": main()
|
训练循环里最容易漏掉的是 optimizer.zero_grad()。如果不清空上一轮的梯度,梯度会累积,训练结果通常会变得很奇怪。CrossEntropyLoss 接收的是模型的原始 logits,所以训练时不需要在最后额外加 softmax。
单张图片预测:predict.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| import torch import torchvision.transforms as transforms from PIL import Image
from model import LeNet
CLASSES = ( "plane", "car", "bird", "cat", "deer", "dog", "frog", "horse", "ship", "truck", )
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") transform = transforms.Compose([ transforms.Resize((32, 32)), transforms.ToTensor(), transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5)), ])
image = transform(Image.open("horse.jpg").convert("RGB")) image = image.unsqueeze(0).to(device)
model = LeNet(num_classes=len(CLASSES)).to(device) model.load_state_dict(torch.load("lenet-cifar10.pth", map_location=device)) model.eval()
with torch.no_grad(): probabilities = torch.softmax(model(image), dim=1)[0]
index = probabilities.argmax().item() print(CLASSES[index], probabilities[index].item())
|
训练和预测必须使用相同的归一化方式。预测脚本里 unsqueeze(0) 是给图片补上 batch 维度,模型实际接收的是 [batch, channel, height, width]。
这次练习的重点
- CIFAR-10 是 10 分类任务,所以最后一层输出 10 个 logits。
- 卷积和池化负责提取特征并缩小空间尺寸,全连接层负责根据特征做分类。
- 训练阶段调用
model.train(),预测阶段调用 model.eval(),后者会关闭 Dropout 等训练行为。
- 保存
state_dict 比直接保存整个模型更容易迁移到其他代码中。
这个 Demo 的准确率不会特别高,但它把数据读取、模型定义、训练、保存和推理完整串起来了。下一步再换成更深的网络或做数据增强,才容易看出模型结构变化带来的差异。