Search Tech Journey

Find topics, journeys and posts

6-month learning plan92 / 130
back to blog
mlintermediate 55m read

S092 · Evaluation Metrics — P/R/F1/ROC/PR/AUC

Accuracy is a lie 90% of the time. Learn why precision/recall/F1, ROC-AUC, and PR-AUC exist, when each one matters, and how to pick the metric your business actually cares about — before your model ships and gets your team paged at 3am.

🤖Machine LearningM11 · Classical ML· Session 092 of 130 90 min

🎯 Pick the right evaluation metric for a real business problem, defend the choice with a confusion matrix, and read ROC/PR curves like a pro.

Why this session exists

Every ML failure story starts the same way: "The model had 99% accuracy in dev." Then it shipped, and it turned out 99% of transactions were legit — so a model that just always says "not fraud" also scores 99%. The wrong metric doesn't just mislead you; it actively hides the fact that your model is useless. This session is the metric literacy that separates people who ship ML in production from people who ship ML in slides.

You will be able to
  • Draw a confusion matrix from a set of predictions and read TP/FP/FN/TN off it without hesitation.
  • Choose precision, recall, F1, ROC-AUC, or PR-AUC given a business problem — and defend the pick.
  • Explain why ROC-AUC is a trap on heavily imbalanced data and when PR-AUC replaces it.
  • Tune a classification threshold to hit a business-defined precision or recall target.
  • Compute all of the above in scikit-learn and interpret the resulting curves.

Prerequisites

  • S088 · Supervised vs Unsupervised — you know classification vs regression.
  • S090 · Logistic Regression — you know your classifier outputs probabilities, not just labels.
  • Basic pandas + numpy from S030–S034.


(a) Intuition · 5 min

A metal detector at an airport
🌍 Real world

You're the operator. Two knobs: sensitivity (how easily it beeps) and selectivity (how specific to metal it is). Crank sensitivity high and you catch every weapon — but also every belt buckle and every foil gum wrapper. Crank selectivity high and you never annoy grandma — but you might miss a real knife.

You can't have both maxed. There's a knob. The knob is a threshold. The whole game is: what does it cost to miss a knife vs stop a grandma?

💻 Code world

Every classifier outputs a probability between 0 and 1. You pick a threshold (default 0.5) above which you call it "positive." Move the threshold up → fewer positives, higher precision, lower recall. Move it down → more positives, higher recall, lower precision.

ROC and PR curves are what happens if you sweep the threshold from 0 to 1 and plot every point. AUC is the area under that curve — a single number that says "how good is the model across all possible thresholds."

The two questions every metric answers

Precision vs recall — memorise this one sentence each
  • Precision — of everything I flagged as positive, what fraction was actually positive? (‘When I say fraud, how often am I right?’)
  • Recall — of everything that was actually positive, what fraction did I catch? (‘Of all the real fraud, how much did I flag?’)
  • F1 — the harmonic mean of the two. Punishes any model that scores well on only one. Use when you need one number and both matter roughly equally.
  • ROC-AUC — probability that a random positive is ranked above a random negative. Great on balanced data, misleading on rare events.
  • PR-AUC — the same idea but on the precision-recall curve. Use this on imbalanced data.

A quick history so you know why we have five metrics

  1. 1904
    Signal detection theory · Rayleigh
    Physicists study how humans detect faint signals against noise — the origin of TP/FP/FN/TN.
  2. 1941
    WWII radar operators · ROC born
    ‘Receiver Operating Characteristic’ literally means how well an operator distinguishes real planes from noise. Curves plotted by hand.
  3. 1955
    Cranfield IR experiments · precision & recall
    Cleverdon at Cranfield introduces precision and recall to measure information-retrieval systems (early search).
  4. 1979
    F-measure · van Rijsbergen
    Combines precision and recall into a single number. Later called F1 when β=1.
  5. 2006
    Davis & Goadrich · PR > ROC on imbalanced data
    ICML paper proves ROC hides poor performance on rare-class problems. PR-AUC becomes the norm for fraud/medical models.

