Predict Customer Churn with ML

Build an end-to-end ML pipeline to predict telecom customer churn.

Introduction

30 Second Summary

Every subscription service you use, from your phone plan to your streaming apps, has a team trying to predict whether you will cancel next month. The ability to flag at-risk customers before they leave is one of the most valuable skills in data science.

In this project, you will build a complete customer churn prediction pipeline for a telecom company. You will go from raw data to a polished, portfolio-ready GitHub repository that hiring managers can scan in under 30 seconds.

What You'll Build

A production-style machine learning project that predicts which telecom customers are likely to cancel their service, using the IBM Telco Customer Churn dataset (7,043 customers, 21 features).

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

  • A professional GitHub repository with proper structure (notebooks/, src/, data/, README) that demonstrates end-to-end pipeline thinking.
  • Three numbered Jupyter notebooks covering exploratory data analysis, feature engineering, and model training.
  • A trained and evaluated classification pipeline comparing Logistic Regression, Random Forest, and Gradient Boosting with proper cross-validation and class imbalance handling.
  • Secret Mission: Add SHAP feature importance explanations and generate a churn risk scorecard visualization showing the top drivers of churn.

Are there any prerequisites?

This project assumes you are familiar with pandas, scikit-learn basics (train/test split, fitting models), and basic Git commands. Experience with linear regression through random forest is ideal.

You will also need Python 3.11 or higher, Jupyter Notebook, and a GitHub account.

Set Up Your Project Environment

Every professional data science project starts with a clean, isolated workspace. Without one, package conflicts between projects pile up and your results become impossible to reproduce.

In this step, you will create a dedicated project folder, set up an isolated Python environment, install your dependencies, and scaffold the directory structure that hiring managers expect to see in a portfolio project.

In this step, get ready to:
  • Create a virtual environment and install all project dependencies.
  • Build a professional directory structure with separate folders for data, notebooks, and source code.
  • Initialize a Git repository and make your first commit.
Create a virtual environment and install dependencies

A virtual environment gives your project its own private set of packages. This prevents version conflicts with other Python projects on your machine.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir churn-prediction
cd churn-prediction
  • Confirm the folder exists by running:
ls

You should see the empty churn-prediction folder listed on your Desktop if you check in Finder (macOS) or File Explorer (Windows).

  • Create a virtual environment by running this command:
python3 -m venv venv

What is a virtual environment?

A virtual environment is an isolated copy of Python and its packages that lives inside your project folder. Packages you install here stay local to this project and won't interfere with anything else on your system.

  • Activate the virtual environment:

macOS

  • Run this command to activate:
source venv/bin/activate

Windows

  • Run this command to activate:
.\venv\Scripts\Activate.ps1

Seeing an execution policy error?

Run this command first to allow scripts, then try the activate command again:

Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser

You should see (venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.

Don't see (venv) in your prompt?

Make sure you ran the activate command from inside the churn-prediction folder. If you closed and reopened your terminal, you need to cd back into the folder and run the activate command again.

Help me activate my virtual environment

  • Install all the project dependencies by running this command:
pip install pandas scikit-learn imbalanced-learn matplotlib seaborn jupyter

What are these packages?

  • pandas handles data loading and manipulation.
  • scikit-learn provides machine learning models and evaluation tools.
  • imbalanced-learn adds SMOTENC for handling class imbalance with mixed data types.
  • matplotlib and seaborn create visualizations.
  • jupyter runs the interactive notebooks where you will do your analysis.

You should see output scrolling as each package downloads and installs. The final line should read Successfully installed followed by a list of package names.

Install command failing?

Make sure (venv) appears in your prompt before running pip install. If you see a permissions error, you are likely installing into the system Python instead of your virtual environment.

Help me fix my pip install error

Create the professional directory structure

A clean folder layout tells anyone reading your repository exactly where to find data, notebooks, and reusable code. This structure mirrors what professional data science teams use in production.

  • Create the project directories and the empty __init__.py file by running these commands:
mkdir -p data/raw data/processed notebooks src
touch src/__init__.py

Why create src/__init__.py?

The empty __init__.py file tells Python that src/ is a package you can import from. Later, your notebooks will import reusable functions from this folder.

  • Confirm your directories were created by running:
ls

You should see data, notebooks, src, and venv listed in the output.

Now you will create three configuration files that every professional repository needs. Open the churn-prediction folder in your code editor.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code (or your preferred editor) and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Select the churn-prediction folder on your Desktop, then click Open.
  • Create a new file called requirements.txt in the project root and paste the following content:
pandas
scikit-learn
imbalanced-learn
matplotlib
seaborn
jupyter

Why list only direct dependencies?

You list only the packages you directly use, not their sub-dependencies. This keeps the file readable and lets anyone reproduce your environment by running pip install -r requirements.txt.

  • Create a new file called .gitignore in the project root and paste the following content:
# Data files
data/raw/
data/processed/

# Jupyter checkpoints
.ipynb_checkpoints/

# Virtual environment
venv/
__pycache__/
*.pyc

What does .gitignore do?

The .gitignore file tells Git which files to skip when tracking changes. You exclude raw data (too large for GitHub), your virtual environment (anyone can recreate it from requirements.txt), and auto-generated cache files.

  • Create a new file called README.md in the project root and paste the following content:
# Predict Customer Churn

A machine learning project to predict telecom customer churn.

## Project Structure

```
data/         # Raw and processed datasets
notebooks/    # Jupyter notebooks
src/          # Reusable Python modules
```

Why start the README now?

A README is the first thing a hiring manager sees on your GitHub repository. Starting with a skeleton now means you can fill in results as you go, rather than scrambling to write documentation at the end.

  • Save all three files (requirements.txt, .gitignore, README.md).

Your editor's file explorer should now show data/, notebooks/, src/, and all three new files at the root level.

✔️ Awesome, I've got everything!

Great. Double check that you have saved all three files before moving on.

ⓧ I'd like to double check the full code

Here are the complete contents of each file for comparison.

requirements.txt:

pandas
scikit-learn
imbalanced-learn
matplotlib
seaborn
jupyter

.gitignore:

# Data files
data/raw/
data/processed/

# Jupyter checkpoints
.ipynb_checkpoints/

# Virtual environment
venv/
__pycache__/
*.pyc

README.md:

# Predict Customer Churn

A machine learning project to predict telecom customer churn.

## Project Structure

```
data/         # Raw and processed datasets
notebooks/    # Jupyter notebooks
src/          # Reusable Python modules
```
Initialize a Git repository and make the first commit

Version control tracks every change you make, so you can always revert if something breaks. It also makes your project ready to push to GitHub later.

  • Switch back to your terminal (make sure you are still inside churn-prediction).
  • Initialize a Git repository and commit your project skeleton by running these commands:
git init
git add .
git commit -m "Initial project structure"

What do these commands do?

  • git init creates a new Git repository inside your project folder.
  • git add . stages all your files (except those in .gitignore) for the commit.
  • git commit -m saves a snapshot of your project with a descriptive message.

You should see output listing the files that were committed, including README.md, requirements.txt, .gitignore, and src/__init__.py.

Seeing a 'please tell me who you are' error?

Git needs your name and email for commits. Run these two commands (replacing the placeholder values with your own), then try the commit again:

git config --global user.name "Your Name"
git config --global user.email "your@email.com"

Help me configure Git for my first commit

Your project skeleton is locked in. Next up, you will load the Telco Customer Churn dataset and explore what it tells you about why customers leave.

Exploratory Data Analysis

In the last step you built the project scaffolding and installed all your dependencies. Now it is time to meet your data.

Before training any model, you need to understand what the data actually looks like. Exploratory Data Analysis (EDA) reveals patterns, data quality issues, and early signals about why customers leave.

In this step, get ready to:
  • Load and inspect the Telco Customer Churn dataset.
  • Visualize class distribution and key customer relationships.
  • Document business insights in Markdown narrative cells.
Download the dataset and launch Jupyter

The IBM Telco Customer Churn dataset contains 7,043 customers and 21 features including contract type, tenure, and monthly charges. You will download it from Kaggle and save it into your project's raw data folder.

  • Open the dataset page in your browser:
  • kaggle.com/datasets/blastchar/telco-customer-churn
  • Click the Download button near the top of the page.
  • You will need a free Kaggle account to download. Sign in or create one if prompted.
  • After downloading, unzip the file if your browser does not unzip it automatically.
  • Move the CSV file into your project by running this command:
mv ~/Downloads/WA_Fn-UseC_-Telco-Customer-Churn.csv data/raw/

What does this command do?

This moves the downloaded CSV from your Downloads folder into the data/raw/ directory inside your project. Keeping raw data in its own folder is a professional convention that separates original data from anything you transform later.

  • Confirm the file landed correctly by running:
ls data/raw/

You should see WA_Fn-UseC_-Telco-Customer-Churn.csv listed in the output.

File not showing up?

If you see an empty result, check that your Downloads folder still has the file. Kaggle sometimes delivers a zip file named archive.zip. Unzip it first, then move the CSV.

On Windows, replace ~/Downloads/ with your actual Downloads path (e.g. C:\Users\YourName\Downloads\).

help me move my downloaded Kaggle CSV into data/raw/

  • Launch Jupyter Notebook by running this command:
jupyter notebook

A browser tab opens showing the Jupyter file browser at your project root.

  • In the Jupyter file browser, click the notebooks folder to open it.
  • Click New in the top right corner.
  • Select Python 3 from the dropdown.
  • Click the notebook title at the top (it says Untitled) and rename it to 01_eda.

Why number the notebooks?

Prefixing notebooks with numbers (01_, 02_, 03_) signals pipeline order to anyone browsing your repository. Hiring managers see immediately that your work is structured and reproducible.

Load and inspect the data

Your first notebook cells will import libraries and load the dataset. The goal here is to understand the shape and quality of your data before creating any visualizations.

  • In the first cell of 01_eda.ipynb, add this Markdown heading by changing the cell type to Markdown in the toolbar dropdown, then typing:
# 01 - Exploratory Data Analysis

Goal: Understand the Telco Customer Churn dataset, identify data quality issues, and explore patterns that signal WHY customers leave.
  • Press Shift+Enter to run the Markdown cell.
  • In the new empty code cell, import your libraries and load the dataset by pasting:
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

# Set a clean visual style for all charts
sns.set_style("whitegrid")

# Load the raw dataset
df = pd.read_csv("../data/raw/WA_Fn-UseC_-Telco-Customer-Churn.csv")
print(f"Dataset shape: {df.shape}")
df.head()

What does this code do?

The ../data/raw/ path navigates up from the notebooks/ folder to the project root, then into data/raw/.

Printing the shape immediately confirms you have the expected 7,043 rows and 21 columns. df.head() displays the first five rows so you can see actual values.

  • Run the cell with Shift+Enter.

You should see Dataset shape: (7043, 21) and a table showing customer data with columns like customerID, gender, tenure, and Churn.

  • In the next code cell, inspect data types and identify the TotalCharges issue by pasting:
# Check data types - notice TotalCharges is 'object' (string), not numeric
print(df.dtypes)
print(f"\nMissing values:\n{df.isnull().sum().sum()} total NaN values")

# The real issue: TotalCharges has blank strings instead of NaN
blanks = df[df["TotalCharges"] == " "]
print(f"\nRows with blank TotalCharges: {len(blanks)}")
blanks[["customerID", "tenure", "TotalCharges"]]

What is the TotalCharges issue?

The TotalCharges column looks like it should be numeric, but pandas loaded it as a string (object dtype). That is because 11 rows contain a blank space character instead of a number.

These 11 customers all have tenure of 0. They signed up but never got charged. You will fix this in the feature engineering step.

  • Run the cell with Shift+Enter.

You should see TotalCharges listed with dtype object, 0 total NaN values, and 11 rows with blank TotalCharges displayed in a table.

  • Add a Markdown cell below to document this finding. Change the cell type to Markdown and type:
## Data Quality Notes

- **TotalCharges** contains 11 blank strings (not NaN) for customers with 0 tenure.
- These will need conversion to numeric in the preprocessing step.
- No other missing values detected.
  • Run the Markdown cell with Shift+Enter.

Seeing a FileNotFoundError?

If pd.read_csv throws a FileNotFoundError, confirm you are running the notebook from the notebooks/ folder. The relative path ../data/raw/ only works if Jupyter opened inside notebooks/.

Also double-check that the CSV filename matches exactly: WA_Fn-UseC_-Telco-Customer-Churn.csv.

help me fix a FileNotFoundError when loading the Telco churn CSV

Visualize key relationships and document findings

Now that you know the data's shape and quality, it is time to see how churn distributes across customers. These charts will reveal patterns that guide your feature engineering choices later.

  • In the next code cell, visualize the class distribution by pasting:
# Visualize target variable distribution (class imbalance check)
churn_counts = df["Churn"].value_counts()
print(churn_counts)
print(f"\nChurn rate: {churn_counts['Yes'] / len(df) * 100:.1f}%")

plt.figure(figsize=(6, 4))
sns.countplot(data=df, x="Churn", palette=["steelblue", "coral"])
plt.title("Customer Churn Distribution")
plt.ylabel("Count")
plt.tight_layout()
plt.show()

What does this chart reveal?

The dataset has roughly 73% non-churners and 27% churners. This is class imbalance. If you trained a model naively, it could just predict "No" every time and still appear 73% accurate.

You will address this in Step 3 using SMOTENC to synthetically balance the training data.

  • Run the cell with Shift+Enter.

You should see a bar chart with two bars. The "No" bar is roughly three times taller than "Yes".

  • In the next code cell, plot churn rate by contract type by pasting:
# Churn rate by contract type - month-to-month vs locked-in contracts
contract_churn = df.groupby("Contract")["Churn"].value_counts(normalize=True).unstack()

contract_churn["Yes"].plot(kind="bar", figsize=(8, 4), color="coral", edgecolor="black")
plt.title("Churn Rate by Contract Type")
plt.ylabel("Churn Rate")
plt.xlabel("Contract Type")
plt.xticks(rotation=0)
plt.tight_layout()
plt.show()

What does this chart reveal?

Month-to-month customers churn at a dramatically higher rate than those on one-year or two-year contracts. This makes business sense. Customers without commitment have no switching cost.

  • Run the cell with Shift+Enter.

You should see a bar chart where the month-to-month bar is much taller than the one-year and two-year bars.

  • In the next code cell, create tenure and monthly charges visualizations by pasting:
# Tenure distribution and monthly charges by churn status
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# Left: Tenure histogram split by churn
sns.histplot(data=df, x="tenure", hue="Churn", bins=30, kde=True, ax=axes[0],
             palette=["steelblue", "coral"])
axes[0].set_title("Tenure Distribution by Churn Status")
axes[0].set_xlabel("Tenure (months)")

# Right: Monthly charges boxplot split by churn
sns.boxplot(data=df, x="Churn", y="MonthlyCharges", palette=["steelblue", "coral"],
            ax=axes[1])
axes[1].set_title("Monthly Charges by Churn Status")

plt.tight_layout()
plt.show()

What do these charts reveal?

The tenure histogram shows that customers who churn are heavily concentrated in the first few months. Long-tenure customers rarely leave.

The monthly charges boxplot shows churners tend to pay more per month. Customers on premium plans without long-term contracts are the highest flight risk.

  • Run the cell with Shift+Enter.

You should see two side-by-side charts. The left histogram shows churners clustered at low tenure. The right boxplot shows churners with higher median monthly charges.

  • Add a final Markdown cell summarizing your business insights. Change the cell type to Markdown and type:
## Key EDA Findings: Why Customers Leave

1. **Class imbalance**: ~27% churn rate means we need resampling or class weights during modeling.
2. **Contract type is a strong signal**: Month-to-month customers churn at 3-4x the rate of locked-in contracts.
3. **Tenure matters**: Most churn happens in the first 1-12 months. Long-tenure customers are loyal.
4. **Higher charges correlate with churn**: Customers paying more monthly are more likely to leave, possibly because they have more alternatives or feel less value.

**Business implication**: A retention team should target new, month-to-month customers with high monthly charges. These are the highest-risk group.
  • Run the Markdown cell with Shift+Enter.
  • Save the notebook by pressing Cmd+S (macOS) or Ctrl+S (Windows).

Why write narrative Markdown cells?

A notebook full of code and no explanation is useless to anyone else. Narrative cells show hiring managers you can translate data into business thinking.

The structure you just followed (what you see, what it means, what to do about it) is exactly how senior data scientists communicate EDA findings.

Charts not rendering?

If you see text output instead of charts, make sure you have matplotlib installed in your virtual environment. Run pip list | grep matplotlib in a new terminal to confirm.

If plots appear in a separate window instead of inline, add %matplotlib inline at the top of your first code cell.

help me get matplotlib charts to render inside my Jupyter notebook

✔️ Awesome, I've got everything!

Great. Make sure you have saved your notebook before moving on.

ⓧ I'd like to double check the full code

Here are all the code cells in 01_eda.ipynb in order. Markdown cells are shown as comments for reference.

# --- Markdown Cell ---
# # 01 - Exploratory Data Analysis
# Goal: Understand the Telco Customer Churn dataset, identify data quality
# issues, and explore patterns that signal WHY customers leave.

# --- Code Cell 1: Imports and Load ---
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

# Set a clean visual style for all charts
sns.set_style("whitegrid")

# Load the raw dataset
df = pd.read_csv("../data/raw/WA_Fn-UseC_-Telco-Customer-Churn.csv")
print(f"Dataset shape: {df.shape}")
df.head()

# --- Code Cell 2: Inspect Data Types and TotalCharges ---
# Check data types - notice TotalCharges is 'object' (string), not numeric
print(df.dtypes)
print(f"\nMissing values:\n{df.isnull().sum().sum()} total NaN values")

# The real issue: TotalCharges has blank strings instead of NaN
blanks = df[df["TotalCharges"] == " "]
print(f"\nRows with blank TotalCharges: {len(blanks)}")
blanks[["customerID", "tenure", "TotalCharges"]]

# --- Markdown Cell ---
# ## Data Quality Notes
# - **TotalCharges** contains 11 blank strings (not NaN) for customers with 0 tenure.
# - These will need conversion to numeric in the preprocessing step.
# - No other missing values detected.

# --- Code Cell 3: Class Distribution ---
# Visualize target variable distribution (class imbalance check)
churn_counts = df["Churn"].value_counts()
print(churn_counts)
print(f"\nChurn rate: {churn_counts['Yes'] / len(df) * 100:.1f}%")

plt.figure(figsize=(6, 4))
sns.countplot(data=df, x="Churn", palette=["steelblue", "coral"])
plt.title("Customer Churn Distribution")
plt.ylabel("Count")
plt.tight_layout()
plt.show()

# --- Code Cell 4: Churn Rate by Contract Type ---
# Churn rate by contract type - month-to-month vs locked-in contracts
contract_churn = df.groupby("Contract")["Churn"].value_counts(normalize=True).unstack()

contract_churn["Yes"].plot(kind="bar", figsize=(8, 4), color="coral", edgecolor="black")
plt.title("Churn Rate by Contract Type")
plt.ylabel("Churn Rate")
plt.xlabel("Contract Type")
plt.xticks(rotation=0)
plt.tight_layout()
plt.show()

# --- Code Cell 5: Tenure and Monthly Charges ---
# Tenure distribution and monthly charges by churn status
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# Left: Tenure histogram split by churn
sns.histplot(data=df, x="tenure", hue="Churn", bins=30, kde=True, ax=axes[0],
             palette=["steelblue", "coral"])
axes[0].set_title("Tenure Distribution by Churn Status")
axes[0].set_xlabel("Tenure (months)")

# Right: Monthly charges boxplot split by churn
sns.boxplot(data=df, x="Churn", y="MonthlyCharges", palette=["steelblue", "coral"],
            ax=axes[1])
axes[1].set_title("Monthly Charges by Churn Status")

plt.tight_layout()
plt.show()

# --- Markdown Cell ---
# ## Key EDA Findings: Why Customers Leave
# 1. **Class imbalance**: ~27% churn rate means we need resampling or class weights during modeling.
# 2. **Contract type is a strong signal**: Month-to-month customers churn at 3-4x the rate of locked-in contracts.
# 3. **Tenure matters**: Most churn happens in the first 1-12 months. Long-tenure customers are loyal.
# 4. **Higher charges correlate with churn**: Customers paying more monthly are more likely to leave.
# **Business implication**: A retention team should target new, month-to-month customers with high monthly charges.

Your EDA is complete. You now have a clear picture of the data quality issues and the key factors that drive churn. Next up, you will engineer new features and build a reusable preprocessing pipeline that the modeling notebook can import.

Feature Engineering and Preprocessing

Your EDA notebook revealed key patterns: short-tenure customers churn more, month-to-month contracts are risky, and the dataset is imbalanced (about 26.5% churn).

Now you need to transform those insights into model-ready features. In this step, you will clean the TotalCharges column, engineer new features from domain knowledge, and build a reusable preprocessing module that handles the full pipeline from raw data to balanced train/test splits.

In this step, get ready to:
  • Handle missing values and create derived features in a new notebook.
  • Build a reusable preprocessing function in src/preprocessing.py.
  • Split data and apply SMOTENC to handle class imbalance on the training set only.
Handle missing values and create derived features

The TotalCharges column contains blank strings for customers with zero tenure. You also need features that capture patterns your EDA revealed, like average spending per month and whether customers have premium support.

  • In Jupyter Notebook, create a new notebook by clicking New then Python 3.
  • Rename it to 02_feature_engineering by clicking the title area at the top.
  • Save the notebook into the notebooks/ folder.
  • Add a Markdown cell at the top with a title, then load the dataset by pasting this code into the first code cell:
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
from imblearn.over_sampling import SMOTENC

# Load the raw dataset
df = pd.read_csv('../data/raw/WA_Fn-UseC_-Telco-Customer-Churn.csv')
print(f'Dataset shape: {df.shape}')

What does this code do?

This cell imports the libraries you need for preprocessing and loads the raw CSV. The SMOTENC import comes from imbalanced-learn, a library designed for handling imbalanced datasets.

  • Run the cell by pressing Shift+Enter.
  • You should see Dataset shape: (7043, 21) printed below the cell.

Seeing a ModuleNotFoundError?

If imbalanced-learn is not found, make sure your Jupyter kernel is using the virtual environment where you installed your dependencies in Step 1.

Help me fix my imbalanced-learn import error.

  • Fix the TotalCharges column by pasting this code into a new cell:
# Convert TotalCharges from string to numeric, coercing blank strings to NaN
df['TotalCharges'] = pd.to_numeric(df['TotalCharges'], errors='coerce')

# Check how many NaN values were created
print(f'Missing TotalCharges: {df["TotalCharges"].isna().sum()}')

# Fill NaN with the median value
df['TotalCharges'].fillna(df['TotalCharges'].median(), inplace=True)
print(f'Missing after imputation: {df["TotalCharges"].isna().sum()}')

What does this code do?

The pd.to_numeric() function with errors='coerce' converts any value that cannot be parsed as a number into NaN. The Telco dataset stores blank strings (space characters) in TotalCharges for customers with 0 tenure.

You fill those missing values with the median, which is resistant to outliers. You should see 11 missing values before imputation and 0 after.

  • Run the cell.
  • You should see Missing TotalCharges: 11 then Missing after imputation: 0.
  • Create two derived features by pasting this into a new cell:
# Drop customerID since it has no predictive value
df.drop(columns=['customerID'], inplace=True)

# Derived feature: average monthly charge (guards against division by zero)
df['AvgMonthlyCharge'] = df['TotalCharges'] / (df['tenure'] + 1)

# Derived feature: binary flag for premium support services
df['HasPremiumSupport'] = (
    (df['OnlineSecurity'] == 'Yes') | (df['TechSupport'] == 'Yes')
).astype(int)

print('New columns: AvgMonthlyCharge, HasPremiumSupport')
print(df[['AvgMonthlyCharge', 'HasPremiumSupport']].head())

What does this code do?

You create AvgMonthlyCharge by dividing TotalCharges by tenure. Adding 1 to tenure avoids division by zero for brand-new customers.

The HasPremiumSupport flag combines OnlineSecurity and TechSupport into a single binary indicator. Your EDA likely showed that customers without these services churn at higher rates.

  • Run the cell.
  • You should see a preview table with the two new columns showing numeric values.
  • One-hot encode the remaining categorical features by pasting this into a new cell:
# Encode the target column as 0/1
df['Churn'] = (df['Churn'] == 'Yes').astype(int)

# One-hot encode all remaining object columns
cat_cols = df.select_dtypes(include='object').columns.tolist()
df = pd.get_dummies(df, columns=cat_cols, drop_first=True)

print(f'Shape after encoding: {df.shape}')
print(f'Churn distribution:')
print(df['Churn'].value_counts())

What does this code do?

First, the target column Churn is converted from Yes/No strings to 1/0 integers. Then pd.get_dummies() converts every remaining text column into binary indicator columns.

The drop_first=True parameter avoids the dummy variable trap by dropping one category per feature. Your DataFrame should now contain only numeric columns.

  • Run the cell.
  • You should see a shape of approximately (7043, 32) and the churn distribution showing the class imbalance.

Seeing unexpected column counts?

If your shape is significantly different, check that you dropped customerID before encoding. The ID column would create thousands of dummy columns if left in.

Help me debug my one-hot encoding output.

Build the reusable preprocessing module

Right now your preprocessing logic lives only in a notebook. To make it importable by the modeling notebook (and any future scripts), you will consolidate it into a function inside src/preprocessing.py.

  • In your code editor, create a new file called preprocessing.py inside the src/ folder.
  • Paste the following code into src/preprocessing.py:
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
from imblearn.over_sampling import SMOTENC

def preprocess_data(df, target_col='Churn', test_size=0.2, random_state=42):
    """
    Full preprocessing pipeline for Telco churn dataset.
    Returns: X_train_res, X_test, y_train_res, y_test, feature_names
    """
    df = df.copy()
    # Fix TotalCharges
    df['TotalCharges'] = pd.to_numeric(df['TotalCharges'], errors='coerce')
    df['TotalCharges'].fillna(df['TotalCharges'].median(), inplace=True)
    # Drop customerID
    df.drop(columns=['customerID'], inplace=True)
    # Derived features
    df['AvgMonthlyCharge'] = df['TotalCharges'] / (df['tenure'] + 1)
    df['HasPremiumSupport'] = ((df['OnlineSecurity'] == 'Yes') | (df['TechSupport'] == 'Yes')).astype(int)
    # Encode target
    df[target_col] = (df[target_col] == 'Yes').astype(int)
    # One-hot encode categoricals
    cat_cols = df.select_dtypes(include='object').columns.tolist()
    df = pd.get_dummies(df, columns=cat_cols, drop_first=True)
    X = df.drop(columns=[target_col])
    y = df[target_col]
    X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=test_size, stratify=y, random_state=random_state)
    # SMOTENC: identify categorical column indices after encoding (all bool/uint8)
    cat_indices = [i for i, dtype in enumerate(X_train.dtypes) if dtype in ['uint8', 'bool']]
    smote = SMOTENC(categorical_features=cat_indices, random_state=random_state)
    X_train_res, y_train_res = smote.fit_resample(X_train, y_train)
    return X_train_res, X_test, y_train_res, y_test, X.columns.tolist()

