EthicsComputerCourses
Join training +91 90532 10052

Project: Customer Churn Prediction

Machine Learning · Lesson 43 of 43 Advanced 10 min read Updated

In this project you will work through a churn problem the way a data scientist in an Indian telecom, bank or subscription company would: from a vague business request to a saved model with a decision rule, an explanation and a written model card. It pulls together the lessons on framing and baselines, data leakage, pipelines, gradient boosting, calibration and thresholds and interpretation.

The brief. A fibre broadband provider in Rajasthan is losing customers. The head of customer experience says: "Tell us who is about to leave, so our retention team can call them with an offer." The data team has a snapshot of 5,000 customers taken on one date, with what was known about each customer on that date, and a label saying whether the customer left within the next 90 days.

The dataset is synthetic and built by the make_customers() function, which every step repeats so that each example runs on its own in the browser. Its patterns are realistic, and it hides one trap for you to find.

Step 1: Frame the problem

Write the decisions down before touching code:

  • Decision: each month, the retention team calls customers and offers ₹400 off the next bill.
  • Target: churned = 1 if the customer cancelled or stopped paying within 90 days of the snapshot.
  • One row: one customer on the snapshot date. Features must be known on that date.
  • Money: a customer who leaves costs about ₹6,000 of future profit. Past campaigns kept about half of the churners who received the offer. So calling a real churner is worth about 0.5 × ₹6,000 − ₹400 = ₹2,600, and calling a customer who would have stayed costs ₹400.
  • Metric: rupees saved compared with calling nobody; for model comparison before choosing a threshold, average precision, which suits a rare positive class.
  • Capacity: the team can make at most 400 calls a month.

Step 2: Load and explore

PythonRun it yourself
import numpy as np
import pandas as pd

def make_customers(n=5000, seed=42):
    """Synthetic broadband customers; churned = left within 90 days of the snapshot."""
    rng = np.random.default_rng(seed)
    df = pd.DataFrame({
        "customer_id": [f"RJ{100000 + i}" for i in range(n)],
        "city": rng.choice(["Jaipur", "Jodhpur", "Udaipur", "Kota", "Ajmer"], n, p=[0.4, 0.2, 0.15, 0.15, 0.1]),
        "plan": rng.choice(["Basic", "Standard", "Premium"], n, p=[0.45, 0.4, 0.15]),
        "contract": rng.choice(["Monthly", "Annual"], n, p=[0.65, 0.35]),
        "payment": rng.choice(["UPI autopay", "Card", "Cash"], n, p=[0.5, 0.2, 0.3]),
        "tenure_months": rng.integers(1, 73, n),
        "complaints_90d": rng.poisson(0.6, n),
        "outage_hours_90d": rng.gamma(1.5, 4, n).round(1),
        "late_payments_12m": rng.poisson(0.5, n),
        "avg_daily_gb": rng.gamma(2, 2.5, n).round(1)})
    logit = (-1.9 + 1.3 * (df["contract"] == "Monthly") - 0.05 * df["tenure_months"]
             + 0.7 * df["complaints_90d"] + 0.07 * df["outage_hours_90d"] + 0.7 * (df["payment"] == "Cash")
             + 0.5 * df["late_payments_12m"] - 0.1 * df["avg_daily_gb"]
             + 0.8 * ((df["plan"] == "Basic") & (df["outage_hours_90d"] > 10)))
    df["churned"] = (rng.random(n) < 1 / (1 + np.exp(-logit))).astype(int)
    df["calls_after_notice"] = np.where(df["churned"] == 1, rng.poisson(2.5, n), 0)
    df.loc[rng.random(n) < 0.03, "avg_daily_gb"] = np.nan
    return df.astype({c: "category" for c in ["city", "plan", "contract", "payment"]})

df = make_customers()
print("Shape:", df.shape)
print(f"Churn rate: {df['churned'].mean():.1%}")
print("Missing values:", df.isna().sum()[df.isna().sum() > 0].to_dict())
print(df.groupby("contract", observed=True)["churned"].mean().round(3).to_dict())
print(df.groupby("payment", observed=True)["churned"].mean().round(3).to_dict())
print(df.groupby("churned")[["tenure_months", "complaints_90d", "outage_hours_90d"]].mean().round(1))
Output
Shape: (5000, 12)
Churn rate: 18.5%
Missing values: {'avg_daily_gb': 146}
{'Annual': 0.099, 'Monthly': 0.232}
{'Card': 0.15, 'Cash': 0.235, 'UPI autopay': 0.167}
         tenure_months  complaints_90d  outage_hours_90d
