Telecom Churn Prediction with XAI

Predict telecom customer churn and explain every prediction with SHAP.

Introduction

30 Second Summary

Every month, telecom companies lose thousands of customers who quietly cancel their subscriptions. If a retention team could predict who is about to leave and understand exactly why, they could step in before it is too late.

In this project, you will build a churn prediction system that takes 100,000 telecom customer records and outputs an actionable retention report. You will train a model, watch it fail due to class imbalance, fix it with XGBoost, then explain every prediction with SHAP so a retention team knows exactly which customers to call and what to offer.

What You'll Build

You will run your notebook, watch it flag the highest-risk customers from a pool of 100,000, then zoom into any single prediction to see a SHAP waterfall plot that spells out exactly which factors are pushing that customer toward leaving.

By the end of this project, you'll have:

  • A way to input any customer's data and get a churn probability with a plain-English explanation of exactly what is driving their risk.
  • A risk-scored customer list (HIGH/MEDIUM/LOW) with tailored retention recommendations that a business team can act on immediately.
  • SHAP visualizations showing the top churn drivers globally and per-customer, plus a Power BI-ready CSV export for dashboard building.
  • Secret Mission: Customer segmentation using K-Means clustering to identify distinct behavioral groups and personalize retention strategies per segment.

Are there any prerequisites?

You'll need Python 3.12+ installed, Jupyter Notebook for running your analysis, and a free Kaggle account to download the dataset. Familiarity with basic Python (variables, loops, importing libraries) will help you follow along.

Before We Start

Before We Start...

Before we dive in, let's commit to what you're about to build and why it matters.

Set Up Your Environment and Load the Data

To predict churn, you need two things: the right tools and the data itself. Without XGBoost and SHAP installed, you cannot train a gradient-boosted model or explain its predictions. Without 100,000 customer records loaded into memory, there is nothing to analyze.

In this step, you will install the two missing libraries, download the telecom churn dataset from Kaggle, load it into a pandas DataFrame, and take your first look at the data quality and class balance.

In this step, get ready to:
  • Install XGBoost and SHAP and verify all library imports.
  • Download and load the 100,000-record telecom churn dataset.
  • Inspect data quality and check the churn class distribution.
Install XGBoost and SHAP

Your environment already has pandas, NumPy, Matplotlib, and scikit-learn. You need two additional libraries: XGBoost for the gradient-boosted classifier, and SHAP for model explainability.

  • Install both libraries by running this command in your terminal:
pip install xgboost shap

You should see installation progress for both packages and their dependencies, ending with a success message.

Seeing an installation error?

Make sure you are using Python 3.12 or higher. XGBoost 3.3.0 and SHAP 0.52.0 both require Python 3.12+. Check your version with python3 --version.

help me fix my XGBoost or SHAP installation error

Create your notebook and import libraries

With the packages installed, you need a Jupyter Notebook to work in. This notebook will hold your entire churn prediction pipeline.

  • Open Jupyter Notebook by pressing Cmd+Space (macOS) or the Windows key (Windows) to open your search bar, then type Jupyter Notebook and press Enter.
  • Create a new Python 3 notebook by clicking New in the top right, then selecting Python 3.
  • Rename the notebook to churn_prediction.ipynb by clicking Untitled at the top left and typing the new name.
  • In the first cell, paste the following import code and run it with Shift+Enter:
# Import core data science libraries
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

# Import machine learning libraries
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report, roc_auc_score, roc_curve

# Import XGBoost and SHAP
import xgboost as xgb
import shap

# Verify key library versions
print(f"pandas: {pd.__version__}")
print(f"numpy: {np.__version__}")
print(f"xgboost: {xgb.__version__}")
print(f"shap: {shap.__version__}")

What does this code do?

  • The first group imports pandas for data manipulation, NumPy for numerical operations, and Matplotlib for plotting.
  • The second group imports scikit-learn tools you will use for the baseline model and evaluation metrics.
  • The third group imports XGBoost (the gradient-boosted classifier) and SHAP (the explainability library).
  • The version prints confirm everything installed correctly.

You should see four version numbers printed below the cell, confirming all libraries loaded successfully.

Seeing a ModuleNotFoundError?

Make sure you ran pip install xgboost shap in the same Python environment that Jupyter is using. If you use virtual environments, activate the correct one before launching Jupyter.

help me fix a ModuleNotFoundError for xgboost or shap in Jupyter

Download and load the dataset

The dataset you need contains 100,000 cleaned telecom customer records with churn labels. It is hosted on Kaggle, so you will download it manually and load it with pandas.

  • Go to the Kaggle dataset page: Telecom Customer Churn - 100k Cleaned Records.
  • Click the Download button to download the dataset ZIP file.
  • Extract the ZIP file and rename the CSV to telecom_churn.csv.
  • Move telecom_churn.csv into the same folder as your churn_prediction.ipynb notebook.

Do I need a Kaggle account?

Yes, Kaggle requires a free account to download datasets. If you do not have one, sign up at kaggle.com with your email or Google account.

  • In the next cell of your notebook, paste the following code to load the dataset and run it with Shift+Enter:
# Load the telecom churn dataset
df = pd.read_csv("telecom_churn.csv")

# Confirm the dataset loaded correctly
print(f"Dataset shape: {df.shape}")
print("\nFirst 5 rows:")
print(df.head())
print("\nDataset info:")
df.info()

What does this code do?

  • pd.read_csv() reads the CSV file into a pandas DataFrame called df.
  • df.shape prints the number of rows and columns, confirming you have 100,000 records.
  • df.head() shows the first 5 rows so you can see what the data looks like.
  • df.info() shows each column name, its data type, and non-null counts.

