跳到主要內容

CV Day 17 偵測評估與錯誤分析

CV Day 17 偵測評估與錯誤分析

執行需求:CPU 可跑。本篇延續 Day 13–16 的 VOC person/car 子集,今天不訓練模型,而是把已有的預測結果(Day 13 的 YOLO11 best.pt 或 Day 14 的推論輸出)拿來做完整評估與錯誤分析。所有範例都用純 PyTorch 在 CPU 上跑得動,數據集規模限縮到 30 張驗證影像,完整跑完 AP、IoU 門檻分析、錯誤分類約 5 分鐘;如果要做完整 600 張驗證集,把 VAL_IDS 換成完整清單即可,CPU 時間約 10–15 分鐘。

引言

Day 13–16 我們分別用 Ultralytics 8.3、torchvision 0.20、transformers 4.46 訓練了三個偵測模型(YOLO11、Faster R-CNN、DETR),每個都達到 mAP@0.5 約 0.72–0.88。但「mAP 高」並不代表「模型在自家任務上沒問題」——mAP 是一個平均指標,會把「某些類別很糟、某些類別很好」平均成一個中等的數字。真正能指引模型改進的,是把錯誤拆成「漏抓」、「誤判」、「定位不準」、「重複預測」四類,並對每一類做統計。

這一篇要把 Day 10 學過的 mAP 與 NMS 工具延伸成「完整的錯誤分析系統」。我們會用純 PyTorch 寫四個函式:compute_ap_per_class 算每類 AP、analyze_iou_thresholds 掃描 IoU 門檻對 mAP 的影響、classify_errors 把每個錯誤預測分類到四種錯誤類型、summary_report 把所有結果彙整成一份簡報。整段在 CPU 上跑得動,不依賴 GPU 或外部資料集。讀完這篇你會了解:偵測任務的錯誤該怎麼分類、不同 IoU 門檻對 mAP 的影響、以及如何從錯誤分析結果反推「下一步該改什麼」。

本篇的示範資料沿用 Day 13–14 的 VOC person/car 子集(每類 300 張、共 600 張驗證)。為了讓 CPU 跑得快一點,我們只取前 30 張驗證影像;如果你想用完整的 600 張,把 VAL_IDS 換成 Day 13 的全部 val 清單即可。預測結果的部分我們用 Day 13 的 YOLO11 best.pt 做示範(runs/detect/voc_yolo11n/weights/best.pt),如果你還沒訓練或想換模型,把 PRED_SOURCE 改成自己的預測檔路徑即可。

每類 AP 與平均 mAP 的實作細節

Day 10 我們用 11-point AP 示範了 mAP 的概念,但在真實評估中通常用 101-point AP(COCO 標準)以獲得更精細的指標。實作上 101-point AP 與 11-point 的差別只在「recall 切成幾段」:11-point 把 recall 切成 0.0、0.1、0.2、…、1.0 共 11 個門檻,取每個門檻對應的最大 precision 做平均;101-point 則切成 0.00、0.01、…、1.00 共 101 個門檻。COCO 標準更嚴格、mAP 數字通常會比 11-point 低 1–3 個百分點。

另一個細節是「per-class 計算」與「全類別一起計算」的差別。Day 10 的範例把所有類別的預測混在一起排序,再算 precision-recall,這在某些實作上會出錯(因為不同類別的 FP 不應該互相干擾)。正確做法是「對每個類別各自排序、計算 AP,最後平均」,這個 per-class 平均對長尾資料集特別重要。本篇的 compute_ap_per_class 函式採用這個標準做法:先依類別分組、再對每個類別排序、計算 precision-recall 與 AP。

最後是「匹配策略」。在算 AP 時,對每個預測框要決定它對應到哪個真實框(TP)或沒有對應(FP)。標準做法是「依信心分數排序、逐個檢查、找 IoU 大於門檻且類別相同且尚未被匹配的真實框」。這個「尚未被匹配」的限制是關鍵:同一個真實框不能被多個預測框匹配,否則 precision 會被高估。本篇的 match_predictions 函式會用一個 matched 布林陣列追蹤每個真實框是否已被匹配。

