这篇记录一次完整的图像分类练习:用 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):
# 32 -> 28 -> 14 -> 10 -> 5
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 的准确率不会特别高,但它把数据读取、模型定义、训练、保存和推理完整串起来了。下一步再换成更深的网络或做数据增强,才容易看出模型结构变化带来的差异。