You should see output showing a shape of (100000, N) where N is the number of columns. The first 5 rows display customer records with columns like tenure, contract type, monthly charges, and the churn label.

Note your column names

Check the column names printed by df.info(). If your churn target column is named something other than Churn (for example, churn or Churn Label), you will need to adapt the column name in the next cell accordingly.

Seeing a FileNotFoundError?

This means pandas cannot find telecom_churn.csv in the current directory. Confirm the CSV is in the exact same folder as your notebook file. Also confirm you renamed the file correctly after extracting the ZIP.

help me fix a FileNotFoundError when loading my CSV in Jupyter

Inspect data quality and class balance

Before building any model, you need to know two things: are there missing values that could break your pipeline, and how imbalanced is the churn target. Class imbalance is the single biggest pitfall in churn prediction.

  • In the next cell, paste the following inspection code and run it with Shift+Enter:
# Check for missing values across all columns
print("Missing values per column:")
print(df.isnull().sum())

# Check churn class distribution
print("\nChurn class distribution:")
print(df["Churn"].value_counts())

# Calculate percentage of churners vs non-churners
churn_rate = df["Churn"].value_counts(normalize=True) * 100
print(f"\nChurners: {churn_rate.iloc[1]:.1f}%")
print(f"Non-churners: {churn_rate.iloc[0]:.1f}%")

What does this code do?

  • df.isnull().sum() counts missing values in every column so you know if any data cleaning is needed.
  • value_counts() shows how many customers churned vs stayed.
  • The percentage calculation reveals the class imbalance. You will likely see that churners make up a minority of the dataset.

Adapt the column name if needed

If your target column is not named Churn, replace "Churn" in the code above with the actual column name you saw in the df.info() output from the previous cell.

Before you run this cell, what percentage of customers do you think churned? Make a prediction, then run the cell.

You should see the non-churners vastly outnumber the churners. This imbalance is what will trip up your first model in Step 3. Remember this number.

Why does class imbalance matter?

When only a small percentage of customers churn, a model can achieve high overall accuracy simply by predicting "no churn" for everyone. It looks smart on paper, but it misses the actual churners. The whole point of this project is to catch those churners.

You will see this problem firsthand in Step 3 when the baseline Logistic Regression misses nearly half of all churners.

✔️ Awesome, I've got everything!

Great. Double check that your notebook has three cells, each running without errors, and that you saved churn_prediction.ipynb.

ⓧ I'd like to double check the full code

Your notebook should have three cells in order. Scroll up and confirm each one is present:

  • Cell 1: All imports (pandas, numpy, matplotlib, sklearn, xgboost, shap) and version prints.
  • Cell 2: pd.read_csv("telecom_churn.csv") with df.shape, df.head(), and df.info() prints.
  • Cell 3: df.isnull().sum(), churn value_counts(), and percentage calculation.

Your environment is ready and you have 100,000 customer records loaded. Next up, you will explore this data visually and engineer features that help the model distinguish churners from loyal customers.

Explore the Data and Engineer Features

You have 100,000 rows loaded, but raw data is not model-ready. Before training anything, you need to understand the patterns hiding in the data and transform those patterns into features a model can learn from.

A new customer paying high monthly charges is at higher risk than a long-tenured one paying the same amount. That kind of business logic does not exist as a single column in the raw data. In this step, you will visualize the key churn drivers, then engineer new features that encode domain knowledge the model cannot discover on its own.

In this step, get ready to:
  • Visualize churn patterns by contract type, tenure, and feature correlations.
  • Engineer three new features that encode business knowledge.
  • Encode categorical variables and split data into train/test sets.
Visualize churn patterns

Exploratory data analysis reveals which features matter most before you build a model. You will plot churn rates across contract types and tenure buckets, then look at how all numeric features correlate with each other.

Quick note on column names

The code below uses the standard column names for this dataset: Contract, tenure, MonthlyCharges, Churn.

If your column names differ slightly, check the output from df.head() you ran in Step 1 and adjust accordingly.

  • Create a new cell in your notebook and plot churn rate by contract type and tenure buckets side by side:
# Ensure Churn is numeric for calculations
if df['Churn'].dtype == 'object':
    df['Churn'] = df['Churn'].map({'Yes': 1, 'No': 0})

# Plot churn rate by contract type and tenure buckets
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# Churn rate by contract type
churn_by_contract = df.groupby('Contract')['Churn'].mean()
axes[0].bar(churn_by_contract.index, churn_by_contract.values,
            color=['#2ecc71', '#3498db', '#e74c3c'])
axes[0].set_title('Churn Rate by Contract Type')
axes[0].set_xlabel('Contract Type')
axes[0].set_ylabel('Churn Rate')

# Churn rate by tenure buckets
df['tenure_bucket'] = pd.cut(df['tenure'],
    bins=[0, 6, 12, 24, 48, 72],
    labels=['0-6', '7-12', '13-24', '25-48', '49-72'])
churn_by_tenure = df.groupby('tenure_bucket')['Churn'].mean()
axes[1].bar(churn_by_tenure.index, churn_by_tenure.values, color='#e74c3c')
axes[1].set_title('Churn Rate by Tenure Bucket')
axes[1].set_xlabel('Tenure (months)')
axes[1].set_ylabel('Churn Rate')

plt.tight_layout()
plt.show()

What does this code do?

  • The first line converts Churn from Yes/No strings to 1/0 integers so you can calculate averages.
  • The left chart shows churn rate per contract type. Month-to-month customers churn far more than those on annual or two-year contracts.
  • The right chart groups customers into tenure buckets. New customers (0-6 months) have the highest churn rate.

Run the cell. You should see two bar charts side by side showing that month-to-month contracts and short tenure have the highest churn rates.

