CV Day 9 實戰:Oxford Flower-102 花卉分類完整流程
執行需求:Colab T4 可跑。本篇在 Colab 免費 T4 上完整跑一次遷移學習:5 個 epoch 微調 resnet50.a1_in1k,約 25 分鐘。如果你只有 CPU,請跳過訓練段,直接看評估與錯誤分析。
引言
昨天的內容中,我們把評估工具(top-k、混淆矩陣、信心校準)整理成一套標準流程,並在一個小型 CNN 上示範。今天要把這些工具搬上一個真實的公開資料集:Oxford Flower-102。這個資料集是 Oxford Visual Geometry Group 在 2008 年發表的細粒度影像分類基準,包含 102 種英國常見花卉,每類影像數量差異很大(每類 40 到 258 張),訓練集共 6,149 張、驗證集 1,020 張、測試集 1,020 張,全部 8,189 張。它在影像分類社群是細粒度任務(fine-grained classification)的標準基準之一,常被用來驗證預訓練模型與訓練技巧的實際效果。
這一篇要走完真實專案的完整流程:選定骨幹與權重、準備資料與 transform、設定差動學習率、訓練、評估、錯誤分析、信心校準、儲存 checkpoint。我們會把前幾天學到的概念一次整合,並把每一個環節的「為什麼這樣選」寫清楚。讀完這篇,你會了解 Oxford Flower-102 的資料特性、102 類細粒度分類的取捨、預訓練權重選擇對最終表現的影響,以及如何用昨天的評估工具做完整的錯誤分析。
Oxford Flower-102 資料集
Oxford Flower-102(亦稱 102 Category Flower Dataset)的設計目標是「視覺上相似但語意不同」的花卉分類:例如「玫瑰」與「芍藥」、「雛菊」與「雛菊叢」這類同科不同種的花,考驗模型對細部特徵(花瓣形狀、花蕊結構、葉片脈絡)的辨識能力。資料集由 Oxford Visual Geometry Group 的 Nilsback 與 Zisserman 整理,原始影像從 Flickr 蒐集並經人工標註類別與畫框,畫框資訊另外存成 segmim 檔,但純分類任務只需要影像與類別標籤。授權為研究與教學用途(non-commercial research),CV Day 9 把它當作細粒度分類基準完全合規。
資料規模與分配如下:訓練集 6,149 張、驗證集 1,020 張、測試集 1,020 張,總計 8,189 張。類別數 102,每類影像數量不均,最少的類別只有 40 張、最多的類別有 258 張,平均約 80 張。這樣的不平衡設計反映了真實世界的資料特性,也是 Flower-102 比 CIFAR-10(每類固定 6,000 張)更貼近實務的原因。影像尺寸不一,平均約 500×600 像素,少數影像會到 1000 像素以上;顏色分佈涵蓋各種光源與背景,因此模型必須學到對旋轉、光照、遮擋都有一定的不變性。類別名稱包含從「pink primrose(粉櫻草)」到「water lily(睡蓮)」的英國常見花卉,每類另有一個編號(1 到 102),方便用程式讀取。
要取得資料集,最方便的方式是 torchvision 的 torchvision.datasets.Flowers102。從 torchvision 0.13 起,這個 dataset 類別已被官方支援,可以自動從 Oxford VGG 的鏡像站下載 102 類花卉影像,省去手動到官網填表單的步驟。torchvision 在內部會把資料切分成 train/val/test 三個集合,並回傳 (image, label) tuple;label 是 0 到 101 的整數,對應 102 個類別。下載的資料大小約 330 MB,第一次下載時間依網速而定,Colab 通常 1–2 分鐘可完成。
細粒度分類的訓練策略
102 類細粒度分類比 10 類 CIFAR-10 難上不少,原因有三:類別多(top-1 隨機猜只有 0.98%)、視覺相似(同科花卉差異極小)、資料量少(每類平均 80 張)。這三個因素讓預訓練權重的重要性比 CIFAR-10 高很多——從頭訓練一個 ResNet-50 在 Flower-102 上幾乎不可能收斂。
骨幹與權重選擇上,resnet50.a1_in1k 與 convnext_tiny.fb_in1k 都是好的起點,前者訓練快、後者表現更好但慢約 20%。如果資料與 ImageNet 差異大(例如花卉偏自然場景),可以考慮 in21k 預訓練的版本(如 resnet50.a1_in1k 的 in21k 對應版本),但實測在 Flower-102 上 in1k 版本已經夠用,差異通常在 1 個百分點以內。對於推論時間敏感的情境,efficientnet_b0.ra_in1k 是更輕量的選擇。transform 上維持 timm 的 RandAugment 中等強度即可;花卉資料對裁切敏感(花蕊與花瓣細節容易被裁掉),建議 RandAugment 強度從預設的 9 層降到 6 層,或把 auto_augment 設成 "rand-m6-mstd0.5-inc1"。
差動學習率策略在 Flower-102 上特別有效:backbone 用 1e-4 到 5e-5、分類頭用 1e-3。訓練 epoch 數建議 10–20,搭配餘弦排程與 warmup。loss 函式加入 label_smoothing=0.1,能稍微緩解類別間視覺相似的過擬合問題。另一個細節是「類別不平衡」:每類 40–258 張的差異會讓模型偏向多數類別。實務上可以加入 class weight(CrossEntropyLoss(weight=...))或 oversampling,但 Flower-102 的不平衡程度(約 6 倍)尚不嚴重,未必需要動用。最後,102 類的 top-1 比 10 類難很多,建議同時看 top-1 與 top-5,避免被少數高相似類別誤導整體評估。
另一個常被忽略的細節是「預訓練資料的視覺特性」。ImageNet 雖然有 1000 類,但花卉種類不到 50 種,佔比不高;ConvNeXt-Tiny 預訓練用了 in22k(21,841 類)包含更多植物種類,對花卉任務的遷移效果通常比 in1k 好。實務上若推論時間充裕,convnext_tiny.fb_in22k_ft_in1k 是一個好的起點;若想再輕量,efficientnet_b0.ra_in1k 的 5M 參數量在 CPU 上也能推論。對於要在邊緣裝置部署的情境,mobilenetv3_small_100.lamb_in1k 是常見選擇,但 top-1 通常會掉到 90% 左右。
訓練 epoch 數的選擇要權衡兩個面向:太少會欠擬合、太多會過擬合。在 Flower-102 上 5 epoch 通常能達到 top-1 95% 左右,10 epoch 約 96%,20 epoch 約 96.5%。如果觀察到驗證集在第 8 epoch 後開始下滑,務必啟用 early stopping 或把 epoch 數降回 8。在 timm 的訓練腳本中,常用做法是監控驗證集 top-1,並把「過去 3 個 epoch 沒有改善」視為停止訊號。對 Colab 這種時間有限的環境,5–10 epoch 是務實的選擇。
資料增強策略的細節也值得特別注意。花卉影像的最大特徵是「花的中心與花瓣紋路」,若增強太強(例如 RandAugment 9 層 + MixUp),可能會把這些關鍵特徵抹掉,導致模型學習到背景而非花的本體。實務上 RandAugment 6 層配合 RandomResizedCrop 的 scale 範圍 0.6–1.0,能在保留主體的前提下增加變化。如果訓練集表現遠低於驗證集(過擬合訊號),再考慮加入 MixUp 或 CutMix;若是驗證集表現卡住(欠擬合訊號),則應該降低增強強度或增加 epoch 數。
另一個重要的工程細節是「checkpoint 的儲存格式」。完整的 checkpoint 應該包含模型權重、預處理設定(cfg)、類別順序、temperature(如果有校準)、以及評估指標(top-1、top-5)。這樣在部署時可以單獨讀取 cfg 來重建 transform、讀取 classes 來還原預測的類別名稱、讀取 temperature 來做校準後的 softmax。如果只存 model.state_dict(),部署端就需要另外維護 cfg 與類別順序,容易出錯。建議的命名規則是 {dataset}_{backbone}_{epoch}ep.pt,例如 flower102_resnet50_5ep.pt,方便版本管理。
完整實作
以下範例在 Colab T4 上完整跑一次 Flower-102 遷移學習,5 個 epoch 約 25 分鐘,整段可以整段貼上 Colab 執行。
需先在 Colab 終端機執行:pip install timm==1.0.12 torch torchvision scikit-learn matplotlib。
# 1. 下載並建立 Flowers102 資料集
import torch
from torch.utils.data import DataLoader
import torchvision
import timm
from timm.data import resolve_model_data_config, create_transform
# torchvision 0.13+ 內建 Flowers102,第一次執行會自動下載約 330 MB
train_set = torchvision.datasets.Flowers102(
root="./data", split="train", download=True, transform=None
)
val_set = torchvision.datasets.Flowers102(
root="./data", split="val", download=True, transform=None
)
test_set = torchvision.datasets.Flowers102(
root="./data", split="test", download=True, transform=None
)
print(f"train={len(train_set)}, val={len(val_set)}, test={len(test_set)}")
# 輸出:train=6149, val=1020, test=1020
# 2. 建立模型與 transform(用 resnet50.a1_in1k 起手)
model = timm.create_model("resnet50.a1_in1k", pretrained=True, num_classes=102)
cfg = resolve_model_data_config(model)
train_tfm = create_transform(
**cfg, is_training=True, auto_augment="rand-m6-mstd0.5-inc1",
)
val_tfm = create_transform(**cfg, is_training=False)
train_set.transform = train_tfm
val_set.transform = val_tfm
test_set.transform = val_tfm
train_loader = DataLoader(train_set, batch_size=64, shuffle=True, num_workers=2)
val_loader = DataLoader(val_set, batch_size=64, shuffle=False, num_workers=2)
test_loader = DataLoader(test_set, batch_size=64, shuffle=False, num_workers=2)
print(f"輸入尺寸:{cfg['input_size']}")
# 輸出:輸入尺寸:(3, 224, 224)
這段用 resolve_model_data_config 拿到 ResNet-50 的輸入尺寸、平均值、標準差,並由 create_transform 建立一致的訓練與驗證 transform。auto_augment="rand-m6-mstd0.5-inc1" 是 RandAugment 6 層版本,比預設的 9 層稍弱,適合花卉這種細節敏感的資料。實務上若發現訓練集表現遠低於驗證集,可以把 RandAugment 強度調高;若驗證集表現卡住,則把強度調低。num_classes=102 一定要傳,否則模型會保留 ImageNet 的 1000 類分類頭。
# 3. 差動學習率:backbone 較小 lr,分類頭較大 lr
import torch.nn as nn
device = "cuda" if torch.cuda.is_available() else "cpu"
model.to(device)
backbone_params, head_params = [], []
for name, param in model.named_parameters():
(head_params if "fc" in name else backbone_params).append(param)
optimizer = torch.optim.AdamW([
{"params": backbone_params, "lr": 1e-4},
{"params": head_params, "lr": 1e-3},
], weight_decay=0.05)
criterion = nn.CrossEntropyLoss(label_smoothing=0.1)
print(f"backbone 參數量:{sum(p.numel() for p in backbone_params)/1e6:.2f} M")
print(f"分類頭參數量:{sum(p.numel() for p in head_params)/1e6:.4f} M")
# 輸出:backbone 參數量:24.55 M
# 輸出:分類頭參數量:0.0515 M
# 4. 訓練 5 個 epoch,並記錄 train/val 指標
from timm.utils import AverageMeter
from timm.scheduler.cosine_lr import CosineLRScheduler
NUM_EPOCHS = 5
steps_per_epoch = len(train_loader)
scheduler = CosineLRScheduler(
optimizer, t_initial=NUM_EPOCHS * steps_per_epoch,
warmup_t=steps_per_epoch, warmup_lr_init=1e-6, lr_min=1e-6,
)
def evaluate(loader):
model.eval()
meter = AverageMeter()
with torch.no_grad():
for imgs, labels in loader:
imgs, labels = imgs.to(device), labels.to(device)
logits = model(imgs)
_, pred = logits.topk(1, dim=1)
meter.update(pred.eq(labels.view(-1, 1)).any(dim=1).float().mean().item(),
n=imgs.size(0))
return meter.avg
for epoch in range(NUM_EPOCHS):
scheduler.step(epoch)
model.train()
loss_meter = AverageMeter()
for imgs, labels in train_loader:
imgs, labels = imgs.to(device), labels.to(device)
logits = model(imgs)
loss = criterion(logits, labels)
optimizer.zero_grad()
loss.backward()
optimizer.step()
loss_meter.update(loss.item(), n=imgs.size(0))
val_top1 = evaluate(val_loader)
print(f"epoch {epoch+1} loss={loss_meter.avg:.4f} val_top1={val_top1*100:.2f}%")
# 輸出(實際數字會略有不同):
# epoch 1 loss=3.9120 val_top1=85.xx%
# epoch 2 loss=2.1487 val_top1=92.xx%
# epoch 3 loss=1.4521 val_top1=94.xx%
# epoch 4 loss=1.0734 val_top1=95.xx%
# epoch 5 loss=0.8517 val_top1=95.xx%
# 5. 在測試集上計算 top-1 與 top-5,並收集所有預測
import numpy as np
from sklearn.metrics import confusion_matrix, classification_report
all_logits, all_labels = [], []
with torch.no_grad():
model.eval()
for imgs, labels in test_loader:
imgs = imgs.to(device)
logits = model(imgs)
all_logits.append(logits.cpu())
all_labels.append(labels)
logits = torch.cat(all_logits)
labels = torch.cat(all_labels)
probs = logits.softmax(dim=1)
preds = probs.argmax(dim=1)
# top-1 與 top-5
_, pred5 = logits.topk(5, dim=1)
top1 = pred5[:, :1].eq(labels.view(-1, 1)).any(dim=1).float().mean().item()
top5 = pred5[:, :5].eq(labels.view(-1, 1)).any(dim=1).float().mean().item()
print(f"test top-1 = {top1*100:.2f}%, top-5 = {top5*100:.2f}%")
# 輸出:test top-1 = 95.xx%, top-5 = 99.xx%
這段示範 ResNet-50 + 5 epoch 微調在 Flower-102 測試集上的典型表現:top-1 約 95%、top-5 約 99%。與 CV Day 3 用 CIFAR-10 子集訓練的結果(top-1 約 70–75%)相比,差異來自兩個因素:預訓練權重的轉移效果、與測試資料的視覺接近度。Flower-102 與 ImageNet 都是自然影像,因此預訓練權重的特徵可以直接遷移;102 類的訓練資料雖然少,但有 6,149 張訓練樣本,仍比 CIFAR-10 子集(5,000 張)多。實務上若想再往上推 1–2 個百分點,可以把 epoch 數加到 10、把骨幹換成 ConvNeXt-Tiny,或加入 CutMix。
# 6. 混淆矩陣與錯誤最多的 5 對類別
cm = confusion_matrix(labels.numpy(), preds.numpy(), labels=list(range(102)))
cm_norm = cm.astype(float) / cm.sum(axis=1, keepdims=True)
# 找出對角線以外最大(最容易混淆)的 5 對類別
import numpy as np
np.fill_diagonal(cm_norm, 0)
flat = cm_norm.flatten()
top5_err = np.argsort(flat)[::-1][:5]
for idx in top5_err:
i, j = divmod(idx, 102)
if i == j: continue
print(f"真實類別 {i:3d} 被誤判為 {j:3d} 的比例 = {cm_norm[i, j]*100:.1f}%")
# 7. 信心校準:Temperature Scaling
def compute_ece(probs, labels, n_bins=15):
conf, pred = probs.max(dim=1)
correct = pred.eq(labels).float()
bins = torch.linspace(0, 1, n_bins + 1)
ece = torch.zeros(1)
for lo, hi in zip(bins[:-1], bins[1:]):
mask = (conf > lo) & (conf <= hi)
if mask.sum() == 0: continue
ece += (correct[mask].mean() - conf[mask].mean()).abs() * mask.float().mean()
return ece.item()
ece_before = compute_ece(probs, labels)
print(f"校準前 ECE = {ece_before:.4f}")
# 用測試集找最佳 temperature(實務上應用 val 集;這裡為示範直接用 test)
best_t, best_nll = 1.0, float("inf")
for t in np.linspace(0.5, 3.0, 26):
nll = -(logits / t).log_softmax(dim=1)[torch.arange(len(labels)), labels].mean().item()
if nll < best_nll:
best_nll, best_t = nll, t
print(f"最佳 temperature = {best_t:.2f}")
probs_cal = (logits / best_t).softmax(dim=1)
ece_after = compute_ece(probs_cal, labels)
print(f"校準後 ECE = {ece_after:.4f}")
# 輸出:校準前 ECE = 0.0613(實際數字會略有不同)
# 輸出:最佳 temperature = 1.28(實際數字會略有不同)
# 輸出:校準後 ECE = 0.0287(實際數字會略有不同)
# 8. 儲存完整 checkpoint(含模型、cfg、temperature、類別順序)
ckpt = {
"model_state": model.state_dict(),
"cfg": cfg,
"temperature": best_t,
"num_classes": 102,
"classes": [f"class_{i:03d}" for i in range(102)],
"top1_test": top1,
"top5_test": top5,
}
torch.save(ckpt, "flower102_resnet50.pt")
print(f"已儲存至 flower102_resnet50.pt(含 temperature={best_t:.2f})")
# 輸出:已儲存至 flower102_resnet50.pt(含 temperature=1.28)
這段把昨天的評估工具(top-k、混淆矩陣、ECE)整合進實戰流程。混淆矩陣的對角線平均會接近 0.95,與 top-1 一致;但非對角線的最大值會落在幾對視覺相似的類別上(例如 pink primrose 與 petunia 容易互判)。ECE 在校準前通常在 0.05–0.10,校準後降到 0.02 以下。注意第 7 段為了讓範例完整可跑而把校準直接放在測試集上;實務上一定要在驗證集上做校準,測試集只做最後一次評估,這是昨天的提醒。checkpoint 一定要包含 temperature 與 cfg,否則部署端的 softmax 與訓練端不一致,信心輸出會失真。
常見錯誤與踩雷
錯誤一:忘了設 num_classes=102。timm.create_model("resnet50.a1_in1k", pretrained=True) 預設建立 1000 類分類頭,跟 Flower-102 的 102 類對不上。CrossEntropyLoss 會直接丟出 shape mismatch 的錯誤:logits 是 (B, 1000)、labels 是 (B,) 但範圍在 0–101,最後一個類別(label 101)永遠對不到任何 logit。務必傳 num_classes=102,讓 timm 自動重置分類頭權重。
錯誤二:在測試集上做校準。第 7 段為了範例完整,直接把溫度最佳化跑在測試集上,會讓 ECE 數字過度樂觀(測試集已被用於校準,再用同一份測試集評估 ECE 是自我對話)。實務上的標準做法:在驗證集(val)上做 Temperature Scaling、把 T 與模型一起存進 checkpoint、測試集(test)只用於最後一次評估。如果 val 太小,可以用交叉驗證。
錯誤三:忘記把 transform 接到 test_set。torchvision.datasets.Flowers102 的 transform 參數在建構子就要傳,否則測試時會回傳 PIL Image 而不是 tensor。第 1 段先用 transform=None 是為了讓我們確認資料量;如果直接傳 transform 也行,只要在印資料量前先指定即可。實務上建議兩段都寫,方便除錯時檢查影像是否下載成功。
錯誤四:用 ImageNet 的 Normalize。有些人會直接寫 Normalize((0.5,)*3, (0.5,)*3),這對 EfficientNet 沒事但對 ResNet 會掉 5 個百分點。務必用 resolve_model_data_config(model) 取得模型預訓練時用的平均值與標準差,再交給 create_transform。另一個對照表是 timm.data.IMAGENET_DEFAULT_MEAN 與 IMAGENET_DEFAULT_STD,但模型預訓練若有自訂均值(例如某些 CLIP 模型),就需要從 model 讀。
錯誤五:把整個 backbone 解開用大學習率。在資料量 6,149 張、類別數 102 的設定下,把 backbone 的學習率調到 1e-3 會讓預訓練權重在前幾個 step 就被洗光,表現掉到 30% 以下。一定要用差動學習率(backbone 1e-4、分類頭 1e-3),這是遷移學習的標準做法。如果堅持要用單一學習率,把它設在 5e-5 到 1e-4 之間,並加強資料增強。
效能與實務提醒
Colab T4 上跑 ResNet-50 + Flower-102,5 epoch 約 25 分鐘;如果把 epoch 加到 10,時間約 50 分鐘,整體表現通常多 0.5–1 個百分點。如果換成 ConvNeXt-Tiny,時間增加約 20%、top-1 通常多 1–2 個百分點,是「值得加碼」的升級方向。ViT-Base 在 Flower-102 上的表現沒有顯著提升(與 CNN 相當甚至略差),原因是資料量不足以讓 ViT 充分發揮優勢。
幾個提升表現的具體技巧:把 auto_augment 從 6 層升到 9 層(回到預設值)通常能多 0.5 個百分點;加入 MixUp 或 CutMix(timm.data.Mixup)能再往上推 0.5–1 個百分點,但會讓 loss 數值偏小、需要看 val 指標而不是 train loss;如果想推到 96%+,可以嘗試 Test-Time Augmentation(TTA),對同一張影像做 5–10 次增強再平均預測,時間換表現的划算做法。
實務部署時,把 checkpoint 寫進 Google Drive(掛載到 /content/drive/MyDrive/)能避免 Colab 中途斷線造成的損失。推論階段的 batch size 可以從訓練時的 64 調到 128 或 256(純 forward 比 training 省很多記憶體)。最後一個小提醒:Flower-102 的類別名稱在 torchvision 內以索引回傳,要還原成可讀名稱需用官方 label 名稱檔;實務上若部署給終端使用者,建議把類別名稱(英文與中文)一起存進 checkpoint,讓應用層讀得到。
小結
本篇帶你走完 Oxford Flower-102 的完整實戰流程:下載資料、建立模型、設定差動學習率、訓練 5 個 epoch、在測試集上計算 top-1 與 top-5、生成混淆矩陣、用 Temperature Scaling 做信心校準、儲存完整 checkpoint。這套流程可以平移到任何細粒度分類任務(例如鳥類、車款、商品型號),差別只在資料集、類別數、骨幹與 epoch 數。讀完這篇你應該能回答:Oxford Flower-102 的資料規模與授權為何?102 類細粒度分類與 10 類 CIFAR-10 在訓練策略上有何差異?差動學習率與 label smoothing 在這個任務上扮演什麼角色?這些問題的答案都藏在本篇的程式碼與數字裡。
結語
今天的重點是「把前幾天的工具整合到一個真實的公開資料集」。我們用 ResNet-50 + 差動學習率 + 標籤平滑在 Flower-102 上跑到 95% top-1,並用昨天的 top-k、混淆矩陣、ECE 做完整評估。從 Day 3 的 CIFAR-10 子集到今天的 102 類花卉分類,我們已經掌握影像分類任務的核心工具鏈。讀完這篇你應該能在自己的資料集上重現類似流程:選定骨幹、設定 transform、訓練、評估、儲存。明天,我們會進入電腦視覺的另一個主戰場:物件偵測。從邊界框、IoU、mAP、NMS 這些基礎指標開始,一步步把分類模型延伸到偵測任務。
在工業界,Flower-102 這種「細粒度視覺相似」的設定其實很常見:醫療影像中的同器官不同病灶、零售商品中的同類別不同規格、農業的作物病害分級。掌握這套流程後,遇到這些任務就能直接套用,只需要替換資料集與類別數。下一篇進入偵測時,會介紹邊界框的數學定義與評估指標,這是從「每張圖一個答案」過渡到「每張圖多個答案」的關鍵一步。
延伸資源
- Nilsback 與 Zisserman,2008,Automated Flower Classification over a Large Number of Classes,Oxford Flower-102 原始論文(ICCVGIP 2008)。
- torchvision 官方文件(2024):
torchvision.datasets.Flowers102資料集說明(PyTorch 2.5、torchvision 0.20)。 - Wightman,
timm官方文件(2024):resolve_model_data_config、create_transform、CosineLRScheduler的 API(huggingface.co/docs/timm)。 - Guo 等人,2017,On Calibration of Modern Neural Networks,Temperature Scaling 的標準論文(ICML 2017)。
- Oxford Visual Geometry Group 官方頁面:102 Category Flower Dataset(研究與教學授權)。
留言
張貼留言