錯誤分析的四大類型

偵測模型的錯誤可以分成四大類,每一類對應不同的模型改進策略:

1. 漏抓(False Negative, FN):真實框沒有被任何預測匹配。成因可能是「物件太小」、「物件被遮擋」、「影像模糊」、「類別太稀有」、「信心門檻設太高」。對應改進:收集更多訓練資料、降低信心門檻、換更大的模型。

2. 誤判(False Positive, FP):預測框對應不到任何真實框(IoU 太小或類別錯)。成因可能是「信心門檻設太低」、「模型把背景誤認為物件」、「類別混淆(例如 cat vs. dog)」。對應改進:提高信心門檻、增加 hard negative 訓練樣本、用混淆矩陣找出最容易混淆的類別對。

3. 定位不準(Localization Error):預測框與真實框類別正確、IoU 介於門檻(例如 0.1)與 0.5 之間。成因可能是「anchor 設計不適合這個資料集」、「框回歸 loss 收斂不完全」。對應改進:換 GIoU Loss、用 K-means 重新設計 anchor、增加框回歸的訓練時間。

4. 重複預測(Duplicate Detection):多個預測框都對應到同一個真實框。成因通常是「NMS IoU 門檻太低」。對應改進:把 NMS IoU 門檻從 0.5 調到 0.6 或 0.7,或換 Soft-NMS/DIoU-NMS。

把錯誤分成這四類之後,你可以畫一張「錯誤類型分布圖」,橫軸是 IoU 門檻、縱軸是錯誤數量;對大多數模型來說,IoU=0.5 時誤判最多、IoU=0.9 時定位不準最多。如果你發現「在 IoU=0.5 的寬鬆標準下仍有大量漏抓」,代表模型 recall 太低、需要更多訓練資料;如果你發現「在 IoU=0.75 的嚴格標準下大量錯誤都是定位不準」,代表框回歸需要更多優化。

完整實作:純 PyTorch 寫的錯誤分析工具

以下範例延續 Day 13 的 VOC 子集,但今天不訓練模型、只評估。我們會用 Day 13 的 YOLO11 best.pt 做示範(如果沒有,把 PRED_SOURCE 改成任何能產生預測的函式即可)。執行前需要:pip install torch torchvision ultralytics,評估階段不需要 GPU。

# 1. 載入預測與真實標註(示範用合成資料,避免依賴特定模型)

import torch
from pathlib import Path
import xml.etree.ElementTree as ET

VOC_ROOT = "/content/datasets/VOCdevkit/VOC2007"
CLASSES = ["person", "car"]
CLASS_TO_IDX = {c: i for i, c in enumerate(CLASSES)}

def load_ids_with_target(split_file, limit=30):
    """從 VOC 載入 image_id 清單,限縮到 limit 張以便 CPU 跑得快。"""
    ids = []
    with open(os.path.join(VOC_ROOT, "ImageSets", "Main", split_file)) as f:
        for line in f:
            image_id = line.strip()
            if not image_id:
                continue
            xml = ET.parse(os.path.join(VOC_ROOT, "Annotations", f"{image_id}.xml")).getroot()
            if any(obj.findtext("name") in CLASS_TO_IDX for obj in xml.findall("object")):
                ids.append(image_id)
                if len(ids) >= limit:
                    break
    return ids

import os
VAL_IDS = load_ids_with_target("val.txt", limit=30)
print(f"取 {len(VAL_IDS)} 張驗證影像做錯誤分析")
# 輸出:取 30 張驗證影像做錯誤分析

為了讓範例完全可重現(且不依賴外部模型),我們接下來的 make_synthetic_predictions 會根據真實標註加上「受控的雜訊」產生預測:每張影像的真實框有 85% 機率被模型偵測到、信心 0.5–0.95 隨機;10% 的預測框位置會偏移 ±10 像素以模擬「定位不準」;5% 的預測會誤判類別以模擬「誤判」。這個合成資料讓我們能驗證分析工具的數值正確性,實際工作上把 make_synthetic_predictions 換成 model.predict(...) 即可。

# 2. 合成預測結果:模擬一個 mAP@0.5 約 0.75 的模型

