Data Science & Machine Learning
ProjectIntermediate 55 min· Machine learning · 14 of 20

Project: predict customer churn

Build an end-to-end churn model: explore the data, set a baseline, compare logistic regression and gradient boosting with cross-validation, choose a threshold by business cost, explain the drivers and save the model.

Goal: build a model that predicts which customers of a subscription business are likely to leave (churn), so the company can contact them before they go. You will take the project from a raw CSV file to a saved model that scores new customers.

The finished result: a notebook or script that loads churn.csv, compares a baseline, logistic regression and gradient boosting using cross-validation, chooses a threshold based on business cost, explains what drives churn, and saves a model file you can load later.

Prerequisites: the earlier lessons in this module: *Classification*, *Train, validation, test and overfitting*, *Metrics*, *Feature engineering* and *Decision trees, random forests and gradient boosting*. Use Google Colab or Kaggle.

The seven stages

  1. 1Create churn.csv with the data script.
  2. 2Explore the data: size, missing values, class balance, patterns.
  3. 3Split into train and test sets and set a baseline.
  4. 4Build two pipelines and compare them with cross-validation.
  5. 5Evaluate once on the test set and choose a threshold.
  6. 6Explain the main drivers of churn.
  7. 7Save the pipeline and score new customers.

The business problem

Imagine a broadband and OTT subscription company in India with 4,000 customers in this sample. Winning a new customer costs far more than keeping an existing one, so the retention team wants a short list of at-risk customers each month. They can call or offer a discount to a limited number of people.

Translate that into machine learning:

- Task: binary classification. Label: churn (1 = left, 0 = stayed). - Output: a churn probability per customer, so the team can sort by risk. - Metric: accuracy is a poor guide, so we use ROC AUC to compare models and precision/recall to choose how many customers to contact.

Stage 1: get the data

The standard public dataset for this problem is the Telco customer churn data, available on Kaggle. To keep this project fully runnable without logging in or downloading anything, the script below creates a realistic synthetic dataset with the same style of columns. The patterns are built in on purpose: month-to-month contracts, short tenure, many support calls and cash on delivery payments raise churn risk. The rest of the project works unchanged if you later swap in a real CSV with similar columns.

make_data.py
# make_data.py  (run once: it creates churn.csv)
import numpy as np
import pandas as pd

rng = np.random.default_rng(42)
n = 4000

contract = rng.choice(["Month-to-month", "One year", "Two year"], size=n, p=[0.55, 0.25, 0.20])
tenure = rng.integers(1, 73, size=n)                       # months with the company
plan = rng.choice(["Basic", "Standard", "Premium"], size=n, p=[0.4, 0.4, 0.2])
base = {"Basic": 399, "Standard": 599, "Premium": 899}     # monthly price in rupees
monthly_charges = np.array([base[p] for p in plan]) + rng.normal(0, 40, size=n).round(0)
payment = rng.choice(["UPI", "Card", "Net banking", "Cash on delivery"], size=n, p=[0.45, 0.25, 0.2, 0.1])
support_calls = rng.poisson(1.5, size=n)
paperless = rng.choice(["Yes", "No"], size=n, p=[0.6, 0.4])
total_charges = (tenure * monthly_charges * rng.uniform(0.95, 1.05, size=n)).round(0)

# Churn is more likely for month-to-month, new, expensive customers with many support calls.
logit = (
    -0.6
    + 1.4 * (contract == "Month-to-month")
    - 1.2 * (contract == "Two year")
    - 0.04 * tenure
    + 0.002 * (monthly_charges - 600)
    + 0.35 * support_calls
    + 0.4 * (payment == "Cash on delivery")
)
churn = rng.random(n) < 1 / (1 + np.exp(-logit))

df = pd.DataFrame({
    "customer_id": [f"C{i:05d}" for i in range(1, n + 1)],
    "contract": contract,
    "tenure_months": tenure,
    "plan": plan,
    "monthly_charges": monthly_charges,
    "total_charges": total_charges,
    "payment_method": payment,
    "support_calls": support_calls,
    "paperless_billing": paperless,
    "churn": np.where(churn, "Yes", "No"),
})
# Real data is messy: hide some total_charges values
df.loc[rng.choice(n, size=25, replace=False), "total_charges"] = np.nan

df.to_csv("churn.csv", index=False)
print(df.shape)
print(df["churn"].value_counts(normalize=True).round(3))

Run once. It writes churn.csv to the current folder. In Colab, paste it into a cell and run it.

Output
(4000, 10)
churn
No     0.67
Yes    0.33
Name: proportion, dtype: float64

Synthetic data

