Index Ai Academy
Phase 3 · Machine learning · Evaluation

ROC curves and AUC

Judge a classifier across every threshold.

About 30 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

Precision and recall change when you move the threshold. To compare two models before anyone has picked a threshold, you need a score that covers every threshold at once. AUC is that score. It's the number you've seen quoted all through this phase. It also has a plain meaning: how often the model ranks a real case above a non-case. That idea carries over to ranking jobs everywhere. Think of lead scoring, search results, or checking whether an AI grader agrees with people.

What you'll be able to do

  • Build a ROC curve from a classifier's scores across every threshold
  • Interpret AUC as the chance a random positive is ranked above a random negative
  • Use precision-recall curves when positives are rare
  • Know what AUC doesn't tell you: the threshold, and whether probabilities are calibrated

What it covers

  1. Every threshold at once
  2. The ROC curve
  3. What AUC means
  4. Worked example: draw a ROC curve by hand
  5. When positives are rare: precision-recall curves
  6. What AUC doesn't tell you

Every threshold at once

A churn model gives each customer a score: its guess at the chance they leave. Picture the customers standing in a line, riskiest at the front. A threshold is a rope you stretch across that line. Everyone in front of the rope gets flagged. Move the rope back and you flag more people.

In the last lesson you counted four kinds of result at one threshold. A true positive (TP) is a churner you flagged. A false negative (FN) is a churner you missed. A false positive (FP) is a happy customer you flagged by mistake. A true negative (TN) is a happy customer you left alone.

At each rope position, work out two rates:

  • True positive rate (TPR): of the customers who really churn, the share you flagged. It's the same thing as recall.
  • False positive rate (FPR): of the customers who stay, the share you flagged by mistake.
TPR=TPTP+FNFPR=FPFP+TN\text{TPR} = \frac{TP}{TP + FN} \qquad \text{FPR} = \frac{FP}{FP + TN}

In plain words: TPR is churners caught divided by all churners. FPR is happy customers flagged divided by all happy customers. Both run from 0 to 1. You want TPR high and FPR low.

Here's a small example. You have 10 customers: 4 churn and 6 stay. At one threshold you flag 4 people. Three are churners and one is a happy customer. So TP = 3, FN = 1, FP = 1 and TN = 5.

  • TPR = 3 / 4 = 0.75. You caught three of the four churners.
  • FPR = 1 / 6 ≈ 0.17. You bothered one of the six happy customers.
tp, fn, fp, tn = 3, 1, 1, 5
print(tp / (tp + fn), round(fp / (fp + tn), 2))
It runs right here in your browser, with nothing to install.

It prints 0.75 and 0.17.

Quick check
Five customers, threshold 0.5. What does this print?
probs = [0.9, 0.8, 0.6, 0.3, 0.2]   # chance of churning
truth = [1, 1, 0, 1, 0]             # 1 = really churns
flagged = [p >= 0.5 for p in probs]
tpr = sum(f and y == 1 for f, y in zip(flagged, truth)) / truth.count(1)
fpr = sum(f and y == 0 for f, y in zip(flagged, truth)) / truth.count(0)
print(round(tpr, 2), fpr)
  1. 0.67 0.5
  2. 0.5 0.5
  3. 0.67 0.33
  4. 1.0 0.5
Show the answer

0.67 0.5

The first three customers are flagged. Two of the three churners are among them, so TPR = 2 / 3 ≈ 0.67. One of the two happy customers is flagged, so FPR = 1 / 2 = 0.5.

The ROC curve

Start with the rope at the very front. A very high threshold flags nobody, so FPR is 0 and TPR is

  1. Put the rope at the very back. A threshold of 0 flags everyone, so FPR is 1 and TPR is 1.

The ROC curve plots one point for every threshold in between. FPR goes along the bottom. TPR goes up the side. (ROC stands for "receiver operating characteristic", a name left over from radar work in World War II. People almost always just say ROC.)

A useful model climbs steeply at first. Its riskiest-looking customers really are churners. So it catches many churners before it flags many happy customers.

