Build an ML Pipeline with scikit-learn

Train and compare three ML classifiers on the Iris dataset using Python.

Introduction

30 Second Summary

Every time your email filters spam or your phone suggests the next word you'll type, a machine learning model is making predictions behind the scenes. These models learn patterns from data and use those patterns to classify new information they've never seen before.

In this project, you will build a complete machine learning pipeline that predicts flower species from physical measurements. You'll train three different algorithms, compare their performance using real metrics, and save the best model for future use.

What You'll Build

A Python classification pipeline using scikit-learn that loads data, trains multiple models, evaluates them with production-relevant metrics, and outputs predictions.

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

  • A working ML classification pipeline in a single Python script (ml_pipeline.py) that loads data, trains models, and outputs predictions.
  • A comparison of three algorithms evaluated with precision, recall, and F1 scores instead of just accuracy.
  • A confusion matrix visualization showing exactly where your model gets confused between species.
  • Secret Mission: Apply your pipeline to the Wine dataset and add cross-validation to prove your model generalizes.

Are there any prerequisites?

None. This guide includes setup for all required tools. You'll install Python packages and create a virtual environment as part of Step 1.

Set Up Your ML Environment

Every ML project starts with the right tools in place. You need Python libraries for machine learning (scikit-learn), data handling (pandas), and visualization (matplotlib).

In this step, you'll create a dedicated project folder, set up an isolated Python environment, and install these three packages.

In this step, get ready to:
  • Create a project folder and virtual environment.
  • Install scikit-learn, pandas, and matplotlib.
  • Create your pipeline script file.
Create a project folder and virtual environment

A virtual environment is an isolated copy of Python for your project. It keeps your ML packages separate from other projects on your machine, preventing version conflicts.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code and press Enter to open it.
  • Open the integrated terminal by pressing Ctrl+` (Windows) or Control+` (macOS).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir ml-pipeline
cd ml-pipeline
  • Confirm the folder exists by running:
ls

The output should be empty since the folder is brand new.

Seeing 'directory already exists'?

This means you've already created ml-pipeline before. That's fine. Just run cd ml-pipeline to move into the existing folder and continue.

Help me troubleshoot this

Now open this folder as your VS Code workspace so the Explorer sidebar shows your project files.

  • In VS Code, click File in the menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the ml-pipeline folder, and click Open (macOS) or Select Folder (Windows).
  • If VS Code asks whether you trust the authors, click Yes, I trust the authors.

Why open the folder this way?

Opening a folder as a workspace tells VS Code to treat ml-pipeline as your project root. The Explorer sidebar will show your files, and any new terminal will start inside this folder automatically.

  • Open a fresh terminal by pressing Ctrl+` (Windows) or Control+` (macOS).
  • Create and activate your virtual environment by following the tab for your operating system:

macOS

  • Create the virtual environment by running:
python3 -m venv venv
  • Activate it by running:
source venv/bin/activate

Windows

  • Create the virtual environment by running:
python -m venv venv
  • Activate it by running:
venv\Scripts\activate

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

  • Verify your Python version by running:
python --version

You should see Python 3.14.2 in the output.

Don't see (venv) in your prompt?

Make sure you ran the activate command for your operating system. On Windows with PowerShell, you may need to allow script execution first by running Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned and then trying the activate command again.

Help me troubleshoot my virtual environment

Install the ML packages

With your virtual environment active, any packages you install stay isolated inside this project. They won't interfere with other Python work on your machine.

  • Install scikit-learn, pandas, and matplotlib by running:
pip install scikit-learn==1.9.0 pandas==3.0.3 matplotlib==3.10.9

What are these packages?

scikit-learn provides ML algorithms and tools for training and evaluating models. pandas handles data loading and manipulation. matplotlib creates charts and visualizations.

The == pins each package to a specific version. This ensures your results match this guide exactly.

The installation may take a minute or two as pip downloads several dependencies.

  • Verify the installation by running:
python -c "import sklearn; print(sklearn.__version__)"

You should see 1.9.0 printed in the terminal.

Installation failed?

Make sure (venv) appears in your prompt before running pip install. If you see a permissions error, your virtual environment is not active.

If you see a network timeout, check your internet connection and try running the pip install command again.

Help me fix my pip install error

Create your script file

All your ML pipeline code will live in a single Python file. This keeps things simple and lets you run the entire pipeline with one command.

  • In the VS Code Explorer sidebar on the left, hover over the ML-PIPELINE heading.
  • Click the New File icon (a page with a small plus sign).
  • Type ml_pipeline.py and press Enter.
  • Add this comment at the top of the file:
# ML Classification Pipeline - First Project

What does this comment do?

This is a header comment that labels your file. It has no effect on how the code runs, but it makes the file's purpose clear at a glance.

  • Save ml_pipeline.py by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • You should see ml_pipeline.py appear in the Explorer sidebar on the left.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file.

ⓧ I'd like to double check the full code

Here's what ml_pipeline.py should look like at this point:

# ML Classification Pipeline - First Project

Your environment is ready. Next up, you'll load and explore the Iris dataset to understand the data your ML models will learn from.

Load and Explore the Iris Dataset

Your ML environment is ready. Now it is time to meet your data.

