Telco Customer Churn: EDA and Prediction

Analyze telecom churn data and build a logistic regression predictor.

Introduction

30 Second Summary

Every subscription service loses customers. Understanding why people cancel is worth millions to companies trying to keep them.

In this project, you will analyze real telecom customer data to discover why customers leave, produce visualizations that reveal churn patterns, and build a machine learning model that predicts which customers are at risk.

What You'll Build

A Jupyter Notebook containing a full Exploratory Data Analysis pipeline from raw CSV data to a trained churn prediction model.

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

  • A cleaned dataset with documented observations about 7,043 telecom customer records.
  • Publication-ready visualizations showing churn patterns across demographics, services, and billing.
  • A trained logistic regression model that predicts customer churn with evaluated accuracy metrics and a confusion matrix.
  • Secret Mission: Build a Random Forest classifier and compare its performance against your logistic regression baseline.

Are there any prerequisites?

This project assumes basic familiarity with Python and pandas. You don't need prior experience with data visualization or machine learning.

Set Up Your Environment

Before you can analyze customer data, you need a working data science environment.

In this step, you'll install the Python libraries for data analysis and visualization, launch Jupyter Notebook, create your project notebook, and download the dataset you'll be working with.

In this step, get ready to:
  • Install pandas, seaborn, matplotlib, and scikit-learn.
  • Launch Jupyter Notebook and create your analysis notebook.
  • Download the Telco Customer Churn dataset from Kaggle.
Install the required Python libraries

This project uses several libraries that work together. pandas handles tabular data, seaborn and matplotlib produce visualizations, and scikit-learn provides machine learning tools.

  • Install all the required libraries by running this command in your terminal:
pip install pandas seaborn matplotlib scikit-learn jupyter

What does this command do?

This single pip install command downloads and installs five packages at once.

  • pandas reads, cleans, and transforms data in table form (DataFrames).
  • seaborn creates statistical charts with minimal code.
  • matplotlib is the base plotting library that seaborn builds on.
  • scikit-learn provides machine learning models and evaluation tools.
  • jupyter runs the interactive notebook environment for your analysis.
  • Wait for the installation to finish. You should see a "Successfully installed" message listing all the packages.

Seeing a permission error?

  • On macOS or Linux, try adding --user to the end of the command to install to your user directory.
  • Make sure you're using the pip that matches your Python 3.12 installation. Try pip3 instead of pip if you have multiple Python versions.

Why can't I install Python packages?

Launch Jupyter Notebook and create your notebook

Jupyter Notebook lets you write code in cells that you run one at a time. This makes it perfect for data exploration because you can see results immediately after each piece of analysis.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create a project folder and move into it by running these commands:
mkdir telco-churn-eda
cd telco-churn-eda
  • Launch Jupyter Notebook by running:
jupyter notebook
  • Your default browser opens to the Jupyter dashboard showing the contents of your project folder.

What should I see?

A browser tab opens showing the Jupyter file browser. The folder will be empty for now since you just created it. Your terminal stays occupied running the Jupyter server. Don't close it.

  • In the Jupyter dashboard, click the New button in the top right corner.
  • Select Python 3 from the dropdown menu.

A new notebook opens in a separate browser tab with the default name "Untitled".

  • Click the notebook name ("Untitled") at the top of the page, next to the Jupyter logo.
  • In the rename dialog, type telco_churn_eda and press Enter.

The notebook tab now shows telco_churn_eda as the title. Your notebook file is saved as telco_churn_eda.ipynb in your project folder.

Browser didn't open automatically?

  • Check your terminal output. Look for a URL starting with http://localhost:8888. Copy and paste it into your browser manually.
  • If the terminal shows an error about port 8888 being in use, close other Jupyter instances first.

Help me launch Jupyter Notebook

Download the Telco Customer Churn dataset

The dataset you'll analyze comes from IBM and is hosted on Kaggle. It contains records for 7,043 telecom customers, including whether they churned.

  • Open the dataset page in a new browser tab: Telco Customer Churn on Kaggle.
  • Click the Download button near the top of the page. Kaggle will download a zip file.
  • Unzip the downloaded file. Inside you'll find a CSV called WA_Fn-UseC_-Telco-Customer-Churn.csv.
  • Move WA_Fn-UseC_-Telco-Customer-Churn.csv into your telco-churn-eda folder on your Desktop (the same folder where your notebook lives).

Why does the file need to be in the same folder?

When your notebook runs code like pd.read_csv('WA_Fn-UseC_-Telco-Customer-Churn.csv'), Python looks for that file in the same directory the notebook is saved in. Keeping everything together avoids path errors later.

  • Confirm the file is in the right place by switching to your Jupyter dashboard browser tab. You should see both your notebook and the CSV file listed.

Don't see the CSV in the Jupyter dashboard?

  • Make sure you moved the CSV into the telco-churn-eda folder, not the Desktop root.
  • If you unzipped the download into a subfolder, check inside it for the CSV and move it up one level.
  • Click the refresh icon in the Jupyter dashboard toolbar to reload the file list.

Help me find my dataset file

Your data science environment is ready. Next up, you'll load this dataset into your notebook and start exploring what's inside.

Load and Inspect the Data

Your environment is set up and your notebook is ready. Before you can analyze churn patterns or build predictions, you need to understand what the raw data actually looks like.

In this step, you'll load the Telco Customer Churn dataset into a pandas DataFrame and run a series of inspection commands. These commands reveal the shape of the data, the types of each column, and the distribution of the target variable you'll eventually predict.

In this step, get ready to:
  • Import your analysis libraries and configure the notebook.
  • Load the Telco Customer Churn dataset and check its dimensions.
  • Inspect column types, summary statistics, and the target variable.
Import libraries and configure the notebook

Every data science notebook starts with imports. You'll bring in the libraries for data manipulation, visualization, and machine learning all at once so they're available throughout the notebook.

  • In your telco_churn_eda.ipynb notebook, click into the first empty cell.
  • Paste the following code into the cell:
# Cell 1: Imports and Setup
import pandas as pd
import numpy as np
import seaborn as sns
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report, confusion_matrix

sns.set_style("whitegrid")
plt.rcParams['figure.figsize'] = (10, 6)

What does this code do?

The import block brings in everything you'll use across the entire notebook. pandas handles data manipulation, numpy provides numerical operations, and seaborn plus matplotlib power your visualizations.

The scikit-learn imports (train_test_split, LogisticRegression, and the metrics functions) are for the prediction model you'll build later. The last two lines set a clean chart style and a default figure size so every plot looks consistent.

  • Run the cell by pressing Shift+Enter.
  • The cell should complete with no output and no errors. A new empty cell appears below.

Seeing a ModuleNotFoundError?

This means one of the libraries isn't installed. Go back to your terminal and run pip install pandas seaborn matplotlib scikit-learn again. Make sure you're using the same Python environment that Jupyter is running in.

Help me fix my import error in Jupyter Notebook.

Load the dataset into a DataFrame

With your libraries ready, you can now load the CSV file into a DataFrame. This is the central data structure in pandas that holds your entire dataset in rows and columns.

  • In the next empty cell, paste this code and run it with Shift+Enter:
# Cell 2: Load the Data
df = pd.read_csv('WA_Fn-UseC_-Telco-Customer-Churn.csv')
print(f"Dataset shape: {df.shape}")
print(f"\nColumn types:\n{df.dtypes}")
df.head()

