Skip to content
intermediate Phase 7 · Logistic Regression and Classification Metrics

ROC Curve and Threshold Selection

Evaluate ranking quality and tune decision cutoffs without retraining.

50m
0 problems
Topic Progress 0%

Sweeping the Cutoff: How One Classifier Draws a Whole Curve

You have a trained classifier and one confusion matrix — but that matrix is an artifact of a single threshold, usually 0.5 by habit. The ROC curve is what you get when you stop being superstitious: sweep the threshold across every score the model produced and plot two rates at each cutoff. Work it on ten labeled examples, five positive, five negative, with scores:

positives: 0.95  0.85  0.70  0.45  0.20
negatives: 0.80  0.60  0.35  0.25  0.10

Two rates drive everything. TPR (true positive rate, sensitivity, recall) = TP / (TP + FN): what fraction of the five positives you caught. FPR = FP / (FP + TN): what fraction of the five negatives you wrongly flagged. Recall from Confusion Matrix, Precision, and Recall that TPR is just recall; FPR is precision's neglected cousin, and the ROC plot pairs them precisely because one normalizes by actual positives and the other by actual negatives.

Threshold TP FP TPR FPR
0.90 1 0 0.2 0.0
0.80 2 1 0.4 0.2
0.70 3 1 0.6 0.2
0.60 3 2 0.6 0.4
0.45 4 2 0.8 0.4
0.35 4 3 0.8 0.6
0.25 4 4 0.8 0.8
0.20 5 4 1.0 0.8
0.10 5 5 1.0 1.0

Reading the table teaches the mechanism. At threshold 0.90 the model flags only its most confident positive: TPR 0.2, FPR 0 — the point (0, 0.2) on the plot. Drop to 0.70 and you catch three of five positives while the one high-scoring negative (0.80) is the only casualty: (0.2, 0.6). At the bottom, threshold 0.10 flags everything, giving TPR 1 and FPR 1 — the unavoidable corner (1, 1). Every threshold is one point; connecting them in order of decreasing threshold is the ROC curve. A perfect classifier visits (0, 1) — all positives ranked above all negatives — hugging the top-left corner. A random scorer traces the diagonal y = x, because catching half the positives requires flagging half the negatives.

Two structural facts round out the construction. The curve is a step function: it only moves when the threshold crosses an observed score, so a model with 10,000 training rows produces a finely resolved curve while this toy example gives eight coarse steps. And the endpoints are fixed regardless of model quality: every ROC curve starts at (0, 0) and ends at (1, 1), so models are compared by how far they bow toward (0, 1) — which is exactly what the area under the curve quantifies.

In scikit-learn the sweep is one call:

from sklearn.metrics import roc_curve, roc_curve_display

fpr, tpr, thresholds = roc_curve(y_test, model.predict_proba(X_test)[:, 1])
# thresholds[0] is np.inf by convention so the curve starts at (0, 0)
roc_curve_display.from_estimator(model, X_test, y_test)  # plot in one line
# pick a point: idx = (tpr >= 0.90).argmax(); operating_threshold = thresholds[idx]

Notice the input: the ROC machinery consumes scores or probabilities, never hard labels. Averaging accuracy over a few thresholds would still hide the trade-off; the full curve exposes every operating point the trained model can reach — without ever retraining it.

AUC as Ranking Probability: 18 Out of 25 Pairs, and What ROC Hides

The area under the ROC curve has a probabilistic meaning that makes it click: AUC is the probability that a randomly chosen positive example receives a higher score than a randomly chosen negative one. It is a rank statistic, not a calibration statistic, and you can verify it by brute force on the ten-example set from the last chapter. Five positives against five negatives gives 5 × 5 = 25 pairs to compare. Score 0.95 outranks all five negatives: 5 pairs. Score 0.85 also outranks all five (the best negative is 0.80): another 5. Score 0.70 beats 0.60, 0.35, 0.25, 0.10: 4. Score 0.45 beats three: 3. Score 0.20 beats only 0.10: 1. Concordant pairs total 5 + 5 + 4 + 3 + 1 = 18, so AUC = 18/25 = 0.72, which is exactly what roc_auc_score(y_test, scores) returns for this data.

