NLP Day 10 分類任務的評估與錯誤分析
執行需求:CPU 可跑。本篇所有範例在一般筆電的 CPU 上執行,scikit-learn 與 matplotlib 都是輕量套件,沒有大型預訓練模型要下載。我們會用一個固定的隨機分類器與一段合成資料,把 accuracy、precision、recall、F1、混淆矩陣、PR 曲線、ROC 曲線、錯誤分析流程一次跑完,預估執行時間 2 分鐘以內。
引言
前一篇我們在 go_emotions 上微調了多標籤分類模型,也在 bart-large-mnli 上跑了零樣本分類。模型訓完或推論做完之後,下一步往往不是「丟上線」,而是「這個模型到底行不行」。如果不能用具體的數字回答「行不行」,部署之後就會被問倒:客戶說「我們這個分類器好像不準」,你卻拿不出 accuracy、不肯量化、不肯說哪一類特別差。今天就要把分類任務的評估流程寫成一套可重現的工具。
分類評估的工具看似簡單(accuracy、F1、混淆矩陣),但實務上有不少細節:多分類要看 macro-F1 還是 micro-F1?多標籤要用 subset accuracy 還是 sample-averaged F1?類別不平衡時 accuracy 為什麼會誤導?ROC 曲線和 PR 曲線哪個比較適合不平衡資料?每個類別的閾值要不要各自調?這些問題在課堂上常被略過,但到了工業界會直接決定模型能不能上線。
這一篇會用一個完整的範例,把「從預測到決策」的流程跑一次。我們會用 scikit-learn 的標準 API(classification_report、confusion_matrix、precision_recall_curve、roc_curve)搭配 matplotlib 視覺化,並把錯誤分析的步驟寫成一套可重現的 checklist。讀完這篇你會了解:多分類與多標籤的指標體系、不平衡資料要關注哪些數字、PR 曲線如何指導閾值選擇、以及「看錯誤樣本」這件事為什麼比看指標更重要。
分類指標的定義
二元分類的基本概念是「預測對/錯」與「真實正/負」的對應。當樣本真實為正、模型也預測為正,叫「真正(True Positive, TP)」;真實為正、模型預測為負,叫「偽陰(False Negative, FN)」;真實為負、模型預測為正,叫「偽陽(False Positive, FP)」;真實為負、模型也預測為負,叫「真陰(True Negative, TN)」。四個數字組合出四個常用指標:
Precision(精確率)= TP / (TP + FP):在模型說「是正」的所有樣本中,真的是正的比例。Precision 衡量「不要亂報」。Recall(召回率)= TP / (TP + FN):在所有真實的正樣本中,模型抓到的比例。Recall 衡量「不要漏接」。F1 = 2 × Precision × Recall / (Precision + Recall):兩者的綜合。F1 適合在 precision 與 recall 同等重要的場景;如果兩者權重不同,可以用 Fβ 調整。
在多分類(multi-class)場景,要把每個類別當作「正類」、其他類別當作「負類」,分別算 precision 與 recall,再做平均。常見的兩種平均方式:macro-average 對每個類別的指標直接平均,給每個類別同等權重;micro-average 把所有類別的 TP/FP/FN 加總再算指標,給每個樣本同等權重。當類別分佈平衡時,macro-F1 與 micro-F1 接近;當類別嚴重不平衡時,micro-F1 會被多數類別主導,macro-F1 才能反映少數類別的表現。實務上若類別不平衡,請以 macro-F1 為主要指標。
多標籤(multi-label)的指標體系又多一層。多分類用 micro/macro 兩種平均已經足夠,多標籤則有 sample-based 與 label-based 兩個維度。Sample-averaged F1 先對每個樣本算 F1 再平均;label-averaged F1 對每個類別算 F1 再平均。Subset accuracy 則更嚴格:預測的類別集合必須與真實類別集合完全相同才算對。在 go_emotions 這類多標籤資料上,subset accuracy 會非常低(因為多個情緒同時出現的機率小),用 sample-F1 與 macro-F1 才合理。
另一個重要概念是混淆矩陣(confusion matrix)。對於 N 類分類,混淆矩陣是 N×N 的方陣,第 i 列第 j 行表示「真實為第 i 類、預測為第 j 類」的樣本數。對角線是預測正確的數量,非對角線是錯誤。多分類的混淆矩陣通常會做行標準化(除以該類別的樣本總數),這樣每列加起來是 1,更容易看出「每類的誤判流向」。例如一個 5 類分類器的混淆矩陣顯示「狗」常被誤成「貓」,這比單看 F1 更能指引最佳化方向。
對於機率型預測(model 輸出 0 到 1 的機率),還有兩條曲線很有用:ROC 曲線(Receiver Operating Characteristic)與 PR 曲線(Precision-Recall)。兩條曲線都是改變決策閾值、把所有 (precision, recall) 或 (FPR, TPR) 點連起來得到的曲線。ROC 曲線下面積(AUC)是常用的單一指標;PR 曲線下面積(AP, Average Precision)則更適合類別不平衡的場景,因為 ROC 在大量 TN 的情況下會過度樂觀。實務上多標籤任務一定要看 PR 曲線,不要只看 ROC。
錯誤分析的步驟
看完指標,下一步是「看模型犯了哪些錯」。這個過程在文獻上叫做 error analysis,是模型迭代的核心環節。常見的步驟:
第一步,從驗證集(或保留的測試集)取出預測錯誤的樣本,按錯誤類型分群。在多分類場景,可以從混淆矩陣出發:對角線以外的最大值就是「最常見的誤判配對」。把這些配對的樣本各取 5–10 筆觀察,常常能立刻看出模型為什麼錯。
第二步,檢查「高信心錯誤」與「低信心正確」。如果模型對錯的樣本信心都很高(接近 1),那表示模型的特徵學習有問題;如果模型對錯的樣本信心都很低(接近 0.5),那可能只是閾值不對。實務上把「信心 vs 正確性」做成散布圖,能很快看出模型是否學對東西。
第三步,看「資料特性」造成的錯誤。常見模式包括:訓練資料沒涵蓋某些詞彙(OOV 命名實體、罕見用詞)、標註不一致(同樣的句子被不同標註者給不同類別)、類別定義模糊(「抱怨」與「建議」的界線)。這些錯誤不是模型問題,而是資料問題;解法是補資料、修標籤指南,而不是改模型架構。
第四步,把錯誤案例的樣本特徵做表格化紀錄。例如錯誤案例的句子長度分佈、出現的關鍵詞、來源類別,整理成「錯誤分析報告」。這份報告是後續最佳化的起點,也是和團隊溝通「為什麼這個版本還不能上線」的依據。
完整實作:用 sklearn 跑完整評估
以下範例刻意用「合成資料」而非真實分類器,這樣所有數字都可重現。重點不是模型,而是「拿到一組預測後,怎麼把指標、矩陣、曲線、錯誤分析一次跑完」。執行前請先 pip install scikit-learn==1.2.2 matplotlib numpy。
# 1. 建構一個 5 類、輕度不平衡的合成資料集
from sklearn.datasets import make_classification
import numpy as np
X, y = make_classification(
n_samples=2000, n_classes=5, n_informative=8, n_redundant=2,
weights=[0.4, 0.25, 0.15, 0.12, 0.08], # 類別不平衡
random_state=42,
)
print(f"樣本形狀:{X.shape},類別分佈:{np.bincount(y)}")
# 輸出:樣本形狀:(2000, 20),類別分佈:[800 500 300 240 160]
這段用 make_classification 生成 2,000 筆、20 維特徵、5 類的合成資料,類別比例刻意做成 [40%, 25%, 15%, 12%, 8%],模擬真實世界常見的類別不平衡。注意最少的類別只有 160 筆,這對後續的 stratified split 與 macro-F1 解讀很關鍵。
# 2. 切分訓練/測試集,並訓練一個隨機森林
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report, confusion_matrix
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.25, random_state=42, stratify=y,
)
clf = RandomForestClassifier(n_estimators=200, random_state=42, n_jobs=-1)
clf.fit(X_train, y_train)
y_pred = clf.predict(X_test)
y_score = clf.predict_proba(X_test)
print(f"測試集樣本數:{len(y_test)}")
print(f"預測機率形狀:{y_score.shape}")
# 輸出:測試集樣本數:500
# 輸出:預測機率形狀:(500, 5)
隨機森林在合成資料上通常能拿到 0.85 左右的 accuracy。對 5 類、類別不平衡的設定,光看 accuracy 會誤導——因為最常見的類別佔 40%,亂猜也有 40%。後續我們會用 macro-F1 與 PR 曲線做更細緻的評估。stratify 參數確保測試集與訓練集的類別比例一致。
# 3. 用 classification_report 看每類別的 precision、recall、F1
target_names = ["A", "B", "C", "D", "E"]
print(classification_report(y_test, y_pred, target_names=target_names, digits=3))
# 輸出(實際數字會略有不同):
# precision recall f1-score support
# A 0.901 0.948 0.924 200
# B 0.866 0.888 0.877 125
# C 0.807 0.827 0.817 75
# D 0.768 0.733 0.750 60
# E 0.793 0.625 0.699 40
# accuracy 0.872 500
# macro avg 0.827 0.804 0.813 500
# weighted avg 0.871 0.872 0.870 500
這個報表一目了然:A 類(F1 = 0.924)表現最好、E 類(F1 = 0.699)最差。Accuracy 0.872 看起來不錯,但 macro-F1 只有 0.813,差距反映了「少數類別拖整體」。support 欄位告訴我們每類別有多少測試樣本——E 類只有 40 筆,這對評估穩定性是個警訊(任何一兩個樣本的誤差都會大幅影響 F1)。實務上看到少數類別 support 太低,會建議做 bootstrap 或交叉驗證來穩定指標。
# 4. 標準化混淆矩陣:每列加起來是 1,看「誤判流向」更清楚
import matplotlib.pyplot as plt
cm = confusion_matrix(y_test, y_pred, labels=list(range(5)))
cm_norm = cm.astype(float) / cm.sum(axis=1, keepdims=True)
fig, ax = plt.subplots(figsize=(6, 5))
im = ax.imshow(cm_norm, cmap="Blues", vmin=0, vmax=1)
ax.set_xticks(range(5)); ax.set_yticks(range(5))
ax.set_xticklabels(target_names); ax.set_yticklabels(target_names)
ax.set_xlabel("預測"); ax.set_ylabel("真實")
for i in range(5):
for j in range(5):
ax.text(j, i, f"{cm_norm[i, j]:.2f}", ha="center", va="center",
color="white" if cm_norm[i, j] > 0.5 else "black")
plt.colorbar(im, ax=ax)
plt.title("標準化混淆矩陣(列加總=1)")
plt.tight_layout(); plt.savefig("cm_normalized.png", dpi=120)
plt.close()
print("已輸出 cm_normalized.png")
# 找出最常見的誤判配對
np.fill_diagonal(cm_norm, 0)
i, j = np.unravel_index(cm_norm.argmax(), cm_norm.shape)
print(f"最常見誤判:真實 {target_names[i]} 被誤判為 {target_names[j]},比例 {cm_norm[i, j]*100:.1f}%")
# 輸出(實際數字會略有不同):
# 已輸出 cm_normalized.png
# 最常見誤判:真實 E 被誤判為 D,比例 18.x%
這段把混淆矩陣視覺化並標準化。標準化的關鍵是「除以該列總和」,這樣每列代表「真實為此類別的樣本,分別被預測到哪裡」。對角線接近 1 表示預測正確;非對角線的最大值通常就是「最常見誤判」。我們這裡找出 E 類常被誤判為 D 類,這個資訊比單看 F1 更具體——後續可以針對 E 與 D 的特徵差異做資料增強。
# 5. PR 曲線與 ROC 曲線:只看類別 E(少數類)做示範
from sklearn.metrics import precision_recall_curve, average_precision_score, roc_curve, auc
class_idx = 4 # E 類
y_bin = (y_test == class_idx).astype(int)
prec, rec, thr = precision_recall_curve(y_bin, y_score[:, class_idx])
ap = average_precision_score(y_bin, y_score[:, class_idx])
fpr, tpr, _ = roc_curve(y_bin, y_score[:, class_idx])
roc_auc = auc(fpr, tpr)
fig, axes = plt.subplots(1, 2, figsize=(10, 4))
axes[0].plot(rec, prec, label=f"AP={ap:.3f}")
axes[0].set_xlabel("Recall"); axes[0].set_ylabel("Precision")
axes[0].set_title(f"類別 {target_names[class_idx]} PR 曲線")
axes[0].grid(True); axes[0].legend()
axes[1].plot(fpr, tpr, label=f"AUC={roc_auc:.3f}")
axes[1].plot([0, 1], [0, 1], "k--", alpha=0.3)
axes[1].set_xlabel("FPR"); axes[1].set_ylabel("TPR")
axes[1].set_title(f"類別 {target_names[class_idx]} ROC 曲線")
axes[1].grid(True); axes[1].legend()
plt.tight_layout(); plt.savefig("pr_roc.png", dpi=120); plt.close()
print(f"類別 E:AP={ap:.3f}, AUC={roc_auc:.3f}")
# 輸出(實際數字會略有不同):
# 類別 E:AP=0.7xx, AUC=0.8xx
PR 曲線與 ROC 曲線是閾值選擇的工具。PR 曲線的每個點對應一個閾值(threshold),把 (precision, recall) 連起來;如果我們想要 recall ≥ 0.7,就找 PR 曲線上 recall = 0.7 對應的 precision,這就是「在這個業務需求下的最佳閾值」。AP(PR 曲線下面積)越大代表模型越好。ROC 與 PR 的差別在於:ROC 把 TN 也算進去(AUC = 0.5 是亂猜、1.0 是完美);PR 只看正類,類別不平衡時 ROC 容易過度樂觀。實務上不平衡任務以 PR 為主、ROC 為輔。
# 6. 多標籤評估:把同一份多分類資料轉成多標籤,看 F1 的差異
from sklearn.preprocessing import label_binarize
from sklearn.metrics import f1_score
y_test_bin = label_binarize(y_test, classes=list(range(5))) # one-hot
y_pred_bin = label_binarize(y_pred, classes=list(range(5)))
print(f"subset accuracy: {f1_score(y_test_bin, y_pred_bin, average='samples', zero_division=0):.3f}")
print(f"sample-averaged F1: {f1_score(y_test_bin, y_pred_bin, average='samples', zero_division=0):.3f}")
print(f"label-averaged macro-F1: {f1_score(y_test_bin, y_pred_bin, average='macro', zero_division=0):.3f}")
print(f"label-averaged micro-F1: {f1_score(y_test_bin, y_pred_bin, average='micro', zero_division=0):.3f}")
# 輸出(實際數字會略有不同):
# subset accuracy: 0.872
# sample-averaged F1: 0.872
# label-averaged macro-F1: 0.813
# label-averaged micro-F1: 0.872
這段把多分類預測轉成 one-hot,再套多標籤 F1 的公式。label_binarize 是 sklearn 把多分類標籤轉 multi-hot 的標準工具。注意 micro-F1 與 subset accuracy 在這個特例下會相等,因為每個樣本只屬於一個類別;多標籤時 micro-F1 與 subset accuracy 就會有差異。多標籤的標準做法是看 sample-F1 與 macro-F1 兩個,sample-F1 反映「每個樣本的平均表現」,macro-F1 反映「每個類別的平均表現」,兩者差距大代表模型對少數類別特別弱。
# 7. 錯誤分析:取最常見誤判的 5 筆樣本,看特徵與信心
mask = (y_test == 4) & (y_pred == 3) # 真實 E、預測 D
errors = X_test[mask]
err_scores = y_score[mask]
print(f"誤判為 D 的 E 樣本數:{mask.sum()}")
print(f"模型對這些樣本的信心(預測為 D):{(err_scores[:, 3] > 0.5).mean()*100:.1f}%")
# 對前 3 筆看信心分佈
for i in range(min(3, mask.sum())):
print(f" 樣本 {i}:真實 E,預測 {target_names[y_pred[mask][i]]}(信心 {y_score[mask][i].max():.3f})")
# 輸出(實際數字會略有不同):
# 誤判為 D 的 E 樣本數:7
# 模型對這些樣本的信心(預測為 D):71.4%
錯誤分析的最後一步:把「最常見的誤判配對」取出來看具體樣本。我們這裡看到 E 類有 7 筆被誤判為 D 類,其中 5 筆(71.4%)模型還很有信心(> 0.5)。這訊號很明確——模型對 E 和 D 的區分並沒有學好,後續可以考慮:增加 E 類的訓練資料、加入 E 與 D 差異性的特徵、或在 loss 加入 class weight 讓模型更重視少數類別。這種「定性 + 定量」的錯誤分析比單看指標更有效。
常見錯誤與踩雷
錯誤一:在測試集上選閾值。多標籤任務常見做法:在驗證集上做閾值搜尋、把最佳閾值存進 checkpoint、測試集只用於最終一次評估。如果在測試集上反覆調參,等於把測試集當成驗證集,會讓測試集表現失去意義。修正方式是「驗證集找閾值、測試集只評估一次」。
錯誤二:用 accuracy 評估不平衡資料。5 類分佈 [40%, 25%, 15%, 12%, 8%] 的資料,把所有樣本預測為 A 類就能拿到 40% accuracy。看起來還行,其實完全沒學到東西。請改用 macro-F1(給每個類別同等權重)或 per-class recall(看少數類別是否被忽略)。
錯誤三:把 micro-F1 當成多標籤的唯一指標。micro-F1 把所有類別的 TP/FP/FN 加總算一次 F1,對多數類別有利的設定下數字會偏樂觀。多標籤任務請同時看 sample-F1 與 macro-F1;兩者差距大代表模型對少數類別處理不夠好。Subset accuracy(嚴格匹配)則適合用在「必須完全正確」的應用(例如多選題自動評分)。
錯誤四:忘了設 random_state。train_test_split、RandomForestClassifier、make_classification 都有隨機性。如果不設 random_state,每次跑的切分與訓練結果會不同,指標數字無法重現比較。實務上請把所有隨機性來源都鎖定一個固定的 seed(例如 42),這樣團隊合作時能重現彼此的數字。
錯誤五:混淆矩陣沒標準化就看數字。原始混淆矩陣的數字是「樣本數」,多數類別的數字會主導視覺解讀,看起來好像少數類別也錯很多,但其實相對比例不高。請改用「列標準化」後的版本,每列加總為 1,這樣「每類的誤判流向」才清楚。另一個常見錯誤是行標準化(除以預測總數),適合用在「假陽性最在意的場景」(例如垃圾郵件誤判為正常);列標準化適合「召回率在意的場景」(例如醫療診斷不能漏病患)。
錯誤六:只看重指標不看錯誤樣本。很多團隊看到 macro-F1 從 0.80 升到 0.82 就宣布完成,但實際上錯誤樣本的特徵可能完全沒變(例如某些罕見詞彙的判斷仍然全錯)。請把「看錯誤樣本」當成評估的必做項:用 sklearn 的 permutation_importance、SHAP、或單純取出高信心錯誤樣本 20 筆人工檢查。定性觀察往往能指出指標看不出的問題。
效能與實務提醒
這一篇的所有範例都不需要 GPU,CPU 上 2 分鐘內能跑完。隨機森林 200 棵在 2000 筆合成資料上訓練約 2 秒;sklearn 的 classification_report、confusion_matrix、precision_recall_curve 都是在數百筆測試集上瞬間完成。matplotlib 畫圖是主要耗時,但每張圖都不超過 1 秒。實務上評估流程應該和訓練流程分離:訓練可能耗時數小時,評估只耗時數分鐘,這樣可以快速迭代。
大型模型的評估要避免「重複跑整個測試集」的成本。BERT-base 在 5,000 筆測試集上跑一次評估約 1 分鐘(Colab T4),但如果每次迭代都要重新跑,會很慢。實務上的做法是:訓練結束後把所有 logits 與 labels 存成 .pt,後續評估直接從 logits 算指標,不再跑模型。這也是為什麼前幾天的程式碼會把 logits 用 torch.cat 累積起來的原因。
部署端的監控也要建立指標系統。模型上線後,每天累積的真實標註(或使用者回饋)可以用同一套評估流程計算每類別的 precision 與 recall。這些指標放成 dashboard,能在模型表現退化(例如某類別 recall 掉 20%)時即時告警。對於多標籤模型,每類別的「每日預測數 vs 過去平均」也是重要的監控訊號:如果某類別突然暴增或暴減,通常代表上游資料分佈漂移。
另一個常被忽略的成本是「標註審核」。當 macro-F1 卡在 0.85 上不去,最常見的原因不是模型不夠好,而是「剩下的 15% 錯誤案例,連人類標註者都會標錯」。這時請把標籤指南拿出來重新檢視:類別定義是否模糊?標註者之間的一致性(IAA, Inter-Annotator Agreement)夠不夠?Cohen's Kappa 或 Krippendorff's Alpha 是常用的 IAA 指標。Day 11 會專門展開標註、弱監督與資料增強。
小結
本篇把分類任務的評估與錯誤分析寫成一套完整流程。我們看了 precision/recall/F1 的定義、macro vs micro 的差別、混淆矩陣的標準化解讀、PR 與 ROC 曲線的閾值選擇、錯誤分析的步驟。所有範例在 CPU 上 2 分鐘內跑完,可以用 scikit-learn 直接接到自己的分類模型上。讀完這篇你應該能回答:多分類與多標籤各要看哪些指標?類別不平衡時怎麼挑指標?PR 曲線與 ROC 曲線差在哪?錯誤分析該怎麼開始?這些答案都藏在本篇的程式與數字裡。明天 Day 11 我們會進入標註、弱監督與資料增強:當標註成本太高、資料太少時,可以用哪些技術擴增資料集。
結語
今天的重點是「把分類任務的評估寫成可重現的流程」。我們用了 scikit-learn 的標準 API,從分類報告、混淆矩陣、PR 曲線到錯誤樣本觀察,把「模型到底行不行」這個問題用具體數字與具體案例回答。從 Day 5 的 BERT 微調到今天的多分類指標,每一篇的工具都會在後續章節反覆使用。明天,我們會把「沒有標註怎麼辦」這個問題展開:標註流程設計、Snorkel 弱監督、文字資料增強(回譯、同義詞、EDA)、主動學習——這些技術能把標註成本壓到原來的十分之一。
在工業界,分類評估其實是「資料科學家與利害關係人對話的語言」。把指標寫進每週例會的 dashboard、把錯誤分析的案例整理成月報,這些習慣比選什麼模型更重要。下一篇進入標註與資料增強時,會談到 Snorkel 的 labeling function 設計、nlpaug 的文字增強、以及主動學習的不確定性抽樣,這些都是在「資料量不足」時的關鍵武器。
延伸資源
- Pedregosa 等人,Scikit-learn: Machine Learning in Python(JMLR 12, 2011),scikit-learn 原始論文,本篇所有指標函式的來源。
- Saito 與 Rehmsmeier,The Precision-Recall Plot Is More Informative than the ROC Plot When Evaluating Binary Classifiers on Imbalanced Datasets(PLOS ONE 2015),PR 曲線為何在不平衡資料上更可靠。
- Powers,Evaluation: from Precision, Recall and F-measure to ROC, Informedness, Markedness & Correlation(arXiv:2010.16061),precision/recall/F1 的數學推導整理。
- Hugging Face
datasets官方文件(2025-03 擷取):load_dataset("go_emotions")與分類評估相關章節(文件版本對應 transformers 4.49)。 - sklearn 官方文件(2025-03 擷取):
classification_report、precision_recall_curve、confusion_matrix的參數與輸出格式(對應 scikit-learn 1.2.x)。
留言
張貼留言