What does this code do?

  • pd.read_csv() reads the CSV file and creates a DataFrame called df.
  • df.shape prints the number of rows and columns so you know the dataset's size.
  • df.dtypes shows the data type of each column (object, int64, float64).
  • df.head() displays the first 5 rows as a preview table.

You should see (7043, 21) printed as the dataset shape. That's 7,043 customer records across 21 columns. The table preview at the bottom shows columns like customerID, gender, tenure, and Churn.

Seeing a FileNotFoundError?

This means the CSV file isn't in the same folder as your notebook. Make sure WA_Fn-UseC_-Telco-Customer-Churn.csv is in the exact same directory where you launched jupyter notebook. The filename must match exactly, including capitalization.

Help me fix the FileNotFoundError for my CSV file.

Inspect the dataset structure

Now that you can see the data, dig deeper. The .info() method reveals missing values and memory usage, while .describe() gives summary statistics for numeric columns. Checking the distribution of the Churn column tells you how balanced your target variable is.

  • In the next empty cell, paste this code and run it with Shift+Enter:
# Cell 3: Initial Inspection
print(df.info())
print(f"\nDescriptive statistics:\n{df.describe()}")
print(f"\nChurn distribution:\n{df['Churn'].value_counts()}")

What does this code do?

  • df.info() prints a summary of each column including the non-null count. This is how you spot missing data.
  • df.describe() shows statistical summaries (mean, min, max, quartiles) for numeric columns only.
  • df['Churn'].value_counts() counts how many customers churned vs. stayed. This is the target variable your model will predict.

The churn distribution should show roughly 5,174 customers with "No" and 1,869 with "Yes". That's approximately 26.5% churn, which means the dataset is imbalanced toward non-churners.

What is class imbalance?

Class imbalance means one outcome is far more common than the other. Here, about 73.5% of customers stayed and only 26.5% left. This matters for prediction because a model that always guesses "No churn" would appear 73.5% accurate without learning anything useful.

  • Add a new Markdown cell below Cell 3. Click the cell type dropdown (which currently says "Code") and change it to Markdown.
  • Write 2-3 bullet points summarizing what you've observed so far: the dataset has 7,043 rows and 21 columns, the target variable is Churn (Yes/No), and there's a class imbalance.
  • Run the markdown cell with Shift+Enter to render it.

Markdown cells tell the story

Good data science notebooks interleave code with narrative. Markdown cells document your thinking so that anyone reading the notebook (including future you) can follow the analysis without re-running every cell.

TotalCharges showing as object type?

You might notice in the df.info() output that TotalCharges appears as an object (string) type instead of a number. That's a known data quality issue in this dataset. You'll fix it in the next step.

✔️ Awesome, I've got everything!

Great. Make sure you've saved your notebook with Cmd+S (macOS) or Ctrl+S (Windows).

ⓧ I'd like to double check the full code

Here's everything you should have in your notebook so far. Each section below is a separate cell.

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

sns.set_style("whitegrid")
plt.rcParams['figure.figsize'] = (10, 6)

# Cell 2: Load the Data
df = pd.read_csv('WA_Fn-UseC_-Telco-Customer-Churn.csv')
print(f"Dataset shape: {df.shape}")
print(f"\nColumn types:\n{df.dtypes}")
df.head()

# Cell 3: Initial Inspection
print(df.info())
print(f"\nDescriptive statistics:\n{df.describe()}")
print(f"\nChurn distribution:\n{df['Churn'].value_counts()}")

You now have a clear picture of the dataset's shape, columns, and target variable. Next, you'll tackle the data quality issues you spotted and get the dataset ready for analysis.

Clean the Data

During inspection, you spotted a few data quality issues that need fixing before any analysis can happen. The TotalCharges column is stored as a string instead of a number, and some rows contain empty strings that will break calculations.

In this step, you'll fix these issues so the dataset is ready for visualization and modeling. Clean data is the foundation of every reliable analysis.

In this step, get ready to:
  • Convert TotalCharges from a string to a numeric column.
  • Handle missing values introduced by the conversion.
  • Standardize the SeniorCitizen column to match other categorical columns.
Convert TotalCharges to numeric

The TotalCharges column looks like it holds numbers, but pandas read it as strings because 11 rows contain empty spaces instead of values. You need to force the conversion and let pandas mark those problem rows for you.

  • Create a new cell in your notebook (Cell 4) and add this code to convert the column:
# Cell 4: Clean TotalCharges
df['TotalCharges'] = pd.to_numeric(df['TotalCharges'], errors='coerce')
print(f"Missing TotalCharges: {df['TotalCharges'].isna().sum()}")

What does this code do?

pd.to_numeric() attempts to convert every value in the column to a number. The errors='coerce' parameter tells pandas to replace any value it cannot convert (like an empty space) with NaN (Not a Number) instead of crashing.

The print statement counts how many NaN values now exist in the column, confirming which rows had invalid data.

  • Run the cell by pressing Shift+Enter.
  • You should see the output Missing TotalCharges: 11 confirming that 11 rows had empty strings.

Seeing 0 missing values?

Make sure you ran Cell 2 first to load the data. If you restarted the kernel, run all cells from the top in order.

Also check that your CSV file is named exactly WA_Fn-UseC_-Telco-Customer-Churn.csv and is in the same directory as your notebook.

Why is my TotalCharges conversion not detecting missing values?

Drop the rows with missing TotalCharges

Those 11 rows represent brand-new customers with 0 tenure who haven't been billed yet. Since they have no charge history to analyze, dropping them is the cleanest approach.

  • Create a new cell (Cell 5) and add this code to remove the incomplete rows:
# Cell 5: Handle Missing Values
df = df.dropna(subset=['TotalCharges'])
print(f"Dataset shape after cleaning: {df.shape}")

What does this code do?

df.dropna(subset=['TotalCharges']) removes only the rows where TotalCharges is NaN. The subset parameter ensures you don't accidentally drop rows that are missing values in other columns.

  • Run the cell. You should see Dataset shape after cleaning: (7032, 21) confirming 11 rows were removed.

Seeing fewer than 7,032 rows?

If you ran Cell 5 more than once without re-running Cell 2 first, the DataFrame was already modified. Restart your kernel and run all cells from Cell 1 onwards.

Help me understand why my row count is wrong after dropping NaN values.

Convert SeniorCitizen to Yes/No strings

The SeniorCitizen column uses 0 and 1 integers, but every other categorical column in the dataset uses "Yes" and "No" strings. Converting it now makes your visualizations consistent later.

  • Create a new cell (Cell 6) and add this code to map the integers to strings:
# Cell 6: Convert SeniorCitizen to Yes/No
df['SeniorCitizen'] = df['SeniorCitizen'].map({0: 'No', 1: 'Yes'})

What does this code do?

The .map() method replaces each value in the column based on the dictionary you pass it. Every 0 becomes "No" and every 1 becomes "Yes".

  • Run the cell.
  • Verify the conversion worked by typing df['SeniorCitizen'].value_counts() in a new cell and running it. You should see counts for "No" and "Yes" instead of 0 and 1.

Still seeing 0 and 1?

Check that you ran Cell 6 before checking the value counts. Also make sure the dictionary uses integers as keys (0 and 1), not strings ('0' and '1').

Why is my SeniorCitizen column still showing integers after mapping?

Why standardize SeniorCitizen?