import random

random.seed(42)

def load_ground_truth(image_id):
    xml = ET.parse(os.path.join(VOC_ROOT, "Annotations", f"{image_id}.xml")).getroot()
    W = int(xml.findtext("size/width"))
    H = int(xml.findtext("size/height"))
    boxes, labels = [], []
    for obj in xml.findall("object"):
        name = obj.findtext("name")
        if name not in CLASS_TO_IDX:
            continue
        bb = obj.find("bndbox")
        boxes.append([float(bb.findtext("xmin")), float(bb.findtext("ymin")),
                      float(bb.findtext("xmax")), float(bb.findtext("ymax"))])
        labels.append(CLASS_TO_IDX[name])
    return {"boxes": torch.tensor(boxes), "labels": torch.tensor(labels), "size": (H, W)}

def make_synthetic_predictions(image_id, miss_rate=0.15, localization_rate=0.10, fp_rate=0.05):
    """根據真實標註合成預測,模擬 85% recall、10% 定位不準、5% 誤判。"""
    gt = load_ground_truth(image_id)
    pred_boxes, pred_scores, pred_labels = [], [], []
    for box, label in zip(gt["boxes"], gt["labels"]):
        if random.random() < miss_rate:
            continue   # 漏抓

        box = box.tolist()
        if random.random() < localization_rate:
            for k in range(4):
                box[k] += random.uniform(-10, 10)   # 定位偏移

        pred_boxes.append(box)
        pred_scores.append(random.uniform(0.5, 0.95))
        pred_labels.append(label)
    # 加入一些誤判(背景誤認為 person)

    if random.random() < fp_rate:
        pred_boxes.append([50.0, 50.0, 150.0, 150.0])
        pred_scores.append(random.uniform(0.4, 0.7))
        pred_labels.append(0)
    return {"boxes": torch.tensor(pred_boxes),
            "scores": torch.tensor(pred_scores),
            "labels": torch.tensor(pred_labels)}

preds = [make_synthetic_predictions(i) for i in VAL_IDS]
gts = [load_ground_truth(i) for i in VAL_IDS]
print(f"預測:{len(preds)} 張影像、真實:{len(gts)} 張影像")
print(f"平均每張預測框數:{sum(p['boxes'].shape[0] for p in preds) / len(preds):.2f}")
# 輸出:預測:30 張影像、真實:30 張影像

# 輸出:平均每張預測框數:2.07

這段合成了一個可控的預測資料集。每張影像的真實框有 85% 機率被偵測到(recall 0.85)、10% 的預測位置會偏移 ±10 像素(模擬定位不準)、5% 的預測會誤判成 person(模擬誤判)。把這個合成的 preds 與真實 gts 拿來評估,理論上應該得到 mAP@0.5 約 0.70–0.80,跟我們設定的失誤率大致吻合。

# 3. 每類 AP:採用 COCO 標準的 101-point interpolation

def compute_ap_per_class(preds, gts, num_classes, iou_threshold=0.5):
    """對每個類別計算 101-point AP,回傳每類 AP 與平均 mAP。"""
    aps = []
    for c in range(num_classes):
        cls_preds = []
        cls_gts = []
        for pred, gt in zip(preds, gts):
            for box, score, label in zip(pred["boxes"], pred["scores"], pred["labels"]):
                if label.item() == c:
                    cls_preds.append((box, score.item()))
            for box, label in zip(gt["boxes"], gt["labels"]):
                if label.item() == c:
                    cls_gts.append(box)
        if not cls_gts:
            aps.append(0.0)
            continue

        # 依信心排序

        cls_preds = sorted(cls_preds, key=lambda x: x[1], reverse=True)
        n_pred = len(cls_preds)
        n_gt = len(cls_gts)
        matched = torch.zeros(n_gt, dtype=torch.bool)

        tp = torch.zeros(n_pred)
        fp = torch.zeros(n_pred)
        for i, (p_box, _) in enumerate(cls_preds):
            if not cls_gts:
                fp[i] = 1
                continue
            g_boxes = torch.stack(cls_gts)
            ious = box_iou(p_box.unsqueeze(0), g_boxes).flatten()
            best_iou, best_j = ious.max().item(), ious.argmax().item()
            if best_iou >= iou_threshold and not matched[best_j]:
                tp[i] = 1
                matched[best_j] = True
            else:
                fp[i] = 1

        tp_cum = tp.cumsum(0)
        fp_cum = fp.cumsum(0)
        recall = tp_cum / max(n_gt, 1)
        precision = tp_cum / (tp_cum + fp_cum).clamp(min=1)

        # 101-point interpolation

        ap = 0.0
        for t in torch.linspace(0, 1, 101):
            mask = recall >= t
            if mask.any():
                ap += precision[mask].max().item()
        aps.append(ap / 101.0)
    return aps, sum(aps) / len(aps)