This data is made up for learning. Do not draw conclusions about real customers from the patterns you find; they are exactly the patterns the generator put in. With a real dataset, the same steps find real patterns, and also real mess.

Stage 2: look before you model

Always explore first. Check size, types, missing values and class balance, and look at how the target varies with a few columns.

Explore
import pandas as pd

pd.set_option("display.width", 120)
pd.set_option("display.max_columns", 20)

df = pd.read_csv("churn.csv")
print(df.head(3).T)
print(df.isna().sum())
print(df.groupby("contract")["churn"].apply(lambda s: (s == "Yes").mean()).round(2))
print(df.groupby("support_calls")["churn"].apply(lambda s: (s == "Yes").mean()).round(2))

Run in a new cell or notebook; it reads the file created in stage 1.

Output
                             0               1         2
customer_id             C00001          C00002    C00003
contract              One year  Month-to-month  Two year
tenure_months               47              52        20
plan                  Standard           Basic  Standard
monthly_charges          524.0           444.0     646.0
total_charges          23931.0         22537.0   13426.0
payment_method     Net banking             UPI       UPI
support_calls                0               2         3
paperless_billing           No              No        No
churn                       No              No        No

total_charges    25   (the only column with missing values)

contract
Month-to-month    0.47
One year          0.22
Two year          0.07

support_calls
0    0.25
1    0.28
2    0.37
3    0.41
4    0.49
5    0.55
6    0.75
7    0.50

Three findings. First, 33% of customers churned: not rare, but imbalanced enough that a do-nothing model gets 67% accuracy. Second, contract type is a strong signal: 47% of month-to-month customers churned against 7% of two-year customers. Third, churn rises with support calls (the last rows have few customers, so the dip at 7 calls is noise). total_charges has 25 missing values that we must handle.

Stage 3: split and set a baseline

We convert the label to 0/1, drop customer_id (an ID carries no pattern and lets a flexible model memorise), and keep 20% of the customers as a final test set. The test set is not touched until the end. The baseline predicts the most common class for everyone.

Split and baseline
from sklearn.dummy import DummyClassifier
from sklearn.model_selection import train_test_split

df["churn"] = (df["churn"] == "Yes").astype(int)     # 1 = left, 0 = stayed

X = df.drop(columns=["customer_id", "churn"])        # an ID carries no signal
y = df["churn"]

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y
)

baseline = DummyClassifier(strategy="most_frequent").fit(X_train, y_train)
print("Baseline accuracy:", round(baseline.score(X_test, y_test), 3))

Continues in the same notebook (uses df from stage 2).

Output
Baseline accuracy: 0.67

Any model that cannot beat 0.67 accuracy, or an ROC AUC of 0.5, is no better than guessing.

Stage 4: build two pipelines and compare with cross-validation

We prepare the data differently for each model. Logistic regression needs missing numbers filled and numbers scaled. Gradient boosting (HistGradientBoostingClassifier) accepts missing values and does not care about scale, so it only needs the text columns one-hot encoded. In both cases the preprocessing sits inside a pipeline so that cross-validation never leaks.

We use stratified 5-fold cross-validation on the training set, scoring with ROC AUC and F1.

Compare models
from sklearn.compose import ColumnTransformer
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold, cross_validate
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

numeric_cols = ["tenure_months", "monthly_charges", "total_charges", "support_calls"]
categorical_cols = ["contract", "plan", "payment_method", "paperless_billing"]

# Logistic regression needs imputed, scaled numbers and one-hot categories.
linear_prep = ColumnTransformer([
    ("num", Pipeline([("impute", SimpleImputer(strategy="median")), ("scale", StandardScaler())]), numeric_cols),
    ("cat", OneHotEncoder(handle_unknown="ignore"), categorical_cols),
])
logreg = Pipeline([("prep", linear_prep), ("clf", LogisticRegression(max_iter=1000))])

# Gradient boosting handles missing numbers itself; it only needs the categories encoded.
tree_prep = ColumnTransformer(
    [("cat", OneHotEncoder(handle_unknown="ignore"), categorical_cols)],
    remainder="passthrough",
)
boosting = Pipeline([("prep", tree_prep), ("clf", HistGradientBoostingClassifier(random_state=42))])

cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
for name, model in [("Logistic regression", logreg), ("Gradient boosting", boosting)]:
    result = cross_validate(model, X_train, y_train, cv=cv, scoring=["roc_auc", "f1"])
    print(f"{name:20s} ROC AUC {result['test_roc_auc'].mean():.3f}   F1 {result['test_f1'].mean():.3f}")

Runs 5-fold cross-validation for each pipeline on the training data only.

