ML Evaluation¶
Accuracy is a terrible metric for most real problems. This sheet maps metrics to the decisions they inform — so you can answer "is this model good enough?" with evidence instead of vibes.
Table of Contents¶
- Installation / Setup
- Core Syntax
- Common Operations
- Practical Examples
- Common Patterns
- Best Practices
- Common Mistakes
- Troubleshooting
- Related Cheat Sheets
- Official Documentation
Installation / Setup¶
All metrics live in sklearn.metrics. In cross_val_score/GridSearchCV you pass a scoring= string; for details use sklearn.metrics directly.
Core Syntax¶
Confusion matrix — start here¶
from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay
cm = confusion_matrix(y_test, y_pred) # rows = actual, cols = predicted
ConfusionMatrixDisplay(cm).plot()
Everything else in classification derives from the four cells:
| Term | Formula | Meaning |
|---|---|---|
| Accuracy | (TP+TN) / all | Fraction correct — misleading when classes imbalanced |
| Precision | TP / (TP+FP) | Of those predicted positive, how many were right |
| Recall / Sensitivity | TP / (TP+FN) | Of actual positives, how many did we catch |
| Specificity | TN / (TN+FP) | Of actual negatives, how many did we correctly reject |
| F1 | 2·P·R / (P+R) | Harmonic mean — balance when both matter |
| Fβ | (1+β²)·P·R / (β²·P+R) | Weighted: β>1 favors recall, β<1 favors precision |
Which to watch — decision table¶
| Problem | Metric |
|---|---|
| Balanced classes, cheap errors | accuracy |
| Rare disease/fraud: must catch positives | recall (raise β in Fβ) |
| Spam filter: false positives annoy users | precision |
| Both matter equally | F1 |
| Ranking / any threshold | ROC-AUC or PR-AUC |
| Probability quality (not just ranking) | log loss, Brier, calibration curve |
| Regression | MAE / RMSE / R² / MAPE |
Common Operations¶
Classification reports¶
from sklearn.metrics import (accuracy_score, precision_score, recall_score,
f1_score, classification_report)
print(classification_report(y_test, y_pred)) # per-class + macro/weighted
# precision recall f1-score support
# 0 0.91 0.93 0.92 430
# 1 0.78 0.74 0.76 120
# accuracy 0.89 550
Imbalanced data: watch the minority-class row and macro/weighted averages — overall accuracy hides the failure.
Probabilities, thresholds, curves¶
from sklearn.metrics import roc_auc_score, roc_curve, average_precision_score
probs = model.predict_proba(X_test)[:, 1] # positive-class probability
print("ROC-AUC:", roc_auc_score(y_test, probs)) # ranking quality
print("PR-AUC :", average_precision_score(y_test, probs)) # better for imbalance
fpr, tpr, thresholds = roc_curve(y_test, probs)
| Curve | Axes | Read it as |
|---|---|---|
| ROC | TPR vs FPR | How well positives separate from negatives at every threshold |
| PR | Precision vs Recall | Value of the model on the positive class (imbalance) |
| Calibration | predicted prob vs actual freq | Whether 0.7 means 70% |
Thresholding — the button you actually press¶
# default threshold is 0.5, but the operating point is a business decision:
from sklearn.metrics import precision_recall_curve
precision, recall, thresholds = precision_recall_curve(y_test, probs)
t80 = thresholds[next(i for i, r in enumerate(recall) if r <= 0.8)]
y_t80 = (probs >= t80).astype(int) # deploy this rule: catch 80% of positives
Always tune the threshold on a validation set, then confirm once on test.
Regression metrics¶
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score
import numpy as np
mae = mean_absolute_error(y_test, y_pred) # same units, robust
rmse = mean_squared_error(y_test, y_pred) ** 0.5 # penalizes big misses
r2 = r2_score(y_test, y_pred) # % variance explained
mape = np.mean(np.abs((y_test - y_pred) / y_test)) # interpretable %, fails at y≈0
| Metric | Units | Sensitive to | Use when |
|---|---|---|---|
| MAE | target units | outliers equally | You pay linearly per error |
| RMSE | target units | large errors (squared) | Big misses hurt more |
| MAPE | % | near-zero targets | Business speaks in % (watch zeros) |
| R² | 0–1 (can go negative) | fit relative to mean | Communicating overall fit |
Practical Examples¶
Example 1 — Churn model: choose the operating point¶
import pandas as pd
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import precision_recall_curve, classification_report
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(X, y, stratify=y, random_state=0)
model = RandomForestClassifier(random_state=0).fit(X_train, y_train)
probs = model.predict_proba(X_test)[:, 1]
# retention budget: only contact people we are fairly sure will churn
precision, recall, thrs = precision_recall_curve(y_test, probs)
idx = next(i for i, p in enumerate(precision) if p <= 0.7) # 70% precision point
rule = thrs[idx - 1]
print(f"threshold {rule:.2f} -> precision {precision[idx]:.2f}, recall {recall[idx]:.2f}")
flagged = probs >= rule
print(pd.Series(flagged).value_counts())
print(classification_report(y_test, flagged))
Example 2 — Medical-style recall target¶
from sklearn.metrics import recall_score
# must not miss positives; precision matters but recall is the contract
for t in [0.3, 0.4, 0.5, 0.6]:
preds = (probs >= t).astype(int)
print(f"t={t:.1f} recall={recall_score(y_test, preds):.3f} "
f"precision={precision_score(y_test, preds):.3f}")
# pick smallest t meeting recall ≥ 0.95, then confirm on the test set
Example 3 — Calibration check (are probabilities real?)¶
from sklearn.calibration import calibration_curve
fraction_pos, mean_pred = calibration_curve(y_test, probs, n_bins=10)
for fp, mp in zip(fraction_pos, mean_pred):
print(f"predicted ~{mp:.2f} -> actually {fp:.2f}")
# diagonal line = perfectly calibrated; systematic bow → CalibratedClassifierCV
from sklearn.calibration import CalibratedClassifierCV
calibrated = CalibratedClassifierCV(model, cv=5).fit(X_train, y_train)
Common Patterns¶
Pattern: pick the metric from the cost asymmetry¶
Cost matrix: catching a churner saves $X; a wasted call costs $Y.
Precision/recall aren't aesthetic choices — they encode those costs.
False-negative-heavy cost → maximize recall at a precision floor.
False-positive-heavy cost → maximize precision at a recall floor.
Pattern: compare candidate models fairly¶
from sklearn.model_selection import cross_val_score
results = {}
for name, model in candidates.items():
results[name] = cross_val_score(model, X, y, cv=5,
scoring="roc_auc").mean()
print(pd.Series(results).sort_values(ascending=False))
Pattern: always pair the headline metric with context¶
summary = {
"model": "rf_v3", "auc": 0.92, "threshold": 0.45,
"precision@thr": 0.71, "recall@thr": 0.80,
"flagged_pct": 0.18, "validation": "CV 5-fold, n=5_000",
}
A metric without the threshold, dataset size, and split description is unverifiable.
Best Practices¶
- Report per-class numbers when classes are imbalanced — never accuracy alone.
- Pick the metric from the business cost, not from what's easy to print.
- Use
predict_proba+ threshold decisions rather than barepredict(). - If you threshold-tune, do it on validation; report the final threshold's metrics on test.
- For ranking (search, scoring leads), use ROC-AUC/PR-AUC; for decisions, use precision/recall at the operating point.
- Check calibration when probabilities feed downstream decisions or are shown to users.
- Regression: report MAE and RMSE (robustness + big-error view); include units.
- Compare against a baseline metric (majority class accuracy, mean-MAE) every time.
- Store the exact evaluation config (split seed, scoring, threshold) with results.
Common Mistakes¶
| Mistake | Fix |
|---|---|
| Accuracy on 95/5 data (95% "great") | Report precision/recall/F1 or AUC on the minority class |
| Threshold tuned on the test set | Thresholds belong on validation; test confirms once |
| Choosing the threshold that maximizes accuracy | Choose by cost: precision vs recall trade-off |
| Treating AUC 0.9 as "the model is great" for decisions | AUC ranks; check precision@the threshold you'll deploy |
| Averaging raw F1 per class without weights | Use average="macro"/"weighted" deliberately |
| MAPE on data with zeros | Use MAE/RMSE or filter near-zero targets |
| Confusing precision & recall in reports | TP/(TP+FP) vs TP/(TP+FN) — read the confusion matrix |
Reporting CV best_score_ as production expectation |
Report on held-out test data |
| Comparing models trained on different feature sets | Same pipeline, same CV, same scoring for all |
Troubleshooting¶
| Symptom | Cause / Fix |
|---|---|
| F1 lower than you expect despite high accuracy | Imbalance — look at minority-class F1 and PR-AUC |
| Precision and recall both drop when threshold rises | Contradiction — check you're comparing the right direction / same fold |
| AUC ≈ 0.5 despite "good" accuracy | Probably imbalance + class prior; use PR-AUC / log loss too |
| Log loss explodes | Model very confident and wrong — check for label leaks or calibration |
| RMSE ≫ MAE | A few huge errors dominate — investigate outliers |
| R² negative | Model worse than predicting the mean — data/target bug or wrong model class |
| Test metric far below CV | Leakage or optimistic tuning — revisit the pipeline, consider nested CV |
Related Cheat Sheets¶
- scikit-learn — metrics in
scoring=and pipelines - ml-model-selection — CV and tuning around these metrics
- ml-preprocessing — leakage that inflates metrics
- statistics — sampling, bias, error bars
- hypothesis-testing — is one model significantly better?
- statistical-distributions — calibration and probability meaning
- pandas — slicing results for analysis
Official Documentation¶
- sklearn metrics — https://scikit-learn.org/stable/modules/model_evaluation.html
- sklearn metrics API — https://scikit-learn.org/stable/modules/classes.html#module-sklearn.metrics
- Probability calibration — https://scikit-learn.org/stable/modules/calibration.html
Last Updated¶
2026-09-06 — Initial version.