Heart Disease Prediction ML Pipeline

Build a full scikit-learn ML pipeline to predict heart disease from clinical data.

Introduction

30 Second Summary

Heart disease is the leading cause of death worldwide, and early detection can save lives. Machine learning models can analyze patterns across hundreds of patients to flag risk factors faster than manual analysis.

In this project, you will build a complete machine learning pipeline that predicts whether a patient has heart disease based on 13 clinical features. You'll train two classifiers, compare their performance, and export the winning model as a reusable file ready for future API integration.

What You'll Build

A Jupyter Notebook containing a full ML pipeline that loads real clinical data from the UCI Heart Disease dataset, preprocesses it with scikit-learn Pipelines, trains and evaluates Logistic Regression and Random Forest classifiers, and exports the best model as a .joblib file.

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

  • A trained heart disease prediction model exported as a .joblib file ready for future API integration.
  • A complete Jupyter Notebook documenting every stage of the ML pipeline from raw data to evaluated model.
  • Hands-on experience with scikit-learn Pipelines, preprocessing, and model comparison that transfers to any tabular prediction problem.
  • Secret Mission: Boost your model's performance with hyperparameter tuning using GridSearchCV.

Are there any prerequisites?

You'll need Python and VS Code already installed on your machine. Familiarity with running Python code in a Jupyter Notebook is helpful but not required.

Before We Start

Before We Start...

Set Up Your ML Environment

Before you can train a machine learning model, you need the right tools in place. A proper ML environment isolates your project's libraries from other Python projects on your system, preventing version conflicts.

In this step, you'll install VS Code extensions for running Jupyter Notebooks, create an isolated virtual environment with all the ML libraries you need, and verify everything works inside a fresh notebook.

In this step, get ready to:
  • Install Python and Jupyter extensions in VS Code.
  • Create a virtual environment and install ML libraries.
  • Create your project notebook and verify the environment.
Install VS Code extensions

VS Code doesn't understand Python or Jupyter notebooks by default. You need two extensions to enable Python code execution and notebook support.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Open the Extensions panel by pressing Cmd+Shift+X (macOS) or Ctrl+Shift+X (Windows).
  • In the search bar at the top of the Extensions panel, type Python.
  • Find the extension published by Microsoft (ID: ms-python.python) and click Install.
  • Back in the search bar, clear the text and type Jupyter.
  • Find the extension published by Microsoft (ID: ms-toolsai.jupyter) and click Install.

What do these extensions do?

The Python extension provides IntelliSense, debugging, and environment management for Python files.

The Jupyter extension lets you create and run .ipynb notebook files directly in VS Code, with cell-by-cell execution and inline output.

  • Verify the extensions work by pressing Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows) to open the Command Palette.
  • Type Create: New Jupyter Notebook and press Enter.

A new .ipynb file should open with an empty code cell ready for input.

Don't see the notebook command?

Make sure both extensions show Installed in the Extensions panel. You may need to reload VS Code after installing them.

Press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows), type Reload Window, and press Enter.

help me troubleshoot why my Jupyter extension isn't showing the Create New Jupyter Notebook command in VS Code

  • Close the untitled notebook for now. You will create the real one shortly.
Create a virtual environment and install libraries

A virtual environment is an isolated Python installation that keeps this project's libraries separate from other projects on your machine. Without one, installing a new version of a library for one project could break another.

  • Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, same shortcut on macOS and Windows).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir heart-disease-prediction
cd heart-disease-prediction
  • Confirm the folder exists by running:
ls

You should see an empty folder (no output) since you just created it.

  • Create your virtual environment by running:
python -m venv heart_disease_env

What does this command do?

python -m venv tells Python to run its built-in virtual environment module. heart_disease_env is the name of the folder where the isolated Python installation lives.

  • Confirm the virtual environment folder was created by running:
ls

You should see heart_disease_env listed in the output.

  • Activate the virtual environment so that any packages you install go into this isolated environment:

macOS

source heart_disease_env/bin/activate

Windows

heart_disease_env\Scripts\activate

You should see (heart_disease_env) appear at the start of your terminal prompt. This confirms you are working inside the virtual environment.

Don't see (heart_disease_env) in your prompt?

On Windows, you may need to enable script execution first. Run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned in PowerShell, then try the activate command again.

On macOS, make sure you typed source before the path. Without it, the script runs in a subshell and the activation disappears immediately.

help me activate my Python virtual environment in VS Code terminal

  • Install all the ML libraries you need for this project by running:
pip install scikit-learn pandas numpy matplotlib seaborn ipykernel

What are these libraries?

  • scikit-learn provides ML algorithms, preprocessing tools, and evaluation metrics.
  • pandas handles tabular data (loading CSV files, filtering rows, computing statistics).
  • numpy provides fast numerical operations on arrays.
  • matplotlib and seaborn create charts and visualizations.
  • ipykernel lets VS Code use this virtual environment as a Jupyter notebook kernel.

The installation may take a minute as pip downloads and installs each package and its dependencies.

  • Verify scikit-learn installed correctly by running:
python -c "import sklearn; print(sklearn.__version__)"

You should see a version number printed (such as 1.9.0). This confirms the library is installed and importable.

Seeing a ModuleNotFoundError?

Make sure (heart_disease_env) appears in your terminal prompt before running the verification command. If it doesn't, activate the environment again and re-run the pip install command.

help me fix ModuleNotFoundError when importing sklearn in my virtual environment

Download the dataset and create your notebook

The dataset you'll use in this project is the UCI Heart Disease Cleveland dataset, a classic benchmark with 303 patient records and 13 clinical features. You'll load it directly from a URL in the next step, so no manual download is needed.

For now, you need to create the Jupyter Notebook file where all your ML code will live.

  • Press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows) to open the Command Palette.
  • Type Create: New Jupyter Notebook and press Enter.
  • Save the new notebook immediately by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Navigate to your heart-disease-prediction folder on the Desktop.
  • Name the file heart_disease_prediction.ipynb and click Save.
  • Select the virtual environment as your notebook kernel by clicking Select Kernel in the top-right corner of the notebook.
  • Click Python Environments.
  • Select heart_disease_env from the list.

Why select a kernel?

The kernel tells your notebook which Python installation to use when running code cells. By selecting heart_disease_env, your notebook can access all the ML libraries you just installed.

Don't see heart_disease_env in the kernel list?

Make sure you installed ipykernel in the previous step. Without it, VS Code cannot use the environment as a notebook kernel.

Try reloading VS Code (Cmd+Shift+P or Ctrl+Shift+P, then type Reload Window) and check the kernel picker again.

help me get my virtual environment to show up as a Jupyter kernel in VS Code

  • In the first empty code cell, type the following test code and run it by pressing Shift+Enter:
import pandas as pd
print('Ready!')

You should see Ready! printed below the cell. This confirms pandas is importable and your kernel is working.

Seeing an error when running the cell?

If you see a ModuleNotFoundError, the notebook is not using the correct kernel. Click the kernel name in the top-right corner and re-select heart_disease_env.

