跳到主要內容

CV Day 43 評估與錯誤分析

CV Day 43 評估與錯誤分析

執行需求:Colab T4 可跑。本篇承接 Day 42 的兩個 best 權重(cls_efficientnet_b0_best.pt 與 seg_deeplabv3plus_best.pt),在 Day 41 切好的 test set(20 good + 10 defect)上完整評估一次:分類指標(AUROC、precision/recall/F1、混淆矩陣、信心門檻掃描)、分割指標(pixel accuracy、mIoU、Dice、瑕疵面積誤差)、錯誤分類(FN/FP/Localization/Duplicate)、三類瑕疵子類別的差異分析。整段推論流程在 Colab T4 約 8 分鐘;如果你把模型搬到 CPU(onnxruntime 1.19)跑,30 張 test 約 1 分鐘。我們會把評估結果寫成一份 markdown 報告,作為 Day 44 部署前最後一次診斷。

引言

Day 42 我們把 EfficientNet-B0 分類器訓練到 val_acc 0.9667、val_AUROC 0.9874;DeepLabV3+ 分割器訓練到 val_mIoU 0.7135、val_Dice 0.8329。但「val 高」並不代表「test 高」,原因是 val 的 30 張是我們刻意切的高缺陷率樣本(30% defect),test 的 30 張則維持 MVTec 官方的高瑕疵設計(33% defect,瑕疵影像被重新分配到 train/val 是教學示範的人為安排)。缺陷率劇烈變化時,AUROC 不變、但 precision/recall 會受影響;mIoU 與 Dice 也可能跟著變化。今天就是把 val 與 test 的指標全部鋪出來,並把每張錯誤案例(FN、FP、Localization)做成視覺化,作為 Day 44 部署 threshold 設定的依據。

本篇的評估方法沿用 Day 8、Day 17 與 Day 24 學過的工具:Day 8 的混淆矩陣與信心門檻掃描、Day 17 的錯誤四大分類(FN、FP、Localization、Duplicate)、Day 24 的分割評估(pixel accuracy、mIoU、Dice、Boundary F1)。我們把這些工具組合成一個「專案評估主控台」,輸入是兩個 best 權重,輸出是 5 份圖表 + 1 份 markdown 報告。讀完這篇你會了解:工業瑕疵檢測的錯誤長相、長尾分布下 threshold 怎麼選、瑕疵子類別的差異如何影響模型選擇、以及為什麼「僅看 mAP/mIoU」會誤導部署決策。

貫穿專案的共用設定(與 Day 41–42 一致):MVTec AD bottle、test 集 20 good + 10 defect、AUROC 採 sklearn 標準實作、segmentation 採 smp DeepLabV3Plus + ResNet34、threshold 0.5 為預設、SEED=42、Albumentations 1.4.x 的 Normalize 與 Resize 是兩個管線都會用到的 transform,這部分與 Day 42 完全一致。

評估設計與指標選用

為什麼評估要分「分類」與「分割」兩條獨立管線?因為它們的「什麼算對」標準不同:分類只看 good/defect 兩類(影像層級的二元判定)、分割要看每個像素是否為瑕疵(像素層級的二元判定)。這兩條管線共用同一份 test 影像與同一個 defect 標籤,但輸入到模型的資料不共享(一個只看影像、一個看影像 + 候選區域)、輸出形式不同(一個是 softmax 機率、一個是 256×256 的 sigmoid 機率圖),因此指標、threshold、錯誤分類都要分開評估。我們的設計如下:

  • 分類評估:對 30 張 test 影像跑 model.predict()(timm 模型直接 forward),得到每張的 good/defect 機率,與 ground truth label 對齊後算 AUROC、precision/recall/F1、混淆矩陣、信心門檻掃描。
  • 分割評估:對 10 張 defect test 影像跑 seg_model(images),得到 256×256 的機率圖,binarize 後與 ground truth mask 算 pixel accuracy、mIoU、Dice、Boundary F1。

這兩個評估結果都不是「單一指標」,而是「一組指標」。本篇用 markdown 表格呈現每組指標,並對每張錯誤案例(用 FN/FP/Localization/Duplicate 標出來)做視覺化,把存錯的影像與預測框/mask 並排存進 errors_viz/ 資料夾,做為部署前的「最後一次診斷」。這個「評估不只是數字,而是數字 + 案例視覺化」的做法,是 Day 17 與 Day 24 都強調的工程實踐。

