Index Ai Academy
Phase 3 · Machine learning · Unsupervised

Capstone: churn prediction

Predict which customers will leave, and measure it properly.

About 45 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

This is the project the whole of Phase 3 has been building toward. It's also one that marketing teams really ask for: "Who's about to leave, and is it worth trying to keep them?" A good answer uses everything from this phase. You'll need clean splits, baselines, pipelines, cross-validation, the right metrics, costs, and an honest account of the model's limits. The result is a plan a marketing director can act on, not only a model.

What you'll be able to do

  • Run a complete ML project: framing, baselines, model comparison, one honest test
  • Translate model scores into a campaign: lift, a cost-based contact list and its expected value
  • Explain what drives the predictions and check whether the probabilities can be trusted
  • State what a churn model can't tell you, and how to test a campaign properly

What it covers

  1. The brief
  2. Step 2: from scores to a campaign
  3. The ten-customer campaign in Python
  4. Step 2, for real: one look at the test set
  5. Step 3: what drives churn, and can the probabilities be trusted?
  6. Step 4: what the model can't tell you

The brief

Picture a café owner with a stack of "we miss you" vouchers. Each voucher costs money. Handing one to every customer wastes most of them, because most customers were coming back anyway. The owner wants to give them only to people who are drifting away, and only when it pays.

An online store has the same problem. Its retention team can send a personal offer that costs $15 per customer. Reaching a customer who was about to churn (stop buying) is worth $80 on average, because some of them stay and keep buying. Here a customer counts as churned if they stopped buying in the following six months.

The team has three questions:

  1. Can we predict churn better than the rule we use today, "anyone who hasn't ordered in 90 days"?
  2. Who should get the offer, and what's it worth?
  3. What drives churn, and how far can we trust the numbers?

You'll answer them in four steps, using 600 customers. Each step reuses a tool from Phase 3.

Step 1: split, baselines, candidates

First, lock a quarter of the customers away as the test set, before you look at anything. Think of it as the final exam. You'll score your chosen model on it once, at the end. The other three quarters are the development set, where you try ideas. The split is stratified: the test set gets the same share of churners as the whole dataset.

Next, measure what you have to beat. There are two baselines:

  • Today's 90-day rule. You score it with precision and recall. Precision asks: of the customers it flags, what share really churn? Recall asks: of all the churners, what share does it flag?
  • Recency alone. Rank customers by days since their last order, longest first. Score that ranking with AUC: the chance that a random churner is ranked above a random customer who stayed. An AUC of 0.5 is a coin flip, and 1.0 is perfect.
Quick check
Why lock the test set away before looking at anything?
  1. So none of your choices can be influenced by it, which keeps the final score honest
  2. To make training faster
  3. To save memory
  4. Because test data is lower quality
Show the answer

So none of your choices can be influenced by it, which keeps the final score honest

Once a set has shaped a decision, it's no longer a fair test of that decision.

Then try three candidate models: logistic regression, a random forest and gradient boosting. Each one sits in a pipeline with the same cleaning steps. You score each with 5-fold cross-validation on the development set. That means splitting the development set into five parts, training on four and scoring on the fifth, five times over. Each model gets five AUC scores, and the code prints their average.

Go deeper What the cleaning steps and settings do
  • prep is a ColumnTransformer. It cleans number columns and text columns in different ways.
  • Number columns: gaps are filled with the column's median, its middle value. add_indicator=True adds a 0/1 column that marks where a value was missing. Then StandardScaler puts every column on a similar scale.
  • Text columns (region, channel, top category): blanks become "unknown". Then each category becomes its own 0/1 column. handle_unknown="ignore" means a category never seen in training won't crash the model.
  • trail_club is already 0 or 1: it records loyalty-club membership.
  • C=0.1 gives logistic regression fairly strong regularization. It keeps the weights small.
  • min_samples_leaf=10 makes every leaf of the forest's trees hold at least 10 customers. max_depth=2 limits each boosted tree to two levels of questions. Both rein in overfitting.
  • The cleaning lives inside the pipeline. So cross-validation re-learns medians and scales on each training fold, and no information leaks in from the fold being scored.