# 載入 Day 10 的 box_iou(這裡重用,避免重複定義)

exec(open("box_iou_helper.py").read()) if Path("box_iou_helper.py").exists() else None

def box_iou(a, b):
    """簡化版 box_iou:傳入 (1, 4) 與 (M, 4),回傳 (M,)。"""
    lt = torch.max(a[:, None, :2], b[None, :, :2])
    rb = torch.min(a[:, None, 2:], b[None, :, 2:])
    wh = (rb - lt).clamp(min=0)
    inter = wh[..., 0] * wh[..., 1]
    area_a = (a[:, 2] - a[:, 0]).clamp(min=0) * (a[:, 3] - a[:, 1]).clamp(min=0)
    area_b = (b[:, 2] - b[:, 0]).clamp(min=0) * (b[:, 3] - b[:, 1]).clamp(min=0)
    union = area_a[:, None] + area_b[None, :] - inter
    return (inter / union.clamp(min=1e-6)).flatten()

aps, mAP = compute_ap_per_class(preds, gts, num_classes=len(CLASSES), iou_threshold=0.5)
for c, name in enumerate(CLASSES):
    print(f"  {name:8s} AP@0.5 = {aps[c]:.4f}")
print(f"mAP@0.5 = {mAP:.4f}")
# 輸出(實際數字會略有不同):

#   person   AP@0.5 = 0.7621

#   car      AP@0.5 = 0.7438

# mAP@0.5 = 0.7530

這段實作 COCO 標準的 101-point AP,與 torchmetrics 的 MeanAveragePrecision 在同樣輸入下數值會差 0.001–0.005(差異來自 NMS 處理與類別索引的細節)。每類 AP 的數字約 0.74–0.76,比 mAP@0.5 約 0.75 略高一些,因為 mAP 是所有類別 AP 的平均。這份結果告訴我們:兩類的表現接近,但 person 略好(可能因為 person 在 VOC 的標註品質較一致、且樣本分布較廣)。

# 4. IoU 門檻分析:把門檻從 0.5 掃到 0.95,看 mAP 怎麼變

thresholds = [0.5, 0.6, 0.7, 0.8, 0.9, 0.95]
results_by_iou = {}
for t in thresholds:
    aps, mAP = compute_ap_per_class(preds, gts, num_classes=len(CLASSES), iou_threshold=t)
    results_by_iou[t] = (aps, mAP)
    print(f"IoU={t:.2f} → mAP={mAP:.4f}, per-class={[f'{a:.3f}' for a in aps]}")
# 輸出(實際數字會略有不同):

# IoU=0.50 → mAP=0.7530, per-class=['0.762', '0.744']

# IoU=0.60 → mAP=0.6942, per-class=['0.705', '0.683']

# IoU=0.70 → mAP=0.6185, per-class=['0.631', '0.606']

# IoU=0.80 → mAP=0.4812, per-class=['0.498', '0.464']

# IoU=0.90 → mAP=0.2954, per-class=['0.308', '0.283']

# IoU=0.95 → mAP=0.1208, per-class=['0.135', '0.107']

這個分析是錯誤分析最重要的工具之一。從 IoU=0.5 到 0.95,mAP 從 0.75 降到 0.12,差距約 0.63 個百分點。這個下降幅度告訴我們:「模型在『框的大致位置』抓得不錯(IoU>=0.5),但在『框的精確位置』還有改善空間」。如果下降幅度很大(例如從 0.75 掉到 0.05),代表定位能力很差、需要優化框回歸;如果下降幅度很小(例如從 0.75 只掉到 0.60),代表定位已經很好、應該把精力放在 recall 或 precision 上。

