Day 38 模型設計與調參
引言
昨天把專案骨架與資料流都準備好了,今天要進入機器學習專案最讓人興奮、也最容易踩坑的一塊——模型設計與超參數調整(Hyperparameter Tuning)。我們會用 SVHN 影像分類任務做為範例,從挑選 backbone、調整容量、到設計超參數搜尋策略,一步步把模型從「能跑」推向「穩定且有效」。
這一篇的核心觀念是:模型設計不是追求越複雜越好,而是要在「偏差(bias)」與「變異(variance)」之間找到甜蜜點;超參數的選擇則影響訓練的速度與最終成效。掌握一些實務眉角後,你會發現:好模型往往不是設計出來的,是實驗迭代出來的。在學習階段,與其追求一次到位,不如把實驗流程做得紮實——把所有設定記錄下來、把每次結果留下來,時間一久就能看出哪種策略對哪種任務最有效。
挑選模型 backbone
面對 SVHN 這種 32×32 的彩色影像分類,有幾個常見的 backbone 選擇:
- 自訂小型 CNN:3 到 4 層卷積搭配 BatchNorm 與 Dropout,參數量可控,訓練速度快。
- ResNet 家族:從 ResNet18 到 ResNet50,殘差連接能緩解梯度消失,深度可以拉得更深。
- 預訓練模型微調:用 torchvision 的 ResNet18 在 ImageNet 上預訓練的權重做遷移學習(Day 34 介紹過)。
資料量充足的話(SVHN 訓練集 7 萬多張),從頭訓練一個小型 CNN 就能交出不錯的成效。我們這裡選 ResNet18,從頭訓練當 baseline,後續再比較是否值得換成預訓練版本。
建立模型定義
把模型寫成獨立的 src/model.py,讓訓練、評估、部署都能呼叫同一份程式碼。為了能靈活調整容量,這裡把常見超參數寫成函式參數。
import torch
import torch.nn as nn
from torchvision import models
def build_model(arch: str = "resnet18", num_classes: int = 10, pretrained: bool = False):
if arch == "resnet18":
model = models.resnet18(weights=None if not pretrained else models.ResNet18_Weights.IMAGENET1K_V1)
model.fc = nn.Linear(model.fc.in_features, num_classes)
elif arch == "simple_cnn":
model = SimpleCNN(num_classes=num_classes)
else:
raise ValueError(f"不支援的 arch: {arch}")
return model
class SimpleCNN(nn.Module):
"""3 層卷積的小型 CNN,適合資料量不大或想快速實驗的場景"""
def __init__(self, num_classes=10):
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(3, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True),
nn.MaxPool2d(2),
nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True),
nn.MaxPool2d(2),
nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True),
nn.AdaptiveAvgPool2d(1),
)
self.classifier = nn.Sequential(
nn.Flatten(),
nn.Dropout(0.3),
nn.Linear(128, num_classes),
)
def forward(self, x):
return self.classifier(self.features(x))
寫成函式 + 模組化的好處,是之後想換 backbone(例如改成 resnet34 或 efficientnet_b0)只需要在設定檔改一行字串,不用動到訓練程式。SimpleCNN 也提供了「完全自訂」的對照組,當你想確認資料流沒有問題時,可以先用這個小模型快速驗證;如果連這麼小的 CNN 都學不起來,那問題八成出在資料或訓練流程,不會是模型本身的設計。
另一個值得提的眉角是 BatchNorm 的位置。在小型 CNN 裡,BatchNorm 幾乎一定要加在卷積層與激活函式之間,能讓訓練更穩定、收斂更快;但在 LSTM 之類的序列模型裡,BatchNorm 的擺放就比較微妙,常常需要實驗才有最佳解。這也是為什麼我們把 backbone 寫成可換的元件——不同的任務、不同的資料,適合的設計組合常常不一樣。
學習率與訓練演算法
學習率是深度學習裡最關鍵的超參數之一,太大會震盪、太小會卡住。一般實務建議:
- 先設一個保守值(例如 1e-3)跑幾個 epoch,確認模型有在學習(loss 在下降)。
- 如果 loss 震盪劇烈或發散,把學習率調小(例如 1e-4)。
- 如果 loss 下降太慢,調高學習率,或換成 AdamW 等自適應演算法。
對 CNN 來說,Adam 或 AdamW 幾乎是預設選擇;對 LSTM/RNN 則可以試試看 SGD + momentum 或 AdamW。下方示範一個常見的寫法,並把 weight_decay(權重衰減)一起放進去。
import torch.optim as optim
def build_optimizer(model, lr: float = 1e-3, weight_decay: float = 5e-4):
return optim.AdamW(
model.parameters(),
lr=lr,
weight_decay=weight_decay,
)
def build_scheduler(optimizer, step_size: int = 10, gamma: float = 0.5):
return optim.lr_scheduler.StepLR(optimizer, step_size=step_size, gamma=gamma)
加入 lr_scheduler 是另一個實務眉角:訓練後期把學習率降下來,模型比較容易收斂到比較好的局部最佳解。常見策略有 StepLR(每 N 個 epoch 降一次)、CosineAnnealingLR(用餘弦曲線平滑下降)、ReduceLROnPlateau(看到驗證 loss 卡住就降)等。這裡先用 StepLR 當範例,後續實驗可以替換。
對學習率還有更進階的玩法是 warmup(熱身):前幾個 epoch 用很小的學習率,再慢慢升到目標值,最後再下降。這種 cosine schedule with warmup 對大型模型(像是 Transformer)特別有效,但對小型 CNN 來說,加了不一定有感。建議先用簡單的 StepLR 跑一輪,看到有需要再升級。
訓練迴圈與早停(Early Stopping)
訓練時除了存最佳權重,也可以加入早停機制——連續幾個 epoch 驗證指標沒有進步,就提前結束訓練,避免不必要的時間浪費,也避免模型在訓練集上過擬合。
def train(
model, train_loader, val_loader, *, epochs, lr, weight_decay,
device, patience=5, ckpt_path="checkpoints/best.pt",
):
optimizer = build_optimizer(model, lr=lr, weight_decay=weight_decay)
scheduler = build_scheduler(optimizer)
criterion = nn.CrossEntropyLoss()
best_acc = 0.0
bad_epochs = 0
for epoch in range(epochs):
model.train()
for x, y in train_loader:
x, y = x.to(device), y.to(device)
optimizer.zero_grad()
loss = criterion(model(x), y)
loss.backward()
optimizer.step()
val_acc = evaluate(model, val_loader, device)
scheduler.step()
print(f"Epoch {epoch+1}: val_acc={val_acc:.4f}")
if val_acc > best_acc:
best_acc = val_acc
bad_epochs = 0
torch.save(model.state_dict(), ckpt_path)
else:
bad_epochs += 1
if bad_epochs >= patience:
print(f"早停:連續 {patience} 個 epoch 沒有進步")
break
return best_acc
這個 train 函式把所有需要被調整的選項(epochs、lr、weight_decay、patience)都用 keyword argument 暴露,方便後續用超參數搜尋工具呼叫。bad_epochs 是「已經連續幾次沒進步」的計數器,一旦超過 patience 就停止訓練,這是早停最簡潔的寫法。
另一個實務細節是權重的保留策略:除了「目前最佳」之外,也可以選擇「最後一個 epoch 的權重」或「所有 epoch 的權重(ckpt)儲存」。前者方便部署,後者方便之後繼續訓練或做 ensemble。對個人小專案來說只存「最佳」通常就夠,但如果你想之後拿多個 epoch 的權重做測試時間增強(Test Time Augmentation),就要規劃好儲存方式。
超參數搜尋策略
當手上的超參數選項太多(學習率、weight_decay、batch_size、模型容量…),手動調整會非常沒效率。這時可以用一些搜尋策略:
- Grid Search:把所有組合列出來,一個個跑。簡單暴力,但很貴。
- Random Search:隨機抽組合,通常比 Grid Search 更有效率。
- Bayesian Optimization:根據過去的結果預測下一組比較好的參數(例如 Optuna、Hyperopt)。
對學習階段的專案,建議先用「Random Search + 視覺化驗證曲線」就夠了。先用較小的 epoch 數篩掉明顯不好的組合,再對剩下幾組跑完整訓練。下方示範一個簡單的隨機搜尋腳本:
import random
SEARCH_SPACE = {
"lr": [1e-3, 5e-4, 1e-4],
"weight_decay": [1e-4, 5e-4, 1e-3],
"batch_size": [64, 128],
}
def sample_config():
return {k: random.choice(v) for k, v in SEARCH_SPACE.items()}
for trial in range(5):
cfg = sample_config()
print(f"=== Trial {trial+1}: {cfg} ===")
train_loader, val_loader, _ = get_dataloaders("data", batch_size=cfg["batch_size"])
model = build_model("resnet18").to(device)
acc = train(model, train_loader, val_loader, epochs=5, **cfg, device=device)
print(f"Trial {trial+1} 最佳正確率:{acc:.4f}")
每一次 trial 會隨機抽一組超參數,跑 5 個 epoch 後看驗證正確率。雖然只是粗篩,但足以找出「明顯有效」的範圍。把這些範圍內的設定,再用更長的 epoch 數跑完整實驗,往往就能拿到一個穩定的模型。
另外,建議把每一組 trial 的設定與結果寫成 JSON 或 CSV 留底,例如 {lr: 1e-3, weight_decay: 5e-4, val_acc: 0.952}。長期下來這些紀錄會變成專案最寶貴的資產——下次換一個類似任務時,可以直接參考過去的結果決定初始超參數,而不是每次都從零開始亂猜。
結語
這一篇從 backbone 的選擇、模型定義、訓練演算法與學習率、訓練迴圈與早停,一路談到超參數搜尋策略。整套流程的關鍵不在於「找到最好的模型」,而是建立一個可重現、可調整的實驗流程。當你能把模型設計與超參數調整的每一個動作寫成程式碼,整個專案的可維護性會大幅提升。
另外也別忘了,每次跑出比上一輪更好的結果時,先把當下的超參數、訓練曲線、評估指標記下來,再決定要不要繼續調。一旦專案累積了十幾輪的實驗記錄,之後不論是要寫報告、重現結果、或交接給同事,都會順暢很多。
明天,我們會進入評估與視覺化:除了正確率之外,還會看混淆矩陣、分類報告、錯誤樣本分析,學會從多個角度判斷模型是否真的可用。
留言
張貼留言