If the cell hangs or shows a connection error, reload VS Code and try again.

help me fix errors when running code cells in my Jupyter notebook in VS Code

✔️ Awesome, I've got everything!

Great. Double check you've saved heart_disease_prediction.ipynb and you see Ready! in the output below your first cell.

ⓧ I'd like to double check the full code

Your notebook should have one cell with the following code, and Ready! printed as output:

import pandas as pd
print('Ready!')

Your ML environment is locked and loaded. Next up, you'll load real patient data into your notebook and start exploring it.

Load and Explore the Data

In the last step, you set up your ML environment with all the libraries needed for machine learning. Now it's time to bring in the data that your model will learn from.

You'll load the UCI Heart Disease Cleveland dataset, which contains 13 clinical measurements from 303 real patients. By the end of this step, you'll have a clean DataFrame ready for visualization and modeling.

In this step, get ready to:
  • Load the UCI Heart Disease dataset into a pandas DataFrame.
  • Inspect data types, statistics, and missing values.
  • Convert the target column to binary classification format.
Load the dataset with pandas

Before you load data, every notebook needs its imports declared upfront. This ensures all libraries are available for every cell that follows.

  • In your heart_disease_prediction.ipynb notebook, delete the existing test cell that contains import pandas as pd; print('Ready!').
  • Create a new Markdown cell at the top of your notebook by clicking + Markdown in the notebook toolbar.
  • Type this into the Markdown cell:
# Heart Disease Prediction - Full ML Pipeline
Predicting heart disease using the UCI Cleveland dataset with scikit-learn.
  • Create a new code cell below the Markdown cell by clicking + Code in the toolbar.
  • Add all the library imports your project needs by pasting this code into the new cell:
# Import data manipulation libraries
import pandas as pd
import numpy as np

# Import visualization libraries
import matplotlib.pyplot as plt
import seaborn as sns

# Import scikit-learn modules for the full pipeline
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.impute import SimpleImputer
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix
from joblib import dump, load

What do these imports do?

  • pandas and numpy handle data loading, manipulation, and numeric operations.
  • matplotlib and seaborn create visualizations like heatmaps and distribution plots.
  • The sklearn modules cover everything from preprocessing to model training to evaluation.
  • joblib exports your trained model as a file you can reload later.
  • Run the imports cell by pressing Shift+Enter.
  • The cell should complete with no output and no errors.

Seeing a ModuleNotFoundError?

Make sure your notebook kernel is set to heart_disease_env. Click the kernel name in the top-right corner of the notebook and select heart_disease_env from the list.

If the environment doesn't appear, restart VS Code and try again. Help me fix my kernel selection

Now you'll load the actual patient data from the UCI Machine Learning Repository. This dataset has no header row, so you need to supply column names manually.

  • Create a new code cell and load the dataset by pasting this code:
# Load the UCI Cleveland Heart Disease dataset directly from the repository
url = "https://archive.ics.uci.edu/ml/machine-learning-databases/heart-disease/processed.cleveland.data"
column_names = [
    'age', 'sex', 'cp', 'trestbps', 'chol', 'fbs',
    'restecg', 'thalach', 'exang', 'oldpeak', 'slope', 'ca', 'thal', 'target'
]
df = pd.read_csv(url, names=column_names, na_values='?')
print(f"Dataset shape: {df.shape}")
print(f"\nFirst 5 rows:")
df.head()

What does this code do?

  • url points directly to the processed Cleveland dataset hosted by UCI.
  • column_names provides the 14 feature names since the raw file has no header row.
  • na_values='?' tells pandas to treat any ? character as a missing value (NaN) during loading.
  • df.head() displays the first 5 rows so you can visually confirm the data loaded correctly.
  • Run the cell with Shift+Enter.
  • You should see Dataset shape: (303, 14) printed, followed by a table showing the first 5 patient records.

Seeing a connection error?

The dataset loads directly from the internet. Make sure you have an active internet connection. If the UCI server is temporarily unavailable, wait a moment and try running the cell again.

Help me troubleshoot my dataset loading error

Inspect the data

Loading data is only the first half of the job. You need to understand the data types, ranges, and statistics before building any model.

  • Create a new code cell and inspect the DataFrame structure by pasting this code:
# Check data types and non-null counts for each column
print("Data Info:")
print(df.info())
print(f"\nStatistical Summary:")
df.describe()

What does this code do?

df.info() shows each column's data type and how many non-null values it has. This helps you spot columns with missing data.

df.describe() gives statistical summaries (mean, standard deviation, min, max, quartiles) for every numeric column. This helps you spot outliers and understand value ranges.

  • Run the cell with Shift+Enter.
  • You should see a table listing all 14 columns with their data types, followed by a statistical summary showing means, standard deviations, and quartiles for each feature.

Seeing unexpected data types?

If ca or thal show as object type instead of float64, double-check that you included na_values='?' in your pd.read_csv() call above.

Help me understand my data types output

Handle missing values

The na_values='?' parameter you used during loading already converted any ? characters into NaN. Now you need to confirm exactly which columns have missing data and how much.

  • Create a new code cell and check for missing values by pasting this code:
# Count missing values in each column
print("Missing values per column:")
print(df.isnull().sum())

What does this code do?

df.isnull().sum() counts the number of NaN values in each column. Columns with zero missing values are ready to use as-is.

You'll handle the actual imputation (filling in missing values) later in Step 4 using SimpleImputer inside a Pipeline. For now, you're just identifying the problem.

  • Run the cell with Shift+Enter.
  • You should see that ca has approximately 4 missing values and thal has approximately 2 missing values. All other columns should show 0.

The target column also needs attention. The original dataset encodes disease severity as values 0 through 4, but you need a simple yes-or-no prediction.

  • Create a new code cell and convert the target to binary format by pasting this code:
# Convert target from severity levels (0-4) to binary (0 = no disease, 1 = disease)
df['target'] = df['target'].apply(lambda x: 1 if x > 0 else 0)
print(f"\nTarget distribution:")
print(df['target'].value_counts())

Why convert to binary?

The original target uses 0 for no disease and 1-4 for increasing severity levels. The standard approach for this dataset (used in published ML research) is binary classification: 0 means no disease, 1 means disease is present.

The lambda function checks each value. If it's greater than 0, it becomes 1. If it's already 0, it stays 0.

  • Run the cell with Shift+Enter.
  • You should see approximately 165 patients with value 0 (no disease) and approximately 138 patients with value 1 (disease present).

Seeing different numbers?

If your distribution doesn't match (roughly 165 vs 138), make sure you ran the target conversion cell only once. Running it multiple times won't cause errors, but the counts should still be close to these numbers if the dataset loaded correctly.

Help me understand my target distribution

✔️ 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 this point. Each # %% marker represents a separate cell in your notebook.

# %% [markdown]
# # Heart Disease Prediction - Full ML Pipeline
# Predicting heart disease using the UCI Cleveland dataset with scikit-learn.

# %% Import Libraries
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.impute import SimpleImputer
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix
from joblib import dump, load