import pandas as pd
import requests
from sklearn.compose import ColumnTransformer
from sklearn.ensemble import HistGradientBoostingClassifier, RandomForestClassifier
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import precision_score, recall_score, roc_auc_score
from sklearn.model_selection import cross_val_score, train_test_split
from sklearn.pipeline import Pipeline, make_pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

df = pd.DataFrame(requests.get("https://api.gradient.test/v1/customers").json()["data"])
numeric = ["tenure_months", "orders_last_year", "avg_order_value", "days_since_last_order",
           "discount_share", "email_open_rate", "support_tickets", "trail_club"]
categorical = ["region", "channel", "top_category"]
X_dev, X_test, y_dev, y_test = train_test_split(df[numeric + categorical], df["churned"], test_size=0.25,
                                                random_state=42, stratify=df["churned"])
print(f"development set {len(X_dev)}, test set {len(X_test)} ({int(y_test.sum())} churners), churn rate {y_dev.mean():.0%}")

rule = (X_dev["days_since_last_order"] > 90).astype(int)
print(f"\ntoday's 90-day rule: flags {rule.mean():.0%} of customers, precision {precision_score(y_dev, rule):.2f}, "
      f"recall {recall_score(y_dev, rule):.2f}")
print(f"recency alone as a score: AUC {roc_auc_score(y_dev, X_dev['days_since_last_order']):.3f}\n")

prep = ColumnTransformer([
    ("num", make_pipeline(SimpleImputer(strategy="median", add_indicator=True), StandardScaler()), numeric),
    ("cat", make_pipeline(SimpleImputer(missing_values="", strategy="constant", fill_value="unknown"),
                          OneHotEncoder(handle_unknown="ignore")), categorical),
])
candidates = {
    "logistic regression": LogisticRegression(C=0.1, max_iter=1000),
    "random forest": RandomForestClassifier(n_estimators=200, min_samples_leaf=10, random_state=0),
    "gradient boosting": HistGradientBoostingClassifier(max_depth=2, learning_rate=0.05, random_state=0),
}
for name, model in candidates.items():
    scores = cross_val_score(Pipeline([("prep", prep), ("model", model)]), X_dev, y_dev, cv=5, scoring="roc_auc")
    print(f"{name:<20} cross-validated AUC {scores.mean():.3f} ± {scores.std():.3f}")
It runs right here in your browser, with nothing to install.

Here's how to read the output:

  • The first line shows the split: 450 development customers and 150 test customers.
  • The 90-day rule catches about half the churners, so its recall is about 0.5. Just over a third of the customers it flags really churn, so its precision is a little above 0.33.
  • Recency on its own ranks customers fairly well, with an AUC of 0.74. That's a high bar for a single column.
  • The forest only just clears that bar, and the boosted trees only tie it.
  • Regularized logistic regression does clearly better.

The number after ± is the standard deviation of the five fold scores. It shows how much the score wobbled from fold to fold. Logistic regression leads the other two by about one of those wobbles. That makes it the likely winner, not a certain one. It's also the easiest model to explain to a marketing director, so it's the one to carry forward.

Quick check
In cross-validation, recency alone scores an AUC of 0.74. A new model scores 0.75 ± 0.04. What's the fair conclusion?
  1. The model must be overfitting
  2. AUC can't be used to compare a model with a single column
  3. It barely beats a one-column score, and the gap is smaller than the fold-to-fold wobble
  4. It's clearly better, because 0.75 is higher than 0.74
Show the answer

It barely beats a one-column score, and the gap is smaller than the fold-to-fold wobble

A lead of 0.01 inside a wobble of ±0.04 could be luck. That's why you measure simple baselines first: they show how much the model really adds.

Step 2: from scores to a campaign

The model gives every customer a churn probability. The team needs decisions, not probabilities. Three ideas turn one into the other. You'll try them on ten made-up customers first, then on the real test set.

Here are the ten, riskiest first. The letter pp stands for the model's churn probability for one customer. The last row shows who really churned (1) and who stayed (0).

customerABCDEFGHIJ
p0.700.450.300.220.150.120.080.050.040.02
churned1010000000

Lift: is the top of the list rich in churners?

Lift compares the churn rate in your top group with the churn rate of everyone.

lift=churn rate in the top groupchurn rate of everyone\text{lift} = \frac{\text{churn rate in the top group}}{\text{churn rate of everyone}}

In plain words: lift says how many times more churners your top group holds than a random group of the same size. A lift of 1 means the model does no better than chance.