When you create grouped bar charts in the next step, pandas and seaborn use column values as axis labels. Having "Yes"/"No" instead of 0/1 makes your charts immediately readable without a legend explaining what the numbers mean.

✔️ 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 full notebook up to this point. Compare your cells against this:

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

sns.set_style("whitegrid")
plt.rcParams['figure.figsize'] = (10, 6)

# Cell 2: Load the Data
df = pd.read_csv('WA_Fn-UseC_-Telco-Customer-Churn.csv')
print(f"Dataset shape: {df.shape}")
print(f"\nColumn types:\n{df.dtypes}")
df.head()

# Cell 3: Initial Inspection
print(df.info())
print(f"\nDescriptive statistics:\n{df.describe()}")
print(f"\nChurn distribution:\n{df['Churn'].value_counts()}")

# Cell 4: Clean TotalCharges
df['TotalCharges'] = pd.to_numeric(df['TotalCharges'], errors='coerce')
print(f"Missing TotalCharges: {df['TotalCharges'].isna().sum()}")

# Cell 5: Handle Missing Values
df = df.dropna(subset=['TotalCharges'])
print(f"Dataset shape after cleaning: {df.shape}")

# Cell 6: Convert SeniorCitizen to Yes/No
df['SeniorCitizen'] = df['SeniorCitizen'].map({0: 'No', 1: 'Yes'})

Your dataset is now clean and consistent. Next up, you'll start visualizing churn patterns to uncover which customer segments are most at risk of leaving.

Visualize Churn Distribution and Demographics

Your data is clean and ready to explore. The next question is: how bad is the churn problem, and who is leaving?

In this step, you'll create visualizations that answer both questions. First, you'll plot the overall churn distribution to understand the scale of the problem. Then you'll break it down by customer demographics to see which groups are most at risk.

In this step, get ready to:
  • Plot the overall churn distribution with labeled counts.
  • Build a grid of demographic churn rate comparisons.
  • Document key insights about which customer groups churn most.
Plot the overall churn distribution

Before diving into which customers churn, you need to understand the baseline. How many customers actually left versus stayed? This tells you whether churn is a niche issue or a significant business problem.

  • Create a new code cell in your notebook (Cell 7) and add the following code to plot the churn distribution:
# Cell 7: Churn Distribution
fig, ax = plt.subplots()
churn_counts = df['Churn'].value_counts()
ax.bar(churn_counts.index, churn_counts.values, color=['#2ecc71', '#e74c3c'])
ax.set_title('Customer Churn Distribution')
ax.set_xlabel('Churn')
ax.set_ylabel('Count')
for i, v in enumerate(churn_counts.values):
    ax.text(i, v + 50, str(v), ha='center', fontweight='bold')
plt.tight_layout()
plt.show()
print(f"Churn rate: {(df['Churn'] == 'Yes').mean():.1%}")

What does this code do?

This cell creates a bar chart showing the count of churned versus non-churned customers. Green represents customers who stayed, and red represents customers who left.

The for loop adds bold count labels above each bar so you can read exact numbers without hovering. The final print statement calculates the churn rate as a percentage of total customers.

  • Run the cell by pressing Shift+Enter.
  • You should see a bar chart with two bars. The "No" bar is much taller than the "Yes" bar, and the printed output shows a churn rate of approximately 26.5%.

What is class imbalance?

When one category is far more common than the other, that's called class imbalance. Here, about 73.5% of customers stayed and only 26.5% churned. This imbalance is important to remember when you build your prediction model later, because a model that always guesses "No churn" would still be right 73.5% of the time.

Not seeing a chart?

Make sure you ran Cells 1 through 6 first. The df variable and matplotlib import must exist from earlier cells. If you restarted the kernel, re-run all cells from the top.

Help me debug why my matplotlib chart isn't displaying in Jupyter Notebook.

Build a demographics churn rate grid

Now that you know the overall churn rate, the next question is whether churn hits all customers equally or concentrates in specific demographic groups. You'll compare churn rates across gender, senior citizen status, partner status, and dependents.

  • Create a new code cell (Cell 8) and add this code to generate a 2x2 grid of demographic churn rate charts:
# Cell 8: Demographics vs Churn
demo_cols = ['gender', 'SeniorCitizen', 'Partner', 'Dependents']
fig, axes = plt.subplots(2, 2, figsize=(12, 10))
axes = axes.flatten()

for i, col in enumerate(demo_cols):
    churn_rate = df.groupby(col)['Churn'].apply(lambda x: (x == 'Yes').mean())
    axes[i].bar(churn_rate.index, churn_rate.values, color=['#3498db', '#e74c3c'])
    axes[i].set_title(f'Churn Rate by {col}')
    axes[i].set_ylabel('Churn Rate')
    axes[i].set_ylim(0, 0.6)
    for j, v in enumerate(churn_rate.values):
        axes[i].text(j, v + 0.02, f'{v:.1%}', ha='center')

plt.tight_layout()
plt.show()

What does this code do?

  • demo_cols lists the four demographic columns you want to compare.
  • plt.subplots(2, 2) creates a 2x2 grid of chart panels. axes.flatten() converts the 2D array into a flat list so you can loop over it with a simple index.
  • The groupby calculates the proportion of "Yes" values in the Churn column for each category. This gives you the churn rate per group rather than raw counts.
  • set_ylim(0, 0.6) keeps all four charts on the same scale so you can visually compare bar heights across panels.
  • Run the cell by pressing Shift+Enter.
  • You should see a 2x2 grid of bar charts. Each chart shows two bars representing the churn rate for each category value, with percentage labels above the bars.

Charts look squished or overlapping?

Make sure figsize=(12, 10) is set in your plt.subplots() call. Without it, the default figure size may be too small for a 2x2 grid and labels will overlap.

Help me fix overlapping labels in my matplotlib subplot grid.

Document your demographic insights

Good data science notebooks don't just show charts. They tell a story. Adding a markdown cell that interprets your findings makes the notebook readable by anyone, including your future self.

  • Create a new Markdown cell below Cell 8. In the Jupyter toolbar, click the dropdown that says Code and change it to Markdown.
  • Write your observations from the demographic charts. Document the following patterns:
  • Senior citizens churn at nearly double the rate of non-seniors.
  • Customers without partners churn more than those with partners.
  • Customers without dependents churn more than those with dependents.
  • Gender has minimal impact on churn rate.
  • Run the markdown cell by pressing Shift+Enter to render your notes.

Why document insights inline?

Professional data science notebooks interleave analysis with interpretation. When you share this notebook with a colleague or hiring manager, the markdown cells make your thought process visible without requiring them to re-interpret every chart from scratch.

✔️ 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 up to and including Cell 8:

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

sns.set_style("whitegrid")
plt.rcParams['figure.figsize'] = (10, 6)

# Cell 2: Load the Data
df = pd.read_csv('WA_Fn-UseC_-Telco-Customer-Churn.csv')
print(f"Dataset shape: {df.shape}")
print(f"\nColumn types:\n{df.dtypes}")
df.head()

# Cell 3: Initial Inspection
print(df.info())
print(f"\nDescriptive statistics:\n{df.describe()}")
print(f"\nChurn distribution:\n{df['Churn'].value_counts()}")

# Cell 4: Clean TotalCharges
df['TotalCharges'] = pd.to_numeric(df['TotalCharges'], errors='coerce')
print(f"Missing TotalCharges: {df['TotalCharges'].isna().sum()}")