# %% Load the Dataset
url = "https://archive.ics.uci.edu/ml/machine-learning-databases/heart-disease/processed.cleveland.data"
column_names = [
    'age', 'sex', 'cp', 'trestbps', 'chol', 'fbs',
    'restecg', 'thalach', 'exang', 'oldpeak', 'slope', 'ca', 'thal', 'target'
]
df = pd.read_csv(url, names=column_names, na_values='?')
print(f"Dataset shape: {df.shape}")
print(f"\nFirst 5 rows:")
df.head()

# %% Inspect the Data
print("Data Info:")
print(df.info())
print(f"\nStatistical Summary:")
df.describe()

# %% Check Missing Values
print("Missing values per column:")
print(df.isnull().sum())

# %% Convert Target to Binary
df['target'] = df['target'].apply(lambda x: 1 if x > 0 else 0)
print(f"\nTarget distribution:")
print(df['target'].value_counts())

Your data is loaded, inspected, and cleaned. Next up, you'll create visualizations that reveal which features are most strongly connected to heart disease.

Visualize the Data

You have a clean dataset with 303 patient records and a binary target column ready to go. Before building any model, you need to understand what the data is actually telling you.

Exploratory Data Analysis (EDA) is the process of visualizing your data to uncover patterns, spot anomalies, and identify which features matter most. In this step, you will create plots that reveal the relationships between clinical features and heart disease presence.

In this step, get ready to:
  • Plot the target distribution and a correlation heatmap of all features.
  • Create histograms and bar charts showing how features differ between disease and no-disease groups.
  • Document your key observations as markdown cells in the notebook.
Plot target distribution and feature correlations

The first thing to check in any classification problem is how balanced your classes are. You also want to see which features correlate with each other and with the target.

  • In your notebook, add a new cell below your last code cell and paste this markdown heading:
# %% [markdown]
# ## Exploratory Data Analysis
  • Run the cell with Shift+Enter.
  • Add a new code cell below and paste this count plot code:
# %% Plot Target Distribution
fig, ax = plt.subplots(figsize=(6, 4))
sns.countplot(x='target', data=df, ax=ax)
ax.set_xticklabels(['No Disease (0)', 'Disease (1)'])
ax.set_title('Heart Disease Distribution')
ax.set_ylabel('Count')
plt.tight_layout()
plt.show()

What does this code do?

This cell creates a bar chart showing how many patients have heart disease (1) versus no heart disease (0).

The sns.countplot() function from seaborn counts the occurrences of each target value and draws them as bars. The plt.tight_layout() call prevents labels from getting cut off.

  • Run the cell with Shift+Enter.
  • You should see a bar chart with two bars. The "No Disease" bar should be slightly taller (~165) than the "Disease" bar (~138).

Don't see a plot?

Make sure you ran all previous cells first. The df variable must exist from your earlier data loading cells.

If you see a NameError, click Run All at the top of your notebook to re-execute every cell in order.

Help me debug why my seaborn plot isn't rendering in my Jupyter notebook.

  • Add a new code cell below and paste this correlation heatmap code:
# %% Correlation Heatmap
fig, ax = plt.subplots(figsize=(12, 10))
correlation_matrix = df.corr(numeric_only=True)
sns.heatmap(correlation_matrix, annot=True, fmt='.2f', cmap='coolwarm',
            center=0, square=True, ax=ax)
ax.set_title('Feature Correlation Heatmap')
plt.tight_layout()
plt.show()

What does this code do?

The df.corr() method calculates the Pearson correlation coefficient between every pair of numeric columns. Values range from -1 (perfect negative correlation) to +1 (perfect positive correlation).

The sns.heatmap() function draws this matrix as a color grid. Red squares indicate positive correlations. Blue squares indicate negative correlations. The annot=True parameter prints the numeric values inside each square.

  • Run the cell with Shift+Enter.
  • You should see a large color-coded grid with all 14 feature names on both axes and a correlation value in each cell.

Heatmap text overlapping or cut off?

If annotations are overlapping, make sure your figsize=(12, 10) matches the code above. A smaller figure size causes the text to crowd together.

Help me fix my seaborn heatmap layout in Jupyter.

Explore key feature distributions

Now you will look at how individual features behave differently for patients with and without heart disease. Overlapping histograms make it easy to spot separation between the two groups.

  • Add a new code cell and paste this histogram code:
# %% Feature Distributions by Target
continuous_features = ['age', 'trestbps', 'chol', 'thalach', 'oldpeak']
fig, axes = plt.subplots(2, 3, figsize=(15, 10))
axes = axes.flatten()

for i, feature in enumerate(continuous_features):
    sns.histplot(data=df, x=feature, hue='target', kde=True, ax=axes[i])
    axes[i].set_title(f'{feature} Distribution by Target')

axes[5].axis('off')
plt.tight_layout()
plt.show()

What does this code do?

This creates a 2x3 grid of subplots. Each subplot shows a histogram for one continuous feature, with overlapping colors for disease (1) and no-disease (0) groups.

The hue='target' parameter splits each histogram by the target class. The kde=True parameter adds a smooth density curve on top. Since there are only 5 features but 6 subplot slots, axes[5].axis('off') hides the empty sixth panel.

  • Run the cell with Shift+Enter.
  • You should see five histograms in a grid. Look for features where the two colored distributions are clearly separated. The thalach (max heart rate) plot should show the most visible separation.

Seeing an empty or distorted grid?

Verify you typed plt.subplots(2, 3, figsize=(15, 10)) and not plt.subplot() (without the s). The plural form creates a grid of axes objects.

Help me debug my matplotlib subplot grid.

  • Add a new code cell and paste this bar chart code:
# %% Heart Disease by Chest Pain Type
fig, ax = plt.subplots(figsize=(8, 5))
cp_counts = df.groupby(['cp', 'target']).size().unstack(fill_value=0)
cp_counts.plot(kind='bar', ax=ax)
ax.set_xlabel('Chest Pain Type (0=typical angina, 1=atypical, 2=non-anginal, 3=asymptomatic)')
ax.set_ylabel('Count')
ax.set_title('Heart Disease by Chest Pain Type')
ax.legend(['No Disease', 'Disease'])
plt.tight_layout()
plt.show()

What does this code do?

This groups the data by chest pain type and target, then creates a grouped bar chart. Each cluster of bars represents one chest pain type, with separate bars for disease and no-disease counts.

The groupby().size().unstack() chain pivots the data into a format that pandas can plot directly. This technique works for any categorical feature you want to compare against the target.

  • Run the cell with Shift+Enter.
  • You should see a grouped bar chart with four clusters. The cluster for chest pain type 3 (asymptomatic) should have the tallest "Disease" bar.

Bars look wrong or legend is mismatched?

Make sure the ax.legend(['No Disease', 'Disease']) line comes after the cp_counts.plot() call. If you put it before, the legend attaches to the wrong bars.

Help me fix my grouped bar chart legend.

Identify important patterns

Documenting what you observe is a critical part of EDA. These observations guide your feature selection and help you explain your model's predictions later.

  • Add a new markdown cell below your last chart and paste these observations:
# %% [markdown]
# ## Key Observations
# - Patients with asymptomatic chest pain (cp=3) have the highest disease rate
# - Higher maximum heart rate (thalach) is associated with no disease
# - Higher oldpeak (ST depression) is associated with disease presence
# - The dataset is reasonably balanced (~46% disease, ~54% no disease)

Why document observations in the notebook?

Markdown cells turn your notebook into a living report. When you (or someone else) revisits this notebook later, the observations explain why certain features matter without having to re-run and re-interpret every chart.

Feel free to add your own observations beyond these four. Anything you noticed in the plots that surprised you or confirmed an expectation is worth writing down.

  • Run the cell with Shift+Enter.
  • You should see the markdown render as a formatted list of bullet points with a "Key Observations" heading.

✔️ Awesome, I've got everything!

Great. Double check you have saved your notebook file.

ⓧ I'd like to double check the full code

Here is the full notebook code up to this point. Compare it against your file:

# %% [markdown]
# # Heart Disease Prediction - Full ML Pipeline
# Predicting heart disease using the UCI Cleveland dataset with scikit-learn.

# %% Import Libraries
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.impute import SimpleImputer
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix
from joblib import dump, load

# %% Load the Dataset
url = "https://archive.ics.uci.edu/ml/machine-learning-databases/heart-disease/processed.cleveland.data"
column_names = [
    'age', 'sex', 'cp', 'trestbps', 'chol', 'fbs',
    'restecg', 'thalach', 'exang', 'oldpeak', 'slope', 'ca', 'thal', 'target'
]
df = pd.read_csv(url, names=column_names, na_values='?')
print(f"Dataset shape: {df.shape}")
print(f"\nFirst 5 rows:")
df.head()

# %% Inspect the Data
print("Data Info:")
print(df.info())
print(f"\nStatistical Summary:")
df.describe()

# %% Check Missing Values
print("Missing values per column:")
print(df.isnull().sum())

# %% Convert Target to Binary
df['target'] = df['target'].apply(lambda x: 1 if x > 0 else 0)
print(f"\nTarget distribution:")
print(df['target'].value_counts())

# %% [markdown]
# ## Exploratory Data Analysis

# %% Plot Target Distribution
fig, ax = plt.subplots(figsize=(6, 4))
sns.countplot(x='target', data=df, ax=ax)
ax.set_xticklabels(['No Disease (0)', 'Disease (1)'])
ax.set_title('Heart Disease Distribution')
ax.set_ylabel('Count')
plt.tight_layout()
plt.show()

# %% Correlation Heatmap
fig, ax = plt.subplots(figsize=(12, 10))
correlation_matrix = df.corr(numeric_only=True)
sns.heatmap(correlation_matrix, annot=True, fmt='.2f', cmap='coolwarm',
            center=0, square=True, ax=ax)
ax.set_title('Feature Correlation Heatmap')
plt.tight_layout()
plt.show()

# %% Feature Distributions by Target
continuous_features = ['age', 'trestbps', 'chol', 'thalach', 'oldpeak']
fig, axes = plt.subplots(2, 3, figsize=(15, 10))
axes = axes.flatten()

for i, feature in enumerate(continuous_features):
    sns.histplot(data=df, x=feature, hue='target', kde=True, ax=axes[i])
    axes[i].set_title(f'{feature} Distribution by Target')

axes[5].axis('off')
plt.tight_layout()
plt.show()

# %% Heart Disease by Chest Pain Type
fig, ax = plt.subplots(figsize=(8, 5))
cp_counts = df.groupby(['cp', 'target']).size().unstack(fill_value=0)
cp_counts.plot(kind='bar', ax=ax)
ax.set_xlabel('Chest Pain Type (0=typical angina, 1=atypical, 2=non-anginal, 3=asymptomatic)')
ax.set_ylabel('Count')
ax.set_title('Heart Disease by Chest Pain Type')
ax.legend(['No Disease', 'Disease'])
plt.tight_layout()
plt.show()

# %% [markdown]
# ## Key Observations
# - Patients with asymptomatic chest pain (cp=3) have the highest disease rate
# - Higher maximum heart rate (thalach) is associated with no disease
# - Higher oldpeak (ST depression) is associated with disease presence
# - The dataset is reasonably balanced (~46% disease, ~54% no disease)

Your EDA is complete. Next up, you will use these insights to build a preprocessing pipeline that prepares the data for machine learning models.

Build the Preprocessing Pipeline

Your EDA gave you a clear picture of the dataset. You spotted which features correlate with heart disease, confirmed the classes are reasonably balanced, and documented your observations.

Now you need to turn that raw DataFrame into something a machine learning model can consume reliably. This means splitting the data, handling missing values, and scaling numeric features. You will build a scikit-learn Pipeline that bundles all of this preprocessing together with the classifier so nothing leaks between your training and test sets.

In this step, get ready to:
  • Separate features from the target and split into training/test sets.
  • Build a ColumnTransformer that applies different preprocessing to numeric and categorical columns.
  • Create two full pipelines combining the preprocessor with Logistic Regression and Random Forest classifiers.
Separate features and target, then split the data

Before any preprocessing happens, you must split the data into training and test sets. Splitting first prevents data leakage, where information from the test set accidentally influences how the model learns.

  • In your notebook, create a new markdown cell and a new code cell below your Key Observations section. Add the following code to separate your features and split the data:
# %% [markdown]
# ## Preprocessing and Model Building

# %% Separate Features and Target
X = df.drop('target', axis=1)
y = df['target']

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42
)
print(f"Training set: {X_train.shape[0]} samples")
print(f"Test set: {X_test.shape[0]} samples")

What does this code do?

X = df.drop('target', axis=1) creates a DataFrame with all 13 feature columns, removing the target. y = df['target'] isolates the binary label you want to predict.

train_test_split randomly shuffles the 303 rows and holds out 20% (about 61 samples) as a test set that the model never sees during training. Setting random_state=42 makes the split reproducible so your results match.

  • Run the cell.
  • You should see output showing Training set: 242 samples and Test set: 61 samples.

Seeing a NameError for df?

Make sure you ran all previous cells in order. If you restarted your kernel, re-run the cells from the top of the notebook to reload df into memory.

help me fix a NameError when splitting my heart disease DataFrame

Build a ColumnTransformer for preprocessing

Your dataset has mixed feature types. Continuous columns like age and chol need scaling. Categorical columns like sex and cp just need missing values filled. A ColumnTransformer lets you route different columns through different preprocessing steps in one object.

  • Add a new code cell below your train/test split cell and paste the following:
# %% Build the Preprocessing Pipeline
numeric_features = ['age', 'trestbps', 'chol', 'thalach', 'oldpeak']
categorical_features = ['sex', 'cp', 'fbs', 'restecg', 'exang', 'slope', 'ca', 'thal']

numeric_transformer = Pipeline(steps=[
    ('imputer', SimpleImputer(strategy='median')),
    ('scaler', StandardScaler())
])

categorical_transformer = Pipeline(steps=[
    ('imputer', SimpleImputer(strategy='most_frequent'))
])