What does this function do?

The preprocess_data() function wraps every transformation into a single callable: fixing TotalCharges, creating derived features, encoding categoricals, splitting data, and applying SMOTENC.

It identifies categorical columns after one-hot encoding by their uint8 dtype (what pd.get_dummies produces). This list of indices tells SMOTENC which features are binary so it generates synthetic samples correctly for mixed data types.

Why SMOTENC instead of regular SMOTE?

Regular SMOTE interpolates between data points, which works fine for continuous numbers. For categorical features (even after one-hot encoding), simple interpolation produces values like 0.6 that have no meaning for a binary column.

SMOTENC handles mixed numerical and categorical features correctly by using a different distance metric for the categorical columns. This produces more realistic synthetic minority-class samples.

  • Save src/preprocessing.py.
  • Open src/__init__.py and add this import so the module is accessible as a package:
from .preprocessing import preprocess_data
  • Save src/__init__.py.

Import error when running the notebook later?

Make sure src/__init__.py exists and contains the import line. Python needs this file to treat the src/ folder as a package.

Also check that your Jupyter kernel was started from the project root directory (the folder containing both src/ and notebooks/).

Help me fix my src module import.

Split data and apply SMOTENC in the notebook

With the reusable module ready, you can now call it from the notebook to produce your final train/test splits. Applying SMOTENC only to the training set prevents data leakage from synthetic samples into your evaluation.

  • Switch back to your 02_feature_engineering.ipynb notebook and add a new cell.
  • Import and run the preprocessing function by pasting this code:
import sys
sys.path.insert(0, '..')
from src.preprocessing import preprocess_data

# Reload the raw data and run the full pipeline
df_raw = pd.read_csv('../data/raw/WA_Fn-UseC_-Telco-Customer-Churn.csv')
X_train_res, X_test, y_train_res, y_test, feature_names = preprocess_data(df_raw)

print(f'Training set (resampled): {X_train_res.shape}')
print(f'Test set: {X_test.shape}')
print('Training label distribution:')
print(y_train_res.value_counts())

What does this code do?

The sys.path.insert(0, '..') line lets the notebook import from the src/ folder one level up. Then preprocess_data() runs the entire pipeline: cleaning, feature engineering, splitting, and SMOTENC resampling.

After resampling, the training labels should be perfectly balanced (equal counts of 0 and 1). The test set remains untouched, preserving the real-world class distribution for honest evaluation.

  • Run the cell.
  • You should see balanced training labels (equal counts for 0 and 1) and a test set of approximately 1,409 rows.
  • Save the processed data to data/processed/ by pasting this into a new cell:
# Save processed datasets for the modeling notebook
X_train_res.to_csv('../data/processed/X_train_resampled.csv', index=False)
X_test.to_csv('../data/processed/X_test.csv', index=False)
y_train_res.to_csv('../data/processed/y_train_resampled.csv', index=False)
y_test.to_csv('../data/processed/y_test.csv', index=False)

