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.

01. Supervised Machine Learning Engineering8 min read

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.

ROC-AUC MeaningProbability Theory
P(f(x+) > f(x-))

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 y∈{0,1}y \in \{0, 1\} and binary prediction y^∈{0,1}\hat{y} \in \{0, 1\}, all predictions partition into a 2×22 \times 2 contingency table:

Architecture Diagram
Rendering diagram...

Why the Harmonic Mean for F1?#

Let P=TPTP+FPP = \frac{TP}{TP + FP} denote Precision and R=TPTP+FNR = \frac{TP}{TP + FN} denote Recall.

Why does F1F_1 employ the Harmonic Mean rather than the standard Arithmetic Mean P+R2\frac{P + R}{2}?

1

Step 1: Definition of the Harmonic Mean

The harmonic mean of two non-zero positive numbers aa and bb is the reciprocal of the arithmetic mean of their reciprocals:

1H(a,b)=12(1a+1b)\frac{1}{H(a, b)} = \frac{1}{2} \left( \frac{1}{a} + \frac{1}{b} \right)
2

Step 2: Solve for $H(a, b)$

Combine fractions over a common denominator:

1H(a,b)=12(a+bab)=a+b2ab\frac{1}{H(a, b)} = \frac{1}{2} \left( \frac{a + b}{ab} \right) = \frac{a + b}{2ab}

Inverting both sides:

H(a,b)=2aba+bH(a, b) = \frac{2ab}{a + b}
3

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 R=1.0R = 1.0 (catches all positives)
  • Precision P=0.01P = 0.01 (99 false alarms for every 1 true positive)

Evaluating with the Arithmetic Mean:

ArithmeticMean=1.0+0.012=0.505\text{ArithmeticMean} = \frac{1.0 + 0.01}{2} = 0.505

A score of 0.5050.505 falsely conveys acceptable performance. Now evaluate with the Harmonic Mean:

F1=2⋅1.0⋅0.011.0+0.01=0.021.01≈0.0198F_1 = \frac{2 \cdot 1.0 \cdot 0.01}{1.0 + 0.01} = \frac{0.02}{1.01} \approx 0.0198

The harmonic mean heavily penalizes disparity between rates, approaching zero if either component collapses.

The Generalized FβF_\beta Score Derivation#

In domains where recall is prioritized over precision (e.g. disease screening), the parameter β\beta weights Recall β\beta times as heavily as Precision:

1Fβ=11+β2(1P+β2R)=R+β2P(1+β2)PR\frac{1}{F_\beta} = \frac{1}{1 + \beta^2} \left( \frac{1}{P} + \frac{\beta^2}{R} \right) = \frac{R + \beta^2 P}{(1 + \beta^2) P R}

Inverting yields the standard FβF_\beta formula:

Fβ=(1+β2)P⋅Rβ2P+RF_\beta = (1 + \beta^2) \frac{P \cdot R}{\beta^2 P + R}

2. ROC-AUC as a Probabilistic Rank Statistic#

The Receiver Operating Characteristic (ROC) curve plots the True Positive Rate (TPR=TPTP+FNTPR = \frac{TP}{TP + FN}) against the False Positive Rate (FPR=FPTN+FPFPR = \frac{FP}{TN + FP}) across all classification thresholds τ∈[0,1]\tau \in [0, 1].

Architecture Diagram
Rendering diagram...

Mathematical Equivalence to the Wilcoxon-Mann-Whitney Statistic#

The Area Under the Curve (AUC) has an exact probabilistic interpretation:

AUC=P(f(x+)>f(x−))\text{AUC} = P\Big(f(x^+) > f(x^-)\Big)

Where x+x^+ is a randomly chosen positive sample (y=1y = 1) and x−x^- is a randomly chosen negative sample (y=0y = 0).

For N+N_+ positive samples and N−N_- negative samples with continuous model scores si=f(xi)s_i = f(x_i):

AUC=1N+N−∑i:yi=1∑j:yj=0I(si>sj)+12I(si=sj)\text{AUC} = \frac{1}{N_+ N_-} \sum_{i: y_i = 1} \sum_{j: y_j = 0} \mathbb{I}\Big(s_i > s_j\Big) + \frac{1}{2} \mathbb{I}\Big(s_i = s_j\Big)

Where I(⋅)\mathbb{I}(\cdot) is the binary indicator function. An AUC of 0.500.50 denotes random guessing, while 1.01.0 represents perfect separation.

3. Probability Calibration: Expected Calibration Error#

A model outputting p^=0.90\hat{p} = 0.90 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 MM confidence bins B1,…,BMB_1, \dots, B_M:

ECE=∑m=1M∣Bm∣N∣acc(Bm)−conf(Bm)∣\text{ECE} = \sum_{m=1}^M \frac{|B_m|}{N} \Big| \text{acc}(B_m) - \text{conf}(B_m) \Big|

Where acc(Bm)\text{acc}(B_m) is empirical accuracy and conf(Bm)\text{conf}(B_m) is average confidence within bin BmB_m.

Post-Hoc Temperature Scaling#

To calibrate modern deep networks without altering top-1 accuracy rankings, Temperature Scaling divides logit scores zz by a learned scalar temperature T>0T > 0:

p^i=exp⁡(zi/T)∑kexp⁡(zk/T)\hat{p}_i = \frac{\exp(z_i / T)}{\sum_k \exp(z_k / T)}
  • If T>1T > 1: Logits are softened, reducing overconfidence.
  • If T<1T < 1: 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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
from 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#

Concept check

Why is the Area Under the Receiver Operating Characteristic (ROC-AUC) curve equal to the Wilcoxon-Mann-Whitney rank statistic?

Concept check

How does post-hoc Temperature Scaling calibrate a deep neural network's overconfident softmax probabilities without degrading test accuracy?