跳到主要內容

Day 33 CNN 實作

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),在我們自己的資料上微調。這是在資料量有限時,最快把影像分類表現推到頂端的做法,也是業界最常用的工作流之一。

留言

這個網誌中的熱門文章

Day 2 變數與資料型別

Day 2 變數與資料型別 引言 寫程式的過程中,變數與資料型別是處理資料的基礎。變數是存放資料的容器,資料型別則決定這筆資料有哪些特性、可以進行哪些操作。學會定義變數、認識各種資料型別,是學好 Python 的關鍵一步。 這篇文章會帶你了解 Python 中變數的觀念、如何定義變數,以及常見的資料型別,包括整數、浮點數、字串、布林值,還有串列、元組、字典與集合等容器型別。我們也會介紹變數的命名規則與撰寫風格建議,以及如何用 type() 檢查資料型別。 什麼是變數?如何在 Python 中定義變數 變數是在程式執行時用來存放資料的名稱。透過定義變數,我們可以給一筆資料一個名字,並在程式的其他地方用這個名字取用該筆資料。在 Python 中,變數不需要事先宣告型別,因為 Python 是動態型別語言,變數的型別由指定給它的值決定。 定義變數的基本語法 在 Python 中定義變數非常簡單,只要用賦值符號 = 把值指定給變數即可。例如: x = 5 # 定義變數 x,並把整數 5 賦值給它 name = "Alice" # 定義變數 name,並把字串 "Alice" 賦值給它 在這裡,x 是一個變數,被賦予整數 5;name 是另一個變數,被賦予字串 "Alice"。 變數的更新與覆寫 變數的值可以修改,也就是說,我們可以在程式的不同地方給同一個變數新的值。例如: x = 10 # x 最初被賦予 10 x = 15 # x 的值現在被更新為 15 這樣就能依照需求,在程式執行過程中靈活調整變數的值。 Python 的動態型別系統 Python 和某些靜態型別語言不同,定義變數時不需要宣告型別。賦值時,Python 會根據值自動判斷變數的型別。例如: x = 5 # x 是整數 x = 3.14 # x 變成浮點數 x = "Hi" # x 變成字串 同一個變數在程式執行過程中可以存放不同型別的值,這是 Python 的彈性之一。 常見資料型別 在 Python 中,資料型別決定我們可以對變數進行哪些操作...

Day 1 Python 簡介與環境設定

Day 1 Python 簡介與環境設定 引言 在現在的科技環境裡,程式設計已經是一項重要技能。無論你是對資料科學有興趣、想成為開發者,或是想踏入人工智慧(AI)領域,學會寫程式都能明顯提升你的競爭力。在眾多程式語言中,Python 因為語法簡單、功能強大、應用範圍廣泛,成為許多人進入程式世界的第一選擇。這篇文章會帶你認識 Python 的背景與優勢,並一步步教你在不同系統上安裝與設定 Python 開發環境,最後寫出第一支 Python 程式。 為什麼選擇 Python? Python 是一種高階程式語言,由 Guido van Rossum 在 1991 年發布。Python 的設計哲學強調程式碼的可讀性,並用縮排來定義程式區塊,這點和許多使用大括號的語言不同。簡潔的語法讓它成為初學者的理想選擇;就算是經驗豐富的開發者,也能用它完成複雜的專案。 Python 的優勢如下: 簡單易學 :Python 的語法清楚、結構簡潔,初學者很快就能上手。和其他語言相比,學習曲線相對平緩,不需要先弄懂一堆複雜觀念,就能開始寫程式。 應用範圍廣泛 :從資料科學、網頁開發、人工智慧、機器學習、自動化測試到網路爬蟲,Python 都有大量開源函式庫與工具支援,而且在這些領域都扮演關鍵角色。 豐富的函式庫與框架 :Python 的函式庫生態系非常龐大。做資料分析有 NumPy、Pandas;開發網站有 Django、Flask;做深度學習有 TensorFlow、PyTorch。各種需求幾乎都能找到對應的套件,讓開發更有效率。 跨平台支援 :Python 支援 Windows、macOS、Linux 等作業系統,程式通常不需要太多修改就能跨平台執行,讓開發與部署更有彈性。 活躍的社群 :Python 擁有龐大的開發者社群。學習或開發上遇到問題,幾乎都能在社群與論壇(例如 Stack Overflow)找到答案,對初學者來說是很強的後盾,也能減少卡關時的挫折感。 Python 的應用領域 Python 的流行與強大功能,讓許多領域都開始大量使用它。以下是幾個常見的應用方向: 資料科學 :隨著大數據與人工智慧興起,資料科學大量使用 Python。NumPy、Pandas 與 Matplotlib 等工具能處理和分析龐...

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門 隨著人工智慧(AI)與深度學習(Deep Learning)快速發展,越來越多人對這些技術產生興趣。不論你是想踏入 AI 領域的初學者,還是已經有程式基礎的開發者,學好 Python 與深度學習框架(例如 PyTorch),都能為你打開更多可能。 為什麼選擇 Python? Python 已經是資料科學與人工智慧領域的首選語言。它的語法簡潔、容易上手,而且擁有龐大的生態系與大量開源函式庫。無論是資料處理、資料視覺化,還是建立機器學習與深度學習模型,Python 都能勝任。對想進入 AI 或資料科學領域的人來說,它幾乎是必備工具。 PyTorch 是什麼? PyTorch 是由 Meta(原 Facebook)AI 研究團隊開發的開源深度學習框架,以易用、靈活和動態計算圖著稱,是許多 AI 研究人員與開發者的首選。相較於其他框架,PyTorch 的寫法更貼近原生 Python,對初學者相對友善。無論是簡單的實驗,還是複雜的深度學習模型,PyTorch 都能提供強大的支援。 這個系列能帶給你什麼? 這個系列會從 Python 的基礎開始,帶你一步一步學習,最後能自己用 PyTorch 建立深度學習模型。即使你完全沒有寫過程式,也能跟著文章的節奏累積技能,理解 AI 與深度學習的核心觀念。 本系列涵蓋的主題 Python 基礎:從變數、條件判斷到函式與模組。 資料處理工具:用 NumPy 與 Pandas 有效率地操作資料。 資料視覺化:用 Matplotlib 與 Seaborn 把資料畫成圖表。 深度學習的數學基礎:線性代數、微積分與機率。 PyTorch 入門:理解張量、模型建構與 GPU 加速。 基礎深度學習模型:CNN 與 RNN 的實作應用。 深度學習專案實戰:從資料前處理到模型部署的端到端流程。 誰適合這個系列? 程式初學者 :如果你對 AI 充滿好奇,卻還沒寫過程式,系列的第一部分會帶你快速上手 Python,並幫助你理解深度學習的基本觀念。 資料科學愛好者 :如果你已經熟悉一些資料處理方法,進階部分會教你如何用 PyTorch 建構深度學習模型。 開發者與研究人員 :想更深入了...