Every machine learning project starts with the same question: what does the data look like? Before you train any model, you need to understand the shape, distribution, and patterns in your dataset. In this step, you will load the classic Iris dataset and explore it with pandas and matplotlib.

In this step, get ready to:
  • Load the Iris dataset and convert it into a pandas DataFrame.
  • Explore the data with summary statistics and class distributions.
  • Create a scatter plot visualization and save it as an image.
Load the dataset and understand its structure

The Iris dataset is a classic classification dataset built right into scikit-learn. It contains 150 measurements of Iris flowers across three species, with four physical features per flower.

First, you will add all the imports your pipeline needs across the full project. This keeps your imports organized in one place at the top of the file.

  • In VS Code, open ml_pipeline.py and add the following imports below the existing comment:
# --- Imports ---
from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier
from sklearn.neighbors import KNeighborsClassifier
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix, ConfusionMatrixDisplay
import pandas as pd
import matplotlib.pyplot as plt
import pickle

What do these imports do?

  • sklearn.datasets gives you built-in datasets like Iris and Wine.
  • sklearn.model_selection provides tools to split your data for training and testing.
  • sklearn.tree, sklearn.neighbors, and sklearn.ensemble contain the three classification algorithms you will train later.
  • sklearn.metrics has functions to measure how well your models perform.
  • pandas lets you work with data in table form.
  • matplotlib creates charts and visualizations.
  • pickle saves trained models to disk so you can reload them later.

Now add the code that loads the Iris dataset and prints its basic structure.

  • Below the imports, add the following code to load the dataset and create a DataFrame:
# --- Step 2: Load and Explore the Iris Dataset ---
print("=" * 50)
print("STEP 2: Loading and Exploring the Iris Dataset")
print("=" * 50)

iris = load_iris()
df = pd.DataFrame(data=iris.data, columns=iris.feature_names)
df["species"] = iris.target

print(f"\nDataset shape: {df.shape}")
print(f"Feature names: {iris.feature_names}")
print(f"Target classes: {list(iris.target_names)}")

What does this code do?

  • load_iris() loads the dataset from scikit-learn. No download needed.
  • pd.DataFrame() converts the raw data into a table with labeled columns for easy exploration.
  • df["species"] = iris.target adds the target column (0, 1, or 2) representing each flower species.
  • The print statements show you the dataset dimensions (150 rows, 5 columns), the four feature names, and the three species classes (setosa, versicolor, virginica).
  • Save ml_pipeline.py.
  • Run the script in your terminal by running:
python ml_pipeline.py
  • You should see output showing the dataset shape as (150, 5), four feature names (sepal length, sepal width, petal length, petal width), and three target classes (setosa, versicolor, virginica).

Seeing a ModuleNotFoundError?

Make sure your virtual environment is activated. You should see (venv) at the start of your terminal prompt.

If it is not showing, activate it again with venv\Scripts\activate and re-run the script.

Help me debug this

Explore the data with summary statistics

You know the dataset has 150 rows and 4 features. Now dig deeper to understand the actual values and how balanced the classes are.

  • In ml_pipeline.py, add the following code below the target classes print statement:
print(f"\nFirst 5 rows:")
print(df.head())
print(f"\nStatistical summary:")
print(df.describe())
print(f"\nClass distribution:")
print(df["species"].value_counts())

What does this code do?

  • df.head() prints the first 5 rows so you can see what individual data points look like.
  • df.describe() shows count, mean, standard deviation, min, max, and percentiles for each feature.
  • df["species"].value_counts() reveals how many samples belong to each class. Balanced classes (50 each) mean no single species dominates the dataset.
  • Save ml_pipeline.py and run it again:
python ml_pipeline.py
  • You should see a table of the first 5 rows, a statistical summary with mean and standard deviation for each feature, and a class distribution showing 50 samples per species.

Output looks garbled or cut off?

If your terminal window is too narrow, the DataFrame table wraps awkwardly. Try widening your terminal or dragging the VS Code panel to be wider.

Help me fix my display

Why does class balance matter?

If one class had 140 samples and the others had 5 each, a model could just always guess the majority class and look 93% accurate. Balanced classes (50 each here) mean accuracy is a meaningful metric.

Later steps will show you metrics that work even when classes are imbalanced.

Visualize the data with a scatter plot

Numbers tell part of the story. A visualization reveals patterns your eyes can catch instantly. You will plot sepal length against petal length, colored by species, to see how separable the classes are.

  • In ml_pipeline.py, add the following visualization code below the class distribution print:
# Visualization
plt.figure(figsize=(8, 6))
for species_id, species_name in enumerate(iris.target_names):
    subset = df[df["species"] == species_id]
    plt.scatter(
        subset["sepal length (cm)"],
        subset["petal length (cm)"],
        label=species_name,
    )
plt.xlabel("Sepal Length (cm)")
plt.ylabel("Petal Length (cm)")
plt.title("Iris Dataset: Sepal Length vs Petal Length")
plt.legend()
plt.savefig("data_exploration.png", dpi=100, bbox_inches="tight")
plt.close()
print("\nVisualization saved as data_exploration.png")

What does this code do?

  • plt.figure(figsize=(8, 6)) creates an 8x6 inch canvas for your chart.
  • The for loop plots each species as a separate color cluster by filtering the DataFrame for each species ID.
  • plt.savefig() saves the chart as a PNG file instead of opening a window. This is how ML pipelines produce artifacts.
  • plt.close() releases memory. Without it, matplotlib keeps the figure in memory until the script ends.
  • Save ml_pipeline.py and run it again:
python ml_pipeline.py
  • You should see all the previous output plus a new line: Visualization saved as data_exploration.png.
  • Confirm the file exists by running:
ls
  • You should see data_exploration.png listed alongside ml_pipeline.py and the venv folder.
  • Open data_exploration.png by clicking on it in the VS Code **Explorer** sidebar. You should see three color-coded clusters of dots.

What does the scatter plot tell you?

Setosa (bottom-left cluster) is clearly separated from the other two species. Versicolor and virginica overlap slightly in the middle-right area.

This tells you a classifier should easily distinguish setosa but might confuse versicolor and virginica. You will see this confirmed in the confusion matrix later.

Don't see data_exploration.png?

Make sure you saved ml_pipeline.py before running it. The file is saved to whatever directory your terminal is currently in.

Run pwd (macOS/Linux) or cd (Windows) to confirm you are inside the ml-pipeline folder.

Help me debug this

✔️ Awesome, I've got everything!

Great. Double check you have saved ml_pipeline.py and that data_exploration.png exists in your project folder.

ⓧ I'd like to double check the full code

Here is the complete ml_pipeline.py file at the end of this step:

# ML Classification Pipeline - First Project

# --- Imports ---
from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier
from sklearn.neighbors import KNeighborsClassifier
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix, ConfusionMatrixDisplay
import pandas as pd
import matplotlib.pyplot as plt
import pickle

# --- Step 2: Load and Explore the Iris Dataset ---
print("=" * 50)
print("STEP 2: Loading and Exploring the Iris Dataset")
print("=" * 50)

iris = load_iris()
df = pd.DataFrame(data=iris.data, columns=iris.feature_names)
df["species"] = iris.target

print(f"\nDataset shape: {df.shape}")
print(f"Feature names: {iris.feature_names}")
print(f"Target classes: {list(iris.target_names)}")
print(f"\nFirst 5 rows:")
print(df.head())
print(f"\nStatistical summary:")
print(df.describe())
print(f"\nClass distribution:")
print(df["species"].value_counts())

# Visualization
plt.figure(figsize=(8, 6))
for species_id, species_name in enumerate(iris.target_names):
    subset = df[df["species"] == species_id]
    plt.scatter(
        subset["sepal length (cm)"],
        subset["petal length (cm)"],
        label=species_name,
    )
plt.xlabel("Sepal Length (cm)")
plt.ylabel("Petal Length (cm)")
plt.title("Iris Dataset: Sepal Length vs Petal Length")
plt.legend()
plt.savefig("data_exploration.png", dpi=100, bbox_inches="tight")
plt.close()
print("\nVisualization saved as data_exploration.png")

You now have a solid understanding of your dataset. You know its shape, its balance, and which features might help a classifier separate the species. Next up, you will split this data and train your first model.

Split Data and Train Your First Model

In the previous step, you loaded the Iris dataset and explored its shape, distributions, and visual patterns. You now know what the data looks like.

Now it's time to teach a machine to recognize those patterns on its own. In this step, you'll split the data into training and test sets, then train your first classification model to predict flower species from measurements.

In this step, get ready to:
  • Split the dataset into training and test sets.
  • Train a Decision Tree classifier on the training data.
  • Evaluate the model's accuracy and understand its limitations.
Prepare the data for training

A model can't learn and be tested on the same data. If you test on data the model already memorized, you won't know if it actually learned the patterns or just recalled them.

The solution is a train/test split. You hold back a portion of the data that the model never sees during training, then use it as a fair test.

  • In ml_pipeline.py, add the following code at the bottom of your file, below the line that says print("\nVisualization saved as data_exploration.png"):
# --- Step 3: Split Data and Train First Model ---
print("\n" + "=" * 50)
print("STEP 3: Splitting Data and Training First Model")
print("=" * 50)

X = iris.data
y = iris.target

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

What does this code do?

  • X = iris.data grabs the feature measurements (sepal length, sepal width, petal length, petal width) as a number array.
  • y = iris.target grabs the species labels (0, 1, or 2) that the model will learn to predict.
  • train_test_split randomly divides the data. test_size=0.2 means 20% is held back for testing (30 samples) and 80% is used for training (120 samples).
  • stratify=y ensures each species is equally represented in both the training and test sets. Without this, you might accidentally put all of one species into the test set.
  • random_state=42 locks the randomness so you get the same split every time you run the script. This makes your results reproducible.
  • Save ml_pipeline.py.
  • Run the script in your terminal by running:
python ml_pipeline.py
  • Near the bottom of the output, you should see Training set size: 120 samples and Test set size: 30 samples.

Not seeing the split output?

  • Make sure the new code is below the visualization code, not inside the for loop. Check your indentation.
  • If you see an error about a name not being defined, confirm that all your Step 2 code (which creates the iris variable) is still in the file above this new section.

Why isn't my train/test split printing the right sizes?

Why do we hold back test data?

Imagine studying for an exam by memorizing all the answers. You'd ace that exact test, but fail any new questions. This is called overfitting.

