The ROC Curve and AUC
What you’ll learn
- how an ROC curve is constructed, one threshold at a time
- the full curve traced by hand from the ten-row example
- the probabilistic meaning of AUC — it is not “accuracy over all thresholds”
- why AUC = 0.5 is chance and AUC below 0.5 means something useful
- exactly when ROC misleads, and why precision-recall is the right choice there
- how to pick between ROC-AUC and average precision, with a rule
Intuition
Precision and recall describe one operating point. The ROC curve describes all of them at once.
Slide the threshold from 1 down to 0. At the top, nothing is predicted positive: no true positives, no false positives, and the curve starts at . At the bottom, everything is predicted positive and the curve ends at . In between, every threshold contributes a point.
The shape of the path between those corners is the model’s ranking ability. A model that scores every positive above every negative goes straight up and then straight across — through the top-left corner. A model that ranks randomly follows the diagonal.
flowchart LR A["Sort all predictions
by score, descending"] --> B["Walk down the list"] B --> C{"Is this row
actually positive?"} C -->|yes| U["Step up
TPR increases"] C -->|no| R["Step right
FPR increases"] U --> B R --> B
The math
Both denominators are row totals of the confusion matrix — the number of actual positives and the number of actual negatives. That single fact explains everything ROC does well and everything it does badly: neither axis depends on how many positives there are relative to negatives, so the curve is invariant to class balance.
What AUC actually measures
The area under the ROC curve has an exact probabilistic interpretation:
AUC is the probability that a randomly chosen positive is ranked above a randomly chosen negative. It is a measure of ranking, not of classification. It never looks at your threshold, which is exactly why it is useful for comparing models before one is chosen — and why it cannot tell you whether a model is deployable.
| AUC | Meaning |
|---|---|
| 1.0 | Every positive ranks above every negative |
| 0.9 | Strong separation |
| 0.7 | Modest, often still useful |
| 0.5 | Chance — the diagonal |
| < 0.5 | Ranking is inverted; flip the sign and you have a good model |
That last row is worth remembering. An AUC of 0.2 is not a bad model, it is a good model with its labels or its sign the wrong way round.
Worked example by hand
The ten-row example, sorted by score. Five positives, five negatives.
| rank | true | score | running TP | running FP | TPR | FPR |
|---|---|---|---|---|---|---|
| — | — | (none flagged) | 0 | 0 | 0.0 | 0.0 |
| 1 | + | 0.95 | 1 | 0 | 0.2 | 0.0 |
| 2 | + | 0.88 | 2 | 0 | 0.4 | 0.0 |
| 3 | + | 0.76 | 3 | 0 | 0.6 | 0.0 |
| 4 | − | 0.70 | 3 | 1 | 0.6 | 0.2 |
| 5 | − | 0.62 | 3 | 2 | 0.6 | 0.4 |
| 6 | + | 0.58 | 4 | 2 | 0.8 | 0.4 |
| 7 | + | 0.31 | 5 | 2 | 1.0 | 0.4 |
| 8 | − | 0.24 | 5 | 3 | 1.0 | 0.6 |
| 9 | − | 0.13 | 5 | 4 | 1.0 | 0.8 |
| 10 | − | 0.05 | 5 | 5 | 1.0 | 1.0 |
Each row is one threshold. A positive steps the curve up by ; a negative steps it right by .
Area by geometry. The curve is a staircase, so the area is a sum of rectangles — one per rightward step, whose height is the TPR at that moment:
Area by counting pairs. The probabilistic definition gives the same answer directly. There are positive/negative pairs; count how many the model ranks correctly:
| positive score | negatives it beats (0.70, 0.62, 0.24, 0.13, 0.05) | count |
|---|---|---|
| 0.95 | all five | 5 |
| 0.88 | all five | 5 |
| 0.76 | all five | 5 |
| 0.58 | 0.24, 0.13, 0.05 | 3 |
| 0.31 | 0.24, 0.13, 0.05 | 3 |
| 21 |
Two completely different routes, the same number — and roc_auc_scoreroc_auc_score returns exactly 0.84.
The pair-counting view is the one worth carrying: AUC is the fraction of positive/negative pairs
your model gets in the right order.
See it move
The sketch draws both routes at once. On the left, the ranked list is consumed one row at a time and the staircase is built step by step — up for a positive, right for a negative, with the rectangle under each rightward step shaded as it is added. On the right, the same event is scored as pairs: the grid fills in green for each correctly ordered pair. The two totals stay equal throughout.
The four red cells in the grid are the model’s entire mistake: score 0.58 and 0.31 each lose to the negatives at 0.70 and 0.62. Twenty-one of twenty-five pairs are ordered correctly, and the staircase area is the same 0.84 because each rectangle is exactly the set of pairs resolved by that rightward step. The equivalence is not a coincidence to memorise; it is the same count, grouped two different ways.
On real data
Reading the plot
Left panel — a healthy comparison.
- Logistic regression reaches AUC 0.992, Naive Bayes 0.974. Both are strong; the ranking between them is what AUC is for.
- The random classifier lands on 0.496 — the diagonal, within sampling noise of exactly 0.5.
- The steep initial rise matters most: it means the highest-scoring predictions are almost all genuine positives, which is what you exploit when you can only act on the top few.
Right panel — the failure mode.
- Positive rate is 1%. ROC-AUC reports 0.855, which sounds like a good model.
- Average precision reports 0.133.
- Both are computed from identical predictions. The disagreement is not a bug; it is the two metrics answering different questions.
Why ROC is optimistic under imbalance
FPR has in its denominator, and under heavy imbalance is enormous. With 3,960 negatives and 40 positives, 200 false positives give:
which barely moves the curve. But those same 200 false positives destroy precision:
ROC absorbs false positives into a huge negative pool; precision does not, because it never counts true negatives at all. When the positive class is rare, precision is measuring the thing you actually care about — is a flag worth investigating? — and ROC is not.
Multiclass ROC
ROC is defined for binary problems. For classes, compute one curve per class one-versus-rest and average:
from sklearn.datasets import load_digits
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
digits = load_digits()
X_tr, X_te, y_tr, y_te = train_test_split(
digits.data, digits.target, test_size=0.3, random_state=0, stratify=digits.target
)
model = make_pipeline(StandardScaler(), LogisticRegression(max_iter=5000))
model.fit(X_tr, y_tr)
proba = model.predict_proba(X_te)
macro = roc_auc_score(y_te, proba, multi_class="ovr", average="macro")
weighted = roc_auc_score(y_te, proba, multi_class="ovr", average="weighted")
print(f"macro OvR AUC {macro:.4f}") # 0.9992
print(f"weighted OvR AUC {weighted:.4f}") # 0.9992from sklearn.datasets import load_digits
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
digits = load_digits()
X_tr, X_te, y_tr, y_te = train_test_split(
digits.data, digits.target, test_size=0.3, random_state=0, stratify=digits.target
)
model = make_pipeline(StandardScaler(), LogisticRegression(max_iter=5000))
model.fit(X_tr, y_tr)
proba = model.predict_proba(X_te)
macro = roc_auc_score(y_te, proba, multi_class="ovr", average="macro")
weighted = roc_auc_score(y_te, proba, multi_class="ovr", average="weighted")
print(f"macro OvR AUC {macro:.4f}") # 0.9992
print(f"weighted OvR AUC {weighted:.4f}") # 0.9992macromacro treats every class equally and weightedweighted scales by support — the same choice as for
multiclass F1, and the same advice: with imbalanced classes, macromacro is usually the honest one.
ROC-AUC versus average precision
| ROC-AUC | Average precision | |
|---|---|---|
| Axes | TPR against FPR | Precision against recall |
| Baseline | Always 0.5 | The positive rate |
| Uses true negatives | Yes, in FPR | No |
| Invariant to class balance | Yes | No — deliberately |
| Best for | Ranking quality overall | Rare-positive problems |
| Interpretation | P(positive ranks above negative) | Precision averaged over recall levels |
The invariance is genuinely a feature and genuinely a bug, depending on the question. If you are comparing two models on the same data, invariance is helpful. If you are deciding whether a fraud model is worth deploying, invariance hides the fact that 87% of its alerts are wrong.
Pitfalls
What does an AUC of 0.84 mean, precisely?
AUC is P(score of a random positive > score of a random negative). In the ten-row example, 21 of 25 pairs are correctly ordered, giving exactly 0.84.
Show answer
B — 84% of randomly chosen positive/negative pairs are ranked in the correct order — AUC is P(score of a random positive > score of a random negative). In the ten-row example, 21 of 25 pairs are correctly ordered, giving exactly 0.84.
Your model has ROC-AUC 0.86 and average precision 0.13 on a dataset with 1% positives. Which number should drive the decision?
FPR divides by a huge pool of true negatives, so hundreds of false positives barely move the ROC curve. Precision has no true-negative term and therefore feels every one of them.
Show answer
B — Average precision, because with a rare positive class it reflects how often a flag is actually worth investigating — FPR divides by a huge pool of true negatives, so hundreds of false positives barely move the ROC curve. Precision has no true-negative term and therefore feels every one of them.
A colleague reports AUC 0.22. What is the most likely explanation?
AUC below 0.5 means positives are systematically scored below negatives. Flipping the sign of the score gives 1 - 0.22 = 0.78. Check the label encoding before retraining.
Show answer
B — The ranking is inverted — a label or sign flip would turn it into 0.78 — AUC below 0.5 means positives are systematically scored below negatives. Flipping the sign of the score gives 1 - 0.22 = 0.78. Check the label encoding before retraining.
Why does the ROC curve start at (0,0) and end at (1,1) for every model?
The endpoints are forced by the extremes of the threshold sweep, for any model at all. Only the path between them carries information.
Show answer
B — At a threshold above every score nothing is flagged, so both rates are 0; below every score everything is flagged, so both are 1 — The endpoints are forced by the extremes of the threshold sweep, for any model at all. Only the path between them carries information.
🧪 Try It Yourself
Exercise 1 – Compute one point on the curve
Exercise 2 – Trace the whole curve
Exercise 3 – AUC by counting pairs
Exercise 4 – Spot a near-random classifier
Exercise 5 – Watch ROC and AP disagree
Recap
- The ROC curve sweeps every threshold, plotting TPR against FPR; both denominators are row totals, so the curve is invariant to class balance.
- Each positive steps the curve up, each negative steps it right.
- AUC is — a ranking statistic. The ten-row example gives 21 of 25 correctly ordered pairs, so AUC = 0.84 by geometry and by counting.
- 0.5 is chance; below 0.5 means the ranking is inverted, not that the model is worthless.
- Under heavy imbalance, ROC is optimistic because FPR hides false positives in a huge negative pool. The same predictions scored 0.855 by ROC and 0.133 by average precision.
- Balanced or symmetric costs → ROC-AUC. Rare positives you act on → average precision.
Exercise 6 – The same 0.84, computed three ways
Next
Phase 4 ends here. Continue to Phase 5 - Ensemble Learning — where the instability of a single decision tree, noted at the end of the trees page, becomes the foundation of the strongest models in classical machine learning.
If this helped you, consider buying me a coffee ☕
Buy me a coffeeWas this page helpful?
Let us know how we did