# Cell 5: Handle Missing Values
df = df.dropna(subset=['TotalCharges'])
print(f"Dataset shape after cleaning: {df.shape}")

# Cell 6: Convert SeniorCitizen to Yes/No
df['SeniorCitizen'] = df['SeniorCitizen'].map({0: 'No', 1: 'Yes'})

# Cell 7: Churn Distribution
fig, ax = plt.subplots()
churn_counts = df['Churn'].value_counts()
ax.bar(churn_counts.index, churn_counts.values, color=['#2ecc71', '#e74c3c'])
ax.set_title('Customer Churn Distribution')
ax.set_xlabel('Churn')
ax.set_ylabel('Count')
for i, v in enumerate(churn_counts.values):
    ax.text(i, v + 50, str(v), ha='center', fontweight='bold')
plt.tight_layout()
plt.show()
print(f"Churn rate: {(df['Churn'] == 'Yes').mean():.1%}")

# Cell 8: Demographics vs Churn
demo_cols = ['gender', 'SeniorCitizen', 'Partner', 'Dependents']
fig, axes = plt.subplots(2, 2, figsize=(12, 10))
axes = axes.flatten()

for i, col in enumerate(demo_cols):
    churn_rate = df.groupby(col)['Churn'].apply(lambda x: (x == 'Yes').mean())
    axes[i].bar(churn_rate.index, churn_rate.values, color=['#3498db', '#e74c3c'])
    axes[i].set_title(f'Churn Rate by {col}')
    axes[i].set_ylabel('Churn Rate')
    axes[i].set_ylim(0, 0.6)
    for j, v in enumerate(churn_rate.values):
        axes[i].text(j, v + 0.02, f'{v:.1%}', ha='center')

plt.tight_layout()
plt.show()

You've established a clear picture of the churn landscape. Next up, you'll dig into which services and contract types drive the highest churn rates.

Analyze Services and Contract Impact

In the previous step, you explored how demographics relate to churn. Now it's time to dig into the business side of the relationship.

Which services are customers using when they leave? What type of contract do they have? These are the questions that help a telecom company decide where to focus retention efforts.

In this step, get ready to:
  • Create horizontal bar charts showing churn rate by internet service, contract type, and payment method.
  • Plot a tenure distribution comparing churned vs retained customers.
  • Build a box plot comparing monthly charges by churn status.
Create service and contract churn charts

A customer's service choices and contract terms often reveal more about churn risk than demographics alone. Fiber optic users, month-to-month contracts, and certain payment methods each tell a different part of the story.

  • Create a new cell in your notebook and add the following code to generate horizontal bar charts for three service-related columns:
# Cell 9: Service and Contract Analysis
service_cols = ['InternetService', 'Contract', 'PaymentMethod']
fig, axes = plt.subplots(1, 3, figsize=(18, 5))

for i, col in enumerate(service_cols):
    churn_rate = df.groupby(col)['Churn'].apply(lambda x: (x == 'Yes').mean()).sort_values(ascending=False)
    axes[i].barh(churn_rate.index, churn_rate.values, color='#e74c3c')
    axes[i].set_title(f'Churn Rate by {col}')
    axes[i].set_xlabel('Churn Rate')
    for j, v in enumerate(churn_rate.values):
        axes[i].text(v + 0.01, j, f'{v:.1%}', va='center')

plt.tight_layout()
plt.show()

What does this code do?

This cell loops through three service-related columns and creates a horizontal bar chart for each one. The .sort_values(ascending=False) call orders the bars so the highest churn rate appears at the top.

The barh method creates horizontal bars, which are easier to read when category labels are long (like payment method names). Each bar gets a percentage annotation positioned just to the right.

  • Run the cell by pressing Shift+Enter.
  • You should see three horizontal bar charts side by side. Fiber optic customers have the highest churn rate in the InternetService chart, month-to-month contracts dominate in the Contract chart, and electronic check leads in PaymentMethod.

Why does fiber optic have the highest churn?

Fiber optic customers pay higher monthly charges and may have higher expectations for service quality. When those expectations aren't met, they leave. Combined with month-to-month contracts, there's very little holding them in place.

Charts look squished or overlapping?

Make sure the figsize=(18, 5) parameter is set in your plt.subplots() call. This gives each subplot enough horizontal space. Also confirm you have plt.tight_layout() before plt.show().

Help me fix my matplotlib subplot layout

Plot tenure distribution by churn status

Tenure tells you how long a customer has been with the company. A KDE (kernel density estimate) plot shows the shape of this distribution for each group, revealing when customers are most likely to leave.

  • Create a new cell and add this code to plot tenure distribution grouped by churn status:
# Cell 10: Tenure Distribution by Churn
fig, ax = plt.subplots(figsize=(10, 5))
sns.kdeplot(data=df, x='tenure', hue='Churn', fill=True, ax=ax, palette=['#2ecc71', '#e74c3c'])
ax.set_title('Tenure Distribution by Churn Status')
ax.set_xlabel('Tenure (months)')
plt.tight_layout()
plt.show()

What does this code do?

The sns.kdeplot() function draws smooth density curves instead of jagged histograms. The hue='Churn' parameter splits the data into two curves: one for customers who stayed and one for those who left.

The fill=True parameter shades under each curve, making it easier to see where the two groups diverge.

  • Run the cell with Shift+Enter.
  • You should see two overlapping density curves. The red curve (churned customers) peaks sharply near 0-2 months, showing that most customers who leave do so very early in their relationship with the company.

Seeing a FutureWarning from seaborn?

You can safely ignore deprecation or FutureWarning messages from seaborn. The plot still renders correctly. These warnings appear because of compatibility between seaborn and newer versions of pandas.

Help me understand this seaborn warning

Build a monthly charges box plot

A box plot shows the distribution of a numeric variable across categories. It reveals the median, spread, and outliers at a glance. This is perfect for comparing what churned customers pay versus those who stay.

  • Create a new cell and add this code to compare monthly charges between churned and non-churned customers:
# Cell 11: Monthly Charges by Churn
fig, ax = plt.subplots(figsize=(10, 5))
sns.boxplot(data=df, x='Churn', y='MonthlyCharges', palette=['#2ecc71', '#e74c3c'], ax=ax)
ax.set_title('Monthly Charges by Churn Status')
plt.tight_layout()
plt.show()

What does this code do?

The sns.boxplot() function creates a box-and-whisker plot for each churn category. The box shows the interquartile range (middle 50% of values), the line inside the box is the median, and the whiskers extend to the full range of typical values.

Setting x='Churn' and y='MonthlyCharges' tells seaborn to split monthly charges into two groups based on churn status.

  • Run the cell with Shift+Enter.
  • You should see two box plots side by side. The "Yes" (churned) box sits notably higher than the "No" box, confirming that customers who leave tend to pay more per month.

Box plot not showing two groups?

Make sure your x parameter is set to 'Churn' (with a capital C). Column names in pandas are case-sensitive, so 'churn' will raise a KeyError.

Help me debug my seaborn box plot

What's the story so far?

Across these three visualizations, a clear profile of the at-risk customer emerges. They use fiber optic internet, have a month-to-month contract, pay with electronic check, joined recently, and pay higher monthly charges.

This is the power of Exploratory Data Analysis. Each chart adds a piece to the puzzle, building toward actionable insights before any machine learning is involved.

✔️ 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's everything your notebook should contain through Cell 11:

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