By holding back 20% of the data, you're giving the model a "pop quiz" it has never seen. If it performs well on this unseen data, you know it actually learned the patterns instead of memorizing the answers.

Train a Decision Tree classifier

A Decision Tree learns by asking yes/no questions about your data. For example: "Is petal length greater than 2.5 cm?" Based on the answer, it splits the data and asks another question until it reaches a prediction.

  • Add the following code at the bottom of ml_pipeline.py, below the line that prints the test set size:
dt_model = DecisionTreeClassifier(random_state=42)
dt_model.fit(X_train, y_train)
dt_predictions = dt_model.predict(X_test)

dt_accuracy = accuracy_score(y_test, dt_predictions)
print(f"\nDecision Tree Accuracy: {dt_accuracy:.4f}")

What does this code do?

  • DecisionTreeClassifier(random_state=42) creates a brand new Decision Tree model. It doesn't know anything yet.
  • .fit(X_train, y_train) is where the model actually learns. It studies the training measurements and their matching species labels to build its internal decision rules.
  • .predict(X_test) gives the model the test measurements (without labels) and asks it to guess the species. These guesses go into dt_predictions.
  • accuracy_score compares the model's guesses against the real answers and calculates the percentage it got right.
  • Save ml_pipeline.py and run the script again:
python ml_pipeline.py
  • At the bottom of your output, you should see Decision Tree Accuracy: followed by a decimal number (like 1.0000 or 0.9667). That number is the fraction of test flowers your model predicted correctly.

Seeing a NameError?

  • If Python says X_train is not defined, make sure this code comes after the train_test_split call, not before it.
  • If Python says DecisionTreeClassifier is not defined, check that the import at the top of your file still says from sklearn.tree import DecisionTreeClassifier.

Help me fix my Decision Tree training code.

Evaluate with accuracy

Your model just scored its first accuracy number. That feels great, but accuracy alone can be misleading in real-world problems.

Why is accuracy not enough?

Imagine a medical test where 95% of patients are healthy. A model that always predicts "healthy" would be 95% accurate, but it would miss every single sick patient. That's a dangerous model.

The Iris dataset is balanced (50 samples per species), so accuracy works reasonably well here. But in the next step, you'll learn metrics that catch problems accuracy hides: precision ("of the flowers I labeled setosa, how many actually were?") and recall ("of all the real setosa flowers, how many did I catch?").

✔️ Awesome, I've got everything!

Great. Double check you've saved ml_pipeline.py and move on to the next step.

ⓧ I'd like to double check the full code

Here is the complete ml_pipeline.py up to this point. Compare your file against this:

# ML Classification Pipeline - First Project

# --- Imports ---
from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier
from sklearn.neighbors import KNeighborsClassifier
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix, ConfusionMatrixDisplay
import pandas as pd
import matplotlib.pyplot as plt
import pickle

# --- Step 2: Load and Explore the Iris Dataset ---
print("=" * 50)
print("STEP 2: Loading and Exploring the Iris Dataset")
print("=" * 50)

iris = load_iris()
df = pd.DataFrame(data=iris.data, columns=iris.feature_names)
df["species"] = iris.target

print(f"\nDataset shape: {df.shape}")
print(f"Feature names: {iris.feature_names}")
print(f"Target classes: {list(iris.target_names)}")
print(f"\nFirst 5 rows:")
print(df.head())
print(f"\nStatistical summary:")
print(df.describe())
print(f"\nClass distribution:")
print(df["species"].value_counts())

# Visualization
plt.figure(figsize=(8, 6))
for species_id, species_name in enumerate(iris.target_names):
    subset = df[df["species"] == species_id]
    plt.scatter(
        subset["sepal length (cm)"],
        subset["petal length (cm)"],
        label=species_name,
    )
plt.xlabel("Sepal Length (cm)")
plt.ylabel("Petal Length (cm)")
plt.title("Iris Dataset: Sepal Length vs Petal Length")
plt.legend()
plt.savefig("data_exploration.png", dpi=100, bbox_inches="tight")
plt.close()
print("\nVisualization saved as data_exploration.png")

# --- Step 3: Split Data and Train First Model ---
print("\n" + "=" * 50)
print("STEP 3: Splitting Data and Training First Model")
print("=" * 50)

X = iris.data
y = iris.target

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

dt_model = DecisionTreeClassifier(random_state=42)
dt_model.fit(X_train, y_train)
dt_predictions = dt_model.predict(X_test)

dt_accuracy = accuracy_score(y_test, dt_predictions)
print(f"\nDecision Tree Accuracy: {dt_accuracy:.4f}")

You just trained your first ML model and got an accuracy score. In the next step, you'll train two more algorithms and compare all three using metrics that reveal much more than accuracy alone.

Compare Multiple Models with Real Metrics

Your Decision Tree gave you a single accuracy score, but accuracy alone can hide problems. A model predicting the majority class every time could score 90% on an imbalanced dataset while being completely useless in practice.

In this step, you will train two more algorithms and evaluate all three using precision, recall, and F1 scores. These metrics reveal exactly where each model succeeds and where it fails, giving you the evidence to pick the best one.

In this step, get ready to:
  • Train a K-Nearest Neighbors and a Random Forest model on your training data.
  • Print classification reports comparing precision, recall, and F1 for all three models.
  • Generate and save a confusion matrix showing prediction errors visually.