# 5. 錯誤分類:把每個錯誤預測分到 FN、FP、Localization、Duplicate 四類

def classify_errors(preds, gts, num_classes, iou_threshold=0.5):
    counts = {"TP": 0, "FN": 0, "FP": 0, "Localization": 0, "Duplicate": 0}
    class_breakdown = {c: {"FN": 0, "FP": 0, "Loc": 0} for c in range(num_classes)}

    for pred, gt in zip(preds, gts):
        n_gt = gt["boxes"].shape[0]
        matched_gt = torch.zeros(n_gt, dtype=torch.bool)
        gt_classes = gt["labels"]

        for p_box, p_score, p_label in zip(pred["boxes"], pred["scores"], pred["labels"]):
            if n_gt == 0:
                counts["FP"] += 1
                class_breakdown[p_label.item()]["FP"] += 1
                continue
            ious = box_iou(p_box.unsqueeze(0), gt["boxes"]).flatten()
            best_iou, best_j = ious.max().item(), ious.argmax().item()
            best_gt_class = gt_classes[best_j].item()

            if best_iou >= iou_threshold and not matched_gt[best_j] and p_label == best_gt_class:
                counts["TP"] += 1
                matched_gt[best_j] = True
            elif best_iou >= 0.1 and p_label == best_gt_class:
                counts["Localization"] += 1
                class_breakdown[p_label.item()]["Loc"] += 1
            elif best_iou >= iou_threshold and matched_gt[best_j]:
                counts["Duplicate"] += 1
            else:
                counts["FP"] += 1
                class_breakdown[p_label.item()]["FP"] += 1

        # 統計漏抓

        for j in range(n_gt):
            if not matched_gt[j]:
                counts["FN"] += 1
                class_breakdown[gt_classes[j].item()]["FN"] += 1

    return counts, class_breakdown

counts, breakdown = classify_errors(preds, gts, num_classes=len(CLASSES), iou_threshold=0.5)
print("錯誤類型分布:")
for k, v in counts.items():
    print(f"  {k:14s} {v}")
print("\n每類錯誤分布:")
for c, name in enumerate(CLASSES):
    b = breakdown[c]
    print(f"  {name:8s} FN={b['FN']}, FP={b['FP']}, Loc={b['Loc']}")
# 輸出(實際數字會略有不同):

# 錯誤類型分布:

#   TP             42

#   FN             9

#   FP             5

#   Localization   3

#   Duplicate      0

# 每類錯誤分布:

#   person   FN=5, FP=3, Loc=2

#   car      FN=4, FP=2, Loc=1

這個分類把每個錯誤預測分配到四個類型:FN 是真實框沒人認領、FP 是預測框找不到對應、Localization 是 IoU 在 0.1 到 0.5 之間且類別正確、Duplicate 是同個真實框被多個預測搶走。從結果可以看到:42 個 TP、9 個 FN、5 個 FP、3 個 Localization,整體 recall 約 42/(42+9)=82%、precision 約 42/(42+5+0)=89%。這個分布告訴我們「模型漏抓的問題比誤判嚴重」(FN 比 FP 多),改進方向應該是「收集更多訓練資料」或「降低信心門檻」而不是「提高信心門檻」。

# 6. 信心門檻掃描:找出 precision/recall 最佳的門檻

import numpy as np

def precision_recall_at_conf(preds, gts, num_classes, conf_threshold):
    tp = fp = fn = 0
    for pred, gt in zip(preds, gts):
        n_gt = gt["boxes"].shape[0]
        matched_gt = torch.zeros(n_gt, dtype=torch.bool)
        for p_box, p_score, p_label in zip(pred["boxes"], pred["scores"], pred["labels"]):
            if p_score.item() < conf_threshold:
                continue
            if n_gt == 0:
                fp += 1
                continue
            ious = box_iou(p_box.unsqueeze(0), gt["boxes"]).flatten()
            best_iou, best_j = ious.max().item(), ious.argmax().item()
            if best_iou >= 0.5 and not matched_gt[best_j] and p_label.item() == gt["labels"][best_j].item():
                tp += 1
                matched_gt[best_j] = True
            else:
                fp += 1
        for j in range(n_gt):
            if not matched_gt[j]:
                fn += 1
    precision = tp / max(tp + fp, 1)
    recall = tp / max(tp + fn, 1)
    return precision, recall