churned                                                 
0                 40.1             0.5               5.7
1                 23.6             0.9               7.6

About 18.5% of customers churned within 90 days. Monthly-contract customers churn more than twice as often as annual ones (23.2% against 9.9%), cash payers more often than UPI autopay users, and churners have shorter tenure (23.6 against 40.1 months) and more complaints. Only avg_daily_gb has gaps (146 rows). Nothing here is surprising, and that is good: exploration should first confirm what the business already knows before you trust anything subtler.

Step 3: Hunt for leaks

Before modelling, score every numeric column on its own, as in Data Leakage in Depth, and ask how each column is recorded.

PythonRun it yourself
import numpy as np
import pandas as pd
from sklearn.metrics import roc_auc_score

def make_customers(n=5000, seed=42):
    rng = np.random.default_rng(seed)
    df = pd.DataFrame({
        "customer_id": [f"RJ{100000 + i}" for i in range(n)],
        "city": rng.choice(["Jaipur", "Jodhpur", "Udaipur", "Kota", "Ajmer"], n, p=[0.4, 0.2, 0.15, 0.15, 0.1]),
        "plan": rng.choice(["Basic", "Standard", "Premium"], n, p=[0.45, 0.4, 0.15]),
        "contract": rng.choice(["Monthly", "Annual"], n, p=[0.65, 0.35]),
        "payment": rng.choice(["UPI autopay", "Card", "Cash"], n, p=[0.5, 0.2, 0.3]),
        "tenure_months": rng.integers(1, 73, n),
        "complaints_90d": rng.poisson(0.6, n),
        "outage_hours_90d": rng.gamma(1.5, 4, n).round(1),
        "late_payments_12m": rng.poisson(0.5, n),
        "avg_daily_gb": rng.gamma(2, 2.5, n).round(1)})
    logit = (-1.9 + 1.3 * (df["contract"] == "Monthly") - 0.05 * df["tenure_months"]
             + 0.7 * df["complaints_90d"] + 0.07 * df["outage_hours_90d"] + 0.7 * (df["payment"] == "Cash")
             + 0.5 * df["late_payments_12m"] - 0.1 * df["avg_daily_gb"]
             + 0.8 * ((df["plan"] == "Basic") & (df["outage_hours_90d"] > 10)))
    df["churned"] = (rng.random(n) < 1 / (1 + np.exp(-logit))).astype(int)
    df["calls_after_notice"] = np.where(df["churned"] == 1, rng.poisson(2.5, n), 0)
    df.loc[rng.random(n) < 0.03, "avg_daily_gb"] = np.nan
    return df.astype({c: "category" for c in ["city", "plan", "contract", "payment"]})

df = make_customers()
numeric = df.select_dtypes("number").columns.drop("churned")
for col in numeric:
    values = df[col].fillna(df[col].median())
    auc = roc_auc_score(df["churned"], values)
    flag = "  <-- check how this is recorded" if max(auc, 1 - auc) > 0.85 else ""
    print(f"{col:<19} {max(auc, 1 - auc):.3f}{flag}")
Output
tenure_months       0.726
complaints_90d      0.602
outage_hours_90d    0.599
late_payments_12m   0.576
avg_daily_gb        0.560
calls_after_notice  0.957  <-- check how this is recorded

calls_after_notice scores 0.957 on its own, far above everything else. A conversation with the call-centre team explains why: these are calls logged after a customer has given notice to cancel. The column exists only because the customer is already leaving, which is target leakage, so it must go. With it, any model would look brilliant in testing and be useless on the snapshot date, when nobody has given notice yet.

The model will use the remaining columns except customer_id, an identifier that carries no information about behaviour (a model could only memorise it). From here on, X = df.drop(columns=["customer_id", "churned", "calls_after_notice"]).

Step 4: Baselines and candidate models

Split once into training and test sets (stratified, 25% test), and compare candidates with 5-fold cross-validation on the training set only. The ladder: a dummy model, a business rule ("monthly contract and at least two complaints") turned into a score, a logistic regression pipeline, and HistGradientBoostingClassifier, which handles the category columns and the missing avg_daily_gb values natively.