(b) Visual walkthrough · 15 min

The confusion matrix — the single most important diagram in classification

Every metric on the planet is a ratio built from these four cells. Learn the picture — the formulas follow for free.

How ROC and PR curves are built

11
Score every example

Model outputs a probability p for each example. You do not yet call anything positive.

22
Sort by score, descending

Highest confidence first. This is the ‘ranking’ view of the model.

33
Sweep a threshold from 1 → 0

At each threshold, everything above is positive; below is negative. Compute a confusion matrix.

44
Plot the (x, y) pair

ROC plots FPR on x, TPR on y. PR plots recall on x, precision on y. One point per threshold.

55
AUC = area under the curve

Random model = 0.5 on ROC, base rate on PR. Perfect model = 1.0 on both.

When to use which curve

ROC-AUC

Balanced data, ranking matters

  • x = False Positive Rate (FP / N), y = True Positive Rate (TP / P)
  • Robust to class balance shifts (invariant if positives/negatives change)
  • 0.5 = random; 1.0 = perfect
  • Use for: churn (~30% positive), spam (~50%), diabetes screening (~10%)
  • Trap: on 1-in-1000 problems it inflates because FPR stays tiny even for terrible models.
PR-AUC

Imbalanced data, precision matters

  • x = Recall (TP / P), y = Precision (TP / (TP+FP))
  • Sensitive to class balance — that's the point
  • Baseline = base rate of positives (0.001 for 1-in-1000 fraud)
  • Use for: fraud, ad click, disease detection, ML anomaly, security alerts
  • Trap: harder to interpret across datasets because baseline shifts.
F1

One number, threshold already chosen

  • Harmonic mean of precision and recall
  • Requires you to commit to a threshold first
  • Fβ variant: β>1 weights recall more, β<1 weights precision
  • Use for: model comparison in a report, once the operating point is fixed
  • Trap: says nothing about ranking quality.

The threshold–metric trade-off


Common misconception
✗ What most people think

"AUC-ROC is the standard way to judge a classifier. A model with 0.95 AUC is excellent, and if I need one number to compare models, that's the one."

✓ What is actually true

ROC-AUC is computed from true positive rate and false positive rate, and FPR has the negative count in its denominator. When negatives vastly outnumber positives, thousands of false positives barely move FPR — so AUC stays beautiful while the alerts your team actually receives are overwhelmingly wrong. For rare-event problems, precision-recall AUC is the honest curve, because precision has predicted positives in its denominator and therefore feels every false alarm.

Why the myth is so sticky

The myth is sticky because ROC-AUC has a genuinely elegant property nothing else offers: it is invariant to class balance, so it measures pure ranking quality and is comparable across datasets. That invariance is exactly what makes it excellent for research comparison — and exactly what makes it misleading for an operational decision, because the thing you deliberately factored out (the base rate) is the thing that determines whether your on-call engineer drowns. The metric is not wrong; it answers a different question than the one being asked.

Prove it to yourself

Same predictions, two curves, radically different verdicts on a 1% positive rate:

from sklearn.metrics import roc_auc_score, average_precision_score
from sklearn.metrics import precision_score, recall_score

p = model.predict_proba(Xte)[:, 1]
print('ROC-AUC ', roc_auc_score(yte, p))          # looks great
print('PR-AUC  ', average_precision_score(yte, p)) # the honest one
print('baseline PR-AUC =', yte.mean())             # random guessing

yhat = p >= 0.5
print(precision_score(yte, yhat), recall_score(yte, yhat))
# PR-AUC's floor is the positive rate, not 0.5.
# 0.30 PR-AUC at a 1% base rate is a 30x lift, not a failure.
From first principles
Start with the question