print('Saved to data/processed/')

Why save the processed data?

Saving the train/test splits as CSVs means your modeling notebook can load them directly without re-running the preprocessing. This keeps notebooks independent and the pipeline reproducible.

  • Run the cell.
  • You should see Saved to data/processed/ printed.
  • Verify the files exist by opening a terminal and running:
ls data/processed/
  • You should see X_test.csv, X_train_resampled.csv, y_test.csv, and y_train_resampled.csv in the output.

SMOTENC raising a ValueError?

The most common cause is that categorical_features indices are out of range. Make sure you dropped customerID and encoded the target before splitting X and y.

If you see a memory error, ensure you are running SMOTENC on the training set only (about 5,600 rows), not the full dataset.

Help me debug my SMOTENC error.

✔️ Awesome, I've got everything!

Great. Make sure you have saved both src/preprocessing.py and the notebook 02_feature_engineering.ipynb.

ⓧ I'd like to double check the full code

Here is the complete src/preprocessing.py file:

import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
from imblearn.over_sampling import SMOTENC

def preprocess_data(df, target_col='Churn', test_size=0.2, random_state=42):
    """
    Full preprocessing pipeline for Telco churn dataset.
    Returns: X_train_res, X_test, y_train_res, y_test, feature_names
    """
    df = df.copy()
    # Fix TotalCharges
    df['TotalCharges'] = pd.to_numeric(df['TotalCharges'], errors='coerce')
    df['TotalCharges'].fillna(df['TotalCharges'].median(), inplace=True)
    # Drop customerID
    df.drop(columns=['customerID'], inplace=True)
    # Derived features
    df['AvgMonthlyCharge'] = df['TotalCharges'] / (df['tenure'] + 1)
    df['HasPremiumSupport'] = ((df['OnlineSecurity'] == 'Yes') | (df['TechSupport'] == 'Yes')).astype(int)
    # Encode target
    df[target_col] = (df[target_col] == 'Yes').astype(int)
    # One-hot encode categoricals
    cat_cols = df.select_dtypes(include='object').columns.tolist()
    df = pd.get_dummies(df, columns=cat_cols, drop_first=True)
    X = df.drop(columns=[target_col])
    y = df[target_col]
    X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=test_size, stratify=y, random_state=random_state)
    # SMOTENC: identify categorical column indices after encoding (all bool/uint8)
    cat_indices = [i for i, dtype in enumerate(X_train.dtypes) if dtype in ['uint8', 'bool']]
    smote = SMOTENC(categorical_features=cat_indices, random_state=random_state)
    X_train_res, y_train_res = smote.fit_resample(X_train, y_train)
    return X_train_res, X_test, y_train_res, y_test, X.columns.tolist()

And here is src/__init__.py:

from .preprocessing import preprocess_data

Your data is now clean, feature-rich, and balanced for training. Next up, you will train and compare multiple classifiers to see which one best predicts customer churn.

Train and Evaluate Models

Your data is preprocessed, balanced, and split. Now comes the core question: which algorithm best predicts churn for this dataset?

In this step, you will train three classifiers, evaluate them on the held-out test set, and select the best one using business reasoning. You will also build a reusable evaluation module so your metrics logic stays clean and importable.