Worked example: two of the ten customers churned, so the overall rate is 2 ÷ 10 = 20%. The top five (A to E) hold both churners, so their rate is 2 ÷ 5 = 40%. The lift is 40% ÷ 20% = 2.0. Your top five are twice as rich in churners as the customer base.

Who to contact: expected value

Phase 2 introduced expected value: each outcome's worth times its chance, added up. Saving a churner is worth $80. So an offer to a customer with churn probability pp is worth p×80p \times 80 dollars on average. It costs $15 whatever happens. Contact the customer when the first number beats the second:

p×80>15p \times 80 > 15

In plain words: send the offer when the customer's chance of leaving, times what keeping them is worth, comes to more than the offer costs.

Worked example: customer C has p=0.30p = 0.30. Then 0.30 × 80 = $24, which beats $15. So you contact C and expect to gain $9 on average. Customer E has p=0.15p = 0.15. Then 0.15 × 80 = $12, which is less than $15. You'd expect to lose $3, so you skip E.

The break-even point is 15 ÷ 80 = 0.1875. Anyone above about a 19% risk gets the offer. Here that's A, B, C and D.

Quick check
A customer has a 25% risk of leaving. Saving one is worth $80 and an offer costs $15. What's the expected gain from contacting them?
print(0.25 * 80 - 15)
  1. 20.0
  2. -5.0
  3. 15.0
  4. 5.0
Show the answer

5.0

0.25 × 80 is $20 of expected value, and the offer costs $15, which leaves $5, printed as 5.0.

Net value: what would the plan have earned?

Once you know who really churned, you can add up the result. Every churner you reached earns $80. Every offer costs $15, whoever gets it:

  • net value = 80 × churners reached − 15 × offers sent.

Worked example: the plan contacts A to D and reaches two churners, A and C. That's 2 × 80 − 4 × 15 = 160 − 60 = $100. Contacting all ten reaches the same two churners but sends ten offers: 2 × 80 − 10 × 15 = 160 − 150 = $10. Most of those extra offers went to people who were staying anyway.

Watch out: this counts every reached churner as a full $80. That's the team's assumption, not a measured fact. Step 4 shows how to measure it.

Try it now

A good model isn't the goal. A campaign that makes money is. Try lowering precision, or raising the cost per contact, and find where it stops paying. The figures here are examples to play with.

Worked example

The ten-customer campaign in Python

The same three calculations in a few lines of plain Python: lift for the top five, the contact rule, and the net value of two plans.

probs   = [0.70, 0.45, 0.30, 0.22, 0.15, 0.12, 0.08, 0.05, 0.04, 0.02]   # riskiest first
churned = [1,    0,    1,    0,    0,    0,    0,    0,    0,    0]      # 1 = really left
OFFER, VALUE = 15, 80

base_rate = sum(churned) / len(churned)
top5_rate = sum(churned[:5]) / 5
print(f"base rate {base_rate:.0%}, top 5 rate {top5_rate:.0%}, lift {top5_rate / base_rate:.1f}x")

contact = [p * VALUE > OFFER for p in probs]
print("customers to contact:", contact.count(True))

def net_value(chosen):
    reached = sum(c for c, pick in zip(churned, chosen) if pick)
    return VALUE * reached - OFFER * sum(chosen)

print("model plan:", net_value(contact))
print("contact everyone:", net_value([True] * 10))
In the lesson you can change this and run it.

It prints a lift of 2.0x, four customers to contact, $100 for the model's plan and $10 for contacting everyone. Those are the numbers you worked out by hand. The next cell does the same with NumPy on the 150 test customers.

Quick check
The offer costs $15 and saving a churner is worth $80. What does this print?
p = 0.18
print(p * 80 > 15)
  1. True
  2. 14.4
  3. An error
  4. False
Show the answer

False

0.18 × 80 = 14.4, which is less than 15, so the test gives False. The break-even risk is 15 ÷ 80, about 0.19, so a customer at 0.18 doesn't get the offer.

Step 2, for real: one look at the test set

Now fit the chosen pipeline on the whole development set and score the test set, once. Why only once? If you kept checking the test set and adjusting, it would stop being a fair exam. Its score would look better than the model will do on new customers.