preprocessor = ColumnTransformer(
    transformers=[
        ('num', numeric_transformer, numeric_features),
        ('cat', categorical_transformer, categorical_features)
    ]
)

What does this code do?

numeric_features lists the 5 continuous columns. categorical_features lists the 8 integer-encoded columns.

The numeric_transformer sub-pipeline first imputes any missing values with the column median, then scales all values to mean 0 and variance 1. The categorical_transformer fills missing values with the most common category. These columns are already integers, so no further encoding is needed.

The preprocessor ColumnTransformer routes each column group to the correct sub-pipeline and concatenates the results into a single clean feature matrix.

Why use a Pipeline instead of manual preprocessing?

A Pipeline ties preprocessing directly to the model. When you call .fit() on the pipeline, it learns scaling parameters from training data only. When you call .predict() on test data, it applies the same learned transforms automatically.

Without a Pipeline, it is easy to accidentally fit a scaler on the entire dataset before splitting. That leaks test-set statistics into training, making your accuracy look better than it really is. The Pipeline prevents this entirely.

  • Run the cell. It completes silently with no output (no errors means success).

Seeing a NameError for Pipeline or SimpleImputer?

These classes were imported in the very first cell of your notebook. If you restarted your kernel, scroll to the top and re-run the import cell first.

help me fix missing imports for my preprocessing pipeline

Create full pipelines with classifiers

The preprocessor handles data cleaning. Now you need to attach a classifier to the end so the full pipeline goes from raw features to a prediction in one step. You will create two pipelines to compare a simple linear model against a tree-based ensemble.

  • Add a new code cell and paste the following to create both classifier pipelines:
# %% Create Full Pipelines with Classifiers
lr_pipeline = Pipeline(steps=[
    ('preprocessor', preprocessor),
    ('classifier', LogisticRegression(max_iter=1000, random_state=42))
])

rf_pipeline = Pipeline(steps=[
    ('preprocessor', preprocessor),
    ('classifier', RandomForestClassifier(n_estimators=100, random_state=42))
])

print("Logistic Regression Pipeline:")
print(lr_pipeline)
print("\nRandom Forest Pipeline:")
print(rf_pipeline)

What does this code do?

lr_pipeline chains the preprocessor with a Logistic Regression classifier. This is the standard baseline for binary classification. max_iter=1000 gives the optimizer enough iterations to converge on this small dataset.

rf_pipeline chains the same preprocessor with a Random Forest classifier. Random Forest builds 100 decision trees (n_estimators=100) and averages their predictions. It captures non-linear relationships that Logistic Regression might miss.

Both pipelines share the same preprocessor object, so the exact same cleaning and scaling logic applies regardless of which model you train.

  • Run the cell.
  • You should see both pipeline definitions printed, showing their step names (preprocessor and classifier) along with the nested transformer details.

Pipeline prints but shows unexpected steps?

Double check that your preprocessor variable was defined in the previous cell. If you accidentally re-ran only this cell without running the one above it, preprocessor will be undefined or stale.

help me debug my scikit-learn pipeline structure

✔️ Awesome, I've got everything!

Great. Double check you have saved your notebook file.

ⓧ I'd like to double check the full code

Here is the complete notebook up to this point. Compare your cells against this:

# %% [markdown]
# # Heart Disease Prediction - Full ML Pipeline
# Predicting heart disease using the UCI Cleveland dataset with scikit-learn.

# %% Import Libraries
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.impute import SimpleImputer
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix
from joblib import dump, load

# %% Load the Dataset
url = "https://archive.ics.uci.edu/ml/machine-learning-databases/heart-disease/processed.cleveland.data"
column_names = [
    'age', 'sex', 'cp', 'trestbps', 'chol', 'fbs',
    'restecg', 'thalach', 'exang', 'oldpeak', 'slope', 'ca', 'thal', 'target'
]
df = pd.read_csv(url, names=column_names, na_values='?')
print(f"Dataset shape: {df.shape}")
print(f"\nFirst 5 rows:")
df.head()

# %% Inspect the Data
print("Data Info:")
print(df.info())
print(f"\nStatistical Summary:")
df.describe()

# %% Check Missing Values
print("Missing values per column:")
print(df.isnull().sum())

# %% Convert Target to Binary
df['target'] = df['target'].apply(lambda x: 1 if x > 0 else 0)
print(f"\nTarget distribution:")
print(df['target'].value_counts())

# %% [markdown]
# ## Exploratory Data Analysis

# %% Plot Target Distribution
fig, ax = plt.subplots(figsize=(6, 4))
sns.countplot(x='target', data=df, ax=ax)
ax.set_xticklabels(['No Disease (0)', 'Disease (1)'])
ax.set_title('Heart Disease Distribution')
ax.set_ylabel('Count')
plt.tight_layout()
plt.show()

# %% Correlation Heatmap
fig, ax = plt.subplots(figsize=(12, 10))
correlation_matrix = df.corr(numeric_only=True)
sns.heatmap(correlation_matrix, annot=True, fmt='.2f', cmap='coolwarm',
            center=0, square=True, ax=ax)
ax.set_title('Feature Correlation Heatmap')
plt.tight_layout()
plt.show()

# %% Feature Distributions by Target
continuous_features = ['age', 'trestbps', 'chol', 'thalach', 'oldpeak']
fig, axes = plt.subplots(2, 3, figsize=(15, 10))
axes = axes.flatten()

for i, feature in enumerate(continuous_features):
    sns.histplot(data=df, x=feature, hue='target', kde=True, ax=axes[i])
    axes[i].set_title(f'{feature} Distribution by Target')

axes[5].axis('off')
plt.tight_layout()
plt.show()

# %% Heart Disease by Chest Pain Type
fig, ax = plt.subplots(figsize=(8, 5))
cp_counts = df.groupby(['cp', 'target']).size().unstack(fill_value=0)
cp_counts.plot(kind='bar', ax=ax)
ax.set_xlabel('Chest Pain Type (0=typical angina, 1=atypical, 2=non-anginal, 3=asymptomatic)')
ax.set_ylabel('Count')
ax.set_title('Heart Disease by Chest Pain Type')
ax.legend(['No Disease', 'Disease'])
plt.tight_layout()
plt.show()

# %% [markdown]
# ## Key Observations
# - Patients with asymptomatic chest pain (cp=3) have the highest disease rate
# - Higher maximum heart rate (thalach) is associated with no disease
# - Higher oldpeak (ST depression) is associated with disease presence
# - The dataset is reasonably balanced (~46% disease, ~54% no disease)

# %% [markdown]
# ## Preprocessing and Model Building

# %% Separate Features and Target
X = df.drop('target', axis=1)
y = df['target']

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42
)
print(f"Training set: {X_train.shape[0]} samples")
print(f"Test set: {X_test.shape[0]} samples")

# %% Build the Preprocessing Pipeline
numeric_features = ['age', 'trestbps', 'chol', 'thalach', 'oldpeak']
categorical_features = ['sex', 'cp', 'fbs', 'restecg', 'exang', 'slope', 'ca', 'thal']