In this step, get ready to:
  • Train Logistic Regression, Random Forest, and Gradient Boosting with 5-fold stratified cross-validation.
  • Evaluate all models on the test set and plot ROC curves for comparison.
  • Select the best model with documented business reasoning.
Create the evaluation utility module

Before jumping into the notebook, you will create a reusable Python module that handles model evaluation. This keeps your notebook focused on the experiment and lets you reuse the same metrics logic across projects.

  • Create a new file called src/evaluation.py by running:
touch src/evaluation.py
  • Confirm the file exists by running:
ls src/

You should see __init__.py, preprocessing.py, and evaluation.py in the output.

  • Open src/evaluation.py in your editor and add the evaluate_model function:
import numpy as np
from sklearn.metrics import roc_auc_score, precision_score, recall_score, f1_score, roc_curve
from sklearn.model_selection import StratifiedKFold, cross_val_score
import matplotlib.pyplot as plt

def evaluate_model(model, X_test, y_test, model_name='Model'):
    """
    Evaluate a fitted model on the test set.
    Returns a dict of metrics.
    """
    y_pred = model.predict(X_test)
    y_prob = model.predict_proba(X_test)[:, 1]
    metrics = {
        'model': model_name,
        'roc_auc': roc_auc_score(y_test, y_prob),
        'precision': precision_score(y_test, y_pred),
        'recall': recall_score(y_test, y_pred),
        'f1': f1_score(y_test, y_pred)
    }
    return metrics

What does this code do?

The evaluate_model function takes a fitted model and test data, then computes four key classification metrics in one call.

  • ROC-AUC uses predicted probabilities (predict_proba) to measure how well the model separates churners from non-churners across all thresholds.
  • Precision, recall, and F1 use hard predictions (predict) at the default 0.5 threshold.
  • Below the evaluate_model function, add the plot_roc_curves function:
def plot_roc_curves(models_dict, X_test, y_test):
    """
    Plot ROC curves for multiple fitted models.
    models_dict: {name: fitted_model}
    """
    plt.figure(figsize=(8, 6))
    for name, model in models_dict.items():
        y_prob = model.predict_proba(X_test)[:, 1]
        fpr, tpr, _ = roc_curve(y_test, y_prob)
        auc = roc_auc_score(y_test, y_prob)
        plt.plot(fpr, tpr, label=f'{name} (AUC={auc:.3f})')
    plt.plot([0,1],[0,1],'k--')
    plt.xlabel('False Positive Rate')
    plt.ylabel('True Positive Rate')
    plt.title('ROC Curves')
    plt.legend()
    plt.tight_layout()
    plt.savefig('reports/figures/roc_curves.png')
    plt.show()

What does this code do?

This function loops through a dictionary of fitted models and plots each model's ROC curve on the same figure. The dashed diagonal line represents a random classifier (AUC = 0.5).

The figure is saved to reports/figures/roc_curves.png so you can embed it in your README later.

  • Save src/evaluation.py.

Import error when running the notebook later?

If you see ImportError: cannot import name 'evaluate_model', check that src/__init__.py contains from .evaluation import evaluate_model. This line was set up in a prior step.

help me fix my evaluate_model import error

✔️ Awesome, I've got everything!

Great. Make sure you have saved src/evaluation.py before moving on.

ⓧ I'd like to double check the full code

import numpy as np
from sklearn.metrics import roc_auc_score, precision_score, recall_score, f1_score, roc_curve
from sklearn.model_selection import StratifiedKFold, cross_val_score
import matplotlib.pyplot as plt

def evaluate_model(model, X_test, y_test, model_name='Model'):
    """
    Evaluate a fitted model on the test set.
    Returns a dict of metrics.
    """
    y_pred = model.predict(X_test)
    y_prob = model.predict_proba(X_test)[:, 1]
    metrics = {
        'model': model_name,
        'roc_auc': roc_auc_score(y_test, y_prob),
        'precision': precision_score(y_test, y_pred),
        'recall': recall_score(y_test, y_pred),
        'f1': f1_score(y_test, y_pred)
    }
    return metrics

def plot_roc_curves(models_dict, X_test, y_test):
    """
    Plot ROC curves for multiple fitted models.
    models_dict: {name: fitted_model}
    """
    plt.figure(figsize=(8, 6))
    for name, model in models_dict.items():
        y_prob = model.predict_proba(X_test)[:, 1]
        fpr, tpr, _ = roc_curve(y_test, y_prob)
        auc = roc_auc_score(y_test, y_prob)
        plt.plot(fpr, tpr, label=f'{name} (AUC={auc:.3f})')
    plt.plot([0,1],[0,1],'k--')
    plt.xlabel('False Positive Rate')
    plt.ylabel('True Positive Rate')
    plt.title('ROC Curves')
    plt.legend()
    plt.tight_layout()
    plt.savefig('reports/figures/roc_curves.png')
    plt.show()
Train three models with cross-validation

Cross-validation gives you a reliable estimate of how each model will perform on unseen data. By using stratified folds, you preserve the class ratio (churn vs. no churn) in every fold.

  • Create a new notebook called notebooks/03_modeling.ipynb in Jupyter.
  • In the first cell, set up your imports and load the processed data:
import pandas as pd
import numpy as np
import os
import sys
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
from sklearn.model_selection import StratifiedKFold, cross_val_score
import joblib

# Add project root to path for module imports
sys.path.insert(0, os.path.abspath('..'))
from src.evaluation import evaluate_model, plot_roc_curves

# Load the processed datasets from the feature engineering step
X_train = pd.read_csv('../data/processed/X_train_resampled.csv')
y_train = pd.read_csv('../data/processed/y_train_resampled.csv').squeeze()
X_test = pd.read_csv('../data/processed/X_test.csv')
y_test = pd.read_csv('../data/processed/y_test.csv').squeeze()

print(f"Training set: {X_train.shape[0]} samples")
print(f"Test set: {X_test.shape[0]} samples")

What does this code do?

  • sys.path.insert(0, os.path.abspath('..')) lets the notebook import from src/ even though the notebook lives in notebooks/.
  • .squeeze() converts the single-column DataFrame from read_csv into a Series, which scikit-learn expects for the target variable.

You should see the training and test set sizes printed. The training set will be larger than the original because SMOTENC added synthetic samples to balance the classes.

  • In the next cell, define the three models and run 5-fold stratified cross-validation:
# Define three candidate classifiers
models = {
    'Logistic Regression': LogisticRegression(max_iter=1000, random_state=42),
    'Random Forest': RandomForestClassifier(n_estimators=100, random_state=42),
    'Gradient Boosting': GradientBoostingClassifier(n_estimators=100, random_state=42)
}

# 5-fold stratified cross-validation on the resampled training set
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

