Customer churn prediction in Python, step by step
A complete machine learning project with scikit-learn: baselines, a fair model comparison, one honest test, and a decision about who to contact.
What you'll build
A churn model is only useful if it changes a decision. This tutorial predicts which of a shop's customers will leave, compares models fairly, tests the winner once, and turns its scores into a campaign: who gets a $15 offer that's worth $80 if it keeps a customer. The data has 600 customers and comes from the academy's practice API.
What you'll learn
- 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 you need
Python and pandas, and the idea of a train and test split. In the lesson it runs in your browser. On your own machine, install pandas and scikit-learn, and change the data address to https://www.indexaiacademy.com/mock/v1/customers. Your numbers may differ slightly between library versions.
Step 1: Load the data and lock away a test set
Split the data before you look at anything. The test set stays untouched until the very end, so it can give an honest score. stratify keeps the churn rate the same on both sides, which matters when only one customer in five leaves.
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%}")
development set 450, test set 150 (29 churners), churn rate 19%
Step 2: Measure what you have to beat
Before training anything, score what the team does today: flag anyone who hasn't ordered in 90 days. If a model can't beat this, it isn't worth building, and without this number you can't say whether it helped.
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")
today's 90-day rule: flags 29% of customers, precision 0.36, recall 0.54 recency alone as a score: AUC 0.741
Step 3: Compare models with cross-validation
The pipeline fills missing values and scales inside each cross-validation fold, so nothing leaks from the data being scored. On 450 rows, the simplest model wins here. The spread after each score is why a single split wouldn't be enough to decide.
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}")logistic regression cross-validated AUC 0.799 ± 0.054 random forest cross-validated AUC 0.756 ± 0.070 gradient boosting cross-validated AUC 0.740 ± 0.078
Rather write it yourself?
In the lesson you write this from a starter, in your browser with nothing to install, and checks tell you what's wrong as you go. A free account opens it and all 244 lessons.
Step 4: Score the winner once on the test set
Now, and only once, use the test set. AUC says the model ranks well, but a marketing team needs lift: how much richer in churners is the list you'd actually contact? The 50 riskiest customers hold 23 of the 29 who left.
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")
test AUC: 0.816 top 15 by risk: churn rate 47% (lift 2.4x), 7 of 29 churners top 30 by risk: churn rate 47% (lift 2.4x), 14 of 29 churners top 50 by risk: churn rate 46% (lift 2.4x), 23 of 29 churners
Step 5: Turn scores into a decision
An offer costs $15 and keeping a customer is worth $80, so contact someone when their probability times $80 is more than $15. That's the whole decision rule. On this test set the model is worth $1,120, against $665 for today's rule and $70 for contacting everyone.
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}")
contact everyone contacts 150 net value on the test set: $ 70 today's 90-day rule contacts 41 net value on the test set: $ 665 model: p x $80 > $15 contacts 48 net value on the test set: $ 1120
Step 6: See what drives churn, and whether to trust the numbers
Shuffling a column and measuring how much the score drops shows what the model relies on. The calibration table checks whether its probabilities mean what they say, which matters because the decision rule above uses them directly. The 0.2 to 0.4 band runs low here, with 27% predicted and 46% actual.
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())
AUC lost when each column is shuffled:
days_since_last_order 0.103
orders_last_year 0.096
trail_club 0.031
email_open_rate 0.016
tenure_months 0.009
calibration on the test set:
customers predicted actual
band
(0.0, 0.1] 71 0.06 0.04
(0.1, 0.2] 35 0.14 0.14
(0.2, 0.4] 26 0.27 0.46
(0.4, 1.0] 18 0.54 0.50None of this says what causes churn. A model trained on past behavior can't tell you what an offer will do. Only an experiment can.
Step 7: Package the decision as functions
These are the same sums as reusable functions, so the next campaign doesn't start from a copy of a notebook. They're what you write in the lesson's exercise, with checks that tell you when one is wrong.
def lift_at_k(y_true, probs, k): y_true, probs = np.asarray(y_true), np.asarray(probs) top = np.argsort(-probs)[:k] return float(y_true[top].mean() / y_true.mean()) def choose_contacts(probs, offer_cost, value): return np.asarray(probs) * value > offer_cost def campaign_value(y_true, contacted, offer_cost, value): y_true, contacted = np.asarray(y_true), np.asarray(contacted, dtype=bool) return int(value * y_true[contacted].sum() - offer_cost * contacted.sum())
Where to take it next
- Run a real experiment: hold back some customers from the offer, then measure the difference.
- Retrain on a schedule, and watch for the score falling as customers change.
- Recalibrate the probabilities if the bands keep running off.
Mistakes to watch for
- Treating high churn risk as high responsiveness to an offer.
- Reporting the cross-validated score of the model you tuned as its final performance.
- Using probabilities in dollar calculations without checking calibration.
- Measuring a campaign by comparing to last year instead of a randomized holdout.
The complete program
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}")
Write it yourself
In the lesson you write this from a starter, in your browser with nothing to install, and checks tell you what's wrong as you go. A free account opens it and all 244 lessons.