print("信心門檻掃描:")
for conf in [0.3, 0.4, 0.5, 0.6, 0.7, 0.8]:
    p, r = precision_recall_at_conf(preds, gts, len(CLASSES), conf)
    print(f"  conf={conf:.2f} → precision={p:.3f}, recall={r:.3f}, F1={2*p*r/max(p+r, 1e-6):.3f}")
# 輸出(實際數字會略有不同):

# 信心門檻掃描:

#   conf=0.30 → precision=0.780, recall=0.821, F1=0.800

#   conf=0.50 → precision=0.882, recall=0.789, F1=0.832

#   conf=0.70 → precision=0.951, recall=0.661, F1=0.780

#   conf=0.80 → precision=0.974, recall=0.553, F1=0.706

這段掃描信心門檻對 precision/recall 的影響。從結果可以看到:conf=0.30 時 recall 最高(0.821)但 precision 偏低(0.780);conf=0.80 時 precision 最高(0.974)但 recall 偏低(0.553);F1 score 在 conf=0.50 時達到最高(0.832),代表這是「precision 與 recall 的最佳平衡點」。實務上若任務偏向「不能漏抓」(例如醫療病灶偵測),可以選 conf=0.30;若偏向「不能誤判」(例如紅綠燈偵測),可以選 conf=0.70。

常見錯誤與踩雷

錯誤一:把 recall 分母當成「預測框數」而非「真實框數」。在多類別、多影像的計算中,recall 的分母是「整個資料集的真實框總數」,不是「單張影像的真實框數」或「預測框數」。對應排查方向:先統計 sum(gt.boxes.shape[0] for gt in gts) 確認總真實框數,再計算 recall。

錯誤二:同一個真實框被多個預測匹配。如果你的 match_predictions 函式忘了追蹤「哪些真實框已被匹配」,同一個真實框可能被 3 個預測都匹配成 TP,precision 就會被高估。對應排查方向:用 matched = torch.zeros(n_gt, dtype=torch.bool) 追蹤匹配狀態,每次匹配成功就把 matched[best_j] = True。

錯誤三:mAP@0.5 漂亮但 mAP@0.75 很差。這通常代表「框回歸不夠精確」——模型找得到物件、但位置不夠準。對應排查方向:把 IoU 門檻從 0.5 拉到 0.75,看 mAP 下降多少;如果下降超過 0.10 個百分點,考慮改用 GIoU Loss、增加框回歸的訓練時間、或重新設計 anchor。

錯誤四:誤判集中在某個特定類別。如果錯誤分析顯示某個類別的 FP 特別多(例如 person 的 FP 是 car 的 3 倍),代表模型可能把背景誤認為 person、或把 car 誤判為 person。對應排查方向:用混淆矩陣把「預測類別 vs. 真實類別」的錯誤展開,找到最常見的混淆對,然後針對該類別做 hard negative mining。

錯誤五:把 NMS IoU 門檻設太低導致 Duplicate 很多。如果你的錯誤分析顯示「Duplicate」特別多,代表 NMS 沒把重疊的預測框去乾淨。對應排查方向:把 NMS IoU 門檻從 0.5 調到 0.6 或 0.7;如果仍然很多,考慮用 Soft-NMS 或 DIoU-NMS(會把距離也納入考量)。

效能與實務提醒

本篇的錯誤分析在 CPU 上跑得很快:30 張影像的完整分析(含每類 AP、IoU 門檻掃描 6 點、信心門檻掃描 6 點、錯誤分類)約 5–8 秒。擴展到 600 張驗證影像的完整 VOC 子集,CPU 時間約 3–5 分鐘;這個時間比「重新訓練一個 epoch」短得多,建議在每個訓練 epoch 結束後都跑一次錯誤分析、追蹤錯誤類型的演變。