PythonRun it yourself
import numpy as np
import pandas as pd
from sklearn.compose import make_column_selector, make_column_transformer
from sklearn.dummy import DummyClassifier
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import average_precision_score, roc_auc_score
from sklearn.model_selection import StratifiedKFold, cross_validate, train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

def make_customers(n=5000, seed=42):
    rng = np.random.default_rng(seed)
    df = pd.DataFrame({
        "customer_id": [f"RJ{100000 + i}" for i in range(n)],
        "city": rng.choice(["Jaipur", "Jodhpur", "Udaipur", "Kota", "Ajmer"], n, p=[0.4, 0.2, 0.15, 0.15, 0.1]),
        "plan": rng.choice(["Basic", "Standard", "Premium"], n, p=[0.45, 0.4, 0.15]),
        "contract": rng.choice(["Monthly", "Annual"], n, p=[0.65, 0.35]),
        "payment": rng.choice(["UPI autopay", "Card", "Cash"], n, p=[0.5, 0.2, 0.3]),
        "tenure_months": rng.integers(1, 73, n),
        "complaints_90d": rng.poisson(0.6, n),
        "outage_hours_90d": rng.gamma(1.5, 4, n).round(1),
        "late_payments_12m": rng.poisson(0.5, n),
        "avg_daily_gb": rng.gamma(2, 2.5, n).round(1)})
    logit = (-1.9 + 1.3 * (df["contract"] == "Monthly") - 0.05 * df["tenure_months"]
             + 0.7 * df["complaints_90d"] + 0.07 * df["outage_hours_90d"] + 0.7 * (df["payment"] == "Cash")
             + 0.5 * df["late_payments_12m"] - 0.1 * df["avg_daily_gb"]
             + 0.8 * ((df["plan"] == "Basic") & (df["outage_hours_90d"] > 10)))
    df["churned"] = (rng.random(n) < 1 / (1 + np.exp(-logit))).astype(int)
    df["calls_after_notice"] = np.where(df["churned"] == 1, rng.poisson(2.5, n), 0)
    df.loc[rng.random(n) < 0.03, "avg_daily_gb"] = np.nan
    return df.astype({c: "category" for c in ["city", "plan", "contract", "payment"]})

df = make_customers()
X = df.drop(columns=["customer_id", "churned", "calls_after_notice"])
y = df["churned"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=42, stratify=y)

rule = (X_train["contract"] == "Monthly").astype(int) + (X_train["complaints_90d"] >= 2).astype(int)
print(f"{'business rule':<14} ROC-AUC {roc_auc_score(y_train, rule):.3f}  "
      f"avg precision {average_precision_score(y_train, rule):.3f}")

prep = make_column_transformer(
    (OneHotEncoder(handle_unknown="ignore"), make_column_selector(dtype_include="category")),
    (make_pipeline(SimpleImputer(strategy="median"), StandardScaler()),
     make_column_selector(dtype_include="number")))
models = {
    "dummy": DummyClassifier(),
    "logistic": make_pipeline(prep, LogisticRegression(max_iter=1000)),
    "boosting": HistGradientBoostingClassifier(learning_rate=0.05, max_iter=300, max_leaf_nodes=15,
                                               early_stopping=True, random_state=42),
}
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
for name, model in models.items():
    s = cross_validate(model, X_train, y_train, cv=cv, scoring=["roc_auc", "average_precision"])
    print(f"{name:<14} ROC-AUC {s['test_roc_auc'].mean():.3f}  "
          f"avg precision {s['test_average_precision'].mean():.3f}")
Output
business rule  ROC-AUC 0.641  avg precision 0.260
dummy          ROC-AUC 0.500  avg precision 0.185
logistic       ROC-AUC 0.823  avg precision 0.541
boosting       ROC-AUC 0.807  avg precision 0.510

The dummy's average precision equals the churn rate (0.185), and the business rule reaches only 0.260. Both learned models are far better, and logistic regression is slightly ahead of boosting (0.541 against 0.510). Boosting is not automatically the winner: here the relationships are mostly smooth and additive, which a linear model captures with fewer moving parts. We choose the logistic pipeline, which is also easier to explain to the retention team. From now on, each step builds it with a small make_model() function.

Step 5: From scores to a rupee decision