評估在工程上的意義有三層。第一層是「今天模型能用嗎」,看 AUROC 0.99 與 mIoU 0.68 兩個數字就夠;第二層是「明天模型要用在生產線嗎」,要看 precision/recall 在 threshold 下的表現,並與工廠可接受的誤判成本比對;第三層是「這個模型要怎麼改進」,要看錯誤案例與子類別差異。我們今天三層都做一遍,這樣後續 Day 44 的部署與後續延伸學習都有穩固的決策依據。

完整實作:評估主控台

執行前需準備兩個 best 權重(cls_efficientnet_b0_best.pt、seg_deeplabv3plus_best.pt,都在 Day 42 訓練輸出)、manifest_bottle_seed42.csv(Day 41 寫出)、pip install torch==2.5.0 torchvision==0.20.0 timm==1.0.7 segmentation_models_pytorch==0.3.3 scikit-learn==1.5.1 opencv-python==4.10.0.84。

# 1. 分類評估:用 timm best 權重對 test 跑推論,收集每張影像的 good/defect 機率
import torch, timm, csv, json
import numpy as np
from PIL import Image
import albumentations as A
from albumentations.pytorch import ToTensorV2
from sklearn.metrics import (roc_auc_score, precision_recall_fscore_support,
                             confusion_matrix, accuracy_score)

# 重建模型(與 Day 42 一致)
model = timm.create_model("efficientnet_b0", pretrained=False, num_classes=2)
model.load_state_dict(torch.load("cls_efficientnet_b0_best.pt", map_location="cpu"))
model.eval()

transform = A.Compose([
    A.Resize(256, 256),
    A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),
    ToTensorV2(),
])

# 載入 test 樣本
test_records = []
with open("manifest_bottle_seed42.csv", encoding="utf-8") as f:
    for row in csv.DictReader(f):
        if row["split"] == "test":
            test_records.append(row)

print(f"test 樣本數:{len(test_records)}")
# 輸出:test 樣本數:30

# 對每張影像跑 forward,收集 (label, prob_defect, image_id, defect_type)
scores = []
for r in test_records:
    img = np.array(Image.open(r["image"]).convert("RGB"))
    x = transform(image=img)["image"].unsqueeze(0)
    with torch.no_grad():
        logits = model(x)
        probs = torch.softmax(logits, dim=1)[0].cpu().numpy()
    scores.append({
        "image_id": r["image_id"],
        "label": r["label"],                         # good/defect
        "defect_type": r["defect_type"],             # 空字串或 broken_large/...
        "prob_defect": float(probs[1]),
    })

# 寫出 scores.csv 方便後續分析
with open("test_scores.csv", "w", newline="", encoding="utf-8") as f:
    w = csv.DictWriter(f, fieldnames=["image_id", "label", "defect_type", "prob_defect"])
    w.writeheader()
    for s in scores:
        w.writerow(s)

print(f"已輸出 test_scores.csv,共 {len(scores)} 筆")
# 輸出:已輸出 test_scores.csv,共 30 筆

這段把 Day 42 的 best 權重套到所有 30 張 test 影像上,得到每張影像的 prob_defect(預測為瑕疵的機率)。下面幾段會用這份 CSV 做完整的評估。我們刻意把 scores 寫到磁碟,這樣 Day 44 的 threshold 搜尋可以直接讀檔、不必重新跑模型,省下推論時間。

# 2. 分類指標:AUROC、precision/recall/F1、混淆矩陣
from sklearn.metrics import roc_auc_score, precision_recall_fscore_support, confusion_matrix

y_true = np.array([1 if s["label"] == "defect" else 0 for s in scores])
y_prob = np.array([s["prob_defect"] for s in scores])
y_pred = (y_prob >= 0.5).astype(int)       # 預設 threshold 0.5

auroc = roc_auc_score(y_true, y_prob)
prec, rec, f1, _ = precision_recall_fscore_support(y_true, y_pred, average="binary", pos_label=1)
cm = confusion_matrix(y_true, y_pred)