Why can you not maximise precision and recall at the same time? This is stated everywhere as a "tradeoff", but it is not an empirical tendency — it is forced by the structure of a thresholded score.

  1. 1
    A classifier produces a score, and a decision requires a threshold t. Predicting positive means score ≥ t, so the set of predicted positives is completely determined by t.
    forced by · any binary decision from a continuous score is a cut point, and nothing else
  2. 2
    Lowering t can only add items to the predicted-positive set; it can never remove one. The sets are therefore nested as t decreases.
    forced by · the condition score ≥ t is monotone in t — this is a set-inclusion fact, not a statistical one
  3. 3
    Recall is caught positives ÷ all positives. Its denominator is fixed, and its numerator can only grow as the set grows. So recall is monotonically non-decreasing as you lower the threshold. No exceptions, no dataset dependence.
    forced by · the denominator is a property of the data, untouched by your threshold
  4. 4
    Precision is caught positives ÷ all predicted positives. Both parts grow, so its direction depends on the quality of each newly admitted item. Items are admitted in descending score order, and any model better than random has a positive rate that declines as you go down the ranking.
    forced by · ranking quality is precisely the statement that higher-scored items are more likely positive
  5. 5
    Therefore each item admitted is, on average, less likely to be a true positive than everything already in the set — so it dilutes the ratio. Precision trends downward as recall rises, and the two are pinned to a single curve traced by one parameter.
    forced by · adding elements of below-average quality to a set must lower its average
⇒ Therefore

Therefore the tradeoff is not a limitation of your model — it is a consequence of both metrics being functions of one threshold on one ranking. A single model does not have a precision or a recall; it has a curve, and picking a point on it is a business decision.

And note what this predicts: the only way to improve both simultaneously is to change the ranking itself — better features, better model — which lifts the entire curve. It also predicts that comparing two models by their F1 at threshold 0.5 is nearly meaningless: you are comparing two arbitrary points on two curves, and the model with the worse point may have the strictly better curve. Compare curves (PR-AUC), then choose the point.

Mental modelThe confusion matrix is the only object; everything else is a ratio of its cells

There are exactly four numbers: TP, FP, FN, TN. Every metric you will ever argue about is a ratio built from them, and each one differs only in which cell it puts in the denominator — which is the same as choosing which mistake you have decided to care about.

Precision divides by the predicted-positive column: it asks "when I raise an alarm, how often am I right?" — the cost of a false positive. Recall divides by the actual-positive row: "of the real cases, how many did I catch?" — the cost of a false negative. Accuracy divides by everything, which is why it silently ignores the class you care about when that class is rare.

  • Never report accuracy on imbalanced data. At a 1% positive rate, predicting all-negative scores 99% and is worthless. Always compare against the majority-class baseline.
  • Pick the metric from the asymmetry of real costs: a missed cancer screening and a false fraud block have wildly different price tags, and the metric should reflect that, not convention.
  • F1 is the harmonic mean of precision and recall, so it weights them equally — an assumption you almost never actually hold. Use Fβ when you can state the ratio of costs.
  • Threshold-free metrics (ROC-AUC, PR-AUC) evaluate the ranking. Threshold metrics evaluate a decision. Report both, because they answer different questions and improving one may not move the other.
🔔 Fires when you see

Fire this the moment you see: accuracy quoted for a rare event · a single F1 at threshold 0.5 used to pick between models · ROC-AUC on a heavily imbalanced problem with no PR curve alongside it · a metric chosen before anyone stated the cost of each error type · a model evaluated on a rebalanced test set (the test set must keep the real base rate).

The tradeoff

You are building a fraud detector where positives are rare. Which metric drives model selection and threshold choice?

