Day 37 專案規劃與資料處理
引言
Day 32 到 Day 36 把我們帶進了影像、序列兩大類深度學習任務的核心模型,也實際訓練了 CNN 與 LSTM。不過模型只是整個機器學習專案的一部分,從「資料」到「可用的成果」之間,還有一段扎實的工程流程要走。從今天起的三篇,會把整個系列做一個收尾,用一個端到端的影像分類專案走完資料處理、模型設計、評估與部署。
這一篇的主題是「專案規劃與資料處理」。我們會先把任務定義清楚,建立一個可重現的專案結構,然後把資料從原始 CSV/資料夾,整理成可以直接餵給 DataLoader 的乾淨版本。這些步驟雖然不像模型設計那麼「炫」,卻決定了後續能不能順利往下走。實務上,有經驗的工程師花在資料處理的時間,往往比花在模型設計的時間還要多——因為模型可以靠經驗法則快速決定,資料卻藏著各種意想不到的坑。
定義任務與評估指標
在寫任何一行程式碼之前,先把任務寫下來:要做什麼、用什麼資料、用什麼指標衡量成功。這一步看起來理所當然,但很多人會跳過,直接進入寫模型階段,結果做了一堆事後才發現任務定義不清。
以這次的範例為例,我們要做的任務是「街景門牌號碼分類」,也就是著名的 SVHN(Street View House Numbers)資料集。我們要從 32×32 的彩色圖片中,預測中間那一個數字(0 到 9)的類別。這個任務的特性是:
- 資料規模:訓練約 73,257 張、測試約 26,032 張,圖片大小固定為 32×32。
- 類別數:10 類(數字 0 到 9),屬於平衡資料集。
- 評估指標:以「分類正確率(Accuracy)」為主,因為類別分布均勻,Accuracy 就能反映模型能力。
- 成功標準:在測試集達到 95% 以上的正確率,並確認模型沒有過擬合。
把這些寫下來後,後續的模型設計、調參、驗證都有了清楚的錨點,避免方向漂移。一個常見的小技巧是把任務定義寫成 README 的第一段,並在每次開新實驗前重新讀一次;當你發現自己已經在嘗試「看起來很帥但跟任務無關」的模型時,就能及時拉回來。
建立專案結構
把檔案與目錄規劃好,是專案能否順利協作的基礎。這個範例採用簡單但可擴充的結構:
svhn_project/
├── data/ # 原始與處理後的資料
├── notebooks/ # 探索用 notebook
├── src/
│ ├── data.py # 資料載入與前處理
│ ├── model.py # 模型定義
│ ├── train.py # 訓練流程
│ ├── evaluate.py # 評估與視覺化
│ └── export.py # 匯出模型
├── configs/
│ └── default.yaml # 超參數設定檔
├── checkpoints/ # 訓練過程中的權重
└── README.md # 專案說明
這樣的結構把「資料、程式、設定、產出」分開,等專案變大時比較好維護。對個人小專案來說,src/ 下的四個檔案(data、model、train、evaluate)已經足夠涵蓋整個流程;規模再大一些,可以再切成 train/、infer/、utils/ 等子資料夾。
另外,建議一開始就把 configs/ 與 checkpoints/ 這兩個目錄放進 .gitignore:前者裝本地開發用的設定(例如資料路徑、API 金鑰),後者裝訓練產生的權重(檔案太大,不適合進版控)。權重如果要長期保留,可以另外放雲端硬碟或模型 registry(MLflow、DVC 等工具都支援)。
把資料處理模組化
把所有跟資料有關的程式集中在 src/data.py,後續不論是訓練、評估或部署,都只要呼叫同一支程式就能拿到一致的資料。下方是一個典型的骨架:
from torchvision import datasets, transforms
from torch.utils.data import DataLoader, random_split
# 統一的資料轉換流程
TRANSFORM_TRAIN = transforms.Compose([
transforms.Resize((32, 32)),
transforms.RandomHorizontalFlip(),
transforms.ColorJitter(brightness=0.2, contrast=0.2),
transforms.ToTensor(),
transforms.Normalize((0.4377, 0.4438, 0.4728), (0.1980, 0.2010, 0.1970)),
])
TRANSFORM_EVAL = transforms.Compose([
transforms.Resize((32, 32)),
transforms.ToTensor(),
transforms.Normalize((0.4377, 0.4438, 0.4728), (0.1980, 0.2010, 0.1970)),
])
def get_dataloaders(data_dir, batch_size=128, val_ratio=0.1):
full_train = datasets.SVHN(
root=data_dir, split="train", download=True, transform=TRANSFORM_TRAIN
)
val_len = int(len(full_train) * val_ratio)
train_len = len(full_train) - val_len
train_set, val_set = random_split(full_train, [train_len, val_len])
test_set = datasets.SVHN(
root=data_dir, split="test", download=True, transform=TRANSFORM_EVAL
)
return (
DataLoader(train_set, batch_size=batch_size, shuffle=True, num_workers=2),
DataLoader(val_set, batch_size=batch_size, shuffle=False, num_workers=2),
DataLoader(test_set, batch_size=batch_size, shuffle=False, num_workers=2),
)
把訓練用與評估用的 transforms 分開是關鍵:訓練可以加資料增強(翻轉、調亮度),評估必須保持一致的可重現性,否則指標會變得難以解讀。Normalize 的平均值與標準差是 SVHN 這個資料集本身的統計值,先用對的值能讓模型更快收斂。
實務上也可以考慮把 TRANSFORM_TRAIN 與 TRANSFORM_EVAL 寫成設定檔的一部份(例如 configs/default.yaml),這樣同一支程式就能在不同任務間快速切換,而不必改程式碼。對個人小專案來說直接寫在 data.py 裡也夠用,但如果你的程式會在多個任務間共用,設定檔會是更好的設計。
驗證集切分與隨機種子
上面 random_split 的寫法很方便,但它每次執行的切分結果都不一樣。這在小型實驗還好,但在正式實驗中很容易造成「同一個模型,這次跑 95%、下次跑 94.5%」的困擾。固定隨機種子是最低成本的解法。
import torch
import random
import numpy as np
def set_seed(seed: int = 42) -> None:
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed)
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
def get_dataloaders(data_dir, batch_size=128, val_ratio=0.1, seed=42):
set_seed(seed)
full_train = datasets.SVHN(
root=data_dir, split="train", download=True, transform=TRANSFORM_TRAIN
)
val_len = int(len(full_train) * val_ratio)
train_len = len(full_train) - val_len
generator = torch.Generator().manual_seed(seed)
train_set, val_set = random_split(full_train, [train_len, val_len], generator=generator)
test_set = datasets.SVHN(
root=data_dir, split="test", download=True, transform=TRANSFORM_EVAL
)
return (
DataLoader(train_set, batch_size=batch_size, shuffle=True, num_workers=2),
DataLoader(val_set, batch_size=batch_size, shuffle=False, num_workers=2),
DataLoader(test_set, batch_size=batch_size, shuffle=False, num_workers=2),
)
把 set_seed 放在最前面,並讓 random_split 使用帶種子的 generator,是 PyTorch 專案裡常見的做法。配合 cudnn.deterministic = True 與 cudnn.benchmark = False,可以讓 GPU 上的運算也接近可重現。不過要提醒:完全可重現有時會犧牲一點效能,正式上線的程式不一定要這麼嚴格,但在實驗與除錯階段非常有幫助。
資料探索:先看一眼資料
資料處理的最後一步,建議在進入訓練前先用 matplotlib 畫幾張圖,確認資料沒有怪東西(例如全黑、標籤錯置)。這個動作只要 5 分鐘,卻能省下之後好幾小時的除錯時間。
import matplotlib.pyplot as plt
from torchvision import datasets
def preview_samples(data_dir, n=8):
ds = datasets.SVHN(root=data_dir, split="train", download=True)
fig, axes = plt.subplots(1, n, figsize=(12, 2))
for i, ax in enumerate(axes):
img, label = ds[i]
ax.imshow(img)
ax.set_title(f"label={label}")
ax.axis("off")
plt.tight_layout()
plt.savefig("checkpoints/sample_preview.png", dpi=120)
print("已存到 checkpoints/sample_preview.png")
# preview_samples("data")
把這個函式放在 notebooks/eda.ipynb 或 src/data.py 都可。它的目的是在「資料長相」與「程式碼預期」之間建立連結:當你預期資料是 32×32 的彩色影像,實際看到的卻是 64×64 的灰階,就可以馬上回頭修 transforms,不用等到訓練結果離譜才發現。
另一個值得養成的習慣,是把資料統計值(平均值、標準差、類別分布)寫進 README 或資料說明檔。下次換到類似的影像任務時,可以參考這份資料,決定要用什麼樣的 Normalize、需不需要做類別加權。SVHN 之所以選 (0.4377, 0.4438, 0.4728) 與 (0.1980, 0.2010, 0.1970),就是根據該資料集 73,257 張訓練影像計算出來的全域統計值,這樣的設定通常比「全用 0.5」更貼近實際分布,能讓模型更快收斂。
結語
這一篇把「任務定義、專案結構、資料模組化、隨機種子、資料探索」這五件事串起來。雖然表面上看不出模型準確率提升,但它們是後續三天(模型設計、評估、部署)的基礎。當專案結構與資料流都已經穩定之後,後續的實驗會跑得順暢很多。
在真實團隊中,資料工程師會把這部分做得更重:建立資料版本(Data Version Control)、寫資料驗證腳本、設置資料品質監控。但在個人或小型團隊的學習階段,先把上面這幾個關鍵動作做好,就足以支撐一個完整的深度學習專案。把這些當作肌肉記憶養成,後面不管遇到什麼任務都能少踩坑。
明天,我們會在這個骨架上設計模型架構、挑選超參數,並說明訓練過程中該怎麼根據驗證曲線調整學習率。
留言
張貼留言