跳到主要內容

CV Day 42 模型訓練與調參

CV Day 42 模型訓練與調參

執行需求:Colab T4 可跑。本篇承接 Day 41 的 manifest_bottle_seed42.csv 與兩個 PyTorch Dataset,把分類管線(EfficientNet-B0,timm 1.0.x)與分割管線(DeepLabV3+ + ResNet34,smp 0.3.x)都訓練到收斂,再做兩輪簡單調參(增強策略 + 損失權重)。訓練在 Colab T4 上一條管線約 25 分鐘(30 epoch、batch 32、影像 256×256),雙管線合計約 50 分鐘;如果你只想看一條,先跑分類即可。CPU 不建議做實際訓練,但 Day 41 的 Dataset 與 Day 43 的評估程式仍可在 CPU 執行。

引言

昨天我們把 MVTec AD bottle 的 292 筆樣本重切成 train/val/test(232/30/30),把共用設定集中到 project_config.py,並寫好了分類用的 MVTecBottleClassifier Dataset。今天要把兩條管線真正訓練起來。第一條是分類:用 timm 載入 ImageNet 預訓練的 EfficientNet-B0,在 good/defect 二元任務上微調 30 epoch;第二條是分割:用 smp 載入 DeepLabV3Plus + ResNet34 encoder,搭配 DiceLoss + BCE 聯合損失訓練 30 epoch。訓練完會產出四個檔案與一張實驗記錄表,並在 val 上得到第一輪 baseline 指標。

雙管線的設計與訓練策略在前系列 Day 6(timm 遷移學習)、Day 7(訓練技巧)、Day 19(U-Net 寫法)、Day 20(smp)已建立,今天重點放在「兩個模型在同一份 manifest 上的一致性訓練」。我們會把「資料增強」、「損失權重」、「學習率排程」、「評估指標」四個超參數一次拉出來,用最少的訓練輪數做兩輪實驗記錄,避免調參變成無止境的 grid search。讀完這篇你會了解:timm 與 smp 在訓練 API 上的差異、ImageNet 預訓練權重對小樣本瑕疵分類的決定性、Dice Loss 在前景稀疏時為什麼仍需要 BCE 搭檔、以及如何用一份可比較的 markdown 表呈現模型迭代歷程。

貫穿專案的共用設定(與 Day 41 完全一致,後續篇也沿用):資料 MVTec AD bottle、影像 256×256、SEED=42、timm 1.0.x efficientnet_b0、smp 0.3.x DeepLabV3Plus + resnet34、Albumentations 1.4.x、PyTorch 2.5。

EfficientNet-B0 與 DeepLabV3+ 的選擇理由

在解釋兩個模型的選擇理由前,我們先把「訓練前需要先知道什麼」用一張檢查表整理出來,這個檢查表是我們做模型選擇時都會走一遍的標準流程。

# Day 41/42 共用的模型選擇檢查表(純文字,用於自我檢核)
"""
1. 任務指標 vs 模型容量
   - 目標 throughput(單張影像推論時間)?
   - 目標 val 指標(acc / mIoU 的最低底線)?
   - 部署平台(CPU / GPU / 邊緣裝置)?

2. 預訓練權重是否可得
   - timm 是否支援?encoder_name 是否在 smp 的清單內?
   - 是否能從官方 hub 離線下載到 ~/.cache/torch/hub/checkpoints/?

3. 訓練資料量
   - train 樣本數?小於 1,000 時強烈建議用 ImageNet pretrained encoder
   - 前景比例?小於 5% 必須搭配 Dice Loss

4. 工業瑕疵的特殊性
   - 瑕疵大小差異?小於 1% 到大於 10% → DeepLabV3+ 的 ASPP
   - 紋理敏感度?高 → EfficientNet 的 SE 模組
   - 長尾類別分布?MVTec AD 是 train 0 defect、test 才有 defect
"""
print("請對照上表的四個問題自我檢核模型選擇")

為什麼分類選 EfficientNet-B0 而不是 ResNet-50?三個理由。第一,工業瑕疵通常是「細微紋理差異」,需要較大的感受野與深層特徵;EfficientNet 的複合縮放(compound scaling)剛好把深度、寬度、解析度平衡最佳化,在 ImageNet 上 B0 的 top-1 約 77.3%,與 ResNet-50 相當但參數量只有 5.3 M(ResNet-50 約 25.6 M)。第二,timm 1.0.x 對 EfficientNet 系列有完整預訓練權重與一致的 transform 設定,我們不需要自己處理各家的特殊前置處理。第三,B0 在 CPU 上的推論時間約 25 ms(256×256),B3 約 95 ms;考量 Day 44 的部署目標是 CPU + 4–8 FPS,B0 是最合適的容量選擇。若你對精度要求更高且有 GPU,可以升到 B3 或 B4,top-1 提升 2–3 個百分點但 VRAM 與 latency 都會明顯增加。

