Predict Customer Churn with Python

Build a churn prediction pipeline using logistic regression and random forest.

Introduction

30 Second Summary

Every subscription service you use tracks whether you're about to cancel. Streaming platforms, phone carriers, and gyms all use data to predict which customers are on their way out so they can step in before it's too late.

In this project, you will build a complete churn prediction pipeline using Python and scikit-learn. You will take a real-world telecom dataset and transform it into trained machine learning models that predict which customers are most likely to leave.

What You'll Build

A Jupyter Notebook containing an end-to-end machine learning pipeline that takes raw customer data, trains two competing classification models, and produces a business recommendation on which model to deploy.

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

  • A cleaned and feature-engineered dataset derived from a real-world telecom dataset with 7,043 customer records.
  • Two trained classification models (Logistic Regression and Random Forest) evaluated with accuracy, F1-score, and ROC-AUC metrics.
  • Visualizations and a trade-off analysis comparing model performance and recommending which model to deploy in production.
  • Secret Mission: Add an XGBoost model and build a customer risk-scoring system that ranks every customer by churn probability.

Are there any prerequisites?

This project assumes you're comfortable with Python basics and have used pandas, NumPy, and matplotlib before. You should have PyCharm installed with Jupyter Notebook support.

All required libraries except seaborn should already be installed. You will add seaborn in Step 1.

Before We Start

Before We Start...

Set Up Your Project and Install Dependencies

Your churn prediction pipeline depends on several Python libraries working together. If any of them are missing or misconfigured, you will hit import errors halfway through your analysis.

In this step, you will create a clean project workspace in PyCharm, install the one library you are missing, and verify that everything loads correctly in a Jupyter Notebook.

In this step, get ready to:
  • Create a project folder and Jupyter Notebook in PyCharm.
  • Install seaborn for data visualization.
  • Import and verify all libraries in your first notebook cell.
Create your project folder and notebook
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type PyCharm and press Enter to open it.
  • Click File in the top menu bar.
  • Click New Project.
  • In the Location field, update the path so the project is saved on your Desktop. Set the folder name to churn-prediction.
  • Click Create.

Finding the right path

The path should end with Desktop/churn-prediction. On macOS this looks like /Users/yourname/Desktop/churn-prediction. On Windows it looks like C:\Users\yourname\Desktop\churn-prediction.

Now create the notebook file inside your new project.

  • In the Project panel on the left, right-click on the churn-prediction folder.
  • Select New, then Jupyter Notebook.
  • Type churn_analysis as the file name and press Enter.

You should see churn_analysis.ipynb open in the editor with an empty code cell ready for input.

Don't see the Jupyter Notebook option?

The Jupyter Notebook option is only available in PyCharm Professional. If you are using the Community Edition, you will not see it in the New menu.

Help me set up Jupyter Notebook support in PyCharm

Install seaborn

You already have pandas, NumPy, matplotlib, and scikit-learn installed. The one missing piece is seaborn, a visualization library that builds on matplotlib to create statistical charts with less code.

  • Click Terminal at the bottom of the PyCharm window to open the built-in terminal.
  • Install seaborn by running this command:
pip install seaborn

You should see Successfully installed seaborn in the terminal output. If seaborn was already installed, you will see Requirement already satisfied instead. Both outcomes mean you are ready to continue.

Seeing an installation error?

Make sure you are using the terminal inside PyCharm, not an external terminal. PyCharm's built-in terminal uses the correct Python interpreter for your project.

If you see a permission error, try running the install with the --user flag:

pip install --user seaborn

Help me fix my seaborn installation error in PyCharm

Import your libraries and verify the setup

Your notebook needs every library loaded before you can use it. This first cell imports everything you will need for the entire project and prints version numbers to confirm nothing is missing.

  • Click on the empty code cell in churn_analysis.ipynb.
  • Copy the following code into the cell:
# Cell 1: Imports and Setup
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report, roc_auc_score, roc_curve

print(f"pandas version: {pd.__version__}")
import sklearn
print(f"scikit-learn version: {sklearn.__version__}")

What does this code do?

  • pandas and numpy handle data loading, cleaning, and manipulation.
  • matplotlib and seaborn create the charts and visualizations you will build throughout the project.
  • The sklearn imports bring in tools for splitting data, scaling features, training two different models, and measuring how well they perform.
  • The two print() statements output version numbers so you can confirm everything loaded correctly.
  • Run the cell by pressing Shift+Enter.

You should see two lines of output showing the pandas and scikit-learn version numbers. No errors means every library loaded successfully.

Seeing a ModuleNotFoundError?

If the error mentions seaborn, go back to the previous substep and confirm the install succeeded.

If the error mentions sklearn, your notebook may be using a different Python interpreter than the one where scikit-learn is installed. Check the interpreter setting in the bottom-right corner of PyCharm.

Help me fix my ModuleNotFoundError in my Jupyter Notebook

✔️ Awesome, I've got everything!

Great. Double check you have saved your notebook before moving on.

ⓧ I'd like to double check the full code

Here is the complete code for Cell 1. Make sure your cell matches this exactly.

# Cell 1: Imports and Setup
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report, roc_auc_score, roc_curve

print(f"pandas version: {pd.__version__}")
import sklearn
print(f"scikit-learn version: {sklearn.__version__}")

Your workspace is ready and all libraries are loaded. Next up, you will load the customer churn dataset and start cleaning it for analysis.

Load and Clean the Dataset

Your notebook is set up with all the libraries you need. Now it's time to bring in the data.

In this step, you'll load a real-world telecom dataset and clean it for analysis. Raw datasets almost always have issues like missing values and inconsistent data types that need fixing before modeling.

In this step, get ready to:
  • Load the IBM Telco Customer Churn dataset from a public URL.
  • Clean problematic columns and convert data types.
  • Verify the dataset is ready for analysis.
Load the dataset from GitHub

