Day 39 模型評估與視覺化
引言
昨天把模型訓練流程架好了,今天要談的是機器學習專案最容易被忽略、卻也最能決定專案成敗的一環——模型評估與視覺化。拿到一個 95% 正確率的模型之後,事情並沒有結束:我們還要回答「這 95% 究竟代表什麼」、「模型在哪些地方出錯」、「出錯的原因是資料還是模型本身」這些問題。
這一篇會從分類指標的計算出發,介紹混淆矩陣(Confusion Matrix)、分類報告(Classification Report)、錯誤樣本分析等技巧。我們會用 Day 37 建立的 SVHN 模型當作範例,把抽象的數字轉換成可以實際行動的洞察。學完今天,你會有一套評估影像分類模型的完整工具箱。對剛接觸機器學習的人來說,這塊往往會被跳過——大家只想趕快看到模型「訓練完」,但真正專業與業餘的差別,常常就在評估這一步的細緻程度。
在測試集上做最終評估
訓練與驗證結束後,模型的真正考試是測試集。測試集在整個訓練過程中都不能拿來調參,否則評估結果會失去意義。下方示範一個標準的測試流程:載入最佳權重、把所有 batch 跑過一次、累計預測結果。
import torch
import numpy as np
from sklearn.metrics import accuracy_score
def evaluate_test(model, test_loader, device):
model.eval()
all_preds, all_labels = [], []
with torch.no_grad():
for x, y in test_loader:
x = x.to(device)
preds = model(x).argmax(dim=1).cpu().numpy()
all_preds.append(preds)
all_labels.append(y.numpy())
preds = np.concatenate(all_preds)
labels = np.concatenate(all_labels)
print(f"測試集 Accuracy: {accuracy_score(labels, preds):.4f}")
return preds, labels
# preds, labels = evaluate_test(model, test_loader, device)
這支函式除了回傳整體正確率,也會把每筆預測結果保留起來,方便後續做更細的分析。注意我們用 model.eval() 關閉 dropout 與 BatchNorm 的訓練模式,並用 torch.no_grad() 關閉梯度計算,這兩個動作都能讓推論更穩定、記憶體更省。
順帶提醒一個小坑:把 batch 一個一個丟進模型時,numpy 的 concatenate 是必要的步驟,因為每個 batch 回傳的是「該批次的預測結果」,要拼起來才會是完整的測試集預測。如果忘記 concatenate,後續呼叫 accuracy_score 時,shape 對不上會直接報錯。另一個常見錯誤是忘了把 GPU 上的 tensor 搬到 CPU(.cpu()),numpy 也沒辦法直接讀 GPU tensor。
分類報告:精確率、召回率、F1-score
在多類別任務裡,單看 Accuracy 很容易被誤導。例如 SVHN 的類別分布大致均勻,Accuracy 大致等於「平均表現」;但若遇到 9 遠多於其他數字的偏斜資料集,模型只要狂猜 9 就能拿到很高的 Accuracy,實際上其他類別幾乎完全沒預測到。
這時就要看「每個類別的精確率(Precision)、召回率(Recall)、F1-score」。三者的意義簡單來說:
- Precision:模型說是某類的樣本中,真的屬於該類的比例。精確率太低代表「誤報」太多。
- Recall:所有真的屬於該類的樣本中,模型抓到的比例。召回率太低代表「漏抓」太多。
- F1-score:Precision 與 Recall 的調和平均數,適合當作綜合指標。
scikit-learn 提供現成的分類報告,只要一行就能看到所有類別的三項指標。
from sklearn.metrics import classification_report
def show_report(labels, preds, class_names=None):
if class_names is None:
class_names = [str(i) for i in range(10)]
print(classification_report(labels, preds, target_names=class_names, digits=4))
# show_report(labels, preds)
執行後會看到類似下表的輸出(示意):
precision recall f1-score support
0 0.9750 0.9821 0.9785 2744
1 0.9812 0.9755 0.9783 5099
2 0.9680 0.9730 0.9705 4827
...
accuracy 0.9683 26032
macro avg 0.9691 0.9683 0.9687 26032
weighted avg 0.9684 0.9683 0.9683 26032
「macro avg」是每個類別指標的簡單平均,適合評估「模型對每個類別是否一視同仁」;「weighted avg」則用支援數(support)當權重,會偏向資料量大的類別。在類別平衡的 SVHN 上,兩者差異不大;在偏斜資料上,這兩個平均值會差很多,這時 macro avg 通常更值得參考。
另一個在實務上很常見的狀況,是某些類別的 Precision 與 Recall 差距很大。例如某個少見類別 Precision 很高(模型說是某類時幾乎不會錯),但 Recall 很低(真正屬於該類的樣本幾乎都被誤判成別的類別)。這種情況通常意味著模型在「不確定」的時候預設不猜該類。這時候可以考慮用 threshold 調整、class weight、或 focal loss 來強化少數類別。
混淆矩陣:一眼看出誰被誤判成誰
分類報告雖然提供了數字,但很難看出「模型最容易把誰誤判成誰」。這時就用混淆矩陣——把預測類別當作欄、真實類別當作列,每一格 (i, j) 代表「真實是 i 但被預測成 j」的數量。對角線上越多越深色,代表模型越準;非對角線的熱點,則是模型最容易搞混的類別。
import matplotlib.pyplot as plt
from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay
def plot_confusion_matrix(labels, preds, class_names=None, normalize=None):
cm = confusion_matrix(labels, preds, normalize=normalize)
disp = ConfusionMatrixDisplay(cm, display_labels=class_names)
fig, ax = plt.subplots(figsize=(8, 8))
disp.plot(ax=ax, cmap="Blues", colorbar=False, xticks_rotation=0)
plt.title("Confusion Matrix" if normalize is None else "Normalized Confusion Matrix")
plt.tight_layout()
plt.savefig("checkpoints/confusion_matrix.png", dpi=120)
print("已存到 checkpoints/confusion_matrix.png")
# plot_confusion_matrix(labels, preds, normalize="true")
normalize 參數很實用:設成 "true" 後,每一列會被縮放到 0~1,顯示「真實是 i 的樣本中,有多少比例被預測成 j」,這在類別分布不均勻時特別好用。實務上建議同時存「原始數量」與「列標準化」兩個版本,前者看絕對數量、後者看比例。
錯誤樣本分析:看模型到底錯在哪
指標與矩陣告訴我們「錯了多少」,但還不足以告訴我們「為什麼錯」。直接畫出模型預測錯誤的樣本,往往比看任何指標都更有啟發性。
def show_misclassified(images, labels, preds, n=12):
mis_idx = np.where(labels != preds)[0]
if len(mis_idx) == 0:
print("沒有錯誤樣本!")
return
sample_idx = mis_idx[:n]
fig, axes = plt.subplots(2, n // 2, figsize=(12, 4))
for ax, i in zip(axes.flat, sample_idx):
img = images[i].transpose(1, 2, 0) # (C, H, W) -> (H, W, C)
img = (img - img.min()) / (img.max() - img.min() + 1e-8)
ax.imshow(img)
ax.set_title(f"真:{labels[i]} / 預:{preds[i]}", fontsize=10)
ax.axis("off")
plt.tight_layout()
plt.savefig("checkpoints/misclassified.png", dpi=120)
print(f"已存 {len(sample_idx)} 個錯誤樣本到 checkpoints/misclassified.png")
# 注意:要把測試集的原始影像(未 Normalize)另外保留
# show_misclassified(test_images, labels, preds)
這個小工具非常實用,常常一畫出來就能看到一些規律:例如 SVHN 容易把「6 跟 8」、「3 跟 9」搞混(連人都很難分辨的低解析度樣本);或模型對某些特殊角度或陰影特別敏感。當你能看出模型失敗的模式,就能回頭設計針對性的資料增強、或調整模型架構。這也是影像分類專案除錯的核心環節。
如果時間充裕,建議把錯誤樣本依「誤判類型」分組統計:例如「3 被誤判成 8」有幾個、「6 被誤判成 0」有幾個。這種統計能幫你快速看到「最該處理的失敗模式」。在資料清理階段,也可以把這些錯誤樣本拿來檢查——很多時候是標註本身就錯了,例如原始資料把「1」標成「7」。當錯誤樣本中有 1% 到 2% 是標註錯誤,這時與其改模型,不如把標籤修一修,成效可能更大。
用 TensorBoard 視覺化訓練過程
除了測試集結果,訓練過程中的 loss 與驗證曲線也值得視覺化。PyTorch 與 TensorBoard 整合得很好,只要裝 tensorboard 套件就能用。
from torch.utils.tensorboard import SummaryWriter
writer = SummaryWriter("runs/svhn_resnet18")
for epoch in range(epochs):
train_loss = run_one_epoch(model, train_loader, criterion, optimizer, device)
val_acc = evaluate(model, val_loader, device)
writer.add_scalar("Loss/train", train_loss, epoch)
writer.add_scalar("Acc/val", val_acc, epoch)
for name, param in model.named_parameters():
writer.add_histogram(name, param, epoch)
writer.close()
# 啟動方式:在終端機輸入 tensorboard --logdir=runs,再用瀏覽器開啟 http://localhost:6006
TensorBoard 不只會畫 loss 與 accuracy 曲線,還能視覺化權重與梯度的分佈、模型運算圖、資料樣本圖等。在做實驗的時候,把每一次 trial 的 log 放在不同子目錄(例如 runs/trial_001/、runs/trial_002/),就能在 TensorBoard 介面上把多組實驗疊在一起比較,這對超參數搜尋特別有幫助。
另一個小提醒:TensorBoard 對深度學習的支援相當完整,但對小型專案來說,matplotlib + pandas 的組合往往更直覺。把訓練指標寫成 CSV,再畫幾張折線圖,一樣能達到「監控訓練過程」的效果。選擇哪個工具,取決於團隊的習慣與專案的規模——大團隊通常會用 TensorBoard 或 MLflow;個人小專案則從 matplotlib 開始就好。
結語
這一篇把模型評估與視覺化從「看數字」推進到「看模式、找原因」。當你能在指標之外進一步分析錯誤樣本、用混淆矩陣找出系統性的失敗模式、再用 TensorBoard 把訓練過程留底,整個專案的成熟度會明顯提升。這些動作也能幫助你在報告中說明「為什麼這個模型可以上線」,而不只是丟一個 95% 的數字。
明天,我們會把這三天的成果整合起來:用 torch.onnx.export 把模型匯出成 ONNX,用 Streamlit 做一個簡單的展示介面,並為整個 40 天系列做一個完整的回顧。
留言
張貼留言