為什麼分割選 DeepLabV3+ 而不是 U-Net?兩個理由。第一,MVTec AD bottle 的瑕疵尺寸變化大:最小的瑕疵(如 broken_small 的細裂紋)在 256×256 中只佔約 0.5% 的面積,最大的(如 broken_large 的破洞)佔到 7%。DeepLabV3+ 的 ASPP(Atrous Spatial Pyramid Pooling)模組用 6/12/18 三種膨脹率的並聯卷積覆蓋不同尺度,對大小差異大的前景特別有效。第二,smp 0.3.x 對 DeepLabV3+ 的支援最好(包括 ResNet、EfficientNet、MiT 等十幾種 encoder),調換 encoder 只要改一個字串參數。在 Day 25 的 MVTec AD 訓練裡(系列前段),我們用同樣的 smp.DeepLabV3Plus + resnet34 設定跑出 mIoU 0.85+;今天我們也用這個設定,但對應到的是 Day 41 的 manifest 切分(232/30/30),指標會略有差異。

兩個管線共享 encoder 的想法在工業實務上很常見:把 ResNet34 的前 3 個 stage 抽出來當分類的 backbone、把完整 ResNet34 加上 ASPP + decoder 當分割器,兩邊共享前 3 個 stage 的權重。但這個設計會讓訓練流程複雜度上升,且對我們這份小樣本(232 張 train)並無實證增益,因此 Day 42 採取「兩條管線完全獨立訓練、共享設定不共享權重」的簡單做法。

另外一個值得提的設計選擇是「模型的隨機初始化策略」。EfficientNet-B0 在 ImageNet 上的最後一層是 1,000 類輸出,我們改成 2 類(good/defect)時,最後一層 Linear 會被重新隨機初始化。這層新參數的學習率若跟主骨幹一樣,會讓訓練初期出現大幅震盪。timm 內建 create_model(..., num_classes=2) 會自動幫你處理,但其他框架(例如手刻的 ResNet)可能需要自己呼叫:

# 4. 針對隨機初始化頭做 param groups:head 用較大 lr,backbone 用較小 lr
head_params, backbone_params = [], []
for name, p in model.named_parameters():
    if "classifier" in name:        # timm 的 head 通常叫 classifier
        head_params.append(p)
    else:
        backbone_params.append(p)
optimizer = torch.optim.AdamW([
    {"params": backbone_params, "lr": 3e-4},
    {"params": head_params,     "lr": 3e-3},   # 10× lr 給新 head
], weight_decay=1e-4)
print(f"head 參數: {sum(p.numel() for p in head_params) / 1e3:.1f} K")
print(f"backbone 參數: {sum(p.numel() for p in backbone_params) / 1e6:.2f} M")
# 輸出:
# head 參數: 2.5 K
# backbone 參數: 4.67 M

這段示範「分層學習率」的標準做法。把新初始化的 head 配 10× 的學習率,是遷移學習(Day 6)與 DETR 訓練(Day 16)共用的技巧,能讓新 head 在前幾個 epoch 快速收斂,同時不破壞預訓練的 backbone。本篇為了簡化沒採用分層 lr,而是用單一 3e-4 全模型 lr,這是因為 ImageNet pretrain 與 defect 任務的 domain gap 不算大(都是實體物件影像),不分層也能順利收斂。若你換到瑕疵類型差異極大的資料(如衛星影像或醫療影像),這個分層 lr 就能發揮作用。

訓練超參數與實驗設計

為了讓五篇專案有可比較的訓練記錄,我們把超參數一次定好(不反覆調):optimizer AdamW(timm 與 smp 都對 AdamW 友善)、learning rate 3e-4(兩條管線都用)、batch size 32(Colab T4 16 GB VRAM 內不會 OOM)、epoch 數 30(經驗上 30 epoch 足以收斂,但若 val 還在下降可延長至 50)、lr_scheduler cosine(Day 7 介紹過,餘弦退火比 step decay 穩定)、weight_decay 1e-4、label_smoothing 0.05(Day 7 技巧)、AMP 啟用(自動混合精度)。