Random guessing follows the diagonal line. On average it flags the same share of churners as of happy customers, so TPR stays close to FPR.

The cell below trains three models you've met before on the practice shop's customers:

  • logistic regression;
  • a decision tree limited to three levels of questions ("depth 3");
  • an "unpruned" tree, allowed to grow as deep as it likes.

For each model, it scores the test customers and draws the ROC curve. It also prints each model's AUC, which the next section explains. The gray dashed line is random guessing.

import pandas as pd
import requests
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.tree import DecisionTreeClassifier
from sklearn.metrics import roc_curve, roc_auc_score

df = pd.DataFrame(requests.get("https://api.gradient.test/v1/customers").json()["data"])
df["email_open_rate"] = df["email_open_rate"].fillna(0)
features = ["tenure_months", "orders_last_year", "avg_order_value", "days_since_last_order",
            "discount_share", "email_open_rate", "support_tickets", "trail_club"]
X_tr, X_te, y_tr, y_te = train_test_split(df[features], df["churned"], test_size=0.3,
                                          random_state=0, stratify=df["churned"])
models = {"logistic regression": make_pipeline(StandardScaler(), LogisticRegression()),
          "tree, depth 3": DecisionTreeClassifier(max_depth=3, random_state=0),
          "unpruned tree": DecisionTreeClassifier(random_state=0)}
for name, model in models.items():
    scores = model.fit(X_tr, y_tr).predict_proba(X_te)[:, 1]
    fpr, tpr, _ = roc_curve(y_te, scores)
    auc = roc_auc_score(y_te, scores)
    plt.plot(fpr, tpr, label=f"{name} (AUC {auc:.2f})")
    print(f"{name:<20} AUC {auc:.3f}")
plt.plot([0, 1], [0, 1], "--", color="gray", label="random guessing (AUC 0.50)")
plt.xlabel("false positive rate (customers who stay, flagged)")
plt.ylabel("true positive rate (churners caught)")
plt.legend()

Look at the unpruned tree. It gives only 0 or 1 as its score, so its "curve" has a single corner. It can't rank customers. It can only sort them into two piles.

Think of two judges at a baking contest. One scores each cake from 1 to 10. The other only says "pass" or "fail". Only the first judge can tell you which cakes are best. Models that give graded scores trace a smoother curve. Logistic regression gives almost every customer a different score. The depth-3 tree sits in between. It gives only a few different scores, so its curve has a few corners.

What AUC means

AUC is short for "area under the curve": the area under the ROC curve. It runs from 0.5 for random guessing to 1.0 for a perfect model. (A model below 0.5 ranks things backwards.)

AUC also has a direct meaning. Pick one churner at random and one customer who stays. Ask the model which of the two is riskier. AUC is the chance it picks the churner. A tie counts as half right.

Think of a tournament where every churner plays every happy customer once. The model is the referee, and the higher score wins. AUC is the churners' win rate.

Here's a small example. Two churners scored 0.9 and 0.6. Three happy customers scored 0.8, 0.4 and 0.2. That makes 2 × 3 = 6 pairs.

  • The churner at 0.9 beats all three happy customers.
  • The churner at 0.6 beats 0.4 and 0.2, but loses to 0.8.

That's 5 wins out of 6 pairs, so AUC = 5 / 6 ≈ 0.833.

churners = [0.9, 0.6]
stayers = [0.8, 0.4, 0.2]
wins = sum(c > s for c in churners for s in stayers)
pairs = len(churners) * len(stayers)
print(wins, "of", pairs, "pairs ->", round(wins / pairs, 3))

It prints 5 of 6 pairs -> 0.833.

So AUC measures ranking: does the model put the right people near the front of the line? That's exactly what you need when the plan is "contact the 50 riskiest customers".

Go deeper Why the area and the win rate are the same number

Walk down the line from the riskiest customer to the safest. Each time you pass a churner, the curve steps up by one share of the churners. Each time you pass a happy customer, it steps right by one share of the happy customers.

Look at one rightward step. Its height is the share of churners you've already passed. Those are exactly the churners ranked above that happy customer. So the area under that step counts the pairs that customer ranks below a churner, as a share of all pairs.