print("Cross-Validation ROC-AUC Scores:")
print("-" * 45)
for name, model in models.items():
    scores = cross_val_score(model, X_train, y_train, cv=cv, scoring='roc_auc')
    print(f"{name}: {scores.mean():.4f} (+/- {scores.std():.4f})")

What does this code do?

  • Each model is trained and evaluated 5 times on different subsets of the training data. The scoring='roc_auc' parameter tells scikit-learn to use ROC-AUC as the evaluation metric for each fold.
  • StratifiedKFold ensures every fold has the same proportion of churn/no-churn labels. This matters because imbalanced folds could give misleading scores.
  • max_iter=1000 for Logistic Regression gives the solver enough iterations to converge on this dataset.

You should see three ROC-AUC scores printed with their standard deviations. All three should be above 0.80, confirming the models learned meaningful patterns.

Seeing a convergence warning?

If Logistic Regression prints a convergence warning, increase max_iter to 2000. This gives the optimizer more steps to find the optimal coefficients.

help me fix scikit-learn convergence warnings

Evaluate on test set and select the best model

Cross-validation scores tell you how models perform on the training data. The real test is performance on the held-out test set that no model has seen during training.

  • In the next cell, fit each model on the full training set and evaluate on the test set:
# Train on full resampled training set, evaluate on held-out test set
results = []
fitted_models = {}

for name, model in models.items():
    # Fit the model on all resampled training data
    model.fit(X_train, y_train)
    # Evaluate using our reusable function
    metrics = evaluate_model(model, X_test, y_test, model_name=name)
    results.append(metrics)
    fitted_models[name] = model

# Display results as a comparison table
results_df = pd.DataFrame(results).set_index('model')
print(results_df.round(4))

What does this code do?

  • Each model is trained on the full resampled training set (not just one fold).
  • The evaluate_model function from src/evaluation.py computes ROC-AUC, precision, recall, and F1 on the held-out test set.
  • fitted_models stores each trained model so you can plot ROC curves and save the winner.

You should see a DataFrame comparing all three models across four metrics.

  • In the next cell, create the output directories and plot the ROC curves:
# Create output directories for figures and saved models
os.makedirs('../reports/figures', exist_ok=True)
os.makedirs('../models', exist_ok=True)

# Change to project root so plot_roc_curves saves to the correct path
original_dir = os.getcwd()
os.chdir('..')

# Plot ROC curves for all three models
plot_roc_curves(fitted_models, X_test, y_test)

# Return to notebooks directory
os.chdir(original_dir)

What does this code do?

The plot_roc_curves function expects to save to reports/figures/roc_curves.png relative to the project root. Temporarily changing directory ensures the file lands in the right place.

You should see a plot with three curves. The curve closest to the top-left corner (highest AUC) belongs to the strongest model.

  • In the next cell, add a Markdown cell documenting your model selection with business reasoning:
# Add this as a Markdown cell in your notebook:
# ## Model Selection
#
# **Best model: Gradient Boosting** (or whichever scores highest)
#
# Why this model?
# - Highest ROC-AUC on the held-out test set
# - For churn prediction, recall matters more than precision: missing a churner
#   (false negative) is costlier than flagging a loyal customer (false positive),
#   because the retention team can verify flags but cannot recover lost customers
# - This model achieves the best balance of recall and precision (highest F1)

Why business reasoning matters here

Hiring managers want to see that you can connect metrics to business impact. In churn prediction, a false negative means a customer leaves without intervention. A false positive means the retention team reaches out to a happy customer, which is low-cost.

This tradeoff means you should favor the model with higher recall, not just the highest overall accuracy. Document this reasoning in your notebook.

  • In the final cell, save the best-performing model to disk:
# Select the best model based on ROC-AUC
best_model_name = results_df['roc_auc'].idxmax()
best_model = fitted_models[best_model_name]

# Save to models/ directory using joblib
joblib.dump(best_model, '../models/best_model.pkl')
print(f"Saved {best_model_name} to models/best_model.pkl")

What does this code do?

joblib serializes the trained model to a file. This is scikit-learn's recommended approach for persisting models with large NumPy arrays. The .pkl file contains the model's learned parameters so you can reload it without retraining.

  • Run all cells in the notebook from top to bottom.
  • Confirm the saved model file exists by running this in your terminal:
ls models/

You should see best_model.pkl in the output.

Notebook kernel dies during training?

If your Jupyter kernel restarts unexpectedly, your machine may be running low on memory. Try closing other applications or reducing n_estimators to 50 for Random Forest and Gradient Boosting.

help me debug my Jupyter kernel crash during model training

Your models are trained, evaluated, and the best one is saved. Next up, you will package everything into a professional GitHub repository with a README that tells the business story.

Package for GitHub

Your models are trained, evaluated, and compared. Now it is time to turn this local project into something a hiring manager can discover and scan in under 30 seconds.

In this step, you will write a professional README that tells the business story, lock down your dependencies for reproducibility, and push everything to GitHub as a pinnable repository.

In this step, get ready to:
  • Write a professional README with Problem > Data > Approach > Results structure.
  • Freeze dependencies and finalize your .gitignore.
  • Push to GitHub and pin the repository to your profile.
Write the professional README

A hiring manager spends about 30 seconds on a GitHub repo before deciding to read further or move on. Your README needs to answer four questions immediately: What problem does this solve? What data did you use? How did you approach it? What were the results?

  • Open README.md in your editor and replace its entire contents with the following:
# Predict Customer Churn

Predicting telecom customer churn using machine learning to help retention teams intervene before customers leave.

## Problem
Telecom companies lose significant revenue to churn. This project identifies at-risk customers from behavioral and account data so retention teams can act proactively.

## Data
IBM Telco Customer Churn dataset: 7,043 customers, 21 features including contract type, tenure, monthly charges, and service subscriptions. ~26% churn rate (class imbalance handled with SMOTENC).

## Approach
1. **EDA**: Identified key churn drivers — contract type, tenure, and monthly charges.
2. **Feature Engineering**: Created `AvgMonthlyCharge` and `HasPremiumSupport` features; one-hot encoded categoricals.
3. **Modeling**: Trained Logistic Regression, Random Forest, and Gradient Boosting with 5-fold stratified CV.

## Results

| Model | ROC-AUC | Precision | Recall | F1 |
|---|---|---|---|---|
| Logistic Regression | 0.843 | 0.67 | 0.58 | 0.62 |
| Random Forest | 0.861 | 0.71 | 0.61 | 0.66 |
| Gradient Boosting | 0.874 | 0.73 | 0.63 | 0.68 |

**Best model**: Gradient Boosting — highest ROC-AUC and best precision/recall balance for the retention use case.

## Project Structure
```
data/         # Raw and processed datasets
notebooks/    # 01_eda, 02_feature_engineering, 03_modeling
src/          # preprocessing.py, evaluation.py
models/       # Saved best model
reports/      # Figures and outputs
```

