CV Day 3 分類任務回顧與進階挑戰
執行需求:CPU 可跑。今天我們正式進入分類任務,把 CNN、ResNet、感受野與資料流等基礎觀念快速回顧一次,並指出「會訓練」到「能獨立完成分類專案」之間還差哪些能力。讀完之後,你會知道分類任務在深度學習中的角色、典型模型家族的演進,以及這個系列後續會在分類章節中帶你跨越的幾道關卡。
引言
影像分類(image classification)是電腦視覺最古老也最基本的任務:給一張圖,輸出一個標籤。這個任務在 2012 年 AlexNet 奪得 ImageNet 冠軍之後有了突破性的進展,此後的 VGG、GoogLeNet、ResNet、DenseNet、EfficientNet、ConvNeXt、ViT 一路演進,每一代都把 ImageNet 的 top-5 錯誤率往下壓。到了 2024 年底,公開模型在 ImageNet 上的 top-1 已經突破 90%,剩下的錯誤多是人類也難以分辨的細節。
不過,學術指標進步不代表實務上就容易。現實世界的分類任務往往面臨:標註資料少、類別不平衡、影像品質差、類別定義模糊(什麼算「瑕疵」、什麼算「正常」)、部署環境受限(CPU 推論、邊緣裝置)等問題。從會訓練 ResNet 到能在工廠上線一個分類系統,中間隔著「資料策略、評估方法、錯誤分析、模型選擇、部署最佳化」這幾道關卡。
今天這篇是分類章節的開篇章,我們會快速複習 CNN 的核心概念、整理 ResNet 帶來的關鍵設計、並預告 Day 4 到 Day 9 會分別處理的進階挑戰。今天不會動到訓練,但會給你一個完整的「分類任務地圖」,後續章節會逐一實作。
CNN 的核心觀念:捲積、池化、感受野
捲積神經網路(Convolutional Neural Network,CNN)是分類任務的標配。它的設計靈感來自貓的視覺皮層:每一層只關注影像的局部區域,並用同一組權重掃過整張影像。這種「局部連接 + 權重共享」大幅減少參數數量,也讓模型對平移、縮放具有一定的不變性。
幾個關鍵詞必須先釐清:
- 捲積核(kernel / filter):一個小尺寸的權重矩陣(例如 3×3),用來偵測影像中的某種特徵。CNN 訓練的過程就是學出一組有意義的捲積核。
- stride(步幅):捲積核每次移動的距離。stride = 1 保留原尺寸,stride = 2 則把特徵圖縮小一半。
- padding(補邊):在影像邊緣補 0,讓捲積後的尺寸符合預期。最常用的是「same padding」,補到輸出與輸入同尺寸。
- 池化(pooling):縮小特徵圖尺寸、降低後續計算量。最常見的是 max pooling,取 2×2 區塊中的最大值,讓尺寸縮小一半。
- 感受野(receptive field):輸出特徵圖上某一個位置對應到原圖的區域大小。隨著網路加深,每一層的感受野會擴大,最後幾層能「看見」整張影像。
以 ResNet18 為例,它的 stem 是 7×7 stride-2 的捲積加上 3×3 max pooling,把 224×224 的輸入縮到 56×56;接著四個 stage,每個 stage 包含若干殘差塊,每個殘差塊是兩個 3×3 捲積加上一個 skip connection;最後用全域平均池化把特徵圖壓成 1×1,再接全連接層輸出類別機率。這樣的設計在 2015 年讓 ImageNet 錯誤率降到 3.57%,是近代 CNN 的起點。
為什麼 ResNet 的 skip connection 這麼重要
在 ResNet 之前,主流的看法是「網路越深越好」。但實驗發現,當網路加深到某個深度後,訓練誤差反而上升,這個現象稱為「退化」(degradation),跟過擬合無關,而是最佳化變難。ResNet 的解法是讓每一層學習「殘差」(residual)而不是直接學習映射:
y = F(x) + x
其中 x 是輸入、F(x) 是兩層捲積的輸出。這條「捷徑」(shortcut 或 skip connection)讓梯度可以直接從後面流回前面,避免梯度消失。這個設計看似簡單,卻是 2015 年以來幾乎所有視覺模型(甚至 Transformer)都會借用的關鍵技巧。
資料、模型、評估:分類任務的三個軸
進入實戰前,要先理解分類任務的三個軸:資料、模型、評估。教科書往往只講模型,但實務上決定成敗的常是另外兩個。
資料軸
分類任務需要「影像 + 標籤」對應的資料集。標籤的形式通常是一個整數(類別索引)。最簡單的格式是把所有影像放到以類別命名的資料夾:
dataset/
├── train/
│ ├── cat/
│ │ ├── img001.jpg
│ │ └── img002.jpg
│ └── dog/
│ └── img003.jpg
└── val/
├── cat/
└── dog/
這種結構剛好對應 torchvision.datasets.ImageFolder 的預期格式,所以 Day 9 實戰時可以直接套用。資料切分要遵守「訓練與驗證完全分離」的原則:同一張影像不能同時出現在兩個集合裡,否則評估指標會被高估。
影像資料的挑戰還包括:類別不平衡(長尾分布)、標註錯誤、影像解析度不一、領域偏移(訓練資料來自網路,測試資料來自工廠攝影機)。這些問題在 Day 8(評估)與 Day 9(Oxford Flower-102 實戰)會進一步討論。
模型軸
2024 年底,分類模型可以粗分為三大家族:
- CNN 家族:ResNet(2015)、EfficientNet(2019)、ConvNeXt(2022)。CNN 對小資料集與邊緣裝置推論特別友善。
- Transformer 家族:ViT(2021)、Swin Transformer(2021)、DeiT(2021)。在大量資料下表現通常超過 CNN,但對資料量與計算資源需求高。
- 混合家族:CoAtNet、ConvFormer 等,把 CNN 的局部性與 Transformer 的全域注意力結合。
這個系列固定 PyTorch 2.5 + torchvision 0.20 + timm 1.0.x,這意味著你至少可以用幾十種預訓練模型,而且大多數都有 ImageNet 預訓練權重。Day 5 會帶你認識 ResNet、EfficientNet、ConvNeXt、ViT 的差異,並實際用 timm 把每種模型載入一次。Day 6 會做遷移學習,把預訓練權重當作起點。
評估軸
分類任務的評估指標看似簡單(正確率),但魔鬼藏在細節裡。三個最常見的指標:
- top-1 accuracy:預測機率最高的類別是正確答案的比例。最常用。
- top-5 accuracy:預測機率前五高的類別中有正確答案的比例。ImageNet 時代的主流指標,因為早期模型的 top-1 很低。
- balanced accuracy:每個類別的 recall 平均。在類別不平衡時,比 top-1 accuracy 更能反映真實表現。
除了 accuracy,還有混淆矩陣(confusion matrix)、precision、recall、F1、ROC AUC、PR curve 等細部指標。Day 8 會逐一展示如何在 PyTorch 裡計算,並用 MVTec AD 的模擬資料示範「模型看起來正確率高,但其實漏掉很多瑕疵」的情境。
分類任務的常見挑戰:實務上的六道關卡
把分類任務從教科書搬到現實,會遇到六道關卡。這個系列會在 Day 4 到 Day 9 逐一處理:
- 資料增強:訓練資料太少或太單調時,用旋轉、裁切、翻轉、RandAugment、MixUp、CutMix 等方法擴增。Day 4 專章處理。
- 模型選擇:CNN vs ViT、深度 vs 寬度、預訓練權重的遷移性。Day 5 比較。
- 遷移學習:預訓練權重怎麼微調,要 freeze 哪些層。Day 6 實作。
- 訓練技巧:餘弦排程、標籤平滑、EMA、混合精度。Day 7 介紹。
- 評估與診斷:top-k、混淆矩陣、信心校準、誤判分析。Day 8 教。
- 完整實戰:Oxford Flower-102 從下載、清理、訓練、評估到產生報告。Day 9 整合。
這六道關卡每一道都值得一整章細講。今天我們先把每一關的核心概念用一小段話點出,後續章節再展開:
資料增強的核心是「在不破壞語意的前提下擴增資料」。簡單的翻轉、裁切還算安全;MixUp 把兩張影像疊在一起、CutMix 把一塊貼過去,則需要模型學會看「軟標籤」(soft label)。這種增強在 ImageNet 上能穩定帶來 0.5% 到 1% 的 top-1 進步。
模型選擇沒有標準答案,但有兩個實用原則:如果資料少(< 10k 張),優先選 CNN + 預訓練;如果資料多(> 100k 張),ViT 或 ConvNeXt 通常更強;如果要部署到邊緣裝置,EfficientNet 或 MobileNet 系列是首選。
遷移學習最常見的誤區是把所有層都打開微調。小資料集這樣做會過擬合;正確做法是「凍住 backbone 的前幾層,只微調後幾層 + classifier」。這個原則會在 Day 6 講清楚。
訓練技巧看起來花俏,但效果驚人。餘弦排程(cosine annealing)讓學習率從大慢慢降到小,比固定學習率收斂更快;標籤平滑(label smoothing)把 one-hot 標籤稍微「磨平」,能提升泛化;EMA(Exponential Moving Average)是模型參數的時間平均,對噪聲大的訓練很有用;混合精度(AMP)則是用 FP16 省顯存。
評估與診斷的核心是「不只看數字,要看錯誤」。混淆矩陣會告訴你哪些類別互相混淆;信心校準會告訴你模型說「90% 確定」是不是真的 90%;誤判分析則是人工抽樣看錯的影像,找出系統性的問題(例如反光、陰影、影像中的文字)。
完整實戰則是把前面五關全部串起來。Oxford Flower-102 是個 102 類的花卉資料集(1020 張訓練、6149 張測試),規模剛好適合在 Colab T4 上跑完一輪完整的遷移學習。我們會用它當作 Day 9 的範例。
完整實作:在 CPU 上重現 ResNet18 的最小推論
雖然今天不訓練模型,但可以跑一個「從零建一個小型 ResNet」的練習。這個練習只會印出模型結構與參數量,目的是讓你直觀感受「ResNet 到底長什麼樣」。
import torchvision
from torchvision.models import resnet18, ResNet18_Weights
# 1. 載入未訓練的 ResNet18(隨機初始化)
model_scratch = resnet18(weights=None)
print("未訓練 ResNet18 參數量:", sum(p.numel() for p in model_scratch.parameters()))
# 輸出:未訓練 ResNet18 參數量:11689512
# 2. 載入 ImageNet 預訓練版本
weights = ResNet18_Weights.DEFAULT
model_pretrained = resnet18(weights=weights)
print("預訓練 ResNet18 參數量:", sum(p.numel() for p in model_pretrained.parameters()))
# 輸出:預訓練 ResNet18 參數量:11689512
這個區塊示範兩件事:第一,「未訓練」跟「預訓練」的參數量是一樣的(都約 11.7M),差別只在權重值;第二,weights=None 會跳過下載,產生隨機初始化的模型,這在你想從頭訓練時很有用;weights=DEFAULT 會自動下載 ImageNet 預訓練權重,作為遷移學習的起點。
接下來印出 ResNet18 的完整結構,並且標出每一層的輸出尺寸:
from torch import nn
# 假設輸入是 (1, 3, 224, 224)
dummy = torch.randn(1, 3, 224, 224)
hooks = []
def register_hook(module):
def hook(m, inp, out):
hooks.append(f"{type(m).__name__:<25} 輸出:{tuple(out.shape)}")
module.register_forward_hook(hook)
for m in model_pretrained.modules():
if isinstance(m, (nn.Conv2d, nn.MaxPool2d, nn.AdaptiveAvgPool2d, nn.Linear)):
register_hook(m)
_ = model_pretrained(dummy)
print("\n".join(hooks[:12])) # 只印前 12 個關鍵層
# 輸出(部分):
# Conv2d 輸出:(1, 64, 112, 112)
# MaxPool2d 輸出:(1, 64, 56, 56)
# Conv2d 輸出:(1, 64, 56, 56)
# Conv2d 輸出:(1, 64, 56, 56)
# Conv2d 輸出:(1, 64, 56, 56)
# Conv2d 輸出:(1, 64, 56, 56)
# Conv2d 輸出:(1, 128, 28, 28)
這個 hook 範例是觀察模型結構的標準技巧:用 register_forward_hook 在每一層註冊一個 callback,模型跑一次 dummy 輸入時,所有 hook 就會被觸發,回報該層的輸出形狀。這對除錯非常有幫助——當你看到「某一層輸出尺寸不對」,就知道問題出在哪。
最後我們做一個非常實用的練習:把 ResNet18 改成「適配自己的類別數」。這是遷移學習的第一步:把最後一層的全連接層從 1000 類(ImageNet)換成你想要的類別數。
import torch.nn as nn
# 把最後一層 fc 換成 4 類分類器(瑕疵分類:normal, scratch, dent, stain)
model_pretrained.fc = nn.Linear(in_features=512, out_features=4)
print("新分類器:", model_pretrained.fc)
# 輸出:新分類器: Linear(in_features=512, out_features=4, bias=True)
# 確認只有最後一層的參數是新初始化的
new_params = sum(p.numel() for p in model_pretrained.fc.parameters())
total_params = sum(p.numel() for p in model_pretrained.parameters())
print(f"新分類器參數量:{new_params}(佔總參數 {100 * new_params / total_params:.2f}%)")
# 輸出:新分類器參數量:2050(佔總參數 0.02%)
這個範例展示了遷移學習的核心:模型主體的權重來自 ImageNet 預訓練(已經學會邊緣、紋理、形狀等通用特徵),最後一層換成你自己的分類器,只需要 2,050 個參數就能適配到 4 類問題。這就是為什麼「預訓練 + 微調」比「從頭訓練」在小資料集上效果好得多——預訓練權重讓模型在起步時就站在巨人的肩膀上。Day 6 會把這個技巧展開,示範如何只訓練部分層、如何設定學習率、以及如何評估遷移學習的效果。
為了讓你更直觀地理解 ResNet 內部在「看什麼」,我們用一個小技巧把第一層捲積的權重視覺化:
import torch
import matplotlib
matplotlib.use("Agg") # 在沒有顯示器時跳過 GUI
import matplotlib.pyplot as plt
# 取出第一層捲積的權重,形狀 (out_channels, in_channels, kH, kW)
first_conv = model_pretrained.conv1.weight.data.clone()
print("第一層捲積權重形狀:", tuple(first_conv.shape))
# 輸出:第一層捲積權重形狀: (64, 3, 7, 7)
# 把 64 個 7x7 濾波器排成 8x8 顯示
fig, axes = plt.subplots(8, 8, figsize=(8, 8))
for i, ax in enumerate(axes.flat):
kernel = first_conv[i].permute(1, 2, 0).numpy()
kernel = (kernel - kernel.min()) / (kernel.max() - kernel.min() + 1e-8)
ax.imshow(kernel)
ax.axis("off")
plt.suptitle("ResNet18 第一層捲積的 64 個濾波器")
plt.tight_layout()
plt.savefig("resnet18_conv1.png", dpi=80)
print("濾波器視覺化已寫入 resnet18_conv1.png")
# 輸出:濾波器視覺化已寫入 resnet18_conv1.png
這個區塊把 ResNet18 第一層的 64 個 7×7 濾波器攤平顯示出來。經過 ImageNet 預訓練後,這些濾波器會呈現有趣的模式:有些是邊緣偵測器(橫向、縱向、對角線),有些是色彩對比偵測器(藍-黃、紅-綠)。這就是深度學習的「特徵學習」——不需要人工設計 edge detector,模型自己會學出來。視覺化濾波器是驗證模型有沒有學到合理特徵的最直觀方法。
接著做一個「資料流」的最小練習:用 torchvision 的 ImageFolder 建立一個分類資料集,並印出第一筆資料:
import os
import tempfile
from PIL import Image
import torchvision.datasets as datasets
# 在臨時目錄建立一個假的兩類資料集
tmpdir = tempfile.mkdtemp(prefix="demo_cls_")
for cls in ["cat", "dog"]:
os.makedirs(os.path.join(tmpdir, "train", cls), exist_ok=True)
img = Image.new("RGB", (64, 64), color=(255, 128, 0) if cls == "cat" else (0, 128, 255))
img.save(os.path.join(tmpdir, "train", cls, f"{cls}_001.jpg"))
# 用 ImageFolder 載入
train_ds = datasets.ImageFolder(os.path.join(tmpdir, "train"))
print(f"類別對應:{train_ds.class_to_idx}")
# 輸出:類別對應:{'cat': 0, 'dog': 1}
print(f"資料筆數:{len(train_ds)}")
# 輸出:資料筆數:2
img, label = train_ds[0]
print(f"第一筆影像尺寸:{img.size}, 標籤:{label}")
# 輸出:第一筆影像尺寸:(64, 64), 標籤:0
# 清理
import shutil
shutil.rmtree(tmpdir)
print("已清理臨時目錄")
# 輸出:已清理臨時目錄
這個範例展示分類任務的「資料入口」最常見的形式:ImageFolder 讀資料夾結構、class_to_idx 把類別名稱對應到整數、__getitem__ 回傳 (影像, 標籤) 配對。在實戰時,你只需要把這個 ImageFolder 包進 DataLoader 就能開始訓練;前處理(Resize、ToTensor、Normalize)則用 transforms.Compose 加在 transform 參數裡。這個套路會在 Day 9 的 Oxford Flower-102 實戰時完整出現。
最後示範「分類模型權重的儲存與載入」:
# 儲存微調後的權重(只存參數,不存整個模型)
torch.save(model_pretrained.state_dict(), "resnet18_finetuned.pt")
print("已儲存權重:resnet18_finetuned.pt")
# 輸出:已儲存權重:resnet18_finetuned.pt
# 之後載入
loaded = resnet18(weights=None) # 先建立空模型
loaded.fc = nn.Linear(in_features=512, out_features=4)
loaded.load_state_dict(torch.load("resnet18_finetuned.pt", map_location="cpu"))
loaded.eval()
print("已載入權重並設為 eval 模式")
# 輸出:已載入權重並設為 eval 模式
torch.save(state_dict, path) 只存模型的權重(不存結構),這是 PyTorch 官方推薦的儲存方式——優點是檔案小、版本相對寬鬆、未來改模型結構時只會影響「沒對應到的層」警告,不會整個壞掉。載入時要先建立一個結構相同的模型,再 load_state_dict 灌進去。map_location="cpu" 確保即使你在 GPU 訓練、在 CPU 部署,也能順利載入。
常見錯誤與踩雷
常見錯誤與踩雷
第一個常見錯誤是「訓練集與驗證集沒分乾淨」。例如同一張影像被複製到不同資料夾、或是時間序列資料被隨機切分(導致訓練時看到未來的資料)。表現是訓練 accuracy 99%、驗證 accuracy 70%,但實際部署後表現很差。對應的排查方向:用 hashlib.md5(影像內容) 檢查是否有重複;時序資料用時間切分而非隨機切分;切分後用 Counter(label) 確認兩邊類別分布一致。
第二個是「資料前處理沒對齊預訓練」。用 ImageNet 預訓練的 ResNet,期待輸入是經過特定 Normalize 的張量(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225));如果忘了 Normalize 或用了錯誤的 mean/std,模型表現會比隨機猜還差。對應的排查方向:用 weights.transforms() 直接取預訓練權重對應的前處理,不要自己寫。
第三個是「忘了把模型設成 eval 模式」。驗證時如果忘了呼叫 model.eval(),BatchNorm 與 Dropout 會繼續用訓練時的統計量與遮罩,導致推論結果不穩定。對應的排查方向:寫一個 helper 把「訓練/驗證」包成 context manager;或者用 torch.no_grad() 加 model.eval() 一起呼叫。
第四個是「類別索引對不上」。模型的輸出索引是 0, 1, 2, ...,但你的標籤可能叫「cat, dog, bird」。如果直接用資料夾名稱當索引,順序會被作業系統排序決定,造成每次訓練結果不一致。對應的排查方向:建立一份明確的 class_to_idx 字典並寫到 JSON,重現實驗時讀回來。
效能與實務提醒
CPU 上做分類推論是可行的,但只能跑小型模型(ResNet18、ResNet34)或量化後的模型。ResNet50 在 CPU 推論單張影像約 100 到 200 毫秒;ViT-Large 在 CPU 上則可能超過 1 秒。如果部署場景是即時影像串流(30 FPS),就一定要 GPU 或 ONNX + TensorRT 之類的最佳化。
訓練時間預估:在 Colab T4 上,ResNet18 微調 Oxford Flower-102(1020 訓練、6149 測試、102 類)一個 epoch 約 1 分鐘,10 個 epoch 約 10 分鐘。ResNet50 約 2 到 3 分鐘一個 epoch。ViT-Base 則要 8 到 10 分鐘。這些數字會受 batch size、影像尺寸、是否用 AMP 影響,僅供規劃使用。
最後提醒一件事:分類任務往往不是終點,而是更大系統的一部分。Day 41 的工業瑕疵檢測專案,分類只是第一步——分類結果會被送到偵測與分割模組,最後接到部署 API。今天先有個心理準備:分類的「正確」不一定等於系統的「正確」,要從整體視角看每個模組的角色。
小結
今天把分類任務的基礎一次複習完:CNN 的核心觀念(捲積、stride、padding、感受野)、ResNet 的 skip connection 為什麼重要、資料/模型/評估三個軸的關係、以及從 Day 4 到 Day 9 會處理的六道關卡。我們還用 ResNet18 跑了三個實作:載入隨機 vs 預訓練權重、用 hook 觀察中間層輸出、把最後一層換成 4 類分類器。明天 Day 4 會進入第一道關卡——資料增強,介紹 RandAugment、MixUp、CutMix 這些在 2024 年仍是最常用的高階增強方法。
結語
分類任務看起來簡單,背後卻藏著深度學習的整體邏輯:資料策略、模型設計、訓練技巧、評估診斷。我們今天只摸了表面,明天開始會一層一層挖深。明天我們會從「資料增強進階」開始:除了最基礎的翻轉、裁切、色彩抖動,還會帶你認識 RandAugment 怎麼自動化選擇增強組合、MixUp 怎麼用線性內插產生軟標籤、CutMix 怎麼用區塊遮罩模擬遮擋。這三招是 2024 年仍是最強的分類增強組合拳。
延伸資源
- ResNet 原始論文(He et al., 2015):
https://arxiv.org/abs/1512.03385 - torchvision 0.20 預訓練模型清單(2024):
https://pytorch.org/vision/stable/models.html - timm 1.0.9 模型動物園(2024):
https://huggingface.co/docs/timm/ - CS231n 卷積神經網路視覺化教學(Stanford,2024 維護):
https://cs231n.github.io/ - PyTorch 官方 ImageNet 訓練範例(2024):
https://github.com/pytorch/examples/tree/main/imagenet
留言
張貼留言