Output
Logistic regression  ROC AUC 0.811   F1 0.613
Gradient boosting    ROC AUC 0.776   F1 0.578

Logistic regression wins here, which may surprise you. The reason is that we built the data from a logistic formula, so a straight-line model is exactly the right shape. On real data with more complicated interactions gradient boosting often wins, which is why you always compare instead of assuming. Gradient boosting with default settings also has to learn that simple pattern from only 3,200 training rows full of random noise, which is harder. Try the exercises to tune it.

We pick logistic regression: it is accurate enough here, fast and easy to explain to the retention team.

Stage 5: choose a threshold using business cost

Now fit the chosen model on the full training set and evaluate it once on the test set. The model gives a churn probability. The default threshold of 0.5 is not necessarily right: it depends on how many customers the team can contact and the cost of a wasted call or offer versus a lost customer.

Evaluate on the test set
from sklearn.metrics import (classification_report, confusion_matrix,
                             precision_score, recall_score, roc_auc_score)

logreg.fit(X_train, y_train)
proba = logreg.predict_proba(X_test)[:, 1]          # probability of churn
print("ROC AUC on test:", round(roc_auc_score(y_test, proba), 3))

for threshold in [0.5, 0.4, 0.3, 0.2]:
    pred = (proba >= threshold).astype(int)
    print(f"threshold {threshold}: precision {precision_score(y_test, pred):.2f}, "
          f"recall {recall_score(y_test, pred):.2f}, flagged {pred.sum()} of {len(pred)}")

pred = (proba >= 0.3).astype(int)
print(confusion_matrix(y_test, pred))
print(classification_report(y_test, pred, target_names=["stayed", "churned"]))

Prints ROC AUC, a table of thresholds, a confusion matrix and a classification report at threshold 0.3.

Output
ROC AUC on test: 0.79
threshold 0.5: precision 0.60, recall 0.47, flagged 207 of 800
threshold 0.4: precision 0.58, recall 0.66, flagged 301 of 800
threshold 0.3: precision 0.52, recall 0.77, flagged 394 of 800
threshold 0.2: precision 0.47, recall 0.88, flagged 489 of 800
[[346 190]
 [ 60 204]]
              precision    recall  f1-score   support

      stayed       0.85      0.65      0.73       536
     churned       0.52      0.77      0.62       264

    accuracy                           0.69       800
   macro avg       0.68      0.71      0.68       800
weighted avg       0.74      0.69      0.70       800

The test ROC AUC of 0.79 is close to the cross-validated 0.81, a healthy sign that we have not fooled ourselves. The threshold table shows the trade-off in the language the team understands:

- At 0.5 the team calls 207 customers and reaches 47% of the leavers, but 40% of calls are wasted. - At 0.3 the team calls 394 customers and reaches 77% of the leavers; about half the calls (precision 0.52) go to people who would stay.

If each retention call is cheap and each lost customer is expensive, choose 0.3 or lower. If the team can only make about 200 calls a month, choose 0.5 or the top-200 customers by probability. This is a business decision, and your job is to lay out the options clearly.

Rank, then take the top N

When capacity is fixed, skip thresholds: sort customers by predicted probability and contact the top N. The ranking quality is what ROC AUC measures.

Stage 6: what drives churn?

A linear model can be read from its coefficients. After scaling, a positive coefficient pushes the prediction towards churn and a negative one towards staying; bigger magnitude means stronger effect. (One-hot columns come in groups, so read each one relative to the others in its group.)

Coefficients
import pandas as pd

names = logreg.named_steps["prep"].get_feature_names_out()
coefs = pd.Series(logreg.named_steps["clf"].coef_[0], index=names).sort_values()
print(coefs.round(2))

Prints each preprocessed feature with its weight, sorted.

Output
cat__contract_Two year                 -1.44
num__tenure_months                     -0.83
cat__plan_Basic                        -0.35
...
num__support_calls                      0.39
cat__contract_Month-to-month            1.44

The story for the retention team: month-to-month customers, customers with many support calls and those on the Premium plan are at higher risk, while two-year contracts and long tenure protect. Be careful with wording: these are associations in the data, not proof that changing a contract type will stop a customer leaving.

A premium plan raising risk is plausible, as higher bills push people to look at alternatives, and in this dataset it was built that way through the price. In a real project you would check such a finding with the business before acting.

Stage 7: save the model and score new customers

A model in a notebook helps nobody. Save the whole pipeline, preprocessing included, with joblib, and load it wherever you need predictions. Because the pipeline contains the preprocessing, new data only needs the same raw columns.

Save and reuse
import joblib

joblib.dump(logreg, "churn_model.joblib")