Ranking is not a decision. Turn the probabilities into "call" or "do not call" with the business numbers from Step 1. The scorer below computes rupees saved compared with calling nobody, and TunedThresholdClassifierCV finds the threshold that maximises it by cross-validation on the training data. For comparison, the formula from Calibration & Cost-Based Thresholds suggests calling when p × 2,600 is greater than (1 − p) × 400, that is when p > 400 / 3,000 ≈ 0.133, provided the probabilities are calibrated. A quick calibration check comes first.

PythonRun it yourself
import numpy as np
import pandas as pd
from sklearn.calibration import calibration_curve
from sklearn.compose import make_column_selector, make_column_transformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import make_scorer
from sklearn.model_selection import TunedThresholdClassifierCV, train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

def make_customers(n=5000, seed=42):
    rng = np.random.default_rng(seed)
    df = pd.DataFrame({
        "customer_id": [f"RJ{100000 + i}" for i in range(n)],
        "city": rng.choice(["Jaipur", "Jodhpur", "Udaipur", "Kota", "Ajmer"], n, p=[0.4, 0.2, 0.15, 0.15, 0.1]),
        "plan": rng.choice(["Basic", "Standard", "Premium"], n, p=[0.45, 0.4, 0.15]),
        "contract": rng.choice(["Monthly", "Annual"], n, p=[0.65, 0.35]),
        "payment": rng.choice(["UPI autopay", "Card", "Cash"], n, p=[0.5, 0.2, 0.3]),
        "tenure_months": rng.integers(1, 73, n),
        "complaints_90d": rng.poisson(0.6, n),
        "outage_hours_90d": rng.gamma(1.5, 4, n).round(1),
        "late_payments_12m": rng.poisson(0.5, n),
        "avg_daily_gb": rng.gamma(2, 2.5, n).round(1)})
    logit = (-1.9 + 1.3 * (df["contract"] == "Monthly") - 0.05 * df["tenure_months"]
             + 0.7 * df["complaints_90d"] + 0.07 * df["outage_hours_90d"] + 0.7 * (df["payment"] == "Cash")
             + 0.5 * df["late_payments_12m"] - 0.1 * df["avg_daily_gb"]
             + 0.8 * ((df["plan"] == "Basic") & (df["outage_hours_90d"] > 10)))
    df["churned"] = (rng.random(n) < 1 / (1 + np.exp(-logit))).astype(int)
    df["calls_after_notice"] = np.where(df["churned"] == 1, rng.poisson(2.5, n), 0)
    df.loc[rng.random(n) < 0.03, "avg_daily_gb"] = np.nan
    return df.astype({c: "category" for c in ["city", "plan", "contract", "payment"]})

def make_model():
    prep = make_column_transformer(
        (OneHotEncoder(handle_unknown="ignore"), make_column_selector(dtype_include="category")),
        (make_pipeline(SimpleImputer(strategy="median"), StandardScaler()),
         make_column_selector(dtype_include="number")))
    return make_pipeline(prep, LogisticRegression(max_iter=1000))

df = make_customers()
X = df.drop(columns=["customer_id", "churned", "calls_after_notice"])
y = df["churned"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=42, stratify=y)

VALUE_OF_SAVE, OFFER = 2600, 400          # net value of calling a churner; cost of calling a stayer
def rupees_saved(y_true, y_pred):
    called = y_pred == 1
    return int(((y_true == 1) & called).sum() * VALUE_OF_SAVE - ((y_true == 0) & called).sum() * OFFER)

tuned = TunedThresholdClassifierCV(make_model(), scoring=make_scorer(rupees_saved), cv=5).fit(X_train, y_train)
model = make_model().fit(X_train, y_train)

proba = model.predict_proba(X_test)[:, 1]
observed, predicted = calibration_curve(y_test, proba, n_bins=5, strategy="quantile")
print("Calibration  predicted:", predicted.round(2), " observed:", observed.round(2))
print(f"Tuned threshold: {tuned.best_threshold_:.3f}")

for name, threshold in [("default 0.5", 0.5), ("formula 0.133", 0.133), ("tuned", tuned.best_threshold_)]:
    call = (proba >= threshold).astype(int)
    print(f"{name:<14} calls {call.sum():>4}  rupees saved ₹{rupees_saved(y_test, call):,}")
Output
Calibration  predicted: [0.02 0.06 0.12 0.23 0.51]  observed: [0.02 0.05 0.16 0.22 0.48]
Tuned threshold: 0.135
default 0.5    calls  107  rupees saved ₹164,200
formula 0.133  calls  571  rupees saved ₹335,600
tuned          calls  569  rupees saved ₹336,400