sns.set_style("whitegrid")
plt.rcParams['figure.figsize'] = (10, 6)

# Cell 2: Load the Data
df = pd.read_csv('WA_Fn-UseC_-Telco-Customer-Churn.csv')
print(f"Dataset shape: {df.shape}")
print(f"\nColumn types:\n{df.dtypes}")
df.head()

# Cell 3: Initial Inspection
print(df.info())
print(f"\nDescriptive statistics:\n{df.describe()}")
print(f"\nChurn distribution:\n{df['Churn'].value_counts()}")

# Cell 4: Clean TotalCharges
df['TotalCharges'] = pd.to_numeric(df['TotalCharges'], errors='coerce')
print(f"Missing TotalCharges: {df['TotalCharges'].isna().sum()}")

# Cell 5: Handle Missing Values
df = df.dropna(subset=['TotalCharges'])
print(f"Dataset shape after cleaning: {df.shape}")

# Cell 6: Convert SeniorCitizen to Yes/No
df['SeniorCitizen'] = df['SeniorCitizen'].map({0: 'No', 1: 'Yes'})

# Cell 7: Churn Distribution
fig, ax = plt.subplots()
churn_counts = df['Churn'].value_counts()
ax.bar(churn_counts.index, churn_counts.values, color=['#2ecc71', '#e74c3c'])
ax.set_title('Customer Churn Distribution')
ax.set_xlabel('Churn')
ax.set_ylabel('Count')
for i, v in enumerate(churn_counts.values):
    ax.text(i, v + 50, str(v), ha='center', fontweight='bold')
plt.tight_layout()
plt.show()
print(f"Churn rate: {(df['Churn'] == 'Yes').mean():.1%}")

# Cell 8: Demographics vs Churn
demo_cols = ['gender', 'SeniorCitizen', 'Partner', 'Dependents']
fig, axes = plt.subplots(2, 2, figsize=(12, 10))
axes = axes.flatten()

for i, col in enumerate(demo_cols):
    churn_rate = df.groupby(col)['Churn'].apply(lambda x: (x == 'Yes').mean())
    axes[i].bar(churn_rate.index, churn_rate.values, color=['#3498db', '#e74c3c'])
    axes[i].set_title(f'Churn Rate by {col}')
    axes[i].set_ylabel('Churn Rate')
    axes[i].set_ylim(0, 0.6)
    for j, v in enumerate(churn_rate.values):
        axes[i].text(j, v + 0.02, f'{v:.1%}', ha='center')

plt.tight_layout()
plt.show()

# Cell 9: Service and Contract Analysis
service_cols = ['InternetService', 'Contract', 'PaymentMethod']
fig, axes = plt.subplots(1, 3, figsize=(18, 5))

for i, col in enumerate(service_cols):
    churn_rate = df.groupby(col)['Churn'].apply(lambda x: (x == 'Yes').mean()).sort_values(ascending=False)
    axes[i].barh(churn_rate.index, churn_rate.values, color='#e74c3c')
    axes[i].set_title(f'Churn Rate by {col}')
    axes[i].set_xlabel('Churn Rate')
    for j, v in enumerate(churn_rate.values):
        axes[i].text(v + 0.01, j, f'{v:.1%}', va='center')

plt.tight_layout()
plt.show()

# Cell 10: Tenure Distribution by Churn
fig, ax = plt.subplots(figsize=(10, 5))
sns.kdeplot(data=df, x='tenure', hue='Churn', fill=True, ax=ax, palette=['#2ecc71', '#e74c3c'])
ax.set_title('Tenure Distribution by Churn Status')
ax.set_xlabel('Tenure (months)')
plt.tight_layout()
plt.show()

# Cell 11: Monthly Charges by Churn
fig, ax = plt.subplots(figsize=(10, 5))
sns.boxplot(data=df, x='Churn', y='MonthlyCharges', palette=['#2ecc71', '#e74c3c'], ax=ax)
ax.set_title('Monthly Charges by Churn Status')
plt.tight_layout()
plt.show()

You now have a clear picture of which services and contract terms drive churn. Next, you'll quantify these relationships with correlation analysis and identify the highest-risk customer segments.

Correlation Analysis and Customer Segmentation

Your visualizations from the last step told a compelling story about churn patterns. Month-to-month contracts, fiber optic internet, and short tenure all appear linked to higher churn rates.

Now it's time to quantify those relationships mathematically. In this step, you'll encode your categorical data into numbers, compute correlations across every feature, and segment customers into risk groups to identify exactly who is most likely to leave.

In this step, get ready to:
  • Encode categorical variables to numeric and compute a correlation matrix.
  • Plot the strongest positive and negative correlations with churn.
  • Segment customers by contract and internet service to identify the highest-risk group.
Encode categorical variables for correlation analysis

Correlation matrices only work with numeric data. Your DataFrame still has text-based categorical columns like Contract and InternetService that need to be converted into numbers before you can measure how strongly each feature relates to churn.

  • Create a new code cell in your notebook and add the following code to encode your categorical columns:
# Cell 12: Correlation Analysis
df_encoded = df.copy()
df_encoded['Churn'] = df_encoded['Churn'].map({'No': 0, 'Yes': 1})

cat_cols = df_encoded.select_dtypes(include='object').columns.tolist()
cat_cols = [c for c in cat_cols if c != 'customerID']
df_encoded = pd.get_dummies(df_encoded, columns=cat_cols, drop_first=True)
df_encoded = df_encoded.drop(columns=['customerID'])

What does this code do?

This cell prepares your data for mathematical comparison by converting everything to numbers.

  • df.copy() creates a separate copy so your original df stays untouched for later use.
  • .map({'No': 0, 'Yes': 1}) converts the Churn target column into binary values that correlations can work with.
  • pd.get_dummies() with drop_first=True converts each categorical column into binary indicator columns while avoiding redundancy.
  • customerID is excluded from encoding and then dropped because it's just an identifier with no predictive value.

What is one-hot encoding?

One-hot encoding turns a categorical column into multiple binary columns. For example, a Contract column with values "Month-to-month", "One year", and "Two year" becomes two new columns: Contract_One year and Contract_Two year. The drop_first=True parameter drops one category to avoid collinearity.

  • Run the cell by pressing Shift+Enter.

The cell runs silently. You won't see output yet because this cell only prepares the encoded DataFrame for the next step.

Seeing a KeyError or TypeError?

  • Make sure you ran all previous cells (Cells 1-11) in order. The df DataFrame must already be cleaned from earlier steps.
  • Check that your SeniorCitizen column was already converted to "Yes"/"No" strings in Cell 6. If it's still 0/1 integers, re-run Cell 6 first.

Help me debug my encoding cell

Plot the strongest correlations with churn

With all features encoded as numbers, you can now compute a correlation coefficient between each feature and the Churn column. Features with strong positive correlations push customers toward churning. Features with strong negative correlations help retain them.

  • Create a new code cell and add this code to compute and visualize the top correlations:
# Cell 13: Correlation Heatmap with Churn
churn_corr = df_encoded.corr()['Churn'].sort_values(ascending=False)
top_corr = pd.concat([churn_corr.head(10), churn_corr.tail(10)])

fig, ax = plt.subplots(figsize=(10, 8))
top_corr.plot(kind='barh', color=top_corr.apply(lambda x: '#e74c3c' if x > 0 else '#2ecc71'), ax=ax)
ax.set_title('Top Features Correlated with Churn')
ax.set_xlabel('Correlation Coefficient')
plt.tight_layout()
plt.show()