numeric_transformer = Pipeline(steps=[
    ('imputer', SimpleImputer(strategy='median')),
    ('scaler', StandardScaler())
])

categorical_transformer = Pipeline(steps=[
    ('imputer', SimpleImputer(strategy='most_frequent'))
])

preprocessor = ColumnTransformer(
    transformers=[
        ('num', numeric_transformer, numeric_features),
        ('cat', categorical_transformer, categorical_features)
    ]
)

# %% Create Full Pipelines with Classifiers
lr_pipeline = Pipeline(steps=[
    ('preprocessor', preprocessor),
    ('classifier', LogisticRegression(max_iter=1000, random_state=42))
])

rf_pipeline = Pipeline(steps=[
    ('preprocessor', preprocessor),
    ('classifier', RandomForestClassifier(n_estimators=100, random_state=42))
])

print("Logistic Regression Pipeline:")
print(lr_pipeline)
print("\nRandom Forest Pipeline:")
print(rf_pipeline)

Your preprocessing and model pipelines are locked in. Next up, you will train both models on the training set and compare their accuracy on unseen test data.

Train, Evaluate, and Export the Model

Your preprocessing pipelines are built and ready. Both the Logistic Regression and Random Forest classifiers are wired into clean, reusable pipelines that handle imputation and scaling automatically.

Now it is time to see how well these models actually predict heart disease. You will train both models, compare their performance with real metrics, and export the winner as a file that is ready for future integration.

In this step, get ready to:
  • Train both pipelines on the training data.
  • Evaluate accuracy, precision, recall, and confusion matrices for each model.
  • Export the best-performing model as a reusable .joblib file.
Train both models

Training a scikit-learn Pipeline means fitting the entire chain of transformers plus the final classifier in one call. The pipeline ensures preprocessing happens on the training data only, preventing data leakage.

  • Add a new markdown cell in your notebook to mark the training section, then add a code cell to train both pipelines by pasting the following:
# %% [markdown]
# ## Training and Evaluation

# %% Train Both Models
lr_pipeline.fit(X_train, y_train)
rf_pipeline.fit(X_train, y_train)
print("Both models trained successfully!")

What does this code do?

The .fit() method triggers the full pipeline for each model. First, the preprocessor imputes missing values and scales numeric features using only the training data. Then the classifier learns patterns from the processed features.

Because preprocessing is inside the pipeline, the scaler never sees test data during fitting. This prevents data leakage.

  • Run both cells. You should see Both models trained successfully! printed below the code cell.

Seeing a NameError?

Make sure you ran all previous cells in order. The variables X_train, y_train, lr_pipeline, and rf_pipeline must exist from the previous step.

help me fix a NameError when training my scikit-learn pipelines

Evaluate and compare model performance

Training a model is only half the job. You need to measure how well each model generalizes to data it has never seen. You will use the held-out test set to generate predictions and compute performance metrics.

  • Evaluate the Logistic Regression model by adding this cell:
# %% Evaluate Logistic Regression
y_pred_lr = lr_pipeline.predict(X_test)
print("=" * 50)
print("LOGISTIC REGRESSION RESULTS")
print("=" * 50)
print(f"Accuracy: {accuracy_score(y_test, y_pred_lr):.4f}")
print(f"\nClassification Report:")
print(classification_report(y_test, y_pred_lr, target_names=['No Disease', 'Disease']))

What does this code do?

The .predict() method runs the test data through the full pipeline (preprocess then classify) and returns predicted labels.

The classification_report shows precision (how many predicted positives are correct), recall (how many actual positives were caught), and F1-score (the balance of both) for each class.

  • Run the cell. You should see an accuracy score and a table of precision, recall, and F1-score values.
  • Now evaluate the Random Forest model by adding this cell:
# %% Evaluate Random Forest
y_pred_rf = rf_pipeline.predict(X_test)
print("=" * 50)
print("RANDOM FOREST RESULTS")
print("=" * 50)
print(f"Accuracy: {accuracy_score(y_test, y_pred_rf):.4f}")
print(f"\nClassification Report:")
print(classification_report(y_test, y_pred_rf, target_names=['No Disease', 'Disease']))
  • Run the cell. Both models should achieve above 75% accuracy. The typical range on this dataset is 78-88%.

Why precision and recall matter for heart disease

In medical diagnosis, missing a disease case (false negative) is far worse than a false alarm (false positive). Recall measures how many actual disease cases the model catches. A model with high recall is better at not missing sick patients.

Compare the recall values in the "Disease" row for both models. The model with higher recall is safer for clinical use.

Numbers alone can be hard to interpret. A confusion matrix shows exactly where each model gets predictions right and wrong.

  • Visualize both confusion matrices side by side by adding this cell:
# %% Visualize Confusion Matrices
fig, axes = plt.subplots(1, 2, figsize=(12, 5))

# Logistic Regression confusion matrix
cm_lr = confusion_matrix(y_test, y_pred_lr)
sns.heatmap(cm_lr, annot=True, fmt='d', cmap='Blues', ax=axes[0],
            xticklabels=['No Disease', 'Disease'],
            yticklabels=['No Disease', 'Disease'])
axes[0].set_title('Logistic Regression')
axes[0].set_ylabel('Actual')
axes[0].set_xlabel('Predicted')

# Random Forest confusion matrix
cm_rf = confusion_matrix(y_test, y_pred_rf)
sns.heatmap(cm_rf, annot=True, fmt='d', cmap='Blues', ax=axes[1],
            xticklabels=['No Disease', 'Disease'],
            yticklabels=['No Disease', 'Disease'])
axes[1].set_title('Random Forest')
axes[1].set_ylabel('Actual')
axes[1].set_xlabel('Predicted')

plt.tight_layout()
plt.show()

How to read a confusion matrix

Each cell in the matrix shows a count. The top-left is true negatives (correctly predicted no disease). The bottom-right is true positives (correctly predicted disease). Off-diagonal cells are errors.

The bottom-left cell (false negatives) is the most dangerous in medical prediction. It represents patients who have disease but the model said they do not.

  • Run the cell. You should see two blue heatmaps side by side with integer counts in each cell.

Plots not showing?

Make sure you ran the evaluation cells above first. The variables y_pred_lr and y_pred_rf must exist before you can compute the confusion matrices.

help me debug why my confusion matrix heatmaps are not rendering

Now you can programmatically pick the winner based on accuracy.

  • Compare both models and select the best one by adding this cell:
# %% Compare Models
lr_accuracy = accuracy_score(y_test, y_pred_lr)
rf_accuracy = accuracy_score(y_test, y_pred_rf)

print(f"\nModel Comparison:")
print(f"Logistic Regression Accuracy: {lr_accuracy:.4f}")
print(f"Random Forest Accuracy:       {rf_accuracy:.4f}")

if lr_accuracy >= rf_accuracy:
    best_pipeline = lr_pipeline
    best_name = "Logistic Regression"
else:
    best_pipeline = rf_pipeline
    best_name = "Random Forest"

print(f"\nBest Model: {best_name}")

