← Back to tutorial Case Lab A / Executed notebook All case labs

CASE LAB A / CUSTOMER CHURN

Customer Churn

A binary-classification lab that compares logistic regression with a decision tree and shows why the decision threshold matters.

CRISP-DM question

Which customers should receive a limited retention offer?

The target is whether a customer churns in the next 30 days. Accuracy alone is not enough: the campaign budget limits how many customers can be contacted.

Learning objective: compare a transparent baseline with a small tree model and explain why the decision threshold changes the result.

In [1]:
from pathlib import Path
import sys

ROOT = Path.cwd()
if not (ROOT / "src").exists():
    ROOT = ROOT.parent
sys.path.insert(0, str(ROOT / "src"))

import pandas as pd
import matplotlib.pyplot as plt
from IPython.display import Image, display

from tutorial.data import write_datasets

plt.rcParams.update({
    "figure.dpi": 150,
    "font.family": "serif",
    "font.serif": ["Times New Roman", "STIXGeneral", "DejaVu Serif"],
    "mathtext.fontset": "stix",
    "axes.grid": True,
    "grid.color": "#c8c8c8",
    "grid.linewidth": 0.5,
    "grid.alpha": 0.7,
    "axes.spines.top": False,
    "axes.spines.right": False,
    "axes.labelsize": 10,
    "axes.titlesize": 10.5,
    "lines.linewidth": 1.4,
})

paths = write_datasets(ROOT / "data")
print(f"Project root: {ROOT.name}")
Project root: data-science-crisp-dm-tutorial
In [2]:
df = pd.read_csv(paths["churn"])
df.head()
Out[2]:
tenure_months monthly_charge support_calls usage_hours contract autopay region churned
0 7 96.959000 3 15.037224 One year Yes East 1
1 56 66.949653 4 36.062132 Two year Yes West 0
2 48 79.269087 2 13.763179 Month-to-month No West 1
3 32 23.801561 1 16.196846 Month-to-month Yes North 0
4 32 48.873047 0 26.560274 One year Yes East 0
In [3]:
print(df.info())
print("\nMissing share:")
print(df.isna().mean().sort_values(ascending=False))
print("\nTarget distribution:")
print(df["churned"].value_counts(normalize=True).rename("share"))
<class 'pandas.DataFrame'>
RangeIndex: 1200 entries, 0 to 1199
Data columns (total 8 columns):
 #   Column          Non-Null Count  Dtype  
---  ------          --------------  -----  
 0   tenure_months   1200 non-null   int64  
 1   monthly_charge  1182 non-null   float64
 2   support_calls   1200 non-null   int64  
 3   usage_hours     1200 non-null   float64
 4   contract        1200 non-null   str    
 5   autopay         1191 non-null   str    
 6   region          1200 non-null   str    
 7   churned         1200 non-null   int64  
dtypes: float64(2), int64(3), str(3)
memory usage: 75.1 KB
None

Missing share:
monthly_charge    0.0150
autopay           0.0075
support_calls     0.0000
tenure_months     0.0000
usage_hours       0.0000
contract          0.0000
region            0.0000
churned           0.0000
dtype: float64

Target distribution:
churned
0    0.578333
1    0.421667
Name: share, dtype: float64

Figure A0 | Missing values by field. Source: synthetic tutorial data; one row represents one customer. The plot makes the preparation choices visible before any model is fitted.

In [4]:
display(Image(filename=str(ROOT / "assets" / "img" / "missing-values.png")))
Bar chart showing missing values by customer field.

Build the model comparison directly

The cells below expose the full learning path: split, preprocessing, fit, predict and evaluate. Keeping preprocessing inside a Pipeline ensures that imputation and scaling are learned from the training data only.

In [5]:
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
from sklearn.tree import DecisionTreeClassifier

X = df.drop(columns="churned")
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,
)

numeric = ["tenure_months", "monthly_charge", "support_calls", "usage_hours"]
categorical = ["contract", "autopay", "region"]

preprocess = ColumnTransformer([
    ("numeric", Pipeline([
        ("impute", SimpleImputer(strategy="median")),
        ("scale", StandardScaler()),
    ]), numeric),
    ("categorical", Pipeline([
        ("impute", SimpleImputer(strategy="most_frequent")),
        ("encode", OneHotEncoder(handle_unknown="ignore")),
    ]), categorical),
])