What does this code do?

  • df_encoded.corr()['Churn'] computes the correlation of every feature against the Churn column, giving you a score between -1 and 1.
  • pd.concat([churn_corr.head(10), churn_corr.tail(10)]) grabs the 10 most positively correlated features and the 10 most negatively correlated features.
  • The color logic makes bars red for positive correlations (increase churn) and green for negative correlations (reduce churn).
  • Run the cell by pressing Shift+Enter.

You should see a horizontal bar chart with red bars extending to the right (churn drivers) and green bars extending to the left (churn reducers). The strongest predictors are Contract type, tenure, OnlineSecurity, and TechSupport.

Chart looks empty or has unexpected features?

  • If the chart is blank, make sure Cell 12 ran without errors. The df_encoded variable must exist before this cell can use it.
  • If you see a deprecation warning about .corr(), the chart should still render correctly. The warning is informational only.

Help me fix my correlation chart

Create a customer segmentation summary

Correlations tell you which individual features matter. Customer segmentation goes further by grouping customers into meaningful segments and calculating churn rates for each combination. This reveals which specific customer profiles are at highest risk.

  • Create a new code cell and add this code to build your segmentation table:
# Cell 14: Customer Segmentation
segmentation = df.groupby(['Contract', 'InternetService']).agg(
    customer_count=('customerID', 'count'),
    churn_rate=('Churn', lambda x: (x == 'Yes').mean()),
    avg_monthly_charges=('MonthlyCharges', 'mean'),
    avg_tenure=('tenure', 'mean')
).sort_values('churn_rate', ascending=False)

print("Customer Segmentation by Contract and Internet Service:")
print(segmentation.to_string())

What does this code do?

  • .groupby(['Contract', 'InternetService']) splits customers into groups based on every combination of contract type and internet service.
  • .agg() computes multiple metrics per group: total customers, churn rate, average monthly charges, and average tenure.
  • .sort_values('churn_rate', ascending=False) puts the highest-risk segment at the top of the table so you can immediately see which group to focus on.
  • Run the cell by pressing Shift+Enter.

You should see a printed table showing each customer segment with its churn rate. The month-to-month fiber optic segment appears at the top as the highest-risk group.

Why is month-to-month fiber optic the riskiest?

This segment combines two churn drivers: no contractual commitment (easy to leave) and higher monthly charges (fiber optic costs more than DSL). Customers paying more with no lock-in have the lowest switching cost and the highest motivation to shop around.

Seeing a NameError for df?

  • This cell uses the original df DataFrame (not df_encoded). Make sure you haven't overwritten df anywhere in your notebook.
  • If your kernel restarted, re-run all cells from Cell 1 through Cell 11 before running Cell 14.

Help me fix my segmentation cell

✔️ 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 full notebook code up to this point. Compare it with your cells to make sure everything matches.

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

sns.set_style("whitegrid")
plt.rcParams['figure.figsize'] = (10, 6)

# Cell 2: Load the Data
df = pd.read_csv('WA_Fn-UseC_-Telco-Customer-Churn.csv')
print(f"Dataset shape: {df.shape}")
print(f"\nColumn types:\n{df.dtypes}")
df.head()

# Cell 3: Initial Inspection
print(df.info())
print(f"\nDescriptive statistics:\n{df.describe()}")
print(f"\nChurn distribution:\n{df['Churn'].value_counts()}")

# Cell 4: Clean TotalCharges
df['TotalCharges'] = pd.to_numeric(df['TotalCharges'], errors='coerce')
print(f"Missing TotalCharges: {df['TotalCharges'].isna().sum()}")

# Cell 5: Handle Missing Values
df = df.dropna(subset=['TotalCharges'])
print(f"Dataset shape after cleaning: {df.shape}")

# Cell 6: Convert SeniorCitizen to Yes/No
df['SeniorCitizen'] = df['SeniorCitizen'].map({0: 'No', 1: 'Yes'})

# Cell 7: Churn Distribution
fig, ax = plt.subplots()
churn_counts = df['Churn'].value_counts()
ax.bar(churn_counts.index, churn_counts.values, color=['#2ecc71', '#e74c3c'])
ax.set_title('Customer Churn Distribution')
ax.set_xlabel('Churn')
ax.set_ylabel('Count')
for i, v in enumerate(churn_counts.values):
    ax.text(i, v + 50, str(v), ha='center', fontweight='bold')
plt.tight_layout()
plt.show()
print(f"Churn rate: {(df['Churn'] == 'Yes').mean():.1%}")

# Cell 8: Demographics vs Churn
demo_cols = ['gender', 'SeniorCitizen', 'Partner', 'Dependents']
fig, axes = plt.subplots(2, 2, figsize=(12, 10))
axes = axes.flatten()

for i, col in enumerate(demo_cols):
    churn_rate = df.groupby(col)['Churn'].apply(lambda x: (x == 'Yes').mean())
    axes[i].bar(churn_rate.index, churn_rate.values, color=['#3498db', '#e74c3c'])
    axes[i].set_title(f'Churn Rate by {col}')
    axes[i].set_ylabel('Churn Rate')
    axes[i].set_ylim(0, 0.6)
    for j, v in enumerate(churn_rate.values):
        axes[i].text(j, v + 0.02, f'{v:.1%}', ha='center')

plt.tight_layout()
plt.show()

# Cell 9: Service and Contract Analysis
service_cols = ['InternetService', 'Contract', 'PaymentMethod']
fig, axes = plt.subplots(1, 3, figsize=(18, 5))

for i, col in enumerate(service_cols):
    churn_rate = df.groupby(col)['Churn'].apply(lambda x: (x == 'Yes').mean()).sort_values(ascending=False)
    axes[i].barh(churn_rate.index, churn_rate.values, color='#e74c3c')
    axes[i].set_title(f'Churn Rate by {col}')
    axes[i].set_xlabel('Churn Rate')
    for j, v in enumerate(churn_rate.values):
        axes[i].text(v + 0.01, j, f'{v:.1%}', va='center')

plt.tight_layout()
plt.show()

# Cell 10: Tenure Distribution by Churn
fig, ax = plt.subplots(figsize=(10, 5))
sns.kdeplot(data=df, x='tenure', hue='Churn', fill=True, ax=ax, palette=['#2ecc71', '#e74c3c'])
ax.set_title('Tenure Distribution by Churn Status')
ax.set_xlabel('Tenure (months)')
plt.tight_layout()
plt.show()

# Cell 11: Monthly Charges by Churn
fig, ax = plt.subplots(figsize=(10, 5))
sns.boxplot(data=df, x='Churn', y='MonthlyCharges', palette=['#2ecc71', '#e74c3c'], ax=ax)
ax.set_title('Monthly Charges by Churn Status')
plt.tight_layout()
plt.show()

# Cell 12: Correlation Analysis
df_encoded = df.copy()
df_encoded['Churn'] = df_encoded['Churn'].map({'No': 0, 'Yes': 1})

cat_cols = df_encoded.select_dtypes(include='object').columns.tolist()
cat_cols = [c for c in cat_cols if c != 'customerID']
df_encoded = pd.get_dummies(df_encoded, columns=cat_cols, drop_first=True)
df_encoded = df_encoded.drop(columns=['customerID'])