The scale reads directly: AUC = 1.0 means every positive outranks every negative; AUC = 0.5 means the ranking is a coin flip — a random-scoring model scores 0.5 in expectation, which is why 0.5 is the useless-model baseline and why an AUC barely above 0.5 deserves a shrug, not a launch. AUC = 0.3 would mean the model ranks systematically backwards — often a swapped label encoding during evaluation, a bug worth checking before despairing.

Because AUC depends only on ranking, any strictly monotone transform of the scores leaves it unchanged. Squaring every probability, taking its logit, or rescaling to dollars moves every ROC point not at all; it only relabels the axis of thresholds. That invariance is the source of both AUC's power and its blind spot: AUC cannot tell you whether 0.9 means 90% or 40% chance — it does not measure calibration — and it averages over operating points you may never use (a system running at FPR 0.4 is rarely deployable, so the left tail of the curve often matters more than the whole-area summary).

That last point is sharpest under class imbalance, which is where ROC quietly flatters you. Take 10,000 transactions with 500 fraudulent (5%). A model operating at TPR 0.8 and FPR 0.04 looks excellent on the ROC plot — point (0.04, 0.8), far above the diagonal — and its ROC AUC might be 0.95. Now count people: TPR 0.8 catches 400 frauds, but FPR 0.04 means 4% of 9,500 clean transactions = 380 false alarms. Precision = 400 / 780 ≈ 0.51: half the review queue is noise, and if analysts clear 200 alerts a day, most of their day is wasted. The ROC did not lie — FPR is genuinely small — but its denominator is the 9,500 negatives, so a tiny FPR is still a large absolute crowd. The precision-recall curve, whose axes are both positive-class quantities, makes that pain visible: it starts at precision = 0.05 (the positive rate — the baseline to beat) and stays high only while precision holds as recall grows. Report ROC AUC for balanced problems and overall ranking quality; report PR AUC (average precision) whenever positives are rare and precision at the operating point is what the business actually pays for.

from sklearn.metrics import roc_auc_score, average_precision_score

roc_auc_score(y_test, scores)          # 0.95 — looks great at 5% positives
average_precision_score(y_test, scores)  # 0.40 — sobering; random baseline is 0.05
# always compare AP against the positive rate, not against 0

The curve and its area describe the model. The threshold is a business decision layered on top — and it can be optimized from costs, which is the mechanism of the next chapter.

Tuning the Cutoff from Costs: Moving the Threshold Without Retraining

Your model is trained and frozen, yet the product owner wants twice the fraud recall by Friday. Retraining is off the table — and it is unnecessary, because the threshold is a free parameter. The question is how to set it from costs instead of vibes, and the clean derivation is worth rehearsing.

Assume the model outputs calibrated probabilities — p is the true chance the row is positive, an assumption logistic regression's cross-entropy training makes reasonable. Two actions, two costs. Predict positive and the truth is negative: you pay the false-positive cost C_FP. Predict negative and the truth is positive: you pay the false-negative cost C_FN. The expected cost of flagging is C_FP × (1 − p); the expected cost of clearing is C_FN × p. Flag whenever the first is smaller:

C_FP (1 − p) < C_FN p   ⟺   p > C_FP / (C_FP + C_FN)

The optimal threshold is t = C_FP / (C_FP + C_FN).* Sanity-check the extremes: equal costs give t* = 0.5, the habit you were taught. If a missed fraud costs 19 times a false alarm (C_FN = 19, C_FP = 1), t* = 1/20 = 0.05 — flag anything above a 5% chance. If false alarms are expensive (a wrongful account freeze, C_FP = 19; C_FN = 1), t* = 0.95. The threshold is where the model's probability meets the cost ratio, and saying that sentence in an interview separates you from someone who tuned until the demo looked good.