The split happens before any transformation. preprocess is then placed inside each model Pipeline, so its medians, category levels and scaling statistics are fitted on X_train only. The tree and logistic models therefore share the same information boundary.

In [6]:
logistic = Pipeline([
    ("prepare", preprocess),
    ("model", LogisticRegression(max_iter=1_000, random_state=42)),
])

tree = Pipeline([
    ("prepare", preprocess),
    ("model", DecisionTreeClassifier(
        max_depth=4,
        min_samples_leaf=18,
        random_state=42,
    )),
])

_ = logistic.fit(X_train, y_train)
_ = tree.fit(X_train, y_train)
In [7]:
from sklearn.metrics import (
    accuracy_score, confusion_matrix, f1_score,
    precision_score, recall_score, roc_auc_score,
)

threshold = 0.40
logistic_probability = logistic.predict_proba(X_test)[:, 1]
tree_probability = tree.predict_proba(X_test)[:, 1]
logistic_prediction = (logistic_probability >= threshold).astype(int)
tree_prediction = (tree_probability >= threshold).astype(int)

def evaluate_classifier(y_true, prediction, probability):
    return {
        "accuracy": accuracy_score(y_true, prediction),
        "precision": precision_score(y_true, prediction, zero_division=0),
        "recall": recall_score(y_true, prediction, zero_division=0),
        "f1": f1_score(y_true, prediction, zero_division=0),
        "roc_auc": roc_auc_score(y_true, probability),
    }

pd.DataFrame({
    "logistic regression": evaluate_classifier(y_test, logistic_prediction, logistic_probability),
    "decision tree": evaluate_classifier(y_test, tree_prediction, tree_probability),
}).T
Out[7]:
accuracy precision recall f1 roc_auc
logistic regression 0.713333 0.628205 0.777778 0.695035 0.770662
decision tree 0.623333 0.541401 0.674603 0.600707 0.676108

At the selected threshold, the model with higher recall finds more churners but may also create more false alerts. F1 balances precision and recall with equal weight; that weighting is a reporting choice, not a universal business objective.

Check split sensitivity with cross-validation

A single holdout result can be lucky. The following cells refit the full Pipeline in five stratified folds and report the mean and standard deviation of ROC AUC. This is a stability check, not a replacement for keeping one untouched confirmation set.

In [8]:
from sklearn.model_selection import StratifiedKFold, cross_validate

fold_protocol = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

def cv_auc(model):
    score = cross_validate(model, X, y, cv=fold_protocol, scoring="roc_auc")["test_score"]
    return pd.Series({"mean_auc": score.mean(), "std_auc": score.std(), "folds": len(score)})

pd.DataFrame({
    "logistic regression": cv_auc(logistic),
    "decision tree": cv_auc(tree),
}).T
Out[8]:
mean_auc std_auc folds
logistic regression 0.734288 0.029795 5.0
decision tree 0.705703 0.042458 5.0
In [9]:
confusion = confusion_matrix(y_test, logistic_prediction, labels=[0, 1])
pd.DataFrame(
    confusion,
    index=["Actual stay", "Actual churn"],
    columns=["Predicted stay", "Predicted churn"],
)
Out[9]:
Predicted stay Predicted churn
Actual stay 116 58
Actual churn 28 98

Figure A1 | Confusion matrix at the selected threshold. Source: tutorial calculations on the synthetic churn holdout set.

In [10]:
display(Image(filename=str(ROOT / "assets" / "img" / "churn-confusion.png")))
Confusion matrix for the logistic-regression churn model.

Figure A2 | Precision, recall, and F1 as the threshold changes. Source: tutorial calculations on the holdout set. The chosen threshold is a decision rule, not a universal constant.

In [11]:
display(Image(filename=str(ROOT / "assets" / "img" / "churn-thresholds.png")))
Line chart showing precision, recall, and F1 across thresholds.

Figure A3 | Baseline and tree model compared on one holdout split. Source: tutorial calculations. The comparison is valid because both models use the same split, preprocessing protocol, and threshold.

In [12]:
display(Image(filename=str(ROOT / "assets" / "img" / "model-comparison.png")))
Grouped bar chart comparing logistic regression and a decision tree.

Interpretation and exercise

  1. Which model has higher Recall at the selected threshold?
  2. What happens if you lower the threshold from 0.40 to 0.30?
  3. Which error is more expensive for the business: a missed churner or an unnecessary retention offer?

The synthetic relationship is only a teaching device. It is not evidence about real customers.