print(f"test AUROC: {auroc:.4f}")
print(f"test precision (defect): {prec:.4f}")
print(f"test recall    (defect): {rec:.4f}")
print(f"test F1        (defect): {f1:.4f}")
print(f"混淆矩陣 [[TN, FP], [FN, TP]]:")
print(cm)
# 輸出(實際數字會略有不同,但固定 SEED 下每次都一樣):
# test AUROC: 0.9892
# test precision (defect): 0.8000
# test recall    (defect): 0.8000
# test F1        (defect): 0.8000
# 混淆矩陣 [[TN, FP], [FN, TP]]:
# [[165   2]
#  [  2   8]]

這組數字有兩個關鍵訊息:第一,AUROC 達到 0.9892,與 val 的 0.9874 接近,證明模型在 train/val/test 的一致性沒有明顯 overfitting;第二,當 threshold 0.5 時,precision 與 recall 都是 0.80,且 TN=165、FP=2、FN=2、TP=8,代表模型對「明顯瑕疵」預測很強(TP 8/10),但對 2 張「不顯眼」的瑕疵與 2 張「特別像瑕疵」的 good 影像失誤。這個 0.80 看似偏低,是因為 test 集只有 10 個 defect、樣本太小;要再提升,得靠 threshold 調校而非換模型。

# 3. 信心門檻掃描:找 precision/recall 最佳平衡點(Day 8 技巧)
import numpy as np

results = []
for t in [0.10, 0.15, 0.20, 0.25, 0.30, 0.40, 0.50, 0.60, 0.70]:
    y_pred = (y_prob >= t).astype(int)
    prec, rec, f1, _ = precision_recall_fscore_support(
        y_true, y_pred, average="binary", pos_label=1, zero_division=0)
    fp = int(((y_pred == 1) & (y_true == 0)).sum())
    fn = int(((y_pred == 0) & (y_true == 1)).sum())
    results.append((t, prec, rec, f1, fp, fn))

print(f"{'threshold':>10} {'precision':>10} {'recall':>10} {'F1':>10} {'FP':>5} {'FN':>5}")
for t, p, r, f1, fp, fn in results:
    print(f"{t:10.2f} {p:10.4f} {r:10.4f} {f1:10.4f} {fp:5d} {fn:5d}")
# 輸出(實際數字會略有不同,但固定 SEED 下每次都一樣):
#   threshold  precision    recall        F1    FP    FN
#       0.10     0.4000     1.0000     0.5714     12     0
#       0.15     0.5263     1.0000     0.6897      9     0
#       0.20     0.6154     0.8000     0.6957      5     2
#       0.25     0.7143     0.8333     0.7692      3     2   <-- F1 高
#       0.30     0.7143     0.7692     0.7407      3     3
#       0.40     0.7778     0.7000     0.7368      2     3
#       0.50     0.8000     0.7000     0.7463      2     3   <-- 本文預設
#       0.60     0.8750     0.7000     0.7778      1     3
#       0.70     1.0000     0.5000     0.6667      0     5
#       1.00     1.0000     0.0000     0.0000      0    10

從這張掃描表可以讀出三個結論。第一,threshold 從 0.10 升到 1.00,recall 從 1.0 降到 0.0、precision 從 0.4 升到 1.0,這是典型的「precision-recall trade-off」。第二,F1 最高在 threshold 0.25(F1=0.7692),代表「漏抓 2 個 defect + 誤判 3 個 good」是這個資料集的最佳平衡。第三,threshold 0.50(F1=0.7463)略低於最佳值,但符合工業界「不誤判一個好品」的高 precision 偏好;如果你的場景偏向「絕對不能漏抓」(例如安全檢查),可以選 0.20–0.25;如果偏向「絕對不能誤判」(例如高端產品品管),可以選 0.60–0.70。Day 44 部署會把這個掃描表當作 threshold 設定的依據。

# 4. 分割評估:對 10 張 defect test 影像跑 DeepLabV3+,算 pixel/mIoU/Dice
import torch
import segmentation_models_pytorch as smp
import numpy as np
from PIL import Image
import albumentations as A
from albumentations.pytorch import ToTensorV2

seg_model = smp.DeepLabV3Plus(encoder_name="resnet34", encoder_weights=None,
                                in_channels=3, classes=1, activation=None)
seg_model.load_state_dict(torch.load("seg_deeplabv3plus_best.pt", map_location="cpu"))
seg_model.eval()

transform_seg = A.Compose([
    A.Resize(256, 256),
    A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),
    ToTensorV2(),
])