What does this code do?

This cell recalculates accuracy for both models, then uses a simple if/else to assign the higher-scoring pipeline to best_pipeline. This variable is what you will export in the next substep.

If both models tie, Logistic Regression wins because it is simpler and more interpretable. Simpler models are preferred when performance is equal.

  • Run the cell. You should see both accuracy scores printed and a line naming the best model.
Export the best model

A trained model only lives in memory until you save it. Exporting to a file means you can reload it later without retraining. This is essential for deploying the model in a web API or sharing it with teammates.

  • Save the best pipeline to disk by adding this cell:
# %% Export the Best Model
dump(best_pipeline, 'heart_disease_model.joblib')
print(f"Model saved as 'heart_disease_model.joblib'")

Why joblib instead of pickle?

scikit-learn recommends joblib for persisting fitted estimators because it is more efficient for objects containing large numpy arrays. The entire pipeline (preprocessor and classifier) is saved together, so loading it later gives you a fully self-contained prediction system.

  • Run the cell. You should see Model saved as 'heart_disease_model.joblib' printed.
  • Verify the export by reloading the model and running a test prediction. Add this cell:
# %% Verify the Exported Model
loaded_model = load('heart_disease_model.joblib')

# Test prediction with a sample patient
sample_patient = pd.DataFrame({
    'age': [55], 'sex': [1], 'cp': [2], 'trestbps': [130],
    'chol': [250], 'fbs': [0], 'restecg': [0], 'thalach': [150],
    'exang': [0], 'oldpeak': [1.5], 'slope': [1], 'ca': [0], 'thal': [3]
})

prediction = loaded_model.predict(sample_patient)
print(f"\nTest prediction for sample patient: {'Disease' if prediction[0] == 1 else 'No Disease'}")
print("Model export and reload verified successfully!")

What does this code do?

First, load() reads the saved pipeline back into memory. Then you create a DataFrame with one row of sample patient data (all 13 features). Finally, .predict() runs that patient through the full pipeline and returns 0 (no disease) or 1 (disease).

This proves your exported model is fully self-contained. Anyone who receives heart_disease_model.joblib can make predictions without needing your notebook or training data.

  • Run the cell. You should see a prediction of either Disease or No Disease followed by Model export and reload verified successfully!.
  • Confirm the file exists by checking your project folder in the VS Code Explorer sidebar. You should see heart_disease_model.joblib listed alongside your notebook.

File not appearing in Explorer?

The file is saved in whichever directory Jupyter treats as the working directory. If you do not see it, add a cell with import os; print(os.getcwd()) to check where the file was saved.

help me find my exported joblib model file

✔️ Awesome, I've got everything!

Great. Double check you have saved your notebook and that heart_disease_model.joblib appears in your project folder.

ⓧ I'd like to double check the full code

Here is the complete notebook code up to and including this step:

# %% [markdown]
# # Heart Disease Prediction - Full ML Pipeline
# Predicting heart disease using the UCI Cleveland dataset with scikit-learn.

# %% Import Libraries
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.impute import SimpleImputer
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix
from joblib import dump, load

# %% Load the Dataset
url = "https://archive.ics.uci.edu/ml/machine-learning-databases/heart-disease/processed.cleveland.data"
column_names = [
    'age', 'sex', 'cp', 'trestbps', 'chol', 'fbs',
    'restecg', 'thalach', 'exang', 'oldpeak', 'slope', 'ca', 'thal', 'target'
]
df = pd.read_csv(url, names=column_names, na_values='?')
print(f"Dataset shape: {df.shape}")
print(f"\nFirst 5 rows:")
df.head()

# %% Inspect the Data
print("Data Info:")
print(df.info())
print(f"\nStatistical Summary:")
df.describe()

# %% Check Missing Values
print("Missing values per column:")
print(df.isnull().sum())

# %% Convert Target to Binary
# Original target: 0 = no disease, 1-4 = disease present (varying severity)
# We convert to binary: 0 = no disease, 1 = disease present
df['target'] = df['target'].apply(lambda x: 1 if x > 0 else 0)
print(f"\nTarget distribution:")
print(df['target'].value_counts())

# %% [markdown]
# ## Exploratory Data Analysis

# %% Plot Target Distribution
fig, ax = plt.subplots(figsize=(6, 4))
sns.countplot(x='target', data=df, ax=ax)
ax.set_xticklabels(['No Disease (0)', 'Disease (1)'])
ax.set_title('Heart Disease Distribution')
ax.set_ylabel('Count')
plt.tight_layout()
plt.show()

# %% Correlation Heatmap
fig, ax = plt.subplots(figsize=(12, 10))
correlation_matrix = df.corr(numeric_only=True)
sns.heatmap(correlation_matrix, annot=True, fmt='.2f', cmap='coolwarm',
            center=0, square=True, ax=ax)
ax.set_title('Feature Correlation Heatmap')
plt.tight_layout()
plt.show()

# %% Feature Distributions by Target
continuous_features = ['age', 'trestbps', 'chol', 'thalach', 'oldpeak']
fig, axes = plt.subplots(2, 3, figsize=(15, 10))
axes = axes.flatten()

for i, feature in enumerate(continuous_features):
    sns.histplot(data=df, x=feature, hue='target', kde=True, ax=axes[i])
    axes[i].set_title(f'{feature} Distribution by Target')

axes[5].axis('off')
plt.tight_layout()
plt.show()

# %% Heart Disease by Chest Pain Type
fig, ax = plt.subplots(figsize=(8, 5))
cp_counts = df.groupby(['cp', 'target']).size().unstack(fill_value=0)
cp_counts.plot(kind='bar', ax=ax)
ax.set_xlabel('Chest Pain Type (0=typical angina, 1=atypical, 2=non-anginal, 3=asymptomatic)')
ax.set_ylabel('Count')
ax.set_title('Heart Disease by Chest Pain Type')
ax.legend(['No Disease', 'Disease'])
plt.tight_layout()
plt.show()

# %% [markdown]
# ## Key Observations
# - Patients with asymptomatic chest pain (cp=3) have the highest disease rate
# - Higher maximum heart rate (thalach) is associated with no disease
# - Higher oldpeak (ST depression) is associated with disease presence
# - The dataset is reasonably balanced (~46% disease, ~54% no disease)

# %% [markdown]
# ## Preprocessing and Model Building

# %% Separate Features and Target
X = df.drop('target', axis=1)
y = df['target']

# Split into train and test sets
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42
)
print(f"Training set: {X_train.shape[0]} samples")
print(f"Test set: {X_test.shape[0]} samples")

# %% Build the Preprocessing Pipeline
# Define column groups
numeric_features = ['age', 'trestbps', 'chol', 'thalach', 'oldpeak']
categorical_features = ['sex', 'cp', 'fbs', 'restecg', 'exang', 'slope', 'ca', 'thal']

# Numeric pipeline: impute missing with median, then scale
numeric_transformer = Pipeline(steps=[
    ('imputer', SimpleImputer(strategy='median')),
    ('scaler', StandardScaler())
])