Not seeing any bars?

  • Check that your Contract column name matches exactly. Run df.columns.tolist() to see all column names.
  • If Churn was already numeric, the map line is safely skipped. But if values are something other than Yes/No (like True/False), adjust the mapping.
  • help me debug my churn bar charts
  • Create another new cell and plot a correlation heatmap of all numeric features:
# Correlation heatmap of numeric features
numeric_cols = df.select_dtypes(include=[np.number])
correlation_matrix = numeric_cols.corr()

plt.figure(figsize=(10, 8))
plt.imshow(correlation_matrix, cmap='coolwarm', aspect='auto')
plt.colorbar(label='Correlation')
plt.xticks(range(len(correlation_matrix.columns)),
           correlation_matrix.columns, rotation=45, ha='right')
plt.yticks(range(len(correlation_matrix.columns)),
           correlation_matrix.columns)
plt.title('Correlation Heatmap of Numeric Features')
plt.tight_layout()
plt.show()

print("Top correlations with Churn:")
print(correlation_matrix['Churn'].sort_values(ascending=False))

What does this code do?

  • select_dtypes(include=[np.number]) filters to only numeric columns so the correlation calculation works.
  • The heatmap uses a coolwarm colormap where red means strong positive correlation and blue means strong negative.
  • Printing correlations with Churn sorted by value reveals which features most strongly predict churn.

Run the cell. You should see a color-coded heatmap and a printed list showing that tenure (negative correlation) and MonthlyCharges (positive correlation) have the strongest relationship with churn.

Heatmap looks empty or labels overlap?

  • If only a few columns appear, you may still have categorical columns encoded as numbers. That is fine for now.
  • If labels are cut off, increase figsize to (12, 10).
  • help me fix my correlation heatmap display
Engineer new features

Raw columns tell the model what happened, but engineered features tell it what that means. A customer with 2 months tenure paying $80/month is very different from one with 60 months tenure paying the same amount.

  • Create a new cell and engineer three features that encode domain knowledge:
# Engineer features that capture business logic

# High ratio = new customer paying a lot (higher risk)
df['charge_per_tenure'] = df['MonthlyCharges'] / (df['tenure'] + 1)

# Count active services per customer
service_columns = ['PhoneService', 'OnlineSecurity', 'OnlineBackup',
                   'DeviceProtection', 'TechSupport', 'StreamingTV',
                   'StreamingMovies']
df['services_count'] = df[service_columns].apply(
    lambda row: (row == 'Yes').sum(), axis=1)

# Flag customers in their first 6 months
df['is_new_customer'] = (df['tenure'] <= 6).astype(int)

# Verify the new features
df_engineered = df.copy()
print(df_engineered[['charge_per_tenure', 'services_count',
                     'is_new_customer']].describe())
print(f"\nEngineered dataframe shape: {df_engineered.shape}")

What do these features capture?

  • charge_per_tenure: divides monthly charges by tenure + 1. A high value flags new customers on expensive plans who have not yet developed loyalty.
  • services_count: counts how many add-on services (security, backup, streaming) the customer uses. Customers with fewer services have less reason to stay.
  • is_new_customer: a binary flag for tenure of 6 months or less. Your EDA just showed that this group churns the most.

Run the cell. You should see summary statistics for all three new columns, confirming they have sensible ranges and no NaN values.

Seeing all zeros for services_count?

  • The service columns may already be encoded as 1/0 instead of Yes/No. If so, change (row == 'Yes').sum() to (row == 1).sum().
  • Check one service column with df['PhoneService'].unique() to see what values it actually contains.
  • help me fix my services_count feature engineering
Encode categories and split into train/test

Machine learning models need numeric inputs. You need to convert all remaining categorical columns into numbers, then split the data so you can train on one portion and test on another.

  • Create a new cell to encode categorical variables and perform the train/test split:
from sklearn.model_selection import train_test_split

# Drop ID column and the temporary tenure_bucket column
df_engineered = df_engineered.drop(
    columns=['customerID', 'tenure_bucket'], errors='ignore')

# One-hot encode all remaining categorical columns
df_encoded = pd.get_dummies(df_engineered, drop_first=True)

# Separate features (X) from target (y)
y = df_encoded['Churn']
X = df_encoded.drop(columns=['Churn'])

print(f"Feature matrix shape: {X.shape}")
print(f"Target vector shape: {y.shape}")
print(f"Churn distribution in target:\n{y.value_counts(normalize=True)}")

What does this code do?

  • Dropping customerID removes the identifier that has no predictive value. Dropping tenure_bucket removes the temporary column you created for visualization.
  • pd.get_dummies() converts every categorical column into binary 0/1 columns (one per category). The drop_first=True avoids multicollinearity by dropping one category per feature.
  • X is the feature matrix (everything except Churn) and y is the target vector.

Run the cell. You should see the feature matrix shape (100000 rows with many columns from encoding) and the churn distribution confirming the class imbalance you noted earlier.

  • Create one more cell to split into training and test sets:
# Split 80% train, 20% test with stratification
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y)

print(f"Training set: {X_train.shape[0]} rows")
print(f"Test set: {X_test.shape[0]} rows")
print(f"\nTrain churn rate: {y_train.mean():.3f}")
print(f"Test churn rate: {y_test.mean():.3f}")
print(f"\ndf_engineered.shape: {df_engineered.shape}")

Why stratify=y?

When you have imbalanced classes (fewer churners than non-churners), a random split might give you more churners in one set by chance. Using stratify=y ensures both train and test sets preserve the same churn percentage as the original data. This gives you a fair evaluation later.

Before you run this cell, what do you expect the train and test churn rates to be relative to each other?