Train two additional models

A single model gives you no basis for comparison. Training multiple algorithms on the same data lets you see which approach fits this problem best.

  • In ml_pipeline.py, scroll to the bottom of your file (after the Decision Tree accuracy print statement).
  • Add the Step 4 section header and the K-Nearest Neighbors model by copying the following code:
# --- Step 4: Compare Multiple Models ---
print("\n" + "=" * 50)
print("STEP 4: Comparing Multiple Models")
print("=" * 50)

# Train K-Nearest Neighbors (finds the 5 closest training samples to classify)
knn_model = KNeighborsClassifier(n_neighbors=5)
knn_model.fit(X_train, y_train)
knn_predictions = knn_model.predict(X_test)

What does this code do?

KNeighborsClassifier(n_neighbors=5) creates a model that classifies a flower by looking at the 5 most similar flowers in the training set and voting on the species.

The .fit() and .predict() calls follow the exact same pattern as your Decision Tree. This consistent API is one of the reasons scikit-learn is so popular for learning ML.

  • You won't see new output yet. Add the Random Forest model directly below by copying this code:
# Train Random Forest (builds 100 decision trees and combines their votes)
rf_model = RandomForestClassifier(n_estimators=100, random_state=42)
rf_model.fit(X_train, y_train)
rf_predictions = rf_model.predict(X_test)

What is a Random Forest?

A Random Forest trains many decision trees on random subsets of your data, then combines their predictions through voting. The idea is that a crowd of simple trees makes better decisions than any single tree alone.

n_estimators=100 means 100 trees. random_state=42 ensures you get the same results each time you run the script.

  • Save ml_pipeline.py.
  • You won't see model results in the terminal yet. The next chunk adds the classification reports that print each model's scores.
Evaluate all three models with a classification report

Accuracy tells you the percentage of correct predictions overall. But precision, recall, and F1 tell you how well the model performs on each individual class.

What are precision, recall, and F1?

Precision answers: of all the flowers the model labeled as species X, how many were actually species X?

Recall answers: of all the actual species X flowers, how many did the model find?

F1 is the balanced average of precision and recall. A high F1 means the model is both precise and thorough.

  • Add this code at the bottom of ml_pipeline.py to organize all three models into a dictionary and print their classification reports:
# Store all models and their predictions for comparison
models = {
    "Decision Tree": (dt_model, dt_predictions),
    "K-Nearest Neighbors": (knn_model, knn_predictions),
    "Random Forest": (rf_model, rf_predictions),
}

# Print detailed metrics for each model
for name, (model, predictions) in models.items():
    print(f"\n--- {name} ---")
    print(classification_report(y_test, predictions, target_names=iris.target_names))

What does this code do?

The models dictionary stores each model alongside its predictions. This makes it easy to loop through all three and print a consistent comparison.

classification_report prints a table with precision, recall, F1-score, and support (sample count) for each class. Passing target_names=iris.target_names replaces the numeric labels (0, 1, 2) with the actual species names.

  • Save ml_pipeline.py.
  • Run the script in your terminal:
python ml_pipeline.py
  • Scroll down to the "STEP 4: Comparing Multiple Models" section in the output. You should see three classification reports, each showing precision, recall, and F1-score columns for setosa, versicolor, and virginica.

Not seeing the Step 4 output?

  • Make sure you saved ml_pipeline.py before running the command.
  • Check that your new code is below the Step 3 code, not inside it or above it.
  • If you see an indentation error, make sure none of the new lines are accidentally indented.

Help me debug my classification report output.

Generate a confusion matrix for the best model

A classification report gives you numbers, but a confusion matrix gives you a picture. It shows exactly which species the model confuses with each other, making patterns of error immediately visible.

  • Add this code at the bottom of ml_pipeline.py to select the best model and generate its confusion matrix:
# Confusion matrix for best model
best_name = max(models, key=lambda k: accuracy_score(y_test, models[k][1]))
best_model, best_predictions = models[best_name]

print(f"\nBest model: {best_name} (Accuracy: {accuracy_score(y_test, best_predictions):.4f})")

# Visualize where the best model makes mistakes
cm = confusion_matrix(y_test, best_predictions)
disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=iris.target_names)
disp.plot(cmap="Blues")
plt.title(f"Confusion Matrix - {best_name}")
plt.savefig("confusion_matrix.png", dpi=100, bbox_inches="tight")
plt.close()
print("Confusion matrix saved as confusion_matrix.png")

What does this code do?

max(models, key=lambda k: accuracy_score(y_test, models[k][1])) finds the model with the highest accuracy by comparing each model's predictions against the true labels.

The confusion matrix is a grid where each row represents the actual species and each column represents the predicted species. Diagonal cells show correct predictions. Off-diagonal cells show mistakes. For example, a 2 in the versicolor row and virginica column means 2 versicolor flowers were incorrectly predicted as virginica.

  • Save ml_pipeline.py and run the script again:
python ml_pipeline.py
  • You should see a line printed that says "Best model:" followed by the winning algorithm and its accuracy score.
  • Confirm the confusion matrix image was saved by running:
ls
  • You should see confusion_matrix.png alongside data_exploration.png in the output.
  • Open confusion_matrix.png from the file explorer in VS Code to see which species the model confuses most often.