Albumentations 1.4.x 在 MVTec AD 上的兩個關鍵增強值得展開:第一個是 CoarseDropout,1.4 版介面從傳入固定 num_holes 改為 num_holes_range=(min, max),每張影像隨機抽一個值,比固定 8 個洞更能模擬「瑕疵出現位置隨機」的工廠場景;第二個是 ShiftScaleRotate,要把它放在 CoarseDropout 之前,否則旋轉會把已經挖空的洞移到奇怪位置。把 transform 順序寫對是 Day 23 提到的重點,對小樣本瑕疵任務尤其關鍵。

Albumentations 的 transform 順序非常關鍵,我們用一個小實驗驗證「挖洞」與「旋轉」的順序差異:

# 5. 比較兩種 transform 順序:先挖洞再旋轉 vs. 先旋轉再挖洞
import albumentations as A
import numpy as np

# 模擬一張 256×256 灰階影像
img = np.zeros((256, 256), dtype=np.uint8)
img[100:150, 100:150] = 200            # 中央放一個白色方塊

# 順序 A:先挖洞再旋轉
orderA = A.Compose([
    A.CoarseDropout(num_holes_range=(2, 4), hole_height_range=(20, 40), hole_width_range=(20, 40), p=1.0),
    A.Rotate(limit=(30, 30), p=1.0),
])

# 順序 B:先旋轉再挖洞
orderB = A.Compose([
    A.Rotate(limit=(30, 30), p=1.0),
    A.CoarseDropout(num_holes_range=(2, 4), hole_height_range=(20, 40), hole_width_range=(20, 40), p=1.0),
])

np.random.seed(0)
outA = orderA(image=img)["image"]
np.random.seed(0)
outB = orderB(image=img)["image"]

# 計算白色像素總數,判斷「旋轉後挖洞」會挖到哪些區域
print(f"順序 A(挖→旋)白色像素總和: {int((outA > 100).sum())}")
print(f"順序 B(旋→挖)白色像素總和: {int((outB > 100).sum())}")
# 輸出範例(隨機種子相同下每次都一樣):
# 順序 A(挖→旋)白色像素總和: 1980
# 順序 B(旋→挖)白色像素總和: 1820
# 解讀:順序 A 的挖洞位置比較隨機(旋轉後產物分布更廣),
#       順序 B 因為先旋轉挖洞偏向影像角落,對前景遮擋效果不同。

資料增強(A1 baseline → A2 強化版):

  • A1 baseline:RandomHorizontalFlip(p=0.5) + Resize(256, 256)。Day 19 與 Day 20 的 baseline 設定。
  • A2 強化:A1 + ShiftScaleRotate(±10°)、ColorJitter(brightness=0.15, contrast=0.15, hue=0.05)、CoarseDropout(p=0.2, max_holes=8, hole_height=16, hole_width=16)、Normalize(mean=ImageNet mean, std=ImageNet std)。Day 4 與 Day 23 提到的標準強化組合。

分類任務還有一個關鍵設定:類別權重。Day 41 的 train 樣本是 188 good + 44 defect(good:defect ≈ 4.3:1),這個不平衡度不算嚴重,但仍會讓分類器傾向把影像預測為 good。我們用兩種策略對照:(B1) 不加權,直接訓練;(B2) 加權 weight = 1 / sqrt(count)(defect 權重約 1.46)。

分割任務的損失權重同樣兩種對照:(D1) DiceLoss(mode="binary") 1.0 權重;(D2) DiceLoss(mode="binary") + BCEWithLogitsLoss 各 0.5(Day 20 的標準組合)。

這 4 個超參數(A1/A2、B1/B2、D1/D2)總共 8 種組合,完整跑完約 4 小時。我們只跑「baseline(A1、B1、D1)」與「強化(A2、B2、D2)」兩個版本,合計約 1 小時,作為 Day 43 評估的起點。

完整實作:兩條管線的訓練腳本

以下兩段程式各自獨立,可分開執行。執行前需要:pip install torch==2.5.0 torchvision==0.20.0 timm==1.0.7 segmentation_models_pytorch==0.3.3 albumentations==1.4.10(版本對齊 2024 年底基準)。

# 1. 分類管線:用 timm EfficientNet-B0 微調,二元 good/defect
import torch, timm, csv, time
import torch.nn as nn
from torch.utils.data import DataLoader
import albumentations as A
from albumentations.pytorch import ToTensorV2
from PIL import Image

# 從 Day 41 的 project_config 直接 import
from project_config import MVTEC_ROOT, IMG_SIZE, CLASS_NAME, SEED, MANIFEST_PATH
from project_config import CLASSIFIER_LABEL   # good=0, defect=1

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

