跳到主要內容

Day 31 手寫數字辨識實作

Day 31 手寫數字辨識實作

引言

前幾天把 PyTorch 的關鍵零件一個個拆開來看過:張量、autograd、Dataset/DataLoader、nn.Module、損失函式與最佳化器。今天要把它們全部組起來,完成深度學習的「經典第一關」:MNIST 手寫數字辨識。MNIST 是由 0 到 9 的灰階手寫數字影像所組成,長期被當作分類模型的入門基準。整個訓練腳本大約只有 80 行,但已涵蓋真實深度學習專案的核心流程,非常適合作為第一個完整作品。

今天會用一個三層的全連接網路(MLP)對付這個任務。預期在 CPU 上跑 5 個 epoch 之後,測試集的正確率可以穩定達到 96% 以上。透過這個範例,可以看到一個真實深度學習專案從資料下載、預處理、訓練到評估的標準流程長什麼樣。

認識 MNIST 資料集

MNIST 資料集由 Yann LeCun 等人在 1998 年整理發布,總共有 70,000 張 28×28 像素的灰階影像:60,000 張訓練影像與 10,000 張測試影像。每張影像對應一個 0 到 9 的標籤,並已經預先切分成訓練與測試兩個集合,因此非常適合作為研究。影像內容是阿拉伯數字的單筆寫法,許多研究人員用它來驗證新的分類模型是否能「正常運作」。

為了避免在每次訓練前都要重新下載,PyTorch 把 MNIST 包進了 torchvision.datasets.MNIST 這個類別。設定 download=True,第一次執行時會自動從官方鏡像站下載約 50 MB 的檔案並解壓縮到 ./data 資料夾;之後再執行就會直接讀取本機檔案。

下載的內容大致包含四個檔案:訓練影像、訓練標籤、測試影像、測試標籤。下載完成後,後續的讀取都在本機完成,不會再連線到外部伺服器。

載入資料與預處理

為了送進全連接網路,每張 28×28 的影像會被攤平成 784 維的向量;像素值原本是 0 到 255,預處理時先用 transforms.ToTensor() 縮放到 0 到 1,再用 Normalize 以均值 0.1307、標準差 0.3081 做正規化。這兩個數字是整個 MNIST 資料集的統計結果,是社群沿用已久的標準值。

import torch
from torch import nn
from torch.utils.data import DataLoader
from torchvision import datasets, transforms

device = "cuda" if torch.cuda.is_available() else "cpu"

transform = transforms.Compose([
    transforms.ToTensor(),                                    # [0, 255] -> [0, 1]
    transforms.Normalize((0.1307,), (0.3081,)),                # 用 MNIST 的均值/標準差
    transforms.Lambda(lambda x: x.view(-1)),                   # 28x28 -> 784
])

# 第一次執行會自動下載到 ./data,之後直接讀本機
train_dataset = datasets.MNIST(
    root="./data", train=True, download=True, transform=transform
)
test_dataset = datasets.MNIST(
    root="./data", train=False, download=True, transform=transform
)

train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=256, shuffle=False)

print(f"訓練資料筆數:{len(train_dataset)}")   # 輸出:60000
print(f"測試資料筆數:{len(test_dataset)}")    # 輸出:10000

訓練階段用 batch_size=64 並打亂順序;測試階段通常用更大的 batch(這裡設為 256),因為不需要打亂。Lambda 搭配 view(-1) 把 28×28 的影像攤平成 784 維,是最直覺的做法。如果之後想保留空間結構、改用卷積神經網路(CNN),就不需要這個 Lambda,CNN 會把影像當成 [1, 28, 28] 的張量直接處理,這部分會在 Day 32 與 Day 33 介紹。

建立 MLP 模型

模型本身並不複雜:兩個隱藏層、一個輸出層,激活函式用 ReLU。輸出層有 10 個神經元,對應 0 到 9 共 10 個類別的分數;接下來交給 CrossEntropyLoss,它會在內部自動套用 softmax。

class MLP(nn.Module):
    def __init__(self):
        super().__init__()
        self.fc1 = nn.Linear(784, 128)
        self.fc2 = nn.Linear(128, 64)
        self.fc3 = nn.Linear(64, 10)
        self.relu = nn.ReLU()

    def forward(self, x):
        x = self.relu(self.fc1(x))
        x = self.relu(self.fc2(x))
        return self.fc3(x)                # 輸出層不接激活函式

model = MLP().to(device)
print(model)

# 輸出:
# MLP(
#   (fc1): Linear(in_features=784, out_features=128, bias=True)
#   (fc2): Linear(in_features=128, out_features=64, bias=True)
#   (fc3): Linear(in_features=64, out_features=10, bias=True)
#   (relu): ReLU()
# )

