模型評估與 Cross-Validation 實務¶
評估做錯,選出的模型就錯——即使訓練做得再好也白費。本頁整理三件事:該用哪個指標衡量、該用哪種 CV 策略,以及超參數選擇和模型性能估計為什麼要分開做。
先想清楚:你在量什麼¶
好的評估流程從「定義成功」開始,而不是直接查指標公式。三個問題:
- 任務類型:分類?回歸?排序?
- 誤差的非對稱性:False Negative 比 False Positive 更貴嗎?(醫療診斷 vs 垃圾郵件)
- 資料特性:類別不平衡?樣本有群組結構?時序?
分類指標速查¶
| 指標 | 公式 | 用途 | 陷阱 |
|---|---|---|---|
| Accuracy | (TP+TN)/(全部) | 類別平衡時直觀 | 不平衡資料會誤導:99% 負例全猜負也有 99% |
| Precision | TP/(TP+FP) | 誤報代價高(垃圾郵件誤殺重要信) | 配合 Recall 看,單看沒意義 |
| Recall | TP/(TP+FN) | 漏報代價高(腫瘤漏診) | 全猜正可得 Recall=1,但 Precision=0 |
| F1 | 2·P·R/(P+R) | P 和 R 並重時的調和平均 | 仍假設 P/R 等重;不等重用 Fβ |
| AUC-ROC | ROC 曲線下面積 | 對閾值不敏感,評整體排序能力 | 嚴重不平衡時會偏樂觀 |
| PR-AUC | Precision-Recall 曲線下面積 | 不平衡資料的首選 | 比 AUC-ROC 更反映少數類表現 |
不平衡資料的評估建議¶
類別比例 > 1:10 時:
- 不用 Accuracy,改用 PR-AUC 或 F1
- Confusion matrix 必看,別只看彙總指標
- class_weight='balanced' 是調模型,不是調評估
from sklearn.metrics import classification_report, roc_auc_score, average_precision_score
print(classification_report(y_test, y_pred))
print("AUC-ROC:", roc_auc_score(y_test, y_prob))
print("PR-AUC:", average_precision_score(y_test, y_prob)) # 不平衡時更重要
回歸指標速查¶
| 指標 | 特性 | 適用情境 |
|---|---|---|
| MAE | 對離群值穩健,單位同 y | 離群值常見時、需要可解釋誤差 |
| MSE / RMSE | 懲罰大誤差,RMSE 單位同 y | 大誤差比小誤差嚴重時 |
| MAPE | 百分比誤差,可跨不同量級比較 | y 值都 > 0;y 值近 0 時會爆 |
| R² | 解釋變異的比例,1 最好,可為負 | 快速理解模型 vs 均值基線的差距 |
| Median AE | 比 MAE 更抗離群 | 離群值極端時 |
⚠️ 回歸模型如果目標做了 log 轉換,記得把預測值反轉回原始尺度再算 MAE/RMSE——否則指標是 log 尺度的,和業務定義的「誤差」不一致。
import numpy as np
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score
# 如果 y 做過 log1p 轉換
y_pred_original = np.expm1(y_pred_log)
y_test_original = np.expm1(y_test_log)
mae = mean_absolute_error(y_test_original, y_pred_original)
rmse = np.sqrt(mean_squared_error(y_test_original, y_pred_original))
r2 = r2_score(y_test_original, y_pred_original)
Cross-Validation 策略一覽¶
| 策略 | 何時用 | 注意事項 |
|---|---|---|
| KFold | 回歸、類別平衡分類 | shuffle=True, random_state= 確保可重現 |
| StratifiedKFold | 分類(預設首選) | 確保每折類別比例和全集一致 |
| GroupKFold | 同一個 entity 的多筆資料(同一個用戶的多次點擊) | 防止 group 內資料跨折造成 leakage |
| TimeSeriesSplit | 時序資料 | 只往前預測,不能讓未來資料進訓練折 |
| LOOCV | 小資料集(< 100 筆) | 計算量大(N 次訓練);估計無偏但 variance 高 |
| RepeatedStratifiedKFold | 追求穩定的估計(多次重複取平均) | 計算量 × repeat 倍 |
核心原則:測試折不能「看到」訓練資訊¶
最常見的 CV 錯誤是在 cross_val_score 外面先做了 StandardScaler、SMOTE 或特徵選擇——這讓 scaler 的 mean_/scale_ 來自全部資料,測試折被「看見了」。
正解:把所有前處理包進 Pipeline,讓每折各自 fit:
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold, cross_val_score
pipe = Pipeline([
('scaler', StandardScaler()),
('clf', LogisticRegression(max_iter=1000))
])
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores = cross_val_score(pipe, X, y, cv=cv, scoring='roc_auc')
print(f"AUC: {scores.mean():.4f} ± {scores.std():.4f}")
時序 CV:用 TimeSeriesSplit¶
from sklearn.model_selection import TimeSeriesSplit, cross_val_score
tscv = TimeSeriesSplit(n_splits=5)
scores = cross_val_score(pipe, X_ts, y_ts, cv=tscv, scoring='neg_mean_absolute_error')
print(f"MAE: {-scores.mean():.4f}")
⚠️ 時序資料也要注意 feature leakage:如果特徵裡有「未來才能知道的資訊」(如月底才有的月報數字用於預測月中),CV 分數會虛高,上線後必然崩。
GroupKFold:防止 entity 跨折¶
from sklearn.model_selection import GroupKFold
gkf = GroupKFold(n_splits=5)
# groups = 每筆資料屬於哪個 entity(如 user_id)
scores = cross_val_score(pipe, X, y, cv=gkf, groups=groups, scoring='f1')
Nested CV:超參數選擇 ≠ 模型性能估計¶
最常見的評估錯誤之一:用同一份 validation set 既選超參數又估模型性能——這會讓性能估計樂觀偏誤(你在測試集上「挑」了表現最好的一次)。
正確做法是 Nested CV:
from sklearn.model_selection import GridSearchCV, cross_val_score, KFold
from sklearn.ensemble import RandomForestClassifier
inner_cv = StratifiedKFold(n_splits=3, shuffle=True, random_state=1)
outer_cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
param_grid = {'clf__n_estimators': [100, 300], 'clf__max_depth': [None, 5, 10]}
pipe = Pipeline([('clf', RandomForestClassifier(random_state=0))])
grid_search = GridSearchCV(pipe, param_grid, cv=inner_cv, scoring='roc_auc')
# 外層 CV 估計泛化性能(每折都跑一次完整的內層超參數搜索)
nested_scores = cross_val_score(grid_search, X, y, cv=outer_cv, scoring='roc_auc')
print(f"Nested CV AUC: {nested_scores.mean():.4f} ± {nested_scores.std():.4f}")
Nested CV 計算量是 outer_splits × inner_splits × 超參數組數,資料大時很貴。折中做法:用訓練集的一個固定 holdout 做內層調參,外層仍做 CV 估性能。不如 nested CV 嚴謹,但工程上常用。
選最終模型並估真實性能¶
標準三切法:
from sklearn.model_selection import train_test_split
X_temp, X_test, y_temp, y_test = train_test_split(X, y, test_size=0.15, stratify=y, random_state=42)
X_train, X_val, y_train, y_val = train_test_split(X_temp, y_temp, test_size=0.18, stratify=y_temp, random_state=42)
# 結果約 70 / 15 / 15
# 用 train+val 做 CV 調參 → 用 test 最後報告性能
# test 只碰一次。碰了就不能再調。
⚠️ Test set 只能碰一次。看了 test 分數再調超參數,test 就污染了,不再是真實估計。如果測試結果不如預期,要回頭改訓練/驗證策略,而不是反覆試到 test 分數變好看。
常見陷阱整理¶
| 陷阱 | 症狀 | 解法 |
|---|---|---|
| CV 前全量 fit scaler | 線下 CV 分數虛高,上線後表現差 | 用 Pipeline 包前處理 |
| 時序不用 TimeSeriesSplit | 訓練時「看到未來」,分數虛高 | TimeSeriesSplit 或手動按時間切 |
| 用 accuracy 評不平衡資料 | 全猜多數類也有高分 | 改用 PR-AUC 或 F1 |
| 超參數選擇和性能估計用同一份 val | 估計樂觀偏誤 | Nested CV 或嚴格三切 |
| Group leakage | 同一 entity 的樣本跨了 train/test | GroupKFold |
| 多次比較膨脹 | 試了 50 組超參數選最好的,分數虛報 | Nested CV;記錄所有試驗結果而非只報最好 |
延伸閱讀(本站)¶
- ML 面試核心觀念 — Cross-validation 的概念速查
- 特徵工程心法 — 前處理決策影響評估設計
- sklearn Pipeline 與資料前處理模板 — Pipeline 的完整實作
來源¶
- sklearn model_selection 官方文件
- Cross-validation: evaluating estimator performance(sklearn User Guide)
- Nested versus non-nested cross-validation(sklearn examples)
- Géron, A.《Hands-On Machine Learning with Scikit-Learn, Keras, and TensorFlow》, 3rd ed. Ch2–3