Run the cell. Both churn rates should be nearly identical (matching the overall dataset imbalance), confirming stratification worked. You should also see 80,000 training rows and 20,000 test rows.

Getting a KeyError or shape mismatch?

  • If you see a KeyError on Churn, your Churn column may have been dropped or renamed during encoding. Run df_encoded.columns.tolist() to check.
  • If get_dummies created a Churn_1 column instead, your Churn was treated as categorical. Go back and confirm the Churn-to-numeric mapping ran successfully.
  • help me fix my train/test split error

✔️ Awesome, I've got everything!

Great. Double check you have saved your notebook with all cells run in order.

ⓧ I'd like to double check the full code

Scroll up through your notebook and confirm you have the following cells present and in order:

  • Cell 1: The bar chart cell plotting churn by contract type and tenure buckets (with the Churn-to-numeric conversion at the top).
  • Cell 2: The correlation heatmap cell with the sorted churn correlations printed below.
  • Cell 3: The feature engineering cell creating charge_per_tenure, services_count, and is_new_customer.
  • Cell 4: The encoding cell with pd.get_dummies() creating X and y.
  • Cell 5: The train/test split cell printing 80,000 training rows, 20,000 test rows, and matching churn rates.

Your data is explored, engineered, and split. Next up, you will train a baseline model and watch it struggle with the class imbalance you just quantified.

Train a Baseline Model and Expose the Class Imbalance Problem

Your data is engineered, encoded, and split into training and test sets. Now it's time to build your first churn prediction model.

Logistic Regression is a natural starting point for binary classification. It trains fast, produces interpretable coefficients, and gives you a performance floor to beat. The real question: will a straightforward classifier handle a dataset where only a fraction of customers actually churn?

In this step, get ready to:
  • Train a Logistic Regression baseline on 100,000 customer records.
  • Evaluate its performance with a classification report and AUC-ROC score.
  • Identify where the model falls short on predicting churners.
Train a Logistic Regression baseline

Logistic Regression is one of the simplest classifiers for a yes/no problem like churn prediction. Training it first gives you a concrete number to compare against when you bring in more powerful models later.

  • Train a Logistic Regression model and generate predictions on your test set by adding this cell to your notebook:
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report, roc_auc_score

# Train a Logistic Regression as the baseline model
lr_model = LogisticRegression(max_iter=1000)
lr_model.fit(X_train, y_train)

# Generate predictions and probability scores on the test set
y_pred_lr = lr_model.predict(X_test)
y_prob_lr = lr_model.predict_proba(X_test)[:, 1]

print("Logistic Regression trained successfully!")
print(f"Predictions generated for {len(y_pred_lr)} test samples")

What does this code do?

  • LogisticRegression(max_iter=1000) creates a classifier with enough iterations to converge on a large dataset.
  • .fit(X_train, y_train) trains the model on 80% of the data.
  • .predict(X_test) generates hard class labels (0 = stays, 1 = churns) for each test customer.
  • .predict_proba(X_test)[:, 1] gets the probability of churn for each customer. You need this for AUC-ROC scoring.
  • Run the cell.
  • You should see "Logistic Regression trained successfully!" and the number of test samples (around 20,000) printed.

Seeing a ConvergenceWarning?

This means the model did not fully converge within 1000 iterations. Check that your feature matrix X_train contains only numeric columns (no leftover string or object columns from encoding). You can verify with X_train.dtypes in a new cell.

Help me debug a ConvergenceWarning in my Logistic Regression training

Evaluate the model and expose the class imbalance problem

The model trained without errors. Before you look at the results, take a moment to form a prediction of your own.

If the model achieves around 80% overall accuracy, does that mean it is good at catching churners specifically? What do you think the recall on the churn class will be?

  • Evaluate your baseline model by adding this cell:
# Print the full classification report
print("Classification Report - Logistic Regression")
print("=" * 50)
print(classification_report(y_test, y_pred_lr))

# Compute AUC-ROC score
auc_score_lr = roc_auc_score(y_test, y_prob_lr)
print(f"\nAUC-ROC Score: {auc_score_lr:.4f}")
  • Run the cell.

Look at the classification report output. The overall accuracy reads around 80%, which sounds respectable. Now look at the recall for class 1 (churn). It sits at only 50-55%.

This means the model misses nearly half of the customers who actually churn. The AUC-ROC score lands around 0.75-0.80, confirming the model struggles to separate churners from non-churners.

Why does 80% accuracy hide a serious problem?

This is the class imbalance problem in action. When most customers (roughly 74%) do NOT churn, a model can achieve 74% accuracy just by predicting "no churn" for everyone. The 80% accuracy is barely better than that lazy strategy.

For a retention team, recall on the churn class is what matters. A model that misses half the churners means half your at-risk customers leave without anyone reaching out. That is a direct revenue loss.

What does recall mean here?

Recall for the churn class answers: "Of all the customers who actually churned, what percentage did the model correctly identify?" A recall of 0.50 means the model only catches 50 out of every 100 actual churners. The other 50 slip through undetected.

Seeing very different numbers?

If your recall is significantly higher (above 0.70) or your accuracy is below 0.70, double-check that your train/test split used stratify=y in the previous step. Without stratification, the class balance in your test set may differ from the training set, causing unpredictable results.

Help me understand why my Logistic Regression metrics look different than expected

✔️ Awesome, I've got everything!

Great. Make sure your notebook is saved with both cells and their output visible.

ⓧ I'd like to double check the full code

Here are both cells you added in this step. Confirm each one is present in your notebook, in order.

from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report, roc_auc_score

# Train a Logistic Regression as the baseline model
lr_model = LogisticRegression(max_iter=1000)
lr_model.fit(X_train, y_train)