ROC-AUC
+ you gain invariant to class balance, so scores are comparable across datasets, time periods, and segments even as the fraud rate drifts; it summarises pure ranking quality in one number and is stable enough to track on a dashboard over months
− you pay optimistic on rare-event problems, because FPR is diluted by the enormous negative count — a model can look near-perfect while the review queue is mostly false alarms. It also tells you nothing about which threshold to use, or what the queue will look like at that threshold.
pick when you are comparing model versions against each other over time and want a balance-independent yardstick — good for tracking, poor for deciding
PR-AUC / average precision
+ you gain directly reflects performance on the minority class and is sensitive to false positives in a way that matches how the operational cost is actually felt; it is the right summary when positives are what you care about and negatives are merely background
− you pay its baseline is the positive rate, so the number is not comparable across datasets or across time as the base rate drifts — a PR-AUC drop can mean the model got worse or that fraud got rarer, and the metric cannot distinguish them
pick when the positive class is the entire point and the negatives are just volume — fraud, anomaly detection, rare disease, defect detection
Expected cost / precision at fixed capacity
+ you gain the only option that answers the actual business question. Assign a currency value to each cell of the confusion matrix and minimise expected cost — or, if your review team can process N cases a day, measure precision@N and recall@N, which is the operating point that literally exists
− you pay requires the business to commit to numbers they often cannot or will not supply, and those costs change; it is also a single operating point, so it hides whether the model would be better or worse if capacity changed
pick when the costs are genuinely knowable (chargeback amount, analyst hour, customer churn) or the review capacity is a hard constraint — which for any real fraud team it is
What a senior engineer actually does

Use PR-AUC to select the model and precision@k to set the threshold, where k is your team's actual daily review capacity. That pairing separates the two decisions correctly: the curve is a property of the model, the point on it is a property of your operations. Reporting ROC-AUC alongside is fine as a stable tracking number, provided nobody mistakes it for an operational one.

The framing that resolves most metric arguments: stop asking "which metric is best" and start asking "what does a false positive cost, and what does a false negative cost". Once those two numbers exist — even roughly — the metric choice and the threshold both fall out, and the debate ends. When they genuinely cannot be stated, precision at fixed capacity is the honest fallback, because capacity is a constraint you can always measure.


(c) Hands-on · 25 min

Build and score a classifier on a real imbalanced dataset (breast cancer + a synthetic 1-in-100 fraud set), print every metric, and draw both curves. Save as metrics_lab.py inside your s092/ folder and uv run metrics_lab.py.

"""metrics_lab.py — classification metrics from first principles + sklearn.
 
Runs two experiments:
  1. Breast cancer (roughly balanced) — ROC-AUC is fine.
  2. Synthetic fraud (1% positives) — ROC-AUC lies; PR-AUC tells the truth.
"""
from __future__ import annotations
import numpy as np
from sklearn.datasets import load_breast_cancer, make_classification
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.metrics import (
    confusion_matrix,
    precision_score,
    recall_score,
    f1_score,
    accuracy_score,
    roc_auc_score,
    average_precision_score,
    precision_recall_curve,
    roc_curve,
    classification_report,
)
 
RNG = 42
 
 
def report(name: str, y_true, y_pred, y_prob) -> None:
    """Print a compact metrics report — everything a code review needs."""
    tn, fp, fn, tp = confusion_matrix(y_true, y_pred).ravel()
    print(f"\n=== {name} ===")
    print(f"  n = {len(y_true):>5}  positives = {int(y_true.sum()):>4}"
          f"  base rate = {y_true.mean():.3f}")
    print(f"  TP={tp:>4}  FP={fp:>4}  FN={fn:>4}  TN={tn:>4}")
    print(f"  accuracy   = {accuracy_score(y_true, y_pred):.3f}")
    print(f"  precision  = {precision_score(y_true, y_pred, zero_division=0):.3f}")
    print(f"  recall     = {recall_score(y_true, y_pred, zero_division=0):.3f}")
    print(f"  f1         = {f1_score(y_true, y_pred, zero_division=0):.3f}")
    print(f"  ROC-AUC    = {roc_auc_score(y_true, y_prob):.3f}")
    print(f"  PR-AUC     = {average_precision_score(y_true, y_prob):.3f}")
 
 