The code then answers the business questions directly:

  • Lift: order = np.argsort(-p) sorts the test customers riskiest first. The loop prints the churn rate and lift for the top 15, 30 and 50.
  • Who to contact: everyone whose expected value is positive, p × $80 > $15.
  • Value: realized is the net value from above. It compares the model's plan with contacting everyone and with today's 90-day rule.
import numpy as np
import pandas as pd
import requests
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
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 Pipeline, make_pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

df = pd.DataFrame(requests.get("https://api.gradient.test/v1/customers").json()["data"])
numeric = ["tenure_months", "orders_last_year", "avg_order_value", "days_since_last_order",
           "discount_share", "email_open_rate", "support_tickets", "trail_club"]
categorical = ["region", "channel", "top_category"]
X_dev, X_test, y_dev, y_test = train_test_split(df[numeric + categorical], df["churned"], test_size=0.25,
                                                random_state=42, stratify=df["churned"])
prep = ColumnTransformer([
    ("num", make_pipeline(SimpleImputer(strategy="median", add_indicator=True), StandardScaler()), numeric),
    ("cat", make_pipeline(SimpleImputer(missing_values="", strategy="constant", fill_value="unknown"),
                          OneHotEncoder(handle_unknown="ignore")), categorical),
])
model = Pipeline([("prep", prep), ("model", LogisticRegression(C=0.1, max_iter=1000))]).fit(X_dev, y_dev)
p = model.predict_proba(X_test)[:, 1]
y = y_test.to_numpy()
print(f"test AUC: {roc_auc_score(y, p):.3f}\n")

order = np.argsort(-p)
for k in [15, 30, 50]:
    rate = y[order[:k]].mean()
    print(f"top {k:>2} by risk: churn rate {rate:.0%} (lift {rate / y.mean():.1f}x), {int(y[order[:k]].sum())} of {int(y.sum())} churners")

OFFER, VALUE = 15, 80
def realized(contacted):
    return VALUE * int(y[contacted].sum()) - OFFER * int(contacted.sum())
plans = {
    "contact everyone": np.ones(len(y), dtype=bool),
    "today's 90-day rule": X_test["days_since_last_order"].to_numpy() > 90,
    "model: p x $80 > $15": p * VALUE > OFFER,
}
print()
for name, contacted in plans.items():
    print(f"{name:<22} contacts {int(contacted.sum()):>3}   net value on the test set: ${realized(contacted):>5}")

On the test customers, the model's top-ranked groups churn at well over twice the base rate. The expected-value plan earns far more than either alternative. Contacting everyone barely breaks even, because most offers go to customers who were staying anyway. It's the ten-customer story again, at full size.

Step 3: what drives churn, and can the probabilities be trusted?

Which columns does the model lean on?

To find out which ingredient matters in a recipe, you could swap one for something random and taste the result. Permutation importance does that with columns. It shuffles one column at a time in the test set, so that column's values land on the wrong customers. Then it measures how much the AUC drops. If the model leans on a column, scrambling it hurts. If the model ignores a column, the score barely moves. The code shuffles each column 10 times and averages the drops, to smooth out luck.

Does a 30% risk mean 30%?

The contact rule multiplies probabilities by dollars. So the probabilities have to mean what they say. Among customers given about a 30% risk, about 30% should churn. A model like that is called calibrated. A weather forecast works the same way: on days it says "30% chance of rain", it should rain about 30% of the time.

Ranking well and being calibrated are different things. A model could put customers in exactly the right order, with a high AUC, while every probability is twice too high.

The calibration table groups the test customers into bands by predicted risk: up to 10%, 10% to 20%, 20% to 40%, and above 40%. For each band it shows the number of customers, the average prediction, and the share who really churned.

import pandas as pd
import requests
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.inspection import permutation_importance
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline, make_pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

df = pd.DataFrame(requests.get("https://api.gradient.test/v1/customers").json()["data"])
numeric = ["tenure_months", "orders_last_year", "avg_order_value", "days_since_last_order",
           "discount_share", "email_open_rate", "support_tickets", "trail_club"]
categorical = ["region", "channel", "top_category"]
X_dev, X_test, y_dev, y_test = train_test_split(df[numeric + categorical], df["churned"], test_size=0.25,
                                                random_state=42, stratify=df["churned"])