The calibration check is reassuring: in every risk band, predicted and observed churn rates are close (0.51 against 0.48 in the top band), so the formula can be trusted, and indeed cross-validation picks 0.135, almost exactly the formula's 0.133. The default 0.5 calls only 107 customers and saves ₹1.64 lakh. The rupee-based threshold calls about 570 and saves about ₹3.36 lakh on the 1,250 test customers: double the money from the same model, just by choosing the threshold from the business numbers.

Step 6: Explain and check segments

Before anyone acts on the model, check what it relies on and whether it works equally well for different groups of customers. Permutation importance (scored with average precision on the test set) shows the drivers. Then compare recall and precision for each city at the chosen threshold: a model that misses most churners in Kota while doing well in Jaipur would make the retention budget unfair and less useful.

PythonRun it yourself
import numpy as np
import pandas as pd
from sklearn.compose import make_column_selector, make_column_transformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.inspection import permutation_importance
from sklearn.metrics import precision_score, recall_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

def make_customers(n=5000, seed=42):
    rng = np.random.default_rng(seed)
    df = pd.DataFrame({
        "customer_id": [f"RJ{100000 + i}" for i in range(n)],
        "city": rng.choice(["Jaipur", "Jodhpur", "Udaipur", "Kota", "Ajmer"], n, p=[0.4, 0.2, 0.15, 0.15, 0.1]),
        "plan": rng.choice(["Basic", "Standard", "Premium"], n, p=[0.45, 0.4, 0.15]),
        "contract": rng.choice(["Monthly", "Annual"], n, p=[0.65, 0.35]),
        "payment": rng.choice(["UPI autopay", "Card", "Cash"], n, p=[0.5, 0.2, 0.3]),
        "tenure_months": rng.integers(1, 73, n),
        "complaints_90d": rng.poisson(0.6, n),
        "outage_hours_90d": rng.gamma(1.5, 4, n).round(1),
        "late_payments_12m": rng.poisson(0.5, n),
        "avg_daily_gb": rng.gamma(2, 2.5, n).round(1)})
    logit = (-1.9 + 1.3 * (df["contract"] == "Monthly") - 0.05 * df["tenure_months"]
             + 0.7 * df["complaints_90d"] + 0.07 * df["outage_hours_90d"] + 0.7 * (df["payment"] == "Cash")
             + 0.5 * df["late_payments_12m"] - 0.1 * df["avg_daily_gb"]
             + 0.8 * ((df["plan"] == "Basic") & (df["outage_hours_90d"] > 10)))
    df["churned"] = (rng.random(n) < 1 / (1 + np.exp(-logit))).astype(int)
    df["calls_after_notice"] = np.where(df["churned"] == 1, rng.poisson(2.5, n), 0)
    df.loc[rng.random(n) < 0.03, "avg_daily_gb"] = np.nan
    return df.astype({c: "category" for c in ["city", "plan", "contract", "payment"]})

def make_model():
    prep = make_column_transformer(
        (OneHotEncoder(handle_unknown="ignore"), make_column_selector(dtype_include="category")),
        (make_pipeline(SimpleImputer(strategy="median"), StandardScaler()),
         make_column_selector(dtype_include="number")))
    return make_pipeline(prep, LogisticRegression(max_iter=1000))

df = make_customers()
X = df.drop(columns=["customer_id", "churned", "calls_after_notice"])
y = df["churned"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=42, stratify=y)
model = make_model().fit(X_train, y_train)

result = permutation_importance(model, X_test, y_test, scoring="average_precision",
                                n_repeats=10, random_state=42)
importance = pd.Series(result.importances_mean, index=X.columns).sort_values(ascending=False)
print("Drop in average precision when shuffled:")
print(importance.round(3).to_string())

THRESHOLD = 0.133
call = model.predict_proba(X_test)[:, 1] >= THRESHOLD
report = pd.DataFrame({"city": X_test["city"], "churned": y_test, "call": call})
rows = []
for city, part in report.groupby("city", observed=True):
    rows.append({"city": city, "customers": len(part), "churn rate": round(part["churned"].mean(), 3),
                 "recall": round(recall_score(part["churned"], part["call"]), 3),
                 "precision": round(precision_score(part["churned"], part["call"]), 3)})