The IBM Telco Customer Churn dataset contains records for 7,043 telecom customers. Each row includes details about demographics, services, contract type, billing, and whether the customer churned.

  • In churn_analysis.ipynb, create a new cell below Cell 1 and paste the following code:
# Cell 2: Load the Dataset
url = "https://raw.githubusercontent.com/IBM/telco-customer-churn-on-icp4d/master/data/Telco-Customer-Churn.csv"
df = pd.read_csv(url)
print(f"Shape: {df.shape}")
print(f"Columns: {list(df.columns)}")
df.head()

What does this code do?

  • url stores the direct link to the raw CSV file hosted on GitHub.
  • pd.read_csv(url) downloads the CSV and loads it into a DataFrame called df.
  • The print statements display the dataset's dimensions (7,043 rows, 21 columns) and all column names.
  • df.head() shows the first 5 rows so you can preview the data.
  • Run the cell.
  • You should see Shape: (7043, 21) followed by a list of 21 column names and a preview table of the first 5 rows.

Not seeing any output?

  • Make sure you ran Cell 1 first. This cell uses pd from your imports.
  • Check your internet connection. The dataset is downloaded directly from GitHub, so you need an active connection.
  • My Jupyter cell isn't loading the CSV from the GitHub URL. What's wrong?
Clean the data

The raw dataset has a few data quality issues that need fixing before modeling. The TotalCharges column contains 11 blank strings instead of numbers, Churn stores text labels instead of numeric values, and customerID is a unique identifier with no predictive value.

  • Create a new cell below Cell 2 and paste this code:
# Cell 3: Clean the Data
# Fix TotalCharges (contains blank strings)
df['TotalCharges'] = pd.to_numeric(df['TotalCharges'], errors='coerce')
df['TotalCharges'] = df['TotalCharges'].fillna(df['TotalCharges'].median())

# Convert Churn to numeric
df['Churn'] = df['Churn'].map({'Yes': 1, 'No': 0})

# Drop customerID
df = df.drop(columns=['customerID'])

print(f"Null values:\n{df.isnull().sum().sum()}")
print(f"TotalCharges dtype: {df['TotalCharges'].dtype}")
print(f"Churn dtype: {df['Churn'].dtype}")
print(f"Churn rate: {df['Churn'].mean():.3f}")

What does this code do?

  • pd.to_numeric() with errors='coerce' converts TotalCharges to numbers. Blank strings become NaN instead of throwing an error.
  • fillna() replaces those NaN values with the column's median. The median is more robust than the mean because outliers don't skew it.
  • .map({'Yes': 1, 'No': 0}) converts the Churn column from text labels to binary numbers that models can work with.
  • .drop(columns=['customerID']) removes the customer ID column. Unique identifiers don't help predict churn.
  • The four print statements at the bottom verify that every transformation worked correctly.
  • Run the cell.
  • You should see 0 null values, TotalCharges as float64, Churn as int64, and a churn rate of approximately 0.265.

Seeing errors or unexpected values?

  • If you see a NameError for df, make sure you ran Cell 2 first. Cell 3 depends on the DataFrame created there.
  • If TotalCharges still shows as object type, check that pd.to_numeric() uses errors='coerce' exactly as shown.
  • My data cleaning cell is throwing an error. Can you help me debug?
Confirm your baseline churn rate

The churn rate of approximately 26.5% means roughly 1 in 4 customers left the company. This number sets the baseline your models need to beat.

Why does the baseline matter?

A model that simply predicts "no churn" for every customer would be correct 73.5% of the time. Your trained models need to beat that number to be useful.

This is why accuracy alone can be misleading with imbalanced datasets. You'll use additional metrics like F1-score and ROC-AUC in later steps to get a fuller picture.

✔️ Awesome, I've got everything!

Double check that you've saved your notebook before moving on.

ⓧ I'd like to double check the full code

Here is the full code for all three cells so far. Make sure each cell in your notebook matches exactly.

# Cell 1: Imports and Setup
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report, roc_auc_score, roc_curve

print(f"pandas version: {pd.__version__}")
import sklearn
print(f"scikit-learn version: {sklearn.__version__}")
# Cell 2: Load the Dataset
url = "https://raw.githubusercontent.com/IBM/telco-customer-churn-on-icp4d/master/data/Telco-Customer-Churn.csv"
df = pd.read_csv(url)
print(f"Shape: {df.shape}")
print(f"Columns: {list(df.columns)}")
df.head()
# Cell 3: Clean the Data
# Fix TotalCharges (contains blank strings)
df['TotalCharges'] = pd.to_numeric(df['TotalCharges'], errors='coerce')
df['TotalCharges'] = df['TotalCharges'].fillna(df['TotalCharges'].median())

# Convert Churn to numeric
df['Churn'] = df['Churn'].map({'Yes': 1, 'No': 0})

# Drop customerID
df = df.drop(columns=['customerID'])

print(f"Null values:\n{df.isnull().sum().sum()}")
print(f"TotalCharges dtype: {df['TotalCharges'].dtype}")
print(f"Churn dtype: {df['Churn'].dtype}")
print(f"Churn rate: {df['Churn'].mean():.3f}")

Your data is clean and ready for exploration. Next up, you'll visualize the patterns hiding in this dataset and uncover which customer segments churn the most.

Explore the Data and Find Patterns

Your data is clean and ready to go. Before jumping into model training, you need to understand the patterns hiding inside the dataset.

Exploratory Data Analysis (EDA) is where you look for trends, imbalances, and relationships in the data. These patterns guide your feature engineering decisions and help you explain your results to stakeholders later.

In this step, get ready to:
  • Visualize the churn distribution and compare churned vs. non-churned customers.
  • Analyze churn rates by contract type and internet service.
  • Build a correlation heatmap to identify the strongest predictors of churn.
Visualize churn distribution and compare averages

