Model Evaluation
Selecting the absolute best metric for the business problem and rigorously validating model robustness — especially for imbalanced financial datasets where accuracy is meaningless.
Key Takeaways
- Never use Accuracy on imbalanced data — a model predicting "no fraud" always achieves 99.9% accuracy in fraud datasets.
- ROC-AUC is optimistic on imbalanced data; use PR-AUC (Precision-Recall) instead.
- SMOTE generates synthetic minority samples — apply ONLY to training data, never to the validation/test set.
- Data leakage: using a feature created after the label event is the most common source in credit models.
- K-Fold cross-validation is invalid for time series — use forward-chaining (TimeSeriesSplit).
- Moving the decision threshold from 0.5 is a business decision, not a modeling one — driven by cost(FP) vs cost(FN).
- Calibration and AUC are orthogonal — always calibrate probability outputs before using them for decisions; verify with Brier score and a reliability diagram.
1. Classification Metrics
The Confusion Matrix
| Predicted Positive | Predicted Negative | |
|---|---|---|
| Actual Positive | TP (True Positive) | FN (False Negative — Miss) |
| Actual Negative | FP (False Positive — False Alarm) | TN (True Negative) |
| Metric | Formula | Answers |
|---|---|---|
| Accuracy | $(TP + TN) / (TP + TN + FP + FN)$ | Overall correctness — misleading when classes are imbalanced |
| Precision | $TP / (TP + FP)$ | Of all positive predictions, how many were correct? |
| Recall (Sensitivity) | $TP / (TP + FN)$ | Of all actual positives, how many did we catch? |
| F1 Score | $2 \times \frac{Precision \times Recall}{Precision + Recall}$ | Harmonic mean — balances precision and recall |
| Specificity | $TN / (TN + FP)$ | Of all actual negatives, how many did we correctly identify? |
$$F_1 = 2 \times \frac{\text{Precision} \times \text{Recall}}{\text{Precision} + \text{Recall}}$$
Precision-Recall Tradeoff
Increasing recall (catching more true positives) typically lowers precision (more false positives creep in). The right balance is a business decision:
- High Recall priority: fraud detection, medical diagnosis — missing a real case is very costly (high cost of FN).
- High Precision priority: content moderation, loan approval — a false alarm is very costly (high cost of FP).
from sklearn.metrics import classification_report, confusion_matrix, roc_auc_score
from sklearn.metrics import precision_recall_curve, average_precision_score
y_pred = model.predict(X_test)
y_proba = model.predict_proba(X_test)[:, 1]
print(classification_report(y_test, y_pred))
print("ROC-AUC: ", roc_auc_score(y_test, y_proba))
print("PR-AUC: ", average_precision_score(y_test, y_proba))
2. ROC-AUC vs. PR-AUC for Imbalanced Data
| Metric | Axes | Good for | Limitation |
|---|---|---|---|
| ROC-AUC | TPR vs. FPR | Balanced classes; comparing model discrimination | FPR can be small even with many FPs when negatives dominate — looks deceptively good on imbalanced data |
| PR-AUC | Precision vs. Recall | Imbalanced data; when minority class performance matters | Harder to interpret; no single threshold comparison |
Why ROC-AUC is optimistic on imbalanced data: FPR = $FP / (FP + TN)$. When the negative class is enormous (e.g., 99.9% of data), even thousands of false positives produce a tiny FPR — making the ROC curve appear excellent despite poor real-world precision. PR-AUC uses Precision (which is sensitive to FP growth) and is therefore more informative.
import matplotlib.pyplot as plt
from sklearn.metrics import RocCurveDisplay, PrecisionRecallDisplay
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
RocCurveDisplay.from_predictions(y_test, y_proba, ax=axes[0])
axes[0].set_title("ROC Curve")
PrecisionRecallDisplay.from_predictions(y_test, y_proba, ax=axes[1])
axes[1].set_title("Precision-Recall Curve")
plt.tight_layout()
plt.show()
3. Handling Imbalanced Data
| Technique | Level | How It Works | Key Caveat |
|---|---|---|---|
| SMOTE | Data-level | Generates synthetic minority samples by interpolating between neighbors | Apply to training data ONLY — never before train/test split |
| Random Oversampling | Data-level | Duplicates minority class samples | Risk of overfitting to duplicated samples |
| Random Undersampling | Data-level | Discards majority class samples | Loses information; only suitable when data is abundant |
| Class Weights | Algorithm-level | Penalizes misclassification of minority class more heavily in loss function | No data augmentation; cleaner than resampling in most cases |
from imblearn.over_sampling import SMOTE
from sklearn.model_selection import train_test_split
from xgboost import XGBClassifier
# CORRECT order: split first, then apply SMOTE to train only
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, stratify=y)
smote = SMOTE(random_state=42)
X_train_res, y_train_res = smote.fit_resample(X_train, y_train) # train only
# Class weights alternative (often cleaner)
model = XGBClassifier(scale_pos_weight=99, eval_metric='aucpr') # 99:1 imbalance ratio
model.fit(X_train, y_train, eval_set=[(X_test, y_test)], verbose=False)
4. Validation Strategies & Data Leakage
Cross-Validation Methods
| Method | Use When | Warning |
|---|---|---|
| K-Fold CV | IID data (non-temporal) | Invalid for time series — leaks future into past |
| Stratified K-Fold | Imbalanced classes + IID data | Same temporal limitation as K-Fold |
| TimeSeriesSplit | Time-ordered data | Train set always precedes validation set — correct approach |
| Out-of-Time Validation | Production credit models | Hold out most recent N months as test set — mimics deployment |
from sklearn.model_selection import TimeSeriesSplit, cross_val_score
# TimeSeriesSplit: always trains on past, validates on future
tscv = TimeSeriesSplit(n_splits=5)
scores = cross_val_score(model, X, y, cv=tscv, scoring='average_precision')
print(f"PR-AUC (time-series CV): {scores.mean():.4f} ± {scores.std():.4f}")
Common Data Leakage Sources
| Leakage Type | Example | Fix |
|---|---|---|
| Future feature | "Number of missed payments in the 30 days after application" used to predict default | Strict feature timestamp cutoff — only use features known at prediction time |
| Scaler fit on full data | StandardScaler fit on train+test before splitting |
Fit scaler on train only; transform test with train statistics |
| Imputation leakage | Filling missing values with the mean of the full dataset (including test) | Compute imputation statistics on training set only |
| Target encoding leakage | Computing target mean encoding on full dataset before CV | Encode within each CV fold; use leave-one-out encoding |
Decision Threshold Tuning
Default threshold is 0.5 — but this is rarely optimal. The right threshold is determined by the business cost of FP vs. FN:
import numpy as np
from sklearn.metrics import precision_recall_curve
precision, recall, thresholds = precision_recall_curve(y_test, y_proba)
# Find threshold that maximizes F1
f1_scores = 2 * precision * recall / (precision + recall + 1e-8)
best_idx = np.argmax(f1_scores)
best_threshold = thresholds[best_idx]
print(f"Best threshold: {best_threshold:.3f}")
print(f"Precision: {precision[best_idx]:.3f}, Recall: {recall[best_idx]:.3f}")
# Apply custom threshold
y_pred_custom = (y_proba >= best_threshold).astype(int)
5. Multi-Class Metrics
Binary classification metrics extend naturally to multiple classes, but the averaging strategy matters: choose based on whether class imbalance and per-class performance both matter to your use case.
Averaging Strategies
| Strategy | How It Works | When to Use |
|---|---|---|
| Macro | Compute metric per class, then average — each class has equal weight regardless of size | Class balance matters; small classes are important (rare disease diagnosis) |
| Micro | Aggregate TP, FP, FN across all classes, then compute — each sample has equal weight | Imbalanced classes; overall accuracy across all instances matters |
| Weighted | Compute per-class metric, then weight by class frequency | Imbalanced classes where larger classes should count more |
from sklearn.metrics import classification_report, confusion_matrix
import seaborn as sns, matplotlib.pyplot as plt
y_true = [0, 1, 2, 0, 1, 2, 2, 0, 1]
y_pred = [0, 2, 2, 0, 1, 1, 2, 0, 1]
# Full report with all averaging strategies
print(classification_report(y_true, y_pred, target_names=['class_0', 'class_1', 'class_2']))
# Shows per-class precision/recall/F1 + macro/micro/weighted averages
# Confusion matrix
cm = confusion_matrix(y_true, y_pred)
sns.heatmap(cm, annot=True, fmt='d', xticklabels=['c0','c1','c2'], yticklabels=['c0','c1','c2'])
plt.ylabel('True'); plt.xlabel('Predicted')
Multi-Class ROC and OvR Strategy
Extend ROC-AUC to multiple classes using One-vs-Rest (OvR): train a binary classifier for
each class vs. all others, compute AUC for each, then average (macro or weighted).
sklearn.metrics.roc_auc_score(y_true, y_score, multi_class='ovr').
Per-Class Inspection
Always inspect per-class metrics, not just the aggregate. A weighted F1 of 0.92 can hide a class with F1 = 0.20 if that class is rare. Common pattern: report macro F1 for business stakeholders ("we perform equally well across all categories") and weighted F1 for technical comparison against baselines.
6. Model Calibration
A calibrated model produces probability outputs that match empirical frequencies. If a model says 70% for 100 instances, roughly 70 of them should be positive. Calibration is critical when predicted probabilities drive decisions (threshold selection, expected value calculations, risk scoring).
Brier Score
Mean squared error of probability predictions:
$$\text{BS} = \frac{1}{N}\sum_{i=1}^{N}(f_i - o_i)^2$$
where $f_i$ is the predicted probability and $o_i \in \{0,1\}$ is the outcome. Range: 0 (perfect) to 1 (worst). A naive model that always predicts the base rate $\bar{p}$ gives $\text{BS} = \bar{p}(1-\bar{p})$. Lower is better.
Expected Calibration Error (ECE)
Groups predictions into $M$ bins by predicted probability; computes weighted average gap between mean predicted probability and actual positive rate:
$$\text{ECE} = \sum_{m=1}^{M} \frac{|B_m|}{N} \left| \text{acc}(B_m) - \text{conf}(B_m) \right|$$
A reliability diagram plots confidence on the x-axis vs. accuracy on the y-axis. A perfectly calibrated model lies on the diagonal.
from sklearn.calibration import calibration_curve, CalibratedClassifierCV
from sklearn.metrics import brier_score_loss
import matplotlib.pyplot as plt
# Brier score
bs = brier_score_loss(y_true, y_prob)
print(f"Brier Score: {bs:.4f}")
# Reliability diagram
fraction_of_positives, mean_predicted_value = calibration_curve(y_true, y_prob, n_bins=10)
plt.plot(mean_predicted_value, fraction_of_positives, 's-', label='Model')
plt.plot([0, 1], [0, 1], '--', label='Perfect calibration')
plt.xlabel('Mean predicted probability'); plt.ylabel('Fraction of positives')
# Platt scaling (post-hoc calibration via logistic regression)
calibrated = CalibratedClassifierCV(base_model, method='sigmoid', cv=5)
calibrated.fit(X_train, y_train)
y_prob_cal = calibrated.predict_proba(X_test)[:, 1]
print(f"Calibrated Brier: {brier_score_loss(y_test, y_prob_cal):.4f}")
Calibration Methods
| Method | How | When |
|---|---|---|
| Platt Scaling | Fit logistic regression on model's raw scores | Small calibration set; sigmoid-shaped miscalibration |
| Isotonic Regression | Fit non-parametric monotonic function to scores | Larger calibration set; arbitrary miscalibration shape |
| Temperature Scaling | Divide logits by scalar $T$ (common in neural networks) | Neural networks; fast, single parameter |
Important: Calibration and discrimination (AUC-ROC) are orthogonal. A model can be well-discriminating but poorly calibrated (most tree models and neural networks without calibration). Always calibrate before using probabilities for decisions.
7. Quantile Regression and Prediction Intervals
Standard regression predicts a conditional mean $E[y|X]$. Quantile regression predicts a conditional quantile $Q_\tau(y|X)$ — the value below which a fraction $\tau$ of outcomes fall. This lets you model uncertainty and produce prediction intervals.
Quantile Loss (Pinball Loss)
$$L_\tau(y, \hat{y}) = \begin{cases} \tau(y - \hat{y}) & \text{if } y \geq \hat{y} \\ (1 - \tau)(\hat{y} - y) & \text{if } y < \hat{y} \end{cases}$$
Minimizing this over a training set gives the $\tau$-th conditional quantile. For $\tau = 0.5$, this is equivalent to MAE (median regression).
from sklearn.ensemble import GradientBoostingRegressor
import numpy as np
X_train, y_train = ...
# Train separate models for lower (5th), median (50th), upper (95th) quantiles
models = {}
for q in [0.05, 0.50, 0.95]:
gbr = GradientBoostingRegressor(loss='quantile', alpha=q,
n_estimators=200, max_depth=4)
gbr.fit(X_train, y_train)
models[q] = gbr
# 90% prediction interval
lower = models[0.05].predict(X_test)
median = models[0.50].predict(X_test)
upper = models[0.95].predict(X_test)
# Coverage: what fraction of true values fall within the interval?
coverage = np.mean((y_test >= lower) & (y_test <= upper))
print(f"90% PI coverage: {coverage:.3f}") # should be ~0.90 if well-calibrated
When to Use Quantile Regression
- When the variance of $y$ is not constant (heteroskedasticity) — e.g., credit loss given default varies hugely by borrower
- When decisions are asymmetric — it may be worse to underestimate a risk than to overestimate it; use a high quantile for conservative planning
- When stakeholders need uncertainty ranges, not just point predictions
- For demand forecasting: use the 90th percentile to set inventory levels
Practice Questions
Q1: Your classification model achieves 99.9% accuracy on a credit fraud dataset. Is this a good model?
Almost certainly not. If only 0.1% of transactions are fraud, a model that always predicts "no fraud" achieves 99.9% accuracy while catching zero fraud cases. This is the accuracy paradox on imbalanced data.
For this model: TP = 0, TN = 99,900, FP = 0, FN = 100. Recall = 0/100 = 0%. The model is completely useless.
Correct metrics to use: Precision, Recall, F1 Score, PR-AUC. Ask the business which is more costly — missing a fraud (FN) or flagging a legitimate transaction (FP).
Q2: Explain the Precision-Recall tradeoff in the context of a credit default model.
Precision = $TP / (TP + FP)$ — of all predicted defaults, how many are real. Recall = $TP / (TP + FN)$ — of all real defaults, how many did we catch.
As you lower the decision threshold (predict "default" more aggressively):
- Recall increases — you catch more real defaults (fewer missed bad loans).
- Precision decreases — more good customers are incorrectly declined (false alarms).
Business framing: Is it worse to miss a bad loan (FN: financial loss from unpaid debt) or to reject a good customer (FP: lost interest revenue, damaged customer relationship)? This comparison drives the threshold decision. At Capital One, a collections analyst might set the threshold to maximize recall within a precision floor — e.g., "catch 80% of defaults while keeping false positive rate below 5%."
Q3: Why is ROC-AUC potentially misleading for highly imbalanced datasets?
ROC-AUC plots TPR (Recall) on the y-axis vs. FPR on the x-axis. FPR = $FP / (FP + TN)$.
When the negative class is enormous (e.g., 99.9% of data), the denominator $FP + TN$ is huge. Even if the model generates thousands of false positives, FPR remains small — making the ROC curve look excellent despite terrible real-world performance.
Example: 100,000 transactions, 100 fraud cases. Model produces 500 FPs and catches 90 TPs. FPR = 500 / (500 + 99,900) ≈ 0.5% — looks fine on ROC. But Precision = 90 / (90 + 500) ≈ 15.3% — 85% of fraud alerts are false alarms.
PR-AUC uses Precision and Recall — both are sensitive to the minority class — and reveals this problem directly.
Q4: Describe a realistic data leakage scenario in a credit default model.
Three classic leakage scenarios in credit:
- Future feature: Using "number of missed payments in the 30 days after application" to predict default at application time. This feature is created after the label event — it encodes future information. In production, this feature would not exist at scoring time, and the model would fail catastrophically.
- Scaler/imputer fit on full dataset: Running
StandardScaler().fit_transform(X)before splitting into train/test. The test-set mean and standard deviation contaminate the scaler — the model has indirectly "seen" the test distribution. Alwaysfitpreprocessing only on training data. - Target encoding leakage: Computing the target mean for each category (e.g., merchant category default rate) on the full dataset including validation rows. The validation set's target values influence the encoding seen during training. Use within-fold encoding or a separate held-out set for encoding computation.
Your multi-class model achieves weighted F1 = 0.91 but macro F1 = 0.72. What does this tell you and what do you do?
This tells you the model performs well on frequent classes (driving up weighted F1) but poorly on rare classes (dragging down macro F1). The gap of 0.19 is significant. Steps: (1) Inspect the classification report to identify which classes have low F1; (2) check if those classes are underrepresented in training data — if so, apply SMOTE, class weights, or collect more data; (3) check if the model sees enough examples at test time to even make meaningful predictions; (4) consider whether the rare classes are genuinely harder (overlapping feature distributions) vs. just data-starved. Report macro F1 to stakeholders when all classes matter equally, weighted F1 when you want to weight by class frequency.
A risk model outputs probabilities but the model is known to be poorly calibrated. What are the consequences and how do you fix it?
Consequences: (1) expected value calculations are wrong — if the model says 30% but true probability is 15%, you'll systematically accept too much risk; (2) threshold optimization is off — the optimal decision threshold assumes probabilities are meaningful; (3) downstream models that use this as a feature will inherit the miscalibration. Fix: apply post-hoc calibration. First, plot a reliability diagram to diagnose the shape of miscalibration. If it's roughly sigmoid-shaped, Platt scaling (logistic regression on the raw scores, fit on a held-out calibration set) is efficient. If miscalibration is complex/non-monotonic, use isotonic regression (requires more calibration data, ~1K+ samples). After calibration, verify with Brier score and ECE on a separate test set.
Action Items
- Calculate Precision, Recall, and F1 by hand given a raw confusion matrix (e.g., TP=85, FP=30, TN=950, FN=15).
- Diagram a TimeSeriesSplit cross-validation strategy with 5 folds and explain why K-Fold is invalid for time series.
- Formulate a complete plan to handle a dataset where the positive class is 0.1% of the total — cover data preparation, model training, metric selection, and threshold tuning.
- Write code to plot both ROC and PR curves for the same model and explain which metric to report to a stakeholder for a fraud use case.