import numpy as np
from sklearn.metrics import confusion_matrix

C_FN, C_FP = 19.0, 1.0
t_star = C_FP / (C_FP + C_FN)                    # 0.05
pred = (proba >= t_star).astype(int)
tn, fp, fn, tp = confusion_matrix(y_val, pred).ravel()
# or sweep and score each candidate directly:
for t in np.quantile(proba, np.linspace(0.01, 0.5, 50)):
    cost = C_FP * (proba[y_val == 0] >= t).sum() + C_FN * (proba[y_val == 1] < t).sum()

Three caveats keep this honest. Calibration first: the formula consumes probabilities, so if the scores are uncalibrated (frequently after class weighting or resampling — the subject of Class Imbalance Remedies), calibrate on a validation split (Platt or isotonic) before trusting t*, or tune the threshold empirically on the validation sweep shown above. Youden's J — maximize TPR − FPR — is the other famous rule; know its assumption, that it implicitly weights TPR and FPR equally and ignores prevalence, which is wrong exactly when the cost story is asymmetric. Validation discipline: pick t on validation data and report it once on the untouched test set; choosing the threshold on the test set leaks labels into the operating point and inflates every number you quote.

One more production concern worth raising unprompted: a threshold is a stored number tied to a score distribution, and both drift. If the model is retrained or the input distribution shifts, scores migrate and yesterday's t* silently changes the operating point — teams that retrain models but freeze the cutoff inherit a moving confusion matrix. Recompute the threshold whenever the model or the calibration changes, monitor the flagged-rate (predicted-positive share) as a cheap drift alarm, and prefer a small number of reviewed operating points over a continuous dial so every deployment state is auditable.

Also know what the threshold cannot fix. Moving it slides you along the model's fixed curve — trading precision for recall, never improving both at once. If no point on the curve satisfies the business (recall 0.9 at precision 0.6 when the curve tops out at 0.35), the model itself is the problem: more features, different training data, class reweighting. The curve tells you which of the two conversations you need to have, and that is the real value of plotting every cutoff instead of one.

Quiz

1. Using the ten scores from the chapter (positives 0.95, 0.85, 0.70, 0.45, 0.20; negatives 0.80, 0.60, 0.35, 0.25, 0.10), what are TPR and FPR at threshold 0.70?

Question 1 options

2. What is the probabilistic interpretation of ROC AUC, and what is the AUC of the ten-example model?

Question 2 options

3. On 5% positive data, a model runs at TPR 0.8 and FPR 0.04 (ROC AUC 0.95). What is its precision, and what does that reveal about ROC on imbalanced data?

Question 3 options

4. A calibrated model scores fraud, with a missed fraud costing 19× a false alarm (C_FN = 19, C_FP = 1). What is the cost-optimal threshold, and where must it be chosen?

Question 4 options

Flashcards

Question

What two rates does an ROC curve plot, and what are their formulas?

Answer

TPR on the y-axis = TP/(TP+FN) (same as recall/sensitivity) and FPR on the x-axis = FP/(FP+TN). Each threshold on the model's scores contributes one (FPR, TPR) point; the curve connects them from threshold +inf (0,0) to -inf (1,1).

Question

State the probabilistic interpretation of ROC AUC and the AUC values for perfect, random, and inverted ranking.

Answer

AUC = P(random positive scores higher than random negative), computable as concordant pairs / all pairs (ties half-weighted). Perfect ranking = 1.0; random scoring = 0.5 in expectation; systematically backwards ranking = below 0.5 (often a swapped label encoding bug).

Question

Why can ROC AUC look excellent while precision is poor on heavily imbalanced data, and what metric avoids that?

Answer

FPR's denominator is the huge negative class, so even a tiny FPR is a large absolute number of false alarms that swamps the true positives among the flagged set. The precision-recall curve (axes are positive-class quantities; random baseline = positive rate) shows the operating precision directly — report PR AUC / average precision when positives are rare.