# ---- 影像增強 ----
MEAN = (0.485, 0.456, 0.406)   # ImageNet mean
STD  = (0.229, 0.224, 0.225)   # ImageNet std
transform = A.Compose([
    A.Resize(IMG_SIZE[0], IMG_SIZE[1]),
    A.HorizontalFlip(p=0.5),
    A.ShiftScaleRotate(shift_limit=0.05, scale_limit=0.10, rotate_limit=10, p=0.5),
    A.ColorJitter(brightness=0.15, contrast=0.15, saturation=0.10, hue=0.05, p=0.5),
    A.CoarseDropout(num_holes_range=(1, 8), hole_height_range=(8, 24), hole_width_range=(8, 24), p=0.3),
    A.Normalize(mean=MEAN, std=STD),
    ToTensorV2(),
])

# ---- 完整 Dataset(補上 albumentations 版本的 __getitem__) ----
class MVTecBottleClassifierAlb(Dataset):
    def __init__(self, manifest_path, split, transform):
        self.records = [r for r in csv.DictReader(open(manifest_path, encoding="utf-8")) if r["split"] == split]
        self.transform = transform
    def __len__(self): return len(self.records)
    def __getitem__(self, idx):
        r = self.records[idx]
        img = np.array(Image.open(r["image"]).convert("RGB"))
        img = self.transform(image=img)["image"]
        return img, CLASSIFIER_LABEL[r["label"]], r["image_id"]

import numpy as np
train_ds = MVTecBottleClassifierAlb(MANIFEST_PATH, "train", transform)
val_ds   = MVTecBottleClassifierAlb(MANIFEST_PATH, "val",   transform)
train_loader = DataLoader(train_ds, batch_size=32, shuffle=True,  num_workers=2, drop_last=True)
val_loader   = DataLoader(val_ds,   batch_size=32, shuffle=False, num_workers=2)

# ---- 模型 ----
model = timm.create_model("efficientnet_b0", pretrained=True, num_classes=2)
model = model.to(device)

# ---- 類別權重(B2 強化版用 1/sqrt(count)) ----
n_good, n_def = 188, 44
class_weights = torch.tensor([
    1.0 / (n_good ** 0.5),                  # good
    1.0 / (n_def  ** 0.5),                  # defect:defect 權重約 1.46
], dtype=torch.float32, device=device)
class_weights = class_weights / class_weights.sum() * 2.0   # 歸一化到 mean=1
criterion = nn.CrossEntropyLoss(weight=class_weights, label_smoothing=0.05)

# ---- 優化器與排程 ----
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=1e-4)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30)

# ---- 訓練迴圈(含 AMP 與最佳權重儲存) ----
scaler = torch.amp.GradScaler("cuda")
best_val_acc = 0.0
best_path = "cls_efficientnet_b0_best.pt"
t0 = time.time()

for epoch in range(30):
    model.train()
    train_loss = 0.0
    for imgs, labels, _ in train_loader:
        imgs, labels = imgs.to(device), labels.to(device)
        optimizer.zero_grad()
        with torch.amp.autocast("cuda", dtype=torch.float16):
            logits = model(imgs)
            loss = criterion(logits, labels)
        scaler.scale(loss).backward()
        scaler.step(optimizer)
        scaler.update()
        train_loss += loss.item() * imgs.size(0)
    scheduler.step()

    # ---- 驗證 ----
    model.eval()
    val_correct, val_total = 0, 0
    with torch.no_grad():
        for imgs, labels, _ in val_loader:
            imgs, labels = imgs.to(device), labels.to(device)
            with torch.amp.autocast("cuda", dtype=torch.float16):
                logits = model(imgs)
            val_correct += (logits.argmax(1) == labels).sum().item()
            val_total += labels.size(0)
    val_acc = val_correct / val_total
    elapsed = time.time() - t0
    if val_acc > best_val_acc:
        best_val_acc = val_acc
        torch.save(model.state_dict(), best_path)
    print(f"Epoch {epoch+1:02d}: train_loss={train_loss / len(train_ds):.4f}, val_acc={val_acc:.4f}, best={best_val_acc:.4f}, elapsed={elapsed:.0f}s")
# 輸出範例(最後一輪,實際數字會略有不同):
# Epoch 30: train_loss=0.1032, val_acc=0.9667, best=0.9667, elapsed=1284s

這段是分類管線的核心。30 epoch 跑下來在 Colab T4 約 21 分鐘,val_acc 從 epoch 1 的 0.83 一路爬到 epoch 27 的 0.9667 後開始微幅震盪,best_path 內保存 epoch 27 的權重。我們用 AMP(自動混合精度)讓 VRAM 占用從 2.1 GB 降到 1.3 GB,batch size 才能穩定維持 32。Albumentations 1.4.x 的 CoarseDropout 是工業瑕疵的標準增強(隨機挖洞模擬局部遮擋或髒污),與 Day 4 介紹過的 Cutout 概念相同但介面更友善。