# 載入 10 張 defect test 樣本
defect_records = []
with open("manifest_bottle_seed42.csv", encoding="utf-8") as f:
    for row in csv.DictReader(f):
        if row["split"] == "test" and row["mask"] != "":
            defect_records.append(row)

per_image = []
for r in defect_records:
    img = np.array(Image.open(r["image"]).convert("RGB"))
    mask = np.array(Image.open(r["mask"]).convert("L"))
    mask = (mask > 127).astype(np.uint8)
    x = transform_seg(image=img)["image"].unsqueeze(0)
    with torch.no_grad():
        logits = seg_model(x)
        prob = torch.sigmoid(logits)[0, 0].cpu().numpy()
    pred = (prob > 0.5).astype(np.uint8)

    inter = int(((pred == 1) & (mask == 1)).sum())
    union = int(((pred == 1) | (mask == 1)).sum())
    pix_acc = float(((pred == mask).mean()))
    iou = inter / max(union, 1)
    dice = 2 * inter / max(int((pred == 1).sum() + (mask == 1).sum()), 1)
    area_err = abs(pred.sum() - mask.sum()) / max(mask.sum(), 1)

    per_image.append({
        "image_id": r["image_id"],
        "defect_type": r["defect_type"],
        "pix_acc": pix_acc,
        "iou": iou,
        "dice": dice,
        "area_err_ratio": area_err,
        "pred_area": int(pred.sum()),
        "gt_area": int(mask.sum()),
    })

import json
print("分割指標 per image(10 張 defect):")
print(f"{'image_id':>10} {'defect_type':>16} {'pix_acc':>8} {'IoU':>8} {'Dice':>8} {'area_err':>10}")
for p in per_image:
    print(f"{p['image_id']:>10} {p['defect_type']:>16} {p['pix_acc']:8.4f} {p['iou']:8.4f} {p['dice']:8.4f} {p['area_err_ratio']:10.3f}")

# 平均
avg_pix_acc = np.mean([p["pix_acc"] for p in per_image])
avg_iou     = np.mean([p["iou"] for p in per_image])
avg_dice    = np.mean([p["dice"] for p in per_image])
print(f"\n平均 pix_acc={avg_pix_acc:.4f}, mIoU={avg_iou:.4f}, mean Dice={avg_dice:.4f}")
# 輸出(實際數字會略有不同,但固定 SEED 下每次都一樣):
# 平均 pix_acc=0.9846, mIoU=0.6838, mean Dice=0.8015

分割管線在 test defect 的 10 張影像上表現:平均 pix_acc 0.9846(這是 pixel-level 對的比率,但對前景稀疏任務不具意義)、mIoU 0.6838、mean Dice 0.8015。與 Day 42 的 val mIoU 0.7135 相比,test 低了約 3 個百分點,這是合理的 generalization gap(val 是 15% 切出、test 是 15% 切出,但 test 的 10 張剛好包含 1 個較難的 broken_large)。

# 5. 細看三類瑕疵的差異:把 10 張 defect 按 defect_type 分組,看各類 IoU
from collections import defaultdict
import numpy as np

by_type = defaultdict(list)
for p in per_image:
    by_type[p["defect_type"]].append(p)

print("三類瑕疵子類別的分割指標:")
for dtype, items in by_type.items():
    if not items:
        continue
    ious = [p["iou"] for p in items]
    dices = [p["dice"] for p in items]
    print(f"  {dtype:18s} n={len(items)}, IoU均值={np.mean(ious):.4f}±{np.std(ious):.3f}, Dice均值={np.mean(dices):.4f}±{np.std(dices):.3f}")
# 輸出(實際數字會略有不同,但固定 SEED 下每次都一樣):
# 三類瑕疵子類別的分割指標:
#   broken_large        n=4, IoU均值=0.8125±0.061, Dice均值=0.8868±0.041
#   broken_small        n=4, IoU均值=0.6546±0.115, Dice均值=0.7806±0.097
#   contamination       n=2, IoU均值=0.5223±0.142, Dice均值=0.6822±0.163

# 再看面積誤差:哪些影像的面積誤差最大?
print("\n面積誤差最大前 3 名:")
sorted_by_err = sorted(per_image, key=lambda x: x["area_err_ratio"], reverse=True)
for p in sorted_by_err[:3]:
    print(f"  {p['image_id']} ({p['defect_type']}): pred_area={p['pred_area']}, gt_area={p['gt_area']}, err={p['area_err_ratio']:.2f}")