Don't see confusion_matrix.png?

  • Check that import matplotlib.pyplot as plt is still at the top of your file. The confusion matrix uses matplotlib to render.
  • Make sure plt.close() is included. Without it, the figure may not save properly.
  • Verify you are running the script from inside your ml-pipeline folder. The image saves to the current working directory.

Help me figure out why my confusion matrix isn't saving.

✔️ Awesome, I've got everything!

Great. Double check you have saved ml_pipeline.py and both data_exploration.png and confusion_matrix.png exist in your project folder.

ⓧ I'd like to double check the full code

Here is the full ml_pipeline.py file as it should look at the end of this step:

# ML Classification Pipeline - First Project

# --- Imports ---
from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier
from sklearn.neighbors import KNeighborsClassifier
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix, ConfusionMatrixDisplay
import pandas as pd
import matplotlib.pyplot as plt
import pickle

# --- Step 2: Load and Explore the Iris Dataset ---
print("=" * 50)
print("STEP 2: Loading and Exploring the Iris Dataset")
print("=" * 50)

iris = load_iris()
df = pd.DataFrame(data=iris.data, columns=iris.feature_names)
df["species"] = iris.target

print(f"\nDataset shape: {df.shape}")
print(f"Feature names: {iris.feature_names}")
print(f"Target classes: {list(iris.target_names)}")
print(f"\nFirst 5 rows:")
print(df.head())
print(f"\nStatistical summary:")
print(df.describe())
print(f"\nClass distribution:")
print(df["species"].value_counts())

# Visualization
plt.figure(figsize=(8, 6))
for species_id, species_name in enumerate(iris.target_names):
    subset = df[df["species"] == species_id]
    plt.scatter(
        subset["sepal length (cm)"],
        subset["petal length (cm)"],
        label=species_name,
    )
plt.xlabel("Sepal Length (cm)")
plt.ylabel("Petal Length (cm)")
plt.title("Iris Dataset: Sepal Length vs Petal Length")
plt.legend()
plt.savefig("data_exploration.png", dpi=100, bbox_inches="tight")
plt.close()
print("\nVisualization saved as data_exploration.png")

# --- Step 3: Split Data and Train First Model ---
print("\n" + "=" * 50)
print("STEP 3: Splitting Data and Training First Model")
print("=" * 50)

X = iris.data
y = iris.target

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

dt_model = DecisionTreeClassifier(random_state=42)
dt_model.fit(X_train, y_train)
dt_predictions = dt_model.predict(X_test)

dt_accuracy = accuracy_score(y_test, dt_predictions)
print(f"\nDecision Tree Accuracy: {dt_accuracy:.4f}")

# --- Step 4: Compare Multiple Models ---
print("\n" + "=" * 50)
print("STEP 4: Comparing Multiple Models")
print("=" * 50)

knn_model = KNeighborsClassifier(n_neighbors=5)
knn_model.fit(X_train, y_train)
knn_predictions = knn_model.predict(X_test)

rf_model = RandomForestClassifier(n_estimators=100, random_state=42)
rf_model.fit(X_train, y_train)
rf_predictions = rf_model.predict(X_test)

models = {
    "Decision Tree": (dt_model, dt_predictions),
    "K-Nearest Neighbors": (knn_model, knn_predictions),
    "Random Forest": (rf_model, rf_predictions),
}

for name, (model, predictions) in models.items():
    print(f"\n--- {name} ---")
    print(classification_report(y_test, predictions, target_names=iris.target_names))

# Confusion matrix for best model
best_name = max(models, key=lambda k: accuracy_score(y_test, models[k][1]))
best_model, best_predictions = models[best_name]

print(f"\nBest model: {best_name} (Accuracy: {accuracy_score(y_test, best_predictions):.4f})")

cm = confusion_matrix(y_test, best_predictions)
disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=iris.target_names)
disp.plot(cmap="Blues")
plt.title(f"Confusion Matrix - {best_name}")
plt.savefig("confusion_matrix.png", dpi=100, bbox_inches="tight")
plt.close()
print("Confusion matrix saved as confusion_matrix.png")

You now have real evidence comparing three algorithms. You know which one performs best and exactly where it makes mistakes. Next up, you will select the winner, make predictions on brand new data, and save your trained model for reuse.

Select and Save the Best Model

In the previous step, you trained three models and compared them using classification reports and a confusion matrix. You now know which algorithm performs best on the Iris test set.

A trained model is only useful if you can use it later without retraining from scratch. In this step, you will formally announce the winning model, use it to predict a brand-new flower measurement, and then save it to disk so it can be loaded and reused anytime.

In this step, get ready to:
  • Print the winning model with its accuracy score.
  • Predict the species of a new flower using the best model.
  • Save the trained model to disk and verify it loads correctly.
Compare model performance and select the winner

Your pipeline already identified the best model in Step 4. Now you will formally print the winner with its accuracy score so the output reads like a clear summary report.

  • In ml_pipeline.py, scroll to the bottom of the file (after the confusion matrix code).
  • Add the Step 5 header and winner announcement by copying the following code at the bottom of the file:
# --- Step 5: Select and Save the Best Model ---
print("\n" + "=" * 50)
print("STEP 5: Selecting and Saving the Best Model")
print("=" * 50)

