Customer Churn Prediction with XGBoost
Predict customer churn, explain decisions with SHAP, and quantify revenue at risk.
Introduction
30 Second Summary
Every subscription service you use faces the same problem. Some customers leave, taking their monthly payments with them, and the company scrambles to figure out why.
In this project, you will build a churn prediction and revenue retention analytics platform that takes raw customer data, predicts who will leave, explains why, and quantifies the dollar impact for the business.
What You'll Build
A complete Jupyter notebook that trains a machine learning model on telecom customer data, then translates the predictions into business-ready insights including revenue at risk, customer risk tiers, and retention campaign ROI.
By the end of this project, you'll have:
- A trained XGBoost churn prediction model achieving ROC-AUC > 0.80 with proper class imbalance handling and cross-validated evaluation.
- A revenue-at-risk analysis quantifying the dollar impact of churn with customer risk segmentation and a simulated retention campaign ROI.
- SHAP explainability plots showing exactly which factors drive individual customer churn predictions.
- Secret Mission: Build an interactive Streamlit dashboard that presents model findings with KPI cards, a risk explorer, and per-customer explainability.
Are there any prerequisites?
You only need basic Python familiarity (variables, functions, loops). No machine learning experience is required.
Everything runs in Google Colab, so there is nothing to install on your computer and no cost involved.
Before We Start
Before We Start...
Set Up Your Environment and Load Data
Every machine learning project starts with the same question: do you have the right tools and the right data? Before you can predict which customers will churn, you need a workspace with the correct libraries installed and a clean dataset ready for analysis.
In this step, you will set up a Google Colab notebook, install three specialized libraries, load the Telco Customer Churn dataset, and clean it so it is ready for exploration and modeling.
In this step, get ready to:
- Install XGBoost, SHAP, and imbalanced-learn in your Colab notebook.
- Load the Telco Customer Churn dataset and confirm its shape.
- Clean the data so it is ready for analysis with zero missing values.
Open Google Colab and create a new notebook
Google Colab gives you a free cloud-based Jupyter notebook environment with most data science libraries pre-installed. You only need to add a few specialized packages.
- Open your browser and navigate to colab.research.google.com.
- Sign in with your Google account if prompted.
- Click File in the top menu bar, then select New notebook.
- Click the notebook title at the top left (it defaults to Untitled0.ipynb) and rename it to churn_analytics.ipynb.
What is Google Colab?
Google Colab is a free, browser-based notebook environment that runs Python code on Google's servers. It comes with pandas, NumPy, scikit-learn, matplotlib, and seaborn pre-installed.
You only need to install packages that are not part of the default environment. Your notebook auto-saves to Google Drive.
Install libraries and import packages
Three libraries are not pre-installed in Colab: XGBoost (the model), SHAP (explainability), and imbalanced-learn (class imbalance handling). You will install them first, then import everything you need for the project.
- In the first cell of your notebook, paste the following code and run it by pressing Shift+Enter:
# Cell 1: Setup and Installation
!pip install xgboost shap imbalanced-learn -q
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.model_selection import train_test_split, StratifiedKFold, cross_val_score
from sklearn.preprocessing import StandardScaler, LabelEncoder
from sklearn.metrics import (
roc_auc_score, classification_report, confusion_matrix,
roc_curve, precision_recall_curve, f1_score
)
from imblearn.over_sampling import SMOTE
import xgboost as xgb
import shap
import warnings
warnings.filterwarnings('ignore')
print("All libraries loaded successfully!")
What does this code do?
- `!pip install xgboost shap imbalanced-learn -q` installs the three packages not already in Colab. The -q flag keeps the output quiet so it does not clutter your notebook.
- The import block brings in pandas for data manipulation, NumPy for math, matplotlib and seaborn for plotting, scikit-learn for model evaluation and preprocessing, SMOTE for handling class imbalance, XGBoost for the classifier, and SHAP for explainability.
- `warnings.filterwarnings('ignore')` suppresses non-critical deprecation warnings so your output stays clean.
- After the cell finishes running, confirm you see All libraries loaded successfully! printed below the cell.
Seeing an installation error?
- If you see a red error mentioning a version conflict, try restarting the runtime by clicking Runtime then Restart runtime, and run the cell again.
- Make sure you typed the exclamation mark ! before pip. This tells Colab to run the command as a shell command.
Help me debug my Colab library installation error
Load the dataset and inspect it
The Telco Customer Churn dataset contains records for 7,043 telecom customers. Each row represents one customer with their service details, billing info, and whether they churned.
- Create a new code cell by clicking the + Code button at the top of the notebook.
- Paste the following code and run it with Shift+Enter:
# Cell 2: Load and Inspect Data
url = "https://raw.githubusercontent.com/IBM/telco-customer-churn-on-icp4d/master/data/Telco-Customer-Churn.csv"
df = pd.read_csv(url)
print(f"Dataset shape: {df.shape}")
print(f"\nColumn names:\n{df.columns.tolist()}")
print(f"\nTarget distribution:")
print(df['Churn'].value_counts(normalize=True))
What does this code do?
- The URL points to the CSV file hosted on IBM's GitHub repository. This avoids needing a Kaggle account or manual file upload.
- `pd.read_csv(url)` downloads the CSV directly into a pandas DataFrame called df.
- `value_counts(normalize=True)` shows the proportion of customers who churned vs. stayed, giving you the class distribution of your target variable.
- Confirm the output shows Dataset shape: (7043, 21).
- Check the target distribution. You should see approximately 73.5% No and 26.5% Yes for the Churn column.
Seeing a connection error?
- If the URL fails to load, check that your Colab runtime is connected by looking at the green checkmark next to Connected in the top right.
- Try clicking Runtime then Reconnect and run the cell again.
Help me fix a connection error when loading data in Colab
Clean the data for analysis
Raw datasets almost always have issues that need fixing. This dataset has blank strings hiding in the TotalCharges column, a non-predictive ID column, and a text-based target variable that needs to become numeric.
- Create a new code cell and paste the following cleaning code:
# Cell 3: Data Cleaning
# TotalCharges has blank strings that need conversion
df['TotalCharges'] = pd.to_numeric(df['TotalCharges'], errors='coerce')
# Fill missing TotalCharges (new customers with 0 tenure)
df['TotalCharges'].fillna(0, inplace=True)
# Drop customerID (not predictive)
df.drop('customerID', axis=1, inplace=True)
# Convert target to binary
df['Churn'] = df['Churn'].map({'Yes': 1, 'No': 0})
print(f"Missing values after cleaning: {df.isnull().sum().sum()}")
print(f"Churn rate: {df['Churn'].mean():.3f} ({df['Churn'].mean()*100:.1f}%)")
What does this code do?
- `pd.to_numeric(errors='coerce')` converts TotalCharges from strings to numbers. Blank strings become NaN (not-a-number) instead of crashing the program.
- `fillna(0)` replaces those NaN values with 0. These are new customers with zero tenure who have not been charged yet.
- Dropping customerID removes a column that is just a unique label. It has no predictive power for churn.
- Mapping Churn to 1 and 0 turns the text labels into numbers that a machine learning model can work with.
- Run the cell and confirm the output shows Missing values after cleaning: 0.
- Verify the churn rate shows approximately 0.265 (26.5%). This means about 1 in 4 customers left the service.
Seeing a KeyError for 'Churn' or 'customerID'?
- This means Cell 2 did not run successfully. Go back and run Cell 2 first to load the DataFrame.
- In Colab, cells must be run in order. If you restart the runtime, you need to re-run all previous cells from the top.
Help me fix a KeyError in my Colab notebook
✔️ Awesome, I've got everything!
Great. Double check you have 3 cells in your notebook and that each one ran without errors.
ⓧ I'd like to double check the full code
Here is the complete code for all three cells. Compare yours against this:
# Cell 1: Setup and Installation
!pip install xgboost shap imbalanced-learn -q
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.model_selection import train_test_split, StratifiedKFold, cross_val_score
from sklearn.preprocessing import StandardScaler, LabelEncoder
from sklearn.metrics import (
roc_auc_score, classification_report, confusion_matrix,
roc_curve, precision_recall_curve, f1_score
)
from imblearn.over_sampling import SMOTE
import xgboost as xgb
import shap
import warnings
warnings.filterwarnings('ignore')
print("All libraries loaded successfully!")
# Cell 2: Load and Inspect Data
url = "https://raw.githubusercontent.com/IBM/telco-customer-churn-on-icp4d/master/data/Telco-Customer-Churn.csv"
df = pd.read_csv(url)
print(f"Dataset shape: {df.shape}")
print(f"\nColumn names:\n{df.columns.tolist()}")
print(f"\nTarget distribution:")
print(df['Churn'].value_counts(normalize=True))
# Cell 3: Data Cleaning
# TotalCharges has blank strings that need conversion
df['TotalCharges'] = pd.to_numeric(df['TotalCharges'], errors='coerce')
# Fill missing TotalCharges (new customers with 0 tenure)
df['TotalCharges'].fillna(0, inplace=True)
# Drop customerID (not predictive)
df.drop('customerID', axis=1, inplace=True)
# Convert target to binary
df['Churn'] = df['Churn'].map({'Yes': 1, 'No': 0})
print(f"Missing values after cleaning: {df.isnull().sum().sum()}")
print(f"Churn rate: {df['Churn'].mean():.3f} ({df['Churn'].mean()*100:.1f}%)")
Your workspace is set up and your data is clean. Next up, you will explore this data visually to uncover the patterns that drive customer churn.
Explore the Data and Engineer Features
Your dataset is cleaned and ready with 7,043 customers and a clear binary target. Before training a model, you need to understand what patterns exist in the data and create features that capture those patterns.
In this step, you will visualize the key churn drivers, engineer new predictive features from the raw columns, and prepare a train/test split that preserves the churn ratio for fair evaluation.
In this step, get ready to:
- Visualize churn patterns across contract types, payment methods, and customer tenure.
- Engineer four domain-specific features that capture churn risk signals.
- Prepare the modeling dataset with encoding and a stratified train/test split.
Visualize churn patterns with exploratory data analysis
Raw numbers only tell part of the story. Visualizing churn across different customer segments reveals which factors have the strongest relationship with customers leaving.
- Create a new code cell in your notebook and add the following EDA code:
# Cell 4: Exploratory Data Analysis
fig, axes = plt.subplots(2, 2, figsize=(14, 10))
# Churn by Contract Type
contract_churn = df.groupby('Contract')['Churn'].mean().sort_values(ascending=False)
contract_churn.plot(kind='bar', ax=axes[0, 0], color=['#e74c3c', '#f39c12', '#27ae60'])
axes[0, 0].set_title('Churn Rate by Contract Type')
axes[0, 0].set_ylabel('Churn Rate')
axes[0, 0].set_xticklabels(axes[0, 0].get_xticklabels(), rotation=0)
# Churn by Payment Method
payment_churn = df.groupby('PaymentMethod')['Churn'].mean().sort_values(ascending=False)
payment_churn.plot(kind='bar', ax=axes[0, 1], color=['#e74c3c', '#f39c12', '#3498db', '#27ae60'])
axes[0, 1].set_title('Churn Rate by Payment Method')
axes[0, 1].set_ylabel('Churn Rate')
axes[0, 1].set_xticklabels(axes[0, 1].get_xticklabels(), rotation=45, ha='right')
# Tenure Distribution by Churn
axes[1, 0].hist(df[df['Churn']==0]['tenure'], bins=30, alpha=0.7, label='Retained', color='#27ae60')
axes[1, 0].hist(df[df['Churn']==1]['tenure'], bins=30, alpha=0.7, label='Churned', color='#e74c3c')
axes[1, 0].set_title('Tenure Distribution by Churn Status')
axes[1, 0].set_xlabel('Tenure (months)')
axes[1, 0].legend()
# Monthly Charges Distribution by Churn
axes[1, 1].hist(df[df['Churn']==0]['MonthlyCharges'], bins=30, alpha=0.7, label='Retained', color='#27ae60')
axes[1, 1].hist(df[df['Churn']==1]['MonthlyCharges'], bins=30, alpha=0.7, label='Churned', color='#e74c3c')
axes[1, 1].set_title('Monthly Charges Distribution by Churn Status')
axes[1, 1].set_xlabel('Monthly Charges ($)')
axes[1, 1].legend()
plt.tight_layout()
plt.show()
print("\nKey Insights:")
print(f"- Month-to-month contract churn rate: {contract_churn.iloc[0]:.1%}")
print(f"- Two year contract churn rate: {contract_churn.iloc[-1]:.1%}")
print(f"- Electronic check payment churn rate: {payment_churn.iloc[0]:.1%}")
What does this code do?
This cell creates a 2x2 grid of charts that answer four business questions at once.
- Top-left: Groups customers by contract type and calculates the average churn rate for each group.
- Top-right: Does the same grouping by payment method to see if billing approach correlates with churn.
- Bottom-left: Overlays tenure histograms for retained vs. churned customers to reveal where in the customer lifecycle churn happens most.
- Bottom-right: Compares monthly charge distributions to see if higher-paying customers churn more.
- Run the cell by pressing Shift+Enter.
- You should see a 2x2 grid of bar charts and histograms, followed by printed insights showing that month-to-month contracts have the highest churn rate and electronic check is the riskiest payment method.
Why do month-to-month contracts churn more?
Month-to-month customers face no switching cost. They can leave at any time without a penalty. Customers on one-year or two-year contracts have already committed, which creates a barrier to leaving.
Combined with electronic check payments (which suggest less integration with the company's systems), these two factors together create the highest-risk customer profile. You will engineer a feature for exactly this combination shortly.
Not seeing the charts?
Make sure you ran all previous cells first (Cells 1-3). The df variable must exist from the earlier loading and cleaning steps.
If you see a NameError, click Runtime in the top menu, then Run all to re-execute every cell from the top.
Help me debug my EDA charts in Google Colab
Engineer domain-specific features
The raw columns tell you what a customer has. Feature engineering creates new columns that capture higher-level patterns the model can learn from more easily.
- Create a new code cell and add the following feature engineering code:
# Cell 5: Feature Engineering
# Service count feature
service_columns = ['PhoneService', 'MultipleLines', 'InternetService',
'OnlineSecurity', 'OnlineBackup', 'DeviceProtection',
'TechSupport', 'StreamingTV', 'StreamingMovies']
df['num_services'] = 0
for col in service_columns:
if col == 'InternetService':
df['num_services'] += (df[col] != 'No').astype(int)
else:
df['num_services'] += (df[col] == 'Yes').astype(int)
# Average monthly spend
df['avg_monthly_spend'] = np.where(
df['tenure'] > 0,
df['TotalCharges'] / df['tenure'],
df['MonthlyCharges']
)
# High value customer flag
monthly_75th = df['MonthlyCharges'].quantile(0.75)
df['is_high_value'] = (df['MonthlyCharges'] > monthly_75th).astype(int)
# Contract risk: month-to-month + electronic check
df['contract_risk'] = (
(df['Contract'] == 'Month-to-month') &
(df['PaymentMethod'] == 'Electronic check')
).astype(int)
print("Engineered features added:")
print(f"- num_services range: {df['num_services'].min()} to {df['num_services'].max()}")
print(f"- avg_monthly_spend mean: ${df['avg_monthly_spend'].mean():.2f}")
print(f"- High value customers: {df['is_high_value'].sum()} ({df['is_high_value'].mean():.1%})")
print(f"- High contract risk: {df['contract_risk'].sum()} ({df['contract_risk'].mean():.1%})")
What does each feature capture?
- num_services counts how many services each customer subscribes to. More services means higher engagement and potentially lower churn.
- avg_monthly_spend divides total charges by tenure to get a normalized spend rate. This accounts for the fact that new customers have low totals simply because they are new.
- is_high_value flags customers paying above the 75th percentile of monthly charges. High-value churners represent disproportionate revenue loss.
- contract_risk combines the two highest-risk factors you discovered in the EDA: month-to-month contract AND electronic check payment.
- Run the cell with Shift+Enter.
- You should see printed output confirming all four features were added, including the range of num_services (0 to 9), the average monthly spend, and the counts for high-value and contract-risk customers.
Seeing unexpected values?
If num_services shows all zeros, check that Cell 3 (data cleaning) did not accidentally remove the service columns. Run df.columns.tolist() to confirm columns like PhoneService and InternetService still exist.
If you run this cell multiple times, num_services values will keep incrementing because the loop adds to the existing column. Use Runtime > Restart and run all to reset.
Help me debug my feature engineering code
Prepare the modeling dataset
Machine learning models need numeric input. The dataset still has text-based categorical columns (like Contract and PaymentMethod) that need to be converted into numbers before training.
- Create a new code cell and add the following dataset preparation code:
# Cell 6: Prepare Modeling Dataset
# Separate target
y = df['Churn']
X = df.drop('Churn', axis=1)
# Identify column types
categorical_cols = X.select_dtypes(include=['object']).columns.tolist()
numerical_cols = X.select_dtypes(include=['number']).columns.tolist()
print(f"Categorical columns ({len(categorical_cols)}): {categorical_cols}")
print(f"Numerical columns ({len(numerical_cols)}): {numerical_cols}")
# One-hot encode categorical variables
X_encoded = pd.get_dummies(X, columns=categorical_cols, drop_first=True)
print(f"\nFinal feature matrix shape: {X_encoded.shape}")
# Train-test split (stratified)
X_train, X_test, y_train, y_test = train_test_split(
X_encoded, y, test_size=0.2, random_state=42, stratify=y
)
print(f"Training set: {X_train.shape[0]} samples")
print(f"Test set: {X_test.shape[0]} samples")
print(f"Train churn rate: {y_train.mean():.3f}")
print(f"Test churn rate: {y_test.mean():.3f}")
What does this code do?
- Separates the target (y = Churn) from the features (X = everything else). The model learns patterns in X that predict y.
- One-hot encoding converts each categorical column into separate binary columns. For example, Contract becomes Contract_One year and Contract_Two year.
- The stratified split ensures both the training and test sets have the same ~26.5% churn rate as the full dataset. This prevents the model from training on a skewed distribution.
- Run the cell with Shift+Enter.
- You should see the list of categorical and numerical columns, the final feature matrix shape, and the train/test split sizes with matching churn rates (both around 0.265).
Train and test churn rates don't match?
If the churn rates differ significantly between train and test sets, check that the stratify=y parameter is included in the train_test_split call. Without stratification, the random split could produce imbalanced subsets.
Help me fix my train/test split for churn prediction
✔️ Awesome, I've got everything!
Great. Double check that all three cells (4, 5, and 6) ran without errors and that your output shows the train/test split with matching churn rates.
ⓧ I'd like to double check the full code
Here is the complete notebook code through the end of this step. Compare your cells against this:
# Cell 1: Setup and Installation
!pip install xgboost shap imbalanced-learn -q
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.model_selection import train_test_split, StratifiedKFold, cross_val_score
from sklearn.preprocessing import StandardScaler, LabelEncoder
from sklearn.metrics import (
roc_auc_score, classification_report, confusion_matrix,
roc_curve, precision_recall_curve, f1_score
)
from imblearn.over_sampling import SMOTE
import xgboost as xgb
import shap
import warnings
warnings.filterwarnings('ignore')
print("All libraries loaded successfully!")
# Cell 2: Load and Inspect Data
url = "https://raw.githubusercontent.com/IBM/telco-customer-churn-on-icp4d/master/data/Telco-Customer-Churn.csv"
df = pd.read_csv(url)
print(f"Dataset shape: {df.shape}")
print(f"\nColumn names:\n{df.columns.tolist()}")
print(f"\nTarget distribution:")
print(df['Churn'].value_counts(normalize=True))
# Cell 3: Data Cleaning
df['TotalCharges'] = pd.to_numeric(df['TotalCharges'], errors='coerce')
df['TotalCharges'].fillna(0, inplace=True)
df.drop('customerID', axis=1, inplace=True)
df['Churn'] = df['Churn'].map({'Yes': 1, 'No': 0})
print(f"Missing values after cleaning: {df.isnull().sum().sum()}")
print(f"Churn rate: {df['Churn'].mean():.3f} ({df['Churn'].mean()*100:.1f}%)")
# Cell 4: Exploratory Data Analysis
fig, axes = plt.subplots(2, 2, figsize=(14, 10))
contract_churn = df.groupby('Contract')['Churn'].mean().sort_values(ascending=False)
contract_churn.plot(kind='bar', ax=axes[0, 0], color=['#e74c3c', '#f39c12', '#27ae60'])
axes[0, 0].set_title('Churn Rate by Contract Type')
axes[0, 0].set_ylabel('Churn Rate')
axes[0, 0].set_xticklabels(axes[0, 0].get_xticklabels(), rotation=0)
payment_churn = df.groupby('PaymentMethod')['Churn'].mean().sort_values(ascending=False)
payment_churn.plot(kind='bar', ax=axes[0, 1], color=['#e74c3c', '#f39c12', '#3498db', '#27ae60'])
axes[0, 1].set_title('Churn Rate by Payment Method')
axes[0, 1].set_ylabel('Churn Rate')
axes[0, 1].set_xticklabels(axes[0, 1].get_xticklabels(), rotation=45, ha='right')
axes[1, 0].hist(df[df['Churn']==0]['tenure'], bins=30, alpha=0.7, label='Retained', color='#27ae60')
axes[1, 0].hist(df[df['Churn']==1]['tenure'], bins=30, alpha=0.7, label='Churned', color='#e74c3c')
axes[1, 0].set_title('Tenure Distribution by Churn Status')
axes[1, 0].set_xlabel('Tenure (months)')
axes[1, 0].legend()
axes[1, 1].hist(df[df['Churn']==0]['MonthlyCharges'], bins=30, alpha=0.7, label='Retained', color='#27ae60')
axes[1, 1].hist(df[df['Churn']==1]['MonthlyCharges'], bins=30, alpha=0.7, label='Churned', color='#e74c3c')
axes[1, 1].set_title('Monthly Charges Distribution by Churn Status')
axes[1, 1].set_xlabel('Monthly Charges ($)')
axes[1, 1].legend()
plt.tight_layout()
plt.show()
print("\nKey Insights:")
print(f"- Month-to-month contract churn rate: {contract_churn.iloc[0]:.1%}")
print(f"- Two year contract churn rate: {contract_churn.iloc[-1]:.1%}")
print(f"- Electronic check payment churn rate: {payment_churn.iloc[0]:.1%}")
# Cell 5: Feature Engineering
service_columns = ['PhoneService', 'MultipleLines', 'InternetService',
'OnlineSecurity', 'OnlineBackup', 'DeviceProtection',
'TechSupport', 'StreamingTV', 'StreamingMovies']
df['num_services'] = 0
for col in service_columns:
if col == 'InternetService':
df['num_services'] += (df[col] != 'No').astype(int)
else:
df['num_services'] += (df[col] == 'Yes').astype(int)
df['avg_monthly_spend'] = np.where(
df['tenure'] > 0,
df['TotalCharges'] / df['tenure'],
df['MonthlyCharges']
)
monthly_75th = df['MonthlyCharges'].quantile(0.75)
df['is_high_value'] = (df['MonthlyCharges'] > monthly_75th).astype(int)
df['contract_risk'] = (
(df['Contract'] == 'Month-to-month') &
(df['PaymentMethod'] == 'Electronic check')
).astype(int)
print("Engineered features added:")
print(f"- num_services range: {df['num_services'].min()} to {df['num_services'].max()}")
print(f"- avg_monthly_spend mean: ${df['avg_monthly_spend'].mean():.2f}")
print(f"- High value customers: {df['is_high_value'].sum()} ({df['is_high_value'].mean():.1%})")
print(f"- High contract risk: {df['contract_risk'].sum()} ({df['contract_risk'].mean():.1%})")
# Cell 6: Prepare Modeling Dataset
y = df['Churn']
X = df.drop('Churn', axis=1)
categorical_cols = X.select_dtypes(include=['object']).columns.tolist()
numerical_cols = X.select_dtypes(include=['number']).columns.tolist()
print(f"Categorical columns ({len(categorical_cols)}): {categorical_cols}")
print(f"Numerical columns ({len(numerical_cols)}): {numerical_cols}")
X_encoded = pd.get_dummies(X, columns=categorical_cols, drop_first=True)
print(f"\nFinal feature matrix shape: {X_encoded.shape}")
X_train, X_test, y_train, y_test = train_test_split(
X_encoded, y, test_size=0.2, random_state=42, stratify=y
)
print(f"Training set: {X_train.shape[0]} samples")
print(f"Test set: {X_test.shape[0]} samples")
print(f"Train churn rate: {y_train.mean():.3f}")
print(f"Test churn rate: {y_test.mean():.3f}")
Your data is explored, features are engineered, and the dataset is split into training and test sets. Next up, you will train an XGBoost model to predict which customers are most likely to churn.
Train the Churn Prediction Model
Your dataset is prepped and split. Now it is time to train a model that predicts which customers will churn.
There is a catch though. Only ~26.5% of customers in the dataset actually churned. If we train a model on this imbalanced data, it could learn to predict "no churn" for everyone and still look 73.5% accurate. In this step, you will fix that imbalance, train an XGBoost classifier, evaluate its performance, and tune its decision threshold for maximum business value.
In this step, get ready to:
- Handle class imbalance with SMOTE and train an XGBoost churn classifier.
- Evaluate model performance with ROC-AUC, precision-recall curves, and a classification report.
- Optimize the classification threshold to balance catching churners against false alarms.
Handle class imbalance and train XGBoost
With only ~26.5% of records labeled as churned, the model would bias toward predicting the majority class. SMOTE (Synthetic Minority Oversampling Technique) generates synthetic examples of the minority class in the training set to balance things out.
- Create a new code cell in your notebook and paste the following code to apply SMOTE and train the model:
# Cell 7: Handle Class Imbalance and Train XGBoost
smote = SMOTE(random_state=42)
X_train_resampled, y_train_resampled = smote.fit_resample(X_train, y_train)
print(f"Before SMOTE: {y_train.value_counts().to_dict()}")
print(f"After SMOTE: {pd.Series(y_train_resampled).value_counts().to_dict()}")
# Calculate scale_pos_weight for XGBoost
scale_pos_weight = (y_train == 0).sum() / (y_train == 1).sum()
# Train XGBoost
model = xgb.XGBClassifier(
n_estimators=200,
max_depth=5,
learning_rate=0.05,
scale_pos_weight=scale_pos_weight,
random_state=42,
eval_metric='logloss',
use_label_encoder=False
)
model.fit(X_train_resampled, y_train_resampled)
# Cross-validation on original training data
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
cv_scores = cross_val_score(model, X_train, y_train, cv=cv, scoring='roc_auc')
print(f"\n5-Fold CV ROC-AUC: {cv_scores.mean():.4f} (+/- {cv_scores.std():.4f})")
What does this code do?
- SMOTE creates synthetic minority-class samples so the training set has equal churn/no-churn counts. This only applies to the training data so the test set stays realistic.
- scale_pos_weight tells XGBoost how much to penalize misclassifying the minority class. This is a second layer of protection against the imbalance.
- The XGBClassifier uses 200 boosting rounds with a conservative learning rate of 0.05. Slower learning rates with more rounds generally produce better generalization.
- 5-fold cross-validation evaluates the model on the original (non-SMOTE) training data to estimate how well it generalizes. The ROC-AUC metric measures ranking quality across all thresholds.
Why use both SMOTE and scale_pos_weight?
SMOTE balances the training data by generating new synthetic examples. scale_pos_weight adjusts the loss function so misclassifying a churner is penalized more heavily. Using both provides a belt-and-suspenders approach that makes the model robust against the class imbalance from two angles.
- Run the cell by pressing Shift+Enter.
You should see the before/after SMOTE class counts (balanced after resampling) and a 5-Fold CV ROC-AUC score. A score above 0.80 indicates strong predictive power.
Seeing a NameError for X_train?
Make sure you ran all previous cells in order (Cells 1 through 6). Colab loses variable state if the runtime disconnects or you skip cells.
Try clicking Runtime > Run all to re-execute everything from the top.
Help me debug my XGBoost training cell in Colab
Evaluate model performance
A trained model means nothing without rigorous evaluation. You need to check how well it distinguishes churners from retained customers on data it has never seen before.
- Generate predictions and evaluation metrics by creating a new cell and pasting this code:
# Cell 8: Model Evaluation
# Predictions
y_pred_proba = model.predict_proba(X_test)[:, 1]
y_pred = model.predict(X_test)
# ROC-AUC
roc_auc = roc_auc_score(y_test, y_pred_proba)
print(f"Test ROC-AUC: {roc_auc:.4f}")
print(f"\nClassification Report (threshold=0.5):")
print(classification_report(y_test, y_pred, target_names=['Retained', 'Churned']))
# Plot ROC and Precision-Recall curves
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# ROC Curve
fpr, tpr, _ = roc_curve(y_test, y_pred_proba)
axes[0].plot(fpr, tpr, 'b-', linewidth=2, label=f'XGBoost (AUC = {roc_auc:.3f})')
axes[0].plot([0, 1], [0, 1], 'k--', linewidth=1)
axes[0].set_xlabel('False Positive Rate')
axes[0].set_ylabel('True Positive Rate')
axes[0].set_title('ROC Curve')
axes[0].legend()
# Precision-Recall Curve
precision, recall, thresholds_pr = precision_recall_curve(y_test, y_pred_proba)
axes[1].plot(recall, precision, 'r-', linewidth=2)
axes[1].set_xlabel('Recall')
axes[1].set_ylabel('Precision')
axes[1].set_title('Precision-Recall Curve')
axes[1].axhline(y=y_test.mean(), color='k', linestyle='--', label=f'Baseline ({y_test.mean():.2f})')
axes[1].legend()
plt.tight_layout()
plt.show()
What do these metrics tell you?
- ROC-AUC measures how well the model ranks customers by churn risk. A score of 1.0 means perfect ranking. A score of 0.5 means random guessing.
- Precision answers: "Of all customers flagged as churners, how many actually churned?"
- Recall answers: "Of all actual churners, how many did the model catch?"
- For churn prediction, recall matters more than precision. Missing a churner (lost revenue) is costlier than a false alarm (wasted intervention cost).
- Run the cell with Shift+Enter.
You should see a ROC-AUC score above 0.80, a classification report table, and two side-by-side curves. The ROC curve should bow toward the top-left corner (good separation), and the Precision-Recall curve should stay well above the dashed baseline.
ROC-AUC below 0.75?
Double-check that Cell 7 used X_train_resampled (not X_train) in the model.fit() call. Training on the non-resampled data without SMOTE balancing can significantly reduce performance.
Help me debug my low ROC-AUC score
Tune the classification threshold
By default, a classifier labels anyone with a predicted probability above 0.5 as a churner. But 0.5 is arbitrary. In a churn scenario, you might prefer a lower threshold that catches more churners (higher recall) even if it means a few false alarms.
- Find the optimal threshold by creating a new cell and pasting this code:
# Cell 9: Threshold Optimization
thresholds = np.arange(0.2, 0.8, 0.05)
results = []
for thresh in thresholds:
y_pred_thresh = (y_pred_proba >= thresh).astype(int)
tp = ((y_pred_thresh == 1) & (y_test == 1)).sum()
fp = ((y_pred_thresh == 1) & (y_test == 0)).sum()
fn = ((y_pred_thresh == 0) & (y_test == 1)).sum()
recall_val = tp / (tp + fn) if (tp + fn) > 0 else 0
precision_val = tp / (tp + fp) if (tp + fp) > 0 else 0
f1_val = 2 * precision_val * recall_val / (precision_val + recall_val) if (precision_val + recall_val) > 0 else 0
results.append({
'threshold': thresh,
'precision': precision_val,
'recall': recall_val,
'f1': f1_val
})
results_df = pd.DataFrame(results)
fig, ax = plt.subplots(figsize=(10, 5))
ax.plot(results_df['threshold'], results_df['precision'], 'b-', label='Precision', linewidth=2)
ax.plot(results_df['threshold'], results_df['recall'], 'r-', label='Recall', linewidth=2)
ax.plot(results_df['threshold'], results_df['f1'], 'g--', label='F1 Score', linewidth=2)
ax.set_xlabel('Classification Threshold')
ax.set_ylabel('Score')
ax.set_title('Threshold Optimization: Precision vs Recall vs F1')
ax.legend()
ax.grid(True, alpha=0.3)
plt.show()
optimal_threshold = results_df.loc[results_df['f1'].idxmax(), 'threshold']
print(f"\nOptimal threshold (max F1): {optimal_threshold:.2f}")
print(f"At this threshold:")
print(f" Precision: {results_df.loc[results_df['f1'].idxmax(), 'precision']:.3f}")
print(f" Recall: {results_df.loc[results_df['f1'].idxmax(), 'recall']:.3f}")
print(f" F1: {results_df.loc[results_df['f1'].idxmax(), 'f1']:.3f}")
What does this code do?
- The loop tests every threshold from 0.2 to 0.8, computing precision, recall, and F1 at each point.
- As the threshold drops, recall increases (you catch more churners) but precision decreases (more false alarms).
- F1 score is the harmonic mean of precision and recall. The threshold that maximizes F1 gives the best balance between the two.
- The plot visualizes this tradeoff so you can see exactly where precision and recall cross, and where F1 peaks.
- Run the cell with Shift+Enter.
You should see a line chart with precision (blue), recall (red), and F1 (green dashed) plotted against classification threshold. The optimal threshold is printed below the chart.
Seeing a flat line or strange plot?
Make sure Cell 8 ran successfully before this cell. The variable y_pred_proba must exist from the previous evaluation cell. If it is missing, re-run Cell 8 first.
Help me troubleshoot my threshold optimization plot
✔️ Awesome, I've got everything!
Great. Double check you have Cells 7, 8, and 9 all running without errors before moving on.
ⓧ I'd like to double check the full code
Here is the complete notebook code from Cell 1 through Cell 9. Compare yours against this:
# Cell 1: Setup and Installation
!pip install xgboost shap imbalanced-learn -q
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.model_selection import train_test_split, StratifiedKFold, cross_val_score
from sklearn.preprocessing import StandardScaler, LabelEncoder
from sklearn.metrics import (
roc_auc_score, classification_report, confusion_matrix,
roc_curve, precision_recall_curve, f1_score
)
from imblearn.over_sampling import SMOTE
import xgboost as xgb
import shap
import warnings
warnings.filterwarnings('ignore')
print("All libraries loaded successfully!")
# Cell 2: Load and Inspect Data
url = "https://raw.githubusercontent.com/IBM/telco-customer-churn-on-icp4d/master/data/Telco-Customer-Churn.csv"
df = pd.read_csv(url)
print(f"Dataset shape: {df.shape}")
print(f"\nColumn names:\n{df.columns.tolist()}")
print(f"\nTarget distribution:")
print(df['Churn'].value_counts(normalize=True))
# Cell 3: Data Cleaning
df['TotalCharges'] = pd.to_numeric(df['TotalCharges'], errors='coerce')
df['TotalCharges'].fillna(0, inplace=True)
df.drop('customerID', axis=1, inplace=True)
df['Churn'] = df['Churn'].map({'Yes': 1, 'No': 0})
print(f"Missing values after cleaning: {df.isnull().sum().sum()}")
print(f"Churn rate: {df['Churn'].mean():.3f} ({df['Churn'].mean()*100:.1f}%)")
# Cell 4: Exploratory Data Analysis
fig, axes = plt.subplots(2, 2, figsize=(14, 10))
contract_churn = df.groupby('Contract')['Churn'].mean().sort_values(ascending=False)
contract_churn.plot(kind='bar', ax=axes[0, 0], color=['#e74c3c', '#f39c12', '#27ae60'])
axes[0, 0].set_title('Churn Rate by Contract Type')
axes[0, 0].set_ylabel('Churn Rate')
axes[0, 0].set_xticklabels(axes[0, 0].get_xticklabels(), rotation=0)
payment_churn = df.groupby('PaymentMethod')['Churn'].mean().sort_values(ascending=False)
payment_churn.plot(kind='bar', ax=axes[0, 1], color=['#e74c3c', '#f39c12', '#3498db', '#27ae60'])
axes[0, 1].set_title('Churn Rate by Payment Method')
axes[0, 1].set_ylabel('Churn Rate')
axes[0, 1].set_xticklabels(axes[0, 1].get_xticklabels(), rotation=45, ha='right')
axes[1, 0].hist(df[df['Churn']==0]['tenure'], bins=30, alpha=0.7, label='Retained', color='#27ae60')
axes[1, 0].hist(df[df['Churn']==1]['tenure'], bins=30, alpha=0.7, label='Churned', color='#e74c3c')
axes[1, 0].set_title('Tenure Distribution by Churn Status')
axes[1, 0].set_xlabel('Tenure (months)')
axes[1, 0].legend()
axes[1, 1].hist(df[df['Churn']==0]['MonthlyCharges'], bins=30, alpha=0.7, label='Retained', color='#27ae60')
axes[1, 1].hist(df[df['Churn']==1]['MonthlyCharges'], bins=30, alpha=0.7, label='Churned', color='#e74c3c')
axes[1, 1].set_title('Monthly Charges Distribution by Churn Status')
axes[1, 1].set_xlabel('Monthly Charges ($)')
axes[1, 1].legend()
plt.tight_layout()
plt.show()
print("\nKey Insights:")
print(f"- Month-to-month contract churn rate: {contract_churn.iloc[0]:.1%}")
print(f"- Two year contract churn rate: {contract_churn.iloc[-1]:.1%}")
print(f"- Electronic check payment churn rate: {payment_churn.iloc[0]:.1%}")
# Cell 5: Feature Engineering
service_columns = ['PhoneService', 'MultipleLines', 'InternetService',
'OnlineSecurity', 'OnlineBackup', 'DeviceProtection',
'TechSupport', 'StreamingTV', 'StreamingMovies']
df['num_services'] = 0
for col in service_columns:
if col == 'InternetService':
df['num_services'] += (df[col] != 'No').astype(int)
else:
df['num_services'] += (df[col] == 'Yes').astype(int)
df['avg_monthly_spend'] = np.where(
df['tenure'] > 0,
df['TotalCharges'] / df['tenure'],
df['MonthlyCharges']
)
monthly_75th = df['MonthlyCharges'].quantile(0.75)
df['is_high_value'] = (df['MonthlyCharges'] > monthly_75th).astype(int)
df['contract_risk'] = (
(df['Contract'] == 'Month-to-month') &
(df['PaymentMethod'] == 'Electronic check')
).astype(int)
print("Engineered features added:")
print(f"- num_services range: {df['num_services'].min()} to {df['num_services'].max()}")
print(f"- avg_monthly_spend mean: ${df['avg_monthly_spend'].mean():.2f}")
print(f"- High value customers: {df['is_high_value'].sum()} ({df['is_high_value'].mean():.1%})")
print(f"- High contract risk: {df['contract_risk'].sum()} ({df['contract_risk'].mean():.1%})")
# Cell 6: Prepare Modeling Dataset
y = df['Churn']
X = df.drop('Churn', axis=1)
categorical_cols = X.select_dtypes(include=['object']).columns.tolist()
numerical_cols = X.select_dtypes(include=['number']).columns.tolist()
print(f"Categorical columns ({len(categorical_cols)}): {categorical_cols}")
print(f"Numerical columns ({len(numerical_cols)}): {numerical_cols}")
X_encoded = pd.get_dummies(X, columns=categorical_cols, drop_first=True)
print(f"\nFinal feature matrix shape: {X_encoded.shape}")
X_train, X_test, y_train, y_test = train_test_split(
X_encoded, y, test_size=0.2, random_state=42, stratify=y
)
print(f"Training set: {X_train.shape[0]} samples")
print(f"Test set: {X_test.shape[0]} samples")
print(f"Train churn rate: {y_train.mean():.3f}")
print(f"Test churn rate: {y_test.mean():.3f}")
# Cell 7: Handle Class Imbalance and Train XGBoost
smote = SMOTE(random_state=42)
X_train_resampled, y_train_resampled = smote.fit_resample(X_train, y_train)
print(f"Before SMOTE: {y_train.value_counts().to_dict()}")
print(f"After SMOTE: {pd.Series(y_train_resampled).value_counts().to_dict()}")
scale_pos_weight = (y_train == 0).sum() / (y_train == 1).sum()
model = xgb.XGBClassifier(
n_estimators=200,
max_depth=5,
learning_rate=0.05,
scale_pos_weight=scale_pos_weight,
random_state=42,
eval_metric='logloss',
use_label_encoder=False
)
model.fit(X_train_resampled, y_train_resampled)
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
cv_scores = cross_val_score(model, X_train, y_train, cv=cv, scoring='roc_auc')
print(f"\n5-Fold CV ROC-AUC: {cv_scores.mean():.4f} (+/- {cv_scores.std():.4f})")
# Cell 8: Model Evaluation
y_pred_proba = model.predict_proba(X_test)[:, 1]
y_pred = model.predict(X_test)
roc_auc = roc_auc_score(y_test, y_pred_proba)
print(f"Test ROC-AUC: {roc_auc:.4f}")
print(f"\nClassification Report (threshold=0.5):")
print(classification_report(y_test, y_pred, target_names=['Retained', 'Churned']))
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
fpr, tpr, _ = roc_curve(y_test, y_pred_proba)
axes[0].plot(fpr, tpr, 'b-', linewidth=2, label=f'XGBoost (AUC = {roc_auc:.3f})')
axes[0].plot([0, 1], [0, 1], 'k--', linewidth=1)
axes[0].set_xlabel('False Positive Rate')
axes[0].set_ylabel('True Positive Rate')
axes[0].set_title('ROC Curve')
axes[0].legend()
precision, recall, thresholds_pr = precision_recall_curve(y_test, y_pred_proba)
axes[1].plot(recall, precision, 'r-', linewidth=2)
axes[1].set_xlabel('Recall')
axes[1].set_ylabel('Precision')
axes[1].set_title('Precision-Recall Curve')
axes[1].axhline(y=y_test.mean(), color='k', linestyle='--', label=f'Baseline ({y_test.mean():.2f})')
axes[1].legend()
plt.tight_layout()
plt.show()
# Cell 9: Threshold Optimization
thresholds = np.arange(0.2, 0.8, 0.05)
results = []
for thresh in thresholds:
y_pred_thresh = (y_pred_proba >= thresh).astype(int)
tp = ((y_pred_thresh == 1) & (y_test == 1)).sum()
fp = ((y_pred_thresh == 1) & (y_test == 0)).sum()
fn = ((y_pred_thresh == 0) & (y_test == 1)).sum()
recall_val = tp / (tp + fn) if (tp + fn) > 0 else 0
precision_val = tp / (tp + fp) if (tp + fp) > 0 else 0
f1_val = 2 * precision_val * recall_val / (precision_val + recall_val) if (precision_val + recall_val) > 0 else 0
results.append({
'threshold': thresh,
'precision': precision_val,
'recall': recall_val,
'f1': f1_val
})
results_df = pd.DataFrame(results)
fig, ax = plt.subplots(figsize=(10, 5))
ax.plot(results_df['threshold'], results_df['precision'], 'b-', label='Precision', linewidth=2)
ax.plot(results_df['threshold'], results_df['recall'], 'r-', label='Recall', linewidth=2)
ax.plot(results_df['threshold'], results_df['f1'], 'g--', label='F1 Score', linewidth=2)
ax.set_xlabel('Classification Threshold')
ax.set_ylabel('Score')
ax.set_title('Threshold Optimization: Precision vs Recall vs F1')
ax.legend()
ax.grid(True, alpha=0.3)
plt.show()
optimal_threshold = results_df.loc[results_df['f1'].idxmax(), 'threshold']
print(f"\nOptimal threshold (max F1): {optimal_threshold:.2f}")
print(f"At this threshold:")
print(f" Precision: {results_df.loc[results_df['f1'].idxmax(), 'precision']:.3f}")
print(f" Recall: {results_df.loc[results_df['f1'].idxmax(), 'recall']:.3f}")
print(f" F1: {results_df.loc[results_df['f1'].idxmax(), 'f1']:.3f}")
Your model is trained, evaluated, and tuned. Next up, you will use SHAP to crack open the black box and explain exactly why individual customers are predicted to churn.
Explain Predictions with SHAP
Your model can predict which customers will churn, but a prediction without an explanation is useless to the business team who needs to act on it. They need to know why a customer is flagged as high-risk so they can design the right intervention.
SHAP (SHapley Additive exPlanations) breaks down each prediction into the contribution of every feature. In this step, you will use SHAP to generate global importance rankings and per-customer explanations that translate directly into business actions.
In this step, get ready to:
- Generate a global SHAP summary plot showing the top churn drivers across all predictions.
- Create individual waterfall plots explaining why specific high-risk customers are predicted to churn.
- Extract a ranked feature importance list and translate it into business-actionable insights.
Create the SHAP explainer and global summary plot
SHAP's TreeExplainer is optimized for tree-based models like XGBoost. It calculates how much each feature pushes a prediction toward or away from churn for every customer in the dataset.
- Create a new code cell in your notebook and paste the following code:
# Cell 10: SHAP Explainability
# Create SHAP explainer
explainer = shap.TreeExplainer(model)
shap_values = explainer.shap_values(X_test)
# Summary plot (global feature importance)
plt.figure(figsize=(12, 8))
shap.summary_plot(shap_values, X_test, max_display=15, show=False)
plt.title('SHAP Feature Importance - Top 15 Churn Drivers')
plt.tight_layout()
plt.show()
What does this code do?
This cell does two things: it computes SHAP values, then visualizes them.
- shap.TreeExplainer(model) creates an explainer specifically designed for tree-based models. It uses the internal tree structure to compute exact SHAP values efficiently.
- explainer.shap_values(X_test) calculates a SHAP value for every feature of every customer in the test set. The result is a matrix with the same shape as X_test.
- shap.summary_plot() displays the top 15 features ranked by their average impact. Each dot is one customer. The color indicates the feature value (red = high, blue = low) and the x-axis shows the SHAP value (positive = pushes toward churn).
- Run the cell by pressing the play button or pressing Shift+Enter.
- You should see a horizontal beeswarm plot with feature names on the left and colored dots spread along the x-axis. Features at the top have the strongest influence on churn predictions.
Plot not showing or cell is slow?
- SHAP value computation can take 15-30 seconds depending on your Colab runtime. Wait for the cell to finish before assuming it failed.
- If you see an error about missing variables, make sure you ran all previous cells in order. The cell needs the trained model and X_test from earlier steps.
Why isn't my SHAP summary plot rendering in Google Colab?
Generate waterfall plots for high-risk customers
The summary plot shows global patterns, but stakeholders want to know about specific customers. A waterfall plot breaks down one customer's prediction into the exact contribution of each feature, showing precisely what pushed them toward the churn threshold.
- Create a new code cell below Cell 10 and paste the following code:
# Cell 11: Individual Customer Explanations
# Find high-risk customers
high_risk_indices = X_test.index[y_pred_proba > 0.7]
if len(high_risk_indices) > 0:
# Show SHAP waterfall for top 3 high-risk customers
for i, idx in enumerate(high_risk_indices[:3]):
position = list(X_test.index).index(idx)
print(f"\n--- High-Risk Customer {i+1} (Churn probability: {y_pred_proba[position]:.1%}) ---")
shap.waterfall_plot(
shap.Explanation(
values=shap_values[position],
base_values=explainer.expected_value,
data=X_test.iloc[position],
feature_names=X_test.columns.tolist()
),
max_display=10,
show=True
)
# Extract top churn drivers
feature_importance = pd.DataFrame({
'feature': X_test.columns,
'mean_abs_shap': np.abs(shap_values).mean(axis=0)
}).sort_values('mean_abs_shap', ascending=False)
print("\nTop 10 Churn Drivers (by mean |SHAP value|):")
for i, row in feature_importance.head(10).iterrows():
print(f" {row['feature']}: {row['mean_abs_shap']:.4f}")
What does this code do?
This cell does two things: it explains individual predictions visually, then ranks the overall churn drivers.
- y_pred_proba > 0.7 filters for customers with over 70% predicted churn probability. These are the highest-risk individuals.
- shap.waterfall_plot() creates a horizontal bar chart for one customer. Each bar shows how much a single feature pushed the prediction up (red) or down (blue) from the base value.
- The feature_importance DataFrame calculates the mean absolute SHAP value for each feature across all test customers. Higher values mean the feature has more overall influence on churn predictions.
- Run the cell.
- You should see up to 3 waterfall plots (one per high-risk customer), each showing 10 features with red and blue bars. Below the plots, a ranked list of the top 10 churn drivers prints with their mean SHAP values.
Not seeing any waterfall plots?
- If no waterfall plots appear but the feature importance list prints, it means no customers in your test set have a churn probability above 0.7. This is unlikely with the Telco dataset but could happen with a different random state.
- If you see a NameError for shap_values or explainer, make sure you ran Cell 10 first. These variables are created there.
Help me debug why my SHAP waterfall plots are not displaying in Colab.
Translate churn drivers into business actions
The ranked list of churn drivers from your output is technically correct, but a business stakeholder does not think in terms of SHAP values. Your job is to translate each technical finding into a concrete retention action.
How do I read the churn drivers?
Each feature in your top 10 list maps to a business lever. Here are examples of how to translate:
- Short tenure (low months) as a top driver means new customers need a stronger onboarding program in their first 6 months.
- Month-to-month contracts appearing high means the business should incentivize annual contracts with discounts or perks.
- Electronic check payment as a risk factor suggests offering auto-pay enrollment with a small discount to reduce friction.
- Missing security or tech support services means bundling protective add-ons into retention offers.
The pattern is: technical finding becomes a specific program or offer that addresses the root cause. This is what separates a data science exercise from a business deliverable.
- Review your top 10 churn drivers printed below the waterfall plots.
- For each of the top 3 features, think about what business action could address that churn driver.
Why bother translating to business language?
A model that says "tenure has a mean absolute SHAP value of 0.15" tells a data scientist something useful. But the VP of Customer Success needs to hear "customers who leave within the first 6 months represent our biggest risk group, and an onboarding program could reduce churn by X%."
The next step takes this further by attaching actual dollar amounts to each risk segment.
✔️ Awesome, I've got everything!
Great. Make sure both Cell 10 and Cell 11 ran without errors and you can see the summary plot, waterfall plots, and the printed top 10 list.
ⓧ I'd like to double check the full code
Here is the full notebook code through the end of this step. Compare your cells against this:
# Cell 1: Setup and Installation
!pip install xgboost shap imbalanced-learn -q
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.model_selection import train_test_split, StratifiedKFold, cross_val_score
from sklearn.preprocessing import StandardScaler, LabelEncoder
from sklearn.metrics import (
roc_auc_score, classification_report, confusion_matrix,
roc_curve, precision_recall_curve, f1_score
)
from imblearn.over_sampling import SMOTE
import xgboost as xgb
import shap
import warnings
warnings.filterwarnings('ignore')
print("All libraries loaded successfully!")
# Cell 2: Load and Inspect Data
url = "https://raw.githubusercontent.com/IBM/telco-customer-churn-on-icp4d/master/data/Telco-Customer-Churn.csv"
df = pd.read_csv(url)
print(f"Dataset shape: {df.shape}")
print(f"\nColumn names:\n{df.columns.tolist()}")
print(f"\nTarget distribution:")
print(df['Churn'].value_counts(normalize=True))
# Cell 3: Data Cleaning
df['TotalCharges'] = pd.to_numeric(df['TotalCharges'], errors='coerce')
df['TotalCharges'].fillna(0, inplace=True)
df.drop('customerID', axis=1, inplace=True)
df['Churn'] = df['Churn'].map({'Yes': 1, 'No': 0})
print(f"Missing values after cleaning: {df.isnull().sum().sum()}")
print(f"Churn rate: {df['Churn'].mean():.3f} ({df['Churn'].mean()*100:.1f}%)")
# Cell 4: Exploratory Data Analysis
fig, axes = plt.subplots(2, 2, figsize=(14, 10))
contract_churn = df.groupby('Contract')['Churn'].mean().sort_values(ascending=False)
contract_churn.plot(kind='bar', ax=axes[0, 0], color=['#e74c3c', '#f39c12', '#27ae60'])
axes[0, 0].set_title('Churn Rate by Contract Type')
axes[0, 0].set_ylabel('Churn Rate')
axes[0, 0].set_xticklabels(axes[0, 0].get_xticklabels(), rotation=0)
payment_churn = df.groupby('PaymentMethod')['Churn'].mean().sort_values(ascending=False)
payment_churn.plot(kind='bar', ax=axes[0, 1], color=['#e74c3c', '#f39c12', '#3498db', '#27ae60'])
axes[0, 1].set_title('Churn Rate by Payment Method')
axes[0, 1].set_ylabel('Churn Rate')
axes[0, 1].set_xticklabels(axes[0, 1].get_xticklabels(), rotation=45, ha='right')
axes[1, 0].hist(df[df['Churn']==0]['tenure'], bins=30, alpha=0.7, label='Retained', color='#27ae60')
axes[1, 0].hist(df[df['Churn']==1]['tenure'], bins=30, alpha=0.7, label='Churned', color='#e74c3c')
axes[1, 0].set_title('Tenure Distribution by Churn Status')
axes[1, 0].set_xlabel('Tenure (months)')
axes[1, 0].legend()
axes[1, 1].hist(df[df['Churn']==0]['MonthlyCharges'], bins=30, alpha=0.7, label='Retained', color='#27ae60')
axes[1, 1].hist(df[df['Churn']==1]['MonthlyCharges'], bins=30, alpha=0.7, label='Churned', color='#e74c3c')
axes[1, 1].set_title('Monthly Charges Distribution by Churn Status')
axes[1, 1].set_xlabel('Monthly Charges ($)')
axes[1, 1].legend()
plt.tight_layout()
plt.show()
print("\nKey Insights:")
print(f"- Month-to-month contract churn rate: {contract_churn.iloc[0]:.1%}")
print(f"- Two year contract churn rate: {contract_churn.iloc[-1]:.1%}")
print(f"- Electronic check payment churn rate: {payment_churn.iloc[0]:.1%}")
# Cell 5: Feature Engineering
service_columns = ['PhoneService', 'MultipleLines', 'InternetService',
'OnlineSecurity', 'OnlineBackup', 'DeviceProtection',
'TechSupport', 'StreamingTV', 'StreamingMovies']
df['num_services'] = 0
for col in service_columns:
if col == 'InternetService':
df['num_services'] += (df[col] != 'No').astype(int)
else:
df['num_services'] += (df[col] == 'Yes').astype(int)
df['avg_monthly_spend'] = np.where(
df['tenure'] > 0,
df['TotalCharges'] / df['tenure'],
df['MonthlyCharges']
)
monthly_75th = df['MonthlyCharges'].quantile(0.75)
df['is_high_value'] = (df['MonthlyCharges'] > monthly_75th).astype(int)
df['contract_risk'] = (
(df['Contract'] == 'Month-to-month') &
(df['PaymentMethod'] == 'Electronic check')
).astype(int)
print("Engineered features added:")
print(f"- num_services range: {df['num_services'].min()} to {df['num_services'].max()}")
print(f"- avg_monthly_spend mean: ${df['avg_monthly_spend'].mean():.2f}")
print(f"- High value customers: {df['is_high_value'].sum()} ({df['is_high_value'].mean():.1%})")
print(f"- High contract risk: {df['contract_risk'].sum()} ({df['contract_risk'].mean():.1%})")
# Cell 6: Prepare Modeling Dataset
y = df['Churn']
X = df.drop('Churn', axis=1)
categorical_cols = X.select_dtypes(include=['object']).columns.tolist()
numerical_cols = X.select_dtypes(include=['number']).columns.tolist()
print(f"Categorical columns ({len(categorical_cols)}): {categorical_cols}")
print(f"Numerical columns ({len(numerical_cols)}): {numerical_cols}")
X_encoded = pd.get_dummies(X, columns=categorical_cols, drop_first=True)
print(f"\nFinal feature matrix shape: {X_encoded.shape}")
X_train, X_test, y_train, y_test = train_test_split(
X_encoded, y, test_size=0.2, random_state=42, stratify=y
)
print(f"Training set: {X_train.shape[0]} samples")
print(f"Test set: {X_test.shape[0]} samples")
print(f"Train churn rate: {y_train.mean():.3f}")
print(f"Test churn rate: {y_test.mean():.3f}")
# Cell 7: Handle Class Imbalance and Train XGBoost
smote = SMOTE(random_state=42)
X_train_resampled, y_train_resampled = smote.fit_resample(X_train, y_train)
print(f"Before SMOTE: {y_train.value_counts().to_dict()}")
print(f"After SMOTE: {pd.Series(y_train_resampled).value_counts().to_dict()}")
scale_pos_weight = (y_train == 0).sum() / (y_train == 1).sum()
model = xgb.XGBClassifier(
n_estimators=200,
max_depth=5,
learning_rate=0.05,
scale_pos_weight=scale_pos_weight,
random_state=42,
eval_metric='logloss',
use_label_encoder=False
)
model.fit(X_train_resampled, y_train_resampled)
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
cv_scores = cross_val_score(model, X_train, y_train, cv=cv, scoring='roc_auc')
print(f"\n5-Fold CV ROC-AUC: {cv_scores.mean():.4f} (+/- {cv_scores.std():.4f})")
# Cell 8: Model Evaluation
y_pred_proba = model.predict_proba(X_test)[:, 1]
y_pred = model.predict(X_test)
roc_auc = roc_auc_score(y_test, y_pred_proba)
print(f"Test ROC-AUC: {roc_auc:.4f}")
print(f"\nClassification Report (threshold=0.5):")
print(classification_report(y_test, y_pred, target_names=['Retained', 'Churned']))
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
fpr, tpr, _ = roc_curve(y_test, y_pred_proba)
axes[0].plot(fpr, tpr, 'b-', linewidth=2, label=f'XGBoost (AUC = {roc_auc:.3f})')
axes[0].plot([0, 1], [0, 1], 'k--', linewidth=1)
axes[0].set_xlabel('False Positive Rate')
axes[0].set_ylabel('True Positive Rate')
axes[0].set_title('ROC Curve')
axes[0].legend()
precision, recall, thresholds_pr = precision_recall_curve(y_test, y_pred_proba)
axes[1].plot(recall, precision, 'r-', linewidth=2)
axes[1].set_xlabel('Recall')
axes[1].set_ylabel('Precision')
axes[1].set_title('Precision-Recall Curve')
axes[1].axhline(y=y_test.mean(), color='k', linestyle='--', label=f'Baseline ({y_test.mean():.2f})')
axes[1].legend()
plt.tight_layout()
plt.show()
# Cell 9: Threshold Optimization
thresholds = np.arange(0.2, 0.8, 0.05)
results = []
for thresh in thresholds:
y_pred_thresh = (y_pred_proba >= thresh).astype(int)
tp = ((y_pred_thresh == 1) & (y_test == 1)).sum()
fp = ((y_pred_thresh == 1) & (y_test == 0)).sum()
fn = ((y_pred_thresh == 0) & (y_test == 1)).sum()
recall_val = tp / (tp + fn) if (tp + fn) > 0 else 0
precision_val = tp / (tp + fp) if (tp + fp) > 0 else 0
f1_val = 2 * precision_val * recall_val / (precision_val + recall_val) if (precision_val + recall_val) > 0 else 0
results.append({
'threshold': thresh,
'precision': precision_val,
'recall': recall_val,
'f1': f1_val
})
results_df = pd.DataFrame(results)
fig, ax = plt.subplots(figsize=(10, 5))
ax.plot(results_df['threshold'], results_df['precision'], 'b-', label='Precision', linewidth=2)
ax.plot(results_df['threshold'], results_df['recall'], 'r-', label='Recall', linewidth=2)
ax.plot(results_df['threshold'], results_df['f1'], 'g--', label='F1 Score', linewidth=2)
ax.set_xlabel('Classification Threshold')
ax.set_ylabel('Score')
ax.set_title('Threshold Optimization: Precision vs Recall vs F1')
ax.legend()
ax.grid(True, alpha=0.3)
plt.show()
optimal_threshold = results_df.loc[results_df['f1'].idxmax(), 'threshold']
print(f"\nOptimal threshold (max F1): {optimal_threshold:.2f}")
print(f"At this threshold:")
print(f" Precision: {results_df.loc[results_df['f1'].idxmax(), 'precision']:.3f}")
print(f" Recall: {results_df.loc[results_df['f1'].idxmax(), 'recall']:.3f}")
print(f" F1: {results_df.loc[results_df['f1'].idxmax(), 'f1']:.3f}")
# Cell 10: SHAP Explainability
explainer = shap.TreeExplainer(model)
shap_values = explainer.shap_values(X_test)
plt.figure(figsize=(12, 8))
shap.summary_plot(shap_values, X_test, max_display=15, show=False)
plt.title('SHAP Feature Importance - Top 15 Churn Drivers')
plt.tight_layout()
plt.show()
# Cell 11: Individual Customer Explanations
high_risk_indices = X_test.index[y_pred_proba > 0.7]
if len(high_risk_indices) > 0:
for i, idx in enumerate(high_risk_indices[:3]):
position = list(X_test.index).index(idx)
print(f"\n--- High-Risk Customer {i+1} (Churn probability: {y_pred_proba[position]:.1%}) ---")
shap.waterfall_plot(
shap.Explanation(
values=shap_values[position],
base_values=explainer.expected_value,
data=X_test.iloc[position],
feature_names=X_test.columns.tolist()
),
max_display=10,
show=True
)
feature_importance = pd.DataFrame({
'feature': X_test.columns,
'mean_abs_shap': np.abs(shap_values).mean(axis=0)
}).sort_values('mean_abs_shap', ascending=False)
print("\nTop 10 Churn Drivers (by mean |SHAP value|):")
for i, row in feature_importance.head(10).iterrows():
print(f" {row['feature']}: {row['mean_abs_shap']:.4f}")
You now have concrete, explainable evidence for why customers leave. Next up, you will attach dollar amounts to these predictions and simulate what a targeted retention campaign would save the business.
Quantify Revenue Impact and Build Retention Strategy
Your model can now predict who will churn and explain exactly why. But a business stakeholder does not want SHAP values or ROC curves. They want dollar figures.
In this step, you will translate model predictions into revenue-at-risk calculations, segment customers into actionable risk tiers, and simulate the ROI of a targeted retention campaign. This is the "so what" that turns a technical exercise into a business deliverable.
In this step, get ready to:
- Calculate revenue at risk and segment customers into risk tiers.
- Simulate a targeted retention campaign and calculate ROI.
- Build an executive summary visualization and save model artifacts.
Calculate revenue at risk and segment customers into tiers
A churn probability alone does not tell you how much money is on the line. A high-value customer with a 40% churn probability represents more risk than a low-value customer at 80%.
- Create a new code cell in your notebook and add the revenue impact analysis by pasting the following code:
# Cell 12: Revenue Impact Analysis
# Add predictions back to test data
test_analysis = X_test.copy()
test_analysis['churn_probability'] = y_pred_proba
test_analysis['actual_churn'] = y_test.values
# Find MonthlyCharges column (it's numerical, not encoded)
test_analysis['monthly_revenue_at_risk'] = test_analysis['MonthlyCharges'] * test_analysis['churn_probability']
test_analysis['annual_revenue_at_risk'] = test_analysis['monthly_revenue_at_risk'] * 12
# Risk tier segmentation
test_analysis['risk_tier'] = pd.cut(
test_analysis['churn_probability'],
bins=[0, 0.3, 0.6, 1.0],
labels=['Low Risk', 'Medium Risk', 'High Risk']
)
# Summary by risk tier
tier_summary = test_analysis.groupby('risk_tier', observed=True).agg(
customer_count=('churn_probability', 'count'),
avg_churn_prob=('churn_probability', 'mean'),
total_monthly_revenue_at_risk=('monthly_revenue_at_risk', 'sum'),
total_annual_revenue_at_risk=('annual_revenue_at_risk', 'sum'),
avg_monthly_charges=('MonthlyCharges', 'mean')
).round(2)
print("=" * 70)
print("REVENUE AT RISK ANALYSIS BY CUSTOMER SEGMENT")
print("=" * 70)
print(f"\nTotal customers analyzed: {len(test_analysis)}")
print(f"Total annual revenue at risk: ${test_analysis['annual_revenue_at_risk'].sum():,.0f}")
print(f"\nBreakdown by risk tier:")
print(tier_summary.to_string())
What does this code do?
This cell bridges the gap between predictions and business impact.
- It copies X_test and attaches each customer's churn probability so you can calculate dollar risk per customer.
- The monthly_revenue_at_risk formula (MonthlyCharges multiplied by churn probability) gives each customer's expected monthly loss, weighted by how likely they are to leave.
- The pd.cut function segments customers into Low Risk (below 30%), Medium Risk (30-60%), and High Risk (above 60%) so you can prioritize interventions.
- Run the cell.
- You should see a formatted table showing total customers analyzed, total annual revenue at risk (a dollar figure), and a breakdown by each risk tier with customer counts, average churn probability, and revenue figures.
Seeing a KeyError on MonthlyCharges?
The MonthlyCharges column is a numerical feature that was kept as-is during one-hot encoding. If you see a KeyError, make sure you ran Cell 6 (Prepare Modeling Dataset) without modifications. The column must exist in X_test as a numerical column.
help me debug a KeyError for MonthlyCharges in my revenue at risk calculation
Simulate a targeted retention campaign
Knowing which customers are at risk is only useful if you can act on it. A retention campaign costs money, so you need to prove the model pays for itself.
- Create a new code cell and add the retention campaign simulation by pasting this code:
# Cell 13: Retention Campaign ROI Simulation
# Campaign parameters
intervention_cost = 50 # $ per customer contacted
success_rate_high = 0.25 # 25% of high-risk interventions succeed
success_rate_medium = 0.35 # 35% of medium-risk interventions succeed
# High-risk campaign
high_risk_customers = test_analysis[test_analysis['risk_tier'] == 'High Risk']
high_risk_count = len(high_risk_customers)
high_risk_campaign_cost = high_risk_count * intervention_cost
high_risk_saved_revenue = (
high_risk_customers['annual_revenue_at_risk'].sum() * success_rate_high
)
high_risk_roi = (high_risk_saved_revenue - high_risk_campaign_cost) / high_risk_campaign_cost * 100
# Medium-risk campaign
medium_risk_customers = test_analysis[test_analysis['risk_tier'] == 'Medium Risk']
medium_risk_count = len(medium_risk_customers)
medium_risk_campaign_cost = medium_risk_count * intervention_cost
medium_risk_saved_revenue = (
medium_risk_customers['annual_revenue_at_risk'].sum() * success_rate_medium
)
medium_risk_roi = (medium_risk_saved_revenue - medium_risk_campaign_cost) / medium_risk_campaign_cost * 100
# Total campaign
total_campaign_cost = high_risk_campaign_cost + medium_risk_campaign_cost
total_saved_revenue = high_risk_saved_revenue + medium_risk_saved_revenue
total_roi = (total_saved_revenue - total_campaign_cost) / total_campaign_cost * 100
print("=" * 70)
print("RETENTION CAMPAIGN ROI SIMULATION")
print("=" * 70)
print(f"\nAssumptions:")
print(f" - Intervention cost per customer: ${intervention_cost}")
print(f" - High-risk success rate: {success_rate_high:.0%}")
print(f" - Medium-risk success rate: {success_rate_medium:.0%}")
print(f"\n{'Segment':<15} {'Customers':<12} {'Cost':<12} {'Saved Revenue':<16} {'ROI':<10}")
print("-" * 65)
print(f"{'High Risk':<15} {high_risk_count:<12} ${high_risk_campaign_cost:<10,.0f} ${high_risk_saved_revenue:<14,.0f} {high_risk_roi:<10.0f}%")
print(f"{'Medium Risk':<15} {medium_risk_count:<12} ${medium_risk_campaign_cost:<10,.0f} ${medium_risk_saved_revenue:<14,.0f} {medium_risk_roi:<10.0f}%")
print(f"-" * 65)
print(f"{'TOTAL':<15} {high_risk_count + medium_risk_count:<12} ${total_campaign_cost:<10,.0f} ${total_saved_revenue:<14,.0f} {total_roi:<10.0f}%")
print(f"\nNet benefit: ${total_saved_revenue - total_campaign_cost:,.0f}")
What does this simulation assume?
The simulation uses realistic assumptions: a $50 per-customer intervention cost (e.g., a personalized offer or retention call) and conservative success rates (25% for high-risk, 35% for medium-risk). Medium-risk customers have a higher success rate because they are less committed to leaving.
ROI is calculated as (saved revenue minus campaign cost) divided by campaign cost, expressed as a percentage. A positive ROI means the model-driven campaign pays for itself and then some.
- Run the cell.
- You should see a formatted table showing the campaign assumptions, customer counts per segment, total cost, saved revenue, and ROI percentage for high-risk, medium-risk, and combined campaigns. The net benefit line at the bottom shows the dollar payoff.
Seeing a division by zero error?
This happens if no customers fall into the High Risk or Medium Risk tier. Make sure Cell 12 ran correctly and that test_analysis['risk_tier'] contains values in all three categories. You can check with test_analysis['risk_tier'].value_counts() in a quick cell.
help me debug a ZeroDivisionError in my retention campaign ROI simulation
Create the executive summary and save artifacts
The executive summary pulls together everything into a single visual and text output. This is what you would present to a stakeholder who wants the answer in 30 seconds.
- Create a new code cell and add the executive summary visualization by pasting this code:
# Cell 14: Executive Summary Visualization
fig, axes = plt.subplots(1, 3, figsize=(16, 5))
# Revenue at risk by tier
tier_revenue = test_analysis.groupby('risk_tier', observed=True)['annual_revenue_at_risk'].sum()
colors = ['#27ae60', '#f39c12', '#e74c3c']
tier_revenue.plot(kind='bar', ax=axes[0], color=colors)
axes[0].set_title('Annual Revenue at Risk by Segment')
axes[0].set_ylabel('Revenue at Risk ($)')
axes[0].set_xticklabels(axes[0].get_xticklabels(), rotation=0)
# Customer count by tier
tier_counts = test_analysis['risk_tier'].value_counts().sort_index()
tier_counts.plot(kind='bar', ax=axes[1], color=colors)
axes[1].set_title('Customer Count by Risk Tier')
axes[1].set_ylabel('Number of Customers')
axes[1].set_xticklabels(axes[1].get_xticklabels(), rotation=0)
# ROI comparison
roi_data = pd.Series({'High Risk\nCampaign': high_risk_roi, 'Medium Risk\nCampaign': medium_risk_roi, 'Combined\nCampaign': total_roi})
roi_data.plot(kind='bar', ax=axes[2], color=['#e74c3c', '#f39c12', '#3498db'])
axes[2].set_title('Retention Campaign ROI (%)')
axes[2].set_ylabel('ROI (%)')
axes[2].set_xticklabels(axes[2].get_xticklabels(), rotation=0)
axes[2].axhline(y=0, color='k', linestyle='--', linewidth=0.5)
plt.tight_layout()
plt.show()
print("\n" + "=" * 70)
print("EXECUTIVE SUMMARY")
print("=" * 70)
print(f"\n1. MODEL PERFORMANCE: ROC-AUC of {roc_auc:.3f} - the model correctly")
print(f" ranks at-risk customers {roc_auc*100:.0f}% of the time.")
print(f"\n2. REVENUE AT RISK: ${test_analysis['annual_revenue_at_risk'].sum():,.0f} annually")
print(f" from {len(test_analysis)} customers analyzed.")
print(f"\n3. TOP CHURN DRIVERS: {feature_importance.iloc[0]['feature']},")
print(f" {feature_importance.iloc[1]['feature']}, {feature_importance.iloc[2]['feature']}")
print(f"\n4. RECOMMENDED ACTION: Deploy targeted retention campaign")
print(f" - Investment: ${total_campaign_cost:,.0f}")
print(f" - Projected savings: ${total_saved_revenue:,.0f}")
print(f" - ROI: {total_roi:.0f}%")
What does this visualization show?
- The left chart shows annual revenue at risk broken down by segment, making it clear where the biggest dollar exposure is.
- The middle chart shows customer counts per tier, giving context for how many people need intervention.
- The right chart compares ROI across campaign strategies, proving which segment delivers the best return per dollar spent.
The printed executive summary below the charts gives the 4-point story: how accurate the model is, how much money is at risk, what drives churn, and what to do about it.
- Run the cell.
- You should see a row of three bar charts (revenue by tier, customer count by tier, ROI comparison) followed by a numbered executive summary with model performance, revenue at risk, top churn drivers, and the recommended action.
Charts look blank or labels overlap?
If the charts appear empty, make sure Cells 12 and 13 both ran successfully before this cell. The variables test_analysis, high_risk_roi, medium_risk_roi, and total_roi must all exist in memory.
help me fix my executive summary visualization that is not rendering correctly
The final step is to save your trained model and processed data so they can be reused later (including in the Secret Mission).
- Create one more code cell and save all artifacts by pasting this code:
# Cell 15: Save Artifacts (for Secret Mission)
import joblib
# Save model and data for Streamlit dashboard
joblib.dump(model, 'churn_model.pkl')
joblib.dump(X_test, 'X_test.pkl')
joblib.dump(y_test, 'y_test.pkl')
joblib.dump(y_pred_proba, 'predictions.pkl')
joblib.dump(explainer, 'shap_explainer.pkl')
joblib.dump(shap_values, 'shap_values.pkl')
joblib.dump(test_analysis, 'test_analysis.pkl')
print("All artifacts saved for Streamlit dashboard!")
print("Files: churn_model.pkl, X_test.pkl, y_test.pkl, predictions.pkl,")
print(" shap_explainer.pkl, shap_values.pkl, test_analysis.pkl")
What is joblib?
The joblib library serializes Python objects (models, DataFrames, arrays) to disk as .pkl files. This lets you reload a trained model without retraining it from scratch. Each file saves one artifact that the Secret Mission dashboard will load directly.
- Run the cell.
- You should see the confirmation message listing all seven saved files. You can also verify them in Colab's file browser panel on the left side.
✔️ Awesome, I've got everything!
Great. Double check you have run all cells from Cell 12 through Cell 15 and saved your notebook.
ⓧ I'd like to double check the full code
Here is the complete notebook from Cell 1 through Cell 15. Compare your notebook against this to make sure everything matches.
# Cell 1: Setup and Installation
!pip install xgboost shap imbalanced-learn -q
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.model_selection import train_test_split, StratifiedKFold, cross_val_score
from sklearn.preprocessing import StandardScaler, LabelEncoder
from sklearn.metrics import (
roc_auc_score, classification_report, confusion_matrix,
roc_curve, precision_recall_curve, f1_score
)
from imblearn.over_sampling import SMOTE
import xgboost as xgb
import shap
import warnings
warnings.filterwarnings('ignore')
print("All libraries loaded successfully!")
# Cell 2: Load and Inspect Data
url = "https://raw.githubusercontent.com/IBM/telco-customer-churn-on-icp4d/master/data/Telco-Customer-Churn.csv"
df = pd.read_csv(url)
print(f"Dataset shape: {df.shape}")
print(f"\nColumn names:\n{df.columns.tolist()}")
print(f"\nTarget distribution:")
print(df['Churn'].value_counts(normalize=True))
# Cell 3: Data Cleaning
df['TotalCharges'] = pd.to_numeric(df['TotalCharges'], errors='coerce')
df['TotalCharges'].fillna(0, inplace=True)
df.drop('customerID', axis=1, inplace=True)
df['Churn'] = df['Churn'].map({'Yes': 1, 'No': 0})
print(f"Missing values after cleaning: {df.isnull().sum().sum()}")
print(f"Churn rate: {df['Churn'].mean():.3f} ({df['Churn'].mean()*100:.1f}%)")
# Cell 4: Exploratory Data Analysis
fig, axes = plt.subplots(2, 2, figsize=(14, 10))
contract_churn = df.groupby('Contract')['Churn'].mean().sort_values(ascending=False)
contract_churn.plot(kind='bar', ax=axes[0, 0], color=['#e74c3c', '#f39c12', '#27ae60'])
axes[0, 0].set_title('Churn Rate by Contract Type')
axes[0, 0].set_ylabel('Churn Rate')
axes[0, 0].set_xticklabels(axes[0, 0].get_xticklabels(), rotation=0)
payment_churn = df.groupby('PaymentMethod')['Churn'].mean().sort_values(ascending=False)
payment_churn.plot(kind='bar', ax=axes[0, 1], color=['#e74c3c', '#f39c12', '#3498db', '#27ae60'])
axes[0, 1].set_title('Churn Rate by Payment Method')
axes[0, 1].set_ylabel('Churn Rate')
axes[0, 1].set_xticklabels(axes[0, 1].get_xticklabels(), rotation=45, ha='right')
axes[1, 0].hist(df[df['Churn']==0]['tenure'], bins=30, alpha=0.7, label='Retained', color='#27ae60')
axes[1, 0].hist(df[df['Churn']==1]['tenure'], bins=30, alpha=0.7, label='Churned', color='#e74c3c')
axes[1, 0].set_title('Tenure Distribution by Churn Status')
axes[1, 0].set_xlabel('Tenure (months)')
axes[1, 0].legend()
axes[1, 1].hist(df[df['Churn']==0]['MonthlyCharges'], bins=30, alpha=0.7, label='Retained', color='#27ae60')
axes[1, 1].hist(df[df['Churn']==1]['MonthlyCharges'], bins=30, alpha=0.7, label='Churned', color='#e74c3c')
axes[1, 1].set_title('Monthly Charges Distribution by Churn Status')
axes[1, 1].set_xlabel('Monthly Charges ($)')
axes[1, 1].legend()
plt.tight_layout()
plt.show()
print("\nKey Insights:")
print(f"- Month-to-month contract churn rate: {contract_churn.iloc[0]:.1%}")
print(f"- Two year contract churn rate: {contract_churn.iloc[-1]:.1%}")
print(f"- Electronic check payment churn rate: {payment_churn.iloc[0]:.1%}")
# Cell 5: Feature Engineering
service_columns = ['PhoneService', 'MultipleLines', 'InternetService',
'OnlineSecurity', 'OnlineBackup', 'DeviceProtection',
'TechSupport', 'StreamingTV', 'StreamingMovies']
df['num_services'] = 0
for col in service_columns:
if col == 'InternetService':
df['num_services'] += (df[col] != 'No').astype(int)
else:
df['num_services'] += (df[col] == 'Yes').astype(int)
df['avg_monthly_spend'] = np.where(
df['tenure'] > 0,
df['TotalCharges'] / df['tenure'],
df['MonthlyCharges']
)
monthly_75th = df['MonthlyCharges'].quantile(0.75)
df['is_high_value'] = (df['MonthlyCharges'] > monthly_75th).astype(int)
df['contract_risk'] = (
(df['Contract'] == 'Month-to-month') &
(df['PaymentMethod'] == 'Electronic check')
).astype(int)
print("Engineered features added:")
print(f"- num_services range: {df['num_services'].min()} to {df['num_services'].max()}")
print(f"- avg_monthly_spend mean: ${df['avg_monthly_spend'].mean():.2f}")
print(f"- High value customers: {df['is_high_value'].sum()} ({df['is_high_value'].mean():.1%})")
print(f"- High contract risk: {df['contract_risk'].sum()} ({df['contract_risk'].mean():.1%})")
# Cell 6: Prepare Modeling Dataset
y = df['Churn']
X = df.drop('Churn', axis=1)
categorical_cols = X.select_dtypes(include=['object']).columns.tolist()
numerical_cols = X.select_dtypes(include=['number']).columns.tolist()
print(f"Categorical columns ({len(categorical_cols)}): {categorical_cols}")
print(f"Numerical columns ({len(numerical_cols)}): {numerical_cols}")
X_encoded = pd.get_dummies(X, columns=categorical_cols, drop_first=True)
print(f"\nFinal feature matrix shape: {X_encoded.shape}")
X_train, X_test, y_train, y_test = train_test_split(
X_encoded, y, test_size=0.2, random_state=42, stratify=y
)
print(f"Training set: {X_train.shape[0]} samples")
print(f"Test set: {X_test.shape[0]} samples")
print(f"Train churn rate: {y_train.mean():.3f}")
print(f"Test churn rate: {y_test.mean():.3f}")
# Cell 7: Handle Class Imbalance and Train XGBoost
smote = SMOTE(random_state=42)
X_train_resampled, y_train_resampled = smote.fit_resample(X_train, y_train)
print(f"Before SMOTE: {y_train.value_counts().to_dict()}")
print(f"After SMOTE: {pd.Series(y_train_resampled).value_counts().to_dict()}")
scale_pos_weight = (y_train == 0).sum() / (y_train == 1).sum()
model = xgb.XGBClassifier(
n_estimators=200,
max_depth=5,
learning_rate=0.05,
scale_pos_weight=scale_pos_weight,
random_state=42,
eval_metric='logloss',
use_label_encoder=False
)
model.fit(X_train_resampled, y_train_resampled)
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
cv_scores = cross_val_score(model, X_train, y_train, cv=cv, scoring='roc_auc')
print(f"\n5-Fold CV ROC-AUC: {cv_scores.mean():.4f} (+/- {cv_scores.std():.4f})")
# Cell 8: Model Evaluation
y_pred_proba = model.predict_proba(X_test)[:, 1]
y_pred = model.predict(X_test)
roc_auc = roc_auc_score(y_test, y_pred_proba)
print(f"Test ROC-AUC: {roc_auc:.4f}")
print(f"\nClassification Report (threshold=0.5):")
print(classification_report(y_test, y_pred, target_names=['Retained', 'Churned']))
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
fpr, tpr, _ = roc_curve(y_test, y_pred_proba)
axes[0].plot(fpr, tpr, 'b-', linewidth=2, label=f'XGBoost (AUC = {roc_auc:.3f})')
axes[0].plot([0, 1], [0, 1], 'k--', linewidth=1)
axes[0].set_xlabel('False Positive Rate')
axes[0].set_ylabel('True Positive Rate')
axes[0].set_title('ROC Curve')
axes[0].legend()
precision, recall, thresholds_pr = precision_recall_curve(y_test, y_pred_proba)
axes[1].plot(recall, precision, 'r-', linewidth=2)
axes[1].set_xlabel('Recall')
axes[1].set_ylabel('Precision')
axes[1].set_title('Precision-Recall Curve')
axes[1].axhline(y=y_test.mean(), color='k', linestyle='--', label=f'Baseline ({y_test.mean():.2f})')
axes[1].legend()
plt.tight_layout()
plt.show()
# Cell 9: Threshold Optimization
thresholds = np.arange(0.2, 0.8, 0.05)
results = []
for thresh in thresholds:
y_pred_thresh = (y_pred_proba >= thresh).astype(int)
tp = ((y_pred_thresh == 1) & (y_test == 1)).sum()
fp = ((y_pred_thresh == 1) & (y_test == 0)).sum()
fn = ((y_pred_thresh == 0) & (y_test == 1)).sum()
recall_val = tp / (tp + fn) if (tp + fn) > 0 else 0
precision_val = tp / (tp + fp) if (tp + fp) > 0 else 0
f1_val = 2 * precision_val * recall_val / (precision_val + recall_val) if (precision_val + recall_val) > 0 else 0
results.append({
'threshold': thresh,
'precision': precision_val,
'recall': recall_val,
'f1': f1_val
})
results_df = pd.DataFrame(results)
fig, ax = plt.subplots(figsize=(10, 5))
ax.plot(results_df['threshold'], results_df['precision'], 'b-', label='Precision', linewidth=2)
ax.plot(results_df['threshold'], results_df['recall'], 'r-', label='Recall', linewidth=2)
ax.plot(results_df['threshold'], results_df['f1'], 'g--', label='F1 Score', linewidth=2)
ax.set_xlabel('Classification Threshold')
ax.set_ylabel('Score')
ax.set_title('Threshold Optimization: Precision vs Recall vs F1')
ax.legend()
ax.grid(True, alpha=0.3)
plt.show()
optimal_threshold = results_df.loc[results_df['f1'].idxmax(), 'threshold']
print(f"\nOptimal threshold (max F1): {optimal_threshold:.2f}")
print(f"At this threshold:")
print(f" Precision: {results_df.loc[results_df['f1'].idxmax(), 'precision']:.3f}")
print(f" Recall: {results_df.loc[results_df['f1'].idxmax(), 'recall']:.3f}")
print(f" F1: {results_df.loc[results_df['f1'].idxmax(), 'f1']:.3f}")
# Cell 10: SHAP Explainability
explainer = shap.TreeExplainer(model)
shap_values = explainer.shap_values(X_test)
plt.figure(figsize=(12, 8))
shap.summary_plot(shap_values, X_test, max_display=15, show=False)
plt.title('SHAP Feature Importance - Top 15 Churn Drivers')
plt.tight_layout()
plt.show()
# Cell 11: Individual Customer Explanations
high_risk_indices = X_test.index[y_pred_proba > 0.7]
if len(high_risk_indices) > 0:
for i, idx in enumerate(high_risk_indices[:3]):
position = list(X_test.index).index(idx)
print(f"\n--- High-Risk Customer {i+1} (Churn probability: {y_pred_proba[position]:.1%}) ---")
shap.waterfall_plot(
shap.Explanation(
values=shap_values[position],
base_values=explainer.expected_value,
data=X_test.iloc[position],
feature_names=X_test.columns.tolist()
),
max_display=10,
show=True
)
feature_importance = pd.DataFrame({
'feature': X_test.columns,
'mean_abs_shap': np.abs(shap_values).mean(axis=0)
}).sort_values('mean_abs_shap', ascending=False)
print("\nTop 10 Churn Drivers (by mean |SHAP value|):")
for i, row in feature_importance.head(10).iterrows():
print(f" {row['feature']}: {row['mean_abs_shap']:.4f}")
# Cell 12: Revenue Impact Analysis
# Add predictions back to test data
test_analysis = X_test.copy()
test_analysis['churn_probability'] = y_pred_proba
test_analysis['actual_churn'] = y_test.values
# Find MonthlyCharges column (it's numerical, not encoded)
test_analysis['monthly_revenue_at_risk'] = test_analysis['MonthlyCharges'] * test_analysis['churn_probability']
test_analysis['annual_revenue_at_risk'] = test_analysis['monthly_revenue_at_risk'] * 12
# Risk tier segmentation
test_analysis['risk_tier'] = pd.cut(
test_analysis['churn_probability'],
bins=[0, 0.3, 0.6, 1.0],
labels=['Low Risk', 'Medium Risk', 'High Risk']
)
# Summary by risk tier
tier_summary = test_analysis.groupby('risk_tier', observed=True).agg(
customer_count=('churn_probability', 'count'),
avg_churn_prob=('churn_probability', 'mean'),
total_monthly_revenue_at_risk=('monthly_revenue_at_risk', 'sum'),
total_annual_revenue_at_risk=('annual_revenue_at_risk', 'sum'),
avg_monthly_charges=('MonthlyCharges', 'mean')
).round(2)
print("=" * 70)
print("REVENUE AT RISK ANALYSIS BY CUSTOMER SEGMENT")
print("=" * 70)
print(f"\nTotal customers analyzed: {len(test_analysis)}")
print(f"Total annual revenue at risk: ${test_analysis['annual_revenue_at_risk'].sum():,.0f}")
print(f"\nBreakdown by risk tier:")
print(tier_summary.to_string())
# Cell 13: Retention Campaign ROI Simulation
# Campaign parameters
intervention_cost = 50 # $ per customer contacted
success_rate_high = 0.25 # 25% of high-risk interventions succeed
success_rate_medium = 0.35 # 35% of medium-risk interventions succeed
# High-risk campaign
high_risk_customers = test_analysis[test_analysis['risk_tier'] == 'High Risk']
high_risk_count = len(high_risk_customers)
high_risk_campaign_cost = high_risk_count * intervention_cost
high_risk_saved_revenue = (
high_risk_customers['annual_revenue_at_risk'].sum() * success_rate_high
)
high_risk_roi = (high_risk_saved_revenue - high_risk_campaign_cost) / high_risk_campaign_cost * 100
# Medium-risk campaign
medium_risk_customers = test_analysis[test_analysis['risk_tier'] == 'Medium Risk']
medium_risk_count = len(medium_risk_customers)
medium_risk_campaign_cost = medium_risk_count * intervention_cost
medium_risk_saved_revenue = (
medium_risk_customers['annual_revenue_at_risk'].sum() * success_rate_medium
)
medium_risk_roi = (medium_risk_saved_revenue - medium_risk_campaign_cost) / medium_risk_campaign_cost * 100
# Total campaign
total_campaign_cost = high_risk_campaign_cost + medium_risk_campaign_cost
total_saved_revenue = high_risk_saved_revenue + medium_risk_saved_revenue
total_roi = (total_saved_revenue - total_campaign_cost) / total_campaign_cost * 100
print("=" * 70)
print("RETENTION CAMPAIGN ROI SIMULATION")
print("=" * 70)
print(f"\nAssumptions:")
print(f" - Intervention cost per customer: ${intervention_cost}")
print(f" - High-risk success rate: {success_rate_high:.0%}")
print(f" - Medium-risk success rate: {success_rate_medium:.0%}")
print(f"\n{'Segment':<15} {'Customers':<12} {'Cost':<12} {'Saved Revenue':<16} {'ROI':<10}")
print("-" * 65)
print(f"{'High Risk':<15} {high_risk_count:<12} ${high_risk_campaign_cost:<10,.0f} ${high_risk_saved_revenue:<14,.0f} {high_risk_roi:<10.0f}%")
print(f"{'Medium Risk':<15} {medium_risk_count:<12} ${medium_risk_campaign_cost:<10,.0f} ${medium_risk_saved_revenue:<14,.0f} {medium_risk_roi:<10.0f}%")
print(f"-" * 65)
print(f"{'TOTAL':<15} {high_risk_count + medium_risk_count:<12} ${total_campaign_cost:<10,.0f} ${total_saved_revenue:<14,.0f} {total_roi:<10.0f}%")
print(f"\nNet benefit: ${total_saved_revenue - total_campaign_cost:,.0f}")
# Cell 14: Executive Summary Visualization
fig, axes = plt.subplots(1, 3, figsize=(16, 5))
# Revenue at risk by tier
tier_revenue = test_analysis.groupby('risk_tier', observed=True)['annual_revenue_at_risk'].sum()
colors = ['#27ae60', '#f39c12', '#e74c3c']
tier_revenue.plot(kind='bar', ax=axes[0], color=colors)
axes[0].set_title('Annual Revenue at Risk by Segment')
axes[0].set_ylabel('Revenue at Risk ($)')
axes[0].set_xticklabels(axes[0].get_xticklabels(), rotation=0)
# Customer count by tier
tier_counts = test_analysis['risk_tier'].value_counts().sort_index()
tier_counts.plot(kind='bar', ax=axes[1], color=colors)
axes[1].set_title('Customer Count by Risk Tier')
axes[1].set_ylabel('Number of Customers')
axes[1].set_xticklabels(axes[1].get_xticklabels(), rotation=0)
# ROI comparison
roi_data = pd.Series({'High Risk\nCampaign': high_risk_roi, 'Medium Risk\nCampaign': medium_risk_roi, 'Combined\nCampaign': total_roi})
roi_data.plot(kind='bar', ax=axes[2], color=['#e74c3c', '#f39c12', '#3498db'])
axes[2].set_title('Retention Campaign ROI (%)')
axes[2].set_ylabel('ROI (%)')
axes[2].set_xticklabels(axes[2].get_xticklabels(), rotation=0)
axes[2].axhline(y=0, color='k', linestyle='--', linewidth=0.5)
plt.tight_layout()
plt.show()
print("\n" + "=" * 70)
print("EXECUTIVE SUMMARY")
print("=" * 70)
print(f"\n1. MODEL PERFORMANCE: ROC-AUC of {roc_auc:.3f} - the model correctly")
print(f" ranks at-risk customers {roc_auc*100:.0f}% of the time.")
print(f"\n2. REVENUE AT RISK: ${test_analysis['annual_revenue_at_risk'].sum():,.0f} annually")
print(f" from {len(test_analysis)} customers analyzed.")
print(f"\n3. TOP CHURN DRIVERS: {feature_importance.iloc[0]['feature']},")
print(f" {feature_importance.iloc[1]['feature']}, {feature_importance.iloc[2]['feature']}")
print(f"\n4. RECOMMENDED ACTION: Deploy targeted retention campaign")
print(f" - Investment: ${total_campaign_cost:,.0f}")
print(f" - Projected savings: ${total_saved_revenue:,.0f}")
print(f" - ROI: {total_roi:.0f}%")
# Cell 15: Save Artifacts (for Secret Mission)
import joblib
# Save model and data for Streamlit dashboard
joblib.dump(model, 'churn_model.pkl')
joblib.dump(X_test, 'X_test.pkl')
joblib.dump(y_test, 'y_test.pkl')
joblib.dump(y_pred_proba, 'predictions.pkl')
joblib.dump(explainer, 'shap_explainer.pkl')
joblib.dump(shap_values, 'shap_values.pkl')
joblib.dump(test_analysis, 'test_analysis.pkl')
print("All artifacts saved for Streamlit dashboard!")
print("Files: churn_model.pkl, X_test.pkl, y_test.pkl, predictions.pkl,")
print(" shap_explainer.pkl, shap_values.pkl, test_analysis.pkl")
Secret mission
Build an Interactive Streamlit Dashboard
Your notebook analysis is powerful, but stakeholders want something they can interact with. In this bonus challenge, you will build a standalone Streamlit app that presents your churn model findings with KPI cards, a filterable risk explorer, and per-customer SHAP explanations.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally and uses Google Colab (free), so there are no ongoing costs.
Resources you used:
- churn_analytics.ipynb notebook saved in Google Drive.
- Local project files: app.py and 7 .pkl artifact files.
Keep everything running
No action needed. Choose this if you plan to revisit the notebook or extend the Streamlit dashboard further.
- Your notebook is saved in Google Drive automatically. You can return to it anytime at colab.research.google.com.
- Your local app.py and .pkl files remain in place for running the Streamlit dashboard again.
- Download a copy of your notebook from Google Colab (File > Download > Download .ipynb) as a portfolio piece.
Pause - I'll come back to this later
Stop any running processes to free up system resources. Your files stay intact for later.
- If your Streamlit app is still running, press Ctrl+C in the terminal to stop it.
- Close the Google Colab browser tab. Your notebook remains saved in Google Drive.
- To resume later, reopen the notebook in Colab and run all cells from the top (Runtime > Run all).
Delete - I don't want to use this again
Remove all project files and start fresh. This cannot be undone.
- Delete the Google Colab notebook: open Google Drive, find churn_analytics.ipynb, right-click it, and select Move to trash.
- Delete your local project files by running this command in the terminal from the folder containing your files:
macOS
rm -f app.py churn_model.pkl X_test.pkl y_test.pkl predictions.pkl shap_explainer.pkl shap_values.pkl test_analysis.pkl
Windows
del app.py, churn_model.pkl, X_test.pkl, y_test.pkl, predictions.pkl, shap_explainer.pkl, shap_values.pkl, test_analysis.pkl
- Optionally, uninstall the Python packages you installed locally for the Streamlit dashboard:
pip uninstall streamlit shap xgboost joblib -y
That's a Wrap!
That's a Wrap!
Nice work. You just built a complete churn prediction and revenue retention analytics platform from scratch.
You've learned how to:
- Trained an XGBoost churn classifier with SMOTE class imbalance handling and cross-validated evaluation achieving ROC-AUC > 0.80.
- Used SHAP explainability to generate waterfall and summary plots showing exactly which features drive individual customer churn predictions.
- Quantified revenue at risk in dollar terms, segmented customers into risk tiers, and simulated a targeted retention campaign ROI to translate ML outputs into executive-ready business recommendations.
- Secret Mission: Deployed findings as an interactive Streamlit dashboard with KPI cards, a risk explorer, and per-customer SHAP explainability.
Ready to quiz yourself?