Question

Derive the cost-optimal threshold from a cost matrix.

Answer

Expected cost of predicting positive = C_FP(1−p); of predicting negative = C_FN·p. Predict positive when C_FP(1−p) < C_FN·p, i.e. when p > C_FP/(C_FP + C_FN). Equal costs → t* = 0.5; miss 19× costlier than alarm → t* = 0.05. Requires calibrated probabilities.

Question

What is invariant when you apply a strictly monotone transform to model scores, and what is lost?

Answer

The ranking is unchanged, so ROC points, ROC AUC, and the confusion matrix at any rank-based threshold mapping are identical. What is lost is calibration: probabilities (and log loss) change, so cost-based thresholds computed from p no longer apply without recalibration.

Revision Notes

Key Takeaways

  • 1. The ROC curve is the confusion matrix at every threshold: plot TPR vs FPR by sweeping the score cutoff, without retraining the model
  • 2. AUC is a rank statistic — the probability a random positive outranks a random negative (18/25 = 0.72 in the chapter's worked example) — and is unchanged by monotone score transforms
  • 3. ROC flatters imbalanced data: FPR 0.04 on 9,500 negatives is 380 false alarms against 400 catches, so precision is only ~0.51 despite a strong-looking ROC point
  • 4. The cost-optimal threshold on calibrated probabilities is C_FP/(C_FP + C_FN); it comes from equalizing expected false-alarm and miss costs, not from convention
  • 5. Tune the threshold on validation data only, and when no point on the curve meets the business target, the fix is the model, not the cutoff

Interview Tips

  • • "Explain the ROC curve to me" — say it is the confusion matrix at every threshold, define TPR and FPR with formulas, and note the fixed endpoints (0,0) and (1,1); adding that it consumes scores, not hard labels, shows you have built one
  • • "What does AUC of 0.72 mean, precisely?" — a randomly chosen positive beats a randomly chosen negative 72% of the time; then say what it does NOT mean: calibration, or performance at your actual operating threshold
  • • "Why do you report PR curves here?" — the FPR denominator is the 9,500 negatives, so tiny rates are big crowds; PR's axes are positive-class quantities and its baseline is the positive rate (0.05), which makes noise-in-the-queue problems visible
  • • "The business wants more recall by Friday." — do not retrain first: compute the cost-optimal threshold t* = C_FP/(C_FP+C_FN) (calibrate if scores were trained with weights/resampling), sweep thresholds on validation, and show the new confusion matrix at the chosen point

Cheat Sheet

ROC Curves and Threshold Selection — Cheat Sheet

Rates

TPR = TP/(TP+FN)   # recall; y-axis of ROC
FPR = FP/(FP+TN)   # x-axis of ROC; small rate, big absolute crowd on imbalanced data

AUC

  • AUC = P(random positive outranks random negative) = concordant pairs / total pairs
  • Perfect 1.0 · random 0.5 · backwards < 0.5 (check label encoding)
  • Invariant to any monotone score transform → measures ranking, never calibration
  • 10-example chapter model: 18/25 pairs = AUC 0.72

ROC vs PR under imbalance (5% positives, TPR 0.8, FPR 0.04)

View Reading
ROC point (0.04, 0.8) — looks great
Headcount TP 400, FP 380 of 9,500 negatives
Precision 400/780 ≈ 0.51 — half the queue is noise
PR baseline random = positive rate 0.05

Cost-optimal threshold (calibrated probabilities)

flag when C_FP(1-p) < C_FN·p   ⟺   p > C_FP/(C_FP + C_FN)
C_FN=19, C_FP=1 → t* = 0.05;  equal costs → t* = 0.5
  • Youden's J (max TPR−FPR) assumes equal weights and ignores prevalence — wrong under asymmetric costs
  • Choose the threshold on a validation split, report once on the test set
  • Threshold moves you along the fixed curve: it trades precision for recall, never improves both

Continue Learning