print(f"\nWinner: {best_name}")
print(f"Accuracy: {accuracy_score(y_test, best_predictions):.4f}")

What does this code do?

This prints a clean summary header and then announces the winner. The best_name variable was already set in Step 4 using the max() function to find the model with the highest accuracy.

The :.4f format shows the accuracy to 4 decimal places, giving you a precise comparison number.

  • Save ml_pipeline.py.
  • You won't see a change yet. The next chunk uses the winning model to make a real prediction.
Make predictions on new unseen data

The whole point of training a model is to use it on data it has never seen before. You will create a fictional flower measurement and ask the model to predict which species it belongs to.

  • Add the following code directly below the accuracy print statement in ml_pipeline.py:
# Predict on new data
new_flower = [[5.1, 3.5, 1.4, 0.2]]
prediction = best_model.predict(new_flower)
predicted_species = iris.target_names[prediction[0]]
print(f"\nNew flower measurement: {new_flower[0]}")
print(f"Predicted species: {predicted_species}")

What does this code do?

The new_flower variable holds four measurements (sepal length, sepal width, petal length, petal width) wrapped in a nested list. The double brackets [[...]] are required because scikit-learn expects a 2D array, even for a single sample.

The .predict() method returns a numeric class (0, 1, or 2). Using iris.target_names[prediction[0]] converts that number back to a human-readable species name like "setosa".

  • Save ml_pipeline.py.
  • You won't see a change yet. The next chunk saves and verifies the model, then you will run the full script.
Save the trained model for future use

A model in memory disappears when your script finishes. To reuse it later (or deploy it to a server), you need to serialize it to a file. Python's built-in pickle module handles this.

  • Add the following code at the bottom of ml_pipeline.py, below the predicted species print statement:
# Save the model
with open("best_model.pkl", "wb") as f:
    pickle.dump(best_model, f)
print("\nModel saved as best_model.pkl")

# Load and verify
with open("best_model.pkl", "rb") as f:
    loaded_model = pickle.load(f)

verification = loaded_model.predict(new_flower)
print(f"Loaded model prediction: {iris.target_names[verification[0]]}")
print(f"Predictions match: {prediction[0] == verification[0]}")

print("\n" + "=" * 50)
print("PIPELINE COMPLETE!")
print("=" * 50)

What does this code do?

The pickle.dump() call serializes the trained model object to a binary file called best_model.pkl. The "wb" mode means "write binary" because pickle produces binary data, not text.

The verification section loads the model back from disk with pickle.load() and makes the same prediction. If the predictions match, the save-and-load cycle worked correctly. This is the same pattern used in production to persist models between training and serving.

Why pickle instead of joblib?

You may see tutorials using joblib for model persistence. Both work well, but pickle is part of the Python standard library. No extra install required.

  • Save ml_pipeline.py.
  • Run the full pipeline in your terminal by running this command:
python ml_pipeline.py
  • Scroll to the bottom of the terminal output. You should see the Step 5 section showing the winner name, the predicted species for the new flower, and Predictions match: True confirming the save/load worked.
  • Confirm the model file exists by running:
ls
  • You should see best_model.pkl listed alongside ml_pipeline.py, data_exploration.png, and confusion_matrix.png.

Seeing Predictions match: False?

Make sure the loaded_model.predict(new_flower) call uses the same new_flower variable as the original prediction. If you redefined new_flower between the two calls, the inputs differ.

Also check that your pickle.dump and pickle.load calls both reference the same filename best_model.pkl.

Why is my model save/load not matching?

✔️ Awesome, I've got everything!

Great. Double check you have saved your file and your terminal shows PIPELINE COMPLETE! at the bottom of the output.

ⓧ I'd like to double check the full code

Here is the complete ml_pipeline.py file. Compare yours against this:

# ML Classification Pipeline - First Project

# --- Imports ---
from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier
from sklearn.neighbors import KNeighborsClassifier
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix, ConfusionMatrixDisplay
import pandas as pd
import matplotlib.pyplot as plt
import pickle

# --- Step 2: Load and Explore the Iris Dataset ---
print("=" * 50)
print("STEP 2: Loading and Exploring the Iris Dataset")
print("=" * 50)

iris = load_iris()
df = pd.DataFrame(data=iris.data, columns=iris.feature_names)
df["species"] = iris.target

print(f"\nDataset shape: {df.shape}")
print(f"Feature names: {iris.feature_names}")
print(f"Target classes: {list(iris.target_names)}")
print(f"\nFirst 5 rows:")
print(df.head())
print(f"\nStatistical summary:")
print(df.describe())
print(f"\nClass distribution:")
print(df["species"].value_counts())

# Visualization
plt.figure(figsize=(8, 6))
for species_id, species_name in enumerate(iris.target_names):
    subset = df[df["species"] == species_id]
    plt.scatter(
        subset["sepal length (cm)"],
        subset["petal length (cm)"],
        label=species_name,
    )
plt.xlabel("Sepal Length (cm)")
plt.ylabel("Petal Length (cm)")
plt.title("Iris Dataset: Sepal Length vs Petal Length")
plt.legend()
plt.savefig("data_exploration.png", dpi=100, bbox_inches="tight")
plt.close()
print("\nVisualization saved as data_exploration.png")