# Generate predictions and probability scores on the test set
y_pred_lr = lr_model.predict(X_test)
y_prob_lr = lr_model.predict_proba(X_test)[:, 1]

print("Logistic Regression trained successfully!")
print(f"Predictions generated for {len(y_pred_lr)} test samples")
# Print the full classification report
print("Classification Report - Logistic Regression")
print("=" * 50)
print(classification_report(y_test, y_pred_lr))

# Compute AUC-ROC score
auc_score_lr = roc_auc_score(y_test, y_prob_lr)
print(f"\nAUC-ROC Score: {auc_score_lr:.4f}")

You have now witnessed the class imbalance problem firsthand. A model that looks decent on paper is actually failing at the one job that matters: catching customers about to leave. In the next step, you will fix this with XGBoost and its built-in imbalance handling.

Train XGBoost and Handle Class Imbalance

Your Logistic Regression baseline revealed the core problem: it misses nearly half the customers who are about to churn. A retention team using that model would let half their at-risk customers walk away uncontacted.

The fix is XGBoost with its scale_pos_weight parameter. This parameter penalizes missed churners more heavily during training, and XGBoost's tree-based structure captures non-linear interactions between features like contract type and tenure that Logistic Regression cannot model.

In this step, get ready to:
  • Train an XGBoost classifier with class imbalance handling.
  • Compare XGBoost against your baseline with side-by-side metrics and ROC curves.
  • Tune the decision threshold to maximize churn detection.
Calculate the imbalance ratio and train XGBoost

The scale_pos_weight parameter tells XGBoost how much more to penalize missed positive cases (churners) relative to missed negatives. The official XGBoost documentation recommends setting it to sum(negative instances) / sum(positive instances).

  • In a new cell in your notebook, calculate the imbalance ratio and train the XGBoost model by running this code:
# Calculate the class imbalance ratio
ratio = (y_train == 0).sum() / (y_train == 1).sum()
print(f"Scale pos weight: {ratio:.2f}")

