Day 33 CNN 實作
引言
Day 32 我們理解了卷積與池化的運作原理,也用 nn.Conv2d 與 nn.MaxPool2d 拼出了一個小型 CNN。今天要把這個 CNN 套到 CIFAR-10 上做完整的實戰,並加上資料增強(data augmentation),讓模型在訓練時看到更多變化。
CIFAR-10 由多倫多大學在 2009 年發布,總共有 60,000 張 32×32 的彩色影像,分成 10 個類別:飛機、汽車、鳥、貓、鹿、狗、青蛙、馬、船、卡車,每類 6,000 張。資料集已經預先切成 50,000 張訓練與 10,000 張測試。比 MNIST 更有挑戰性的地方在於:彩色影像、類別間差異細微(貓 vs 狗、卡車 vs 汽車),而且每張影像的內容、構圖與背景都很多樣。今天會用一個三層的 CNN 訓練 10 個 epoch,預期可以達到約 70% 左右的測試正確率。
認識 CIFAR-10 與資料增強
CIFAR-10 的影像只有 32×32,比 MNIST 還小,但因為是彩色且包含真實世界物體,難度明顯更高。為了避免在 50,000 張影像上過擬合,實務上會用資料增強:訓練時隨機把影像水平翻轉、隨機裁切,讓模型看到同一張影像的多種變形,提升泛化能力。
PyTorch 把資料增強的操作放在 torchvision.transforms 模組,常見的有 RandomHorizontalFlip(隨機水平翻轉)、RandomCrop(隨機裁切)、ColorJitter(隨機調整亮度對比)等。注意一個原則:訓練與測試的 transform 不一樣;測試階段只做必要的正規化,不做任何隨機變化,否則評估結果會不一致。
import torch
from torchvision import datasets, transforms
device = "cuda" if torch.cuda.is_available() else "cpu"
# 訓練用:隨機水平翻轉 + 隨機裁切 + ToTensor + Normalize
train_transform = transforms.Compose([
transforms.RandomHorizontalFlip(p=0.5),
transforms.RandomCrop(32, padding=4),
transforms.ToTensor(),
transforms.Normalize(
mean=[0.4914, 0.4822, 0.4465],
std=[0.2470, 0.2435, 0.2616],
),
])
# 測試用:只做 ToTensor 與 Normalize
test_transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize(
mean=[0.4914, 0.4822, 0.4465],
std=[0.2470, 0.2435, 0.2616],
),
])
# 第一次執行會自動下載 CIFAR-10 到 ./data
train_dataset = datasets.CIFAR10(
root="./data", train=True, download=True, transform=train_transform
)
test_dataset = datasets.CIFAR10(
root="./data", train=False, download=True, transform=test_transform
)
from torch.utils.data import DataLoader
train_loader = DataLoader(train_dataset, batch_size=128, shuffle=True, num_workers=2)
test_loader = DataLoader(test_dataset, batch_size=256, shuffle=False, num_workers=2)
print(f"訓練資料筆數:{len(train_dataset)}") # 輸出:50000
print(f"測試資料筆數:{len(test_dataset)}") # 輸出:10000
print(f"影像形狀:{train_dataset[0][0].shape}") # torch.Size([3, 32, 32])
這裡的 mean 與 std 是 CIFAR-10 整個資料集的統計結果,是社群沿用的標準值。RandomHorizontalFlip(p=0.5) 表示 50% 的機率水平翻轉影像;RandomCrop(32, padding=4) 先在外圍補 4 像素的 0,再隨機裁切回 32×32,這是小型影像資料增強的常用組合。
資料下載的部分,download=True 會自動從官方鏡像站下載約 170 MB 的檔案到 ./data,之後再執行就會直接讀本機,訓練流程不會再連線到外部伺服器。
建立 CNN 模型
我們沿用 Day 32 的 SmallCNN 概念,再多加一層卷積區塊、稍微擴大通道數,目標是在合理時間內達到 70% 以上的測試正確率。
import torch.nn as nn
class CIFARCNN(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
# 區塊 1:3 -> 32,32x32 -> 16x16
self.conv1 = nn.Conv2d(3, 32, kernel_size=3, padding=1)
self.bn1 = nn.BatchNorm2d(32)
self.relu1 = nn.ReLU()
self.pool1 = nn.MaxPool2d(2, 2)
# 區塊 2:32 -> 64,16x16 -> 8x8
self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1)
self.bn2 = nn.BatchNorm2d(64)
self.relu2 = nn.ReLU()
self.pool2 = nn.MaxPool2d(2, 2)
# 區塊 3:64 -> 128,8x8 -> 4x4
self.conv3 = nn.Conv2d(64, 128, kernel_size=3, padding=1)
self.bn3 = nn.BatchNorm2d(128)
self.relu3 = nn.ReLU()
self.pool3 = nn.MaxPool2d(2, 2)
# 分類頭
self.fc1 = nn.Linear(128 * 4 * 4, 256)
self.relu4 = nn.ReLU()
self.dropout = nn.Dropout(0.5)
self.fc2 = nn.Linear(256, num_classes)
def forward(self, x):
x = self.pool1(self.relu1(self.bn1(self.conv1(x))))
x = self.pool2(self.relu2(self.bn2(self.conv2(x))))
x = self.pool3(self.relu3(self.bn3(self.conv3(x))))
x = x.view(x.size(0), -1)
x = self.dropout(self.relu4(self.fc1(x)))
return self.fc2(x)
model = CIFARCNN().to(device)
print(f"參數量:{sum(p.numel() for p in model.parameters()):,}")
這個模型有三個卷積區塊(通道數依序為 32、64、128),每個區塊都加了 BatchNorm2d。Batch Normalization 在訓練時會對每個 batch 的通道做標準化,有助於加速收斂、降低對初始化的敏感度。在呼叫 model(x) 之前記得用 model.train() 與 model.eval() 切換 BatchNorm 的行為,這對正確性非常關鍵。
分類頭加了 Dropout(0.5),在訓練時隨機把一半的神經元輸出歸零,避免模型過度依賴少數特徵、產生過擬合。
訓練流程
訓練迴圈和 Day 30、Day 31 幾乎一樣:呼叫 model.train()、清空梯度、前向傳播、算損失、反向傳播、更新參數。為了評估方便,我們另外寫一個 evaluate() 函式。
import torch.nn.functional as F
loss_fn = nn.CrossEntropyLoss()
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=5e-4)
def evaluate(loader):
model.eval()
total_loss, correct, total = 0.0, 0, 0
with torch.no_grad():
for x, y in loader:
x, y = x.to(device), y.to(device)
logits = model(x)
total_loss += loss_fn(logits, y).item() * x.size(0)
correct += (logits.argmax(dim=1) == y).sum().item()
total += x.size(0)
return total_loss / total, correct / total
for epoch in range(1, 11):
model.train()
running_loss = 0.0
for x, y in train_loader:
x, y = x.to(device), y.to(device)
optimizer.zero_grad()
logits = model(x)
loss = loss_fn(logits, y)
loss.backward()
optimizer.step()
running_loss += loss.item() * x.size(0)
train_loss = running_loss / len(train_loader.dataset)
test_loss, test_acc = evaluate(test_loader)
print(f"epoch {epoch:2d}: train loss = {train_loss:.4f}, "
f"test loss = {test_loss:.4f}, test acc = {test_acc:.4f}")
把這段程式跑起來,第一次執行會等 CIFAR-10 下載,之後每個 epoch 在 GPU 上大約 20 到 40 秒,在 CPU 上會久很多(建議有 GPU 再跑)。
評估與觀察
訓練 10 個 epoch 之後,輸出大致會像下面這樣(每次數字會略有不同):
epoch 1: train loss = 1.5121, test loss = 1.2863, test acc = 0.5343
epoch 2: train loss = 1.1406, test loss = 1.0689, test acc = 0.6179
epoch 3: train loss = 0.9382, test loss = 0.9654, test acc = 0.6597
epoch 4: train loss = 0.8204, test loss = 0.8961, test acc = 0.6818
epoch 5: train loss = 0.7351, test loss = 0.8432, test acc = 0.7094
epoch 6: train loss = 0.6712, test loss = 0.8235, test acc = 0.7213
epoch 7: train loss = 0.6184, test loss = 0.7908, test acc = 0.7356
epoch 8: train loss = 0.5782, test loss = 0.7845, test acc = 0.7421
epoch 9: train loss = 0.5421, test loss = 0.7698, test acc = 0.7508
epoch 10: train loss = 0.5127, test loss = 0.7614, test acc = 0.7562
從 epoch 1 的 53% 爬升到 epoch 10 的 75% 左右,這是一個簡單 CNN 在 CIFAR-10 上的合理成績。如果想要再往上推,可以:
- 把模型加深(增加卷積區塊或通道數),例如改用 ResNet 類的殘差結構。
- 用更積極的資料增強,例如
Cutout、MixUp等。 - 改用更穩定的學習率策略,例如在第 5、第 8 個 epoch 把學習率乘以 0.1。
觀察 train loss 與 test loss 的差距,可以判斷模型是否過擬合。在這個範例中,train loss 比 test loss 低一些,但差距不大,顯示資料增強與 Dropout 確實有發揮效果。如果差距快速擴大,可以加大 Dropout 比率或加入 weight decay(這裡已經設為 5e-4)。
加上學習率排程器
CIFAR-10 比 MNIST 更需要仔細調整學習率。除了 AdamW 的預設學習率之外,常見做法是在訓練中後段把學習率調小,讓模型在最低點附近穩定收斂。torch.optim.lr_scheduler.CosineAnnealingLR 會沿著餘弦曲線把學習率從初始值平滑降到 0,是 2024 年影像分類最常用的選擇之一。
import torch.optim.lr_scheduler as lr_scheduler
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=5e-4)
scheduler = lr_scheduler.CosineAnnealingLR(optimizer, T_max=10)
for epoch in range(1, 11):
model.train()
# ... 正常的訓練步驟 ...
scheduler.step() # 在每個 epoch 結束時更新
加了餘弦退火之後,後期 epoch 的更新步幅會自然變小,能多賺 1 到 2 個百分點的測試正確率。Day 38 會更系統地介紹如何挑選學習率、排程器與其他超參數。
單張影像推論與模型儲存
訓練完成後,最常見的兩個延伸動作是:對單張影像做預測,以及把模型儲存起來供日後使用。
# 訓練完成後先儲存
torch.save(model.state_dict(), "cifar_cnn.pt")
# 之後要推論時再載入
from PIL import Image
model = CIFARCNN().to(device)
model.load_state_dict(torch.load("cifar_cnn.pt", map_location=device))
model.eval()
# 讀取真實影像(假設是 32x32 彩色)
img = Image.open("test.jpg").convert("RGB")
x = test_transform(img) # 用測試階段的 transform
x = x.unsqueeze(0).to(device) # 加 batch 維度
with torch.no_grad():
logits = model(x)
pred = logits.argmax(dim=1).item()
classes = ["飛機", "汽車", "鳥", "貓", "鹿", "狗", "青蛙", "馬", "船", "卡車"]
print(f"預測結果:{classes[pred]}")
unsqueeze(0) 把單張影像從 [3, 32, 32] 變成 [1, 3, 32, 32],符合模型的 batch 維度慣例。load_state_dict 搭配 map_location=device 可以處理「在 GPU 訓練、CPU 推論」或反過來的情況,是部署時的標準寫法。
怎麼讀這個訓練輸出
看到「train loss」與「test loss」兩條下降曲線時,可以從幾個角度解讀:
- 差距大小:train loss 與 test loss 的差距代表「過擬合」的程度。差距越大代表模型在訓練資料上表現太好,但對沒看過的資料卻沒把握,這時可以加資料增強、加大 Dropout、或用 weight decay。
- 下降速度:前幾個 epoch 通常下降最快,之後趨於平緩。如果全程幾乎沒動,可能是學習率太小;如果一開始就震盪甚至飆高,則是學習率太大。
- 最後正確率:CIFAR-10 的「簡單自建 CNN」基準大約落在 70%–75% 上下;如果一直停在 60% 附近,可以嘗試更深的網路(例如 Day 34 會介紹的 ResNet)。
觀察這幾個指標,是日後設計與調整模型的核心功夫。Day 38 與 Day 39 會更系統地介紹如何讀懂訓練曲線、判斷瓶頸、以及用更精細的評估指標(混淆矩陣、每類正確率等)來診斷模型的弱點。
結語
今天用一個三層的 CNN 在 CIFAR-10 上訓練到約 75% 的測試正確率,並用 RandomHorizontalFlip 與 RandomCrop 做資料增強、用 BatchNorm 與 Dropout 提升泛化能力、用 CosineAnnealingLR 平滑地調整學習率。這套流程是影像分類任務的標準配備,之後遇到更大型的資料集(例如 ImageNet)或更深的網路(例如 ResNet),概念都一樣。能在自己電腦上把這個 CNN 從頭跑到尾,等於完成了深度學習入門最重要的一塊拼圖。
明天,我們會進入遷移學習:直接拿別人在大型資料集上預訓練好的模型(例如 ResNet18),在我們自己的資料上微調。這是在資料量有限時,最快把影像分類表現推到頂端的做法,也是業界最常用的工作流之一。
留言
張貼留言