Capstone: churn prediction
Predict which customers will leave, and measure it properly.
Create a free account Try part of it here
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
- The brief
- Step 2: from scores to a campaign
- The ten-customer campaign in Python
- Step 2, for real: one look at the test set
- Step 3: what drives churn, and can the probabilities be trusted?
- 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:
- Can we predict churn better than the rule we use today, "anyone who hasn't ordered in 90 days"?
- Who should get the offer, and what's it worth?
- 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.
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
prepis 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=Trueadds a 0/1 column that marks where a value was missing. ThenStandardScalerputs 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_clubis already 0 or 1: it records loyalty-club membership.C=0.1gives logistic regression fairly strong regularization. It keeps the weights small.min_samples_leaf=10makes every leaf of the forest's trees hold at least 10 customers.max_depth=2limits 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}")
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.
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 stands for the model's churn probability for one customer. The last row shows who really churned (1) and who stayed (0).
| customer | A | B | C | D | E | F | G | H | I | J |
|---|---|---|---|---|---|---|---|---|---|---|
| p | 0.70 | 0.45 | 0.30 | 0.22 | 0.15 | 0.12 | 0.08 | 0.05 | 0.04 | 0.02 |
| churned | 1 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
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.
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 is worth dollars on average. It costs $15 whatever happens. Contact the customer when the first number beats the second:
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 . Then 0.30 × 80 = $24, which beats $15. So you contact C and expect to gain $9 on average. Customer E has . 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.
print(0.25 * 80 - 15)
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.
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.
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))
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.
p = 0.18 print(p * 80 > 15)
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:
realizedis 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.
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.
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
Try a quiz question
What's the most reliable way to find out how much a retention campaign actually reduces churn?