Add up every step and you get the share of all (churner, happy customer) pairs ranked correctly. When a churner and a happy customer tie, the curve moves diagonally, and the area under that slanted step counts the tie as half.

The cell below checks this on the real churn test set. It lines up every churner's score against every happy customer's score in one grid. (pos[:, None] > neg[None, :] builds that grid: one row per churner, one column per happy customer.) It takes the share of pairs ranked correctly, adds half the ties, and prints it next to scikit-learn's roc_auc_score. The two numbers match.

import numpy as np
import pandas as pd
import requests
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import roc_auc_score

df = pd.DataFrame(requests.get("https://api.gradient.test/v1/customers").json()["data"])
df["email_open_rate"] = df["email_open_rate"].fillna(0)
features = ["tenure_months", "orders_last_year", "avg_order_value", "days_since_last_order",
            "discount_share", "email_open_rate", "support_tickets", "trail_club"]
X_tr, X_te, y_tr, y_te = train_test_split(df[features], df["churned"], test_size=0.3,
                                          random_state=0, stratify=df["churned"])
scores = make_pipeline(StandardScaler(), LogisticRegression()).fit(X_tr, y_tr).predict_proba(X_te)[:, 1]

pos, neg = scores[y_te.values == 1], scores[y_te.values == 0]
ranked_right = (pos[:, None] > neg[None, :]).mean() + 0.5 * (pos[:, None] == neg[None, :]).mean()
print(f"{len(pos) * len(neg)} (churner, stayer) pairs; ranked correctly: {ranked_right:.3f}")
print(f"roc_auc_score: {roc_auc_score(y_te, scores):.3f}")
Try it now

The curve is every threshold at once, and AUC is the area under it. Lower the model's skill to 0 and it falls onto the diagonal: no better than chance.

Quick check
Two churners and two happy customers. One pair is a tie. What does this print?
churners = [0.7, 0.3]
stayers = [0.5, 0.3]
pairs = [(c, s) for c in churners for s in stayers]
wins = sum(1 if c > s else 0.5 if c == s else 0 for c, s in pairs)
print(wins / len(pairs))
  1. 0.5
  2. 0.625
  3. 0.6
  4. 0.75
Show the answer

0.625

There are four pairs. 0.7 beats 0.5 and 0.3, so that's two wins. 0.3 loses to 0.5. 0.3 against 0.3 is a tie, worth half. Total 2.5 / 4 = 0.625.

Worked example

Worked example: draw a ROC curve by hand

A lead-scoring model scores six sales leads. Three of them bought (1) and three didn't (0). The scores are already sorted from highest to lowest. Use each score as a threshold, record the (FPR, TPR) point, then find the area under the points. Check that it matches the win rate over all (buyer, non-buyer) pairs.

scores    = [0.95, 0.85, 0.7, 0.5, 0.4, 0.1]   # model's chance each lead buys
converted = [1,    1,    0,   1,   0,   0]     # 1 = really bought
P, N = converted.count(1), converted.count(0)

points = [(0.0, 0.0)]
for t in scores:                               # already sorted, highest first
    tp = sum(1 for s, y in zip(scores, converted) if s >= t and y == 1)
    fp = sum(1 for s, y in zip(scores, converted) if s >= t and y == 0)
    points.append((fp / N, tp / P))
print([(round(x, 2), round(y, 2)) for x, y in points])

area = sum((x2 - x1) * (y1 + y2) / 2 for (x1, y1), (x2, y2) in zip(points, points[1:]))
pos = [s for s, y in zip(scores, converted) if y == 1]
neg = [s for s, y in zip(scores, converted) if y == 0]
wins = sum(a > b for a in pos for b in neg)
print(round(area, 3), round(wins / (P * N), 3))
In the lesson you can change this and run it.

Walk down the list. The first two leads (0.95 and 0.85) both bought, so the curve climbs straight up to TPR 0.67 without moving right. The lead at 0.7 didn't buy, so the curve steps right to FPR 0.33. The lead at 0.5 bought, so it climbs to TPR 1.0. The last two didn't buy, so it runs right to (1.0, 1.0).

