The Metro Bag Scanner
Imagine the Delhi Metro installs an AI system at a busy station. Every unattended bag that sits on the platform for more than a minute gets scanned by a camera, and the AI outputs a "suspicion score" between 0 and 1 — not a simple yes or no, but a number like 0.83 or 0.12 that reflects how confident the model is that the bag is a real threat. A control-room operator has to decide: at what score should the alarm actually sound?
This single question — where do we draw the line on a score to turn it into a decision — is exactly what ROC curves and AUC are built to answer. They don't just tell you "the model is 90% accurate." They tell you how the model behaves across every possible line you could draw, and they give you one honest number to compare two models even before you've picked where that line goes. That is a genuinely different kind of question from the ones you may have seen when studying accuracy, and it deserves a genuinely different tool.
From a Score to a Decision: The Idea of a Threshold
Most classifiers you meet in machine learning don't output a hard label. They output a probability or a score. A spam filter doesn't think in "spam / not spam" — it computes something like "87% likely spam." A medical test for a disease produces a reading, not a verdict. The metro scanner produces a suspicion score for every bag.
To turn that score into an actual decision, you pick a threshold. If the score is at or above the threshold, the system predicts "positive" (suspicious, spam, diseased). Below it, "negative." Set the threshold at 0.9 and the metro alarm rings only for bags the AI is almost certain about — very few false alarms, but a genuinely dangerous bag scored at 0.75 slips through unflagged. Set the threshold at 0.2 and almost every bag with even mild suspicion triggers an alarm — you'll catch nearly every real threat, but the platform staff will be evacuating for backpacks and lunch boxes all day.
This is the central tension in every threshold-based classifier: moving the threshold trades one kind of mistake for the other. There is no single "correct" threshold in the abstract — it depends on what a missed threat costs versus what a false alarm costs. ROC and AUC exist precisely because judging a model at only one threshold hides this entire trade-off from you.
Four Outcomes: Building a Confusion Matrix
At any fixed threshold, every bag the scanner looks at falls into exactly one of four buckets, comparing what the AI predicted against what was actually true:
- True Positive (TP): the bag really was suspicious, and the AI flagged it. A correct alarm.
- False Positive (FP): the bag was perfectly safe, but the AI flagged it anyway. A false alarm.
- True Negative (TN): the bag was safe, and the AI correctly ignored it.
- False Negative (FN): the bag really was suspicious, but the AI missed it. This is the outcome everyone wants to avoid the most.
These four counts together are called a confusion matrix, and every classification metric you will ever compute — accuracy, precision, recall, and the two rates we need next — is built out of just these four numbers.
Two Rates That Matter: TPR and FPR
ROC curves are built from exactly two of these four numbers, turned into rates so they can be compared fairly across different sample sizes:
- True Positive Rate (TPR), also called Recall or Sensitivity: out of all the bags that were actually suspicious, what fraction did the AI catch? TPR = TP / (TP + FN). This measures how good the model is at not missing real threats.
- False Positive Rate (FPR): out of all the bags that were actually safe, what fraction did the AI wrongly flag? FPR = FP / (FP + TN). This measures how trigger-happy the model is.
Notice the denominators: TPR only ever looks at the truly-positive bags, and FPR only ever looks at the truly-negative bags. Because of this, both rates stay meaningful even if your metro station sees far more safe bags than suspicious ones — which, thankfully, it does in real life. This is what makes TPR and FPR more trustworthy than raw accuracy when one class is much rarer than the other, a point we will return to.
An ideal scanner has TPR = 1 (catches every real threat) and FPR = 0 (never false-alarms on a safe bag). No real model achieves both at once — pushing TPR up by lowering the threshold always drags FPR up too. The ROC curve is simply a picture of exactly how that trade-off plays out for one specific model.
A Worked Example: Eight Bags, One Score Each
Suppose the scanner has scored 8 bags. Four were genuinely suspicious (P1–P4) and four were genuinely safe (N1–N4). Here are the AI's suspicion scores, already sorted from highest to lowest:
Bag Actual AI Score
P1 Suspicious 0.95
P2 Suspicious 0.85
N1 Safe 0.70
P3 Suspicious 0.60
N2 Safe 0.50
P4 Suspicious 0.40
N3 Safe 0.30
N4 Safe 0.10
Look closely at N1 and P4: a genuinely safe bag (N1) scored higher than a genuinely suspicious one (P4). No real classifier ranks every case perfectly — this is exactly the kind of imperfection ROC and AUC are designed to quantify honestly, rather than hide behind a single accuracy number.
Let's compute TPR and FPR at one threshold by hand, say threshold = 0.55. Bags scoring 0.55 or above get flagged: P1, P2, N1, P3. That's 3 correctly flagged suspicious bags (TP = 3) and 1 false alarm (FP = 1, which is N1). The missed suspicious bag is P4 (FN = 1), and the correctly-ignored safe bags are N2, N3, N4 (TN = 3).
TPR = TP/(TP+FN) = 3/(3+1) = 0.75. FPR = FP/(FP+TN) = 1/(1+3) = 0.25. At this one threshold, the scanner catches 75% of real threats while false-alarming on 25% of safe bags. That's one point. To draw a curve, we need many such points.
Sweeping the Threshold: How the ROC Curve Is Born
The idea is simple: instead of picking one threshold, slide it from 1.0 down to 0.0, recomputing TPR and FPR at every value where a new bag crosses the line. Each threshold gives one (FPR, TPR) point. Plot all of them and connect them, and you get the ROC curve — "ROC" stands for Receiver Operating Characteristic, a name left over from 1940s radar engineers who invented this exact technique to judge how well radar operators distinguished real aircraft from noise.
Sweeping through our 8-bag example threshold by threshold:
Threshold Flagged bags TP FP TPR FPR
1.00 none 0 0 0.00 0.00
0.95 P1 1 0 0.25 0.00
0.85 P1,P2 2 0 0.50 0.00
0.70 P1,P2,N1 2 1 0.50 0.25
0.60 P1,P2,N1,P3 3 1 0.75 0.25
0.50 P1,P2,N1,P3,N2 3 2 0.75 0.50
0.40 + P4 4 2 1.00 0.50
0.30 + N3 4 3 1.00 0.75
0.10 + N4 (all 8 flagged) 4 4 1.00 1.00
Every threshold sweep starts at (0,0) — set the bar impossibly high and nothing gets flagged, so both rates are zero — and ends at (1,1) — set the bar at zero and everything gets flagged, so both rates hit 100%. What happens in between is the entire story of how good the model is, and it's exactly what the ROC curve shows:
Reading the Curve: Diagonal, Corners, and Shape
Three landmarks give the whole picture meaning:
- The diagonal line from (0,0) to (1,1) represents a model that guesses randomly — flip a coin for every bag. At any point on this line, TPR equals FPR: you're catching real threats at exactly the same rate you're false-alarming on safe bags. Pure luck, no skill.
- The top-left corner, (0,1), is the dream: TPR = 1 and FPR = 0. Every real threat caught, zero false alarms. A curve that hugs this corner belongs to an excellent model.
- The shape of the curve tells you how far above the diagonal the model manages to climb. Our metro scanner's curve bows up and to the left of the diagonal — it's genuinely better than guessing, catching 75% of threats while only false-alarming on 25% of safe bags at the 0.55 threshold we tried earlier, well above the "equal rates" diagonal.
A model whose ROC curve falls below the diagonal is worse than random guessing — which, oddly, is actually useful information: if you simply flip its predictions (call everything it says "safe" as "suspicious" instead), you'd get a model better than random. This rarely happens with a properly trained model, but it's a real diagnostic signal if you ever see it.
Computing ROC Points in Python
Rather than sweeping by hand, here's the calculation as code, using the same 8-bag data (label 1 = suspicious, label 0 = safe):
data = [(1, 0.95), (1, 0.85), (0, 0.70), (1, 0.60),
(0, 0.50), (1, 0.40), (0, 0.30), (0, 0.10)]
def confusion_counts(data, threshold):
TP = FP = TN = FN = 0
for label, score in data:
predicted = 1 if score >= threshold else 0
if predicted == 1 and label == 1:
TP += 1
elif predicted == 1 and label == 0:
FP += 1
elif predicted == 0 and label == 0:
TN += 1
else:
FN += 1
return TP, FP, TN, FN
def tpr_fpr(data, threshold):
TP, FP, TN, FN = confusion_counts(data, threshold)
tpr = TP / (TP + FN) if (TP + FN) > 0 else 0
fpr = FP / (FP + TN) if (FP + TN) > 0 else 0
return tpr, fpr
thresholds = [1.0, 0.95, 0.85, 0.70, 0.60, 0.50, 0.40, 0.30, 0.10, 0.0]
for t in thresholds:
tpr, fpr = tpr_fpr(data, t)
print(f"threshold={t:.2f} TPR={tpr:.2f} FPR={fpr:.2f}")
Trace it by hand for threshold = 0.70: the loop checks each bag's score against 0.70. Scores 0.95, 0.85, and 0.70 satisfy score >= 0.70; their labels are 1, 1, 0, giving TP=2, FP=1. The rest fail the test — 0.60, 0.50, 0.40, 0.30, 0.10 — with labels 1,0,1,0,0, giving TN=3, FN=2. So TPR = 2/(2+2) = 0.50 and FPR = 1/(1+3) = 0.25, which is exactly the fourth row of our threshold-sweep table. Run the full loop and you reproduce every row we computed by hand — the printed output is threshold=1.00 TPR=0.00 FPR=0.00, then 0.25/0.00, 0.50/0.00, 0.50/0.25, 0.75/0.25, 0.75/0.50, 1.00/0.50, 1.00/0.75, and finally 1.00/1.00 twice.
What AUC Really Means
AUC stands for Area Under the (ROC) Curve. It compresses the whole curve — every threshold's trade-off at once — into a single number between 0 and 1. AUC = 1.0 means a perfect model; AUC = 0.5 means the model performs like random guessing; AUC below 0.5 means it's doing worse than chance.
Because our ROC curve is a staircase made of straight horizontal and vertical segments, we can compute the exact area using nothing more than the geometry of rectangles and trapezoids — no calculus needed. Walking along the curve from point to point and summing up each strip's area:
points = [(0.00, 0.00), (0.00, 0.25), (0.00, 0.50), (0.25, 0.50),
(0.25, 0.75), (0.50, 0.75), (0.50, 1.00), (0.75, 1.00), (1.00, 1.00)]
def auc_trapezoid(points):
area = 0.0
for i in range(1, len(points)):
x1, y1 = points[i - 1]
x2, y2 = points[i]
area += (x2 - x1) * (y1 + y2) / 2
return area
print(auc_trapezoid(points)) # 0.8125
Trace the arithmetic: most segments have x1 = x2 (a vertical rise), contributing zero width and hence zero area. The segments that actually move sideways are (0,0.5)→(0.25,0.5), width 0.25 at height 0.5, area 0.125; (0.25,0.75)→(0.5,0.75), width 0.25 at height 0.75, area 0.1875; (0.5,1.0)→(0.75,1.0), width 0.25 at height 1.0, area 0.25; and (0.75,1.0)→(1.0,1.0), width 0.25 at height 1.0, area 0.25. Sum: 0.125 + 0.1875 + 0.25 + 0.25 = 0.8125.
There's a second, completely independent way to arrive at the same 0.8125, and it's the definition that actually explains why AUC is useful: AUC is the probability that the model ranks a randomly chosen positive example higher than a randomly chosen negative example. With 4 suspicious bags and 4 safe bags, there are 4 × 4 = 16 possible (suspicious, safe) pairs. Check each pair — does the suspicious bag's score beat the safe bag's score?
P1 (0.95) and P2 (0.85) beat all four safe bags — that's 4 + 4 = 8 correct pairs. P3 (0.60) beats N2, N3, N4 but loses to N1 (0.70) — 3 correct pairs. P4 (0.40) beats only N3 and N4, losing to both N1 (0.70) and N2 (0.50) — 2 correct pairs. Total correctly-ordered pairs: 8 + 3 + 2 = 13 out of 16. AUC = 13/16 = 0.8125 — the identical number, arrived at by pure ranking logic instead of geometry. This is why AUC is often described as measuring a model's ranking quality, not just its accuracy at one cutoff: it asks, across every possible comparison, how often does the model correctly judge that "this one is more suspicious than that one"?
Two Misconceptions Worth Killing
Misconception 1: "A model with 95% accuracy is obviously a good model." Not necessarily — and this is precisely where ROC/AUC earns its keep. Suppose only 2% of bags at a station are ever actually suspicious. A lazy model that predicts "safe" for every single bag would be 98% accurate while catching zero real threats — TPR = 0. Accuracy hides this failure because it's dominated by the huge number of true negatives. TPR and FPR don't get fooled this way, because each is computed only within its own actual class (TPR only among true positives, FPR only among true negatives), so a model that never predicts positive gets exposed immediately with TPR = 0. This is exactly why ROC-based evaluation is standard for problems like fraud detection or rare-disease screening, where the "interesting" class is a tiny minority.
Misconception 2: "AUC = 0.5 means the model is right half the time, like 50% accuracy." These are different quantities measuring different things. AUC = 0.5 means the model's suspicion scores carry no real information for telling suspicious bags apart from safe ones — ranking a random positive above a random negative is a coin flip. A model's actual accuracy at some chosen threshold could still come out to any number depending on how many bags are truly positive versus negative in your data; accuracy is threshold- and class-balance-dependent, while AUC deliberately is not. Don't read AUC as "percent correct" — read it as "how well does this model separate the two classes, across all thresholds at once."
Choosing a Threshold Is a Real-World Decision
ROC and AUC don't hand you a threshold — they show you every option and let you pick with your eyes open, based on what each type of mistake actually costs. For the metro scanner, a missed threat (false negative) is far more dangerous than a false alarm (false positive), so operators deliberately choose a low threshold, accepting more false alarms to push TPR as close to 1.0 as possible — accepting the lower-left region of the curve's lower thresholds, near the top where TPR is high, even if FPR climbs too.
Compare that to an email spam filter, where wrongly blocking a genuine, important message (a false positive) is often considered worse than letting one spam email through (a false negative) — so spam filters typically use a higher threshold, tolerating a few missed spam messages to keep false positives low. Same mathematics, same shape of trade-off, opposite choice of threshold — because the costs of the two error types are different in the two situations. This is also, incidentally, why "sensitivity" and "specificity" (1 − FPR) are the exact terms doctors use when discussing medical test cutoffs: a screening test for a serious disease is deliberately set to a low threshold (high sensitivity, more false alarms) precisely because a missed case is far costlier than an unnecessary follow-up test.
Practice: Test Yourself
- A model scores 5 truly positive examples as 0.9, 0.8, 0.7, 0.3, 0.2, and 5 truly negative examples as 0.6, 0.5, 0.4, 0.1, 0.05. At threshold = 0.45, list which examples are flagged positive, then compute TP, FP, TN, FN, TPR, and FPR.
- Using the same 10 scores from question 1, count the correctly-ordered (positive, negative) pairs to compute AUC via the ranking method. There are 5 × 5 = 25 total pairs.
- A hospital's rare-disease test has 99% accuracy but AUC = 0.52 on a population where only 1% of people actually have the disease. Explain, using what you learned above, how both numbers can be true at once, and which one you should trust more to judge the test.
- Two models, A and B, have AUC = 0.90 and AUC = 0.75 respectively. Model B, however, has higher accuracy at the specific threshold your application currently uses. Is it possible for both facts to be true simultaneously? Explain why or why not.
Answer key: (1) Flagged: 0.9, 0.8, 0.7, 0.6, 0.5 → among these, three are truly positive (0.9,0.8,0.7) and two are truly negative (0.6,0.5), so TP=3, FP=2; the unflagged 0.3,0.2 are truly positive (FN=2) and 0.4,0.1,0.05 are truly negative (TN=3). TPR = 3/5 = 0.6, FPR = 2/5 = 0.4. (2) Positives 0.9,0.8,0.7 each beat all 5 negatives (3×5=15 correct pairs); 0.3 beats only 0.1,0.05 (2 pairs); 0.2 beats only 0.1,0.05 (2 pairs). Total = 15+2+2 = 19/25, AUC = 0.76. (3) The 99% accuracy comes almost entirely from correctly saying "no disease" to the 99% of healthy people — even a test that never correctly identifies a sick person could still score close to 99% accuracy this way. AUC = 0.52, close to 0.5, reveals the test barely ranks sick people above healthy ones at all — it is nearly useless for its actual job. Trust AUC here. (4) Yes — AUC compares ranking quality across every threshold, while accuracy is measured at one specific threshold. Model A could rank cases better overall (higher AUC) while Model B happens to be better-calibrated or luckier at that one particular cutoff. They are not contradictory; they are answering different questions.
Summary
A classifier that outputs a score, not just a label, needs a threshold to become a decision — and every threshold trades true positives for false positives differently. The ROC curve plots this entire trade-off by sweeping the threshold and recording (FPR, TPR) at each step: it starts at (0,0), ends at (1,1), and a good model bows toward the top-left corner, above the diagonal line that represents random guessing. AUC compresses that whole curve into one number — the area beneath it — which equals the probability that the model ranks a random true positive above a random true negative. Because AUC evaluates every threshold at once and is computed separately within each true class, it stays meaningful even when accuracy is misleadingly inflated by class imbalance, and AUC = 0.5 always signals "no better than chance" regardless of what accuracy happens to read. Choosing where to actually set the threshold, though, is not a math question — it is a judgment about which kind of mistake, a false alarm or a missed case, costs more in your specific situation.