def tune_for_recall(y_true, y_prob, target_recall: float = 0.95) -> float:
    """Return the threshold that hits at least target_recall with best precision."""
    prec, rec, thr = precision_recall_curve(y_true, y_prob)
    # last elements of prec/rec correspond to threshold=+inf, no threshold there
    mask = rec[:-1] >= target_recall
    if not mask.any():
        return 0.0
    # among all thresholds that meet recall, pick the one with highest precision
    idx = np.argmax(prec[:-1][mask])
    return float(thr[mask][idx])
 
 
def experiment_balanced() -> None:
    X, y = load_breast_cancer(return_X_y=True)
    Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.3, random_state=RNG, stratify=y)
    clf = LogisticRegression(max_iter=5000).fit(Xtr, ytr)
    prob = clf.predict_proba(Xte)[:, 1]
    pred = (prob >= 0.5).astype(int)
    report("Breast cancer — threshold 0.5", yte, pred, prob)
 
 
def experiment_imbalanced() -> None:
    X, y = make_classification(
        n_samples=10_000, n_features=20, weights=[0.99, 0.01],
        random_state=RNG, n_informative=5,
    )
    Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.3, random_state=RNG, stratify=y)
    clf = LogisticRegression(max_iter=5000, class_weight=None).fit(Xtr, ytr)
    prob = clf.predict_proba(Xte)[:, 1]
 
    # A: default threshold — will look great on accuracy, awful on recall
    pred_default = (prob >= 0.5).astype(int)
    report("Synthetic fraud — threshold 0.5 (naive)", yte, pred_default, prob)
 
    # B: threshold tuned to reach 95% recall — see precision collapse
    thr = tune_for_recall(yte, prob, target_recall=0.95)
    pred_recall = (prob >= thr).astype(int)
    print(f"\n  → threshold moved to {thr:.3f} to hit recall ≥ 0.95")
    report(f"Synthetic fraud — threshold {thr:.3f} (recall-tuned)", yte, pred_recall, prob)
 
    # Full sklearn text report for the recall-tuned model
    print("\n" + classification_report(yte, pred_recall, digits=3))
 
 
if __name__ == "__main__":
    experiment_balanced()
    experiment_imbalanced()

Anatomy of the script

Anatomy of the script

Line 27 · confusion_matrix(...).ravel()
Flattens the 2×2 matrix in sklearn's canonical order: tn, fp, fn, tp. Memorise this order — you'll re-derive it on every code review.
core
Line 39 · roc_auc_score(y_true, y_prob)
Note the argument is y_prob, NOT y_pred. Pass predicted labels here by accident and you'll get a garbage number that still looks plausible.
footgun
Line 40 · average_precision_score
This is the sklearn name for PR-AUC — the mean precision across all recall levels. Same input signature as roc_auc_score.
core
Line 45 · precision_recall_curve
Returns three arrays: prec, rec, thr. len(thr) == len(prec) - 1 because the last (prec, rec) pair corresponds to threshold = +∞.
gotcha
Line 66 · make_classification(weights=[0.99, 0.01])
Synthetic 1% positive class — the honest simulation of fraud, defect, or anomaly detection.
data
Line 76 · tune_for_recall
Business-first threshold selection: given a recall SLA, find the highest-precision operating point that meets it. This is what production teams actually do.
prod
Line 82 · classification_report
sklearn's built-in one-liner report. Gives per-class precision/recall/F1 + macro/weighted averages. Paste into design docs unchanged.
prod
Try itFeel the ROC-AUC vs PR-AUC gap on a truly rare event

Edit experiment_imbalanced and change weights=[0.99, 0.01] to weights=[0.999, 0.001] (a 1-in-1000 problem — closer to real credit card fraud).

Re-run and report both ROC-AUC and PR-AUC. Then answer: which of the two numbers would you put in your model's ship / no-ship review, and why?

💡 Hint · Watch how ROC-AUC barely changes while PR-AUC craters. That gap is the entire argument for using PR-AUC on rare-event problems.