# Categorical pipeline: impute missing with most frequent value
categorical_transformer = Pipeline(steps=[
    ('imputer', SimpleImputer(strategy='most_frequent'))
])

# Combine into a ColumnTransformer
preprocessor = ColumnTransformer(
    transformers=[
        ('num', numeric_transformer, numeric_features),
        ('cat', categorical_transformer, categorical_features)
    ]
)

# %% Create Full Pipelines with Classifiers
# Pipeline 1: Logistic Regression
lr_pipeline = Pipeline(steps=[
    ('preprocessor', preprocessor),
    ('classifier', LogisticRegression(max_iter=1000, random_state=42))
])

# Pipeline 2: Random Forest
rf_pipeline = Pipeline(steps=[
    ('preprocessor', preprocessor),
    ('classifier', RandomForestClassifier(n_estimators=100, random_state=42))
])

print("Logistic Regression Pipeline:")
print(lr_pipeline)
print("\nRandom Forest Pipeline:")
print(rf_pipeline)

# %% [markdown]
# ## Training and Evaluation

# %% Train Both Models
lr_pipeline.fit(X_train, y_train)
rf_pipeline.fit(X_train, y_train)
print("Both models trained successfully!")

# %% Evaluate Logistic Regression
y_pred_lr = lr_pipeline.predict(X_test)
print("=" * 50)
print("LOGISTIC REGRESSION RESULTS")
print("=" * 50)
print(f"Accuracy: {accuracy_score(y_test, y_pred_lr):.4f}")
print(f"\nClassification Report:")
print(classification_report(y_test, y_pred_lr, target_names=['No Disease', 'Disease']))

# %% Evaluate Random Forest
y_pred_rf = rf_pipeline.predict(X_test)
print("=" * 50)
print("RANDOM FOREST RESULTS")
print("=" * 50)
print(f"Accuracy: {accuracy_score(y_test, y_pred_rf):.4f}")
print(f"\nClassification Report:")
print(classification_report(y_test, y_pred_rf, target_names=['No Disease', 'Disease']))

# %% Visualize Confusion Matrices
fig, axes = plt.subplots(1, 2, figsize=(12, 5))

# Logistic Regression confusion matrix
cm_lr = confusion_matrix(y_test, y_pred_lr)
sns.heatmap(cm_lr, annot=True, fmt='d', cmap='Blues', ax=axes[0],
            xticklabels=['No Disease', 'Disease'],
            yticklabels=['No Disease', 'Disease'])
axes[0].set_title('Logistic Regression')
axes[0].set_ylabel('Actual')
axes[0].set_xlabel('Predicted')

# Random Forest confusion matrix
cm_rf = confusion_matrix(y_test, y_pred_rf)
sns.heatmap(cm_rf, annot=True, fmt='d', cmap='Blues', ax=axes[1],
            xticklabels=['No Disease', 'Disease'],
            yticklabels=['No Disease', 'Disease'])
axes[1].set_title('Random Forest')
axes[1].set_ylabel('Actual')
axes[1].set_xlabel('Predicted')

plt.tight_layout()
plt.show()

# %% Compare Models
lr_accuracy = accuracy_score(y_test, y_pred_lr)
rf_accuracy = accuracy_score(y_test, y_pred_rf)

print(f"\nModel Comparison:")
print(f"Logistic Regression Accuracy: {lr_accuracy:.4f}")
print(f"Random Forest Accuracy:       {rf_accuracy:.4f}")

if lr_accuracy >= rf_accuracy:
    best_pipeline = lr_pipeline
    best_name = "Logistic Regression"
else:
    best_pipeline = rf_pipeline
    best_name = "Random Forest"

print(f"\nBest Model: {best_name}")

# %% Export the Best Model
dump(best_pipeline, 'heart_disease_model.joblib')
print(f"Model saved as 'heart_disease_model.joblib'")

# %% Verify the Exported Model
loaded_model = load('heart_disease_model.joblib')

# Test prediction with a sample patient
sample_patient = pd.DataFrame({
    'age': [55], 'sex': [1], 'cp': [2], 'trestbps': [130],
    'chol': [250], 'fbs': [0], 'restecg': [0], 'thalach': [150],
    'exang': [0], 'oldpeak': [1.5], 'slope': [1], 'ca': [0], 'thal': [3]
})

prediction = loaded_model.predict(sample_patient)
print(f"\nTest prediction for sample patient: {'Disease' if prediction[0] == 1 else 'No Disease'}")
print("Model export and reload verified successfully!")

Secret mission

Boost Your Model with Hyperparameter Tuning

Your model works, but can it work better? In this secret mission, you'll use GridSearchCV to systematically search for the best hyperparameters and squeeze more performance out of your Random Forest classifier. This is how data scientists optimize models in production.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Virtual environment (heart_disease_env).
  • Jupyter Notebook (heart_disease_prediction.ipynb).
  • Exported model files (heart_disease_model.joblib and heart_disease_model_tuned.joblib).
  • VS Code extensions (Python and Jupyter).

Keep everything running

No action needed. Choose this if you plan to extend this project or use the trained model in a future API integration.

  • Your virtual environment, notebook, and model files stay on disk ready for your next session.
  • The VS Code extensions remain useful for any future Python or Jupyter work.

Pause - I'll come back to this later

Deactivate the virtual environment to free up your terminal, but keep all files on disk so you can resume later.

  • If your virtual environment is still active, deactivate it by running:
  • Your notebook and model files remain untouched. When you return, reactivate the environment and pick up where you left off.
deactivate

Delete - I don't want to use this again

Remove all project files and start fresh. This deletes your trained models, notebook, and virtual environment permanently.

  • If your virtual environment is still active, deactivate it first:
deactivate
  • Delete the virtual environment, notebook, and model files. On macOS/Linux, run:
rm -rf heart_disease_env
rm heart_disease_prediction.ipynb
rm heart_disease_model.joblib
rm heart_disease_model_tuned.joblib
  • On Windows, run:
rmdir /s /q heart_disease_env
del heart_disease_prediction.ipynb
del heart_disease_model.joblib
del heart_disease_model_tuned.joblib
  • To uninstall the VS Code extensions, open VS Code and go to the Extensions panel (Ctrl+Shift+X).
  • Find Python by Microsoft, right-click it, and select Uninstall.
  • Do the same for Jupyter by Microsoft.

That's a Wrap!

That's a Wrap!

Nice work! You just built a complete machine learning pipeline that predicts heart disease from real clinical data. From raw UCI dataset to exported model file, you followed the exact workflow data scientists use in production.

You've learned how to:

  • Build a full ML pipeline from raw data to a trained, evaluated model using the UCI Heart Disease Cleveland dataset.
  • Use scikit-learn Pipelines and ColumnTransformer to prevent data leakage and keep preprocessing tied to the model in a single exportable object.
  • Train, compare, and evaluate Logistic Regression vs Random Forest classifiers, then export the best model as a .joblib file ready for future API integration.
  • Secret Mission: Tuned hyperparameters with GridSearchCV to maximize recall for disease detection.

Ready to quiz yourself?