CV Day 8 評估與診斷:top-k、混淆矩陣、信心校準
執行需求:CPU 可跑。本篇所有範例都在一般筆電的 CPU 上跑得動;包含一個小型 CNN 在 CIFAR-10 子集上的訓練(約 3 分鐘)與完整的 top-k、混淆矩陣、信心校準分析。GPU 會跑得更快,但不是必須。
引言
昨天的內容中,我們把四個訓練技巧一次疊上去,讓模型在同一個資料集上多拿 0.5–1.5 個百分點的 top-1。但訓練結束並不表示工作完成:模型到底學到了什麼?它在哪些類別上特別容易出錯?它給出的信心分數到底可不可信?這些問題需要一套完整的「評估與診斷」方法才能回答。評估不是把測試集丟進模型、印一個正確率就算交差,而是要從多個面向拆解模型的行為,找出下一步該改進的方向。
這一篇要帶你認識影像分類任務的三個核心評估工具:top-k 正確率看模型在前 k 個預測中是否包含正確答案;混淆矩陣把錯誤集中在哪幾對類別之間視覺化;信心校準(calibration)則檢查模型給出的機率是否真的反映「預測為對的機率」。我們會用一個在 CIFAR-10 子集上訓練的小型 CNN 走完整流程,並把所有指標畫成圖表與表格,方便你在自己的任務上重現同樣的分析。讀完這篇,你會了解三個指標的數學定義、PyTorch 與 scikit-learn 的對應 API,以及當指標不理想時該從哪個方向改善。
top-k 正確率
top-k 正確率回答一個簡單問題:「模型的預測機率前 k 名中,有沒有包含正確答案?」對 k=1 來說就是我們熟悉的正確率(top-1);對 k=5 來說,只要正確答案出現在預測前五名就算對,這在 ImageNet 這種 1000 類任務上特別常用,因為類別之間本來就有視覺相似性(例如「哈士奇」與「阿拉斯加雪橇犬」),top-1 反而會低估模型的實力。
PyTorch 計算 top-k 不需要額外安裝套件,用 logits.topk(k, dim=1) 一次取得前 k 名的索引,再用 pred.eq(labels.view(-1, 1)).any(dim=1) 比對即可。這個寫法的關鍵在「廣播比對」:把標籤形狀從 (B,) 擴展成 (B, 1),再與 (B, k) 的預測做元素相等比較,最後沿著 k 軸取 any,就能一次得到每個樣本是否命中。下面這段示範兩個簡單情境:
import torch
logits = torch.tensor([
[2.0, 1.0, 0.5, 0.0], # 真實類別 0
[0.1, 0.2, 3.0, 0.5], # 真實類別 2
[0.7, 0.6, 0.5, 0.4], # 真實類別 1
])
labels = torch.tensor([0, 2, 1])
# 取前 2 名預測
top2 = logits.topk(2, dim=1).indices
print("top-2 預測:", top2.tolist())
# 輸出:top-2 預測:[[0, 1], [2, 0], [0, 1]]
# top-2 是否包含正確答案
hit = top2.eq(labels.view(-1, 1)).any(dim=1)
print("是否命中:", hit.tolist())
# 輸出:是否命中:[True, True, False]
第三個樣本沒命中,因為它的正確類別 1 排在預測第 2 名之後。實務上 top-k 的選擇與任務性質有關:二分類任務永遠只看 top-1 即可;100 類以下的任務建議同時看 top-1 與 top-3;1000 類以上(例如 ImageNet)的標配是 top-1 與 top-5。另外要注意,top-k 指標會把「模型把正確答案排在第 2 名」視為正確,這在某些嚴格情境(例如醫療診斷)並不合適,這時要退回 top-1 或加入「margin」指標。
混淆矩陣
混淆矩陣(confusion matrix)是一個 C×C 的表格,C 是類別數,矩陣的 (i, j) 元素代表「真實類別是 i、預測為 j」的樣本數。對角線是正確預測,非對角線是錯誤。當類別數很大時,整張表不容易讀,但對 10–50 類的任務來說,它是定位「模型最容易把哪些類別搞混」最有效的工具。例如一個鳥類分類器可能把「麻雀」與「燕子」大量搞混、把「老鷹」與「禿鷲」搞混,這些訊號在 top-1 正確率中完全看不到。
混淆矩陣的計算不需要自己手寫迴圈,sklearn.metrics.confusion_matrix 與 torchmetrics.ConfusionMatrix 都能一鍵生成。實務上比較常用的是常態化版本(normalized confusion matrix):把每一行(也就是每個真實類別)除以該類別的總樣本數,讓每行加總為 1,這樣可以一眼看出「該類別有幾成的樣本被誤判成其他類別」。另一個延伸是「只保留對角線上下 K 名」的視覺化變體,把矩陣稀疏化,讓錯誤集中在少數高對比區塊。
從混淆矩陣還能進一步算出每類別的 precision、recall、F1:
- Precision(精確率):在所有「預測為類別 i」的樣本中,真的屬於類別 i 的比例。回答「模型說是 i 的時候有多可靠」。
- Recall(召回率):在所有「真實為類別 i」的樣本中,被模型正確預測為 i 的比例。回答「類別 i 的樣本被找出了多少」。
- F1-score:precision 與 recall 的調和平均,避免某一項特別高、另一項特別低時的平均失真。
當類別不平衡時,整體 top-1 正確率會被多數類別主導,而 precision/recall per class 才能看出少數類別的真實表現。實務上在工業界,precision 高、recall 低代表「模型太保守、漏掉很多正例」;recall 高、precision 低代表「模型太冒進、太多誤報」。兩種極端各有對應的調整策略,混淆矩陣正是進入這個討論的第一步。
信心校準
信心校準(confidence calibration)檢查一件事:當模型說「我有 90% 的把握這張圖是貓」時,這句話在統計上是否正確?如果模型對 100 張預測信心 0.9 的圖,最後真的有 90 張預測對,那它就是「校準良好」的;如果只有 60 張對,那就是「過度自信」(overconfident)。過度自信是現代深度學習模型的通病——CNN 與 Transformer 都傾向給出比實際準確率更高的 softmax 機率,這在醫療、自駕車等需要可靠機率輸出的場景是嚴重問題。
衡量校準最常用的指標是 Expected Calibration Error(ECE):把所有樣本依預測信心切成 M 個等寬區間(例如 10 個區間,每區寬度 0.1),計算每個區間內的「平均信心」與「實際正確率」,再以該區間樣本數加權平均絕對差:
ECE = Σ (|bin_accuracy - bin_confidence|) × (bin_size / N)
ECE 越接近 0 表示校準越好。ImageNet 上未校準的 ResNet-50 大約有 0.05–0.08 的 ECE,意即平均信心比實際準確度高 5–8 個百分點。改善校準最便宜的方法是 Temperature Scaling:在訓練完成後,用一個額外的標尺參數 T 把 softmax 的 logits 平滑化:
softmax(logits / T)
當 T > 1 時機率分佈變得更平滑(降低過度自信);T < 1 時更尖銳。把 T 在驗證集上最佳化(最小化 NLL),通常能把 ECE 從 0.07 降到 0.02 左右,且不改變 top-1 預測——這是它的最大優點:完全保留分類表現,只調整機率尺度。實作上只要在 nn.Module 上多寫一個 temperature 參數,在 evaluate 階段做 softmax 之前除以 T 即可。
另一個延伸方法是 Platt Scaling:對每個類別學一組 (a, b) 參數,把 logits 線性變換後再 softmax;以及 Dirichlet Calibration:對多類別同時學一組線性變換矩陣。實務上 Temperature Scaling 已足夠應付大多數情境,更複雜的方法需要更大的校準集才有差異。除了事後校準,Label Smoothing(昨天介紹的)在訓練階段就對校準有幫助:把 one-hot 標籤改成軟標籤後,模型被迫不要太自信,輸出機率會比硬標籤訓練的模型更貼近實際準確率。但要注意,Label Smoothing 不一定改善 ECE 本身,而是降低 overconfidence 的程度;如果目標是嚴格的 ECE 指標,仍然要做 Temperature Scaling。
完整實作
以下範例在 CPU 上跑一個小型的 CIFAR-10 子集訓練流程,並完整輸出 top-1、top-3、混淆矩陣、信心校準等指標。整段可以整段貼上執行。
需先在終端機執行:pip install torch torchvision scikit-learn matplotlib。
# 1. 準備資料:CIFAR-10 子集(每類 500 張訓練 + 100 張驗證)
import torch
from torch.utils.data import DataLoader, Subset
import torchvision
import torchvision.transforms as T
transform = T.Compose([
T.ToTensor(),
T.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)),
])
train_set = torchvision.datasets.CIFAR10("./data", train=True, download=True, transform=transform)
val_set = torchvision.datasets.CIFAR10("./data", train=False, download=True, transform=transform)
def subset(dataset, per_class):
by_class = {}
for i, (_, y) in enumerate(dataset):
by_class.setdefault(y, []).append(i)
idx = []
for y, lst in by_class.items():
idx.extend(lst[:per_class])
return Subset(dataset, idx)
train_loader = DataLoader(subset(train_set, 500), batch_size=64, shuffle=True, num_workers=2)
val_loader = DataLoader(subset(val_set, 100), batch_size=64, shuffle=False, num_workers=2)
print(f"train={len(train_loader.dataset)}, val={len(val_loader.dataset)}")
# 輸出:train=5000, val=1000
# 2. 定義一個小型 CNN(CPU 上 3 分鐘可完成訓練)
import torch.nn as nn
import torch.nn.functional as F
class SmallCNN(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(3, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(), nn.MaxPool2d(2),
nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(2),
nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(), nn.AdaptiveAvgPool2d(1),
)
self.head = nn.Linear(128, num_classes)
# 額外保留一個 temperature 參數給校準用
self.temperature = nn.Parameter(torch.ones(1), requires_grad=False)
def forward(self, x):
h = self.features(x).flatten(1)
return self.head(h) / self.temperature.clamp(min=0.05)
torch.manual_seed(0)
model = SmallCNN()
print(f"參數量:{sum(p.numel() for p in model.parameters())/1e6:.2f} M")
# 輸出:參數量:0.15 M
# 3. 訓練 3 個 epoch(CPU 約 3 分鐘)
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=0.01)
criterion = nn.CrossEntropyLoss()
for epoch in range(3):
model.train()
for imgs, labels in train_loader:
logits = model(imgs)
loss = criterion(logits, labels)
optimizer.zero_grad()
loss.backward()
optimizer.step()
print(f"epoch {epoch+1} loss={loss.item():.4f}")
# 輸出(實際數字會略有不同):
# epoch 1 loss=1.4521
# epoch 2 loss=1.1038
# epoch 3 loss=0.8947
# 4. 計算 top-1 與 top-3 正確率
@torch.no_grad()
def evaluate_topk(loader, ks=(1, 3)):
model.eval()
correct = {k: 0 for k in ks}
total = 0
for imgs, labels in loader:
logits = model(imgs)
maxk = max(ks)
_, pred = logits.topk(maxk, dim=1)
correct_mat = pred.eq(labels.view(-1, 1))
for k in ks:
correct[k] += correct_mat[:, :k].any(dim=1).sum().item()
total += labels.size(0)
return {k: correct[k] / total for k in ks}
metrics = evaluate_topk(val_loader, ks=(1, 3))
print(f"top-1 = {metrics[1]*100:.2f}%, top-3 = {metrics[3]*100:.2f}%")
# 輸出:top-1 = 70.xx%, top-3 = 90.xx%
# 5. 收集所有預測,計算混淆矩陣與 ECE
import numpy as np
from sklearn.metrics import confusion_matrix
all_logits, all_labels = [], []
with torch.no_grad():
model.eval()
for imgs, labels in val_loader:
all_logits.append(model(imgs))
all_labels.append(labels)
logits = torch.cat(all_logits)
labels = torch.cat(all_labels)
probs = logits.softmax(dim=1)
preds = probs.argmax(dim=1)
cm = confusion_matrix(labels.numpy(), preds.numpy(), labels=list(range(10)))
cm_norm = cm.astype(float) / cm.sum(axis=1, keepdims=True)
print("常態化混淆矩陣對角線平均 =", np.diag(cm_norm).mean().round(4))
# 輸出:常態化混淆矩陣對角線平均 = 0.70xx
# 6. 計算 ECE 並用 Temperature Scaling 校準
def compute_ece(probs, labels, n_bins=10):
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
bin_conf = conf[mask].mean()
bin_acc = correct[mask].mean()
ece += (bin_acc - bin_conf).abs() * mask.float().mean()
return ece.item()
ece_before = compute_ece(probs, labels)
print(f"校準前 ECE = {ece_before:.4f}")
# 輸出:校準前 ECE = 0.0842(實際數字會略有不同)
# 用驗證集最佳化 temperature
best_t, best_nll = 1.0, float("inf")
for t in np.linspace(0.5, 3.0, 26):
scaled = (logits / t).log_softmax(dim=1)
nll = -scaled[torch.arange(len(labels)), labels].mean().item()
if nll < best_nll:
best_nll, best_t = nll, t
print(f"最佳 temperature = {best_t:.2f}")
# 把校準後的 ECE 算回來
model.temperature.data.fill_(best_t)
with torch.no_grad():
probs_cal = (logits / best_t).softmax(dim=1)
ece_after = compute_ece(probs_cal, labels)
print(f"校準後 ECE = {ece_after:.4f}")
# 輸出:校準前 ECE = 0.0842(實際數字會略有不同)
# 輸出:最佳 temperature = 1.35(實際數字會略有不同)
# 輸出:校準後 ECE = 0.0421(實際數字會略有不同)
# 7. 印出每類別 precision / recall / F1
from sklearn.metrics import classification_report
class_names = ["airplane","automobile","bird","cat","deer",
"dog","frog","horse","ship","truck"]
print(classification_report(labels.numpy(), preds.numpy(),
target_names=class_names, digits=3))
這段範例把三種評估工具整合在一個流程中:先用 evaluate_topk 計算 top-1 與 top-3 正確率;用 confusion_matrix 生成混淆矩陣,再用 classification_report 印出每類別的 precision、recall、F1。最後用 compute_ece 比較校準前後的 ECE。在子集上訓練的 SmallCNN 預期 top-1 約 65–75%、top-3 約 88–92%;ECE 校準前可能在 0.05–0.15,校準後通常能降到 0.02 以下。注意 model.temperature 用 nn.Parameter 儲存但 requires_grad=False,這是實務上常見的寫法:把它當成模型的一部分儲存起來,部署時直接讀取,避免另外開設定檔。
常見錯誤與踩雷
錯誤一:在測試集上做校準。Temperature Scaling 必須在驗證集(validation set)上最佳化溫度參數,絕對不能用測試集。測試集的職責是「最後一次評估」,一旦拿來調參,評估結果就失去意義。實務上的標準切法是 train/val/test = 7/1.5/1.5,校準用 val、評估用 test。如果資料量太小,可以做交叉驗證,但同樣要在 fold 內部做校準。
錯誤二:top-k 在多標籤任務上失靈。上面介紹的 top-k 假設每張圖只有一個正確類別(單標籤)。如果你的任務是多標籤(multi-label,例如一張圖同時有「貓」與「沙發」),top-k 的「預測命中正確類別」就不再適用,要改用 mean Average Precision(mAP)、F1 多標籤版本,或 Hamming loss。本系列後續會在 CV Day 17 偵測評估中介紹 mAP,那時候會再深入多標籤評估。
錯誤三:混淆矩陣忘了常態化。當類別數不平衡時(例如類別 A 有 1000 張、類別 B 只有 50 張),原始混淆矩陣的數值會被類別 A 主導,看不出類別 B 的錯誤模式。常態化後(每行除以該類別總數),錯誤集中在哪幾對類別之間就一目了然。視覺化時也建議用 cmap="Blues" 加對數色階,讓小數值的差異更明顯。
錯誤四:把 ECE 當成單一指標。ECE 對區間數 M 很敏感:M=10 與 M=15 算出來的數值會不一樣。實務上建議固定 M=15 或 M=20,並在報告中標明區間數。另一個盲點是 ECE 對「少數高信心錯誤」很敏感:如果模型對 5 個樣本給出信心 0.99 但全錯,這 5 個樣本會把 ECE 拉高很多,但對整體 top-1 的影響不到 1%。把 ECE 與 reliability diagram(信心-準確度對照圖)一起看,才能避免被單一數字誤導。
錯誤五:Temperature Scaling 影響部署。在評估階段我們用 logits / T 重新 softmax,但這需要把 T 跟模型一起儲存並部署。忘記帶 T 會讓部署端的 softmax 回到未校準的狀態,機率輸出會跟訓練時不一致。建議把 temperature 與模型 state_dict 一起寫進 checkpoint,並在部署腳本開頭讀回來。
效能與實務提醒
本篇範例在 CPU 上跑一個 3 epoch 的 CIFAR-10 子集訓練約 3 分鐘,全部評估流程加總約 4 分鐘。如果把訓練換成預訓練模型(例如 torchvision.models.resnet18),可以直接跳過訓練段,只跑評估部分,整體時間縮短到 1 分鐘以內。混淆矩陣的計算成本是 O(N),即使 N=10 萬筆樣本也不到 1 秒。ECE 的計算稍貴一點(需要對每個樣本做信心排序),但對 CIFAR-10(1000 筆)這種規模仍然是毫秒級。
評估指標的選擇與「任務目標」直接相關:對醫療影像診斷,recall 比 precision 更重要(漏診代價大);對垃圾訊息過濾,precision 比 recall 更重要(誤殺代價大);對自駕車的物件偵測,每個物件都需要高 recall 與合理 precision。建議在寫評估程式前先想清楚「這個任務最不能容忍的錯誤是哪一種」,再決定主要看哪個指標。如果同時有多種需求,可以輸出多指標並列報告,由決策者依情境權衡。
實務上還有一個常被忽略的小細節:評估時務必固定隨機種子(torch.manual_seed(0))、關閉資料增強的隨機性(is_training=False)。如果驗證階段用了訓練模式的 transform,會把同一張驗證圖片增強成不同版本,導致評估結果不穩定、無法重現。建議把 model.eval() 與 torch.no_grad() 寫成評估函式的固定開頭,這是減少 bug 最便宜的做法。最後一個小提醒:對大型模型(例如 ViT-Large)做 ECE 評估時,softmax 在 float16 下可能會發生數值溢位,這時把 dtype 強制設為 float32 能避免「所有信心都接近 1.0」的假象。
小結
本篇帶你把模型評估從「看一個正確率」升級成「看一組指標」:top-k 回答「模型有沒有把正確答案排進前幾名」、混淆矩陣回答「模型最容易搞混哪些類別之間」、ECE 與 Temperature Scaling 回答「模型給出的機率到底可不可信」。這三個指標在實務上幾乎是分類任務的標配:論文要看 top-1 與 top-5、產品要看混淆矩陣定位改進方向、嚴格決策場景要看 ECE 評估信心品質。建議在自己的任務中固定輸出這三類指標,並把 ECE 校準納入模型儲存的一部分,這樣部署端的機率輸出就能與訓練端保持一致。
結語
今天的重點是「評估不是把測試集丟進去就好」。我們從一個訓練好的小型 CNN 開始,把它的 top-1 正確率、混淆矩陣、ECE 都拆開來看,找出它在哪些類別上最容易出錯、給出的機率是否過度自信。這正是模型迭代的核心:先看清楚錯誤,再對症下藥。讀完這篇你應該能回答:top-k 的計算原理是什麼?混淆矩陣怎麼讀?ECE 怎麼算、怎麼用 Temperature Scaling 改善?這三個問題的答案都藏在本篇的數學式與程式碼裡,請把它們整理到自己的筆記本中,明天進入實戰篇章時會用到。
在工業界,模型評估的完整流程通常包含「指標計算 + 錯誤分析 + 校準」三步。今天的範例是這個流程的最小可行版本;當資料量增加到數萬張、類別數增加到上百類時,混淆矩陣需要稀疏化或分群呈現、ECE 需要分區間細看、錯誤分析需要抽樣到具體影像做人工檢視。這些進階技巧會在後續 CV Day 17(偵測評估)與 CV Day 43(評估與錯誤分析)進一步展開。明天,我們會把今天的所有評估工具搬到一個真實的公開資料集上:Oxford Flower-102 花卉分類完整流程。
延伸資源
- Guo 等人,2017,On Calibration of Modern Neural Networks,ECE 指標的標準提出與 Temperature Scaling 的經典論文(ICML 2017,arXiv:1706.04599)。
- torchmetrics 官方文件(2024):
Accuracy、ConfusionMatrix、CalibrationError等分類評估 API(PyTorch Lightning 生態)。 - scikit-learn 官方文件(2024):
classification_report、confusion_matrix,每類別 precision/recall/F1 的標準輸出格式。 - PyTorch 官方教學(2024):Training a Classifier,CIFAR-10 訓練流程與評估的入門範例(PyTorch 2.5)。
- Wightman,
timm官方文件(2024):AverageMeter與訓練指標工具(huggingface.co/docs/timm)。
留言
張貼留言