Python · SQL · Web Dev · Java · AI/ML tracks launching soon — your one platform for all of IT
Intermediate

ROC Curve and AUC — Threshold-Independent Evaluation

What the ROC curve actually measures, why AUC equals a probability, and how to use operating points to choose a threshold for production.

22–28 min March 2026
Before any formula — what problem does ROC solve?

Precision and recall change every time you move the threshold. ROC-AUC gives you one number that works across every threshold at once.

Module 34 showed the fundamental problem: precision and recall depend on the threshold you choose. Lower the threshold from 0.5 to 0.3 — you catch more fraud (higher recall) but generate more false alarms (lower precision). Every threshold gives a different precision/recall pair. Which one do you report? Which one do you optimise?

The deeper question is: before you even choose a threshold, how good is the model's underlying ability to separate fraud from legitimate transactions? If the model's scores completely overlap — fraud transactions score 0.4–0.6 and legitimate transactions also score 0.4–0.6 — no threshold will produce a useful classifier. If fraud scores 0.7–0.9 and legitimate scores 0.1–0.3, any reasonable threshold works perfectly.

The ROC curve answers this question. It plots how the true positive rate and false positive rate trade off as you sweep the threshold from 1.0 down to 0.0 — across every possible threshold simultaneously. The AUC (area under that curve) collapses this into one number that describes the model's separability regardless of any threshold choice.

🧠 Analogy — read this first

Imagine 100 Brex loan applicants — 10 will default, 90 will not. You line them up ordered by your model's default score, highest first. The ROC curve asks: as you walk down the line and draw a threshold between each pair of adjacent applicants, what fraction of the 10 defaulters have you caught so far (TPR), and what fraction of the 90 non-defaulters have you incorrectly included (FPR)?

If your model is perfect, all 10 defaulters appear at the top of the list before any non-defaulter. TPR reaches 1.0 while FPR is still 0.0 — a curve that hugs the top-left corner. AUC = 1.0. If your model is random, defaulters and non-defaulters are scattered randomly — TPR and FPR increase at the same rate. AUC = 0.5.

🎯 Pro Tip
AUC has the most intuitive interpretation in all of evaluation metrics: it equals the probability that the model assigns a higher score to a randomly chosen positive than to a randomly chosen negative. AUC = 0.92 means: pick one random fraud transaction and one random legitimate transaction — there is a 92% chance the model scored the fraud transaction higher. This requires zero threshold decisions.
How the ROC curve is constructed

Building the curve from scratch — every threshold, one point

The ROC curve is constructed by sweeping the classification threshold from 1.0 (predict everything negative) down to 0.0 (predict everything positive). At each threshold you compute TPR and FPR and plot one point. Connect all points and you have the ROC curve.

Constructing the ROC curve — step by step on 10 predictions
RankScoreTrue labelThreshold hereTPRFPRROC point
10.95✓ fraud≥0.951/4=0.250/6=0.00(0.00, 0.25)
20.88✓ fraud≥0.882/4=0.500/6=0.00(0.00, 0.50)
30.81✗ legit≥0.812/4=0.501/6=0.17(0.17, 0.50)
40.74✓ fraud≥0.743/4=0.751/6=0.17(0.17, 0.75)
50.68✗ legit≥0.683/4=0.752/6=0.33(0.33, 0.75)
60.55✓ fraud≥0.554/4=1.002/6=0.33(0.33, 1.00)
70.42✗ legit≥0.424/4=1.003/6=0.50(0.50, 1.00)
80.31✗ legit≥0.314/4=1.004/6=0.67(0.67, 1.00)
90.20✗ legit≥0.204/4=1.005/6=0.83(0.83, 1.00)
100.09✗ legit≥0.094/4=1.006/6=1.00(1.00, 1.00)

4 actual fraud cases, 6 legitimate. Each row: lower the threshold by one rank, add that prediction, recompute TPR and FPR. The rightmost column is one point on the ROC curve. Connect all 10 points — that is the ROC curve.

python
import numpy as np
from sklearn.metrics import roc_curve, auc

# ── Manual ROC curve construction — every step visible ─────────────────
y_true  = np.array([1, 1, 0, 1, 0, 1, 0, 0, 0, 0])
y_score = np.array([0.95, 0.88, 0.81, 0.74, 0.68,
                    0.55, 0.42, 0.31, 0.20, 0.09])

n_pos = y_true.sum()        # 4 actual fraud cases
n_neg = (1 - y_true).sum()  # 6 actual legit cases

print("Building ROC curve manually:")
print(f"{'Threshold':>12} {'TP':>5} {'FP':>5} {'TPR':>8} {'FPR':>8} {'Point'}")
print("─" * 58)