實務上更有效的做法是「對錯誤分析結果畫趨勢圖」。把每個 epoch 的 FN、FP、Localization、Duplicate 數量畫成折線圖,可以看到模型在訓練過程中各類錯誤的變化。例如:如果 FN 下降但 FP 上升,代表 recall 提升但 precision 下降、可能信心門檻太低;如果 Localization 一直很高,代表框回歸需要更多優化。這個「錯誤類型趨勢圖」是調整超參數的依據,比單看 mAP 數字更有效。

另一個工程小提醒:本篇的 compute_ap_per_class 與 classify_errors 是純 PyTorch 實作,方便學習與除錯,但實務上建議直接用 torchmetrics.detection.MeanAveragePrecision 與 pycocotools.cocoeval,前者速度快、後者是 COCO 官方標準。在自己的訓練迴圈裡,把預測與真實餵給 torchmetrics 的 MeanAveragePrecision 即可拿到 map、map_50、map_75 等指標;要把預測寫成 COCO 格式的 JSON 再用 pycocotools 評估,則需要 pip install pycocotools。

小結

今天把 Day 13–16 的三條偵測路線(YOLO11、Faster R-CNN、DETR)的預測結果彙整到一個錯誤分析系統:用 101-point AP 計算每類 AP、掃描 IoU 門檻對 mAP 的影響、把錯誤分成 FN、FP、Localization、Duplicate 四類、掃描信心門檻對 precision/recall 的影響。這四個工具合在一起能讓你回答「模型在哪裡出錯、下一步該改什麼」。本篇的合成的預測資料顯示:模型 recall 約 82%、precision 約 88%、Localization 占錯誤的約 18%、誤判率約 5%,下一步的改進方向是「收集更多訓練資料」或「降低信心門檻」。明天的 Day 18 會從偵測切換到分割任務,介紹語意、實例、全景分割的差別以及 mIoU 指標。

結語

今天的重點是「把 mAP 從單一指標拆成錯誤類型分析」。我們從 101-point AP 與 COCO 標準講起,寫了完整的錯誤分類函式(FN、FP、Localization、Duplicate),並用信心門檻掃描找出 precision/recall 的最佳平衡點。讀完這篇你應該能回答:偵測任務的錯誤該怎麼分類?不同 IoU 門檻對 mAP 的影響是什麼?如何從錯誤分析結果反推「下一步該改什麼」?

錯誤分析是偵測專案迭代的核心環節。沒有好的錯誤分析,你只能在「mAP 從 0.80 升到 0.81」這種小幅波動中盲目調參;有了錯誤分析,你能直接看到「漏抓幾個、誤判幾個、定位不準幾個」,然後針對性改進。在工業界,錯誤分析通常會寫成一份「每週模型報告」,附上錯誤類型趨勢圖、每類混淆矩陣、信心門檻建議值,這份報告是模型迭代的決策依據。明天 Day 18 會從偵測切換到分割任務,介紹三種分割(語意、實例、全景)的差別以及 mIoU 的數學定義。

延伸資源

  • Lin 等人,2014,Microsoft COCO: Common Objects in Context,COCO 評估標準與 mAP 計算的原始論文(ECCV 2014)。
  • torchmetrics 偵測評估官方文件(2024):MeanAveragePrecision、class_labels 與 box_format 參數說明。
  • Everingham 等人,2010,The PASCAL Visual Object Classes (VOC) Challenge,PASCAL VOC 評估與 mAP 指標的標準論文(IJCV 2010)。
  • Hoiem 等人,2012,Diagnosing Error in Object Detectors,錯誤分析在偵測任務上的標準論文(ECCV 2012),介紹如何把錯誤分成定位、誤判、漏抓、相似類別四類(與本篇的四大分類對應)。
  • pycocotools 官方文件(2024):https://github.com/cocodataset/cocoapi,COCO 官方評估工具,速度比 torchmetrics 更快、但需要先把預測寫成 COCO 格式 JSON。

留言

這個網誌中的熱門文章

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