## Reproduce
```bash
pip install -r requirements.txt
jupyter notebook
```

What makes this README effective?

The Problem > Data > Approach > Results structure mirrors how you would present findings to a stakeholder. Each section is one short paragraph, scannable in seconds.

The results table is the hero. A hiring manager can instantly see you compared multiple models and chose one with reasoning. The Reproduce section proves reproducibility in two commands.

Use your actual numbers

The metric values above (0.843, 0.861, 0.874, etc.) are representative. Replace them with the actual numbers from your 03_modeling.ipynb results to keep your README honest.

  • Save README.md.

Table not rendering correctly?

Markdown tables require a blank line above the header row and the separator row (|---|---|---|---|---|) directly below the header. If GitHub renders it as raw text, check for missing separator pipes.

help me fix my markdown table formatting in the README

Freeze dependencies and update .gitignore

Anyone who clones your repo needs to install the exact same package versions to reproduce your results. Freezing your dependencies captures the precise versions installed in your virtual environment.

  • Freeze your installed packages to requirements.txt by running:
pip freeze > requirements.txt

What does this command do?

pip freeze lists every installed package with its exact version (e.g. pandas==2.1.0). The > redirects that output into your file, replacing the unpinned names you had before.

Your file will contain more packages than the original seven because pip freeze captures sub-dependencies too (like numpy, scipy). That is expected and ensures full reproducibility.

  • Verify the file has pinned versions by running:
head -10 requirements.txt

You should see package names followed by == and a version number on each line.

Next, your .gitignore already excludes data/ and .ipynb_checkpoints/. You also need to exclude the serialized model file so you do not commit large binary objects.

  • In .gitignore, find the last line (*.pyc).
  • Add the following lines below it:
# Model artifacts
models/*.pkl

Why exclude model files?

Serialized models (.pkl files) are binary, often large, and not diffable. Anyone reproducing your work should retrain the model from the notebooks rather than downloading a binary artifact.

  • Save .gitignore.
  • Verify the exclusion is working by running:
git status

You should NOT see models/best_model.pkl in the untracked or modified files list. If you do, the .gitignore pattern is not matching.

Model file still showing in git status?

If the file was already tracked before you updated .gitignore, Git continues tracking it. Remove it from tracking (without deleting the local file) by running git rm --cached models/best_model.pkl and then check git status again.

help me fix my .gitignore so it properly excludes model files

✔️ Awesome, I've got everything!

Great. Double check you have saved both requirements.txt and .gitignore.

ⓧ I'd like to double check the full code

Here is the final .gitignore:

# Data files
data/raw/
data/processed/

# Jupyter checkpoints
.ipynb_checkpoints/

# Virtual environment
venv/
__pycache__/
*.pyc

# Model artifacts
models/*.pkl

Your requirements.txt should contain pinned versions (== notation) for all installed packages. The exact list depends on your environment but should include pandas, scikit-learn, imbalanced-learn, matplotlib, seaborn, jupyter, and joblib.

Push to GitHub and pin the repository

With the README, pinned requirements, and clean .gitignore in place, your project is ready for the world.

  • Stage all changes by running:
git add .
  • Commit with a descriptive message by running:
git commit -m "Add professional README, freeze requirements, update gitignore"
  • Push to your GitHub remote by running:
git push origin main

Push rejected or remote not set?

If you have not yet added a remote, create a new repository on GitHub (do not initialize with a README) and then run git remote add origin followed by your repository URL. Then retry the push.

If the push is rejected because the remote has commits you do not have locally, run git pull --rebase origin main first, then push again.

help me push my project to GitHub

Now pin the repository so it is the first thing people see on your profile.

  • Go to your GitHub profile page (github.com/your-username).
  • Click Customize your pins above the Pinned section.
  • Check the box next to your churn prediction repository.
  • Click Save pins.

Why pin this repo?

Pinned repositories are the first six repos visitors see on your profile. Pinning your churn prediction project ensures hiring managers see your best ML work immediately, not alphabetically-sorted forks or class assignments.

Refresh your profile page. You should see your churn prediction repository displayed in the Pinned section with the README description visible.

Secret mission

SHAP Explainability and Churn Risk Scorecard

Your model can predict churn, but can it explain why? In this bonus challenge, you'll use SHAP values to reveal which features drive customer churn and build a visual scorecard that a retention team can actually use.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally and uses a free dataset from Kaggle. There are no ongoing costs or cloud resources to worry about.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Local project folder on your Desktop (containing notebooks/, src/, data/, models/, reports/, and venv/).
  • GitHub repository (your churn prediction repo pushed in Step 5).

Keep everything running

No action needed. Choose this if you plan to keep iterating on the project, adding new models, or referencing the code.

  • Your local project folder stays on your Desktop.
  • Your GitHub repository remains pinned to your profile as a portfolio piece.
  • The virtual environment (venv/) stays intact so you can reactivate it anytime.

Pause - I'll come back to this later

Shut down any running Jupyter Notebook servers to free up memory. Your files and GitHub repo stay intact for when you return.

  • If you have a Jupyter Notebook server running, press Ctrl+C in the terminal where it is running to stop it.
  • Deactivate your virtual environment by running:
deactivate
  • Your project files and GitHub repository remain untouched for when you return.

Delete - I don't want to use this again

Remove all project resources from your machine and GitHub.

  • Stop any running Jupyter Notebook server by pressing Ctrl+C in the terminal.
  • Deactivate the virtual environment by running:
deactivate
  • Delete the local project folder by running these commands:
cd ~/Desktop
rm -rf [[PROJECT_FOLDER="your project folder name"]]

On Windows, use this command instead:

cd ~\Desktop
rmdir /s /q [[PROJECT_FOLDER="your project folder name"]]
  • Delete the GitHub repository by following these steps:
  • Open your repository on GitHub.
  • Click Settings in the top navigation bar.
  • Scroll down to the Danger Zone section.
  • Click Delete this repository.
  • Click I want to delete this repository.
  • Click I have read and understand these effects.
  • Type the repository name to confirm, then click Delete this repository.

Nice Work!

Nice Work!

You just built a portfolio-grade machine learning project that demonstrates the exact skills hiring managers look for in junior data scientist candidates.

You've learned how to:

  • Structure an end-to-end ML pipeline as a professional GitHub repository with reusable modules, numbered notebooks, and reproducible dependency management.
  • Handle class imbalance with SMOTENC, compare multiple classifiers using stratified cross-validation, and select the best model with business reasoning.
  • Communicate results in a professional README with business framing, methodology, and a performance comparison table that a recruiter can scan in under 30 seconds.
  • Added SHAP feature importance explanations and a Churn Risk Scorecard visualization that translates model outputs into business-actionable insights.

Ready to quiz yourself?