Model Evaluation, ROC Curves & Calibration
Derive the harmonic mean of F1 and F-beta scores, the probabilistic interpretation of ROC-AUC, Expected Calibration Error, temperature scaling, and zero-dependency implementations in Python and Rust.
A machine learning model is only as effective as the metric used to measure its generalization performance. Relying uncritically on accuracy can lead to disastrous deployments in imbalanced regimes such as fraud detection, cancer diagnosis, or rare event prediction.
Rigorous evaluation demands understanding threshold-dependent metric tradeoffs, the probabilistic rank-order interpretation of ROC-AUC, and post-hoc probability calibration.
The Area Under the ROC Curve is mathematically identical to the Wilcoxon-Mann-Whitney rank statistic.
1. Confusion Matrix & The Harmonic Mean of F1#
Given binary ground truth and binary prediction , all predictions partition into a contingency table:
Why the Harmonic Mean for F1?#
Let denote Precision and denote Recall.
Why does employ the Harmonic Mean rather than the standard Arithmetic Mean ?
Step 1: Definition of the Harmonic Mean
The harmonic mean of two non-zero positive numbers and is the reciprocal of the arithmetic mean of their reciprocals:
Step 2: Solve for $H(a, b)$
Combine fractions over a common denominator:
Inverting both sides:
Step 3: Failure of Arithmetic Mean Under Extreme Imbalance
Suppose a pathological classifier predicts positive for every single instance. On a dataset with 99% negative cases and 1% positive cases:
- Recall (catches all positives)
- Precision (99 false alarms for every 1 true positive)
Evaluating with the Arithmetic Mean:
A score of falsely conveys acceptable performance. Now evaluate with the Harmonic Mean:
The harmonic mean heavily penalizes disparity between rates, approaching zero if either component collapses.
The Generalized Score Derivation#
In domains where recall is prioritized over precision (e.g. disease screening), the parameter weights Recall times as heavily as Precision:
Inverting yields the standard formula:
2. ROC-AUC as a Probabilistic Rank Statistic#
The Receiver Operating Characteristic (ROC) curve plots the True Positive Rate () against the False Positive Rate () across all classification thresholds .
Mathematical Equivalence to the Wilcoxon-Mann-Whitney Statistic#
The Area Under the Curve (AUC) has an exact probabilistic interpretation:
Where is a randomly chosen positive sample () and is a randomly chosen negative sample ().
For positive samples and negative samples with continuous model scores :
Where is the binary indicator function. An AUC of denotes random guessing, while represents perfect separation.
3. Probability Calibration: Expected Calibration Error#
A model outputting should be correct exactly 90% of the time. Modern deep neural networks with high parameter counts often produce overconfident, uncalibrated probabilities.
To quantify calibration across confidence bins :
Where is empirical accuracy and is average confidence within bin .
Post-Hoc Temperature Scaling#
To calibrate modern deep networks without altering top-1 accuracy rankings, Temperature Scaling divides logit scores by a learned scalar temperature :
- If : Logits are softened, reducing overconfidence.
- If : Logits are sharpened.
- Because temperature scaling is a strictly monotonic transformation, it preserves all argmax class predictions while aligning probabilities with true empirical frequency.
4. From-Scratch Implementation: Zero Dependencies#
The following implementations compute a complete classification evaluation suite (Confusion Matrix, Precision, Recall, Specificity, F1, and ROC-AUC via the trapezoidal rule) from scratch in Python and Rust.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465from typing import List, Dict, Tuple class ModelEvaluator: @staticmethod def confusion_matrix(y_true: List[int], y_pred: List[int]) -> Dict[str, int]: tp = sum(1 for yt, yp in zip(y_true, y_pred) if yt == 1 and yp == 1) fp = sum(1 for yt, yp in zip(y_true, y_pred) if yt == 0 and yp == 1) tn = sum(1 for yt, yp in zip(y_true, y_pred) if yt == 0 and yp == 0) fn = sum(1 for yt, yp in zip(y_true, y_pred) if yt == 1 and yp == 0) return {"TP": tp, "FP": fp, "TN": tn, "FN": fn} @classmethod def classification_report(cls, y_true: List[int], y_pred: List[int]) -> Dict[str, float]: cm = cls.confusion_matrix(y_true, y_pred) tp, fp, tn, fn = cm["TP"], cm["FP"], cm["TN"], cm["FN"] precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0 recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0 specificity = tn / (tn + fp) if (tn + fp) > 0 else 0.0 accuracy = (tp + tn) / len(y_true) if y_true else 0.0 f1 = (2 * precision * recall) / (precision + recall) if (precision + recall) > 0 else 0.0 return { "Accuracy": accuracy, "Precision": precision, "Recall": recall, "Specificity": specificity, "F1_Score": f1, } @staticmethod def roc_auc_score(y_true: List[int], y_scores: List[float]) -> float: # Separate positive and negative sample scores pos_scores = [score for yt, score in zip(y_true, y_scores) if yt == 1] neg_scores = [score for yt, score in zip(y_true, y_scores) if yt == 0] n_pos = len(pos_scores) n_neg = len(neg_scores) if n_pos == 0 or n_neg == 0: return 0.5 # Compute Wilcoxon-Mann-Whitney rank statistic concordant_pairs = 0.0 for pos in pos_scores: for neg in neg_scores: if pos > neg: concordant_pairs += 1.0 elif pos == neg: concordant_pairs += 0.5 return concordant_pairs / (n_pos * n_neg) if __name__ == "__main__": y_true = [1, 0, 1, 1, 0, 0, 1, 0, 1, 0] y_scores = [0.92, 0.15, 0.78, 0.85, 0.42, 0.23, 0.65, 0.55, 0.89, 0.11] y_pred = [1 if s >= 0.5 else 0 for s in y_scores] evaluator = ModelEvaluator() report = evaluator.classification_report(y_true, y_pred) auc = evaluator.roc_auc_score(y_true, y_scores) print("Classification Report:") for metric, val in report.items(): print(f" {metric}: {val:.4f}") print(f" ROC-AUC: {auc:.4f}")
5. Active Knowledge Checks#
Why is the Area Under the Receiver Operating Characteristic (ROC-AUC) curve equal to the Wilcoxon-Mann-Whitney rank statistic?
How does post-hoc Temperature Scaling calibrate a deep neural network's overconfident softmax probabilities without degrading test accuracy?