print(pd.DataFrame(rows).to_string(index=False))
Output
Drop in average precision when shuffled:
tenure_months        0.218
contract             0.090
complaints_90d       0.073
late_payments_12m    0.058
outage_hours_90d     0.046
payment              0.030
avg_daily_gb         0.020
plan                -0.000
city                -0.002
   city  customers  churn rate  recall  precision
  Ajmer        132       0.167   0.909      0.323
 Jaipur        496       0.185   0.793      0.319
Jodhpur        253       0.202   0.745      0.380
   Kota        186       0.156   0.862      0.312
Udaipur        183       0.202   0.865      0.320

Tenure is by far the strongest driver, followed by contract type and recent complaints, which matches what the business believes: a good sign. plan and city add nothing. Across cities, recall stays between 0.75 and 0.91 and precision between 0.31 and 0.38. With only 132 test customers in Ajmer, differences of a few points are noise, so the model looks consistent across cities. Record these numbers in the model card and check them again after deployment.

Step 7: Respect capacity, save and use the model

The team can make at most 400 calls a month. If more customers pass the threshold, call the highest-risk ones first. Train the final model on all the training data, check it once on the test set, save it with joblib (see Saving & Loading Models), and use it the way the monthly job will: load, score the new snapshot, sort, cut at the threshold and the capacity.

PythonRun it yourself
import joblib
import numpy as np
import pandas as pd
from sklearn.compose import make_column_selector, make_column_transformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import average_precision_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

def make_customers(n=5000, seed=42):
    rng = np.random.default_rng(seed)
    df = pd.DataFrame({
        "customer_id": [f"RJ{100000 + i}" for i in range(n)],
        "city": rng.choice(["Jaipur", "Jodhpur", "Udaipur", "Kota", "Ajmer"], n, p=[0.4, 0.2, 0.15, 0.15, 0.1]),
        "plan": rng.choice(["Basic", "Standard", "Premium"], n, p=[0.45, 0.4, 0.15]),
        "contract": rng.choice(["Monthly", "Annual"], n, p=[0.65, 0.35]),
        "payment": rng.choice(["UPI autopay", "Card", "Cash"], n, p=[0.5, 0.2, 0.3]),
        "tenure_months": rng.integers(1, 73, n),
        "complaints_90d": rng.poisson(0.6, n),
        "outage_hours_90d": rng.gamma(1.5, 4, n).round(1),
        "late_payments_12m": rng.poisson(0.5, n),
        "avg_daily_gb": rng.gamma(2, 2.5, n).round(1)})
    logit = (-1.9 + 1.3 * (df["contract"] == "Monthly") - 0.05 * df["tenure_months"]
             + 0.7 * df["complaints_90d"] + 0.07 * df["outage_hours_90d"] + 0.7 * (df["payment"] == "Cash")
             + 0.5 * df["late_payments_12m"] - 0.1 * df["avg_daily_gb"]
             + 0.8 * ((df["plan"] == "Basic") & (df["outage_hours_90d"] > 10)))
    df["churned"] = (rng.random(n) < 1 / (1 + np.exp(-logit))).astype(int)
    df["calls_after_notice"] = np.where(df["churned"] == 1, rng.poisson(2.5, n), 0)
    df.loc[rng.random(n) < 0.03, "avg_daily_gb"] = np.nan
    return df.astype({c: "category" for c in ["city", "plan", "contract", "payment"]})

def make_model():
    prep = make_column_transformer(
        (OneHotEncoder(handle_unknown="ignore"), make_column_selector(dtype_include="category")),
        (make_pipeline(SimpleImputer(strategy="median"), StandardScaler()),
         make_column_selector(dtype_include="number")))
    return make_pipeline(prep, LogisticRegression(max_iter=1000))

df = make_customers()
FEATURES = ["city", "plan", "contract", "payment", "tenure_months", "complaints_90d",
            "outage_hours_90d", "late_payments_12m", "avg_daily_gb"]
X_train, X_test, y_train, y_test = train_test_split(
    df[FEATURES], df["churned"], test_size=0.25, random_state=42, stratify=df["churned"])
model = make_model().fit(X_train, y_train)
print(f"Final test average precision: {average_precision_score(y_test, model.predict_proba(X_test)[:, 1]):.3f}")