# Cell 13: Correlation Heatmap with Churn
churn_corr = df_encoded.corr()['Churn'].sort_values(ascending=False)
top_corr = pd.concat([churn_corr.head(10), churn_corr.tail(10)])

fig, ax = plt.subplots(figsize=(10, 8))
top_corr.plot(kind='barh', color=top_corr.apply(lambda x: '#e74c3c' if x > 0 else '#2ecc71'), ax=ax)
ax.set_title('Top Features Correlated with Churn')
ax.set_xlabel('Correlation Coefficient')
plt.tight_layout()
plt.show()

# Cell 14: Customer Segmentation
segmentation = df.groupby(['Contract', 'InternetService']).agg(
    customer_count=('customerID', 'count'),
    churn_rate=('Churn', lambda x: (x == 'Yes').mean()),
    avg_monthly_charges=('MonthlyCharges', 'mean'),
    avg_tenure=('tenure', 'mean')
).sort_values('churn_rate', ascending=False)

print("Customer Segmentation by Contract and Internet Service:")
print(segmentation.to_string())

You now have hard numbers backing up your visual analysis. The correlation chart and segmentation table give you evidence-based answers about why customers churn. Next up, you'll use these encoded features to train a machine learning model that predicts churn before it happens.

Build a Churn Prediction Model

Your correlation analysis revealed which features are most closely tied to churn. Now it is time to put those features to work in a model that predicts whether a customer will leave.

You will train a Logistic Regression model using scikit-learn. Logistic regression is fast, interpretable, and a solid baseline for binary classification problems like churn prediction.

In this step, get ready to:
  • Split the encoded data into training and test sets.
  • Train a logistic regression model on the training data.
  • Evaluate model performance with a classification report and confusion matrix.
Prepare features and split the data

Before training a model, you need to separate the features (what the model uses to predict) from the target (what it predicts). You also need a test set that the model has never seen, so you can measure how well it generalizes.

  • Create a new cell in your notebook below Cell 14 and add the following code:
# Cell 15: Prepare Data for Modeling
X = df_encoded.drop(columns=['Churn'])
y = df_encoded['Churn']

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]} samples")
print(f"Test set: {X_test.shape[0]} samples")

What does this code do?

  • X contains every column from df_encoded except the Churn column. These are the features the model learns from.
  • y is the Churn column alone (0 or 1). This is what the model tries to predict.
  • train_test_split reserves 20% of rows for testing. The stratify=y parameter ensures both sets keep the same proportion of churned vs non-churned customers.
  • Run the cell by pressing Shift+Enter.

You should see output showing the training set and test set sample counts.

Seeing a NameError?

If you see NameError: name 'df_encoded' is not defined, go back and run Cell 12 first. The df_encoded DataFrame is created there.

Help me fix a NameError for df_encoded in my Jupyter Notebook.

Train a logistic regression model

Logistic regression predicts the probability that each customer belongs to the "churned" class. It learns which features push that probability up or down, making it easy to interpret.

  • Create a new cell and add the following code to train the model and generate predictions:
# Cell 16: Train Logistic Regression
lr_model = LogisticRegression(max_iter=1000, random_state=42)
lr_model.fit(X_train, y_train)
y_pred = lr_model.predict(X_test)

print("Logistic Regression Classification Report:")
print(classification_report(y_test, y_pred))

What does this code do?

  • LogisticRegression(max_iter=1000) creates the model with enough iterations to converge on this dataset.
  • .fit() trains the model by learning patterns from the training data.
  • .predict() applies what it learned to the test set and generates churn/no-churn predictions.
  • classification_report prints precision, recall, and f1-score for each class. These tell you how accurate the model is at identifying churners vs non-churners.
  • Run the cell by pressing Shift+Enter.

You should see a classification report table showing precision, recall, and f1-score for both classes (0 and 1).

How do I read this report?

Precision tells you what percentage of predicted churners actually churned. Recall tells you what percentage of actual churners the model caught.

For churn prediction, recall matters most. A missed churner is a lost customer. A false alarm just means reaching out to someone who was going to stay anyway.

Seeing a ConvergenceWarning?

This warning means the model needed more iterations to find the best solution. The max_iter=1000 parameter should prevent this, but if you still see it, the model still produces results. The predictions are usable.

Help me understand this ConvergenceWarning from LogisticRegression.

Visualize predictions with a confusion matrix

A confusion matrix shows exactly where the model gets things right and wrong. It plots actual labels against predicted labels in a grid, so you can see how many customers were correctly or incorrectly classified.

  • Create a new cell and add the following code to plot the confusion matrix as a heatmap:
# Cell 17: Confusion Matrix
fig, ax = plt.subplots(figsize=(6, 5))
cm = confusion_matrix(y_test, y_pred)
sns.heatmap(cm, annot=True, fmt='d', cmap='Blues', ax=ax,
            xticklabels=['No Churn', 'Churn'],
            yticklabels=['No Churn', 'Churn'])
ax.set_title('Confusion Matrix - Logistic Regression')
ax.set_xlabel('Predicted')
ax.set_ylabel('Actual')
plt.tight_layout()
plt.show()

What does this code do?

  • confusion_matrix(y_test, y_pred) compares actual churn labels against the model's predictions and returns a 2x2 grid of counts.
  • sns.heatmap() renders the grid as a color-coded chart. Darker blues mean higher counts.
  • The top-left cell shows customers correctly predicted as non-churners. The bottom-right shows customers correctly predicted as churners. Off-diagonal cells are mistakes.
  • Run the cell by pressing Shift+Enter.

You should see a blue heatmap with four cells showing counts. The diagonal cells (top-left and bottom-right) represent correct predictions.

Heatmap not showing?

Make sure you ran Cell 16 first. The y_pred variable needs to exist before you can compare it against y_test. Also confirm you have plt.show() at the end of the cell.

Help me fix my confusion matrix heatmap that is not displaying.

✔️ Awesome, I've got everything!

Great. Double check that your notebook is saved, then continue.

ⓧ I'd like to double check the full code

Here is the complete notebook from Cell 1 through Cell 17. Compare your cells against this reference.

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

sns.set_style("whitegrid")
plt.rcParams['figure.figsize'] = (10, 6)

# Cell 2: Load the Data
df = pd.read_csv('WA_Fn-UseC_-Telco-Customer-Churn.csv')
print(f"Dataset shape: {df.shape}")
print(f"\nColumn types:\n{df.dtypes}")
df.head()

# Cell 3: Initial Inspection
print(df.info())
print(f"\nDescriptive statistics:\n{df.describe()}")
print(f"\nChurn distribution:\n{df['Churn'].value_counts()}")

# Cell 4: Clean TotalCharges
df['TotalCharges'] = pd.to_numeric(df['TotalCharges'], errors='coerce')
print(f"Missing TotalCharges: {df['TotalCharges'].isna().sum()}")

# Cell 5: Handle Missing Values
df = df.dropna(subset=['TotalCharges'])
print(f"Dataset shape after cleaning: {df.shape}")

# Cell 6: Convert SeniorCitizen to Yes/No
df['SeniorCitizen'] = df['SeniorCitizen'].map({0: 'No', 1: 'Yes'})

# Cell 7: Churn Distribution
fig, ax = plt.subplots()
churn_counts = df['Churn'].value_counts()
ax.bar(churn_counts.index, churn_counts.values, color=['#2ecc71', '#e74c3c'])
ax.set_title('Customer Churn Distribution')
ax.set_xlabel('Churn')
ax.set_ylabel('Count')
for i, v in enumerate(churn_counts.values):
    ax.text(i, v + 50, str(v), ha='center', fontweight='bold')