# --- Step 3: Split Data and Train First Model ---
print("\n" + "=" * 50)
print("STEP 3: Splitting Data and Training First Model")
print("=" * 50)

X = iris.data
y = iris.target

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

dt_model = DecisionTreeClassifier(random_state=42)
dt_model.fit(X_train, y_train)
dt_predictions = dt_model.predict(X_test)

dt_accuracy = accuracy_score(y_test, dt_predictions)
print(f"\nDecision Tree Accuracy: {dt_accuracy:.4f}")

# --- Step 4: Compare Multiple Models ---
print("\n" + "=" * 50)
print("STEP 4: Comparing Multiple Models")
print("=" * 50)

knn_model = KNeighborsClassifier(n_neighbors=5)
knn_model.fit(X_train, y_train)
knn_predictions = knn_model.predict(X_test)

rf_model = RandomForestClassifier(n_estimators=100, random_state=42)
rf_model.fit(X_train, y_train)
rf_predictions = rf_model.predict(X_test)

models = {
    "Decision Tree": (dt_model, dt_predictions),
    "K-Nearest Neighbors": (knn_model, knn_predictions),
    "Random Forest": (rf_model, rf_predictions),
}

for name, (model, predictions) in models.items():
    print(f"\n--- {name} ---")
    print(classification_report(y_test, predictions, target_names=iris.target_names))

# Confusion matrix for best model
best_name = max(models, key=lambda k: accuracy_score(y_test, models[k][1]))
best_model, best_predictions = models[best_name]

print(f"\nBest model: {best_name} (Accuracy: {accuracy_score(y_test, best_predictions):.4f})")

cm = confusion_matrix(y_test, best_predictions)
disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=iris.target_names)
disp.plot(cmap="Blues")
plt.title(f"Confusion Matrix - {best_name}")
plt.savefig("confusion_matrix.png", dpi=100, bbox_inches="tight")
plt.close()
print("Confusion matrix saved as confusion_matrix.png")

# --- Step 5: Select and Save the Best Model ---
print("\n" + "=" * 50)
print("STEP 5: Selecting and Saving the Best Model")
print("=" * 50)

print(f"\nWinner: {best_name}")
print(f"Accuracy: {accuracy_score(y_test, best_predictions):.4f}")

# Predict on new data
new_flower = [[5.1, 3.5, 1.4, 0.2]]
prediction = best_model.predict(new_flower)
predicted_species = iris.target_names[prediction[0]]
print(f"\nNew flower measurement: {new_flower[0]}")
print(f"Predicted species: {predicted_species}")

# Save the model
with open("best_model.pkl", "wb") as f:
    pickle.dump(best_model, f)
print("\nModel saved as best_model.pkl")

# Load and verify
with open("best_model.pkl", "rb") as f:
    loaded_model = pickle.load(f)

verification = loaded_model.predict(new_flower)
print(f"Loaded model prediction: {iris.target_names[verification[0]]}")
print(f"Predictions match: {prediction[0] == verification[0]}")

print("\n" + "=" * 50)
print("PIPELINE COMPLETE!")
print("=" * 50)

Secret mission

Apply Your Pipeline to a New Dataset

Your Iris pipeline works, but can you prove the workflow generalizes? In this challenge, you will apply the exact same pattern to the Wine dataset (13 features, 3 classes) and add cross-validation to get a more reliable performance estimate.

Clean Up Your Resources

Clean Up Your Resources

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

Resources you used:

  • The ml-pipeline project folder on your Desktop (contains all scripts, images, and the saved model).
  • The venv virtual environment inside your project folder (contains scikit-learn, pandas, and matplotlib).

Keep everything running

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

  • Your ml-pipeline folder stays on your Desktop with all your scripts and outputs.
  • Your virtual environment stays ready. Next time you open the project, activate it again with venv\Scripts\activate to pick up where you left off.
  • Python, VS Code, and all installed packages remain on your machine.

Pause - I'll come back to this later

Deactivate the virtual environment to free up your terminal session. Your files and packages stay intact for next time.

  • Deactivate your virtual environment by running:
deactivate
  • Close VS Code. When you come back, reopen the ml-pipeline folder and activate your environment again.

Delete - I don't want to use this again

Remove all project files. Since everything lives in one folder (including the virtual environment and installed packages), a single delete clears it all.

  • Deactivate the virtual environment first by running:
deactivate
  • Navigate to your Desktop by running:
cd ~/Desktop
  • Delete the entire project folder by running:

macOS

rm -rf ml-pipeline

Windows

rmdir /s /q ml-pipeline
  • Confirm deletion by running:
ls
  • You should no longer see ml-pipeline in the output.

Nice Work!

Nice Work!

You just built a complete machine learning classification pipeline from scratch using Python and scikit-learn. You went from raw data to trained models, evaluated them with production-relevant metrics, and saved the best one to disk.

You've learned how to:

  • Build an end-to-end ML pipeline that loads data, splits it into training and test sets, trains multiple algorithms, and compares their performance.
  • Evaluate models using precision, recall, F1 scores, and confusion matrices to understand real-world performance beyond simple accuracy.
  • Save a trained model to disk with pickle and reload it to make predictions on new unseen data.
  • Secret Mission: Applied the pipeline to the Wine dataset with cross-validation, proving the workflow generalizes to any classification problem.

Ready to quiz yourself?