# Sort by score descending — walk from high threshold to low
order       = np.argsort(y_score)[::-1]
y_sorted    = y_true[order]
score_sorted = y_score[order]

roc_points = [(0.0, 0.0)]   # start at origin
tp, fp = 0, 0
for i, (label, score) in enumerate(zip(y_sorted, score_sorted)):
    if label == 1:
        tp += 1
    else:
        fp += 1
    tpr = tp / n_pos
    fpr = fp / n_neg
    roc_points.append((fpr, tpr))
    print(f"  t≥{score:.2f}     {tp:>5} {fp:>5} {tpr:>8.3f} {fpr:>8.3f}  ({fpr:.2f}, {tpr:.2f})")

# ── AUC using the trapezoid rule ───────────────────────────────────────
fprs = [p[0] for p in roc_points]
tprs = [p[1] for p in roc_points]

# Trapezoid rule: sum of (width × average height) for each step
manual_auc = sum(
    (fprs[i+1] - fprs[i]) * (tprs[i+1] + tprs[i]) / 2
    for i in range(len(fprs) - 1)
)
print(f"
Manual AUC (trapezoid rule): {manual_auc:.4f}")

# ── sklearn verification ───────────────────────────────────────────────
fpr_sk, tpr_sk, thresholds_sk = roc_curve(y_true, y_score)
auc_sk = auc(fpr_sk, tpr_sk)
print(f"sklearn AUC:                 {auc_sk:.4f}  ← matches")
The most important insight about AUC

AUC = P(score of random positive > score of random negative)

The probabilistic interpretation of AUC is not just a nice fact — it is the most practically useful way to understand and communicate model quality. It requires no threshold, no class imbalance adjustment, and no domain knowledge to interpret.

It means you can directly answer the question: "if I show this model one fraud transaction and one legitimate transaction, what is the probability it will rank the fraud higher?" For Stripe's fraud model with AUC = 0.94, the answer is 94%. This is the number you put in the model card, the slide deck, and the compliance audit report.

Proving the probabilistic interpretation — counting concordant pairs

A concordant pair is a (positive, negative) pair where the model correctly scores the positive higher. A discordant pair is one where the negative scores higher. AUC equals the fraction of all possible positive-negative pairs that are concordant. This is the Mann-Whitney U statistic — a non-parametric test that predates ROC analysis by decades.

AUC = (concordant pairs + 0.5 × tied pairs) / (n_pos × n_neg)
concordant: score(positive) > score(negative)
discordant: score(positive) < score(negative)
tied: score(positive) = score(negative)
python
import numpy as np
from sklearn.metrics import roc_auc_score

np.random.seed(42)
n_pos, n_neg = 200, 800   # Brex loan dataset: 20% default rate

# Simulate model scores
pos_scores = np.random.beta(5, 2, n_pos)   # defaulters score higher
neg_scores = np.random.beta(2, 5, n_neg)   # non-defaulters score lower

y_true  = np.array([1]*n_pos + [0]*n_neg)
y_score = np.concatenate([pos_scores, neg_scores])

# ── Method 1: sklearn ──────────────────────────────────────────────────
auc_sklearn = roc_auc_score(y_true, y_score)

# ── Method 2: probabilistic — count concordant pairs ──────────────────
concordant = 0
discordant = 0
tied       = 0

# Sample 10,000 random pairs for speed (exact needs n_pos × n_neg pairs)
np.random.seed(0)
n_pairs = 10_000
pos_sample = np.random.choice(pos_scores, n_pairs)
neg_sample = np.random.choice(neg_scores, n_pairs)

concordant = (pos_sample > neg_sample).sum()
discordant = (pos_sample < neg_sample).sum()
tied       = (pos_sample == neg_sample).sum()

auc_manual = (concordant + 0.5 * tied) / n_pairs

print(f"AUC (sklearn):               {auc_sklearn:.4f}")
print(f"AUC (concordant pairs):      {auc_manual:.4f}  ← same interpretation")
print(f"
Concordant pairs: {concordant:,}/{n_pairs:,} ({concordant/n_pairs*100:.1f}%)")
print(f"Discordant pairs: {discordant:,}/{n_pairs:,} ({discordant/n_pairs*100:.1f}%)")
print(f"Tied pairs:       {tied:,}/{n_pairs:,}")
print(f"
Interpretation: if you pick one random defaulter and one random")
print(f"non-defaulter, the model ranks the defaulter higher {auc_sklearn*100:.1f}% of the time.")

# ── AUC on different datasets — what the numbers mean in practice ──────
print("
AUC benchmarks by domain:")
benchmarks = [
    ('Fraud detection (Stripe)',    0.94, 'Production quality'),
    ('Credit scoring (Brex)',         0.88, 'Good, acceptable'),
    ('Churn prediction (DoorDash)',     0.81, 'Fair, investigate features'),
    ('Random model (baseline)',       0.50, 'No signal at all'),
    ('Reverse model (worse than rnd)',0.30, 'AUC < 0.5 — flip predictions'),
]
for name, auc_val, verdict in benchmarks:
    bar = '█' * int(auc_val * 30)
    print(f"  {name:<35}: {bar:<30} {auc_val:.2f}  {verdict}")
When ROC-AUC misleads you

ROC-AUC vs PR-AUC — which to use and when

ROC-AUC has a critical weakness on severely imbalanced datasets. When the negative class is 99× larger than the positive class, a huge number of true negatives make FPR look small even when the model generates enormous absolute numbers of false positives. The ROC curve looks excellent while the precision is terrible.

The Precision-Recall curve is immune to this. It never looks at true negatives at all — it only measures how well the model finds the positive class. For fraud detection (1–2% fraud), disease diagnosis (1% positive), and any severely imbalanced problem, PR-AUC is the more honest metric.

ROC vs PR — same model, same data, different story
ROC Curve — looks excellent
AUC = 0.97 — "great model!"
FPR (False Positive Rate) →TPR (Recall) ↑ideal: top-left

Large TN count makes FPR look tiny even with many FPs. Optimistic on imbalanced data.

PR Curve — reveals the truth
AP = 0.41 — "poor precision!"
Recall →Precision ↑ideal: top-left

No TN in formula — shows that at high recall, precision collapses. Honest on imbalanced data.

python
import numpy as np
from sklearn.metrics import (roc_auc_score, average_precision_score,
                              roc_curve, precision_recall_curve)
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
import warnings
warnings.filterwarnings('ignore')

np.random.seed(42)

# ── Demonstrate ROC vs PR on severely imbalanced data ──────────────────
# 0.5% fraud rate — extreme imbalance
n = 20_000
X = np.random.randn(n, 8)
# Fraud: only 100 cases out of 20,000 (0.5%)
y = np.zeros(n, dtype=int)
fraud_idx = np.random.choice(n, 100, replace=False)
y[fraud_idx] = 1
# Add some signal
X[fraud_idx, 0] += 2.5
X[fraud_idx, 1] += 1.8

X_tr, X_te, y_tr, y_te = train_test_split(
    X, y, test_size=0.2, stratify=y, random_state=42
)
sc = StandardScaler()
model = GradientBoostingClassifier(
    n_estimators=200, learning_rate=0.1, max_depth=3,
    subsample=0.8, random_state=42,
    scale_pos_weight=None,
)
model.fit(sc.fit_transform(X_tr), y_tr)
y_prob = model.predict_proba(sc.transform(X_te))[:, 1]

roc_auc = roc_auc_score(y_te, y_prob)
pr_auc  = average_precision_score(y_te, y_prob)
base_pr = y_te.mean()   # PR-AUC baseline = fraud rate

print(f"Dataset: {y_te.sum()} fraud / {(y_te==0).sum()} legit ({y_te.mean()*100:.1f}% fraud)")
print(f"
ROC-AUC:  {roc_auc:.4f}  ← looks great")
print(f"PR-AUC:   {pr_auc:.4f}  ← reveals poor precision at high recall")
print(f"PR base:  {base_pr:.4f}  ← random model PR-AUC (= fraud rate)")
print(f"PR skill: {(pr_auc - base_pr)/(1 - base_pr):.4f}  ← normalised PR improvement")

# ── Precision at specific recall levels ───────────────────────────────
prec, rec, thresh = precision_recall_curve(y_te, y_prob)

print(f"
Precision at specific recall levels (what matters for ops):")
for target_recall in [0.90, 0.80, 0.70, 0.60, 0.50]:
    idx     = np.argmin(np.abs(rec - target_recall))
    print(f"  At recall={target_recall:.0%}: precision={prec[idx]:.3f}  "
          f"threshold={thresh[idx] if idx < len(thresh) else 'n/a':.3f}  "
          f"({prec[idx]*100:.1f}% of flagged are genuine fraud)")

# ── Decision guide ─────────────────────────────────────────────────────
print(f"
When to use which:")
print(f"  ROC-AUC: balanced classes, ranking quality, model comparison")
print(f"  PR-AUC:  imbalanced classes (<10% positive), precision matters")
print(f"  Both:    always report both — they capture different aspects")
From curve to decision

Choosing an operating point — where on the ROC curve should you sit?

The ROC curve gives you all possible operating points. Choosing which point to operate at is a business decision, not a modelling decision. The right point depends on the cost ratio between false negatives and false positives, the operational capacity of your review team, and regulatory requirements.

Three systematic methods for choosing an operating point, each appropriate for different situations:

Youden IndexJ = TPR − FPR → find max J

When you have no cost information — maximises the balanced distance from the random baseline.

Limitation: Treats FP and FN as equally costly — rarely true in practice.
Cost-minimising thresholdCost = FN × cost_FN + FP × cost_FP → find min cost

When you know the relative cost of each error type. Stripe: cost_FN=$2500 (missed fraud), cost_FP=$50 (friction). Most situations.

Limitation: Requires knowing business costs explicitly. Cost estimates may themselves be uncertain.
Fixed recall constraintFind threshold where TPR ≥ target_recall

When a regulator or business sets a minimum recall requirement. e.g. "catch at least 90% of all fraud no matter what."

Limitation: May force very low precision — many false alarms. Secondary optimisation needed.
python
import numpy as np
from sklearn.metrics import roc_curve, roc_auc_score
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
import warnings
warnings.filterwarnings('ignore')

np.random.seed(42)
n = 10_000

# Stripe fraud dataset
amount        = np.abs(np.random.normal(1200, 2000, n)).clip(10, 50_000)
merchant_risk = np.random.uniform(0, 1, n)
n_tx_hour     = np.random.randint(0, 20, n).astype(float)
device_age    = np.abs(np.random.normal(200, 150, n)).clip(0, 1000)
is_new_device = np.random.randint(0, 2, n).astype(float)
fraud_score   = (
    (amount/50_000)*0.30 + merchant_risk*0.25
    + (n_tx_hour/20)*0.25 + is_new_device*0.15
    + np.random.randn(n)*0.05
)
y = (fraud_score > 0.55).astype(int)
X = np.column_stack([amount, merchant_risk, n_tx_hour, device_age, is_new_device])

X_tr, X_te, y_tr, y_te = train_test_split(
    X, y, test_size=0.2, stratify=y, random_state=42
)
sc      = StandardScaler()
X_tr_sc = sc.fit_transform(X_tr)
X_te_sc = sc.transform(X_te)

# Use validation split for threshold selection
X_tv, X_val, y_tv, y_val = train_test_split(
    X_tr_sc, y_tr, test_size=0.2, stratify=y_tr, random_state=42
)
model = GradientBoostingClassifier(
    n_estimators=200, learning_rate=0.1, max_depth=3,
    subsample=0.8, random_state=42,
)
model.fit(X_tv, y_tv)
val_prob = model.predict_proba(X_val)[:, 1]

fpr, tpr, thresholds = roc_curve(y_val, val_prob)

# ── Method 1: Youden Index ─────────────────────────────────────────────
youden     = tpr - fpr
best_idx_j = np.argmax(youden)
t_youden   = thresholds[best_idx_j]
print(f"Method 1 — Youden Index:")
print(f"  Best threshold: {t_youden:.3f}")
print(f"  TPR={tpr[best_idx_j]:.3f}  FPR={fpr[best_idx_j]:.3f}  J={youden[best_idx_j]:.3f}")

# ── Method 2: Cost minimisation ────────────────────────────────────────
cost_fn = 2500   # $ cost of missing one fraud
cost_fp = 50     # $ cost of one false alarm

n_pos = y_val.sum()
n_neg = (1 - y_val).sum()

costs = []
for t, tp_rate, fp_rate in zip(thresholds, tpr, fpr):
    fn  = n_pos * (1 - tp_rate)   # false negatives
    fp  = n_neg * fp_rate          # false positives
    costs.append(fn * cost_fn + fp * cost_fp)

best_idx_cost = np.argmin(costs)
t_cost        = thresholds[best_idx_cost]
print(f"
Method 2 — Cost minimisation (FN=${cost_fn}, FP=${cost_fp}):")
print(f"  Best threshold: {t_cost:.3f}")
print(f"  TPR={tpr[best_idx_cost]:.3f}  FPR={fpr[best_idx_cost]:.3f}  Cost=${min(costs):,.0f}")

# ── Method 3: Fixed recall constraint ─────────────────────────────────
target_recall  = 0.90
recall_thresh_idx = np.argmin(np.abs(tpr - target_recall))
t_recall       = thresholds[recall_thresh_idx]
print(f"
Method 3 — Fixed recall constraint (TPR ≥ {target_recall:.0%}):")
print(f"  Best threshold: {t_recall:.3f}")
print(f"  TPR={tpr[recall_thresh_idx]:.3f}  FPR={fpr[recall_thresh_idx]:.3f}")

# ── Final evaluation on test set ──────────────────────────────────────
test_prob = model.predict_proba(X_te_sc)[:, 1]
print(f"
Test set AUC: {roc_auc_score(y_te, test_prob):.4f}")
print(f"
Comparison at each threshold on test set:")
print(f"{'Method':<28} {'Threshold':>10} {'TPR':>7} {'FPR':>7} {'Cost $':>10}")
print("─" * 67)

for name, t in [('Youden Index',    t_youden),
                 ('Cost-minimising', t_cost),
                 ('90% recall fix',  t_recall)]:
    pred  = (test_prob >= t).astype(int)
    tp    = ((pred == 1) & (y_te == 1)).sum()
    fp    = ((pred == 1) & (y_te == 0)).sum()
    fn    = ((pred == 0) & (y_te == 1)).sum()
    tpr_t = tp / y_te.sum()
    fpr_t = fp / (y_te == 0).sum()
    cost  = fn * cost_fn + fp * cost_fp
    print(f"  {name:<26}  {t:>10.3f}  {tpr_t:>7.3f}  {fpr_t:>7.3f}  {cost:>10,.0f}")
Beyond binary classification

Multi-class AUC — OvR and OvO strategies

ROC-AUC extends to multi-class problems via two strategies. One-vs-Rest (OvR) computes one ROC curve per class treating it as the positive class against all others combined. One-vs-One (OvO) computes one ROC curve for every pair of classes. Both produce a single aggregate AUC via averaging.

python
import numpy as np
from sklearn.metrics import roc_auc_score
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler, label_binarize
import warnings
warnings.filterwarnings('ignore')

np.random.seed(42)
n = 4000

# DoorDash support ticket categories: 4 classes
X = np.random.randn(n, 10)
y = np.random.choice([0, 1, 2, 3], n, p=[0.40, 0.25, 0.20, 0.15])
classes = ['delivery', 'food_quality', 'payment', 'general']

# Add signal
for cls in range(4):
    X[y == cls, cls] += 2.5

X_tr, X_te, y_tr, y_te = train_test_split(
    X, y, test_size=0.2, stratify=y, random_state=42
)
sc = StandardScaler()
model = RandomForestClassifier(n_estimators=100, random_state=42, n_jobs=-1)
model.fit(sc.fit_transform(X_tr), y_tr)
y_prob = model.predict_proba(sc.transform(X_te))

# ── Multi-class AUC — two strategies ─────────────────────────────────
# OvR: one AUC per class vs all others (macro average)
auc_ovr_macro    = roc_auc_score(y_te, y_prob, multi_class='ovr',  average='macro')
auc_ovr_weighted = roc_auc_score(y_te, y_prob, multi_class='ovr',  average='weighted')

# OvO: one AUC per class pair (all n×(n-1)/2 pairs)
auc_ovo_macro    = roc_auc_score(y_te, y_prob, multi_class='ovo',  average='macro')
auc_ovo_weighted = roc_auc_score(y_te, y_prob, multi_class='ovo',  average='weighted')

print("Multi-class AUC:")
print(f"  OvR macro:    {auc_ovr_macro:.4f}  (unweighted mean per class)")
print(f"  OvR weighted: {auc_ovr_weighted:.4f}  (weighted by class frequency)")
print(f"  OvO macro:    {auc_ovo_macro:.4f}  (unweighted mean per class pair)")
print(f"  OvO weighted: {auc_ovo_weighted:.4f}  (weighted by pair frequency)")

# ── Per-class AUC ─────────────────────────────────────────────────────
y_bin = label_binarize(y_te, classes=list(range(4)))
print(f"
Per-class AUC (OvR):")
for i, cls in enumerate(classes):
    auc_i = roc_auc_score(y_bin[:, i], y_prob[:, i])
    bar   = '█' * int(auc_i * 30)
    print(f"  {cls:<14}: {bar:<30} {auc_i:.4f}")

# ── Which to use? ─────────────────────────────────────────────────────
print(f"
OvR vs OvO guidance:")
print(f"  OvR:  each class vs all others — faster, standard default")
print(f"  OvO:  each pair of classes — less influenced by class imbalance")
print(f"  macro:    treat all classes equally — use when minority class matters")
print(f"  weighted: weight by support — use for overall performance summary")
Errors you will hit

Every common ROC-AUC mistake — explained and fixed

AUC = 0.50 on the test set but model trains perfectly — AUC on training is 0.98
Why it happens

Classic data leakage or label leakage. The model memorised a feature that directly encodes the label — a timestamp, a transaction ID sequence, a derived feature computed using the label. Or the test set has a completely different distribution from training (temporal split missing). AUC collapses to 0.5 when the model has no valid signal on new data.

Fix

Remove any feature that could directly or indirectly encode the label. Check feature correlations with y — any feature with correlation above 0.9 is suspicious. Use chronological splits for time-series data. Run the model with randomly shuffled labels — if AUC is still high, a feature is leaking. Audit the preprocessing pipeline for fit-before-split mistakes from Module 20.

roc_auc_score raises ValueError: Only one class present in y_true
Why it happens

The test split contains only one class — either all positive or all negative. This happens on very small datasets or severely imbalanced classes where a random split puts all minority-class examples in one split. With only one class, TPR and FPR cannot both be computed — the ROC curve is undefined.

Fix

Always use stratify=y in train_test_split: train_test_split(X, y, stratify=y). This guarantees both classes appear in every split. For extremely rare classes (less than 10 positive examples total), you may not have enough data for a reliable held-out test set — use cross-validation instead: cross_val_score(model, X, y, cv=StratifiedKFold(5), scoring='roc_auc').

AUC is high (0.92) but the model is useless at the operating threshold — precision is 2%
Why it happens

AUC measures ranking quality across all thresholds equally, including thresholds that are operationally meaningless. With 0.1% fraud rate, even a good model may have very low precision at the threshold where you would actually operate (e.g. flagging 5% of transactions for review). The AUC averages over all thresholds including high-recall-zero-precision regions.

Fix

Always inspect the PR curve and precision at your actual operating recall level. A model with AUC=0.92 but precision=2% at 80% recall means 98% of flagged transactions are false alarms — operationally unworkable. Use PR-AUC as primary metric for severely imbalanced problems. Report precision at your target recall (e.g. precision@80%recall) alongside AUC.

roc_auc_score gives different result from manually computed AUC using numpy trapz
Why it happens

sklearn's roc_curve returns FPR/TPR values at the actual threshold points — it uses the exact trapezoidal rule on these points. If you manually compute the curve with a different set of threshold values (e.g. np.linspace(0, 1, 100)), you get a coarser approximation that may differ from sklearn's exact computation, especially when the curve has sharp bends.

Fix

Always use sklearn's roc_curve output directly: fpr, tpr, _ = roc_curve(y_true, y_score); auc_val = auc(fpr, tpr). Never manually construct threshold arrays for AUC computation — sklearn uses all unique score values as thresholds, giving the exact AUC. Manual linspace grids miss critical threshold points and produce approximation errors.

What this looks like at work

AUC in a real evaluation report — and picking a threshold from a capacity budget

AUC shows up in exactly the places this module already mentioned in passing: the model card, the slide deck for the launch review, the compliance audit report for a regulated model like credit scoring or medical screening. The most common misuse is not a mathematical error — it is quoting a single AUC number as if it settles the question of whether a model is good enough to ship, with no confidence interval and no breakdown by segment. A model can post a strong overall AUC while performing meaningfully worse for one region, one device type, or one customer tier — and an aggregate number computed across the whole population will not surface that on its own.

A real evaluation report usually contains more than the headline number: the overall AUC with a bootstrapped confidence interval (so a reviewer can tell whether a reported improvement is real or just noise from the particular test split), AUC broken down by the segments that matter for fairness or business risk, the ROC curve itself as a plot rather than a single statistic, and — critically — the specific operating threshold chosen for production along with the precision and recall actually achieved at that threshold.

A concrete threshold decision — a fixed review-capacity constraint

Instead of starting from a cost ratio, some teams start from an operational limit: a fraud review team can manually check only a small, fixed share of daily transaction volume, no matter how good the model looks on paper. That caps the acceptable false positive rate directly — the threshold is not chosen to minimise cost or hit a target recall, it is chosen to be the least restrictive threshold that still keeps the false positive rate within the review team's actual staffing capacity. Read straight off the ROC curve: among every threshold whose false positive rate fits inside that capacity budget, pick the one with the highest true positive rate.

python
import numpy as np
from sklearn.metrics import roc_curve, roc_auc_score
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
import warnings
warnings.filterwarnings('ignore')

np.random.seed(42)
n = 10_000
amount        = np.abs(np.random.normal(1200, 2000, n)).clip(10, 50_000)
merchant_risk = np.random.uniform(0, 1, n)
n_tx_hour     = np.random.randint(0, 20, n).astype(float)
fraud_score   = (amount/50_000)*0.30 + merchant_risk*0.35 + (n_tx_hour/20)*0.25 + np.random.randn(n)*0.05
y             = (fraud_score > 0.55).astype(int)
X             = np.column_stack([amount, merchant_risk, n_tx_hour])

X_tr, X_val, y_tr, y_val = train_test_split(X, y, test_size=0.2, stratify=y, random_state=42)
sc = StandardScaler()
model = GradientBoostingClassifier(n_estimators=200, learning_rate=0.1, max_depth=3, random_state=42)
model.fit(sc.fit_transform(X_tr), y_tr)
val_prob = model.predict_proba(sc.transform(X_val))[:, 1]

# ── Real evaluation reports rarely stop at a single AUC number ────────
# Capacity constraint: the review team can manually check at most 2%
# of daily transaction volume, no matter how good the model looks.
fpr, tpr, thresholds = roc_curve(y_val, val_prob)

max_fpr_capacity = 0.02
eligible   = np.where(fpr <= max_fpr_capacity)[0]
best_idx   = eligible[np.argmax(tpr[eligible])]   # highest recall within capacity
t_capacity = thresholds[best_idx]

print(f"Capacity-constrained threshold: {t_capacity:.3f}")
print(f"  Achieved recall at this threshold: {tpr[best_idx]:.3f}")
print(f"  Achieved FPR (within capacity):    {fpr[best_idx]:.3f}")

# ── Bootstrap confidence interval on AUC — a single point estimate
#    is not enough on its own to greenlight a launch ──────────────────
def bootstrap_auc(y_true, y_score, n_boot=500):
    n = len(y_true)
    aucs = []
    for _ in range(n_boot):
        idx = np.random.randint(0, n, n)
        if len(np.unique(y_true[idx])) < 2:
            continue
        aucs.append(roc_auc_score(y_true[idx], y_score[idx]))
    return np.percentile(aucs, [2.5, 50, 97.5])

lo, mid, hi = bootstrap_auc(y_val, val_prob)
print(f"\nAUC 95% CI: {lo:.3f} to {hi:.3f}  (median {mid:.3f})")
print("A report showing only the point estimate hides how much that")
print("number could move on a different sample of the same population.")

The escalation pattern — flag confident negatives, act on confident positives, route only the genuinely uncertain cases to a human reviewer or a slower, more expensive model — is the same pattern that shows up across fraud review, content moderation, and medical triage. The ROC curve and its capacity-driven threshold are what decide the boundary of that uncertain middle band in the first place.

Misconceptions

Five things people get wrong about ROC and AUC

Myth: Since AUC is threshold-independent, you never have to think about thresholds

AUC deliberately ignores thresholds so it can summarize a model's underlying ranking ability in one number, but that is a property of the evaluation, not of deployment. In production the model still has to output a single yes-or-no decision for every prediction, which means someone still has to pick one specific threshold before the model can be used. AUC tells you whether the model is capable of separating the classes well across the board; it never tells you which threshold to actually deploy — that is always a separate decision, driven by the real costs of false positives and false negatives.

Myth: A high AUC means the model performs well everywhere you might operate it

AUC is an average taken across every possible threshold, including many that nobody would ever use in production. A model can post an excellent overall AUC while performing quite badly in the specific region of the curve where you actually need to operate — for example, at the high-recall end where precision matters most for a fraud team's review queue. A single aggregate number can hide a weak stretch in exactly the operating range that matters, which is why you should always inspect the curve, or precision at your target recall, rather than trusting the summary statistic alone.

Myth: An AUC around 0.5 means the model is 'half right' or weakly useful

An AUC of 0.5 has a precise meaning that has nothing to do with being half correct — it means the model's scores contain no usable signal for ranking positives above negatives, equivalent to guessing at random. This is a common mix-up with accuracy, where 50 percent genuinely does mean getting half of the predictions right. An AUC of 0.5 is not "somewhat helpful"; it says the model could be replaced with a coin flip and perform identically, which is a much stronger and more useful statement than "half correct."

Myth: AUC is misleading only when it looks suspiciously perfect

The dangerous case is not a suspiciously high AUC from leakage — that at least tends to get double-checked. The quieter trap is a perfectly plausible, honestly-earned high AUC on a severely imbalanced dataset, where a huge pool of true negatives makes the false positive rate look tiny even while the model is generating large numbers of false positives in absolute terms. A model can post a legitimate, high ROC-AUC while its precision-recall curve tells a much worse story, with precision collapsing at any recall level worth operating at. This is exactly why PR-AUC exists as a second check specifically for imbalanced problems.

Myth: An AUC of 0.85 on one dataset is directly comparable to an AUC of 0.85 on another

AUC depends on how separable the two classes are in a given population, which is itself a property of the dataset, not just the model — a task with a rare, very distinctive positive class can be easy to achieve a high AUC on, while a task with subtle or overlapping classes may cap out well below that even for a strong model. Comparing AUC across different datasets, different populations, or even the same problem measured in different time periods treats two different exams as if they were the same exam. AUC is only a fair comparison between models evaluated on the exact same test set and population.

Interview prep

ROC and AUC — 5 questions interviewers actually ask

Q1 — What does an AUC of 0.5 actually mean, and what would an AUC of 0 mean?

An AUC of 0.5 means the model's scores carry no information that separates the two classes — it is mathematically equivalent to ranking transactions at random, the same performance you would get from a coin flip. An AUC of 0 is actually more informative than it sounds: it means the model consistently ranks every negative above every positive, a perfectly inverted ranking. In practice, an AUC near 0 is a strong signal of a bug, most often flipped labels or an inverted score, not a hopeless model — flipping the prediction (or the label encoding) turns an AUC of 0 into an AUC of 1.

Q2 — Explain what AUC measures to someone with no machine learning background

I would skip the formula entirely and describe the experiment it corresponds to: pick one random example the model should have flagged and one random example it should not have, and ask the model to score both. AUC is simply the probability the model gives the one that should have been flagged a higher score. An AUC of 0.9 means that if you ran that experiment many times, the model would get the ranking right 90 percent of the time. That framing makes it clear why AUC needs no threshold to be meaningful — it is purely about whether the model's relative ordering of the two groups is correct.

Q3 — Model A gets an AUC of 0.95 on a balanced dataset, Model B gets an AUC of 0.95 on a dataset that is 1 percent positive. Are they equally good?

Not necessarily, and this is a common trap. AUC reflects how separable the classes are in that specific population as much as how good the model is, so the same AUC number can represent a much easier or much harder task depending on the class balance and how distinctive the positive class is. Before calling them equally good I would look at the precision-recall curve for both, especially Model B's, since PR-AUC is far more sensitive to what is actually happening on the rare positive class — it is entirely possible for Model B to have excellent ROC-AUC and mediocre precision at any usable recall level.

Q4 — When would you prefer PR-AUC over ROC-AUC, with a concrete example?

Whenever the positive class is a small minority of the data, because ROC-AUC's false positive rate is calculated against a huge pool of true negatives, which makes it look forgiving even when the model produces a large absolute number of false positives. A concrete case: a fraud dataset with a very low fraud rate can show an excellent ROC-AUC around 0.97 while its PR-AUC is only around 0.41, revealing that precision collapses badly at any recall level a review team could actually operate at. PR-AUC ignores true negatives entirely, so it reflects that reality directly instead of averaging it away.

Q5 — If AUC does not depend on a threshold, why do you still need to choose one before shipping the model?

Because AUC evaluates the model as a ranking system across every possible cutoff, but a production system has to make one concrete decision — flag this transaction or do not — for every single prediction, and that requires exactly one threshold. I would pick it using whichever method fits the situation: the Youden index when there is no cost information and both error types are equally bad, a cost-minimizing threshold when I know the dollar cost of a false positive versus a false negative, or a fixed-recall constraint when a regulator or the business has set a minimum catch rate. AUC tells you the model is worth deploying at all; choosing the threshold is the separate step that turns it into an actual decision-making system.

What comes next

You can evaluate any model at any threshold. Next: does your evaluation generalise — or did you get lucky on this particular test set?

ROC-AUC on a single test split gives one number. But how stable is it? A different random seed for the split might give AUC = 0.91 instead of 0.94. Cross-validation gives you a distribution of AUC scores across multiple non-overlapping test sets — mean and standard deviation — so you can report confidence intervals, not just point estimates. Module 37 covers cross-validation, the bias-variance tradeoff, and how to use them together to make model comparisons statistically rigorous.

Next — Module 37 · Model Evaluation
Cross-Validation and the Bias-Variance Tradeoff

From point estimates to confidence intervals. K-fold, stratified, and repeated CV — and when the bias-variance tradeoff determines which model to choose.

Start →

🎯 Key Takeaways

  • The ROC curve plots TPR (recall) against FPR as the classification threshold sweeps from 1.0 to 0.0. Each threshold produces one point on the curve. AUC is the area under that curve — a single number summarising model quality across every possible threshold.
  • AUC has a clean probabilistic interpretation: it equals the probability that the model assigns a higher score to a randomly chosen positive than to a randomly chosen negative. AUC = 0.94 means a random fraud transaction scores higher than a random legitimate one 94% of the time.
  • ROC-AUC is optimistic on severely imbalanced datasets. A large pool of true negatives makes FPR look tiny even with many absolute false positives. For fraud rates below 5%, use PR-AUC (average precision) as the primary metric — it ignores true negatives entirely.
  • Choosing an operating point on the ROC curve is a business decision, not a modelling decision. Three methods: Youden Index (max TPR − FPR, equal error cost), cost minimisation (explicit FN and FP costs), or fixed recall constraint (regulatory minimum catch rate).
  • For multi-class problems use roc_auc_score with multi_class="ovr" (One-vs-Rest) or "ovo" (One-vs-One). Use average="macro" when all classes matter equally, average="weighted" for an overall performance summary weighted by class frequency.
  • Never use a manually constructed threshold grid (np.linspace) to compute AUC — always use sklearn's roc_curve output directly with the auc() function. Manual grids miss critical threshold points and produce approximation errors.
Share

Discussion

0

Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.

Continue with GitHub
Loading...