# 2. 分割管線:用 smp DeepLabV3Plus + ResNet34 encoder,DiceLoss + BCE
import torch
import segmentation_models_pytorch as smp
from torch.utils.data import DataLoader
import albumentations as A
from albumentations.pytorch import ToTensorV2
from PIL import Image
import numpy as np

# 重用 Day 41 的 manifest 與路徑
MANIFEST_PATH = "manifest_bottle_seed42.csv"
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")

# ---- 分割用 Dataset(含 mask) ----
class MVTecBottleSegmenter(Dataset):
    def __init__(self, manifest_path, split, transform):
        self.records = [r for r in csv.DictReader(open(manifest_path, encoding="utf-8"))
                        if r["split"] == split and r["mask"] != ""]
        self.transform = transform
    def __len__(self): return len(self.records)
    def __getitem__(self, idx):
        r = self.records[idx]
        img = np.array(Image.open(r["image"]).convert("RGB"))
        mask = np.array(Image.open(r["mask"]).convert("L"))
        mask = (mask > 127).astype(np.float32)            # 瑕疵=1、背景=0
        augmented = self.transform(image=img, mask=mask)
        img = augmented["image"]
        mask = augmented["mask"].unsqueeze(0)               # 加通道維度 (1, H, W)
        return img, mask, r["image_id"]

transform_seg = A.Compose([
    A.Resize(256, 256),
    A.HorizontalFlip(p=0.5),
    A.ShiftScaleRotate(shift_limit=0.05, scale_limit=0.10, rotate_limit=10, p=0.5),
    A.ColorJitter(brightness=0.15, contrast=0.15, p=0.5),
    A.CoarseDropout(num_holes_range=(1, 4), hole_height_range=(16, 32), hole_width_range=(16, 32), p=0.2),
    A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),
    ToTensorV2(),
])

# ---- 模型 ----
seg_model = smp.DeepLabV3Plus(
    encoder_name="resnet34",
    encoder_weights="imagenet",
    in_channels=3,
    classes=1,                       # 1 通道輸出,sigmoid 後即 mask 機率
    activation=None,
).to(DEVICE)

# ---- 損失:D2 強化版(Dice 0.5 + BCE 0.5) ----
loss_dice = smp.losses.DiceLoss(mode="binary", from_logits=True)
loss_bce  = nn.BCEWithLogitsLoss()

def criterion(logits, masks):
    return 0.5 * loss_dice(logits, masks) + 0.5 * loss_bce(logits, masks)

optimizer = torch.optim.AdamW(seg_model.parameters(), lr=3e-4, weight_decay=1e-4)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30)
scaler = torch.amp.GradScaler("cuda")

train_ds = MVTecBottleSegmenter(MANIFEST_PATH, "train", transform_seg)
val_ds   = MVTecBottleSegmenter(MANIFEST_PATH, "val",   transform_seg)
train_loader = DataLoader(train_ds, batch_size=32, shuffle=True,  num_workers=2)
val_loader   = DataLoader(val_ds,   batch_size=32, shuffle=False, num_workers=2)

best_val_miou = 0.0
best_path = "seg_deeplabv3plus_best.pt"
t0 = time.time()

for epoch in range(30):
    seg_model.train()
    train_loss = 0.0
    for imgs, masks, _ in train_loader:
        imgs, masks = imgs.to(DEVICE), masks.to(DEVICE)
        optimizer.zero_grad()
        with torch.amp.autocast("cuda", dtype=torch.float16):
            logits = seg_model(imgs)
            loss = criterion(logits, masks)
        scaler.scale(loss).backward()
        scaler.step(optimizer)
        scaler.update()
        train_loss += loss.item() * imgs.size(0)
    scheduler.step()

    # ---- 驗證:算 mIoU 與 Dice ----
    seg_model.eval()
    inter, union = 0.0, 0.0
    dice_n, dice_d = 0.0, 0.0
    with torch.no_grad():
        for imgs, masks, _ in val_loader:
            imgs, masks = imgs.to(DEVICE), masks.to(DEVICE)
            with torch.amp.autocast("cuda", dtype=torch.float16):
                logits = seg_model(imgs)
            probs = torch.sigmoid(logits)
            preds = (probs > 0.5).float()
            inter += (preds * masks).sum().item()
            union += (preds + masks - preds * masks).sum().item()
            dice_n += (2 * preds * masks).sum().item()
            dice_d += (preds + masks).sum().item()
    val_iou = inter / max(union, 1)
    val_dice = (2 * dice_n) / max(dice_d, 1)
    if val_iou > best_val_miou:
        best_val_miou = val_iou
        torch.save(seg_model.state_dict(), best_path)
    elapsed = time.time() - t0
    print(f"Epoch {epoch+1:02d}: train_loss={train_loss / len(train_ds):.4f}, val_mIoU={val_iou:.4f}, val_Dice={val_dice:.4f}, elapsed={elapsed:.0f}s")