A model that simply predicts "no churn" for every customer would be right about 73.5% of the time. To build something genuinely useful, you first need to see how imbalanced the churn classes are and what separates customers who leave from those who stay.

  • Create a new cell in your notebook below Cell 3.
  • Paste the following code to plot the churn distribution and print average values for churned vs. non-churned customers:
# Cell 4: Churn Distribution and Averages
fig, ax = plt.subplots(figsize=(6, 4))
sns.countplot(x='Churn', data=df, ax=ax)
ax.set_xticklabels(['No Churn', 'Churn'])
ax.set_title('Churn Distribution')
plt.tight_layout()
plt.show()

print(df.groupby('Churn')[['tenure', 'MonthlyCharges', 'TotalCharges']].mean())

What does this code do?

  • sns.countplot() creates a bar chart counting how many customers are in each churn class (0 and 1). This immediately shows you the class imbalance.
  • df.groupby('Churn')[['tenure', 'MonthlyCharges', 'TotalCharges']].mean() calculates the average tenure, monthly charges, and total charges for each group. This reveals how churned customers differ numerically from loyal ones.
  • Run the cell by pressing Shift+Enter.

You should see a bar chart with two bars showing the class imbalance. The "No Churn" bar is much taller, confirming that roughly 73.5% of customers stayed.

Below the chart, the printed table shows that churned customers have shorter average tenure and higher monthly charges compared to loyal customers.

Not seeing the chart?

  • Make sure you ran Cell 1 (imports) and Cell 3 (data cleaning) before running this cell. The notebook depends on earlier cells being executed in order.
  • If you see a NameError for df or sns, go back and run all previous cells from Cell 1 onward.
  • Help me debug my churn distribution chart
  • Create a new cell below Cell 4 and change its type to Markdown by selecting Markdown from the cell type dropdown in the toolbar.
  • Write 1-2 sentences summarizing the key difference: churned customers tend to have shorter tenure and higher monthly charges than loyal customers.
  • Run the Markdown cell to render it.

Why write Markdown cells?

Markdown cells turn your notebook into a document that tells a story, not just a stack of code. Hiring managers reviewing a portfolio notebook expect to see written analysis alongside the code.

Analyze churn by contract type and internet service

Averages tell you that churned customers behave differently. The next question is which specific categories drive the most churn.

  • Create a new code cell below your Markdown cell.
  • Paste the following code to calculate churn rates by contract type and internet service, then plot a bar chart:
# Cell 5: Churn by Contract Type and Internet Service
contract_churn = df.groupby('Contract')['Churn'].mean().sort_values(ascending=False)
internet_churn = df.groupby('InternetService')['Churn'].mean().sort_values(ascending=False)

print("Churn rate by contract type:")
print(contract_churn)
print("\nChurn rate by internet service:")
print(internet_churn)

fig, ax = plt.subplots(figsize=(8, 5))
contract_churn.plot(kind='bar', color=['#e74c3c', '#f39c12', '#2ecc71'], ax=ax)
ax.set_title('Churn Rate by Contract Type')
ax.set_ylabel('Churn Rate')
ax.set_xlabel('Contract Type')
plt.xticks(rotation=0)
plt.tight_layout()
plt.show()

What does this code do?

  • df.groupby('Contract')['Churn'].mean() calculates the churn rate for each contract type. Since Churn is 0 or 1, the mean equals the proportion of customers who churned.
  • The same logic applies to InternetService. This tells you which service types have the highest churn.
  • The bar chart uses red, orange, and green colours sorted from highest to lowest churn rate, making the riskiest contract type jump out visually.
  • Run the cell by pressing Shift+Enter.

You should see churn rates printed for each contract type and internet service type. Month-to-month contracts show around 43% churn, while two-year contracts show around 3%.

The bar chart makes the difference dramatic. Fiber optic internet users also churn at around 42%, which is far higher than DSL or no-internet customers.

Seeing unexpected churn rates?

  • If churn rates appear as all 0s or all 1s, check that you ran Cell 3 and the Churn column was properly mapped from Yes/No to 1/0.
  • If the bar chart has no bars, confirm that the Contract column still contains string values like Month-to-month. Run df['Contract'].unique() to verify.
  • Help me debug my churn rate by contract type analysis
Build a correlation heatmap

Groupby analysis shows you patterns in categorical columns. A correlation heatmap does the same for numeric columns, revealing which features move together and which ones are most connected to churn.

  • Create a new code cell below Cell 5.
  • Paste the following code to generate a correlation heatmap of all numeric columns:
# Cell 6: Correlation Heatmap
numeric_cols = df.select_dtypes(include=[np.number]).columns
correlation = df[numeric_cols].corr()

fig, ax = plt.subplots(figsize=(10, 8))
sns.heatmap(correlation, annot=True, cmap='coolwarm', center=0, fmt='.2f', ax=ax)
ax.set_title('Correlation Heatmap')
plt.tight_layout()
plt.show()

What does this code do?

  • df.select_dtypes(include=[np.number]) picks only the numeric columns, filtering out categorical ones that can't be correlated directly.
  • .corr() computes the Pearson correlation between every pair of numeric columns. Values range from -1 (perfect inverse relationship) to +1 (perfect positive relationship).
  • sns.heatmap() renders the matrix as a colour-coded grid. The coolwarm colour map makes negative correlations blue and positive ones red.
  • Run the cell by pressing Shift+Enter.

You should see a colour-coded grid showing correlation values between all numeric columns. Look at the Churn row to spot which features correlate most strongly with churn.

Heatmap labels overlapping or cut off?

  • This usually happens when the figure size is too small. Confirm you have figsize=(10, 8) in your plt.subplots() call.
  • Make sure plt.tight_layout() is included before plt.show(). This adjusts spacing so labels fit inside the figure.
  • Help me fix my correlation heatmap layout
  • Create a new cell below Cell 6 and change its type to Markdown.
  • Write a summary of your top 3 business insights from the EDA you have done so far.

Three insights to write about