plt.tight_layout()
plt.show()
print(f"Churn rate: {(df['Churn'] == 'Yes').mean():.1%}")

# Cell 8: Demographics vs Churn
demo_cols = ['gender', 'SeniorCitizen', 'Partner', 'Dependents']
fig, axes = plt.subplots(2, 2, figsize=(12, 10))
axes = axes.flatten()

for i, col in enumerate(demo_cols):
    churn_rate = df.groupby(col)['Churn'].apply(lambda x: (x == 'Yes').mean())
    axes[i].bar(churn_rate.index, churn_rate.values, color=['#3498db', '#e74c3c'])
    axes[i].set_title(f'Churn Rate by {col}')
    axes[i].set_ylabel('Churn Rate')
    axes[i].set_ylim(0, 0.6)
    for j, v in enumerate(churn_rate.values):
        axes[i].text(j, v + 0.02, f'{v:.1%}', ha='center')

plt.tight_layout()
plt.show()

# Cell 9: Service and Contract Analysis
service_cols = ['InternetService', 'Contract', 'PaymentMethod']
fig, axes = plt.subplots(1, 3, figsize=(18, 5))

for i, col in enumerate(service_cols):
    churn_rate = df.groupby(col)['Churn'].apply(lambda x: (x == 'Yes').mean()).sort_values(ascending=False)
    axes[i].barh(churn_rate.index, churn_rate.values, color='#e74c3c')
    axes[i].set_title(f'Churn Rate by {col}')
    axes[i].set_xlabel('Churn Rate')
    for j, v in enumerate(churn_rate.values):
        axes[i].text(v + 0.01, j, f'{v:.1%}', va='center')

plt.tight_layout()
plt.show()

# Cell 10: Tenure Distribution by Churn
fig, ax = plt.subplots(figsize=(10, 5))
sns.kdeplot(data=df, x='tenure', hue='Churn', fill=True, ax=ax, palette=['#2ecc71', '#e74c3c'])
ax.set_title('Tenure Distribution by Churn Status')
ax.set_xlabel('Tenure (months)')
plt.tight_layout()
plt.show()

# Cell 11: Monthly Charges by Churn
fig, ax = plt.subplots(figsize=(10, 5))
sns.boxplot(data=df, x='Churn', y='MonthlyCharges', palette=['#2ecc71', '#e74c3c'], ax=ax)
ax.set_title('Monthly Charges by Churn Status')
plt.tight_layout()
plt.show()

# Cell 12: Correlation Analysis
df_encoded = df.copy()
df_encoded['Churn'] = df_encoded['Churn'].map({'No': 0, 'Yes': 1})

cat_cols = df_encoded.select_dtypes(include='object').columns.tolist()
cat_cols = [c for c in cat_cols if c != 'customerID']
df_encoded = pd.get_dummies(df_encoded, columns=cat_cols, drop_first=True)
df_encoded = df_encoded.drop(columns=['customerID'])

# Cell 13: Correlation Heatmap with Churn
churn_corr = df_encoded.corr()['Churn'].sort_values(ascending=False)
top_corr = pd.concat([churn_corr.head(10), churn_corr.tail(10)])

fig, ax = plt.subplots(figsize=(10, 8))
top_corr.plot(kind='barh', color=top_corr.apply(lambda x: '#e74c3c' if x > 0 else '#2ecc71'), ax=ax)
ax.set_title('Top Features Correlated with Churn')
ax.set_xlabel('Correlation Coefficient')
plt.tight_layout()
plt.show()

# Cell 14: Customer Segmentation
segmentation = df.groupby(['Contract', 'InternetService']).agg(
    customer_count=('customerID', 'count'),
    churn_rate=('Churn', lambda x: (x == 'Yes').mean()),
    avg_monthly_charges=('MonthlyCharges', 'mean'),
    avg_tenure=('tenure', 'mean')
).sort_values('churn_rate', ascending=False)

print("Customer Segmentation by Contract and Internet Service:")
print(segmentation.to_string())

# Cell 15: Prepare Data for Modeling
X = df_encoded.drop(columns=['Churn'])
y = df_encoded['Churn']

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]} samples")
print(f"Test set: {X_test.shape[0]} samples")

# Cell 16: Train Logistic Regression
lr_model = LogisticRegression(max_iter=1000, random_state=42)
lr_model.fit(X_train, y_train)
y_pred = lr_model.predict(X_test)

print("Logistic Regression Classification Report:")
print(classification_report(y_test, y_pred))

# Cell 17: Confusion Matrix
fig, ax = plt.subplots(figsize=(6, 5))
cm = confusion_matrix(y_test, y_pred)
sns.heatmap(cm, annot=True, fmt='d', cmap='Blues', ax=ax,
            xticklabels=['No Churn', 'Churn'],
            yticklabels=['No Churn', 'Churn'])
ax.set_title('Confusion Matrix - Logistic Regression')
ax.set_xlabel('Predicted')
ax.set_ylabel('Actual')
plt.tight_layout()
plt.show()

Secret mission

Random Forest vs Logistic Regression

Your logistic regression model gives you a solid baseline, but how does it stack up against a more powerful algorithm? Build a Random Forest classifier, compare both models head-to-head on accuracy, precision, and recall, and discover which features matter most when predicting churn.

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 remove them to free up disk space.

Resources you used:

  • telco_churn_eda.ipynb notebook file.
  • WA_Fn-UseC_-Telco-Customer-Churn.csv dataset file.
  • Python packages installed via pip: pandas, seaborn, matplotlib, scikit-learn, jupyter.

Keep everything running

No action needed. Choose this if you plan to continue exploring the dataset or building on your analysis.

  • Your notebook and CSV file remain in place on your Desktop.
  • The installed Python packages stay available for other projects.

Pause - I'll come back to this later

Shut down the Jupyter Notebook server to free up memory. Your files and packages stay on disk for when you return.

  • Switch to the terminal where Jupyter is running.
  • Press Ctrl+C to stop the Jupyter server. Confirm with y if prompted.
  • Your notebook, CSV file, and installed packages remain on disk. To resume later, navigate to your project folder and run jupyter notebook again.

Delete - I don't want to use this again

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

  • Stop the Jupyter server by pressing Ctrl+C in its terminal.
  • Delete the project files by running this command from your Desktop:
rm telco_churn_eda.ipynb WA_Fn-UseC_-Telco-Customer-Churn.csv

On Windows, run this instead:

del telco_churn_eda.ipynb, WA_Fn-UseC_-Telco-Customer-Churn.csv
  • Uninstall the Python packages you installed for this project by running:
pip uninstall pandas seaborn matplotlib scikit-learn jupyter -y

Keep packages you use elsewhere

If you use pandas, matplotlib, or other packages in other projects, skip the uninstall step. Only remove packages you no longer need anywhere on your system.

Nice Work!

Nice Work!

You've completed a full EDA and prediction pipeline on real telecom customer data.

You've learned how to:

  • Built a clean, documented analysis notebook from raw CSV to business insights.
  • Produced visualizations revealing that month-to-month contracts, fiber optic internet, and short tenure are the strongest churn indicators.
  • Trained and evaluated a logistic regression model that predicts customer churn.
  • Built a Random Forest classifier and compared model performance, identifying the most predictive features.

Ready to quiz yourself?