Customer Churn Predictor with XAI
Build an XGBoost churn model with SHAP explainability and Streamlit app.
Introduction
30 Second Summary
Every subscription service, online retailer, and SaaS company has the same nightmare: customers leaving without warning. The real cost is not just lost revenue. It is the missed opportunity to intervene before the customer walks away.
In this project, you will build an end-to-end customer churn prediction system from 541K+ real e-commerce transactions. You will define what "churn" means, engineer features from raw data, train and compare two models, and deploy the winner as an interactive app with built-in explanations for every prediction.
What You'll Build
The finished app lets anyone select a customer and instantly see their churn probability, risk level, and a visual breakdown of exactly which factors are pushing them toward or away from leaving.
By the end of this project, you'll have:
- A live Streamlit app where you can select any customer and see their churn risk score update in real time.
- A SHAP waterfall chart explaining exactly which behaviors drive each customer's risk up or down, so you can answer "why is this customer at risk?" for any prediction.
- A side-by-side model comparison between Logistic Regression and XGBoost that demonstrates why gradient boosting outperforms on non-linear patterns.
- Secret Mission: Add RFM-based customer segmentation so the app recommends different retention strategies by segment.
Are there any prerequisites?
This project assumes you are familiar with Python and have basic experience with pandas and scikit-learn (importing libraries, reading data, calling .fit() and .predict()). No prior experience with XGBoost, SHAP, or Streamlit is needed.
Before We Start
Before We Start...
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up Your Environment
Your churn predictor needs several machine learning and visualization libraries working together. A clean, isolated environment ensures that package versions don't conflict with anything else on your system.
In this step, you'll create a dedicated project folder, set up a virtual environment, install all the packages you need, and download the real-world dataset you'll be working with.
In this step, get ready to:
- Create a project folder and virtual environment for isolated dependencies.
- Install scikit-learn, XGBoost, SHAP, Streamlit, and supporting packages.
- Download and verify the UCI Online Retail dataset (541K+ real transactions).
Create your project folder and virtual environment
A virtual environment gives your project its own isolated set of Python packages. This prevents version conflicts between projects on your machine.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir churn-predictor
cd churn-predictor
Your terminal prompt now shows you're inside the churn-predictor folder.
- Create a virtual environment by running:
python -m venv venv
- Activate the virtual environment using the command for your operating system:
macOS
source venv/bin/activate
Windows
venv\Scripts\activate
Your terminal prompt now shows (venv) at the beginning. This confirms you're working inside the isolated environment.
Don't see (venv) in your prompt?
On Windows, if you see an error about running scripts being disabled, run this command first, then try the activate command again:
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
On macOS, make sure you typed source before the path. Without it, the activation script won't run in your current shell.
help me activate my Python virtual environment
Install all required packages
Now that your environment is active, you'll create a requirements file that pins the exact package versions this project uses. This ensures reproducibility.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the churn-predictor folder, and click Open.
- Create a new file by pressing Cmd+N (macOS) or Ctrl+N (Windows).
- Paste the following package list into the file:
scikit-learn==1.9.0
xgboost==3.3.0
shap==0.52.0
streamlit==1.60.0
matplotlib
openpyxl
joblib
What are these packages?
- scikit-learn provides machine learning tools like Logistic Regression, train/test splitting, and evaluation metrics.
- xgboost is a gradient boosting library that captures non-linear patterns in data.
- shap explains individual model predictions by showing which features pushed the result up or down.
- streamlit turns Python scripts into interactive web apps with minimal code.
- matplotlib, openpyxl, and joblib handle plotting, Excel file reading, and model saving respectively.
- Save the file as requirements.txt by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- In the save dialog, type requirements.txt as the filename and save it inside the churn-predictor folder.
- Open the integrated terminal in VS Code by pressing Ctrl+` (both macOS and Windows).
- If your terminal does not show (venv) at the beginning of the prompt, activate the virtual environment again using the activation command for your OS from the previous substep.
- Install all the packages by running:
pip install -r requirements.txt
This takes 1-2 minutes depending on your internet connection. You'll see packages downloading and installing in the terminal output.
- Verify that all packages installed correctly by running:
python -c "import sklearn, xgboost, shap, streamlit; print('All packages imported successfully')"
You should see All packages imported successfully printed with no errors.
Seeing an import error?
Make sure (venv) is visible in your terminal prompt. If it's missing, the packages installed into the virtual environment but your terminal is using the system Python instead.
If a specific package failed to install, check that you're running Python 3.12 or higher with python --version. Both xgboost and shap require Python 3.12+.
help me fix package import errors in my Python virtual environment
✔️ Awesome, I've got everything!
Great. Make sure you've saved requirements.txt and that the import command printed the success message.
ⓧ I'd like to double check the full code
Your requirements.txt should contain exactly this:
scikit-learn==1.9.0
xgboost==3.3.0
shap==0.52.0
streamlit==1.60.0
matplotlib
openpyxl
joblib
Download the UCI Online Retail dataset
Your model needs real transaction data to learn from. You'll use the UCI Online Retail dataset, which contains 541,909 real e-commerce transactions from a UK retailer.
- Open your web browser and navigate to the dataset page:
https://archive.ics.uci.edu/dataset/352/online+retail
- Click the Download button on the dataset page.
- Extract the downloaded zip file. Inside you'll find a file called Online Retail.xlsx (22.6 MB).
- Move Online Retail.xlsx into your churn-predictor folder on the Desktop.
What is this dataset?
This dataset contains all transactions for a UK-based online retailer between December 2010 and December 2011. Each row is a single product purchased within an invoice. It includes customer IDs, product codes, quantities, prices, and dates.
The data is messy by design. About 25% of rows have missing customer IDs, some transactions are cancellations, and there are anomalies in quantity and price. Cleaning this up is part of the real-world data science experience.
Before you run the next command, what do you expect to see if the file is in the right place and loads correctly?
- Verify that the dataset loads correctly by running this in your VS Code terminal:
python -c "import pandas as pd; df = pd.read_excel('Online Retail.xlsx', engine='openpyxl'); print(f'Loaded {len(df):,} rows')"
You should see Loaded 541,909 rows printed to your terminal. This confirms the file is in the right folder and pandas can read it.
Seeing a FileNotFoundError?
Make sure Online Retail.xlsx is directly inside the churn-predictor folder, not in a subfolder. The filename has a space between "Online" and "Retail".
Run ls (macOS) or dir (Windows) to see what files are in your current directory. If Online Retail.xlsx isn't listed, move it there from your Downloads folder.
help me fix a FileNotFoundError when loading my Excel dataset
Your environment is ready with all packages installed and real transaction data loaded. Next up, you'll dig into this data and engineer churn labels that define which customers stopped buying.
Load Data and Engineer Churn Labels
Your environment is ready and the dataset is downloaded. Now you face the core challenge: this dataset has 541K+ rows of raw purchase transactions, but no column that says whether a customer churned.
Defining "churn" from real transaction patterns is what separates a data scientist from someone who downloads a pre-labeled dataset. In this step, you will load the raw data, clean it up, and engineer your own churn labels based on purchasing behavior.
In this step, get ready to:
- Load and explore the raw transaction data to understand its shape and quality.
- Clean the data by removing invalid records, cancellations, and missing values.
- Define churn labels by splitting customers into reference and evaluation periods.
Load and explore the raw data
Before you can define churn, you need to see what you are working with. The dataset contains transaction-level records with columns for invoice numbers, product codes, quantities, prices, dates, and customer IDs.
- Create a new file called train_model.py in your churn-predictor folder.
- Add the following imports and data loading code:
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report, roc_auc_score
import xgboost as xgb
import shap
import joblib
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
# 1. Load data
print("Loading data...")
df = pd.read_excel("Online Retail.xlsx", engine="openpyxl")
print(f"Loaded {len(df):,} rows and {df.shape[1]} columns")
print(f"\nColumns: {list(df.columns)}")
print(f"\nMissing values:\n{df.isna().sum()}")
What does this code do?
The imports pull in every library you will need across the full training pipeline. You import them all now so the file is ready to grow in later steps.
The matplotlib.use("Agg") line sets a non-interactive backend so plots save to files without opening a window. The load section reads the Excel file and immediately prints shape, column names, and missing-value counts so you can spot data quality issues.
- Save train_model.py.
- Run the script to see the raw data landscape:
python train_model.py
You should see output confirming 541,909 rows loaded, 8 columns listed, and a missing-value summary showing CustomerID has roughly 135,000 null entries.
Seeing a FileNotFoundError?
Make sure Online Retail.xlsx is in the same folder as train_model.py. The filename is case-sensitive and includes a space.
help me fix a FileNotFoundError when loading Online Retail.xlsx with pandas
Clean the data
The raw data has significant quality issues. About 25% of rows have no CustomerID, some invoices are cancellations (prefixed with 'C'), and some rows have non-positive quantities or prices. You need to remove all of these before engineering features.
- Add the following cleaning code below the load section in train_model.py:
# 2. Clean data
print("\n--- Cleaning Data ---")
initial_rows = len(df)
df = df.dropna(subset=["CustomerID"])
print(f"Dropped rows without CustomerID: {initial_rows - len(df):,}")
cancelled = df["InvoiceNo"].astype(str).str.startswith("C")
df = df[~cancelled]
print(f"Removed {cancelled.sum():,} cancelled orders")
df = df[(df["Quantity"] > 0) & (df["UnitPrice"] > 0)]
df["CustomerID"] = df["CustomerID"].astype(int)
df["InvoiceDate"] = pd.to_datetime(df["InvoiceDate"])
df["TotalPrice"] = df["Quantity"] * df["UnitPrice"]
print(f"\nAfter cleaning: {len(df):,} rows, {df['CustomerID'].nunique():,} customers")
What does this cleaning do?
- Drops rows with missing CustomerID because you cannot track a customer without an ID.
- Removes cancelled orders (InvoiceNo starting with 'C') because cancellations are not real purchases.
- Filters out non-positive Quantity and UnitPrice values, which are data entry errors.
- Creates a TotalPrice column (Quantity multiplied by UnitPrice) for monetary calculations later.
- Save train_model.py and run it again:
python train_model.py
You should now see the cleaning summary: roughly 135,000 rows dropped for missing CustomerID, about 9,000 cancelled orders removed, and a final count of around 354,000 clean rows across approximately 4,300 customers.
Seeing a KeyError on CustomerID?
Double-check the column name is exactly CustomerID with that capitalization. The dataset uses mixed casing. If you see different column names in your load output, match them exactly.
help me debug a KeyError in my pandas cleaning code
Define churn labels
Now for the key insight. You will split the timeline into a reference period (first ~10 months) and an evaluation period (last 2 months). Any customer who purchased during the reference period but NOT during the evaluation period gets labeled as "churned."
Why define churn this way?
In a subscription business, churn is obvious: the customer cancelled. In e-commerce, there is no cancellation event. You have to decide what "gone" means.
A 60-day evaluation window is a common choice because it is long enough that a customer who was going to return would have done so, but short enough to leave plenty of reference data for feature engineering.
- Add the churn label definition below the cleaning section in train_model.py:
# 3. Define churn
print("\n--- Defining Churn Labels ---")
cutoff_date = df["InvoiceDate"].max() - pd.Timedelta(days=60)
snapshot_date = cutoff_date
reference = df[df["InvoiceDate"] <= cutoff_date]
evaluation = df[df["InvoiceDate"] > cutoff_date]
reference_customers = set(reference["CustomerID"].unique())
evaluation_customers = set(evaluation["CustomerID"].unique())
churned_customers = reference_customers - evaluation_customers
print(f"Reference period: up to {cutoff_date.date()}")
print(f"Evaluation period: {cutoff_date.date()} to {df['InvoiceDate'].max().date()}")
print(f"Reference period customers: {len(reference_customers):,}")
print(f"Churned customers: {len(churned_customers):,}")
print(f"Retained customers: {len(evaluation_customers & reference_customers):,}")
print(f"Churn rate: {len(churned_customers)/len(reference_customers):.1%}")
What does this code do?
- Sets a cutoff date 60 days before the last transaction in the dataset.
- Splits all transactions into a reference period (everything before the cutoff) and an evaluation period (the final 60 days).
- Finds customers who appear in the reference period but are absent from the evaluation period. These customers "churned."
- Calculates the churn rate by dividing churned customers by total reference-period customers.
- Save train_model.py.
Before you run this, what churn rate do you expect? A typical e-commerce churn rate over a 60-day window is somewhere between 30-50%. Think about what yours might be given this is a UK online retailer.
- Run the script to see your churn labels:
python train_model.py
You should see output showing approximately 4,300 customers in the reference period with a churn rate around 35-40%. This is a realistic business scenario where roughly one-third of customers have gone silent.
Churn rate seems too high or too low?
If your churn rate is above 60% or below 20%, check that your cleaning step ran correctly. The cancelled-order filter and positive-quantity filter both affect the customer count. Scroll up in the terminal to verify the cleaning stats match the expected values.
help me understand why my churn rate calculation seems off
✔️ Awesome, I've got everything!
Great. Double check you have saved train_model.py and your output shows the churn rate line.
ⓧ I'd like to double check the full code
Your train_model.py should contain three sections in this order. Scroll up through your file and confirm each is present:
- Imports (12 lines starting with import pandas as pd through import matplotlib.pyplot as plt).
- Section # 1. Load data that reads the Excel file and prints shape, columns, and missing values.
- Section # 2. Clean data that drops nulls, removes cancellations, filters quantities, and creates TotalPrice.
- Section # 3. Define churn that splits by cutoff date and prints the churn rate.
You have just turned 541K rows of raw transactions into a clean, labeled dataset with roughly 4,300 customers. Next up, you will engineer features from these transactions and train your first model to predict which customers will churn.
Build Features and Train Baseline Model
Your data is clean and every customer now has a churn label. The next challenge is turning those raw transactions into features a machine learning model can actually learn from.
In this step, you'll aggregate thousands of transaction rows into a compact profile for each customer. Then you'll train a Logistic Regression as a baseline to test whether those features carry any predictive signal. If the baseline can't beat random guessing, the features need work before trying fancier models.
In this step, get ready to:
- Engineer 7 customer-level features from raw transaction data.
- Split data into training and test sets with stratification.
- Train and evaluate a Logistic Regression baseline model.
Engineer customer-level features from transaction data
Right now, your data has one row per transaction. A model needs one row per customer with summary statistics that describe their behavior. You'll create 7 features: recency, frequency, monetary value, average basket value, average quantity, unique products, and average unit price.
- In train_model.py, find the last line of your churn definition section:
print(f"Churn rate: {len(churned_customers)/len(reference_customers):.1%}")
- Add the following feature engineering code below that line:
# 4. Feature engineering
print("\n--- Engineering Features ---")
customer_data = reference.groupby("CustomerID").agg(
last_purchase=("InvoiceDate", "max"),
frequency=("InvoiceNo", "nunique"),
monetary=("TotalPrice", "sum"),
avg_quantity=("Quantity", "mean"),
unique_products=("StockCode", "nunique"),
avg_unit_price=("UnitPrice", "mean"),
).reset_index()
customer_data["recency"] = (snapshot_date - customer_data["last_purchase"]).dt.days
customer_data["avg_basket_value"] = customer_data["monetary"] / customer_data["frequency"]
customer_data = customer_data.drop(columns=["last_purchase"])
customer_data["churned"] = customer_data["CustomerID"].apply(
lambda x: 1 if x in churned_customers else 0
)
print(f"Features created for {len(customer_data):,} customers")
print(f"\nChurn distribution:")
print(customer_data["churned"].value_counts())
print(f"\nFeature summary:")
print(customer_data.describe().round(2))
What does this code do?
- groupby("CustomerID").agg(...) collapses thousands of transaction rows into one row per customer, calculating their last purchase date, order count, total spend, and more.
- recency measures how many days have passed since the customer last bought something. Higher recency means they haven't been back in a while.
- avg_basket_value is derived by dividing total spend by order count. It tells you how much a customer typically spends per visit.
- The final line attaches the churned label (1 or 0) to each customer so the model knows what to predict.
- Save train_model.py.
- Run the script to see your new features by running:
python train_model.py
At the bottom of the output, you should see a "Features created for" line showing around 4,300 customers, a churn distribution table, and a feature summary with statistics for all 7 features.
Seeing a KeyError or empty DataFrame?
- Make sure you placed the new code after the churn definition section, not inside it. The reference and snapshot_date variables must already exist before this code runs.
- Check that column names in the .agg() call match the DataFrame columns exactly: InvoiceDate, InvoiceNo, TotalPrice, Quantity, StockCode, UnitPrice.
- help me debug my feature engineering code
Prepare the data for modeling
With your features built, you need to separate the input columns from the target column and split everything into training and test sets. Stratification ensures both sets have the same churn ratio as the full dataset.
- In train_model.py, add this code below the feature summary prints:
# 5. Prepare for modeling
features = ["recency", "frequency", "monetary", "avg_basket_value",
"avg_quantity", "unique_products", "avg_unit_price"]
X = customer_data[features]
y = customer_data["churned"]
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y
)
print(f"\nTrain set: {len(X_train):,} customers")
print(f"Test set: {len(X_test):,} customers")
What does this code do?
- features lists the 7 columns the model will use as inputs. The churned column is the target (what we're predicting), not a feature.
- train_test_split holds out 20% of customers for testing. The stratify=y argument ensures both sets maintain the same churn/non-churn ratio, so neither set is accidentally biased.
- Save train_model.py and run the script again:
python train_model.py
You should see new lines at the bottom showing the train set and test set sizes. The train set should have around 3,400 customers and the test set around 860.
Train and evaluate a Logistic Regression baseline
Logistic Regression is the classic first model to try. It's fast, interpretable, and gives you a performance floor. If it can't beat random guessing, your features need rethinking.
- Add the Logistic Regression training code below the train/test print statements in train_model.py:
# 6. Logistic Regression baseline
print("\n--- Logistic Regression Baseline ---")
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)
lr_model = LogisticRegression(random_state=42, max_iter=1000)
lr_model.fit(X_train_scaled, y_train)
lr_proba = lr_model.predict_proba(X_test_scaled)[:, 1]
lr_pred = lr_model.predict(X_test_scaled)
print(classification_report(y_test, lr_pred))
lr_auc = roc_auc_score(y_test, lr_proba)
print(f"ROC AUC: {lr_auc:.4f}")
Why scale features for Logistic Regression?
StandardScaler normalizes each feature to have mean 0 and standard deviation 1. Logistic Regression uses gradient descent, which converges faster when features are on the same scale.
Notice that fit_transform is called on training data (learns the mean/std), but only transform is called on test data. This prevents data leakage from the test set into scaling parameters.
Before you run this, what do you think the model's accuracy will be? Will it beat 50% (random guessing) on its first try with your hand-crafted features?
- Save train_model.py and run the script:
python train_model.py
At the bottom of the output, you should see a full classification report with precision, recall, and f1-score for both classes (0 and 1). Below that, the ROC AUC score should be somewhere around 0.75 to 0.82.
What does ROC AUC mean?
ROC AUC measures how well the model distinguishes between churned and retained customers. A score of 0.5 means random guessing. A score of 1.0 means perfect separation. Your baseline landing around 0.75-0.82 means the features carry real signal, but there's room for improvement.
Seeing a very low ROC AUC or convergence warning?
- If you see a convergence warning, make sure max_iter=1000 is set in LogisticRegression(). The default 100 iterations is often not enough.
- If ROC AUC is near 0.5, check that the StandardScaler is applied to both X_train and X_test. Unscaled features can prevent the model from learning.
- help me debug my Logistic Regression results
✔️ Awesome, I've got everything!
Great. Double check you've saved train_model.py with all three new sections: feature engineering, data preparation, and the Logistic Regression baseline.
ⓧ I'd like to double check the full code
Your train_model.py is now too long to display in one block. Scroll up and confirm these sections are present in this order:
- Imports and matplotlib.use("Agg") at the top.
- Section 1: Load data (reads the Excel file).
- Section 2: Clean data (drops nulls, cancellations, creates TotalPrice).
- Section 3: Define churn (cutoff date, reference vs evaluation split).
- Section 4: Feature engineering (groupby("CustomerID").agg(...), derived features, churn label).
- Section 5: Prepare for modeling (features list, X/y split, train_test_split).
- Section 6: Logistic Regression baseline (StandardScaler, train, evaluate, print ROC AUC).
Your baseline is solid. The Logistic Regression proves your features carry real churn signal. But a ROC AUC around 0.75-0.82 leaves room for improvement. Next, you'll train an XGBoost model that captures non-linear feature interactions and see how much further you can push performance.
Train XGBoost and Compare Models
Your Logistic Regression baseline works, but it treats every feature independently. It cannot capture the non-linear interactions between features that drive real churn behavior.
For example, a customer with high recency AND low frequency is much more likely to churn than either factor alone suggests. XGBoost handles these interactions naturally through decision trees. In this step, you will train an XGBoost model and compare it head-to-head with your baseline.
In this step, get ready to:
- Train an XGBoost classifier on the same train/test split without feature scaling.
- Compare both models side by side with ROC AUC scores.
- Discover the black-box problem that comes with better performance.
Train the XGBoost classifier
Tree-based models like XGBoost split data at thresholds, so they do not care about feature scale. This means you can train on the raw (unscaled) X_train and X_test directly.
- In train_model.py, add the following code below the Logistic Regression section (after the line print(f"ROC AUC: {lr_auc:.4f}")):
# 7. XGBoost
print("\n--- XGBoost ---")
xgb_model = xgb.XGBClassifier(
n_estimators=100,
max_depth=5,
learning_rate=0.1,
random_state=42,
eval_metric="logloss",
)
xgb_model.fit(X_train, y_train)
xgb_proba = xgb_model.predict_proba(X_test)[:, 1]
xgb_pred = xgb_model.predict(X_test)
print(classification_report(y_test, xgb_pred))
xgb_auc = roc_auc_score(y_test, xgb_proba)
print(f"ROC AUC: {xgb_auc:.4f}")
What does this code do?
- n_estimators=100 builds 100 sequential decision trees, each correcting the mistakes of the previous ones.
- max_depth=5 limits how complex each tree can get. This prevents overfitting to noise in the training data.
- learning_rate=0.1 controls how much each tree contributes. Smaller values mean more trees are needed but generalization improves.
- Notice that xgb_model.fit(X_train, y_train) uses the unscaled data, not X_train_scaled. Tree-based models do not need feature scaling.
Why XGBoost over other gradient boosting libraries?
XGBoost is the most widely recognized gradient boosting library in data science hiring. SHAP (which you will use next) has native support for XGBoost's tree structure, making explainability straightforward.
- Save train_model.py.
- Run the script to see XGBoost's classification report:
python train_model.py
Scroll to the bottom of the output. You should see a new --- XGBoost --- section with a classification report and a ROC AUC score noticeably higher than your Logistic Regression baseline.
Seeing an error about XGBoost?
- If you see ModuleNotFoundError: No module named 'xgboost', make sure your virtual environment is activated and run pip install -r requirements.txt again.
- If you see an indentation error, check that the xgb.XGBClassifier() parameters are indented consistently (4 spaces per level).
Help me debug my XGBoost training error.
Compare models side by side
XGBoost's individual report looks promising. Now add a direct comparison so you can see both models' ROC AUC scores together in one place.
- In train_model.py, add the following code at the very end of the file (below the XGBoost ROC AUC print):
print(f"\n--- Model Comparison ---")
print(f"Logistic Regression ROC AUC: {lr_auc:.4f}")
print(f"XGBoost ROC AUC: {xgb_auc:.4f}")
print(f"Improvement: +{(xgb_auc - lr_auc):.4f}")
What does this comparison show?
This prints both ROC AUC scores side by side and calculates the raw improvement. ROC AUC measures how well the model separates churners from non-churners. A score of 1.0 is perfect. A score of 0.5 is random guessing.
- Save train_model.py.
Before you run the script again, make a prediction. Do you think XGBoost will beat Logistic Regression by a little, or by a lot?
- Run the script to see the comparison:
python train_model.py
Scroll to the very bottom. You should see the --- Model Comparison --- section showing XGBoost's ROC AUC is 5 to 15 percentage points higher than Logistic Regression.
XGBoost clearly wins. But now try to answer this question: why did the model predict a specific customer will churn? You cannot. The model gives you a probability, but no reasoning behind it. This is the black-box problem.
What is the black-box problem?
A business leader will never act on "the model says 73% churn risk" without understanding why. Is it because the customer stopped buying recently? Because their order sizes shrank? Without explanations, the model's predictions are not actionable.
In the next step, you will solve this with SHAP, which breaks open the black box and shows exactly which features pushed each individual prediction up or down.
XGBoost performing worse than Logistic Regression?
- Make sure you are passing the unscaled data to XGBoost. Use X_train and X_test, not X_train_scaled and X_test_scaled.
- Verify that xgb_model.fit(X_train, y_train) appears after the Logistic Regression section. If XGBoost trains before the train/test split, it will fail silently.
My XGBoost model is not outperforming Logistic Regression. Help me debug.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
Your train_model.py is now over 30 lines, so confirm each section is present by scrolling through your file. You should see these sections in order:
- Imports (pandas, numpy, sklearn, xgboost, shap, joblib, matplotlib).
- Section 1: Load data (pd.read_excel).
- Section 2: Clean data (drop missing CustomerID, remove cancellations, filter quantities).
- Section 3: Define churn (cutoff date, reference vs evaluation periods).
- Section 4: Feature engineering (7 features via groupby).
- Section 5: Prepare for modeling (train/test split with stratify).
- Section 6: Logistic Regression baseline (StandardScaler, fit, predict, classification report).
- Section 7: XGBoost (XGBClassifier with n_estimators=100, max_depth=5, learning_rate=0.1, fit on unscaled data, classification report).
- Model Comparison (prints both ROC AUC scores and the improvement).
Your XGBoost model significantly outperforms the Logistic Regression baseline. You have proven your features carry strong predictive signal. But a prediction without an explanation is just a number. Next up, you will crack open the black box with SHAP and deploy the whole thing as an interactive app.
Add SHAP Explainability and Deploy with Streamlit
Your XGBoost model predicts churn significantly better than the Logistic Regression baseline. But try answering this question: why does the model think customer 14567 will churn?
You cannot answer that from the model alone. A business leader will never act on "73% churn risk" without understanding what is driving it. In this step, you will add SHAP to explain every individual prediction, then deploy the full system as an interactive Streamlit app.
In this step, get ready to:
- Generate SHAP explanations and save all model artifacts.
- Build a Streamlit app that scores customers and displays visual explanations.
- Launch and test the app with different customer profiles.
Generate SHAP explanations and save model artifacts
SHAP (SHapley Additive exPlanations) borrows a concept from game theory to assign each feature a contribution score for every prediction. For tree-based models like XGBoost, SHAP has a fast exact algorithm called TreeExplainer.
- In train_model.py, scroll to the very end of the file (after the Model Comparison print statements).
- Add the SHAP explainability section by pasting this code at the bottom:
# 8. SHAP Explainability
print("\n--- Generating SHAP Explanations ---")
explainer = shap.TreeExplainer(xgb_model)
shap_values = explainer(X_test)
shap.summary_plot(shap_values, X_test, show=False)
plt.tight_layout()
plt.savefig("shap_summary.png", dpi=150, bbox_inches="tight")
plt.close()
print("SHAP summary plot saved to shap_summary.png")
What does this code do?
TreeExplainer wraps your XGBoost model and can compute exact SHAP values for every prediction in the test set.
The summary plot visualizes which features matter most across all customers. Each dot is one customer, and the position shows whether that feature pushed the prediction toward churn (right) or retention (left).
- Directly below the SHAP section, add the artifact-saving code:
# 9. Save artifacts
joblib.dump(xgb_model, "xgb_churn_model.joblib")
joblib.dump(scaler, "scaler.joblib")
joblib.dump(explainer, "shap_explainer.joblib")
customer_data.to_csv("customer_features.csv", index=False)
print("\n--- Artifacts Saved ---")
print(" xgb_churn_model.joblib")
print(" scaler.joblib")
print(" shap_explainer.joblib")
print(" customer_features.csv")
print(" shap_summary.png")
print("\nDone! Run 'streamlit run app.py' to launch the app.")
What does this code do?
joblib.dump serializes the trained model, scaler, and SHAP explainer to disk so the Streamlit app can load them without retraining.
customer_features.csv saves the full customer feature table so users can select existing customers in the app.
- Save train_model.py.
Before you run this, what do you expect to see in the terminal output after the model comparison section?
- Run the full training script by executing:
python train_model.py
You will see all the familiar output from earlier steps (loading, cleaning, training), followed by new lines showing SHAP generation and artifact saving. Look for these lines near the end:
--- Generating SHAP Explanations ---
SHAP summary plot saved to shap_summary.png
--- Artifacts Saved ---
xgb_churn_model.joblib
scaler.joblib
shap_explainer.joblib
customer_features.csv
shap_summary.png
Done! Run 'streamlit run app.py' to launch the app.
- Confirm the artifacts were created by running:
ls *.joblib *.csv *.png
You should see xgb_churn_model.joblib, scaler.joblib, shap_explainer.joblib, customer_features.csv, and shap_summary.png listed.
Not seeing the artifact files?
- Make sure you saved train_model.py before running it. Unsaved changes mean the new code never executes.
- If you see an error about shap or joblib not being found, confirm your virtual environment is still activated.
- help me debug why my SHAP artifacts aren't being created
Build the Streamlit app
Now you have a trained model and explainer saved to disk. The next piece is an interactive front end where anyone can score a customer and see the explanation.
What is Streamlit?
Streamlit turns Python scripts into interactive web apps with zero front-end code. You write normal Python, and Streamlit handles rendering widgets, charts, and layouts in the browser.
Every time you save your file, Streamlit detects the change and offers to rerun the app automatically. This creates a tight code-save-see loop during development.
- Create a new file called app.py in your churn-predictor folder.
- Add the imports, page config, and model-loading functions by pasting this code:
import streamlit as st
import pandas as pd
import numpy as np
import joblib
import shap
import matplotlib.pyplot as plt
st.set_page_config(page_title="Customer Churn Predictor", layout="wide")
st.title("Customer Churn Risk Predictor")
st.markdown("Predict which customers are at risk of churning and understand **why**.")
# Load artifacts
@st.cache_resource
def load_model():
model = joblib.load("xgb_churn_model.joblib")
explainer = joblib.load("shap_explainer.joblib")
return model, explainer
@st.cache_data
def load_data():
return pd.read_csv("customer_features.csv")
model, explainer = load_model()
customer_data = load_data()
features = ["recency", "frequency", "monetary", "avg_basket_value",
"avg_quantity", "unique_products", "avg_unit_price"]
What does this code do?
@st.cache_resource caches the model and explainer so they load once and persist across reruns. Without caching, Streamlit would reload the model from disk every time a user interacts with the app.
@st.cache_data caches the customer CSV. It returns a fresh copy each call to prevent accidental mutation.
- Below the features list, add the sidebar for customer selection:
# Sidebar
st.sidebar.header("Select a Customer")
mode = st.sidebar.radio("Input mode:", ["Select existing customer", "Enter custom values"])
if mode == "Select existing customer":
customer_id = st.sidebar.selectbox(
"Customer ID:",
customer_data["CustomerID"].tolist()
)
customer = customer_data[customer_data["CustomerID"] == customer_id][features].iloc[0]
else:
st.sidebar.markdown("Enter customer metrics:")
customer = pd.Series({
"recency": st.sidebar.slider("Days since last purchase", 0, 365, 30),
"frequency": st.sidebar.slider("Number of orders", 1, 200, 5),
"monetary": st.sidebar.slider("Total spend (GBP)", 1, 50000, 500),
"avg_basket_value": st.sidebar.slider("Avg basket value (GBP)", 1, 5000, 100),
"avg_quantity": st.sidebar.slider("Avg items per order", 1, 500, 20),
"unique_products": st.sidebar.slider("Unique products purchased", 1, 500, 10),
"avg_unit_price": st.sidebar.slider("Avg unit price (GBP)", 0.1, 100.0, 3.0),
})
What does this code do?
The sidebar gives users two modes. They can pick an existing customer from the dataset by ID, or dial in custom values using sliders to test hypothetical profiles.
- Save app.py and launch the Streamlit server by running:
streamlit run app.py
What happens when you run this?
Streamlit spins up a local web server and opens your default browser to http://localhost:8501. If the browser does not open automatically, navigate to that URL manually.
Streamlit will ask if you want to "Always rerun" when the file changes. Click that option so the app auto-updates as you add more code.
You should see the title "Customer Churn Risk Predictor" and a sidebar with the customer selection radio button and dropdown.
Seeing a ModuleNotFoundError?
- Make sure your virtual environment is activated in the terminal where you ran streamlit run app.py.
- If you see an error about missing xgb_churn_model.joblib, confirm you successfully ran train_model.py in the previous substep and the files exist.
- help me fix my Streamlit app not loading
- Return to app.py in your editor. Below the sidebar code, add the prediction and metrics display:
# Prediction
input_df = pd.DataFrame([customer[features]])
churn_proba = model.predict_proba(input_df)[:, 1][0]
churn_pred = "HIGH RISK" if churn_proba >= 0.5 else "LOW RISK"
risk_color = "red" if churn_proba >= 0.5 else "green"
# Display results
col1, col2, col3 = st.columns(3)
with col1:
st.metric("Churn Probability", f"{churn_proba:.1%}")
with col2:
st.metric("Risk Level", churn_pred)
with col3:
st.metric("Recency (days)", f"{int(customer['recency'])}")
st.divider()
What does this code do?
predict_proba returns the model's confidence that the customer will churn. Values at or above 0.5 are labeled HIGH RISK.
st.columns(3) creates a three-column layout. Each column displays one key metric as a large, scannable card.
- Save app.py and switch to your browser.
The app auto-reruns. You should now see three metric cards at the top: Churn Probability, Risk Level, and Recency (days).
- Back in app.py, add the SHAP waterfall explanation section below the divider:
# SHAP explanation
st.subheader("Why is this the prediction?")
st.markdown("Each bar shows how a feature pushes the prediction higher (toward churn) or lower (toward retention).")
shap_values = explainer(input_df)
fig, ax = plt.subplots(figsize=(10, 4))
shap.plots.waterfall(shap_values[0], show=False)
plt.tight_layout()
st.pyplot(fig)
plt.close()
st.divider()
What does this code do?
The explainer computes SHAP values for the single customer input. The waterfall plot shows each feature's contribution, stacked from the base value (average prediction) to the final output.
Red bars push the prediction toward churn. Blue bars push it toward retention. This is the answer to "why does the model think this customer will churn?"
- Finally, add the customer profile table and global feature importance image:
# Customer profile
st.subheader("Customer Profile")
profile_df = pd.DataFrame({
"Metric": [
"Days since last purchase",
"Number of orders",
"Total spend (GBP)",
"Avg basket value (GBP)",
"Avg items per order",
"Unique products purchased",
"Avg unit price (GBP)",
],
"Value": [
f"{customer['recency']:.0f}",
f"{customer['frequency']:.0f}",
f"{customer['monetary']:.2f}",
f"{customer['avg_basket_value']:.2f}",
f"{customer['avg_quantity']:.1f}",
f"{customer['unique_products']:.0f}",
f"{customer['avg_unit_price']:.2f}",
],
})
st.table(profile_df)
# Global feature importance
st.subheader("Global Feature Importance (All Customers)")
st.image("shap_summary.png", caption="SHAP Summary Plot - which features matter most across all customers")
What does this code do?
The profile table shows the selected customer's raw feature values so stakeholders can see the data behind the prediction.
The global importance section displays the shap_summary.png you generated earlier. It answers a different question: across all customers, which features matter most for predicting churn?
- Save app.py and check your browser.
You should now see the full app: metric cards, a SHAP waterfall chart, a customer profile table, and the global SHAP summary image at the bottom.
SHAP waterfall chart not rendering?
- If you see a blank space where the chart should be, make sure matplotlib is imported at the top of app.py.
- If the app crashes with a FileNotFoundError for shap_summary.png, confirm you ran train_model.py successfully and the image file exists in your project folder.
- help me fix the SHAP chart not displaying in my Streamlit app
✔️ Awesome, I've got everything!
Great. Double check you have saved app.py and that the app is running without errors in your browser.
ⓧ I'd like to double check the full code
Your app.py should contain these sections in order. Scroll through your file and confirm each one is present:
- Imports (streamlit, pandas, numpy, joblib, shap, matplotlib.pyplot).
- Page config, title, and markdown description.
- load_model() with @st.cache_resource and load_data() with @st.cache_data.
- features list with all 7 feature names.
- Sidebar with radio button and selectbox/slider logic.
- Prediction section with predict_proba and three st.metric cards.
- SHAP explanation with shap.plots.waterfall and st.pyplot.
- Customer profile table with st.table.
- Global feature importance with st.image("shap_summary.png").
Test the app with different customer profiles
The app is running, but you need to verify it actually explains predictions differently for different customers.
Before you test, what do you expect to see for a customer who has not purchased in a long time? Think about which feature would dominate the SHAP explanation.
- In the sidebar, switch the input mode to **Enter custom values**.
- Set the **Days since last purchase** slider to 300.
- Leave all other sliders at their defaults.
The churn probability jumps to a high value and the risk level shows HIGH RISK. In the SHAP waterfall chart, you should see the recency feature as the largest red bar, pushing the prediction strongly toward churn.
- Now set **Days since last purchase** back to 5 and increase **Number of orders** to 50.
The prediction flips to LOW RISK. The waterfall chart now shows recency and frequency as blue bars pulling the prediction away from churn.
Why do the explanations change?
SHAP computes contributions for each individual prediction, not just global averages. A customer with high recency (long time since last purchase) gets a different explanation than a frequent buyer.
This is exactly why SHAP matters for stakeholders. Instead of "the model says 73%", you can say "this customer is at risk because they haven't purchased in 300 days, and their order frequency dropped."
Predictions not changing when you move sliders?
- Make sure you selected **Enter custom values** in the sidebar radio button. If you are on **Select existing customer**, the sliders will not appear.
- If the app is frozen, check the terminal for errors and restart with streamlit run app.py.
- help me figure out why my Streamlit sliders are not updating the prediction
You now have a complete explainable ML system. Anyone can select a customer, see their churn risk, and understand exactly which behaviors are driving that risk. Next up, you will take this further with a Secret Mission that adds customer segmentation and personalized retention strategies.
Secret mission
Add Customer Segmentation for Targeted Retention
Knowing a customer will churn is only half the puzzle. Different customer segments need different retention strategies. Can you add RFM-based segmentation so the app recommends a tailored action for each customer profile?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- The churn-predictor/ project folder on your Desktop (contains all Python scripts, model artifacts, and data).
- Python virtual environment (venv/ folder inside your project).
- The Streamlit development server (running on http://localhost:8501).
- The downloaded Online Retail.xlsx dataset (22.6 MB).
Keep everything running
No action needed. Choose this if you're still actively building or want to experiment further with the churn predictor.
- Your Streamlit app remains available at http://localhost:8501 as long as the process is running in your terminal.
- All model artifacts (xgb_churn_model.joblib, shap_explainer.joblib, customer_features.csv) stay on disk ready to reload.
- Your virtual environment keeps all installed packages available without reinstalling.
Pause - I'll come back to this later
Shut down the running Streamlit server to free up your terminal. Your files stay on disk so you can resume later.
- Switch to the terminal where Streamlit is running.
- Press Ctrl+C to stop the server.
- Deactivate your virtual environment by running:
deactivate
When you're ready to resume, reactivate the environment and relaunch the app:
cd ~/Desktop/churn-predictor
venv\Scripts\activate
streamlit run app.py
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes your trained models, data, and code permanently.
- Switch to the terminal where Streamlit is running and press Ctrl+C to stop the server.
- Deactivate your virtual environment by running:
deactivate
- Delete the entire project folder (which includes the virtual environment, dataset, and all artifacts) by running:
macOS
cd ~/Desktop
rm -rf churn-predictor
Windows
cd ~\Desktop
rmdir /s /q churn-predictor
- Confirm the folder is gone by running:
ls ~/Desktop
You should no longer see churn-predictor in the output.
Nice Work!
Nice Work!
You just built a complete customer churn intelligence system from scratch. Starting with 541K+ rows of messy e-commerce transactions, you turned raw data into an explainable, deployed ML product that any stakeholder can use.
You've learned how to:
- Define churn labels from raw transaction patterns and engineer 7 customer-level features (recency, frequency, monetary, and beyond) that capture real purchasing behavior.
- Train and compare Logistic Regression vs XGBoost, demonstrating how gradient boosting captures non-linear feature interactions that a linear model misses.
- Add SHAP explainability to answer "why is this customer at risk?" and deploy an interactive Streamlit app where stakeholders can score customers and see visual explanations in real time.
- Secret Mission: Added RFM-based customer segmentation with targeted retention strategy recommendations tailored to each segment.
Ready to quiz yourself?