Your insights will vary depending on what you noticed, but here are three strong ones to consider:

  • Contract type is the strongest predictor of churn. Month-to-month customers churn at around 43%, compared to only 3% for two-year contracts.
  • Fiber optic internet users churn at a much higher rate (around 42%) than DSL or no-internet customers.
  • Longer tenure correlates with lower churn. Customers who have been around longer are less likely to leave.
  • Run the Markdown cell to render your summary.

✔️ Awesome, I've got everything!

Great. Double check you have saved your notebook before moving on.

ⓧ I'd like to double check the full code

Here is the complete notebook code up to this point. Compare each cell with yours to spot any differences.

# Cell 1: Imports and Setup
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report, roc_auc_score, roc_curve

print(f"pandas version: {pd.__version__}")
import sklearn
print(f"scikit-learn version: {sklearn.__version__}")

# Cell 2: Load the Dataset
url = "https://raw.githubusercontent.com/IBM/telco-customer-churn-on-icp4d/master/data/Telco-Customer-Churn.csv"
df = pd.read_csv(url)
print(f"Shape: {df.shape}")
print(f"Columns: {list(df.columns)}")
df.head()

# Cell 3: Clean the Data
df['TotalCharges'] = pd.to_numeric(df['TotalCharges'], errors='coerce')
df['TotalCharges'] = df['TotalCharges'].fillna(df['TotalCharges'].median())
df['Churn'] = df['Churn'].map({'Yes': 1, 'No': 0})
df = df.drop(columns=['customerID'])

print(f"Null values:\n{df.isnull().sum().sum()}")
print(f"TotalCharges dtype: {df['TotalCharges'].dtype}")
print(f"Churn dtype: {df['Churn'].dtype}")
print(f"Churn rate: {df['Churn'].mean():.3f}")

# Cell 4: Churn Distribution and Averages
fig, ax = plt.subplots(figsize=(6, 4))
sns.countplot(x='Churn', data=df, ax=ax)
ax.set_xticklabels(['No Churn', 'Churn'])
ax.set_title('Churn Distribution')
plt.tight_layout()
plt.show()

print(df.groupby('Churn')[['tenure', 'MonthlyCharges', 'TotalCharges']].mean())

# Cell 5: Churn by Contract Type and Internet Service
contract_churn = df.groupby('Contract')['Churn'].mean().sort_values(ascending=False)
internet_churn = df.groupby('InternetService')['Churn'].mean().sort_values(ascending=False)

print("Churn rate by contract type:")
print(contract_churn)
print("\nChurn rate by internet service:")
print(internet_churn)

fig, ax = plt.subplots(figsize=(8, 5))
contract_churn.plot(kind='bar', color=['#e74c3c', '#f39c12', '#2ecc71'], ax=ax)
ax.set_title('Churn Rate by Contract Type')
ax.set_ylabel('Churn Rate')
ax.set_xlabel('Contract Type')
plt.xticks(rotation=0)
plt.tight_layout()
plt.show()

# Cell 6: Correlation Heatmap
numeric_cols = df.select_dtypes(include=[np.number]).columns
correlation = df[numeric_cols].corr()

fig, ax = plt.subplots(figsize=(10, 8))
sns.heatmap(correlation, annot=True, cmap='coolwarm', center=0, fmt='.2f', ax=ax)
ax.set_title('Correlation Heatmap')
plt.tight_layout()
plt.show()

You now have a clear picture of who churns and why. Next up, you will engineer new features and prepare the data for model training.

Engineer Features and Prepare for Modeling

Your EDA revealed clear patterns in the data. Month-to-month customers churn at 43%. Fiber optic users leave more often. Longer tenure correlates with loyalty. Now it's time to turn those insights into features a machine learning model can actually learn from.

In this step, you'll engineer new columns that capture spending patterns, convert categorical text into numbers, and split the data into training and test sets. This is where raw data becomes model-ready input.

In this step, get ready to:
  • Create new features that capture tenure and spending patterns.
  • One-hot encode categorical columns into numeric format.
  • Split and scale the data for model training.
Create new features from existing columns

Feature engineering means creating new columns from existing data to help models detect patterns more easily. Two new features will capture information that the raw columns don't surface on their own.

  • Create a new cell in your notebook (Cell 7) and add the following code:
# Cell 7: Feature Engineering
# Create tenure groups
df['tenure_group'] = pd.cut(
    df['tenure'],
    bins=[0, 12, 24, 48, 60, 72],
    labels=['0-1yr', '1-2yr', '2-4yr', '4-5yr', '5-6yr'],
    include_lowest=True
)

# Create average monthly spend
df['avg_monthly_spend'] = df['TotalCharges'] / (df['tenure'] + 1)

print("Tenure group distribution:")
print(df['tenure_group'].value_counts().sort_index())
print(f"\nAvg monthly spend stats:\n{df['avg_monthly_spend'].describe()}")

What does this code do?

  • pd.cut() bins the continuous tenure column into five labeled groups like 0-1yr and 2-4yr. This lets the model treat tenure as a category instead of a raw number.
  • avg_monthly_spend divides TotalCharges by tenure + 1. The + 1 prevents division by zero for customers with zero tenure.
  • Run the cell.
  • You should see a tenure group distribution showing counts per group (e.g. 0-1yr, 1-2yr, etc.) and descriptive statistics for avg_monthly_spend.

Seeing a ValueError about bin edges?

  • Check that your bins list is exactly [0, 12, 24, 48, 60, 72] with include_lowest=True. The bins must be in ascending order and cover the full range of tenure values (0 to 72).
  • Make sure your labels list has exactly five items to match the five bins.
  • Help me fix my pd.cut() feature engineering code
One-hot encode all categorical columns

ML models like Logistic Regression and Random Forest need numeric input. One-hot encoding converts each text category (like "Month-to-month") into its own 0/1 column.

  • Create a new cell (Cell 8) and add the following code:
# Cell 8: One-Hot Encode and Split
# One-hot encode categorical columns
df_encoded = pd.get_dummies(df, drop_first=True, dtype=float)

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

What does this code do?

  • pd.get_dummies() converts every categorical column into binary 0/1 columns. drop_first=True removes one category per feature to avoid the dummy variable trap. dtype=float ensures numeric 0.0/1.0 output instead of boolean True/False values.
  • X holds all feature columns the model learns from. y holds just the Churn column the model will predict.

Why pass dtype=float?

In pandas 3.x, pd.get_dummies() returns boolean True/False by default. scikit-learn's scaler and some models expect numeric columns. Passing dtype=float gives you clean 0.0/1.0 values that every model can handle.

You won't see output yet. The next section adds the train/test split to this same cell.

Split the data and scale features

Models need separate training and test data. Training data teaches the model patterns. Test data evaluates how well it generalizes to unseen customers.

  • Below the code you just added in Cell 8, add the following:
# Train-test split
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42
)

# Scale features
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)

print(f"X_train shape: {X_train.shape}")
print(f"X_test shape: {X_test.shape}")
print(f"Features: {list(X.columns)}")

What does this code do?

  • train_test_split() reserves 20% of rows for testing and keeps 80% for training. random_state=42 makes the split reproducible so you get the same rows every time.
  • StandardScaler normalizes feature values so no single column dominates during training. The scaler is fit on training data only, then applied to both sets. This prevents data leakage from the test set.
  • Run Cell 8.
  • You should see X_train shape with approximately 5,634 rows and X_test shape with approximately 1,409 rows. A full list of feature column names prints below the shapes.

Seeing a TypeError about boolean columns?

  • Make sure you passed dtype=float to pd.get_dummies(). Without it, pandas 3.x returns boolean True/False values that StandardScaler cannot process.
  • If you see a KeyError for Churn, confirm you ran Cell 3 (which converts Churn to 0/1) before running Cell 8.
  • Help me debug my one-hot encoding and train/test split

Why fit the scaler on training data only?

If you fit the scaler on the full dataset, the model indirectly learns statistics (mean, standard deviation) from the test set. This is called data leakage. It inflates your evaluation metrics and gives you a false sense of how well the model performs on truly unseen data.

✔️ Awesome, I've got everything!

Great. Double check you've saved your notebook before moving on.

ⓧ I'd like to double check the full code

Here is the complete notebook code through Cell 8. Compare it against your cells:

# Cell 1: Imports and Setup
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report, roc_auc_score, roc_curve

print(f"pandas version: {pd.__version__}")
import sklearn
print(f"scikit-learn version: {sklearn.__version__}")

# Cell 2: Load the Dataset
url = "https://raw.githubusercontent.com/IBM/telco-customer-churn-on-icp4d/master/data/Telco-Customer-Churn.csv"
df = pd.read_csv(url)
print(f"Shape: {df.shape}")
print(f"Columns: {list(df.columns)}")
df.head()

# Cell 3: Clean the Data
# Fix TotalCharges (contains blank strings)
df['TotalCharges'] = pd.to_numeric(df['TotalCharges'], errors='coerce')
df['TotalCharges'] = df['TotalCharges'].fillna(df['TotalCharges'].median())

# Convert Churn to numeric
df['Churn'] = df['Churn'].map({'Yes': 1, 'No': 0})

# Drop customerID
df = df.drop(columns=['customerID'])

print(f"Null values:\n{df.isnull().sum().sum()}")
print(f"TotalCharges dtype: {df['TotalCharges'].dtype}")
print(f"Churn dtype: {df['Churn'].dtype}")
print(f"Churn rate: {df['Churn'].mean():.3f}")

# Cell 4: Churn Distribution and Averages
fig, ax = plt.subplots(figsize=(6, 4))
sns.countplot(x='Churn', data=df, ax=ax)
ax.set_xticklabels(['No Churn', 'Churn'])
ax.set_title('Churn Distribution')
plt.tight_layout()
plt.show()

print(df.groupby('Churn')[['tenure', 'MonthlyCharges', 'TotalCharges']].mean())

# Cell 5: Churn by Contract Type and Internet Service
contract_churn = df.groupby('Contract')['Churn'].mean().sort_values(ascending=False)
internet_churn = df.groupby('InternetService')['Churn'].mean().sort_values(ascending=False)

print("Churn rate by contract type:")
print(contract_churn)
print("\nChurn rate by internet service:")
print(internet_churn)

fig, ax = plt.subplots(figsize=(8, 5))
contract_churn.plot(kind='bar', color=['#e74c3c', '#f39c12', '#2ecc71'], ax=ax)
ax.set_title('Churn Rate by Contract Type')
ax.set_ylabel('Churn Rate')
ax.set_xlabel('Contract Type')
plt.xticks(rotation=0)
plt.tight_layout()
plt.show()

# Cell 6: Correlation Heatmap
numeric_cols = df.select_dtypes(include=[np.number]).columns
correlation = df[numeric_cols].corr()

fig, ax = plt.subplots(figsize=(10, 8))
sns.heatmap(correlation, annot=True, cmap='coolwarm', center=0, fmt='.2f', ax=ax)
ax.set_title('Correlation Heatmap')
plt.tight_layout()
plt.show()

# Cell 7: Feature Engineering
# Create tenure groups
df['tenure_group'] = pd.cut(
    df['tenure'],
    bins=[0, 12, 24, 48, 60, 72],
    labels=['0-1yr', '1-2yr', '2-4yr', '4-5yr', '5-6yr'],
    include_lowest=True
)

# Create average monthly spend
df['avg_monthly_spend'] = df['TotalCharges'] / (df['tenure'] + 1)

print("Tenure group distribution:")
print(df['tenure_group'].value_counts().sort_index())
print(f"\nAvg monthly spend stats:\n{df['avg_monthly_spend'].describe()}")

# Cell 8: One-Hot Encode and Split
# One-hot encode categorical columns
df_encoded = pd.get_dummies(df, drop_first=True, dtype=float)

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

# Train-test split
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42
)

# Scale features
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)

print(f"X_train shape: {X_train.shape}")
print(f"X_test shape: {X_test.shape}")
print(f"Features: {list(X.columns)}")

Your data is cleaned, engineered, and split into training and test sets. Next up, you'll train two competing models and find out which one predicts churn better.

Train and Compare Two Models

Your data is cleaned, explored, feature-engineered, and split into training and test sets. Now comes the core of the project: training machine learning models that predict which customers will churn.

You will train two competing models and evaluate them head to head. Logistic Regression is simple and interpretable. Random Forest is more powerful but harder to explain to stakeholders. Your job is to decide which model a business should actually deploy and defend that choice with data.

In this step, get ready to:
  • Train a Logistic Regression model and evaluate its accuracy and ROC-AUC.
  • Train a Random Forest model and compare it side by side.
  • Visualize ROC curves and feature importance, then write a business recommendation.
Train a Logistic Regression model

Logistic Regression predicts a probability between 0 and 1 for each customer. You can inspect its coefficients to explain exactly why a customer is flagged as high-risk, which makes it a favorite for business presentations.

  • Create a new cell in your notebook (Cell 9) and paste the following code to train, predict, and evaluate a Logistic Regression model:
# Cell 9: Train Logistic Regression
lr_model = LogisticRegression(max_iter=1000, random_state=42)
lr_model.fit(X_train_scaled, y_train)

lr_preds = lr_model.predict(X_test_scaled)
lr_probs = lr_model.predict_proba(X_test_scaled)[:, 1]

print("=== Logistic Regression ===")
print(classification_report(y_test, lr_preds, target_names=['No Churn', 'Churn']))
print(f"ROC-AUC: {roc_auc_score(y_test, lr_probs):.4f}")

What does this code do?

  • LogisticRegression(max_iter=1000, random_state=42) creates the model. max_iter=1000 gives the optimizer enough iterations to converge. random_state=42 locks the results so they are reproducible.
  • predict() returns hard class labels (0 or 1). predict_proba()[:, 1] returns the probability of churn for each customer, which is needed for the ROC-AUC score.
  • classification_report prints precision, recall, and F1-score for each class. ROC-AUC measures how well the model separates churners from non-churners across all probability thresholds.
  • Run the cell.
  • You should see a full classification report with precision, recall, and F1-score for both classes. The ROC-AUC should be around 0.84-0.86.

Why does ROC-AUC matter more than accuracy here?

Your dataset has about 73.5% non-churners. A model that predicts "no churn" for every customer would score 73.5% accuracy without learning anything useful.

ROC-AUC ignores that class imbalance by measuring how well the model ranks churners above non-churners. A score of 0.85 means the model is doing real work, not just riding the majority class.

Seeing a convergence warning?

  • Make sure you set max_iter=1000 in the LogisticRegression constructor. The default of 100 is not always enough for this dataset.
  • Confirm you are passing X_train_scaled (the scaled version) to .fit(). Unscaled features can prevent convergence.

Help me debug my Logistic Regression convergence warning.

Train a Random Forest model

Random Forest builds 100 separate decision trees on random subsets of the data, then averages their predictions. It can capture nonlinear patterns that Logistic Regression misses.

  • Create a new cell (Cell 10) and paste the following code to train and evaluate a Random Forest model:
# Cell 10: Train Random Forest
rf_model = RandomForestClassifier(n_estimators=100, random_state=42)
rf_model.fit(X_train_scaled, y_train)

rf_preds = rf_model.predict(X_test_scaled)
rf_probs = rf_model.predict_proba(X_test_scaled)[:, 1]

print("=== Random Forest ===")
print(classification_report(y_test, rf_preds, target_names=['No Churn', 'Churn']))
print(f"ROC-AUC: {roc_auc_score(y_test, rf_probs):.4f}")

What does this code do?

  • n_estimators=100 tells the model to build 100 decision trees. More trees generally improve stability but take longer to train.
  • The rest of the cell follows the same predict-and-evaluate pattern as Logistic Regression. This makes it easy to compare the two reports side by side.
  • Run the cell.
  • You should see a classification report and ROC-AUC score. Compare the F1-score for the Churn class (class 1) between the two models. This is the metric that matters most for catching churners.

Why compare F1-score on the Churn class specifically?

Accuracy treats both classes equally, but your business cares more about catching churners than correctly labeling loyal customers. The F1-score for class 1 (Churn) balances precision ("of the customers I flagged, how many actually churned?") with recall ("of all churners, how many did I catch?").

Random Forest taking a long time?

  • Training 100 trees on about 5,600 rows should take a few seconds. If it takes more than 30 seconds, check that you are passing X_train_scaled (not the full X DataFrame).

Help me figure out why my Random Forest is slow or erroring.

Plot ROC curves and analyze feature importance

Numbers in a table are useful, but a visual comparison makes the difference between the two models obvious at a glance. You will plot both models' ROC curves on one chart, then look at which features the Random Forest relies on most.

  • Create a new cell (Cell 11) and paste the following code to plot both ROC curves on the same figure:
# Cell 11: ROC Curve Comparison
lr_fpr, lr_tpr, _ = roc_curve(y_test, lr_probs)
rf_fpr, rf_tpr, _ = roc_curve(y_test, rf_probs)

fig, ax = plt.subplots(figsize=(8, 6))
ax.plot(lr_fpr, lr_tpr, label=f'Logistic Regression (AUC={roc_auc_score(y_test, lr_probs):.3f})')
ax.plot(rf_fpr, rf_tpr, label=f'Random Forest (AUC={roc_auc_score(y_test, rf_probs):.3f})')
ax.plot([0, 1], [0, 1], 'k--', label='Random Classifier')
ax.set_xlabel('False Positive Rate')
ax.set_ylabel('True Positive Rate')
ax.set_title('ROC Curve Comparison')
ax.legend()
plt.tight_layout()
plt.show()

What does this chart show?

  • roc_curve() calculates the false positive rate and true positive rate at every probability threshold. Each curve shows the trade-off between catching more churners and falsely flagging loyal customers.
  • The dashed diagonal line represents a random classifier (AUC = 0.5). The further your model's curve bows toward the top-left corner, the better it is at separating churners from non-churners.
  • Each curve's label includes its AUC score so you can compare at a glance.
  • Run the cell.
  • You should see two colored curves (one for each model) above the dashed diagonal line, with AUC scores displayed in the legend.

Seeing a NameError for lr_probs or rf_probs?

  • Make sure you ran Cell 9 and Cell 10 before this cell. The variables lr_probs and rf_probs are created in those cells.

Help me fix my ROC curve cell.

Now let's see which features the Random Forest model considers most important for predicting churn.

  • Create a new cell (Cell 12) and paste the following code to plot the top 15 feature importances:
# Cell 12: Feature Importance from Random Forest
feature_importance = pd.Series(
    rf_model.feature_importances_,
    index=X.columns
).sort_values(ascending=False)

fig, ax = plt.subplots(figsize=(10, 6))
feature_importance.head(15).plot(kind='barh', ax=ax)
ax.set_title('Top 15 Feature Importances (Random Forest)')
ax.set_xlabel('Importance')
plt.tight_layout()
plt.show()

What does this code do?

  • feature_importances_ is a built-in attribute of the trained Random Forest. It scores each feature by how much it reduces prediction uncertainty across all 100 trees.
  • The chart shows the top 15 features sorted by importance. Features like TotalCharges, MonthlyCharges, and tenure typically appear near the top. This tells a retention team exactly where to focus.
  • Run the cell.
  • You should see a horizontal bar chart with 15 features ranked by importance.

Chart looks empty or shows no bars?

  • Make sure you use X.columns (the original feature DataFrame) as the index, not X_train.columns. Both have the same column names, but X is defined earlier and guaranteed to be in scope.
  • Confirm Cell 10 ran successfully. The rf_model variable must exist and be fitted before you can access feature_importances_.

Help me fix my feature importance chart.

The final piece is your trade-off analysis. This is the part that turns a technical exercise into something you could present to a hiring manager.

  • Create a new Markdown cell below Cell 12.
  • Write a short analysis (3-5 sentences) that covers:

✔️ I'll give it a go!

Think about what a VP of Customer Success would want to hear. They care about catching churners (recall) and trusting the model's flags (precision). Which model gives the best balance for that audience?

ⓧ Maybe a little help...

Here is an example you can adapt in your own words:

"Both models achieved similar accuracy, but Logistic Regression delivered a slightly higher ROC-AUC. Logistic Regression is also fully interpretable, meaning I can show stakeholders exactly which factors (contract type, tenure, monthly charges) drive the churn prediction. Random Forest may capture subtle nonlinear interactions, but the marginal improvement does not justify the loss of explainability. For a first deployment, I recommend Logistic Regression. It is transparent, fast to retrain, and gives the retention team clear action items."

✔️ Awesome, I've got everything!

Double check you have saved your notebook with all cells (1 through 12) and your Markdown trade-off analysis.

ⓧ I'd like to double check the full code

Here is the complete notebook code through Cell 12. Compare it against your cells to make sure nothing is missing.

# Cell 1: Imports and Setup
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report, roc_auc_score, roc_curve

print(f"pandas version: {pd.__version__}")
import sklearn
print(f"scikit-learn version: {sklearn.__version__}")

# Cell 2: Load the Dataset
url = "https://raw.githubusercontent.com/IBM/telco-customer-churn-on-icp4d/master/data/Telco-Customer-Churn.csv"
df = pd.read_csv(url)
print(f"Shape: {df.shape}")
print(f"Columns: {list(df.columns)}")
df.head()

# Cell 3: Clean the Data
# Fix TotalCharges (contains blank strings)
df['TotalCharges'] = pd.to_numeric(df['TotalCharges'], errors='coerce')
df['TotalCharges'] = df['TotalCharges'].fillna(df['TotalCharges'].median())

# Convert Churn to numeric
df['Churn'] = df['Churn'].map({'Yes': 1, 'No': 0})

# Drop customerID
df = df.drop(columns=['customerID'])

print(f"Null values:\n{df.isnull().sum().sum()}")
print(f"TotalCharges dtype: {df['TotalCharges'].dtype}")
print(f"Churn dtype: {df['Churn'].dtype}")
print(f"Churn rate: {df['Churn'].mean():.3f}")

# Cell 4: Churn Distribution and Averages
fig, ax = plt.subplots(figsize=(6, 4))
sns.countplot(x='Churn', data=df, ax=ax)
ax.set_xticklabels(['No Churn', 'Churn'])
ax.set_title('Churn Distribution')
plt.tight_layout()
plt.show()

print(df.groupby('Churn')[['tenure', 'MonthlyCharges', 'TotalCharges']].mean())

# Cell 5: Churn by Contract Type and Internet Service
contract_churn = df.groupby('Contract')['Churn'].mean().sort_values(ascending=False)
internet_churn = df.groupby('InternetService')['Churn'].mean().sort_values(ascending=False)

print("Churn rate by contract type:")
print(contract_churn)
print("\nChurn rate by internet service:")
print(internet_churn)

fig, ax = plt.subplots(figsize=(8, 5))
contract_churn.plot(kind='bar', color=['#e74c3c', '#f39c12', '#2ecc71'], ax=ax)
ax.set_title('Churn Rate by Contract Type')
ax.set_ylabel('Churn Rate')
ax.set_xlabel('Contract Type')
plt.xticks(rotation=0)
plt.tight_layout()
plt.show()

# Cell 6: Correlation Heatmap
numeric_cols = df.select_dtypes(include=[np.number]).columns
correlation = df[numeric_cols].corr()

fig, ax = plt.subplots(figsize=(10, 8))
sns.heatmap(correlation, annot=True, cmap='coolwarm', center=0, fmt='.2f', ax=ax)
ax.set_title('Correlation Heatmap')
plt.tight_layout()
plt.show()

# Cell 7: Feature Engineering
# Create tenure groups
df['tenure_group'] = pd.cut(
    df['tenure'],
    bins=[0, 12, 24, 48, 60, 72],
    labels=['0-1yr', '1-2yr', '2-4yr', '4-5yr', '5-6yr'],
    include_lowest=True
)

# Create average monthly spend
df['avg_monthly_spend'] = df['TotalCharges'] / (df['tenure'] + 1)

print("Tenure group distribution:")
print(df['tenure_group'].value_counts().sort_index())
print(f"\nAvg monthly spend stats:\n{df['avg_monthly_spend'].describe()}")

# Cell 8: One-Hot Encode and Split
# One-hot encode categorical columns
df_encoded = pd.get_dummies(df, drop_first=True, dtype=float)

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

# Train-test split
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42
)

# Scale features
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)

print(f"X_train shape: {X_train.shape}")
print(f"X_test shape: {X_test.shape}")
print(f"Features: {list(X.columns)}")

# Cell 9: Train Logistic Regression
lr_model = LogisticRegression(max_iter=1000, random_state=42)
lr_model.fit(X_train_scaled, y_train)

lr_preds = lr_model.predict(X_test_scaled)
lr_probs = lr_model.predict_proba(X_test_scaled)[:, 1]

print("=== Logistic Regression ===")
print(classification_report(y_test, lr_preds, target_names=['No Churn', 'Churn']))
print(f"ROC-AUC: {roc_auc_score(y_test, lr_probs):.4f}")

# Cell 10: Train Random Forest
rf_model = RandomForestClassifier(n_estimators=100, random_state=42)
rf_model.fit(X_train_scaled, y_train)

rf_preds = rf_model.predict(X_test_scaled)
rf_probs = rf_model.predict_proba(X_test_scaled)[:, 1]

print("=== Random Forest ===")
print(classification_report(y_test, rf_preds, target_names=['No Churn', 'Churn']))
print(f"ROC-AUC: {roc_auc_score(y_test, rf_probs):.4f}")

# Cell 11: ROC Curve Comparison
lr_fpr, lr_tpr, _ = roc_curve(y_test, lr_probs)
rf_fpr, rf_tpr, _ = roc_curve(y_test, rf_probs)

fig, ax = plt.subplots(figsize=(8, 6))
ax.plot(lr_fpr, lr_tpr, label=f'Logistic Regression (AUC={roc_auc_score(y_test, lr_probs):.3f})')
ax.plot(rf_fpr, rf_tpr, label=f'Random Forest (AUC={roc_auc_score(y_test, rf_probs):.3f})')
ax.plot([0, 1], [0, 1], 'k--', label='Random Classifier')
ax.set_xlabel('False Positive Rate')
ax.set_ylabel('True Positive Rate')
ax.set_title('ROC Curve Comparison')
ax.legend()
plt.tight_layout()
plt.show()

# Cell 12: Feature Importance from Random Forest
feature_importance = pd.Series(
    rf_model.feature_importances_,
    index=X.columns
).sort_values(ascending=False)

fig, ax = plt.subplots(figsize=(10, 6))
feature_importance.head(15).plot(kind='barh', ax=ax)
ax.set_title('Top 15 Feature Importances (Random Forest)')
ax.set_xlabel('Importance')
plt.tight_layout()
plt.show()

Secret mission

Add XGBoost and Build a Risk-Scoring System

You've compared Logistic Regression and Random Forest. Now bring in a third contender, XGBoost, and build a customer risk-scoring system that ranks every customer by their likelihood of churning. This is the kind of actionable output a retention team would actually use.

Before you go

Clean Up Your Resources

This project runs entirely locally on your machine, so there are no ongoing costs. Decide whether to keep your project files, pause by closing PyCharm, or delete everything entirely.

Resources you used:

  • The churn-prediction folder on your Desktop containing churn_analysis.ipynb.
  • Python libraries installed via pip: seaborn (pandas, NumPy, matplotlib, and scikit-learn were already installed).
  • The IBM Telco Customer Churn dataset loaded from a public GitHub URL (not saved locally unless you exported it).

Keep everything running

No action needed. Choose this if you plan to keep experimenting with your notebook or want to extend it further.

  • Your churn-prediction folder and notebook stay on your Desktop.
  • All installed Python libraries remain available for future projects.

Pause - I'll come back to this later

Close PyCharm to free up memory. Your files and libraries stay on disk so you can pick up where you left off.

  • Save your notebook in PyCharm.
  • Close PyCharm.
  • When you are ready to resume, reopen PyCharm and navigate back to churn_analysis.ipynb in your churn-prediction folder.

Delete - I don't want to use this again

Remove all project files and optionally uninstall the seaborn library you added.

  • Close PyCharm.
  • Delete the churn-prediction folder from your Desktop.
  • Optionally, uninstall seaborn by opening a terminal and running:
pip uninstall seaborn
  • Keep pandas, NumPy, matplotlib, and scikit-learn installed. They are useful for many other Python projects.

Mission accomplished

That's a Wrap!

Nice work! You just built a complete churn prediction pipeline that takes raw customer data all the way through to a business recommendation.

You've learned how to:

  • Clean, explore, and feature-engineer a real-world telecom dataset with 7,043 customer records.
  • Train and compare two classification models (Logistic Regression vs. Random Forest) using accuracy, F1-score, and ROC-AUC.
  • Produce a business-ready analysis with visualizations, trade-off reasoning, and a model deployment recommendation.
  • Secret Mission: Added an XGBoost model and built a customer risk-scoring system with actionable risk tiers for a retention team.

Ready to quiz yourself?