Machine Learning course menu
Machine Learning Project: Customer Churn Prediction in Python
A complete classification project: predicting which customers will cancel, exploring churn drivers, handling class imbalance, comparing models with ROC AUC and average precision, choosing a decision threshold from business costs, and producing a ranked list for a retention campaign.
On this page
Churn means customers leaving: cancelling a subscription, closing an account, moving to a competitor. Keeping a customer is usually far cheaper than winning a new one, so companies use churn models to find at-risk customers early and offer them help or a discount.
Goal: a subscription service wants a monthly list of customers most likely to cancel. A retention offer costs ₹500 per customer contacted; a lost customer costs about ₹4,000 in future revenue. The offer persuades roughly half of the would-be churners who receive it to stay.
Run the steps in order; each builds on the previous one.
Step 1: The data#
Simulated customer records, built so that churn depends on contract type, tenure, support complaints, price and usage, like real subscription data:
import numpy as np
import pandas as pd
rng = np.random.default_rng(7)
n = 4000
contract = rng.choice(["monthly", "yearly"], size=n, p=[0.6, 0.4])
tenure = rng.integers(1, 61, n) # months as a customer
monthly_fee = rng.choice([299, 499, 799], size=n, p=[0.4, 0.4, 0.2])
complaints = rng.poisson(0.6, n)
usage_hours = rng.gamma(4, 5, n).round(1) # hours used last month
score = (-1.6 + 1.6 * (contract == "monthly") - 0.04 * tenure + 0.6 * complaints
+ 0.0015 * (monthly_fee - 499) - 0.05 * usage_hours)
churned = rng.random(n) < 1 / (1 + np.exp(-score))
customers = pd.DataFrame({"contract": contract, "tenure": tenure, "monthly_fee": monthly_fee,
"complaints": complaints, "usage_hours": usage_hours, "churned": churned.astype(int)})
print(customers.head())
print("churn rate:", round(customers["churned"].mean(), 3)) contract tenure monthly_fee complaints usage_hours churned
0 yearly 31 299 0 15.7 0
1 yearly 49 299 2 12.5 0
2 yearly 28 499 0 37.5 0
3 monthly 15 499 0 26.1 1
4 monthly 41 299 0 17.2 0
churn rate: 0.115Step 2: Explore churn drivers#
print(customers.groupby("contract")["churned"].mean().round(3))
print(customers.groupby(pd.cut(customers["tenure"], [0, 6, 24, 60]), observed=True)["churned"].mean().round(3))
print(customers.groupby(customers["complaints"].clip(upper=3))["churned"].mean().round(3))contract
monthly 0.161
yearly 0.046
Name: churned, dtype: float64
tenure
(0, 6] 0.233
(6, 24] 0.153
(24, 60] 0.073
Name: churned, dtype: float64
complaints
0 0.083
1 0.135
2 0.182
3 0.301
Name: churned, dtype: float64Monthly-contract customers, new customers and customers who complained churn far more. These patterns already suggest actions (for example, encouraging yearly contracts), independent of any model.
Step 3: Split, baseline and models#
Churners are a minority, so the split is stratified, and models are compared with ROC AUC and average precision rather than accuracy:
from sklearn.compose import ColumnTransformer
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_validate, train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
X = customers.drop(columns="churned")
y = customers["churned"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=0, stratify=y)
preprocess = ColumnTransformer([
("num", StandardScaler(), ["tenure", "monthly_fee", "complaints", "usage_hours"]),
("cat", OneHotEncoder(drop="first"), ["contract"]),
])
models = {
"logistic regression": make_pipeline(preprocess, LogisticRegression(max_iter=1000)),
"gradient boosting": make_pipeline(preprocess, HistGradientBoostingClassifier(max_depth=3, random_state=0)),
}
print("accuracy of always predicting 'stays':", round(1 - y_train.mean(), 3))
for name, model in models.items():
scores = cross_validate(model, X_train, y_train, cv=5, scoring=["roc_auc", "average_precision"])
print(f"{name}: ROC AUC {scores['test_roc_auc'].mean():.3f}, average precision {scores['test_average_precision'].mean():.3f}")accuracy of always predicting 'stays': 0.885
logistic regression: ROC AUC 0.782, average precision 0.335
gradient boosting: ROC AUC 0.751, average precision 0.293Logistic regression wins on both measures, and it is also easier to explain to the business. That is not a surprise here: the simulated churn follows a logistic pattern, so a flexible model only adds noise. Real data often has interactions that favour boosting; always compare.
Step 4: Fit and look at the coefficients#
model = models["logistic regression"].fit(X_train, y_train)
names = model[0].get_feature_names_out()
coefs = pd.Series(model[-1].coef_[0], index=names).sort_values()
print(coefs.round(2))cat__contract_yearly -1.43
num__tenure -0.71
num__usage_hours -0.44
num__monthly_fee 0.32
num__complaints 0.39
dtype: float64Coefficients are on standardised features, so their sizes are comparable: a monthly contract and complaints increase churn risk; longer tenure and more usage reduce it.
Step 5: Choose the threshold from business costs#
The default 0.5 threshold ignores what mistakes cost. Contacting a customer costs ₹500; saving a would-be churner (half of those contacted) is worth ₹4,000. Compute the expected profit of the campaign at each threshold:
proba = model.predict_proba(X_test)[:, 1]
rows = []
for threshold in [0.1, 0.2, 0.3, 0.4, 0.5, 0.6]:
contacted = proba >= threshold
churners_reached = (contacted & (y_test == 1)).sum()
profit = churners_reached * 0.5 * 4000 - contacted.sum() * 500
rows.append({"threshold": threshold, "contacted": int(contacted.sum()),
"churners_reached": int(churners_reached), "profit_rs": int(profit)})
print(pd.DataFrame(rows).to_string(index=False)) threshold contacted churners_reached profit_rs
0.1 409 90 -24500
0.2 172 55 24000
0.3 70 29 23000
0.4 30 18 21000
0.5 13 9 11500
0.6 5 3 3500The most profitable threshold is 0.2, far below 0.5, with 0.3 almost as profitable while contacting far fewer customers (a sensible choice if the retention team has limited time). At 0.1 the campaign loses money: too many contacted customers were never going to leave. an offer is cheap compared with a lost customer, so it pays to contact customers even at moderate risk. The best threshold comes from the costs, not from a default.
Step 6: The campaign list#
The business needs a ranked list, not a confusion matrix:
best_threshold = max(rows, key=lambda r: r["profit_rs"])["threshold"]
campaign = X_test.assign(churn_probability=proba.round(2))
campaign = campaign[campaign["churn_probability"] >= best_threshold].sort_values("churn_probability", ascending=False)
print("threshold:", best_threshold, " customers to contact:", len(campaign))
print(campaign.head(5).to_string())threshold: 0.2 customers to contact: 177
contract tenure monthly_fee complaints usage_hours churn_probability
1657 monthly 6 499 3 9.9 0.68
3253 monthly 1 799 2 18.8 0.64
307 monthly 1 799 1 6.9 0.64
849 monthly 2 799 1 9.2 0.61
1050 monthly 4 499 2 7.8 0.60The top of the list matches the profile from the exploration: new, monthly-contract customers, most with complaints and low usage.
Step 7: Measure the real effect#
A churn model predicts who would leave; it does not prove the offer works. Before rolling out, run an experiment: randomly give the offer to half of the high-risk customers and compare their churn with the other half (an A/B test). Only that tells you the true value of the campaign.
What you practised#
- exploring churn drivers by group before modelling;
- stratified splits and imbalance-aware metrics (ROC AUC, average precision);
- choosing a simple, explainable model when it performs as well;
- turning probabilities into decisions with business costs;
- producing a ranked action list and planning an experiment to measure impact.
Practice exercise
Suppose the retention offer becomes more expensive: ₹1,500 per customer contacted. Rerun the profit table with the new cost. How does the best threshold change, and why?
Show hint
Change 500 to 1500 in the profit formula and look at which threshold now gives the highest profit.
Show solution
rows = []
for threshold in [0.1, 0.2, 0.3, 0.4, 0.5, 0.6]:
contacted = proba >= threshold
churners_reached = (contacted & (y_test == 1)).sum()
rows.append({"threshold": threshold, "contacted": int(contacted.sum()),
"profit_rs": int(churners_reached * 0.5 * 4000 - contacted.sum() * 1500)})
print(pd.DataFrame(rows).to_string(index=False)) threshold contacted profit_rs
0.1 409 -433500
0.2 172 -148000
0.3 70 -47000
0.4 30 -9000
0.5 13 -1500
0.6 5 -1500At ₹1,500 per contact, every threshold loses money; the least bad choices still lose ₹1,500. Even among the highest-risk customers, too few are saved to pay for the offer. The right decision is not a different threshold but no campaign (or a cheaper offer). A good model does not guarantee a profitable action; the costs decide.
Finished the course? Test yourself with the machine learning interview questions.
Finished this lesson?