The area adds up slices. Each slice is its width times the average of its two heights. The area comes to 8 / 9 ≈ 0.889.

Now count pairs. There are 3 × 3 = 9 (buyer, non-buyer) pairs. The buyers at 0.95 and 0.85 beat all three non-buyers: 6 wins. The buyer at 0.5 beats 0.4 and 0.1 but loses to 0.7: 2 more wins. That's 8 / 9 ≈ 0.889 again. Two very different routes give the same AUC.

When positives are rare: precision-recall curves

The false positive rate divides by all the negatives. When negatives vastly outnumber positives, that can hide a lot of false alarms.

Take card fraud, where 1 transaction in 1,000 is fraud. Out of a million transactions, 1,000 are fraud and 999,000 are honest. Say a model catches 900 frauds. It also flags 5,000 honest ones.

  • TPR = 900 / 1,000 = 0.9. That looks great.
  • FPR = 5,000 / 999,000 ≈ 0.005. That looks tiny.
  • Precision = 900 / (900 + 5,000) ≈ 0.15. Fewer than 1 in 6 alerts is real fraud.
frauds, honest = 1000, 999000            # 1 fraud in every 1,000
caught, false_alarms = 900, 5000         # what the model flags
print("TPR", caught / frauds)
print("FPR", round(false_alarms / honest, 4))
print("precision", round(caught / (caught + false_alarms), 2))

It prints TPR 0.9, FPR 0.005 and precision 0.15. The ROC curve looks great while the fraud team drowns in false alarms.

A precision-recall curve shows this problem. It plots precision against recall for every threshold. Its summary number is average precision (average_precision_score).

Watch the baseline. Random guessing scores the positive rate, not 0.5. For the fraud example that's 0.001. Always compare against that number.

Rule of thumb:

  • Use ROC AUC to compare rankers on reasonably balanced data.
  • Use precision-recall when positives are rare and false alarms are what hurts.
Quick check
In a set of emails, 1 in 50 is spam. A spam model's average precision is 0.30. About what would random guessing score?
  1. 0
  2. 0.02
  3. 0.30
  4. 0.5
Show the answer

0.02

For average precision, random guessing scores the positive rate: 1 / 50 = 0.02. So 0.30 is about 15 times better than random, even though it sounds low.

What AUC doesn't tell you

  • The threshold. AUC averages over every threshold. You still pick one from what mistakes cost, as in the previous lesson.
  • Calibration. A model is calibrated when its probabilities match reality. Think of a weather forecast: on days it says "30% chance of rain", it should rain about 30% of the time. A model can rank perfectly while its probabilities are off. For example, everyone it calls "0.9" might churn only half the time. Sometimes you use the probabilities themselves, for example to work out expected lost revenue. Then check calibration: among customers given about 0.3, about 30% should churn. scikit-learn's CalibratedClassifierCV can fix a model that ranks well but is miscalibrated.
  • Whether it ranks well where it matters. Two models with the same AUC can differ at the very top of the list. That's where your 50 calls go.

Beyond churn

AUC works any time a system gives scores and you have the true labels. A few places you'll meet it:

  • a lead-scoring model: are buyers scored above non-buyers?
  • a search or retrieval system: are relevant documents scored above irrelevant ones?
  • an LLM judge's 1–10 ratings, checked against human pass/fail labels.
Quick check
A lead-scoring model has AUC 0.85. What does that mean?
  1. 85% of its predictions are correct
  2. It catches 85% of converting leads
  3. 85% of the leads it flags will convert
  4. For a random converting lead and a random non-converting lead, it scores the converter higher 85% of the time
Show the answer

For a random converting lead and a random non-converting lead, it scores the converter higher 85% of the time

AUC is a ranking probability; the other options are accuracy, recall and precision.

Practice what you just read

  • 6 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 5 review cards for spaced repetition

Create a free account

Try a quiz question

What AUC does a model that assigns random scores get, on average?

  1. 0.5
  2. 0
  3. 1.0
  4. The positive rate

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson