Skip to content
Laviyx Learn

Try

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.

Intermediate11 min readUpdated
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:

Python
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))
Output
  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.115

Step 2: Explore churn drivers#

Python
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))
Output
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: float64

Monthly-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:

Python
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}")
Output
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.293

Logistic 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#

Python
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))
Output
cat__contract_yearly   -1.43
num__tenure            -0.71
num__usage_hours       -0.44
num__monthly_fee        0.32
num__complaints         0.39
dtype: float64

Coefficients 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:

Python
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))
Output
 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       3500

The 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:

Python
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())
Output
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.60

The 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
Python
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))
Output
 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      -1500

At ₹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?