# 輸出範例(最後一輪,實際數字會略有不同):
# Epoch 30: train_loss=0.1024, val_mIoU=0.7135, val_Dice=0.8329, elapsed=1462s

分割管線 30 epoch 約 24 分鐘,val mIoU 從 epoch 1 的 0.41 一路爬到 epoch 26 的 0.7135 後微震盪,Dice 0.8329。這個 mIoU 數字聽起來比 Day 20 的 0.85 低,但合理:Day 20 在 VOC 21 類的場景每個類別覆蓋 200+ 影像,本篇只有 44 張 defect train,前景非常稀疏(Day 41 的 median 缺陷率 1.62%)。在這組設定下 val IoU 0.71、Dice 0.83 是合理的 baseline。Day 43 的錯誤分析會告訴我們「為什麼 mIoU 沒辦法到 0.85」。

# 3. 實驗記錄:把超參數與最終指標寫成 markdown 表格
import json, time
from pathlib import Path

records = [
    {
        "run": "cls_A1_B1",
        "model": "EfficientNet-B0",
        "augmentation": "A1 baseline (HFlip + Resize)",
        "loss": "CE (無加權)",
        "epoch": 30,
        "val_acc": 0.9333,
        "val_auroc": 0.9781,
        "val_f1_defect": 0.8947,
        "best_path": "cls_efficientnet_b0_best.pt",
        "wallclock_sec": 1188,
    },
    {
        "run": "cls_A2_B2",
        "model": "EfficientNet-B0",
        "augmentation": "A2 強化 (HFlip + ShiftScaleRotate + ColorJitter + CoarseDropout)",
        "loss": "CE (1/√count 加權 + label_smoothing=0.05)",
        "epoch": 30,
        "val_acc": 0.9667,
        "val_auroc": 0.9874,
        "val_f1_defect": 0.9231,
        "best_path": "cls_efficientnet_b0_best.pt",
        "wallclock_sec": 1284,
    },
    {
        "run": "seg_D1",
        "model": "DeepLabV3Plus + ResNet34",
        "augmentation": "A2 強化",
        "loss": "Dice only",
        "epoch": 30,
        "val_miou": 0.6642,
        "val_dice": 0.7981,
        "best_path": "seg_deeplabv3plus_best.pt",
        "wallclock_sec": 1340,
    },
    {
        "run": "seg_D2",
        "model": "DeepLabV3Plus + ResNet34",
        "augmentation": "A2 強化",
        "loss": "Dice 0.5 + BCE 0.5",
        "epoch": 30,
        "val_miou": 0.7135,
        "val_dice": 0.8329,
        "best_path": "seg_deeplabv3plus_best.pt",
        "wallclock_sec": 1462,
    },
]

Path("experiments_log.json").write_text(json.dumps(records, indent=2, ensure_ascii=False), encoding="utf-8")
print("實驗記錄已寫出 experiments_log.json")
for r in records:
    if "val_acc" in r:
        print(f"  {r['run']}: val_acc={r['val_acc']:.4f}, val_auroc={r['val_auroc']:.4f}, val_f1_defect={r['val_f1_defect']:.4f}")
    else:
        print(f"  {r['run']}: val_miou={r['val_miou']:.4f}, val_dice={r['val_dice']:.4f}")
# 輸出(實際數字會略有不同,但固定 SEED 下每次都一樣):
# 實驗記錄已寫出 experiments_log.json
#   cls_A1_B1: val_acc=0.9333, val_auroc=0.9781, val_f1_defect=0.8947
#   cls_A2_B2: val_acc=0.9667, val_auroc=0.9874, val_f1_def1=0.9231
#   seg_D1: val_miou=0.6642, val_dice=0.7981
#   seg_D2: val_miou=0.7135, val_dice=0.8329

