Machine Learning — Customer Churn Prediction
End-to-end ML pipeline that cleans, explores, and models 7,000 telecom customer records to predict churn — comparing 5 classification algorithms, explaining predictions with SHAP, and deploying an interactive Streamlit app for real-time risk scoring.
Tools: Python · pandas · NumPy · scikit-learn · XGBoost · LightGBM · SHAP · matplotlib · seaborn · Streamlit · Jupyter Notebook
Live App: Launch Streamlit Churn Predictor →
Project Overview
Overview
This project builds an end-to-end machine learning pipeline that cleans, explores, and models 7,000 telecom customer records to predict churn — comparing five classification algorithms head-to-head, explaining predictions with SHAP values, and deploying an interactive Streamlit web app for real-time risk scoring. The pipeline spans six phases: data cleaning, exploratory data analysis, feature engineering & preprocessing, model training & comparison, hyperparameter tuning, and SHAP explainability — each implemented in dedicated Jupyter notebooks with all artifacts saved for downstream consumption.
The final deployed model is a Logistic Regression classifier (class_weight='balanced') achieving 0.83 AUC and 79% recall on the held-out test set. The model is served through a four-page Streamlit application where users can input any customer profile and receive a churn probability score with a SHAP waterfall explanation showing exactly which features drove that prediction.
Business Context
Customer churn — when a customer cancels their service — is one of the most expensive problems in subscription businesses. Acquiring a new customer costs 5–7× more than retaining an existing one. Telecom companies, SaaS platforms, streaming services, and any company with recurring revenue build churn prediction models to identify at-risk customers before they leave, target retention offers to the right people, quantify the financial impact of churn reduction, and understand which factors drive customer attrition.
This project simulates the role of a data analyst or data scientist tasked with building a churn prediction system for a telecom company's customer success team. The dataset comes from IBM's Telco Customer Churn sample on Kaggle — 7,043 customers with 21 attributes covering demographics, service subscriptions, account details, and churn status.
Why Recall Is the Priority Metric
In churn prediction, the costs of errors are asymmetric. A false negative (missed churner) means
a lost customer and lost lifetime revenue. A false positive (flagging a loyal customer) means
sending an unnecessary retention offer — a much cheaper mistake. This cost asymmetry means we optimize for
recall: catching as many actual churners as possible, even if it means generating some false alarms.
Every model in this project handles class imbalance explicitly (via class_weight='balanced' or
scale_pos_weight), and model selection prioritizes recall alongside AUC.
Pipeline Architecture
The project follows a structured six-phase pipeline, each phase in its own Jupyter notebook with shared utilities in a dedicated Python module:
| Phase | Notebook | Purpose | Key Output |
|---|---|---|---|
| 1 — Data Cleaning | 01_data_cleaning.ipynb |
Load, inspect, fix dtypes, handle NaNs, encode target | telco_churn_cleaned.csv |
| 2 — EDA | 02_eda.ipynb |
7 visualizations, churn driver identification | 7 chart PNGs, documented findings |
| 3 — Feature Engineering | 03_feature_engineering.ipynb |
5 engineered features, preprocessing pipeline, train/test split | preprocessor.pkl, feature_names.pkl |
| 4 — Model Training | 04_model_training.ipynb |
Train 5 models, cross-validate, compare on test set | best_model.pkl, model_comparison.csv |
| 5 — Tuning | 05_tuning_evaluation.ipynb |
RandomizedSearchCV (50 iterations), default vs tuned comparison | Decision: keep default balanced model |
| 6 — SHAP | 05_tuning_evaluation.ipynb |
Global importance, waterfall explanations, business translations | 3 SHAP chart PNGs |
Key Results at a Glance
| Metric | Value |
|---|---|
| Best Model | Logistic Regression (class_weight='balanced') |
| Test AUC | 0.8344 |
| Test Recall | 0.7888 (79% of churners caught) |
| Test F1 | 0.6033 |
| Test Accuracy | 0.7242 |
| Models Compared | 5 (Logistic Regression, Random Forest, XGBoost, LightGBM, SVM) |
| Tuning Decision | Kept default — tuned model lost 22pp recall for marginal AUC gain |
| SHAP #1 Feature | tenure (low tenure strongly predicts churn) |
| Deployed App | 4-page Streamlit app with real-time predictions |
Skills Demonstrated
| Skill Area | What This Project Shows |
|---|---|
| Data Cleaning | dtype conversion, NaN handling decisions, target encoding, data quality validation |
| Exploratory Data Analysis | 7 publication-quality visualizations, churn driver identification, statistical summaries |
| Feature Engineering | 5 domain-informed features (ServiceCount, HasInternet, HasPhone, AvgMonthlyCharge, TenureGroup) with shared Python module for train/serve consistency |
| Machine Learning | 5-model head-to-head comparison across 3 algorithmic families (linear, ensemble, geometric), stratified cross-validation, class imbalance handling |
| Hyperparameter Tuning | RandomizedSearchCV (50 iterations, 5 folds), principled decision to reject tuned model based on recall loss — demonstrates business-aware model selection |
| Model Explainability | SHAP LinearExplainer for global feature importance and per-customer waterfall explanations — translating predictions into business-readable insights |
| Deployment | 4-page Streamlit app with real-time prediction, serialized model/preprocessor loading, SHAP waterfall generation on-the-fly |
| Python Ecosystem | pandas · NumPy · scikit-learn · XGBoost · LightGBM · SHAP · matplotlib · seaborn · Streamlit · joblib |
| Communication | Business translations of SHAP findings, cost-asymmetry framing of metric selection, actionable retention recommendations |
Live Application
Launch Streamlit Churn Predictor → — Input any customer profile and receive a churn risk score with a SHAP waterfall explanation.
Dataset
Source & Overview
This project uses the Telco Customer Churn dataset — an IBM sample dataset hosted on Kaggle at kaggle.com/datasets/blastchar/telco-customer-churn. The dataset contains records for 7,043 customers of a fictional telecom company, with 21 columns capturing customer demographics, service subscriptions, account details, and whether the customer churned (cancelled their service) in the last month.
| Property | Value |
|---|---|
| File | WA_Fn-UseC_-Telco-Customer-Churn.csv |
| File Size | ~955 KB |
| Raw Dimensions | 7,043 rows × 21 columns |
| Cleaned Dimensions | 7,032 rows × 20 columns |
| Target Variable | Churn (Yes/No → 1/0, binary classification) |
| Class Distribution | 73.4% No Churn (5,163) / 26.6% Churn (1,869) — moderately imbalanced |
Column Descriptions — Customer Demographics (5 columns)
| Column | Type | Values | Description |
|---|---|---|---|
customerID |
string | 7,043 unique | Unique customer identifier — dropped before modeling (not a predictive feature) |
gender |
categorical | Male, Female | Customer gender — EDA showed no meaningful difference in churn rates |
SeniorCitizen |
binary | 0, 1 | Whether customer is 65+ — already numeric (unlike other binary columns which use Yes/No), treated as a numeric feature during preprocessing |
Partner |
categorical | Yes, No | Whether customer has a partner |
Dependents |
categorical | Yes, No | Whether customer has dependents |
Column Descriptions — Service Information (10 columns)
| Column | Type | Values | Description |
|---|---|---|---|
tenure |
integer | 0–72 months | Number of months the customer has been with the company — emerged as the single strongest SHAP feature |
PhoneService |
categorical | Yes, No | Whether customer has phone service |
MultipleLines |
categorical | Yes, No, No phone service | Whether customer has multiple phone lines — "No phone service" is a distinct category, not missing data |
InternetService |
categorical | DSL, Fiber optic, No | Type of internet service — fiber optic customers showed significantly higher churn rates despite paying more |
OnlineSecurity |
categorical | Yes, No, No internet service | Whether customer has online security add-on — absence correlates strongly with churn |
OnlineBackup |
categorical | Yes, No, No internet service | Whether customer has online backup add-on |
DeviceProtection |
categorical | Yes, No, No internet service | Whether customer has device protection add-on |
TechSupport |
categorical | Yes, No, No internet service | Whether customer has tech support add-on — absence correlates strongly with churn |
StreamingTV |
categorical | Yes, No, No internet service | Whether customer has streaming TV add-on |
StreamingMovies |
categorical | Yes, No, No internet service | Whether customer has streaming movies add-on |
Column Descriptions — Account Information (5 columns)
| Column | Type | Values | Description |
|---|---|---|---|
Contract |
categorical | Month-to-month, One year, Two year | Contract type — the single strongest churn predictor in EDA (42.7% churn for month-to-month vs. 2.8% for two-year) |
PaperlessBilling |
categorical | Yes, No | Whether customer uses paperless billing |
PaymentMethod |
categorical | Electronic check, Mailed check, Bank transfer (automatic), Credit card (automatic) | Payment method — electronic check showed noticeably higher churn than other methods |
MonthlyCharges |
float | 18.25–118.75 | Monthly charge amount in dollars — churners skew toward $70–$100/month plans |
TotalCharges |
string → float | varies | Total amount charged over customer lifetime — stored as string in raw data with whitespace entries for tenure=0 customers (see Known Data Issues below) |
Column Descriptions — Target Variable (1 column)
| Column | Type | Values | Description |
|---|---|---|---|
Churn |
categorical → binary | Yes → 1, No → 0 | Whether the customer churned (cancelled service) in the last month — this is the prediction target |
Class Distribution
The target variable is moderately imbalanced: 73.4% of customers did not churn (class 0) while
26.6% did churn (class 1). This imbalance is not extreme enough to require synthetic oversampling (SMOTE), but it
is significant enough that all five models in this project use explicit class imbalance handling —
class_weight='balanced' for Logistic Regression, Random Forest, and SVM, and
scale_pos_weight (ratio of negatives to positives ≈ 2.76) for XGBoost and LightGBM. Evaluation
also prioritizes recall and AUC over raw accuracy, since a naive "predict no churn for everyone" model would
achieve 73.4% accuracy while catching zero churners.
# Verify class distribution after encoding
print(df['Churn'].value_counts(normalize=True).round(4))
# Output:
# Churn
# 0 0.7342
# 1 0.2658
# Compute class weight ratio for gradient boosting models
pos_weight = y_train.value_counts()[0] / y_train.value_counts()[1]
# pos_weight ≈ 2.76
Known Data Issues
Three data issues were identified and handled during cleaning (see Phase 1 for full details and code):
TotalCharges stored as string with 11 whitespace entries for tenure=0 customers (converted and dropped),
SeniorCitizen already encoded as 0/1 (routed to numeric features),
and "No internet service" / "No phone service" values preserved as distinct categories during encoding.
Feature Categorization Summary
After cleaning, the 20 remaining columns break down into three groups that determined preprocessing strategy in Phase 3:
| Category | Count | Columns | Preprocessing |
|---|---|---|---|
| Numeric (original) | 4 | SeniorCitizen, tenure, MonthlyCharges, TotalCharges |
StandardScaler |
| Categorical (binary Yes/No) | 6 | gender, Partner, Dependents, PhoneService, PaperlessBilling, Churn (target) |
OneHotEncoder (drop='first') for features; Churn mapped directly to 0/1 |
| Categorical (3–4 values) | 9 | MultipleLines, InternetService, OnlineSecurity, OnlineBackup, DeviceProtection, TechSupport, StreamingTV, StreamingMovies, Contract, PaymentMethod |
OneHotEncoder (drop='first', handle_unknown='ignore') |
After feature engineering in Phase 3 (adding ServiceCount, HasInternet,
HasPhone, AvgMonthlyCharge, and TenureGroup), the final feature set
grows to 9 numeric + 15 categorical = 24 features before encoding, expanding to
35 columns after one-hot encoding with drop='first'.
Phase 1 — Data Cleaning
Overview
Phase 1 loads the raw Telco Customer Churn CSV, inspects its structure for data quality issues, fixes the
one significant dtype problem (TotalCharges stored as string), handles the resulting NaN rows,
drops the non-predictive customerID column, encodes the target variable, and saves a cleaned
dataset for all downstream notebooks. The entire phase runs in
notebooks/01_data_cleaning.ipynb.
Step 1: Load & Confirm Shape
The raw dataset is loaded and the shape and column dtypes are immediately checked. This first inspection
flagged that TotalCharges was stored as object dtype (string) instead of numeric
— the key data issue that drives the rest of the cleaning logic.
import pandas as pd
import numpy as np
# Load the raw dataset
df = pd.read_csv('../data/WA_Fn-UseC_-Telco-Customer-Churn.csv')
# Confirm shape and inspect dtypes
print(f"Shape: {df.shape}")
print(f"\nColumn dtypes:\n{df.dtypes}")
# Output:
# Shape: (7043, 21)
#
# customerID object
# gender object
# SeniorCitizen int64 ← already numeric (0/1)
# Partner object
# Dependents object
# tenure int64
# PhoneService object
# MultipleLines object
# InternetService object
# OnlineSecurity object
# OnlineBackup object
# DeviceProtection object
# TechSupport object
# StreamingTV object
# StreamingMovies object
# Contract object
# PaperlessBilling object
# PaymentMethod object
# MonthlyCharges float64
# TotalCharges object ← should be numeric – flagged for investigation
# Churn object
Step 2: Visual Inspection
A quick look at the first 10 rows confirms the general structure — categorical columns use readable
string values (Yes/No, Male/Female, Month-to-month, etc.), numeric columns look reasonable, and
TotalCharges appears numeric in most rows but is typed as object:
# First look at the data
df.head(10)
# Output (abbreviated):
# customerID gender SeniorCitizen Partner Dependents tenure ...
# 0 7590-VHVEG Female 0 Yes No 1 ...
# 1 5575-GNVDE Male 0 No No 34 ...
# 2 3668-QPYBK Male 0 No No 2 ...
# 3 7795-CFOCW Male 0 No No 45 ...
# 4 9237-HQITU Female 0 No No 2 ...
# ...
# [10 rows × 21 columns]
Step 3: Check for Missing Values, Duplicates & Cardinality
This step checks for explicit nulls, duplicate rows, and the number of unique values per column. The result
was clean on the surface — zero missing values across all 21 columns and zero duplicates — but
this was misleading for TotalCharges, where whitespace strings were hiding the real nulls.
The cardinality check also confirmed customerID had 7,043 unique values (one per row, confirming
it's just an identifier to drop).
# Check for missing values and duplicates
print(f"Missing values per column:\n{df.isnull().sum()}\n")
print(f"Duplicate rows: {df.duplicated().sum()}")
print(f"\nUnique values per column:\n{df.nunique()}")
# Output:
# Missing values per column:
# customerID 0
# gender 0
# SeniorCitizen 0
# Partner 0
# Dependents 0
# tenure 0
# PhoneService 0
# MultipleLines 0
# InternetService 0
# OnlineSecurity 0
# OnlineBackup 0
# DeviceProtection 0
# TechSupport 0
# StreamingTV 0
# StreamingMovies 0
# Contract 0
# PaperlessBilling 0
# PaymentMethod 0
# MonthlyCharges 0
# TotalCharges 0 ← 0 "missing" – but whitespace strings aren't caught by .isnull()
# Churn 0
#
# Duplicate rows: 0
#
# Unique values per column:
# customerID 7043 ← unique identifier, will drop
# gender 2
# SeniorCitizen 2
# Partner 2
# Dependents 2
# tenure 73 ← 0–72 months
# PhoneService 2
# MultipleLines 3 ← Yes, No, No phone service
# InternetService 3 ← DSL, Fiber optic, No
# OnlineSecurity 3
# OnlineBackup 3
# DeviceProtection 3
# TechSupport 3
# StreamingTV 3
# StreamingMovies 3
# Contract 3
# PaperlessBilling 2
# PaymentMethod 4 ← 4 payment methods
# MonthlyCharges 1585
# TotalCharges 6531
# Churn 2
Step 4: Fix TotalCharges Dtype & Investigate NaN Rows
This is the most important cleaning step. TotalCharges was stored as a string because 11 rows
contained whitespace instead of a numeric value. Converting with pd.to_numeric(errors='coerce')
turned these whitespace entries into NaN. Investigation confirmed a clear pattern: all
11 NaN rows had tenure=0 — brand new customers who had not yet been billed.
# Fix TotalCharges: convert from string to numeric
# Whitespace entries (tenure=0 customers) will become NaN
df['TotalCharges'] = pd.to_numeric(df['TotalCharges'], errors='coerce')
# Check how many NaNs this created
print(f"TotalCharges NaNs after conversion: {df['TotalCharges'].isna().sum()}")
# Output: TotalCharges NaNs after conversion: 11
# Investigate: show all 11 NaN rows
print(f"\nRows with NaN TotalCharges:")
print(df[df['TotalCharges'].isna()][['customerID', 'tenure', 'MonthlyCharges', 'TotalCharges']])
# Output:
# customerID tenure MonthlyCharges TotalCharges
# 488 4472-LVYGI 0 52.55 NaN
# 753 3115-CZMZD 0 20.25 NaN
# 936 5709-LVOEQ 0 80.85 NaN
# 1082 4367-NUYAO 0 25.75 NaN
# 1340 1371-DWPAZ 0 56.05 NaN
# 3331 7644-OMVMY 0 19.85 NaN
# 3826 3213-VVOLG 0 25.35 NaN
# 4380 2520-SGTTA 0 20.00 NaN
# 5218 2923-ARZLG 0 19.70 NaN
# 6670 4075-WKNIU 0 73.35 NaN
# 6754 2775-SEFEE 0 61.90 NaN
Decision: Drop vs. Impute
Two options were considered for handling these 11 rows:
| Option | Approach | Tradeoff |
|---|---|---|
| A — Drop (chosen) | Remove the 11 rows entirely | Loses 0.16% of data — negligible impact on a 7K-row dataset. Clean and simple. |
| B — Impute with 0 | Set TotalCharges = 0 for tenure=0 customers | Logically valid (no billing history = $0), but introduces 11 rows where tenure=0 and TotalCharges=0, which could create a misleading signal during modeling. |
Option A was chosen because dropping 11 rows from a 7,043-row dataset has no meaningful impact on model training, and it avoids any edge-case artifacts from imputed values. This is the kind of pragmatic cleaning decision that matters in practice — the data loss is negligible, so the simpler approach wins.
Step 5: Drop customerID, Encode Target & Verify
After handling TotalCharges, three remaining transformations complete the cleaning pipeline:
drop customerID (an identifier, not a predictive feature), encode the Churn
target variable from Yes/No strings to 1/0 binary integers, and verify the final shape and class distribution.
# Drop the 11 rows with NaN TotalCharges (0.16% of data – negligible)
# These are tenure=0 customers with no billing history
df = df.dropna(subset=['TotalCharges'])
# Drop customerID – not a predictive feature
df = df.drop('customerID', axis=1)
# Encode target variable: Yes=1, No=0
df['Churn'] = df['Churn'].map({'Yes': 1, 'No': 0})
# Verify final state
print(f"Shape after cleaning: {df.shape}")
print(f"\nTotalCharges dtype: {df['TotalCharges'].dtype}")
print(f"\nChurn distribution:\n{df['Churn'].value_counts(normalize=True).round(4)}")
# Output:
# Shape after cleaning: (7032, 20)
#
# TotalCharges dtype: float64
#
# Churn distribution:
# Churn
# 0 0.7342
# 1 0.2658
# Name: proportion, dtype: float64
Step 6: Save Cleaned Dataset
The cleaned DataFrame is saved to CSV for consumption by all subsequent notebooks (EDA, feature engineering, model training, tuning/SHAP). This establishes a clean handoff point — downstream notebooks load the cleaned file and never touch the raw data.
# Save cleaned dataset for use in subsequent notebooks
df.to_csv('../data/telco_churn_cleaned.csv', index=False)
print(f"Cleaned dataset saved: {df.shape[0]} rows × {df.shape[1]} columns")
print(f"Columns: {list(df.columns)}")
# Output:
# Cleaned dataset saved: 7032 rows × 20 columns
# Columns: ['gender', 'SeniorCitizen', 'Partner', 'Dependents', 'tenure',
# 'PhoneService', 'MultipleLines', 'InternetService', 'OnlineSecurity',
# 'OnlineBackup', 'DeviceProtection', 'TechSupport', 'StreamingTV',
# 'StreamingMovies', 'Contract', 'PaperlessBilling', 'PaymentMethod',
# 'MonthlyCharges', 'TotalCharges', 'Churn']
Phase 1 Summary
| Step | Action | Result |
|---|---|---|
| 1 | Load raw CSV | 7,043 rows × 21 columns |
| 2 | Visual inspection (df.head(10)) |
Confirmed structure; noted TotalCharges as object dtype |
| 3 | Check nulls, duplicates, cardinality | 0 explicit nulls, 0 duplicates — but whitespace strings in TotalCharges were hiding the real issue |
| 4 | Convert TotalCharges to float64 |
11 whitespace entries → NaN; all 11 had tenure=0 |
| 5 | Drop 11 NaN rows | 0.16% data loss — negligible |
| 6 | Drop customerID |
Not a predictive feature |
| 7 | Encode target: Churn Yes → 1, No → 0 | Binary classification target ready |
| 8 | Save cleaned CSV | data/telco_churn_cleaned.csv — 7,032 rows × 20 columns |
Cleaning Results at a Glance
| Metric | Before | After |
|---|---|---|
| Rows | 7,043 | 7,032 |
| Columns | 21 | 20 |
| TotalCharges dtype | object (string) | float64 |
| Null values | 11 hidden (whitespace strings) | 0 |
| Duplicate rows | 0 | 0 |
| Churn encoding | Yes / No (string) | 1 / 0 (int) |
| Class distribution | — | 73.4% No Churn / 26.6% Churn |
Key Observations Carried Forward
Three observations from the cleaning phase informed decisions in later phases:
1. SeniorCitizen is already 0/1. Unlike every other binary column (which uses Yes/No strings),
SeniorCitizen was pre-encoded as integers. In Phase 3, this column was routed to the numeric
feature list (processed with StandardScaler) rather than the categorical list (processed with
OneHotEncoder).
2. "No internet service" and "No phone service" are informative categories. Six service
columns contain "No internet service" and MultipleLines contains "No phone service." These are
not missing data — they indicate a customer who simply doesn't have that base service. They were
preserved as distinct categories during one-hot encoding in Phase 3.
3. Moderate class imbalance (73/27) requires explicit handling. The 26.6% churn rate is
enough to bias a model toward always predicting "no churn." In Phase 4, all five models used explicit
imbalance handling (class_weight='balanced' or scale_pos_weight), and evaluation
prioritized recall and AUC over raw accuracy.
Phase 2 — Exploratory Data Analysis
Overview
Phase 2 explores the cleaned dataset visually and statistically to identify which features are strong
churn predictors and which carry no signal. The goal is to build intuition about churn drivers before
any modeling, and to produce publication-quality visualizations that document the findings. All work
runs in notebooks/02_eda.ipynb, with 7 chart files saved to outputs/figures/
for reuse in the Streamlit app and this portfolio page.
Key Code: Setup & Load Cleaned Data
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
# Set consistent plot style
sns.set_style('whitegrid')
plt.rcParams['figure.figsize'] = (10, 6)
plt.rcParams['font.size'] = 12
# Load cleaned dataset from Phase 1
df = pd.read_csv('../data/telco_churn_cleaned.csv')
print(f"Loaded: {df.shape[0]} rows × {df.shape[1]} columns")
# Output: Loaded: 7032 rows × 20 columns
Chart 1: Overall Churn Distribution
The first visualization confirms the class distribution: 73.4% of customers did not churn (5,163) while 26.6% did churn (1,869). This moderate imbalance is important context — it means accuracy alone is a misleading metric (a model predicting "no churn" for everyone scores 73.4%), which is why recall and AUC were prioritized throughout the project.
# Overall churn distribution
fig, ax = plt.subplots(figsize=(8, 5))
churn_counts = df['Churn'].value_counts()
bars = ax.bar(['No Churn (0)', 'Churn (1)'], churn_counts.values,
color=['#2ecc71', '#e74c3c'], edgecolor='black', linewidth=0.5)
# Add value labels on bars
for bar, count in zip(bars, churn_counts.values):
pct = count / len(df) * 100
ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 50,
f'{count:,}\n({pct:.1f}%)', ha='center', fontsize=12, fontweight='bold')
ax.set_title('Customer Churn Distribution', fontsize=14, fontweight='bold')
ax.set_ylabel('Number of Customers')
plt.tight_layout()
plt.savefig('../outputs/figures/churn_distribution.png', dpi=150, bbox_inches='tight')
plt.show()
class_weight='balanced' in modeling and
evaluate with recall/AUC rather than accuracy.
Chart 2: Churn Rate by Contract Type
This is the single most impactful chart from the entire EDA. Contract type is the strongest churn predictor by a wide margin: month-to-month customers churn at 42.7%, one-year contract customers at 11.3%, and two-year contract customers at just 2.8%. This 15× difference between the highest and lowest churn rates makes contract type the clearest lever for retention strategy.
# Churn rate by contract type – the single most impactful EDA chart
contract_churn = df.groupby('Contract')['Churn'].mean().sort_values(ascending=False)
fig, ax = plt.subplots(figsize=(8, 5))
colors = ['#e74c3c', '#f39c12', '#2ecc71']
bars = ax.bar(contract_churn.index, contract_churn.values * 100,
color=colors, edgecolor='black', linewidth=0.5)
for bar, rate in zip(bars, contract_churn.values):
ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.8,
f'{rate*100:.1f}%', ha='center', fontsize=13, fontweight='bold')
ax.set_title('Churn Rate by Contract Type', fontsize=14, fontweight='bold')
ax.set_ylabel('Churn Rate (%)')
ax.set_ylim(0, 50)
plt.tight_layout()
plt.savefig('../outputs/figures/churn_by_contract.png', dpi=150, bbox_inches='tight')
plt.show()
Chart 3: Tenure Distribution by Churn Status
This visualization confirms that churn is heavily front-loaded. Customers who churned
are concentrated in the first 0–12 months of tenure, while retained customers are distributed more
evenly across the full 0–72 month range. Long-tenured customers (60+ months) rarely churn. This
pattern directly motivated the TenureGroup and is_new_customer engineered
features in Phase 3.
# Tenure distribution by churn status – overlapping histograms
fig, ax = plt.subplots(figsize=(10, 6))
ax.hist(df[df['Churn'] == 0]['tenure'], bins=40, alpha=0.6,
label='No Churn', color='#2ecc71', edgecolor='black', linewidth=0.3)
ax.hist(df[df['Churn'] == 1]['tenure'], bins=40, alpha=0.6,
label='Churn', color='#e74c3c', edgecolor='black', linewidth=0.3)
ax.set_title('Tenure Distribution by Churn Status', fontsize=14, fontweight='bold')
ax.set_xlabel('Tenure (months)')
ax.set_ylabel('Number of Customers')
ax.legend(fontsize=12)
plt.tight_layout()
plt.savefig('../outputs/figures/tenure_by_churn.png', dpi=150, bbox_inches='tight')
plt.show()
Chart 4: Monthly Charges Distribution by Churn Status
Customers who churned skew toward higher monthly charges ($70–$100/month), while retained customers cluster at the low end (~$20). This aligns with the fiber optic finding (fiber costs more) and suggests that higher-paying customers may feel they're not getting enough value for the premium.
# Monthly charges distribution by churn status
fig, ax = plt.subplots(figsize=(10, 6))
ax.hist(df[df['Churn'] == 0]['MonthlyCharges'], bins=40, alpha=0.6,
label='No Churn', color='#2ecc71', edgecolor='black', linewidth=0.3)
ax.hist(df[df['Churn'] == 1]['MonthlyCharges'], bins=40, alpha=0.6,
label='Churn', color='#e74c3c', edgecolor='black', linewidth=0.3)
ax.set_title('Monthly Charges by Churn Status', fontsize=14, fontweight='bold')
ax.set_xlabel('Monthly Charges ($)')
ax.set_ylabel('Number of Customers')
ax.legend(fontsize=12)
plt.tight_layout()
plt.savefig('../outputs/figures/monthly_charges_by_churn.png', dpi=150, bbox_inches='tight')
plt.show()
Chart 5: Churn Rate Across All Categorical Features
A 4×4 grid of bar charts shows the churn rate across all 16 categorical features in a single view. This comprehensive visual makes it easy to spot which features have a strong signal (Contract, InternetService, OnlineSecurity, TechSupport, PaymentMethod) versus those with little or no signal (gender, PhoneService).
# Churn rate by ALL categorical features – 4×4 grid
cat_cols = ['gender', 'SeniorCitizen', 'Partner', 'Dependents',
'PhoneService', 'MultipleLines', 'InternetService', 'OnlineSecurity',
'OnlineBackup', 'DeviceProtection', 'TechSupport', 'StreamingTV',
'StreamingMovies', 'Contract', 'PaperlessBilling', 'PaymentMethod']
fig, axes = plt.subplots(4, 4, figsize=(20, 16))
axes = axes.flatten()
for i, col in enumerate(cat_cols):
churn_rate = df.groupby(col)['Churn'].mean().sort_values(ascending=False)
axes[i].bar(range(len(churn_rate)), churn_rate.values * 100,
color='#3498db', edgecolor='black', linewidth=0.3)
axes[i].set_title(col, fontsize=11, fontweight='bold')
axes[i].set_xticks(range(len(churn_rate)))
axes[i].set_xticklabels(churn_rate.index, rotation=45, ha='right', fontsize=8)
axes[i].set_ylabel('Churn %')
# Add value labels
for j, (rate, label) in enumerate(zip(churn_rate.values, churn_rate.index)):
axes[i].text(j, rate * 100 + 0.5, f'{rate*100:.1f}%',
ha='center', fontsize=7, fontweight='bold')
plt.suptitle('Churn Rate by Categorical Feature', fontsize=16, fontweight='bold', y=1.01)
plt.tight_layout()
plt.savefig('../outputs/figures/churn_by_categories.png', dpi=150, bbox_inches='tight')
plt.show()
Chart 6: Correlation Heatmap
The correlation heatmap quantifies relationships between the numeric features and the encoded churn target.
Key correlations: tenure vs. Churn is −0.35 (the strongest single
predictor), TotalCharges vs. tenure is 0.83 (expected —
charges accumulate with time), MonthlyCharges vs. Churn is 0.19 (moderate
positive), and TotalCharges vs. Churn is −0.20 (loyal customers
accumulate more charges). The high correlation between TotalCharges and tenure (0.83) was noted but
both were kept because the model can handle multicollinearity in tree-based methods, and the linear model
benefits from their distinct information.
# Correlation heatmap for numeric features + target
numeric_cols = ['SeniorCitizen', 'tenure', 'MonthlyCharges', 'TotalCharges', 'Churn']
corr_matrix = df[numeric_cols].corr()
fig, ax = plt.subplots(figsize=(8, 6))
sns.heatmap(corr_matrix, annot=True, fmt='.2f', cmap='RdBu_r', center=0,
square=True, linewidths=0.5, ax=ax,
vmin=-1, vmax=1, cbar_kws={'shrink': 0.8})
ax.set_title('Correlation Heatmap – Numeric Features + Churn', fontsize=14, fontweight='bold')
plt.tight_layout()
plt.savefig('../outputs/figures/correlation_heatmap.png', dpi=150, bbox_inches='tight')
plt.show()
Chart 7: Tenure vs. Monthly Charges Scatter Plot
This scatter plot explores whether there is a visible boundary between churners and non-churners in
the tenure–MonthlyCharges feature space. The pattern shows that churners (red) tend to cluster in the
low-tenure, high-charge region (bottom-right area), while retained customers are spread
more broadly. This confirms the intuition that customers paying a lot as new subscribers are the highest
risk — a pattern captured by the AvgMonthlyCharge engineered feature in Phase 3.
# Scatter plot: tenure vs monthly charges colored by churn
fig, ax = plt.subplots(figsize=(10, 6))
# Plot retained customers first (background), then churners (foreground)
retained = df[df['Churn'] == 0]
churned = df[df['Churn'] == 1]
ax.scatter(retained['tenure'], retained['MonthlyCharges'],
alpha=0.3, c='#2ecc71', label='No Churn', s=15, edgecolors='none')
ax.scatter(churned['tenure'], churned['MonthlyCharges'],
alpha=0.5, c='#e74c3c', label='Churn', s=15, edgecolors='none')
ax.set_title('Tenure vs Monthly Charges by Churn Status', fontsize=14, fontweight='bold')
ax.set_xlabel('Tenure (months)')
ax.set_ylabel('Monthly Charges ($)')
ax.legend(fontsize=12)
plt.tight_layout()
plt.savefig('../outputs/figures/tenure_vs_charges_scatter.png', dpi=150, bbox_inches='tight')
plt.show()
Key Findings Summary
The EDA findings are ranked by importance to churn prediction. These directly informed feature engineering decisions in Phase 3 and business recommendations at the end of the project:
| # | Finding | Evidence | Impact on Later Phases |
|---|---|---|---|
| 1 | Contract type is the strongest predictor | Month-to-month: 42.7% churn, One year: 11.3%, Two year: 2.8% | Became the #2 SHAP feature (Contract_Two year); drove the top business recommendation |
| 2 | Churn is front-loaded by tenure | Most churners leave within first 12 months; 60+ month customers rarely churn | Motivated TenureGroup engineered feature; tenure became #1 SHAP feature |
| 3 | Higher monthly charges correlate with churn | Churners skew $70–$100/month; retained cluster at ~$20 | MonthlyCharges kept as numeric feature; correlation = 0.19 with churn |
| 4 | Fiber optic customers churn more | Higher churn despite paying more — suggests service quality issue | Became #3 SHAP feature (InternetService_Fiber optic); drove business recommendation |
| 5 | Lack of support add-ons increases churn | No OnlineSecurity / No TechSupport have significantly higher churn rates | Motivated ServiceCount and HasInternet engineered features |
| 6 | Electronic check has higher churn | Noticeably higher churn rate than mailed check, bank transfer, or credit card | Became #8 SHAP feature; drove "migrate payment methods" recommendation |
| 7 | Gender and phone service have no signal | Virtually identical churn rates across categories | Kept in model (let the model confirm) but expected near-zero SHAP importance — confirmed |
Correlation Highlights
| Feature Pair | Correlation | Interpretation |
|---|---|---|
tenure vs. Churn |
−0.35 | Strongest single numeric predictor — longer tenure = less churn |
TotalCharges vs. tenure |
0.83 | High positive — expected, as charges accumulate over time |
MonthlyCharges vs. Churn |
0.19 | Moderate positive — higher monthly bills correlate with more churn |
TotalCharges vs. Churn |
−0.20 | Negative — loyal customers accumulate more total charges |
SeniorCitizen vs. Churn |
0.15 | Slight positive — senior citizens churn somewhat more |
Charts Generated
| File | Description | Reused In |
|---|---|---|
churn_distribution.png |
Overall churn bar chart: 5,163 No (73.4%) / 1,869 Yes (26.6%) | Portfolio page |
churn_by_contract.png |
Churn rate by contract type: 42.7% / 11.3% / 2.8% | Streamlit app (Page 3), portfolio page |
tenure_by_churn.png |
Overlapping histograms of tenure by churn status | Streamlit app (Page 3), portfolio page |
monthly_charges_by_churn.png |
Overlapping histograms of monthly charges by churn | Portfolio page |
churn_by_categories.png |
4×4 grid of churn rate across all 16 categorical features | Portfolio page |
correlation_heatmap.png |
Heatmap of numeric feature correlations including Churn | Portfolio page |
tenure_vs_charges_scatter.png |
Scatter: tenure vs. monthly charges colored by churn | Portfolio page |
EDA → Feature Engineering Connection
The EDA findings directly shaped the five engineered features created in Phase 3. The front-loaded tenure
pattern motivated TenureGroup (bucketing tenure into 0–12, 13–24, 25–48,
49+ month bins). The support add-on signal motivated ServiceCount (counting how many optional
services a customer has — more services = more switching cost = stickier customer). The
internet/phone service patterns motivated HasInternet and HasPhone as binary
consolidation features. And the observation that churners cluster in the low-tenure, high-charge region
motivated AvgMonthlyCharge (TotalCharges / tenure) to capture the cost-to-loyalty ratio.
Two additional EDA-only analyses were performed but intentionally excluded from the model:
tenure_group (binned churn rates for visualization only — redundant with raw
tenure for modeling) and a TotalCharges / (tenure + 1) validation check
(confirming data consistency — nearly identical to MonthlyCharges, would add multicollinearity if
used as a feature).
Phase 3 — Feature Engineering & Preprocessing
Overview
Phase 3 transforms the cleaned dataset into a model-ready format through three steps: engineering 5 new
domain-informed features, splitting the data into train/test sets with stratification, and building a
scikit-learn preprocessing pipeline that scales numeric features and one-hot encodes categorical features.
A critical design decision was placing all feature engineering logic in a shared Python module
(feature_helpers.py) used by both the training notebooks and the deployed Streamlit app —
guaranteeing identical transformations at train time and serve time. All work runs in
notebooks/03_feature_engineering.ipynb.
Key Code: Load Cleaned Data & Apply Feature Engineering
The notebook loads the cleaned CSV from Phase 1, imports the shared engineer_features() function,
and applies it to create the 5 new columns. The output is verified by inspecting the first 10 rows of the
new features alongside the original columns they were derived from:
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.compose import ColumnTransformer
import joblib
import os
# Load cleaned dataset from Phase 1
df = pd.read_csv('../data/telco_churn_cleaned.csv')
# Import and apply shared feature engineering function
from feature_helpers import engineer_features
df = engineer_features(df)
print(f"Shape after feature engineering: {df.shape}")
print(f"\nNew columns: ServiceCount, HasInternet, HasPhone, AvgMonthlyCharge, TenureGroup")
# Output:
# Shape after feature engineering: (7032, 25)
# New columns: ServiceCount, HasInternet, HasPhone, AvgMonthlyCharge, TenureGroup
# Verify new features
print(f"\nSample of new features:")
df[['tenure', 'MonthlyCharges', 'TotalCharges', 'ServiceCount',
'HasInternet', 'HasPhone', 'AvgMonthlyCharge', 'TenureGroup']].head(10)
# Output:
# tenure MonthlyCharges TotalCharges ServiceCount HasInternet HasPhone AvgMonthlyCharge TenureGroup
# 0 1 29.85 29.85 1 1 0 29.850000 0
# 1 34 56.95 1889.50 2 1 1 55.573529 2
# 2 2 53.85 108.15 2 1 1 54.075000 0
# 3 45 42.30 1840.75 3 1 0 40.905556 2
# 4 2 70.70 151.65 0 1 1 75.825000 0
# 5 8 99.65 820.50 3 1 1 102.562500 0
# 6 22 89.10 1949.40 2 1 1 88.609091 1
# 7 10 29.75 301.90 1 1 0 30.190000 0
# 8 28 104.80 3046.05 4 1 1 108.787500 2
# 9 62 56.15 3487.95 2 1 1 56.257258 3
The Shared Feature Engineering Function
This is the single source of truth for all feature transformations. The function lives in
notebooks/feature_helpers.py and is copied identically to app/feature_helpers.py
for the Streamlit deployment. Any change to feature logic must be made in this one function to guarantee
train/serve consistency:
"""
Shared feature engineering function.
Used by notebooks (training) and Streamlit app (prediction)
to ensure identical transformations.
"""
def engineer_features(df):
"""
Create engineered features from the cleaned telco churn dataset.
Parameters:
df: pandas DataFrame with cleaned columns
Returns:
df: DataFrame with new features added
"""
df = df.copy()
# 1. Service count: how many optional services does the customer have?
service_cols = ['OnlineSecurity', 'OnlineBackup', 'DeviceProtection',
'TechSupport', 'StreamingTV', 'StreamingMovies']
df['ServiceCount'] = df[service_cols].apply(
lambda row: (row == 'Yes').sum(), axis=1
)
# 2. Has internet service (binary)
df['HasInternet'] = (df['InternetService'] != 'No').astype(int)
# 3. Has phone service (binary)
df['HasPhone'] = (df['PhoneService'] == 'Yes').astype(int)
# 4. Average monthly charge (TotalCharges / tenure), handle tenure=0
df['AvgMonthlyCharge'] = df.apply(
lambda row: row['TotalCharges'] / row['tenure'] if row['tenure'] > 0
else row['MonthlyCharges'], axis=1
)
# 5. Tenure group (numeric encoding for modeling)
def tenure_bucket(t):
if t <= 12: return 0
elif t <= 24: return 1
elif t <= 48: return 2
else: return 3
df['TenureGroup'] = df['tenure'].apply(tenure_bucket)
return df
5 Engineered Features
| Feature | Logic | Type | EDA Insight That Motivated It |
|---|---|---|---|
ServiceCount |
Count of "Yes" across 6 optional service columns (OnlineSecurity, OnlineBackup, DeviceProtection, TechSupport, StreamingTV, StreamingMovies) | int (0–6) | EDA showed lack of add-ons increases churn — more services = more switching cost = stickier customer |
HasInternet |
1 if InternetService ≠ "No", else 0 | binary | Internet customers (especially fiber optic) churn at higher rates — consolidates the signal |
HasPhone |
1 if PhoneService = "Yes", else 0 | binary | Parallel to HasInternet — consolidates base service indicator |
AvgMonthlyCharge |
TotalCharges / tenure (uses MonthlyCharges if tenure = 0) | float | Churners cluster in the low-tenure, high-charge scatter region — captures the cost-to-loyalty ratio |
TenureGroup |
0–12 → 0, 13–24 → 1, 25–48 → 2, 49+ → 3 | int (0–3) | Churn is heavily front-loaded in the first 12 months — bucketing captures this non-linear relationship |
Features Intentionally Excluded from the Model
Two additional features were computed during EDA but intentionally excluded from the modeling pipeline:
| Feature | Used In | Why Excluded |
|---|---|---|
tenure_group (string bins) |
EDA only (binned churn rate chart) | Redundant with raw tenure for modeling; the numeric TenureGroup serves the same role more efficiently |
TotalCharges / (tenure + 1) |
EDA only (data quality validation) | Nearly identical to MonthlyCharges — would add multicollinearity without new signal |
Key Code: Define Feature Lists & Separate Target
After feature engineering, the 24 features (excluding the Churn target) are explicitly split
into numeric and categorical lists. This separation drives the preprocessing pipeline — numeric
features get StandardScaler, categorical features get OneHotEncoder:
# Define feature lists
numeric_features = ['SeniorCitizen', 'tenure', 'MonthlyCharges', 'TotalCharges',
'ServiceCount', 'HasInternet', 'HasPhone', 'AvgMonthlyCharge',
'TenureGroup']
categorical_features = ['gender', 'Partner', 'Dependents', 'PhoneService',
'MultipleLines', 'InternetService', 'OnlineSecurity',
'OnlineBackup', 'DeviceProtection', 'TechSupport',
'StreamingTV', 'StreamingMovies', 'Contract',
'PaperlessBilling', 'PaymentMethod']
print(f"Numeric features ({len(numeric_features)}): {numeric_features}")
print(f"\nCategorical features ({len(categorical_features)}): {categorical_features}")
print(f"\nTotal features: {len(numeric_features) + len(categorical_features)}")
# Output:
# Numeric features (9): ['SeniorCitizen', 'tenure', 'MonthlyCharges', ...]
# Categorical features (15): ['gender', 'Partner', 'Dependents', ...]
# Total features: 24
# Separate features and target
X = df[numeric_features + categorical_features]
y = df['Churn']
print(f"\nX shape: {X.shape}")
print(f"y shape: {y.shape}")
print(f"y distribution:\n{y.value_counts(normalize=True).round(4)}")
# Output:
# X shape: (7032, 24)
# y shape: (7032,)
# y distribution:
# Churn
# 0 0.7342
# 1 0.2658
Key Code: Train/Test Split with Stratification
The data is split before any preprocessing to prevent data leakage. The
stratify=y parameter ensures both train and test sets preserve the original 73.4/26.6 class
distribution, which is critical for reliable evaluation on imbalanced data:
# Train-test split (80/20) with stratification to preserve churn ratio
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y
)
print(f"X_train: {X_train.shape}")
print(f"X_test: {X_test.shape}")
print(f"\nTrain churn rate: {y_train.mean():.4f}")
print(f"Test churn rate: {y_test.mean():.4f}")
# Output:
# X_train: (5625, 24)
# X_test: (1407, 24)
#
# Train churn rate: 0.2658
# Test churn rate: 0.2658 ← stratification confirmed
Key Code: Build Preprocessing Pipeline
The ColumnTransformer applies StandardScaler to the 9 numeric features and
OneHotEncoder to the 15 categorical features. Three key design decisions:
drop='first' avoids the dummy variable trap for Logistic Regression and SVM
(e.g., for a 3-category column like InternetService, only 2 binary columns are created, with the dropped
category becoming the implicit baseline).
handle_unknown='ignore' makes the pipeline robust for the Streamlit app —
if a user enters a category combination not seen during training, the encoder outputs zeros instead of
throwing an error.
Fit on training data only, transform both — the scaler learns means/stds and the encoder learns categories exclusively from the training set. The test set is transformed using those learned parameters, preventing any information leakage from test to train.
# Build preprocessing pipeline
# StandardScaler for numeric features, OneHotEncoder for categorical features
preprocessor = ColumnTransformer(
transformers=[
('num', StandardScaler(), numeric_features),
('cat', OneHotEncoder(drop='first', sparse_output=False,
handle_unknown='ignore'), categorical_features)
]
)
# Fit on training data only, transform both
X_train_processed = preprocessor.fit_transform(X_train)
X_test_processed = preprocessor.transform(X_test)
# Extract feature names for later use (SHAP needs these)
cat_feature_names = preprocessor.named_transformers_['cat'] \
.get_feature_names_out(categorical_features).tolist()
all_feature_names = numeric_features + cat_feature_names
print(f"X_train_processed shape: {X_train_processed.shape}")
print(f"X_test_processed shape: {X_test_processed.shape}")
print(f"\nTotal feature columns after encoding: {len(all_feature_names)}")
print(f"\nFirst 10 feature names: {all_feature_names[:10]}")
# Output:
# X_train_processed shape: (5625, 35)
# X_test_processed shape: (1407, 35)
#
# Total feature columns after encoding: 35
#
# First 10 feature names: ['SeniorCitizen', 'tenure', 'MonthlyCharges',
# 'TotalCharges', 'ServiceCount', 'HasInternet', 'HasPhone',
# 'AvgMonthlyCharge', 'TenureGroup', 'gender_Male']
Key Code: Save All Artifacts
Four serialized artifacts are saved for consumption by the model training notebook (Phase 4), the tuning/SHAP notebook (Phase 5–6), and the Streamlit app:
# Save the preprocessor and feature names for later use
os.makedirs('../models', exist_ok=True)
joblib.dump(preprocessor, '../models/preprocessor.pkl')
joblib.dump(all_feature_names, '../models/feature_names.pkl')
# Also save the split data for the next notebook
joblib.dump((X_train, X_test, y_train, y_test), '../models/train_test_split.pkl')
joblib.dump((X_train_processed, X_test_processed), '../models/processed_data.pkl')
print("Saved:")
print(" - models/preprocessor.pkl")
print(" - models/feature_names.pkl")
print(" - models/train_test_split.pkl")
print(" - models/processed_data.pkl")
Feature Summary: Before & After Encoding
| Stage | Numeric | Categorical | Total Columns |
|---|---|---|---|
| After feature engineering (before encoding) | 9 | 15 | 24 |
After OneHotEncoding (drop='first') |
9 | 26 (from 15 original) | 35 |
Complete Feature Lists
9 Numeric Features (StandardScaler)
| Feature | Source | Description |
|---|---|---|
SeniorCitizen | Original | 0/1 binary — already numeric in raw data |
tenure | Original | Months with the company (0–72) |
MonthlyCharges | Original | Monthly bill amount ($18.25–$118.75) |
TotalCharges | Original | Cumulative charges over customer lifetime |
ServiceCount | Engineered | Count of optional services (0–6) |
HasInternet | Engineered | 1 if customer has any internet service |
HasPhone | Engineered | 1 if customer has phone service |
AvgMonthlyCharge | Engineered | TotalCharges / tenure (cost-to-loyalty ratio) |
TenureGroup | Engineered | Bucketed tenure: 0–12→0, 13–24→1, 25–48→2, 49+→3 |
15 Categorical Features (OneHotEncoder, drop='first')
| Feature | Values | Columns After Encoding |
|---|---|---|
gender | Male, Female | 1 (gender_Male) |
Partner | Yes, No | 1 |
Dependents | Yes, No | 1 |
PhoneService | Yes, No | 1 |
MultipleLines | Yes, No, No phone service | 2 |
InternetService | DSL, Fiber optic, No | 2 |
OnlineSecurity | Yes, No, No internet service | 2 |
OnlineBackup | Yes, No, No internet service | 2 |
DeviceProtection | Yes, No, No internet service | 2 |
TechSupport | Yes, No, No internet service | 2 |
StreamingTV | Yes, No, No internet service | 2 |
StreamingMovies | Yes, No, No internet service | 2 |
Contract | Month-to-month, One year, Two year | 2 |
PaperlessBilling | Yes, No | 1 |
PaymentMethod | Electronic check, Mailed check, Bank transfer, Credit card | 3 |
Total after encoding: 9 numeric + 26 one-hot = 35 columns entering the model.
Train/Test Split Summary
| Set | Rows | Features (before encoding) | Features (after encoding) | Churn Rate |
|---|---|---|---|---|
| Training | 5,625 | 24 | 35 | 0.2658 |
| Test | 1,407 | 24 | 35 | 0.2658 |
The identical churn rates (0.2658) confirm that stratify=y worked correctly — both sets
preserve the original class distribution.
Saved Artifacts
| File | Contents | Consumed By |
|---|---|---|
models/preprocessor.pkl |
Fitted ColumnTransformer (scaler + encoder) |
Phase 4 notebook, Phase 5–6 notebook, Streamlit app |
models/feature_names.pkl |
List of 35 post-encoding feature names | SHAP explainability (Phase 6) |
models/train_test_split.pkl |
(X_train, X_test, y_train, y_test) before encoding | Phase 4 notebook |
models/processed_data.pkl |
(X_train_processed, X_test_processed) after encoding | Phase 4 notebook, Phase 5–6 notebook |
Class Imbalance Strategy
The 73.4/26.6 class split is moderate — not extreme enough for SMOTE (synthetic oversampling) but significant enough to require explicit handling. The chosen approach:
| Strategy | Applied To | How It Works |
|---|---|---|
class_weight='balanced' |
Logistic Regression, Random Forest, SVM | Adjusts the loss function to penalize misclassification of the minority class (churners) more heavily, proportional to class frequency |
scale_pos_weight |
XGBoost, LightGBM | Set to ratio of negatives to positives (≈ 2.76) — equivalent effect to class_weight='balanced' for gradient boosting models |
| Threshold tuning | Final evaluation (Phase 6) | Analyzed precision-recall tradeoff across thresholds to understand business impact of different cutoff points |
SHAP Interpretation Note
With drop='first', SHAP values for one-hot encoded categories are interpreted
relative to the dropped baseline category. For example, the SHAP value for
InternetService_Fiber optic represents the effect of fiber optic compared to the dropped
baseline (which is DSL). Similarly, Contract_Two year shows the effect compared to
the dropped month-to-month baseline. This is technically correct and actually makes business interpretation
intuitive — the most common/default category becomes the implicit reference point.
Phase 4 — Model Training & Comparison
Overview
Phase 4 trains five classification algorithms head-to-head on the preprocessed training set, evaluates
each with 5-fold stratified cross-validation, then scores them on the held-out test set across multiple
metrics. The goal is not just to find the best model, but to compare algorithmic families — linear
(Logistic Regression), ensemble (Random Forest), gradient boosting (XGBoost, LightGBM), and geometric
(SVM) — and document why the winner was selected based on business-relevant criteria. All
work runs in notebooks/04_model_training.ipynb.
Why These 5 Models
| # | Model | Library | Family | Why Include It |
|---|---|---|---|---|
| 1 | Logistic Regression | sklearn | Linear | Baseline — simple, interpretable, fast. If this does well, complex models need to beat it to justify their complexity. |
| 2 | Random Forest | sklearn | Ensemble | Ensemble of decision trees. Handles non-linear relationships, resistant to overfitting. Industry workhorse. |
| 3 | XGBoost | xgboost | Gradient Boosting | Consistently wins Kaggle competitions for tabular data. Level-wise tree growth strategy. |
| 4 | LightGBM | lightgbm | Gradient Boosting | Microsoft’s gradient boosting framework. Leaf-wise tree growth — often faster than XGBoost with competitive accuracy. |
| 5 | SVM | sklearn | Geometric | Different mathematical approach (finds optimal separating hyperplane). Shows breadth across algorithmic families. |
Key Code: Load Artifacts & Define All 5 Models
The notebook loads the preprocessed data and fitted artifacts from Phase 3, then defines all five models
with explicit class imbalance handling. Logistic Regression, Random Forest, and SVM use
class_weight='balanced', while XGBoost and LightGBM use scale_pos_weight
(the ratio of non-churners to churners ≈ 2.76):
import joblib
import pandas as pd
import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from xgboost import XGBClassifier
from lightgbm import LGBMClassifier
from sklearn.svm import SVC
from sklearn.model_selection import cross_val_score, StratifiedKFold
from sklearn.metrics import (accuracy_score, precision_score, recall_score,
f1_score, roc_auc_score, classification_report,
confusion_matrix, RocCurveDisplay)
import matplotlib.pyplot as plt
import seaborn as sns
# Load preprocessed data and artifacts from Phase 3
X_train, X_test, y_train, y_test = joblib.load('../models/train_test_split.pkl')
X_train_processed, X_test_processed = joblib.load('../models/processed_data.pkl')
all_feature_names = joblib.load('../models/feature_names.pkl')
# Calculate class weight ratio for gradient boosting models
pos_weight = y_train.value_counts()[0] / y_train.value_counts()[1]
print(f"scale_pos_weight: {pos_weight:.2f}") # Output: scale_pos_weight: 2.76
# Define all 5 models with class imbalance handling
models = {
'Logistic Regression': LogisticRegression(
class_weight='balanced', max_iter=1000, random_state=42
),
'Random Forest': RandomForestClassifier(
class_weight='balanced', n_estimators=100, random_state=42
),
'XGBoost': XGBClassifier(
scale_pos_weight=pos_weight, n_estimators=100,
random_state=42, eval_metric='logloss'
),
'LightGBM': LGBMClassifier(
scale_pos_weight=pos_weight, n_estimators=100,
random_state=42, verbose=-1
),
'SVM': SVC(
class_weight='balanced', probability=True, random_state=42
),
}
Key Code: 5-Fold Stratified Cross-Validation
Each model is evaluated with 5-fold stratified cross-validation on the training set to assess generalization performance and stability. The stratified folds ensure each fold preserves the 73.4/26.6 class ratio. Cross-validation scores are collected for AUC before any test set evaluation:
# 5-fold stratified cross-validation
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
cv_results = {}
for name, model in models.items():
cv_auc = cross_val_score(model, X_train_processed, y_train,
cv=cv, scoring='roc_auc')
cv_results[name] = {
'cv_auc_mean': cv_auc.mean(),
'cv_auc_std': cv_auc.std(),
}
print(f"{name}: CV AUC = {cv_auc.mean():.4f} (±{cv_auc.std():.4f})")
# Output:
# Logistic Regression: CV AUC = 0.8453 (±0.0187)
# Random Forest: CV AUC = 0.8282 (±0.0150)
# XGBoost: CV AUC = 0.8212 (±0.0142)
# LightGBM: CV AUC = 0.8346 (±0.0144)
# SVM: CV AUC = 0.8286 (±0.0190)
Key Code: Train on Full Training Set & Evaluate on Test Set
After cross-validation, each model is trained on the full training set and evaluated on the held-out test set. Six metrics are collected per model: accuracy, precision, recall, F1, and AUC on the test set, plus the cross-validation AUC mean and standard deviation:
# Train each model and evaluate on test set
results = []
fitted_models = {}
for name, model in models.items():
# Train on full training set
model.fit(X_train_processed, y_train)
fitted_models[name] = model
# Predict on test set
y_pred = model.predict(X_test_processed)
y_prob = model.predict_proba(X_test_processed)[:, 1]
# Collect all metrics
results.append({
'Model': name,
'CV AUC (mean)': cv_results[name]['cv_auc_mean'],
'CV AUC (std)': cv_results[name]['cv_auc_std'],
'Accuracy': accuracy_score(y_test, y_pred),
'Precision': precision_score(y_test, y_pred),
'Recall': recall_score(y_test, y_pred),
'F1': f1_score(y_test, y_pred),
'AUC': roc_auc_score(y_test, y_prob),
})
# Create comparison DataFrame sorted by Test AUC
results_df = pd.DataFrame(results).sort_values('AUC', ascending=False).round(4)
print(results_df.to_string(index=False))
Results: Head-to-Head Comparison
The complete results table, sorted by test AUC (the primary overall quality metric):
| Model | CV AUC (mean) | CV AUC (std) | Accuracy | Precision | Recall | F1 | AUC |
|---|---|---|---|---|---|---|---|
| Logistic Regression | 0.8453 | 0.0187 | 0.7242 | 0.4884 | 0.7888 | 0.6033 | 0.8344 |
| LightGBM | 0.8346 | 0.0144 | 0.7548 | 0.5263 | 0.7754 | 0.6270 | 0.8292 |
| Random Forest | 0.8282 | 0.0150 | 0.7861 | 0.6229 | 0.4947 | 0.5514 | 0.8170 |
| SVM | 0.8286 | 0.0190 | 0.7257 | 0.4900 | 0.7888 | 0.6045 | 0.8151 |
| XGBoost | 0.8212 | 0.0142 | 0.7456 | 0.5164 | 0.6738 | 0.5847 | 0.8095 |
Bold values indicate the best score in each column. Full CSV: model_comparison.csv
Key Code: Model Comparison Bar Chart
# Model comparison bar chart – AUC, Recall, F1 for all 5 models
metrics_to_plot = ['AUC', 'Recall', 'F1']
fig, axes = plt.subplots(1, 3, figsize=(18, 5))
for i, metric in enumerate(metrics_to_plot):
values = results_df.sort_values(metric, ascending=True)
axes[i].barh(values['Model'], values[metric], color='#3498db',
edgecolor='black', linewidth=0.5)
axes[i].set_title(metric, fontsize=14, fontweight='bold')
axes[i].set_xlim(0, 1)
for j, (val, name) in enumerate(zip(values[metric], values['Model'])):
axes[i].text(val + 0.01, j, f'{val:.4f}', va='center', fontsize=10)
plt.suptitle('Model Comparison – Key Metrics', fontsize=16, fontweight='bold')
plt.tight_layout()
plt.savefig('../outputs/figures/model_comparison.png', dpi=150, bbox_inches='tight')
plt.show()
Key Code: ROC Curves Overlay
# ROC curves – all 5 models on the same plot
fig, ax = plt.subplots(figsize=(8, 6))
for name, model in fitted_models.items():
y_prob = model.predict_proba(X_test_processed)[:, 1]
RocCurveDisplay.from_predictions(y_test, y_prob, name=name, ax=ax)
# Add random baseline
ax.plot([0, 1], [0, 1], 'k--', label='Random (AUC = 0.50)', alpha=0.5)
ax.set_title('ROC Curves – All 5 Models', fontsize=14, fontweight='bold')
ax.legend(loc='lower right')
plt.tight_layout()
plt.savefig('../outputs/figures/roc_curves.png', dpi=150, bbox_inches='tight')
plt.show()
Key Code: Confusion Matrices
# Confusion matrices – all 5 models side by side
fig, axes = plt.subplots(1, 5, figsize=(25, 4))
for i, (name, model) in enumerate(fitted_models.items()):
y_pred = model.predict(X_test_processed)
cm = confusion_matrix(y_test, y_pred)
sns.heatmap(cm, annot=True, fmt='d', cmap='Blues', ax=axes[i],
xticklabels=['No Churn', 'Churn'],
yticklabels=['No Churn', 'Churn'])
axes[i].set_title(name, fontsize=10, fontweight='bold')
axes[i].set_ylabel('Actual' if i == 0 else '')
axes[i].set_xlabel('Predicted')
plt.suptitle('Confusion Matrices – All 5 Models', fontsize=14, fontweight='bold')
plt.tight_layout()
plt.savefig('../outputs/figures/confusion_matrix.png', dpi=150, bbox_inches='tight')
plt.show()
Key Code: Save Artifacts
# Save the best model and all models
best_model = fitted_models['Logistic Regression']
joblib.dump(best_model, '../models/best_model.pkl')
joblib.dump(fitted_models, '../models/all_models.pkl')
# Save comparison table for Streamlit app
results_df.to_csv('../outputs/metrics/model_comparison.csv', index=False)
print("Saved:")
print(" - models/best_model.pkl (Logistic Regression)")
print(" - models/all_models.pkl (all 5 fitted models)")
print(" - outputs/metrics/model_comparison.csv")
Best Model Selection: Logistic Regression
The model selection criteria, ranked by priority for a churn prediction use case:
| Priority | Criterion | Why It Matters for Churn | Winner |
|---|---|---|---|
| 1 | Recall | A missed churner = lost customer (expensive). A false alarm = unnecessary retention offer (cheap). Optimize for catching churners. | Logistic Regression & SVM (tied: 0.7888) |
| 2 | ROC-AUC | Overall discriminative ability across all thresholds — how well can the model separate churners from non-churners? | Logistic Regression (0.8344) |
| 3 | F1 Score | Balance of precision and recall — secondary to recall but useful as a tiebreaker. | LightGBM (0.6270) |
| 4 | CV Stability | Low cross-validation variance means the model generalizes well and isn’t overfitting to a lucky split. | Logistic Regression (0.8453 ± 0.0187) |
Winner: Logistic Regression. It achieved the highest test AUC (0.8344), the highest recall tied with SVM (0.7888 — catching 79% of all churners), and the highest cross-validated AUC (0.8453) with low variance (±0.0187). Since it tied SVM on recall but beat it on AUC, and since logistic regression is far more interpretable and faster to deploy, it was the clear choice.
The Surprise: Simplest Model Won
The project plan predicted that XGBoost or LightGBM would win. Instead, the simplest model outperformed the ensemble and gradient boosting methods on the two priority metrics. This is an important finding: model complexity doesn’t always win. On this dataset with properly engineered features, the relationships between features and churn are approximately linear — a well-configured logistic regression on good features is highly effective. This result also makes the model much easier to explain to stakeholders and more efficient to deploy.
Why Not Random Forest?
Random Forest had the highest accuracy (0.7861) and precision (0.6229) but the lowest recall (0.4947) — it only caught 49% of actual churners, missing more than half. In a churn context, this model would let 51% of at-risk customers walk out the door unidentified. High precision (fewer false alarms) is meaningless if you’re missing the majority of churners.
Metric Definitions Reference
| Metric | What It Measures | For Churn, Higher Is Better? |
|---|---|---|
| Accuracy | Overall correct predictions | Yes, but misleading with imbalanced classes (73.4% baseline) |
| Precision | Of predicted churners, how many actually churned | Yes — fewer false alarms |
| Recall | Of actual churners, how many did we catch | YES — priority metric |
| F1 | Harmonic mean of precision and recall | Yes — balanced tradeoff |
| ROC-AUC | Discriminative ability across all thresholds | Yes — overall model quality |
Charts Generated
| File | Description | Reused In |
|---|---|---|
model_comparison.png |
Bar chart comparing AUC, Recall, F1 across all 5 models | Streamlit app (Page 2), portfolio page |
roc_curves.png |
ROC curves overlay for all 5 models + random baseline | Streamlit app (Page 2), portfolio page |
confusion_matrix.png |
5 confusion matrices side by side | Streamlit app (Page 2), portfolio page |
Saved Artifacts
| File | Contents | Consumed By |
|---|---|---|
models/best_model.pkl |
Fitted Logistic Regression (class_weight='balanced') |
Phase 5–6 notebook, Streamlit app |
models/all_models.pkl |
Dictionary of all 5 fitted models | Phase 5 notebook (comparison reference) |
outputs/metrics/model_comparison.csv |
Full results table (all models, all metrics) | Streamlit app (Page 2) |
Phase 5 — Hyperparameter Tuning
Overview
Phase 5 attempts to improve the winning Logistic Regression model from Phase 4 by searching across
hyperparameter combinations using RandomizedSearchCV. The tuning runs 50 random parameter
combinations with 5-fold stratified cross-validation, optimizing for AUC. The result was a tuned model
that improved accuracy and precision but sacrificed 22 percentage points of recall —
leading to a principled decision to reject the tuned model and keep the default. This
phase runs in the first half of notebooks/05_tuning_evaluation.ipynb.
Key Code: Tuning Setup
The parameter search space covers the regularization strength (C), penalty type
(l1 vs l2), solver algorithm, and critically whether to use
class_weight='balanced' or None. Including class_weight in the
search space allows the tuner to explore whether removing class balancing improves overall AUC —
which it did, but at a cost:
from sklearn.model_selection import RandomizedSearchCV, StratifiedKFold
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import (accuracy_score, precision_score, recall_score,
f1_score, roc_auc_score, classification_report)
# Define hyperparameter search space
param_dist = {
'C': [0.001, 0.01, 0.1, 0.5, 1, 5, 10, 50, 100],
'penalty': ['l1', 'l2'],
'solver': ['liblinear', 'saga'],
'class_weight': ['balanced', None],
'max_iter': [1000]
}
# RandomizedSearchCV: 50 iterations, 5-fold stratified CV, optimize AUC
search = RandomizedSearchCV(
LogisticRegression(random_state=42),
param_distributions=param_dist,
n_iter=50,
scoring='roc_auc',
cv=StratifiedKFold(n_splits=5, shuffle=True, random_state=42),
random_state=42,
n_jobs=-1,
verbose=1
)
search.fit(X_train_processed, y_train)
print(f"Best CV AUC: {search.best_score_:.4f}")
print(f"Best params: {search.best_params_}")
# Output:
# Best CV AUC: 0.8459
# Best params: {'solver': 'liblinear', 'penalty': 'l2',
# 'max_iter': 1000, 'class_weight': None, 'C': 1}
Tuning Configuration
| Setting | Value | Why |
|---|---|---|
| Method | RandomizedSearchCV |
More efficient than GridSearch for multi-parameter spaces — samples random combinations rather than exhaustive grid |
| Iterations | 50 | 50 random parameter combinations evaluated — sufficient coverage for a 5-parameter space |
| CV Folds | 5 (stratified) | Same fold strategy as Phase 4 — preserves class ratio in each fold |
| Scoring | roc_auc |
Optimizes overall discriminative ability across all thresholds |
| Random State | 42 | Reproducibility — same random combinations every run |
Parameter Search Space
| Parameter | Values Searched | What It Controls |
|---|---|---|
C |
0.001, 0.01, 0.1, 0.5, 1, 5, 10, 50, 100 | Inverse regularization strength — smaller C = stronger regularization (simpler model), larger C = less regularization (more complex model) |
penalty |
l1, l2 | Regularization type — L1 (Lasso) can drive coefficients to exactly zero (feature selection), L2 (Ridge) shrinks coefficients toward zero |
solver |
liblinear, saga | Optimization algorithm — liblinear is efficient for small datasets, saga supports L1 and L2 on larger data |
class_weight |
balanced, None | Critical parameter — 'balanced' upweights the minority class; None treats both classes equally. This is where the recall tradeoff occurs. |
max_iter |
1000 | Fixed — ensures convergence for all solver/penalty combinations |
Best Tuned Parameters
| Parameter | Best Value |
|---|---|
C | 1 |
penalty | l2 |
solver | liblinear |
class_weight | None |
max_iter | 1000 |
| Best CV AUC | 0.8459 |
The tuner selected class_weight=None — removing class balancing. This is the key
finding: without balanced class weights, the model optimizes for the majority class (no churn), which
improves accuracy and precision but reduces recall.
Key Code: Evaluate Tuned Model on Test Set
# Get the best tuned model
tuned_model = search.best_estimator_
# Predict on test set with tuned model
y_pred_tuned = tuned_model.predict(X_test_processed)
y_prob_tuned = tuned_model.predict_proba(X_test_processed)[:, 1]
# Collect tuned metrics
tuned_metrics = {
'Accuracy': accuracy_score(y_test, y_pred_tuned),
'Precision': precision_score(y_test, y_pred_tuned),
'Recall': recall_score(y_test, y_pred_tuned),
'F1': f1_score(y_test, y_pred_tuned),
'AUC': roc_auc_score(y_test, y_prob_tuned),
}
for metric, value in tuned_metrics.items():
print(f"{metric}: {value:.4f}")
# Output:
# Accuracy: 0.7982
# Precision: 0.6347
# Recall: 0.5668
# F1: 0.5989
# AUC: 0.8346
Key Code: Default vs Tuned Side-by-Side Comparison
# Load the default model from Phase 4
default_model = joblib.load('../models/best_model.pkl')
# Predict with default model
y_pred_default = default_model.predict(X_test_processed)
y_prob_default = default_model.predict_proba(X_test_processed)[:, 1]
# Default metrics
default_metrics = {
'Accuracy': accuracy_score(y_test, y_pred_default),
'Precision': precision_score(y_test, y_pred_default),
'Recall': recall_score(y_test, y_pred_default),
'F1': f1_score(y_test, y_pred_default),
'AUC': roc_auc_score(y_test, y_prob_default),
}
# Side-by-side comparison
print(f"{'Metric':<12} {'Default':>10} {'Tuned':>10} {'Change':>10}")
print("-" * 44)
for metric in default_metrics:
d = default_metrics[metric]
t = tuned_metrics[metric]
change = t - d
print(f"{metric:<12} {d:>10.4f} {t:>10.4f} {change:>+10.4f}")
# Output:
# Metric Default Tuned Change
# --------------------------------------------
# Accuracy 0.7242 0.7982 +0.0740
# Precision 0.4884 0.6347 +0.1463
# Recall 0.7888 0.5668 -0.2220
# F1 0.6033 0.5989 -0.0044
# AUC 0.8344 0.8346 +0.0002
Results: Default vs Tuned Comparison
| Metric | Default (Balanced) | Tuned | Change | Better For Churn? |
|---|---|---|---|---|
| Accuracy | 0.7242 | 0.7982 | +0.0740 | Misleading — mostly from predicting more "no churn" correctly |
| Precision | 0.4884 | 0.6347 | +0.1463 | Yes — fewer false alarms, but at what cost? |
| Recall | 0.7888 | 0.5668 | −0.2220 | NO — 22 percentage points lost. Now misses 43% of churners. |
| F1 | 0.6033 | 0.5989 | −0.0044 | Essentially flat — precision gains offset by recall losses |
| AUC | 0.8344 | 0.8346 | +0.0002 | Negligible improvement — essentially identical |
Decision: Keep the Default Balanced Model
The tuned model gained accuracy (+7.4pp) and precision (+14.6pp) but lost 22 percentage points of recall (from 79% down to 57%). AUC was essentially unchanged (+0.0002). This tradeoff is unacceptable for a churn prediction use case:
| Scenario | Default Model | Tuned Model |
|---|---|---|
| Out of 374 actual churners in the test set… | Catches 295 (79%) | Catches 212 (57%) |
| Misses… | 79 churners | 162 churners |
| False alarms (loyal customers flagged)… | 309 | 122 |
The tuned model generates fewer false alarms (122 vs 309), but it doubles the number of missed churners (162 vs 79). In business terms: the cost of sending 187 extra retention offers to loyal customers (the false alarm difference) is far lower than the cost of losing 83 additional customers who churn undetected. A $10 retention offer sent to 187 non-churners costs $1,870. Losing 83 customers with an average lifetime value of even $500 costs $41,500. The math is clear.
Key Code: Final Model Confirmation & Classification Report
# Decision: KEEP the default balanced model
# The tuned model sacrifices too much recall for marginal AUC gain
final_model = default_model # LogisticRegression(class_weight='balanced')
# Save final model (overwrite with the confirmed default)
joblib.dump(final_model, '../models/best_model.pkl')
# Final classification report on test set
y_pred_final = final_model.predict(X_test_processed)
print(classification_report(y_test, y_pred_final, target_names=['No Churn', 'Churn']))
# Output:
# precision recall f1-score support
#
# No Churn 0.90 0.70 0.79 1033
# Churn 0.49 0.79 0.60 374
#
# accuracy 0.72 1407
# macro avg 0.70 0.74 0.70 1407
# weighted avg 0.79 0.72 0.74 1407
Final Deployed Model
| Property | Value |
|---|---|
| Model Type | LogisticRegression |
| Key Parameter | class_weight='balanced' |
| Other Parameters | max_iter=1000, random_state=42 |
| Test AUC | 0.8344 |
| Test Recall | 0.7888 (catches 79% of churners) |
| Test F1 | 0.6033 |
| Test Accuracy | 0.7242 |
| Serialized To | models/best_model.pkl |
Why This Matters: Principled Model Selection
This phase demonstrates something more valuable than finding the best hyperparameters — it demonstrates principled model selection based on business cost asymmetry. The tuned model "looks better" on accuracy (a metric most people intuitively understand), but a model that catches only 57% of churners is less useful than one that catches 79%, even if it generates more false positives. Knowing when not to tune — when the default configuration already makes the right tradeoff for the business problem — is a judgment call that separates thoughtful analysis from blind optimization.
Phase 6 — SHAP Explainability
Overview
Phase 6 is the crown jewel of the project — what separates it from a tutorial. SHAP (SHapley
Additive exPlanations) values are computed for every test set customer to answer two questions:
globally, which features drive churn predictions across the entire customer base? And
locally, for any individual customer, exactly which features pushed their risk score up
or down, and by how much? The result is a model that doesn’t just predict — it explains.
This phase runs in the second half of notebooks/05_tuning_evaluation.ipynb.
What Is SHAP?
SHAP is rooted in cooperative game theory (specifically Shapley values). For each prediction, SHAP assigns every feature an additive contribution showing how it pushed the prediction away from the baseline (the average prediction across all customers). Features with positive SHAP values push toward churn; features with negative SHAP values push away from churn. The values are additive — the base value plus all SHAP values equals the model’s log-odds output for that customer.
Key Code: Create SHAP Explainer & Compute Values
Since the final model is Logistic Regression, shap.LinearExplainer is used (the appropriate
explainer for linear models). SHAP values are computed for all 1,407 test set customers, producing a
matrix of shape (1407, 35) — one SHAP value per feature per customer:
import shap
import matplotlib.pyplot as plt
# Load the final model and processed test data
final_model = joblib.load('../models/best_model.pkl')
X_train_processed, X_test_processed = joblib.load('../models/processed_data.pkl')
all_feature_names = joblib.load('../models/feature_names.pkl')
# Create SHAP explainer (LinearExplainer for logistic regression)
explainer = shap.LinearExplainer(final_model, X_train_processed,
feature_names=all_feature_names)
# Compute SHAP values for all test set customers
shap_values = explainer.shap_values(X_test_processed)
print(f"SHAP values shape: {shap_values.shape}")
print(f"Base value (expected value): {explainer.expected_value:.4f}")
# Output:
# SHAP values shape: (1407, 35)
# Base value (expected value): -1.0175 (log-odds space)
Key Code: SHAP Summary Plot (Beeswarm)
The beeswarm plot is the most informative SHAP visualization. Each dot represents one customer’s SHAP value for one feature. The horizontal position shows the magnitude and direction of impact (right = pushes toward churn, left = pushes away from churn), and the color indicates whether the feature value itself was high (red) or low (blue). Features are ranked top-to-bottom by mean absolute SHAP value (global importance):
# SHAP Summary Plot (Beeswarm) – global feature importance + direction
plt.figure(figsize=(10, 8))
shap.summary_plot(shap_values, X_test_processed,
feature_names=all_feature_names,
max_display=15, show=False)
plt.tight_layout()
plt.savefig('../outputs/figures/shap_summary.png', dpi=150, bbox_inches='tight')
plt.show()
Key Code: SHAP Bar Plot (Global Importance)
The bar plot provides a simpler view of global importance — showing just the mean absolute SHAP value per feature without the directional detail of the beeswarm:
# SHAP Bar Plot – mean absolute SHAP values (simpler global importance)
plt.figure(figsize=(10, 8))
shap.summary_plot(shap_values, X_test_processed,
feature_names=all_feature_names,
plot_type='bar', max_display=15, show=False)
plt.tight_layout()
plt.savefig('../outputs/figures/shap_bar.png', dpi=150, bbox_inches='tight')
plt.show()
Global Feature Importance: Top 15
The top 15 features by mean absolute SHAP value, with business interpretation for each:
| Rank | Feature | Direction | Business Interpretation |
|---|---|---|---|
| 1 | tenure |
Low tenure → churn | The single strongest predictor. New customers are the highest risk — the first 12 months are critical. |
| 2 | Contract_Two year |
Having two-year contract → no churn | Strongest churn reducer. Long-term contracts lock in loyalty. (Baseline: month-to-month) |
| 3 | InternetService_Fiber optic |
Fiber optic → churn | Fiber customers churn more despite paying more — points to a service quality or expectations gap. |
| 4 | TotalCharges |
Higher total charges → no churn | Proxy for loyalty — customers who’ve paid more have been around longer and are stickier. |
| 5 | MonthlyCharges |
Higher monthly charges → churn | Higher monthly bills correlate with churn — customers may feel they’re not getting value. |
| 6 | Contract_One year |
Having one-year contract → no churn | Reduces churn, but less strongly than two-year. Any commitment beyond month-to-month helps. |
| 7 | TenureGroup |
Lower group → churn | Reinforces the tenure signal — the 0–12 month bucket is the danger zone. |
| 8 | PaymentMethod_Electronic check |
Electronic check → churn | Manual payment = less friction to leave. Automatic payments create passive retention. |
| 9 | OnlineSecurity_Yes |
Having security → no churn | Support add-ons increase switching cost and perceived value. |
| 10 | MultipleLines_Yes |
Multiple lines → slight churn | May correlate with higher bills; minor effect. |
| 11 | TechSupport_Yes |
Having tech support → no churn | Customers who feel supported stay longer. Bundling tech support is a direct retention lever. |
| 12 | StreamingTV_Yes |
Streaming TV → slight churn | Correlated with higher monthly charges; minor independent effect. |
| 13 | StreamingMovies_Yes |
Streaming movies → slight churn | Same pattern as StreamingTV — service usage correlated with higher bills. |
| 14 | PaperlessBilling_Yes |
Paperless billing → slight churn | Digital-first customers may be more tech-savvy and more willing to switch providers. |
| 15 | HasInternet |
Having internet → churn | Internet customers face more competition and have more reasons to switch than phone-only customers. |
Key Code: SHAP Waterfall Plot (Individual Customer Explanation)
The waterfall plot explains a single customer’s prediction feature by feature. Starting from the base value (average prediction), each bar shows how one feature pushed the prediction up (toward churn, red) or down (away from churn, blue). This is the visualization that appears in the Streamlit app’s Predict page for every customer scored in real time:
# Find the highest-risk customer in the test set
y_prob_final = final_model.predict_proba(X_test_processed)[:, 1]
high_risk_idx = y_prob_final.argmax()
print(f"Highest-risk customer index: {high_risk_idx}")
print(f"Churn probability: {y_prob_final[high_risk_idx]:.4f}")
print(f"Actual churn: {y_test.iloc[high_risk_idx]}")
# Build SHAP Explanation object for this customer
explanation = shap.Explanation(
values=shap_values[high_risk_idx],
base_values=explainer.expected_value,
data=X_test_processed[high_risk_idx],
feature_names=all_feature_names
)
# Generate waterfall plot
fig, ax = plt.subplots(figsize=(10, 6))
shap.waterfall_plot(explanation, max_display=10, show=False)
plt.tight_layout()
plt.savefig('../outputs/figures/shap_waterfall_example.png', dpi=150,
bbox_inches='tight')
plt.show()
Waterfall Decomposition: High-Risk Customer
The highest-risk customer in the test set was correctly predicted as a churner. The waterfall plot breaks down exactly why the model flagged them:
Features Pushing Toward Churn (Red)
| Feature | SHAP Value | Plain-Language Meaning |
|---|---|---|
tenure | +1.22 | Very low tenure — brand new customer, highest risk window |
InternetService_Fiber optic | +0.59 | Fiber optic customer — higher churn rate segment |
Contract_Two year = 0 | +0.36 | Not on a two-year contract — no long-term commitment |
TenureGroup | +0.30 | Falls in the 0–12 month danger zone bucket |
PaymentMethod_Electronic check | +0.20 | Manual payment method — less friction to leave |
MultipleLines_Yes | +0.19 | Multiple lines — correlated with higher bills |
SeniorCitizen | +0.18 | Senior citizen — slightly higher churn demographic |
StreamingMovies_Yes | +0.17 | Streaming movies — adds to monthly cost |
StreamingTV_Yes | +0.17 | Streaming TV — adds to monthly cost |
Features Pushing Away from Churn (Blue)
| Feature | SHAP Value | Plain-Language Meaning |
|---|---|---|
TotalCharges | −0.54 | Relatively low total charges partially offset risk |
MonthlyCharges | −0.44 | Monthly charges not at the extreme high end |
Plain-language summary: “This customer is high risk because they have very low tenure, use fiber optic internet, are not on a long-term contract, pay by electronic check, and are a senior citizen. Their relatively low total and monthly charges partially offset the risk, but not enough.”
Key Code: SHAP in the Streamlit App (Real-Time Per-Customer Explanations)
The Streamlit app generates SHAP waterfall plots on-the-fly for every customer scored. When a user submits a customer profile through the form, the app loads the model and preprocessor, transforms the input, predicts the churn probability, and then runs SHAP to explain that specific prediction:
# From app/app.py – real-time SHAP explanation in the Streamlit app
# After preprocessing and prediction...
churn_prob = model.predict_proba(input_processed)[0][1]
# Generate per-customer SHAP explanation
cat_names = preprocessor.named_transformers_['cat'] \
.get_feature_names_out(categorical_features).tolist()
all_names = numeric_features + cat_names
explainer = shap.LinearExplainer(model, input_processed,
feature_names=all_names)
shap_values = explainer.shap_values(input_processed)
explanation = shap.Explanation(
values=shap_values[0],
base_values=explainer.expected_value,
data=input_processed[0],
feature_names=all_names
)
# Display waterfall in Streamlit
fig, ax = plt.subplots(figsize=(8, 5))
shap.waterfall_plot(explanation, max_display=10, show=False)
plt.tight_layout()
st.pyplot(fig)
plt.close()
Business Translations of SHAP Findings
SHAP values translate model predictions into actionable business insights. Here are the key translations from the global importance ranking:
| SHAP Finding | Business Translation | Recommended Action |
|---|---|---|
| Tenure is the #1 driver; low tenure pushes strongly toward churn | New customers are the highest risk segment — most attrition happens in the first 12 months | Focus retention programs on the first-year experience: onboarding, check-ins, early loyalty incentives |
| Contract_Two year is the #1 churn reducer | Long-term contracts are the single most effective retention mechanism in the data | Incentivize annual/two-year contracts — even small discounts would likely pay for themselves through reduced churn |
| InternetService_Fiber optic pushes toward churn despite higher revenue | Fiber customers pay more but leave more — this is a service quality or expectations problem, not a pricing problem | Investigate fiber optic service quality, speed consistency, and support experience |
| OnlineSecurity and TechSupport reduce churn when present | Support add-ons create both perceived value and switching cost | Bundle OnlineSecurity and TechSupport at reduced cost or include by default for at-risk segments |
| Electronic check payment increases churn | Manual payment = less friction to cancel; automatic payments create passive retention | Migrate electronic check users to automatic payment methods (bank transfer, credit card) |
Connection to Teaching Background
SHAP values translate black-box model predictions into plain-language explanations that any business stakeholder can understand. Instead of just saying “this customer is high risk,” the model says “this customer is high risk because they’re on a month-to-month contract, have been here only 3 months, and don’t have tech support.” This ability to make complex analytical outputs accessible to non-technical audiences directly ties to a background in mathematics education and translating abstract concepts for diverse learners.
Charts Generated
| File | Description | Reused In |
|---|---|---|
shap_summary.png |
Beeswarm plot — top 15 features with direction + magnitude for all 1,407 test customers | Streamlit app (Page 3), portfolio page |
shap_bar.png |
Bar plot — mean absolute SHAP values (simpler global importance view) | Portfolio page |
shap_waterfall_example.png |
Waterfall plot — single high-risk customer showing feature-by-feature contribution | Portfolio page |
Streamlit App
Overview
The project culminates in a deployed, interactive Streamlit web application where anyone can input a customer profile and receive a churn risk score with a SHAP waterfall explanation — no code required. The app has four pages: a real-time churn predictor with per-customer explanations, a model performance dashboard, a data insights & SHAP explainability page, and a sample predictions table. A live Streamlit app is significantly more compelling than a static Jupyter notebook — it demonstrates deployment skills and makes the project accessible to non-technical stakeholders.
Key Code: App Initialization & Model Loading
The app loads the serialized model and preprocessor once using Streamlit’s
@st.cache_resource decorator (caches across reruns and sessions for performance),
imports the shared engineer_features() function, and sets up sidebar navigation
across the four pages:
import streamlit as st
import pandas as pd
import numpy as np
import joblib
import shap
import matplotlib.pyplot as plt
import os, sys
# Get the directory where app.py lives (works on both local and Streamlit Cloud)
APP_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, APP_DIR)
from feature_helpers import engineer_features
# Page config
st.set_page_config(page_title="Customer Churn Predictor",
page_icon="📊", layout="wide")
# Load model and preprocessor (cached across sessions)
@st.cache_resource
def load_model():
model = joblib.load(os.path.join(APP_DIR, 'best_model.pkl'))
preprocessor = joblib.load(os.path.join(APP_DIR, 'preprocessor.pkl'))
return model, preprocessor
model, preprocessor = load_model()
# Sidebar navigation
st.sidebar.title("📊 Churn Predictor")
page = st.sidebar.radio("Navigate", [
"Predict Churn Risk", "Model Performance",
"Data Insights", "Sample Predictions"
])
Page 1: Predict Churn Risk
The centerpiece of the app. A three-column input form captures all customer attributes —
demographics, services, and account details — using dropdowns for categorical features and sliders
for numeric features. When the user clicks "Predict," the app builds a DataFrame from the inputs,
auto-computes TotalCharges from tenure × MonthlyCharges, applies the
shared engineer_features() function (guaranteeing identical transformations to training),
transforms with the fitted preprocessor, predicts churn probability, and generates a SHAP waterfall
plot explaining that specific prediction — all in under a second.
Key Code: Input Form (3-Column Layout)
col1, col2, col3 = st.columns(3)
with col1:
st.subheader("Demographics")
gender = st.selectbox("Gender", ["Male", "Female"])
senior_citizen = st.selectbox("Senior Citizen", ["No", "Yes"])
partner = st.selectbox("Partner", ["Yes", "No"])
dependents = st.selectbox("Dependents", ["Yes", "No"])
with col2:
st.subheader("Services")
tenure = st.slider("Tenure (months)", 0, 72, 12)
phone_service = st.selectbox("Phone Service", ["Yes", "No"])
multiple_lines = st.selectbox("Multiple Lines",
["Yes", "No", "No phone service"])
internet_service = st.selectbox("Internet Service",
["DSL", "Fiber optic", "No"])
online_security = st.selectbox("Online Security",
["Yes", "No", "No internet service"])
online_backup = st.selectbox("Online Backup",
["Yes", "No", "No internet service"])
device_protection = st.selectbox("Device Protection",
["Yes", "No", "No internet service"])
tech_support = st.selectbox("Tech Support",
["Yes", "No", "No internet service"])
streaming_tv = st.selectbox("Streaming TV",
["Yes", "No", "No internet service"])
streaming_movies = st.selectbox("Streaming Movies",
["Yes", "No", "No internet service"])
with col3:
st.subheader("Account")
contract = st.selectbox("Contract",
["Month-to-month", "One year", "Two year"])
paperless_billing = st.selectbox("Paperless Billing", ["Yes", "No"])
payment_method = st.selectbox("Payment Method", [
"Electronic check", "Mailed check",
"Bank transfer (automatic)", "Credit card (automatic)"
])
monthly_charges = st.slider("Monthly Charges ($)", 18.0, 120.0, 70.0)
Key Code: Build DataFrame, Engineer Features & Predict
if st.button("Predict Churn Risk", type="primary"):
# Build input DataFrame matching the training schema exactly
input_data = pd.DataFrame([{
'gender': gender,
'SeniorCitizen': 1 if senior_citizen == "Yes" else 0,
'Partner': partner,
'Dependents': dependents,
'tenure': tenure,
'PhoneService': phone_service,
'MultipleLines': multiple_lines,
'InternetService': internet_service,
'OnlineSecurity': online_security,
'OnlineBackup': online_backup,
'DeviceProtection': device_protection,
'TechSupport': tech_support,
'StreamingTV': streaming_tv,
'StreamingMovies': streaming_movies,
'Contract': contract,
'PaperlessBilling': paperless_billing,
'PaymentMethod': payment_method,
'MonthlyCharges': monthly_charges,
'TotalCharges': monthly_charges * tenure, # Auto-computed
}])
# Apply shared feature engineering (identical to training)
input_engineered = engineer_features(input_data)
# Define feature order (must match training preprocessor)
numeric_features = ['SeniorCitizen', 'tenure', 'MonthlyCharges',
'TotalCharges', 'ServiceCount', 'HasInternet',
'HasPhone', 'AvgMonthlyCharge', 'TenureGroup']
categorical_features = ['gender', 'Partner', 'Dependents',
'PhoneService', 'MultipleLines',
'InternetService', 'OnlineSecurity',
'OnlineBackup', 'DeviceProtection',
'TechSupport', 'StreamingTV',
'StreamingMovies', 'Contract',
'PaperlessBilling', 'PaymentMethod']
input_final = input_engineered[numeric_features + categorical_features]
# Preprocess and predict
input_processed = preprocessor.transform(input_final)
churn_prob = model.predict_proba(input_processed)[0][1]
Key Code: Risk Display & Real-Time SHAP Waterfall
# Display color-coded risk level
if churn_prob >= 0.5:
st.error(f"⚠️ HIGH RISK – Churn Probability: {churn_prob:.1%}")
elif churn_prob >= 0.3:
st.warning(f"⚡ MEDIUM RISK – Churn Probability: {churn_prob:.1%}")
else:
st.success(f"✅ LOW RISK – Churn Probability: {churn_prob:.1%}")
# Generate per-customer SHAP explanation
st.subheader("Why this prediction?")
cat_names = preprocessor.named_transformers_['cat'] \
.get_feature_names_out(categorical_features).tolist()
all_names = numeric_features + cat_names
explainer = shap.LinearExplainer(model, input_processed,
feature_names=all_names)
shap_values = explainer.shap_values(input_processed)
explanation = shap.Explanation(
values=shap_values[0],
base_values=explainer.expected_value,
data=input_processed[0],
feature_names=all_names
)
fig, ax = plt.subplots(figsize=(8, 5))
shap.waterfall_plot(explanation, max_display=10, show=False)
plt.tight_layout()
st.pyplot(fig)
plt.close()
Risk Thresholds
| Probability Range | Risk Level | Display Color | Streamlit Component |
|---|---|---|---|
| ≥ 50% | HIGH RISK | Red | st.error() |
| 30% – 49% | MEDIUM RISK | Yellow | st.warning() |
| < 30% | LOW RISK | Green | st.success() |
Page 2: Model Performance
Displays the full model comparison from Phase 4 — the 5-model results table loaded from
model_comparison.csv, a blue info box explaining why Logistic Regression was selected,
the model comparison bar chart, the ROC curves overlay, and all 5 confusion matrices. All charts are
pre-generated PNGs loaded from the figures/ directory.
# PAGE 2: Model Performance
st.title("📈 Model Performance")
st.write("Comparison of 5 classification models trained on the "
"Telco Customer Churn dataset.")
# Model comparison table
comparison_df = pd.read_csv(os.path.join(APP_DIR, 'model_comparison.csv'))
st.dataframe(comparison_df, use_container_width=True)
st.info("**Best Model: Logistic Regression** – Highest AUC (0.834) "
"and Recall (0.789). Recall is our priority metric: it's "
"cheaper to send a retention offer to a loyal customer "
"than to miss a churner.")
# Charts in two columns
col1, col2 = st.columns(2)
with col1:
st.subheader("Model Comparison")
st.image(os.path.join(APP_DIR, 'figures/model_comparison.png'),
use_container_width=True)
with col2:
st.subheader("ROC Curves")
st.image(os.path.join(APP_DIR, 'figures/roc_curves.png'),
use_container_width=True)
st.subheader("Confusion Matrices")
st.image(os.path.join(APP_DIR, 'figures/confusion_matrix.png'),
use_container_width=True)
Page 3: Data Insights & SHAP Explainability
Combines key EDA charts (churn by contract type, tenure distribution by churn) with the SHAP beeswarm summary plot and the five business recommendations. This page bridges the analytical findings and the model’s learned patterns into a single view.
# PAGE 3: Data Insights & SHAP Explainability
st.title("🔍 Data Insights & SHAP Explainability")
# EDA charts in two columns
st.subheader("Key EDA Findings")
col1, col2 = st.columns(2)
with col1:
st.image(os.path.join(APP_DIR, 'figures/churn_by_contract.png'),
caption='Churn Rate by Contract Type',
use_container_width=True)
with col2:
st.image(os.path.join(APP_DIR, 'figures/tenure_by_churn.png'),
caption='Tenure Distribution by Churn Status',
use_container_width=True)
# SHAP summary
st.markdown("---")
st.subheader("SHAP Feature Importance")
st.write("SHAP values show how each feature contributes to the "
"model's churn predictions.")
st.image(os.path.join(APP_DIR, 'figures/shap_summary.png'),
caption='SHAP Summary – Top 15 Features Driving Churn',
use_container_width=True)
# Business recommendations
st.subheader("Business Recommendations")
st.markdown("""
1. **Incentivize annual contracts** – Month-to-month customers
churn at 42.7% vs 2.8% for two-year contracts
2. **Focus retention on new customers** – Most churn happens
in the first few months
3. **Bundle support add-ons** – Customers with OnlineSecurity
and TechSupport churn significantly less
4. **Address fiber optic experience** – Fiber customers churn
more despite paying more (potential service quality issue)
5. **Migrate electronic check users** – This payment method
correlates with higher churn
""")
Page 4: Sample Predictions
Displays a table of 15 test set customers with their actual churn status, the model’s predicted status, and the churn probability score. The probability column is color-coded with a red-yellow-green gradient so visitors can immediately see the model’s confidence level. This gives a concrete sense of model accuracy without needing to input values manually.
# PAGE 4: Sample Predictions
st.title("📋 Sample Predictions")
st.write("Predictions on 15 test set customers showing actual vs "
"predicted churn with probability scores.")
sample_df = pd.read_csv(
os.path.join(APP_DIR, '..', 'outputs', 'metrics',
'sample_predictions.csv')
)
# Color-coded probability column
st.dataframe(
sample_df.style.background_gradient(
subset=['Churn_Probability'], cmap='RdYlGn_r'
),
use_container_width=True
)
st.markdown("---")
st.write("**How to read this table:**")
st.markdown("""
- **Actual**: Whether the customer actually churned (1) or stayed (0)
- **Predicted**: The model's binary prediction at the 0.5 threshold
- **Churn_Probability**: The model's confidence score
(0 = definitely staying, 1 = definitely churning)
""")
4-Page Summary
| Page | Title | Purpose | Key Features |
|---|---|---|---|
| 1 | Predict Churn Risk | Real-time scoring of any customer profile | 3-column input form (16 fields), auto-computed TotalCharges, color-coded risk level, live SHAP waterfall explanation |
| 2 | Model Performance | Show all 5-model comparison results | Results table from CSV, info box explaining winner, model comparison chart, ROC curves, confusion matrices |
| 3 | Data Insights | Bridge EDA findings and SHAP importance | Churn by contract chart, tenure histogram, SHAP beeswarm, 5 business recommendations |
| 4 | Sample Predictions | Concrete examples of model accuracy | 15 test customers with actual vs predicted, color-gradient probability column |
Train/Serve Consistency Guarantees
A common deployment failure is a mismatch between training-time and serve-time feature transformations. This app avoids that through three design decisions:
| Risk | Mitigation |
|---|---|
| Feature engineering differs between training and prediction | feature_helpers.py is a single shared module — identical copy in notebooks/ and app/ |
| Preprocessing pipeline differs (different scaling, different encoding) | The fitted ColumnTransformer is serialized via joblib — same scaler means, stds, and encoder categories at serve time |
| Feature column order mismatch | Feature lists are explicitly defined in the same order as training (numeric_features + categorical_features) |
| Unknown categories in user input | handle_unknown='ignore' in OneHotEncoder — unseen categories produce zeros instead of errors |
| SeniorCitizen encoding mismatch | App converts "Yes"/"No" dropdown to 1/0 integer before building the DataFrame |
App Files
| File | Purpose | Source |
|---|---|---|
app/app.py | Main Streamlit application (4 pages) | Written in Phase 7 |
app/feature_helpers.py | Shared feature engineering function | Copied from notebooks/feature_helpers.py |
app/best_model.pkl | Serialized LogisticRegression model | Copied from models/best_model.pkl |
app/preprocessor.pkl | Fitted ColumnTransformer (scaler + encoder) | Copied from models/preprocessor.pkl |
app/model_comparison.csv | 5-model results table for Page 2 | Copied from outputs/metrics/ |
app/requirements.txt | Streamlit-specific dependencies | Written in Phase 7 |
app/figures/ | 6 pre-generated chart PNGs for Pages 2 & 3 | Copied from outputs/figures/ |
Key Code: Streamlit Requirements
# app/requirements.txt
streamlit
pandas
numpy
scikit-learn
shap
matplotlib
joblib
Deployment
| Property | Value |
|---|---|
| Platform | Streamlit Community Cloud (free tier) |
| Repository | nadeaujonny/nadeaujonny.github.io |
| Branch | main |
| Entry File | projects/ml-churn-prediction/app/app.py |
| Live URL | nadeaujonnyappio-ba7xf6aknjidd9ppd5ww3t.streamlit.app |
Why a Deployed App Matters
A deployed Streamlit app is significantly more impactful than a static notebook for three reasons: it demonstrates deployment skills (model serialization, dependency management, cloud hosting), it makes the project accessible to non-technical stakeholders (a hiring manager can try the predictor without installing Python), and the real-time SHAP waterfall explanations demonstrate explainability in action rather than as a static screenshot. The app turns a completed analysis into a usable tool.
Key Findings & Business Recommendations
Overview
This section synthesizes the “so what” of the entire project — the analytical findings and modeling insights that translate into concrete, actionable business recommendations. Every finding listed below is supported by evidence from multiple phases: EDA visualizations (Phase 2), SHAP feature importance rankings (Phase 6), and the model selection and tuning decisions (Phases 4–5). These aren’t hypothetical suggestions — they are data-driven conclusions backed by a trained, validated, and explainable model.
Key Findings
| # | Finding | Evidence | Where Discovered |
|---|---|---|---|
| 1 | Contract type is the single strongest churn predictor | 42.7% vs 11.3% vs 2.8% churn rates (Phase 2); SHAP #2 feature (Phase 6) | EDA (Phase 2), SHAP (Phase 6) |
| 2 | Churn is heavily front-loaded by tenure | Front-loaded in first 12 months (Phase 2); SHAP #1 feature, correlation −0.35 (Phase 6) | EDA (Phase 2), SHAP (Phase 6), Feature Engineering (Phase 3 — motivated TenureGroup) |
| 3 | Fiber optic customers churn more despite paying more | Higher churn despite higher price (Phase 2); SHAP #3 feature (Phase 6) | EDA (Phase 2), SHAP (Phase 6) |
| 4 | Support add-ons (OnlineSecurity, TechSupport) significantly reduce churn | Absence correlates with higher churn (Phase 2); OnlineSecurity SHAP #9, TechSupport #11 (Phase 6) | EDA (Phase 2), SHAP (Phase 6), Feature Engineering (Phase 3 — motivated ServiceCount) |
| 5 | Electronic check payment method correlates with higher churn | SHAP #8 feature (Phase 6); visible in EDA categorical grid (Phase 2) | EDA (Phase 2), SHAP (Phase 6) |
| 6 | The simplest model outperformed ensemble methods | LR beat all 4 complex models on AUC and recall (Phase 4) | Model Training (Phase 4) |
| 7 | Hyperparameter tuning improved precision but sacrificed recall — the default was kept | −22pp recall for +0.0002 AUC; default kept (Phase 5) | Hyperparameter Tuning (Phase 5) |
Business Recommendations
| # | Recommendation | Rationale | Expected Impact |
|---|---|---|---|
| 1 | Incentivize annual contracts | Month-to-month customers churn at 42.7% vs. 2.8% for two-year contracts. Contract type is the #2 SHAP feature. Even small discounts on annual plans would likely pay for themselves through reduced churn. | Highest-impact single intervention — converting even 10% of month-to-month customers to annual would significantly reduce overall churn rate |
| 2 | Focus retention efforts on new customers (first 12 months) | Tenure is the #1 SHAP feature. Most churn happens in the first year. Customers who survive 12 months are far more likely to stay long-term. | Targeted onboarding programs, check-in calls at 30/60/90 days, early loyalty incentives for first-year customers |
| 3 | Bundle OnlineSecurity and TechSupport add-ons | Both reduce churn when present (SHAP #9 and #11). Customers with support add-ons have more perceived value and higher switching costs. | Offer these services at reduced cost or include by default for at-risk segments — the retention value likely exceeds the cost of the service |
| 4 | Investigate fiber optic service quality | Fiber optic is the #3 SHAP feature pushing toward churn, despite being the highest-revenue internet tier. Higher churn at higher price points signals an experience problem, not a pricing problem. | Audit fiber optic speed consistency, installation experience, and support response times. The revenue-at-risk from fiber churn likely justifies investment in service quality. |
| 5 | Migrate electronic check users to automatic payment methods | Electronic check is SHAP #8, pushing toward churn. Manual payment = lower friction to cancel. Automatic payments (bank transfer, credit card) create passive retention. | Offer a small discount or incentive for switching to autopay — even a $5/month discount costs less than losing the customer |
EDA → SHAP Alignment
A strong validation of the project’s analytical integrity is that the EDA findings (computed from raw churn rates with no model involved) align closely with the SHAP feature importance rankings (learned by the trained model). The features that showed the strongest churn signal in EDA — contract type, tenure, internet service type, support add-ons, payment method — are the same features that SHAP ranks as most important. This convergence from two independent methods means the findings are robust, not artifacts of a particular analytical approach.
| Feature | EDA Rank (by churn rate spread) | SHAP Rank (by mean |SHAP value|) | Aligned? |
|---|---|---|---|
| Contract type | #1 | #2 and #6 (Two year, One year) | Yes |
| Tenure | #2 | #1 | Yes |
| Internet service type | #3 (fiber optic) | #3 | Yes |
| Monthly charges | #4 | #5 | Yes |
| Support add-ons | #5 | #9 and #11 | Yes |
| Payment method | #6 | #8 | Yes |
| Gender | No signal | Not in top 15 | Yes |
Technical Details
Libraries & Dependencies
| Library | Role in This Project | Phase(s) Used |
|---|---|---|
pandas |
Data loading, cleaning, manipulation, feature engineering, DataFrame operations throughout | All phases (1–7) |
numpy |
Numerical computing, array operations | All phases |
scikit-learn |
Logistic Regression, Random Forest, SVM, ColumnTransformer, StandardScaler, OneHotEncoder, train_test_split, StratifiedKFold, cross_val_score, RandomizedSearchCV, all evaluation metrics | Phases 3–5 |
xgboost |
XGBClassifier — gradient boosting (level-wise tree growth) | Phase 4 |
lightgbm |
LGBMClassifier — gradient boosting (leaf-wise tree growth) | Phase 4 |
shap |
LinearExplainer for global feature importance (beeswarm, bar plots) and per-customer waterfall explanations | Phase 6, Streamlit app (Page 1) |
matplotlib |
All chart generation — EDA visualizations, model comparison, ROC curves, confusion matrices, SHAP plots | Phases 2, 4, 6, Streamlit app |
seaborn |
Statistical visualization — heatmaps, styled plots built on top of matplotlib | Phases 2, 4 |
streamlit |
4-page interactive web application — input forms, predictions, chart display, data tables | Phase 7 (deployment) |
joblib |
Serialization of model (.pkl), preprocessor, feature names, and train/test data for artifact handoff between notebooks and app |
Phases 3–7 |
scipy |
Statistical distributions used by RandomizedSearchCV parameter spaces | Phase 5 |
jupyter |
Interactive notebook environment for all 5 analysis notebooks | Phases 1–6 |
Key Code: Full Requirements
# requirements.txt (full project)
pandas>=2.0
numpy>=1.24
scikit-learn>=1.3
xgboost>=2.0
lightgbm>=4.0
shap>=0.42
matplotlib>=3.7
seaborn>=0.12
streamlit>=1.28
scipy>=1.10
joblib>=1.3
jupyter>=1.0
# app/requirements.txt (Streamlit deployment only)
streamlit
pandas
numpy
scikit-learn
shap
matplotlib
joblib
# Install everything
pip install pandas numpy scikit-learn xgboost lightgbm shap \
matplotlib seaborn streamlit scipy joblib jupyter
All libraries install via pip with no special dependencies. No GPU required. Everything runs locally on any modern machine.
See Phase 5 for the final model specification.
How to Reproduce
-
Clone the repository:
git clone https://github.com/nadeaujonny/nadeaujonny.github.io.git cd projects/ml-churn-prediction -
Download the dataset from
Kaggle
and place the CSV file in
data/:data/WA_Fn-UseC_-Telco-Customer-Churn.csv -
Install dependencies:
pip install -r requirements.txt -
Run the notebooks in order (each builds on the previous):
jupyter notebook # Execute in order: # 01_data_cleaning.ipynb → produces data/telco_churn_cleaned.csv # 02_eda.ipynb → produces outputs/figures/*.png # 03_feature_engineering.ipynb → produces models/preprocessor.pkl + splits # 04_model_training.ipynb → produces models/best_model.pkl + charts # 05_tuning_evaluation.ipynb → produces SHAP charts + final evaluation -
Run the Streamlit app locally:
cd app streamlit run app.py
See Project Overview for the pipeline architecture.
Project Structure
projects/ml-churn-prediction/
├── index.md # Portfolio page (this page)
├── README.md # GitHub README
├── requirements.txt # Full project Python dependencies
├── .gitignore
│
├── data/
│ ├── WA_Fn-UseC_-Telco-Customer-Churn.csv # Raw dataset (gitignored)
│ └── telco_churn_cleaned.csv # Cleaned dataset (gitignored)
│
├── notebooks/
│ ├── 01_data_cleaning.ipynb # Phase 1: Load, inspect, clean, save
│ ├── 02_eda.ipynb # Phase 2: 7 visualizations, findings
│ ├── 03_feature_engineering.ipynb # Phase 3: Features, split, preprocessing
│ ├── 04_model_training.ipynb # Phase 4: Train 5 models, compare, save
│ ├── 05_tuning_evaluation.ipynb # Phases 5–6: Tuning, SHAP, final eval
│ └── feature_helpers.py # Shared feature engineering function
│
├── models/
│ ├── best_model.pkl # Final Logistic Regression model
│ ├── preprocessor.pkl # Fitted ColumnTransformer
│ ├── feature_names.pkl # 35 post-encoding feature names
│ ├── all_models.pkl # All 5 fitted models
│ ├── train_test_split.pkl # (X_train, X_test, y_train, y_test)
│ └── processed_data.pkl # (X_train_processed, X_test_processed)
│
├── outputs/
│ ├── figures/
│ │ ├── churn_distribution.png # Overall churn bar chart
│ │ ├── churn_by_contract.png # Churn rate by contract type ★
│ │ ├── tenure_by_churn.png # Tenure histograms by churn ★
│ │ ├── monthly_charges_by_churn.png
│ │ ├── churn_by_categories.png # 4×4 categorical grid
│ │ ├── correlation_heatmap.png
│ │ ├── tenure_vs_charges_scatter.png
│ │ ├── model_comparison.png # Model comparison bar chart ★
│ │ ├── roc_curves.png # ROC curves overlay ★
│ │ ├── confusion_matrix.png # 5 confusion matrices ★
│ │ ├── shap_summary.png # SHAP beeswarm ★
│ │ ├── shap_bar.png # SHAP bar plot
│ │ └── shap_waterfall_example.png # SHAP waterfall (high-risk)
│ └── metrics/
│ ├── model_comparison.csv # 5-model results table ★
│ └── sample_predictions.csv # 15 test customer predictions
│
└── app/
├── app.py # Streamlit application (4 pages)
├── feature_helpers.py # Copy of shared function
├── best_model.pkl # Copy for deployment
├── preprocessor.pkl # Copy for deployment
├── model_comparison.csv # Copy for Page 2
├── requirements.txt # Streamlit-specific deps
└── figures/ # ★ Chart copies for app
├── churn_by_contract.png
├── confusion_matrix.png
├── model_comparison.png
├── roc_curves.png
├── shap_summary.png
└── tenure_by_churn.png
★ indicates files that are used by both the notebooks/portfolio page and the Streamlit app
(copied into app/figures/ for deployment).
See Phases 3–6 for artifact details.
Environment & Runtime
| Property | Value |
|---|---|
| Python Version | 3.13.9 |
| GPU Required | No — all models train in seconds on CPU |
| Total Training Time (all 5 models) | < 30 seconds on a modern laptop |
| Streamlit Deployment | Streamlit Community Cloud (free tier) |
| Repository | github.com/nadeaujonny/…/ml-churn-prediction |
| Random Seed | random_state=42 throughout — all results are reproducible |
Project Files & Repository
Live Links
| Resource | Link |
|---|---|
| Live Streamlit App | nadeaujonnyappio-ba7xf6aknjidd9ppd5ww3t.streamlit.app |
| GitHub Repository | github.com/nadeaujonny/…/ml-churn-prediction |
| Portfolio Page | nadeaujonny.github.io/projects/ml-churn-prediction |
| Dataset (Kaggle) | kaggle.com/datasets/blastchar/telco-customer-churn |
Notebooks (execute in order)
| Notebook | Description |
|---|---|
01_data_cleaning.ipynb |
Phase 1 — Data Cleaning |
02_eda.ipynb |
Phase 2 — Exploratory Data Analysis |
03_feature_engineering.ipynb |
Phase 3 — Feature Engineering & Preprocessing |
04_model_training.ipynb |
Phase 4 — Model Training & Comparison |
05_tuning_evaluation.ipynb |
Phases 5–6 — Tuning & SHAP |
Shared Module
| File | Purpose | Locations |
|---|---|---|
feature_helpers.py |
Single source of truth for the engineer_features() function — creates ServiceCount, HasInternet, HasPhone, AvgMonthlyCharge, and TenureGroup. Guarantees identical feature transformations at training time and serve time. |
notebooks/feature_helpers.py (training) and app/feature_helpers.py (deployment) — identical copies |
See the Streamlit App section for deployment file details.
Output Artifacts
See Phases 2, 4, and 6 for chart descriptions.
Data & Metrics
| File | Generated By | Description |
|---|---|---|
outputs/metrics/model_comparison.csv |
Notebook 04 | 5-model comparison table (CV AUC, Accuracy, Precision, Recall, F1, AUC) |
outputs/metrics/sample_predictions.csv |
Notebook 05 | 15 test customers: Actual, Predicted, Churn_Probability |
See Phases 3–6 for serialized artifact details.