joblib.dump({"model": model, "features": FEATURES, "threshold": 0.133, "capacity": 400,
             "version": "churn-2026-11"}, "churn_model.joblib")

# The monthly job: load, score next month's snapshot, choose whom to call
bundle = joblib.load("churn_model.joblib")
snapshot = make_customers(n=3000, seed=7)                 # stands in for next month's customers
risk = bundle["model"].predict_proba(snapshot[bundle["features"]])[:, 1]
scored = snapshot.assign(risk=risk).sort_values("risk", ascending=False)
call_list = scored[scored["risk"] >= bundle["threshold"]].head(bundle["capacity"])
print(f"Customers above threshold: {(scored['risk'] >= bundle['threshold']).sum()}, calling {len(call_list)}")
print(f"Churn rate in call list: {call_list['churned'].mean():.1%} (all customers: {snapshot['churned'].mean():.1%})")
print(call_list[["customer_id", "city", "contract", "complaints_90d", "risk"]].head(4).round(3).to_string(index=False))
Output
Final test average precision: 0.538
Customers above threshold: 1360, calling 400
Churn rate in call list: 54.8% (all customers: 18.5%)
customer_id    city contract  complaints_90d  risk
   RJ100338 Jodhpur  Monthly               6 0.990
   RJ100497  Jaipur  Monthly               4 0.972
   RJ100333  Jaipur  Monthly               1 0.939
   RJ101535   Ajmer  Monthly               1 0.911

The test score (0.538 average precision) matches the cross-validated 0.541, so our choices did not overfit. On the new snapshot, 1,360 customers pass the threshold, far more than the team can call, so the capacity rule keeps the 400 riskiest. Of those, 54.8% really churn, about three times the 18.5% base rate. (We can check this only because the data is synthetic; in real life you learn the outcome 90 days later.) The top of the list looks as expected: monthly contracts and several complaints.

Step 8: Write the model card

A model nobody can audit becomes a liability. Store a short model card next to the saved file:

Text
Model:        churn-2026-11, logistic regression pipeline (scikit-learn 1.8):
              one-hot categories, median-imputed and scaled numbers
Purpose:      Rank broadband customers by 90-day churn risk for retention calls.
              Not for pricing, credit or any decision that penalises a customer.
Data:         5,000-customer snapshot; features known on the snapshot date only.
Removed:      customer_id (identifier), calls_after_notice (recorded after the
              cancellation notice: target leakage).
Performance:  Test average precision and rupees saved recorded in Step 7;
              cross-validated comparison with a business rule and logistic regression in Step 4.
Decision:     Call if risk >= 0.133, highest risk first, at most 400 calls a month.
Segments:     Recall and precision by city checked in Step 6; re-check every quarter.
Monitoring:   Monthly drift report on inputs; actual churn of called and not-called
              customers; retrain when rupees saved falls for two months in a row.
Owner:        Data science team; questions to the customer experience head.

Where to go next

  • Add time-based validation: train on older snapshots and test on the latest, as in Time Series Forecasting with ML.
  • Put the monthly scoring job behind the checks and drift monitoring from MLOps Basics.
  • Model uplift (the change in churn caused by a call) once you have results from the control group.

Common mistakes

  • Starting with the model before agreeing on the decision, costs and capacity.
  • Keeping a column because it is "highly predictive" without asking when it is recorded.
  • Reporting accuracy for an 18% positive class; use average precision and rupees saved.
  • Using the default 0.5 threshold when the costs of the two mistakes are very different.
  • Skipping segment checks, which can hide a model that fails for one city or plan.
  • Claiming the model saved money without a control group.

Key points

  • Frame the decision, target, prediction point, money and capacity before writing code.
  • Screen every column for target leakage; a column recorded after the outcome must go.
  • Compare a dummy, a business rule, a linear model and gradient boosting with the same cross-validation.
  • Turn probabilities into actions with a threshold chosen from rupee costs, then apply the capacity limit.
  • Explain the model and check its performance per segment before anyone acts on it.
  • Save the model with its features, threshold and version, and document it in a model card.

Exercise

The finance team revises the numbers: the offer now costs ₹250, and the value of keeping a churner rises to ₹8,000 (still with a 50% success rate, so calling a churner is worth ₹3,750 net). Recompute the formula threshold, retune the threshold with TunedThresholdClassifierCV and the updated scorer, and print the number of calls and the rupees saved on the test set for both thresholds.