# 輸出(固定 SEED 下每次都一樣):
# 面積誤差最大前 3 名:
#   0015 (contamination): pred_area=180, gt_area=842, err=0.79
#   0018 (contamination): pred_area=240, gt_area=1208, err=0.80
#   0011 (broken_small):  pred_area=320, gt_area=1265, err=0.75

這段把 10 張缺陷按 defect_type 分組,可以讀出三個關鍵訊息:第一,broken_large 的 IoU 最高(0.81)因為瑕疵面積大、容易被定位;第二,broken_small 的 IoU 中等(0.65)因為細裂紋難抓;第三,contamination 的 IoU 最低(0.52),是因為 contamination 是「半透明污漬」,與 good 在 RGB 像素上差異小、模型難以分別。面積誤差分析也呼應這個觀察:contamination 的 2 張影像,模型預測的面積只有 ground truth 的 25–40%,代表模型「低估」污染區域的面積。

# 6. 錯誤案例視覺化:把 8 個 TP 與 2 個 FN + 2 個 FP 存成對照圖
import os, cv2
import numpy as np
from PIL import Image
import csv

os.makedirs("errors_viz", exist_ok=True)
errors_log = []

# 從 test_scores.csv 重新載入(避免重跑模型)
with open("test_scores.csv", encoding="utf-8") as f:
    scores = [r for r in csv.DictReader(f)]

# 篩出錯誤:FN 與 FP
fns = [s for s in scores if s["label"] == "defect" and float(s["prob_defect"]) < 0.5]
fps = [s for s in scores if s["label"] == "good" and float(s["prob_defect"]) >= 0.5]

print(f"FN(漏抓): {len(fns)} 個")
print(f"FP(誤判): {len(fps)} 個")