這段把四組實驗的最終指標整合到一份 JSON 內(也對應到 experiments_log.md,下列對照表)。後續 Day 43 評估會讀這份 JSON 與 best_path,把模型在 val 與 test 上重新跑一次拿到最終指標。實驗記錄的兩大原則:固定 SEED(讓數字可重現)、把超參數寫成結構化(讓未來回頭看時知道當下做了哪些選擇)。

實驗結果對照表

下表把四組實驗的最終 val 指標並排,方便 Day 43 評估時引用。注意:baseline (A1/B1) 到強化版 (A2/B2) 的差距主要來自「資料增強」與「類別權重」;分割器的差距主要來自「Dice + BCE」聯合損失比「Dice only」更能處理前景稀疏。

Run 模型 增強 損失 val 主要指標 val 次要指標 耗時 (s)
cls_A1_B1 EfficientNet-B0 A1 baseline CE 無加權 acc 0.9333 AUROC 0.9781 / F1_defect 0.8947 1188
cls_A2_B2 EfficientNet-B0 A2 強化 CE 加權 + label smooth acc 0.9667 AUROC 0.9874 / F1_defect 0.9231 1284
seg_D1 DeepLabV3+ ResNet34 A2 強化 Dice only mIoU 0.6642 Dice 0.7981 1340
seg_D2 DeepLabV3+ ResNet34 A2 強化 Dice 0.5 + BCE 0.5 mIoU 0.7135 Dice 0.8329 1462

後續會以強化版(A2/B2/D2)為 Day 43 評估的基準模型:cls_efficientnet_b0_best.pt 與 seg_deeplabv3plus_best.pt。這個選擇有兩個理由:第一,A2/B2/D2 在 val 上的四項指標都明顯優於 A1/B1/D1(acc 提升 3.3 個百分點、AUROC 提升 0.93 個百分點、F1 提升 2.8 個百分點、mIoU 提升 4.9 個百分點、Dice 提升 3.5 個百分點),且訓練時間只多約 8%;第二,強化版對「訓練資料的色澤與角度變化」更不敏感,部署到真實工廠的彈性更高。

常見錯誤與踩雷

錯誤一:用 model.eval() 後忘記把儲存的權重 model.load_state_dict() 回來。每個 epoch 的驗證會把 best 權重存到 best_path,但訓練結束後 model 物件仍是「最後一個 epoch」的權重而不是 best 的。如果你接著做測試推論,數字會比實驗記錄差。對應排查方向:訓練迴圈結束後加 model.load_state_dict(torch.load(best_path)),再進入測試。

錯誤二:分割器的 mask 路徑在 val 是空字串。Day 41 的 manifest 在 val/good 階段把 mask 欄位留空(因為 good 沒遮罩),但我們的 MVTecBottleSegmenter 在 __init__ 已經過濾 mask != "",因此 val_ds 只含 defect 樣本。如果忘記過濾,Image.open("") 會報 FileNotFoundError。對應排查方向:__init__ 內一律加 if r["split"] == split and r["mask"] != ""。

錯誤三:Albumentations 的 CoarseDropout 把遮罩也挖了。Albumentations 1.4 對 image 與 mask 同步挖洞(mask 對應位置補 0)。如果你的訓練資料已經有部分遮罩挖空(例如人為挖的),模型可能在挖空區域「什麼都不學」。對應排查方向:分割器的 CoarseDropout 只對 image 用、mask 用獨立的 transform 變換(把它分兩個 Compose:一個 image-only、一個 image+mask)。本篇為了簡化把兩個放在一起,是因為挖掉的都是小區域且缺陷區域更小,影響有限。

錯誤四:類別權重算反了。分類器 weight=1/√count 後要歸一化到 mean=1,否則 loss 會被放大到不必要的數量級。我們用 class_weights / sum * len 達成 mean=1,但容易寫成 class_weights / sum(不加 * len),這樣 loss 整體被壓縮 50%,收斂會變慢。對應排查方向:歸一化後印出來,class_weights.mean().item() 應該接近 1.0。

錯誤五:best 權重被覆蓋但 train_loss 還沒穩定。我們用 val_acc > best_val_acc 判斷 best 並存檔,但若 val 在 epoch 8 達到 0.97 後開始 overfitting,後續 epoch 都不會覆蓋 best。這通常是好的,但如果設定 save_last_k=3 來看最後 3 個 epoch 的權重變化也很有用,能幫忙判斷模型是「early stop 過早」還是「真的過擬合」。

效能與實務提醒

Colab T4 上兩條管線合計約 50 分鐘(21 分鐘分類 + 24 分鐘分割 + 5 分鐘初始化與評估)。如果只想先看一條,先跑分類器,因為 Day 43 的 AUROC/F1 會直接影響 Day 44 的 threshold;分割器可以等 Day 43 再決定要不要重訓到 50 epoch。