Show solution
Python
import numpy as np
import pandas as pd
from sklearn.compose import make_column_selector, make_column_transformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import make_scorer
from sklearn.model_selection import TunedThresholdClassifierCV, train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

def make_customers(n=5000, seed=42):
    rng = np.random.default_rng(seed)
    df = pd.DataFrame({
        "customer_id": [f"RJ{100000 + i}" for i in range(n)],
        "city": rng.choice(["Jaipur", "Jodhpur", "Udaipur", "Kota", "Ajmer"], n, p=[0.4, 0.2, 0.15, 0.15, 0.1]),
        "plan": rng.choice(["Basic", "Standard", "Premium"], n, p=[0.45, 0.4, 0.15]),
        "contract": rng.choice(["Monthly", "Annual"], n, p=[0.65, 0.35]),
        "payment": rng.choice(["UPI autopay", "Card", "Cash"], n, p=[0.5, 0.2, 0.3]),
        "tenure_months": rng.integers(1, 73, n),
        "complaints_90d": rng.poisson(0.6, n),
        "outage_hours_90d": rng.gamma(1.5, 4, n).round(1),
        "late_payments_12m": rng.poisson(0.5, n),
        "avg_daily_gb": rng.gamma(2, 2.5, n).round(1)})
    logit = (-1.9 + 1.3 * (df["contract"] == "Monthly") - 0.05 * df["tenure_months"]
             + 0.7 * df["complaints_90d"] + 0.07 * df["outage_hours_90d"] + 0.7 * (df["payment"] == "Cash")
             + 0.5 * df["late_payments_12m"] - 0.1 * df["avg_daily_gb"]
             + 0.8 * ((df["plan"] == "Basic") & (df["outage_hours_90d"] > 10)))
    df["churned"] = (rng.random(n) < 1 / (1 + np.exp(-logit))).astype(int)
    df["calls_after_notice"] = np.where(df["churned"] == 1, rng.poisson(2.5, n), 0)
    df.loc[rng.random(n) < 0.03, "avg_daily_gb"] = np.nan
    return df.astype({c: "category" for c in ["city", "plan", "contract", "payment"]})

def make_model():
    prep = make_column_transformer(
        (OneHotEncoder(handle_unknown="ignore"), make_column_selector(dtype_include="category")),
        (make_pipeline(SimpleImputer(strategy="median"), StandardScaler()),
         make_column_selector(dtype_include="number")))
    return make_pipeline(prep, LogisticRegression(max_iter=1000))

df = make_customers()
X = df.drop(columns=["customer_id", "churned", "calls_after_notice"])
y = df["churned"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=42, stratify=y)

VALUE_OF_SAVE, OFFER = 0.5 * 8000 - 250, 250
def rupees_saved(y_true, y_pred):
    called = y_pred == 1
    return int(((y_true == 1) & called).sum() * VALUE_OF_SAVE - ((y_true == 0) & called).sum() * OFFER)

formula = OFFER / (OFFER + VALUE_OF_SAVE)
tuned = TunedThresholdClassifierCV(make_model(), scoring=make_scorer(rupees_saved), cv=5).fit(X_train, y_train)
proba = make_model().fit(X_train, y_train).predict_proba(X_test)[:, 1]

for name, t in [(f"formula {formula:.3f}", formula), (f"tuned {tuned.best_threshold_:.3f}", tuned.best_threshold_)]:
    call = (proba >= t).astype(int)
    print(f"{name:<14} calls {call.sum():>4}  rupees saved ₹{rupees_saved(y_test, call):,}")
Output
formula 0.062  calls  843  rupees saved ₹673,250
tuned 0.077    calls  761  rupees saved ₹665,750

The cheaper offer and higher value push the formula threshold down to 0.062, so many more customers are worth calling (843 in the test set). Cross-validation picks a similar 0.077, and both save about ₹6.7 lakh. With only 400 calls a month, capacity now limits the savings even more, and this analysis gives the manager a rupee figure to justify more callers.

Quick Quiz

1. Which column in this project had to be removed as target leakage?

2. Why is average precision used to compare models here instead of accuracy?

3. Calling a churner is worth ₹2,600 net and calling a stayer costs ₹400. With calibrated probabilities, above which probability should you call?

4. How can the company prove that the retention calls actually reduce churn?

Learn Machine Learning with a mentor

Free lessons get you started. Our Data Science & Machine Learning training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call