# Later, in another script or notebook:
loaded = joblib.load("churn_model.joblib")

new_customers = pd.DataFrame([{
    "contract": "Month-to-month", "tenure_months": 3, "plan": "Premium",
    "monthly_charges": 899, "total_charges": 2700, "payment_method": "Cash on delivery",
    "support_calls": 4, "paperless_billing": "Yes",
}, {
    "contract": "Two year", "tenure_months": 48, "plan": "Basic",
    "monthly_charges": 399, "total_charges": 19000, "payment_method": "UPI",
    "support_calls": 0, "paperless_billing": "No",
}])
print(loaded.predict_proba(new_customers)[:, 1].round(2))

The two example customers are very different. The first is high risk; the second is low risk.

Output
[0.93 0.01]

The new month-to-month, three-month-old, four-support-calls customer has a 93% churn probability; the two-year customer of 48 months has 1%. Turning this into a web service is covered in *MLOps basics: from notebook to deployed model*.

One caution: pickled or joblib files can run code when loaded, so only load model files you created or trust, and use the same scikit-learn version when loading as when saving.

Using the real Telco dataset

To repeat this with the public Telco customer churn data from Kaggle, download the CSV, load it with pd.read_csv, and adjust the column lists. Things to watch for: the churn column is "Yes"/"No" as here; TotalCharges may load as text because a few rows contain a blank space, so convert it with pd.to_numeric(df['TotalCharges'], errors='coerce'); and drop the ID column. Check the dataset's page for the exact column names and licence.

Review checklist

Before you share it

  1. I split the data before any learning step and kept the test set untouched until the end.
  2. I compared against a baseline and used a metric suited to the problem.
  3. All preprocessing sits inside a pipeline.
  4. I chose the model with cross-validation, not the test set.
  5. I chose a threshold based on business cost or capacity, and explained the trade-off.
  6. I checked for leakage: every feature is known before a customer leaves.
  7. I describe findings as associations, not causes.
Prompt · Write the project summary for a manager
I built a customer churn model. Details:
- Data: [rows, period, source]
- Best model: [model] with ROC AUC [value] on a held-out test set
- At threshold [value] it flags [N] of [M] customers, catching [recall]% of churners with [precision]% precision
- Main drivers: [list from coefficients]

Write a one-page summary for a non-technical manager: what the model does, how good it is in plain words, what action the team should take, the risks and limits (including that drivers are associations, not causes), and what to monitor once it is in use.

What to expect: A good summary avoids jargon, states the trade-off in calls and customers, and mentions limits and monitoring.

Try it: Stretch 1: tune gradient boosting

Use GridSearchCV (from sklearn.model_selection) on the boosting pipeline with clf__learning_rate in [0.03, 0.1], clf__max_depth in [2, 3, None] and clf__max_iter in [100, 300], scored by roc_auc. Does it close the gap with logistic regression?

Try it: Stretch 2: cost-based threshold

Suppose a retention offer costs ₹300 per customer called, and each churner you save is worth ₹6,000 in future revenue, but only 30% of called churners accept the offer. For thresholds from 0.1 to 0.7, compute the expected profit on the test set and pick the best threshold.

Try it: Stretch 3: add features

Create charges_per_month_ratio = total_charges / (tenure_months * monthly_charges) and is_new = tenure_months < 6, and add them to the pipeline. Use the ablation approach from the feature engineering lesson. Does the score change?

Try it: Stretch 4: use the real data

Download the Telco customer churn dataset from Kaggle, adapt the column lists in stage 4, fix TotalCharges, and re-run the project. Compare which model wins and which features matter with the synthetic results.

Key takeaways

  1. A churn project turns a business question into a classification task, a metric and an action.
  2. Explore first, split early, and compare every model against a simple baseline.
  3. Use pipelines with cross-validation so preprocessing never leaks; choose models on cross-validation, not the test set.
  4. Accuracy is misleading for churn; use ROC AUC to compare and precision/recall to choose a threshold.
  5. The threshold or top-N list is a business decision based on costs and team capacity.
  6. Coefficients and importances describe associations, not causes.
  7. Save the whole pipeline with joblib so new customers can be scored with the same preprocessing.

Check yourself

Finishing the quiz marks the lesson as done.

1. Why do we compare models using cross-validation on the training set rather than on the test set?

2. A churn model flags 300 of 800 customers with precision 0.58 and recall 0.66. What does 0.58 precision mean?

3. The retention team can only call 200 customers a month. What is the simplest sensible approach?

4. Why is customer_id dropped before training?

5. Logistic regression beat gradient boosting in this project. What is the best conclusion?

6. Which statement about the coefficients is correct?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free