隱藏層的大小 128 與 64 是經驗值,適合作為示範;如果之後想提高表現,可以嘗試更大的隱藏層或加上 dropout、BatchNorm。這個範例刻意維持簡單,目標是讓讀者看到一個能跑的完整流程。

訓練與評估

把 Day 30 的標準訓練迴圈搬過來,再加上正確率的計算,就是這個專案的核心。

loss_fn = nn.CrossEntropyLoss()
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3)

def evaluate(loader):
    model.eval()
    total, correct, total_loss = 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, 6):
    model.train()
    running_loss = 0.0
    for x, y in train_loader:
        x, y = x.to(device), y.to(device)
        optimizer.zero_grad()
        loss = loss_fn(model(x), 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}: train loss = {train_loss:.4f}, "
          f"test loss = {test_loss:.4f}, test acc = {test_acc:.4f}")

把這段程式跑起來,第一次執行會等資料下載,之後每個 epoch 在 CPU 上大約 15 到 30 秒,在 GPU 上會快更多。執行結果大致會像下面這樣(每次數字會略有不同):

epoch 1: train loss = 0.3014, test loss = 0.1422, test acc = 0.9571
epoch 2: train loss = 0.1187, test loss = 0.0958, test acc = 0.9714
epoch 3: train loss = 0.0771, test loss = 0.0824, test acc = 0.9755
epoch 4: train loss = 0.0536, test loss = 0.0756, test acc = 0.9781
epoch 5: train loss = 0.0398, test loss = 0.0721, test acc = 0.9802

測試正確率從第一個 epoch 的 95.7% 一路爬升到第五個 epoch 的 98% 左右。這個數字不算頂尖(CNN 可以輕鬆達到 99% 以上),但對於純全連接網路已經是合理的成果。從 train loss 持續下降、test loss 同步下降的趨勢看來,模型仍在「學東西」而不是「背答案」。如果想看更細節的評估,例如混淆矩陣,可以參考 Day 39 會介紹的方法。

對單張影像做推論

訓練完成後,最常見的下一步是拿模型去預測新影像。PyTorch 的標準做法是把模型切到 eval() 模式、用 torch.no_grad() 包住推論程式碼,再把單張影像轉成模型期待的形狀。這裡示範從測試集取一張影像、印出預測結果。

model.eval()
x, y = test_dataset[0]                  # 取得第一張測試影像與標籤
x = x.unsqueeze(0).to(device)           # [784] -> [1, 784],在最前面加上 batch 維度

with torch.no_grad():
    logits = model(x)
    pred = logits.argmax(dim=1).item()

print(f"真實標籤:{y}")                  # 輸出:例如 7
print(f"預測標籤:{pred}")               # 應該與真實標籤相同

unsqueeze(0) 的作用是在最前面加一個 batch 維度,讓單張影像的形狀從 [784] 變成 [1, 784],符合模型期待的 [batch, features] 結構。如果之後想接受真實世界的影像,只要記得把影像轉成灰階、縮放到 28×28、用同一組 mean/std 做正規化即可。

儲存與載入模型

訓練好的模型應該儲存下來,否則關閉程式後就要重頭再來。PyTorch 提供兩種常見的存法:只存參數(state_dict)或整個模型。只存參數是比較建議的做法,因為這種方式不受類別結構影響,日後就算改了模型定義,只要參數名稱對得起來就能載入。

# 儲存
torch.save(model.state_dict(), "mlp_mnist.pt")

# 載入(先建立模型,再把參數放進去)
model = MLP().to(device)
model.load_state_dict(torch.load("mlp_mnist.pt", map_location=device))
model.eval()

map_location=device 是載入時的關鍵:如果模型是在 GPU 上訓練、但現在要在 CPU 上推論,這個引數會自動把權重搬到 CPU;反之亦然。這在部署或換機器時非常方便。Day 40 會再示範如何把 PyTorch 模型匯出成 ONNX,部署到獨立的推論環境。

結語

今天把整套 PyTorch 訓練流程在 MNIST 上跑了一遍。torchvision.datasets.MNIST 幫我們處理資料下載與讀取,DataLoader 提供批次化的訓練資料,三層 MLP 在 CPU 上就能達到 98% 左右的測試正確率。這是深度學習入門最經典的「Hello World」,之後不管是影像分類、文字分類還是其他任務,幾乎都會沿用同樣的流程:資料 → 模型 → 損失函式 → 最佳化器 → 訓練迴圈 → 評估。如果你已經能獨自完成這個專案,代表已經掌握 PyTorch 最核心的工作流,可以放心進入後面的 CNN、RNN 與遷移學習章節。

明天,我們會進到 CNN 的世界。先理解卷積(convolution)與池化(pooling)為什麼比全連接更適合處理影像,再用手寫範例與小張圖示,看 CNN 怎麼從影像中萃取特徵。

留言

這個網誌中的熱門文章

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 建構深度學習模型。 開發者與研究人員 :想更深入了...