prep = ColumnTransformer([
    ("num", make_pipeline(SimpleImputer(strategy="median", add_indicator=True), StandardScaler()), numeric),
    ("cat", make_pipeline(SimpleImputer(missing_values="", strategy="constant", fill_value="unknown"),
                          OneHotEncoder(handle_unknown="ignore")), categorical),
])
model = Pipeline([("prep", prep), ("model", LogisticRegression(C=0.1, max_iter=1000))]).fit(X_dev, y_dev)

imp = permutation_importance(model, X_test, y_test, scoring="roc_auc", n_repeats=10, random_state=0)
ranked = pd.Series(imp.importances_mean, index=X_test.columns).sort_values(ascending=False)
print("AUC lost when each column is shuffled:")
print(ranked.head(5).round(3).to_string())

p = model.predict_proba(X_test)[:, 1]
table = pd.DataFrame({"predicted": p, "churned": y_test.to_numpy()})
table["band"] = pd.cut(table["predicted"], [0, 0.1, 0.2, 0.4, 1.0])
print("\ncalibration on the test set:")
print(table.groupby("band", observed=True).agg(customers=("churned", "size"),
      predicted=("predicted", "mean"), actual=("churned", "mean")).round(2).to_string())

Recency and order frequency carry most of the signal, followed by loyalty-club membership. Customers who are drifting away show it in their buying first.

Calibration looks reasonable at the low and high ends. The middle band churned more than predicted. But that band holds only 26 customers, so the gap could be noise. Re-check it on more data before you use the probabilities for revenue forecasts.

Quick check
Suppose shuffling days_since_last_order drops the AUC by 0.10, and shuffling region drops it by 0.001. What does that tell you?
  1. The model's probabilities are miscalibrated
  2. The model relies heavily on recency and hardly at all on region
  3. Region causes churn more than recency does
  4. Recency should be removed from the model
Show the answer

The model relies heavily on recency and hardly at all on region

A big drop means the model leans on that column. It shows what the model uses, which isn't the same as what causes churn.

Step 4: what the model can't tell you

  • Who will leave isn't who an offer will save. Think of the café again. Some regulars who stop coming have moved away, and no voucher brings them back. Others would have come back anyway. The voucher only matters for the people in between. Finding the people an offer really changes is called uplift modeling, and it needs data from a past campaign.
  • So test the campaign. Take a random slice of the customers you'd contact, and don't send them the offer. This group is the holdout. Compare churn between the two groups. The difference measures what the campaign really did, and that number replaces the assumed $80.
  • Models change the world they predict. Once offers go out, next year's churn labels reflect the campaign. Retrain with that in mind, and keep the holdout group.
  • Watch for drift. Track the spread of risk scores and the real churn rate every month. Retrain when they move.
  • Use the data responsibly. Stick to features customers would expect a retailer to use. Check that the contact list isn't skewed against a group in ways you can't justify.

The one-paragraph summary

Here's what you'd send the marketing director:

We built a churn model on 600 customers, and it ranks risk better than our 90-day rule. On customers it had never seen, its highest-risk group churned at more than twice the average rate. Contacting customers whose expected value is positive would have earned about 70% more than the 90-day rule. It would also have earned far more than contacting everyone. Those figures assume a $15 offer and an $80 value for each would-be churner reached. Recency and order frequency drive the predictions. Next step: run the campaign with a 20% holdout group to measure the real effect.

Quick check
Your churn model is accurate. Why might sending offers to its 100 highest-risk customers still waste money?
  1. Many of them would leave regardless of the offer, and the model doesn't predict who the offer changes
  2. The model's AUC is too high to trust
  3. Offers should only go to low-risk customers
  4. High-risk customers never respond to email
Show the answer

Many of them would leave regardless of the offer, and the model doesn't predict who the offer changes

Risk isn't the same as responsiveness. Measuring the offer's real effect takes a holdout test, and targeting the people it changes takes uplift modeling.

Practice what you just read

  • 4 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's the most reliable way to find out how much a retention campaign actually reduces churn?

  1. Randomly hold out some targeted customers, send them nothing, and compare churn between the groups
  2. Compare this year's churn with last year's, since the only thing that changed is the campaign
  3. Look at the model's AUC, since a higher score means the campaign is reaching the right people
  4. Ask the customers who received the offer, and count how many say it changed their decision

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

Before this lesson