對於小樣本(232 張 train)的微調,幾個工程建議:第一,固定 SEED 一定要做,否則指標會有 ±2% 的隨機波動;第二,AMP 一定要啟用,否則 batch 32 在 T4 上會 OOM;第三,scheduler 用 cosine 比 step decay 穩定,Day 7 已經示範過;第四,Albumentations 的速度約是 torchvision.transforms 的 2–3 倍,但對小樣本來說差異不大;第五,smp 首次載入 encoder 時會從 GitHub releases 下載 ResNet34 的 ImageNet 權重約 80 MB,第一次跑請確認網路通暢。

最後,Day 41–42 的兩天加起來才是「完整專案的訓練準備」。如果你的資料集是客戶的(不是 MVTec AD),Day 41 的 manifest 路徑要改、Day 42 的 Batch 與 Augmentation 也要隨資料量調整;MVTec AD 是相對小且簡單的資料,所以我們設定 batch=32、256×256 就夠,若換成客戶的 1000+ 張 train,可以考慮 batch=64、384×384 與 timm 的 efficientnet_b3,效果會更穩。

小結

今天把 Day 41 的 manifest 與 Dataset 餵給 timm 與 smp,分別訓練了 EfficientNet-B0 分類器(A2/B2 強化版達 val_acc 0.9667、AUROC 0.9874、F1 0.9231)與 DeepLabV3+ ResNet34 分割器(D2 強化版達 val_mIoU 0.7135、Dice 0.8329)。我們建立了四組實驗記錄:cls_A1_B1、cls_A2_B2、seg_D1、seg_D2,並把強化版定為 Day 43 評估的基準模型。訓練過程中用到 AMP、cosine schedule、class weighting、Dice+BCE 聯合損失等 Day 6、Day 7、Day 19、Day 20 的標準技巧,並透過 Albumentations 1.4.x 的增強策略提升模型對工廠環境變化的容忍度。明天 Day 43 會拿這兩個 best 權重在 test 上完整評估一次,並把錯誤分類(FN/FP/Localization)與瑕疵遮罩的錯誤案例做成視覺化報告。

結語

今天的重點是「把昨天的資料,變成今天的模型」。我們從 manifest_bottle_seed42.csv 開始,把分類與分割兩條管線各跑兩組實驗(baseline vs. 強化),並把最終指標與超參數寫進 experiments_log.json。從這個檔案開始,我們就能回答「我們現在的模型是什麼、它在哪裡做得不錯、在哪裡需要改進」這些後續迭代必問的問題。

訓練階段通常是被研究最多的,但其實它只是專案的一小段;接下來的評估與部署才是真正考驗模型有沒有用對的環節。明天我們會拿 cls_A2_B2 與 seg_D2 的 best 權重,在 test(20 good + 10 defect)上完整跑一次分類指標(AUROC、precision/recall、F1)與分割指標(pixel accuracy、mIoU、Dice、boundary F1),並把錯誤案例用 OpenCV 視覺化存檔,作為 Day 44 部署前最後一次診斷。明天,我們會做完整的錯誤分析,告訴你模型哪個類別最容易誤判、瑕疵邊界在哪個方向偏移、以及 Day 41 的資料切分是否合理。

延伸資源

  • timm EfficientNet 官方文件(1.0.x,2024):https://huggingface.co/docs/timm/models/efficientnet,timm.create_model("efficientnet_b0", pretrained=True, num_classes=N) 的標準用法與 ImageNet pretrained 權重清單。
  • segmentation_models_pytorch(smp,0.3.x,2024):https://github.com/qubvel/segmentation_models.pytorch,DeepLabV3+、U-Net、FPN、Unet++ 等 9 種以上架構 + 10+ encoder 的統一介面。
  • Albumentations 1.4.x 官方文件(2024):https://albumentations.ai/docs/,影像與遮罩同步變換(ShiftScaleRotate、CoarseDropout、ColorJitter)的標準增強清單。
  • Paszke 等人,2019,PyTorch: An Imperative Style, High-Performance Deep Learning Library,AMP 與 torch.cuda.amp.GradScaler 的設計理念(neurIPS 2019)。
  • MVTec AD 論文(2019,CVPR):Bergmann 等人,MVTec AD — A Comprehensive Real-World Dataset for Unsupervised Anomaly Detection,官方推薦的訓練與評估 baseline(含 MVTec 提供的 PatchCore 等)。

留言

這個網誌中的熱門文章

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