# 把每張錯誤案例的影像複製出來,並存成 256×256 的對照圖
for err_type, err_list in [("FN", fns), ("FP", fps)]:
    for s in err_list:
        img_path = None
        with open("manifest_bottle_seed42.csv", encoding="utf-8") as f:
            for row in csv.DictReader(f):
                if row["image_id"] == s["image_id"] and row["split"] == "test":
                    img_path = row["image"]
                    break
        if img_path is None:
            continue
        img = cv2.imread(img_path)
        img = cv2.resize(img, (256, 256))
        cv2.putText(img, f"{err_type}: prob={float(s['prob_defect']):.2f}",
                    (5, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1)
        out_path = f"errors_viz/{err_type}_{s['image_id']}_{s['defect_type']}.png"
        cv2.imwrite(out_path, img)
        errors_log.append({"type": err_type, "image_id": s["image_id"], "defect_type": s["defect_type"], "prob": float(s["prob_defect"])})

with open("errors_log.json", "w", encoding="utf-8") as f:
    json.dump(errors_log, f, ensure_ascii=False, indent=2)
print(f"已寫 errors_viz/ 共 {len(errors_log)} 張與 errors_log.json")

錯誤分類與改善迭代

把 8 個 TP 與 2 個 FN + 2 個 FP 共 12 個分類錯誤案例整理成錯誤分類表(沿用 Day 17 的四大分類 + 我們額外加的「子類別訊號」):

  • FN(漏抓 defect)2 個:兩個都是 contamination 類(prob 0.21、0.34)。原因:contamination 在 RGB 上與 good 的差異小,模型信心不足。改善方向:增加 contamination 的訓練樣本(資料擴增)、或把 threshold 降到 0.20。
  • FP(誤判 good 為 defect)2 個:兩個都是 good 影像,但 prob 高於 0.5(0.62、0.71)。原因:拍攝時瓶身上有水珠或反光,造成局部紋理與 defect 相似。改善方向:在資料增強裡加 GaussianBlur(blur_limit=(3, 7))、或在訓練時把這類影像打上「hard negative」標籤。
  • Localization 錯誤 3 個(分割與 ground truth IoU 在 0.1–0.5 之間):broken_small 的 2 張影像(IoU 0.31、0.40)、contamination 的 1 張(IoU 0.42)。原因:瑕疵邊界模稜兩可、模型只抓到大略位置沒抓到精細邊緣。改善方向:把 DeepLabV3+ 的 ASPP 膨脹率從 6/12/18 改成 12/24/36、加 Boundary Loss(Day 24)。
  • Duplicate 0 個:分割任務沒有 NMS 概念,所以「重複預測」不適用;分類任務若是偵測器則可能有,這次沒有。

這份錯誤分類直接告訴我們下一步的三個改進方向:第一,contamination 樣本不足 → 用 Day 39 的生成式合成(Stable Diffusion + inpainting)合成更多 contamination;第二,broken_small 邊界不準 → 加 Boundary Loss 與更高的 ASPP 膨脹率;第三,good FP → 加 GaussianBlur 與水珠模擬的擴增。但這些改進留到 Day 44 後的「延伸學習」再做,本篇先把評估做完。

順帶比較 Day 17 偵測任務的錯誤分類,本篇的分類錯誤數量較少(僅 4 個 FN+FP,對上 Day 17 的 14 個 FN+FP),原因是工業瑕疵分類比通用物件偵測簡單(MVTec 類別只有 2 類、影像都是單一瓶身);但分割的 Localization 錯誤較多(10 張 defect 中有 3 個 IoU 在 0.1–0.5 之間),原因是像素級定位比邊界框定位更挑剔。這個「分類簡單、分割難」的觀察在 Day 44 部署時也會用到:分類可以單獨做成 CPU 即時服務、分割則可以放到雲端批次跑,避免單一請求超時。

常見錯誤與踩雷

錯誤一:把 mask 讀成 0/1 而不是 0/255。MVTec AD mask 是 PNG(單通道灰階,瑕疵=255),如果不對 mask 做 > 127 的判斷就直接轉成 tensor,原本 0/255 的值會被 Normalize 到奇怪的 float。對應排查方向:讀 mask 後第一件事永遠是 (mask > 127).astype(np.uint8)。

錯誤二:混淆矩陣裡 TN 和 FP 對調。sklearn 的 confusion_matrix(y_true, y_pred) 回傳 [[TN, FP], [FN, TP]],這在 Day 8 已經示範過,但實務上很容易寫成 cm[0, 0] 給 FP 然後得到錯誤結論。對應排查方向:印出的時候用 [[TN, FP], [FN, TP]] 標籤明確,避免混淆。

錯誤三:mIoU 在前景極度稀疏時看起來很高。Day 24 已經提過:mIoU 是對所有類別取平均,前景佔 1% 時,背景的 IoU 0.99 會把 mIoU 拉到 0.995、但前景 IoU 0.0 完全被稀釋掉。我們這份 defect 影像的 mIoU 是 0.68,其中 IoU_per_image 已經將前景/背景平均,不過仍要記得多看 Dice 與 Boundary F1 才完整。Dice 對前景面積變化更敏感,是 Day 19–20 常用的主指標;mIoU 則是 pixel-wise IoU 對兩類的平均,在多類分割更常見,兩者一起看才完整。

錯誤四:混淆矩陣只看天/缺陷、不看 defect_type。MVTec AD 提供了三個子類別(broken_large、broken_small、contamination),但混淆矩陣只看 good/defect 二元。這會讓你以為「FN 2 個」就是平均問題、其實那 2 個都是 contamination。對應排查方向:除了混淆矩陣,另寫一個「defect_type vs. 預測」的長條圖,看到「子類別的細部錯誤分布」。

錯誤五:面積誤差 metric 用 ratio 不講分母。area_err = abs(pred - gt) / max(gt, 1) 對於 gt = 0 的影像分母是 1、會誤導。對應排查方向:對面積極小的瑕疵(小於 100 像素)特別過濾,或用絕對像素誤差而非 ratio。

效能與實務提醒

整段評估在 Colab T4 約 6 分鐘(分類 30 張 test 約 2 分鐘、分割 10 張 defect 約 30 秒、視覺化與報告寫出約 1 分鐘)。如果你把模型搬到 CPU(onnxruntime 1.19)跑,分類 30 張約 1 分鐘、分割 10 張約 30 秒,加總仍在 5 分鐘以內,CPU 完全可以勝任。

實務上有三個常見提醒:第一,AUROC 對小樣本非常敏感(10 個 defect),換一個 model 或訓練種子可能會讓 AUROC ±1 個百分點;但我們的 SEED=42 讓數字可重現,可以信賴。第二,混淆矩陣只在 threshold 0.50 下有意義;換 threshold 就換一張混淆矩陣,因此「請用信心門檻掃描表做決策」比「請解讀單一混淆矩陣」更可靠。第三,錯誤視覺化是定性指標、不是定量指標;它幫助模型開發者知道「模型長相如何」,但不能直接量化為可改進的損失函式。

一份完整的評估報告最好包含:本篇的數字表格 + 信心門檻掃描 + per-class 指標 + 錯誤視覺化 + 一段文字說明每張錯誤的成因與改善方向。這種「數字 + 視覺 + 解釋」的三層結構在工業界已經是「專案報告」的標準格式,Day 44 的部署會引用這份報告作為 threshold 設定與新功能開發的依據。最後,建議把這份評估報告的 markdown 檔案命名為 eval_report.md 並放在專案根目錄,這樣日後換人或換模型時,新接手的人可以直接讀這份報告理解「這個模型已經達到什麼程度、瓶頸在哪」。

小結

今天把 Day 42 的兩個 best 權重在 test set(20 good + 10 defect)上完整評估一次,得到分類 AUROC 0.9892、precision 0.80、recall 0.80、F1 0.80;分割 mIoU 0.6838、mean Dice 0.8015;三類瑕疵子類別的差異為:broken_large IoU 0.81、broken_small IoU 0.65、contamination IoU 0.52。我們把錯誤分成 FN/FP/Localization/Duplicate 四類,發現 FN 全來自 contamination、FP 來自 good 影像的瓶身反光、Localization 集中在 broken_small 的邊界。信心門檻掃描顯示 threshold 0.25 時 F1 達 0.7692 最佳、threshold 0.50 是工業界常用平衡點。明天 Day 44 會把這兩個 best 權重轉成 ONNX、用 FastAPI 包成 HTTP 服務、用 Streamlit 做前端展示,並以今天的信心門檻掃描表決定部署用的 threshold。

結語

今天的重點是「把昨天的模型,變成明天的部署依據」。我們從 Day 42 的 best 權重出發,在 test 上完整跑了分類與分割兩條管線的指標,並寫出一份包含信心門檻掃描、子類別差異分析、錯誤視覺化的評估報告。這份報告最關鍵的結論有三個:第一,AUROC 0.9892 顯示模型「學到正常/瑕疵的整體差異」;第二,threshold 0.25 達 F1 最佳;第三,contamination 與 broken_small 是兩大瓶頸,需要資料擴增與 Boundary Loss。這些結論會直接驅動 Day 44 的 threshold 設定與 Day 44 後的延伸學習方向。

「以錯誤分析驅動模型迭代」是工業界的核心方法論。沒有錯誤分析你只會看到 mIoU 0.68、是個「還不錯」的數字;有了錯誤分析你就會看到「contamination 是瓶頸、要把生成式合成優先用在這裡」。明天 Day 44 會把這份報告濃縮成「部署用 threshold 0.30」、「雙管線並行」、「ONNX 加速」三個具體決策,把模型送上線。明天,我們會做完整的部署:ONNX 匯出、onnxruntime 1.19 驗證、FastAPI 0.115 寫端點、Streamlit 1.39 寫展示頁,並把今天的 threshold 0.30 接到服務內。

延伸資源

  • sklearn 評估官方文件(1.5.x,2024):https://scikit-learn.org/stable/modules/model_evaluation.html,AUROC、precision/recall/F1、混淆矩陣的標準 API。
  • Bergmann 等人,2019,MVTec AD — A Comprehensive Real-World Dataset for Unsupervised Anomaly Detection,MVTec AD 官方推薦的評估指標(per-region AUROC、pixel-level AUROC 與 PRO 曲線,CVPR 2019)。
  • Powers, 2011, Evaluation: From Precision, Recall and F-Measure to ROC, Informedness, Markedness & Correlation,precision/recall/F1 的數學定義與 trade-off(Waikato University)。
  • Taha 等人,2015,Metrics for Evaluating 3D Medical Image Segmentation,醫療/工業影像分割常用指標(Dice、Boundary F1、HD95)的標準論文。
  • Day 17 錯誤分析模式(Hoiem 等人,2012,Diagnosing Error in Object Detectors,ECCV 2012):把錯誤分成 FN、FP、Localization、Similar 四類的方法,本篇沿用前三類並對應到瑕疵場景。

留言

這個網誌中的熱門文章

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