(d) Production reality · 15 min

War story Kaggle · Credit Card Fraud Detection dataset· 2016284,807 transactions, 492 frauds (0.17%)
🔥 What broke

Early competitors submitted models with ROC-AUC of 0.98 and celebrated. The dataset has 0.17% positives. A model that always predicts "not fraud" scores accuracy of 99.83% and ROC-AUC of 0.5. Some submissions were only marginally better than the trivial baseline while looking spectacular.

Kaggle eventually switched the leaderboard metric to PR-AUC (Average Precision). Suddenly a lot of "great" models fell from 0.98 to 0.65 — the honest number.

🧯 The fix
PR-AUC as the primary metric. Every serious fraud-detection paper since roughly 2018 reports PR-AUC as the headline number. ROC-AUC is often reported as a secondary, but never alone.
🎓 Lesson to steal
On rare-event problems (fraud, medical, security, defect), ROC-AUC inflates because the false positive rate stays tiny even for terrible models. Always report PR-AUC. The one time you'll see a big gap between the two is exactly the time it matters.
Post-mortem
War story Google — Gmail spam filterbillions of emails / day
🔥 What broke

Early Gmail spam classifiers optimised for F1. Problem: a false positive (legit email in spam folder) is catastrophically worse than a false negative (spam in inbox — annoying but harmless). F1 weights them equally.

Users would lose interview replies, wedding invitations, medical results. Gmail's Net Promoter Score dropped every time the model got "better" by F1.

🧯 The fix

Switched to a precision-at-target-recall operating point: "hold recall at ~95%, maximise precision at that recall." Equivalently, tune the threshold so precision is at least, say, 99.9% — a false positive rate less than 1 in 1,000.

This is the pattern behind the tune_for_recall function in the hands-on script.

🎓 Lesson to steal
Never let the metric optimise your business — let the business define the operating point, then optimise the metric under that constraint. "Precision at recall = 0.95" is a real, shippable target; "highest F1" often isn't.
War story A healthcare startup · common failure modecancer screening prototype
🔥 What broke
A team built a chest X-ray classifier, reported 92% accuracy on a balanced test set, and demoed it to a hospital partner. The hospital's real prevalence of the target disease was 2%. In deployment the model flagged 40% of images as positive — radiologists were buried and stopped trusting it within a week.
🧯 The fix

Two changes: (1) evaluate on data with the deployment class balance, not the training balance; (2) calibrate probabilities (Platt scaling or isotonic regression) and set a threshold so specificity is at least 0.95 in the deployment population.

🎓 Lesson to steal
Metrics computed on balanced test sets are marketing, not evaluation. Always report metrics on data with the same class prevalence you'll see in production, and pick your threshold on that same data.

Where this shows up in the rest of the plan

Metrics are the vocabulary of every ML session that follows
S094 · Imbalanced data
SMOTE, class weights, threshold tuning — all judged by the metrics from today.
S095 · Model selection & CV
Cross-validation aggregates the same metrics across folds.
S102 · CNN image classification
Uses top-1/top-5 accuracy for balanced sets, mAP for detection.
S121 · LLM evaluation
Extends this into ranking metrics (nDCG, MRR) and LLM-as-a-judge.
S128 · MLOps monitoring
You'll page an on-call engineer when PR-AUC drops 5 points in a day — but only if the metric was chosen well here.
S130 · Capstone
Your final review deck must open with the metric, the operating point, and the confusion matrix. Not accuracy.

(e) Recall + stretch · 10 min

Quick recall · click to reveal
★ = stretch question

Explain-out-loud test

If you can't teach these three to a friend without notes, redo the session:

  1. What is a confusion matrix, and what are TP/FP/FN/TN? (draw it)
  2. When does accuracy lie? (one concrete example, with numbers)
  3. When do you use PR-AUC instead of ROC-AUC? (and why)

What comes next

Hub: The 6-Month Learning Plan


Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.