You should see a ratio printed (likely around 2.5-3.5, depending on your dataset's churn rate).

  • In the next cell, train the XGBoost classifier using this ratio:
from xgboost import XGBClassifier

# Train XGBoost with class imbalance handling
xgb_model = XGBClassifier(
    scale_pos_weight=ratio,
    eval_metric='auc',
    random_state=42
)
xgb_model.fit(X_train, y_train)
print("XGBoost model trained successfully.")

What does this code do?

The scale_pos_weight=ratio parameter makes XGBoost treat each missed churner as being worth ratio times more than a missed non-churner. This forces the model to work harder to correctly identify churners rather than defaulting to the majority class.

Setting eval_metric='auc' tells XGBoost to optimize for ranking quality (AUC-ROC) during training. The random_state=42 ensures reproducible results.

Model training taking too long?

On 100,000 rows, training should complete within 10-30 seconds. If it's significantly longer, check that your feature matrix X_train doesn't contain non-numeric columns (strings or objects) by running X_train.dtypes.

help me debug why my XGBoost model is taking too long to train

Evaluate XGBoost and compare with the baseline

Now that the model is trained, you need to measure how much it improved over the Logistic Regression baseline. The key metric to watch is recall on the churn class (class 1).

  • Generate predictions and print the XGBoost classification report by running this cell:
# Generate predictions and probabilities on the test set
y_pred_xgb = xgb_model.predict(X_test)
y_prob_xgb = xgb_model.predict_proba(X_test)[:, 1]

# Print XGBoost performance metrics
print("XGBoost Classification Report:")
print(classification_report(y_test, y_pred_xgb))
print(f"XGBoost AUC-ROC: {roc_auc_score(y_test, y_prob_xgb):.4f}")

Before you run this cell, think: do you expect churn recall to be higher or lower than the ~50-55% from Logistic Regression?

You should see recall on the churn class jump to approximately 0.75 or higher, and AUC-ROC reach around 0.85. That is a significant improvement over the baseline.

What does this tell us?

The predict_proba(X_test)[:, 1] extracts the probability of class 1 (churn) for each customer. These probabilities are what you will later use for risk scoring and threshold tuning.

An AUC-ROC of ~0.85 means the model ranks a randomly chosen churner higher than a randomly chosen non-churner 85% of the time.

  • Print a side-by-side comparison of both models by running this cell:
# Side-by-side comparison of Logistic Regression vs XGBoost
print("=" * 50)
print("MODEL COMPARISON")
print("=" * 50)
lr_auc = roc_auc_score(y_test, y_prob_lr)
xgb_auc = roc_auc_score(y_test, y_prob_xgb)
lr_recall = classification_report(y_test, y_pred_lr, output_dict=True)['1']['recall']
xgb_recall = classification_report(y_test, y_pred_xgb, output_dict=True)['1']['recall']
print(f"\nLogistic Regression AUC-ROC: {lr_auc:.4f}")
print(f"XGBoost AUC-ROC:             {xgb_auc:.4f}")
print(f"\nLogistic Regression Churn Recall: {lr_recall:.4f}")
print(f"XGBoost Churn Recall:             {xgb_recall:.4f}")

You should see the XGBoost AUC-ROC about 0.05-0.10 higher than Logistic Regression, and churn recall jump by roughly 20+ percentage points.

Why compare using both AUC-ROC and recall?

AUC-ROC measures the model's overall ranking ability across all thresholds. Recall on the churn class measures how many actual churners the model catches at the default 0.5 threshold.

A retention team cares more about recall. Missing a churner means losing a customer forever. A false alarm (predicting churn on someone who stays) just means making an unnecessary retention offer.

  • Visualize the improvement by plotting both ROC curves on the same chart:
from sklearn.metrics import roc_curve

# Plot both ROC curves on the same chart
fpr_lr, tpr_lr, _ = roc_curve(y_test, y_prob_lr)
fpr_xgb, tpr_xgb, _ = roc_curve(y_test, y_prob_xgb)

plt.figure(figsize=(8, 6))
plt.plot(fpr_lr, tpr_lr, label=f'Logistic Regression (AUC = {lr_auc:.4f})')
plt.plot(fpr_xgb, tpr_xgb, label=f'XGBoost (AUC = {xgb_auc:.4f})')
plt.plot([0, 1], [0, 1], 'k--', label='Random')
plt.xlabel('False Positive Rate')
plt.ylabel('True Positive Rate')
plt.title('ROC Curve Comparison')
plt.legend()
plt.show()

You should see the XGBoost curve sitting noticeably above the Logistic Regression curve, especially in the left portion of the plot. The dashed diagonal line represents a random classifier with no predictive power.

Both curves look identical?

Check that you used y_prob_xgb (probabilities) and not y_pred_xgb (binary predictions) in the roc_curve() call. ROC curves need continuous probability scores, not 0/1 labels.

help me debug why my ROC curves look the same for both models

Tune the decision threshold

By default, the model predicts churn when the probability exceeds 0.5. But 0.5 is not always the best threshold for imbalanced data.

A lower threshold catches more churners but generates more false alarms. A higher threshold misses churners but reduces false positives. This is a business decision about the cost trade-off between losing a customer versus making an unnecessary retention offer.

  • Test thresholds from 0.3 to 0.6 and find the one that maximizes the F1-score for the churn class:
from sklearn.metrics import f1_score
import numpy as np

# Test thresholds from 0.3 to 0.6
thresholds = np.arange(0.3, 0.61, 0.05)
f1_scores = []

for thresh in thresholds:
    preds = (y_prob_xgb >= thresh).astype(int)
    f1 = f1_score(y_test, preds, pos_label=1)
    f1_scores.append(f1)
    print(f"Threshold: {thresh:.2f} | F1 (churn): {f1:.4f}")

You should see a table of thresholds with their F1-scores. The F1-score balances precision and recall into a single number, peaking at the optimal trade-off point.

  • Select the best threshold and generate tuned predictions:
# Select the threshold with the highest F1-score
best_threshold = thresholds[np.argmax(f1_scores)]
print(f"Best threshold: {best_threshold:.2f}")

# Generate final predictions using the optimal threshold
y_pred_tuned = (y_prob_xgb >= best_threshold).astype(int)
print(f"\nTuned Classification Report:")
print(classification_report(y_test, y_pred_tuned))

Before you run this, predict: will the best threshold be above or below 0.5?

You should see best_threshold land somewhere between 0.35 and 0.50. The tuned classification report shows a better balance between precision and recall on the churn class than the default 0.5 cutoff.

Why not just always use 0.3?

A threshold of 0.3 catches almost every churner but floods the retention team with false alarms. If retention campaigns cost money (phone calls, discount offers), too many false positives waste budget.

The F1-score finds the sweet spot where you catch the most churners without overwhelming the team. In production, you would adjust this based on the actual cost of a retention call versus the revenue lost from churn.

All F1-scores look the same?

Make sure you are using y_prob_xgb (continuous probabilities between 0 and 1) and not y_pred_xgb (binary 0/1 predictions). Thresholding binary predictions produces the same result at every threshold.

help me debug why my threshold tuning produces identical F1-scores at every threshold

✔️ Awesome, I've got everything!

Great. Make sure you have saved your notebook with all cells run in order.

ⓧ I'd like to double check the full code

Scroll up and confirm each of these cells is present in your notebook, in order:

  • The imbalance ratio calculation cell printing Scale pos weight.
  • The XGBoost training cell creating xgb_model.
  • The predictions cell generating y_pred_xgb and y_prob_xgb, plus the classification report.
  • The side-by-side model comparison cell.
  • The ROC curve comparison plot.
  • The threshold tuning loop cell.
  • The best threshold selection and y_pred_tuned cell.

Your XGBoost model now catches significantly more churners than the baseline, and you have a tuned threshold that balances detection with false alarms. Next up, you will use SHAP to explain exactly why each customer is predicted to churn so a retention team knows what action to take.

Explain Predictions with SHAP and Export Retention Report

Your XGBoost model now catches far more churners than the baseline. But a churn score alone is not useful to a retention team.

The retention team needs to know why each customer is at risk so they can take the right action. SHAP connects your model's predictions to specific, actionable drivers that explain each decision.

In this step, get ready to:
  • Generate global SHAP explanations showing the top churn drivers across all customers.
  • Explain a single high-risk customer's prediction with a waterfall plot.
  • Build and export a retention report with risk tiers and recommendations.
Create a SHAP explainer and compute SHAP values

SHAP's TreeExplainer is optimized specifically for tree-based models like XGBoost. It computes how much each feature contributes to pushing a prediction higher or lower.

  • In a new cell in churn_prediction.ipynb, create a SHAP TreeExplainer and compute SHAP values by running this code:
# Create a SHAP explainer optimized for tree-based models
explainer = shap.TreeExplainer(xgb_model)

# Sample 1000 rows from the test set for speed
X_test_sample = X_test.sample(1000, random_state=42)

# Compute SHAP values (returns an Explanation object)
shap_values = explainer(X_test_sample)

print(f"SHAP values shape: {shap_values.shape}")
print("SHAP computation complete.")

What does this code do?

shap.TreeExplainer(xgb_model) builds an explainer that understands how XGBoost's decision trees split data. It uses Shapley values from game theory to compute exact feature contributions for each prediction.

Sampling 1000 rows keeps computation fast. Computing SHAP values on all 20,000 test rows would work but take significantly longer.

  • Run the cell. You should see output showing the shape of your SHAP values (1000 rows by number of features) and a completion message.

Seeing a slow computation or memory error?

If SHAP is taking a very long time, reduce the sample to 500 rows: X_test_sample = X_test.sample(500, random_state=42).

If you see an import error, confirm you installed SHAP earlier with pip install shap and restarted your kernel.

Why is my SHAP computation failing?

Generate the global summary plot and a single-customer waterfall

A summary plot reveals which features matter most across all predictions. A waterfall plot zooms into one customer and shows the exact math behind their risk score.

  • In the next cell, generate the global beeswarm summary plot by running:
# Global feature importance: beeswarm summary plot
# Each dot is one customer, color shows feature value (red=high, blue=low)
shap.summary_plot(shap_values, X_test_sample, max_display=10)

How do I read the beeswarm plot?

Each row is a feature. Each dot is one customer. Position on the x-axis shows how much that feature pushed the prediction toward churn (right) or away from it (left).

Dot color shows the feature's actual value for that customer (red = high, blue = low). You should see contract type, tenure, and monthly charges near the top.

  • Run the cell. A beeswarm plot appears showing the top 10 features driving churn predictions.

Before running the next cell, think about what you expect. For one specific high-risk customer, which features do you think push them toward churning?

  • Now select a high-risk customer and generate a waterfall plot by running this in the next cell:
# Find a high-risk customer from our sample
high_risk_idx = (shap_values.values.sum(axis=1)).argmax()

print(f"Selected customer index: {high_risk_idx}")
print(f"Top feature contributions for this customer:")

# Waterfall plot showing per-feature contributions for one customer
shap.plots.waterfall(shap_values[high_risk_idx])

What does the waterfall show?

The waterfall starts at the model's baseline prediction (average) and shows each feature pushing the final score up or down. This is what a retention agent would see.

For example: "This customer is at risk because they have a month-to-month contract AND high monthly charges AND only 3 months tenure."

  • Run the cell. You should see a waterfall chart with red bars (features pushing toward churn) and blue bars (features pushing away from churn) stacking from the base value to the final prediction.

Waterfall plot not rendering?

If you see a blank output, add import matplotlib.pyplot as plt at the top of the cell and plt.tight_layout() after the plot call. Some Jupyter environments need explicit rendering triggers.

Help me display SHAP waterfall plots in Jupyter

Build the retention report and export to CSV

Now you will turn raw predictions into something a retention team can act on. This means assigning risk tiers and mapping each customer's top SHAP drivers to a specific recommendation.

  • In the next cell, assign risk tiers based on churn probability thresholds by running:
import numpy as np

# Get churn probabilities for all test customers
test_probs = xgb_model.predict_proba(X_test)[:, 1]

# Assign risk tiers based on probability thresholds
def assign_risk_tier(prob):
    if prob >= 0.6:
        return 'HIGH'
    elif prob >= 0.35:
        return 'MEDIUM'
    else:
        return 'LOW'

risk_tiers = [assign_risk_tier(p) for p in test_probs]

print(f"HIGH risk: {risk_tiers.count('HIGH')}")
print(f"MEDIUM risk: {risk_tiers.count('MEDIUM')}")
print(f"LOW risk: {risk_tiers.count('LOW')}")

Why these threshold values?

HIGH (>= 0.6) means the model is very confident this customer will churn. These need immediate outreach. MEDIUM (0.35 to 0.6) means elevated risk worth monitoring. LOW (< 0.35) means likely to stay.

These thresholds are a business decision. A more aggressive retention team might lower the HIGH threshold to catch more at-risk customers, accepting more false alarms.

  • Run the cell. You should see a count of customers in each risk tier printed below.
  • Next, compute SHAP values for the full test set and extract the top drivers for each customer by running:
# Compute SHAP values for the full test set
explainer_full = shap.TreeExplainer(xgb_model)
shap_values_full = explainer_full(X_test)

# Get feature names from the test set
feature_names = X_test.columns.tolist()

# Extract the top 2 drivers for each customer
top_drivers_1 = []
top_drivers_2 = []

for i in range(len(X_test)):
    # Sort features by absolute SHAP value
    abs_vals = np.abs(shap_values_full.values[i])
    sorted_idx = np.argsort(abs_vals)[::-1]
    top_drivers_1.append(feature_names[sorted_idx[0]])
    top_drivers_2.append(feature_names[sorted_idx[1]])

print(f"Computed top drivers for {len(X_test)} customers.")

What does this code do?

For each customer, it sorts the SHAP values by absolute magnitude to find which features had the biggest impact on that customer's prediction. The top two drivers become actionable intelligence for the retention team.

This may take a minute to compute across all test set customers. The progress message confirms it finished.

  • Run the cell. You should see a confirmation that top drivers were computed for all test set customers.
  • Map retention recommendations and build the final report DataFrame by running:
import pandas as pd

# Map retention recommendations based on top SHAP driver
def get_recommendation(driver):
    if 'contract' in driver.lower():
        return 'Offer annual contract discount'
    elif 'tenure' in driver.lower():
        return 'Assign dedicated account manager'
    elif 'charge' in driver.lower():
        return 'Review pricing plan options'
    elif 'service' in driver.lower():
        return 'Bundle additional services'
    else:
        return 'Schedule retention call'

recommendations = [get_recommendation(d) for d in top_drivers_1]

What does the recommendation logic do?

The function checks each customer's top SHAP driver and maps it to a specific action. If contract type is the biggest factor pushing them toward churn, the recommendation is to offer a contract discount.

This turns abstract model output into a concrete action a retention agent can take today.

  • Run the cell. No output is expected yet. The recommendations list is now ready for assembly.
  • Assemble the final retention report DataFrame by running this in the next cell:
# Build the final retention report DataFrame
retention_report = pd.DataFrame({
    'customer_id': range(len(X_test)),
    'churn_probability': test_probs,
    'risk_tier': risk_tiers,
    'top_driver_1': top_drivers_1,
    'top_driver_2': top_drivers_2,
    'recommendation': recommendations
})

print(retention_report.head(10))
print(f"\nTotal customers in report: {len(retention_report)}")
  • Run the cell. You should see the first 10 rows of the retention report with customer IDs, probabilities, tiers, drivers, and recommendations.

Before you export, predict: what columns will you see on the first line of the CSV file?

  • Export the report to a CSV file for Power BI by running this in the next cell:
# Export the retention report to CSV for Power BI
retention_report.to_csv('churn_predictions_for_powerbi.csv', index=False)

print("Exported to churn_predictions_for_powerbi.csv")
print(f"Columns: {list(retention_report.columns)}")
print(f"Rows: {len(retention_report)}")
  • Run the cell. You should see a confirmation that the file was exported, listing all 6 columns (customer_id, churn_probability, risk_tier, top_driver_1, top_driver_2, recommendation) and the total row count.
  • Confirm the CSV exists by running this in the next cell:
# Verify the exported file
import os
print(f"File exists: {os.path.exists('churn_predictions_for_powerbi.csv')}")
print(f"File size: {os.path.getsize('churn_predictions_for_powerbi.csv') / 1024:.1f} KB")
  • Run the cell. You should see File exists: True and a file size in the tens to hundreds of KB range, confirming the export worked.

CSV file not found or empty?

Make sure you ran the export cell (the one with to_csv) and that it completed without errors. If the file size shows 0 KB, the DataFrame may be empty.

Check that test_probs has values by running len(test_probs) in a new cell. If it returns 0, re-run the risk tier cell from above.

Help me fix my CSV export in pandas

✔️ Awesome, I've got everything!

Great. Double check you have saved your notebook and that churn_predictions_for_powerbi.csv exists in the same folder as your notebook.

ⓧ I'd like to double check the full code

Here are the cells you added in this step. Scroll up and confirm each one is present in your notebook, in order:

  • Cell 1: TreeExplainer creation and SHAP values computed on a 1000-row sample.
  • Cell 2: shap.summary_plot() beeswarm visualization with max_display=10.
  • Cell 3: High-risk customer selection and shap.plots.waterfall() visualization.
  • Cell 4: Risk tier assignment (HIGH/MEDIUM/LOW) with tier counts printed.
  • Cell 5: Full test set SHAP values and top driver extraction loop.
  • Cell 6: Recommendation mapping function.
  • Cell 7: Retention report DataFrame assembly with retention_report.head(10) output.
  • Cell 8: CSV export to churn_predictions_for_powerbi.csv.
  • Cell 9: File existence verification.

You now have a complete churn intelligence system. Every prediction comes with a reason and a recommended action. The exported CSV is ready for dashboard visualization in Power BI.

Secret mission

Customer Segmentation with K-Means

Your churn model treats all high-risk customers the same. But a price-sensitive new customer needs a completely different retention offer than a long-tenured customer frustrated by service issues. Can you use K-Means clustering to discover distinct behavioral groups and personalize retention strategies per segment?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your files and packages in place, or delete them entirely.

Resources you used:

  • churn_prediction.ipynb (your Jupyter Notebook with all code cells).
  • telecom_churn.csv (the raw 100k-record Kaggle dataset).
  • churn_predictions_for_powerbi.csv (exported predictions with risk tiers, SHAP drivers, and segments).
  • XGBoost and SHAP Python packages (installed via pip).

Keep everything running

No action needed. Choose this if you plan to continue into Project 2 (Power BI dashboard) or want to experiment further with your model.

  • Your notebook, dataset, and exported CSV stay where they are.
  • XGBoost and SHAP remain installed in your Python environment.

Pause - I'll come back to this later

Shut down Jupyter Notebook to free up memory. Your files stay on disk so you can resume any time.

  • In the terminal where Jupyter is running, press Ctrl+C to stop the notebook server.
  • Confirm the shutdown when prompted.
  • Your files and installed packages remain in place for when you return.

Delete - I don't want to use this again

Remove all project files and uninstall the packages you added during this project.

  • Stop Jupyter Notebook by pressing Ctrl+C in the terminal where it is running.
  • Delete the project files by running these commands in your terminal (navigate to the folder containing your notebook first):

macOS

rm churn_prediction.ipynb telecom_churn.csv churn_predictions_for_powerbi.csv

Windows

del churn_prediction.ipynb, telecom_churn.csv, churn_predictions_for_powerbi.csv
  • Uninstall the packages you installed for this project by running:
pip uninstall xgboost shap -y
  • This removes XGBoost and SHAP from your Python environment. Your other packages (pandas, scikit-learn, Matplotlib, NumPy) were already installed before this project and remain untouched.

Nice Work!

Nice Work!

You just built a complete churn prediction intelligence system on 100,000 telecom customers. From raw data to explainable, actionable retention recommendations, this is the kind of end-to-end ML pipeline that data science teams ship in production.

Here's what you accomplished:

  • Experienced the class imbalance problem firsthand with Logistic Regression, then fixed it with XGBoost to reach ~0.85 AUC-ROC on real telecom data.
  • Used SHAP to explain every single prediction with per-customer feature contributions that a retention team can act on immediately.
  • Created a risk-tiered retention report with tailored recommendations and exported a Power BI-ready CSV for dashboard visualization.
  • Secret Mission: Segmented customers into behavioral clusters using K-Means for personalized retention strategies per segment.

Ready to quiz yourself?