Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 60

Building Reusable ML Pipelines

Recommended resources

Hello! In our last lesson, you built a robust EarlyStopping class to prevent overfitting by automatically halting training at the optimal moment. This was a crucial step in automating the training process. Now, we're going to take this a step further.

This lesson is about moving from individual scripts to a cohesive, reusable system. You have built an impressive arsenal of models, optimizers, and evaluation tools from scratch. The goal now is to structure them in a way that allows you to mix, match, and test them with minimal effort. We will design and implement a flexible training and evaluation pipeline. Given your background in software engineering, you'll find that we'll be applying classic object-oriented principles like abstraction and encapsulation to achieve this.

By the end of this lesson, you will be able to:

  • Design a modular structure for ML projects using classes for models, data, and training.
  • Refactor your existing from-scratch models to fit into this reusable pipeline.
  • Create a Trainer class that can train and evaluate any of your models.

This will set the stage perfectly for the final capstone module, where you will systematically compare all the algorithms you've built.

1. Why Bother with a Pipeline?

Before we dive into coding, let's establish why a structured pipeline is essential. As projects grow, a single script for data loading, preprocessing, training, and evaluation becomes unmanageable. Every time you want to try a new model or a different dataset, you end up copying, pasting, and modifying large chunks of code, which is inefficient and error-prone.

A well-designed pipeline solves this by separating concerns.

Machine Learning Pipeline Overview
This flowchart shows the distinct stages of a typical machine learning workflow. Our goal is to create a code structure that reflects this separation, making each stage a modular and replaceable component.

To get into the right mindset, let's watch a video that discusses high-level principles for structuring data science projects.

7 Tips To Structure Your Python Data Science Projects

The video '7 Tips To Structure Your Python Data Science Projects' by ArjanCodes offers excellent advice from a software engineering perspective. While not strictly about ML pipelines, its principles are directly applicable.

Please watch the following segments: Tip 1: Use a common structure (00:42 - 01:45) Tip 5: Move reusable code to a shared package (08:09 - 09:19) Tip 6: Move configuration to a separate file (09:19 - 11:09) Focus on how these ideas promote consistency, reusability, and maintainability.

The key takeaways from the video are:

  • Consistency is key: Using a standard structure across projects makes it easier to switch between them.
  • Isolate reusable logic: Code that can be used in multiple places (like your custom optimizers or loss functions) should be in its own modules.
  • Separate configuration from code: Hyperparameters, file paths, and other settings should not be hardcoded.

We will embody these principles by designing a set of interacting classes.


2. An Object-Oriented Blueprint for Machine Learning

A powerful and common pattern for structuring ML projects involves three core classes, each with a distinct responsibility:

  1. DataModule: Handles all aspects of data management—loading, splitting, preprocessing, and serving up batches.
  2. Module (or Model): Encapsulates the model architecture. It defines the model's layers and the forward pass. It knows nothing about data loading or the training process itself.
  3. Trainer: The orchestrator. It takes a Model, a DataModule, and an optimizer, and manages the entire training loop—iterating through epochs and batches, calculating loss, backpropagating gradients, updating weights, and running evaluation.

This design is heavily inspired by modern deep learning frameworks like PyTorch Lightning and is detailed in the "Dive into Deep Learning" book.

3.2. Object-Oriented Design for Implementation

The article 'Object-Oriented Design for Implementation' from d2l.ai provides the blueprint for the structure we will adopt. It formally introduces the Module, DataModule, and Trainer classes.

Please read the introduction and the sections on 'Models' (3.2.2), 'Data' (3.2.3), and 'Training' (3.2.4). Focus on the purpose and expected methods of each class (Module, DataModule, Trainer). Don't worry about the framework-specific code details; concentrate on the class design.


3. Implementing the Pipeline Components from Scratch

Now, let's define the skeletons for these classes in Python. This structure will be the foundation for your reusable pipeline.

A. The Model Base Class

Every model you've built, from Linear Regression to your MLP, can be adapted to fit a common interface. A base Model class will define this contract.

The ML by Hand article you've seen before reinforces this idea. The nn.Module it describes is the fundamental building block for all network layers and models.

How I Built a Deep Learning Library from Scratch...

Let's revisit the 'ML by Hand' article, which has a great section on the design of a base module for models.

Read the 'NN Module' section. Pay close attention to how it stores parameters (_parameters) and defines an abstract forward method. This is exactly what we want for our own Model base class.

Based on this, here is a simple, framework-agnostic base class for our models.

import numpy as np

class Model:
    """Base class for all models."""
    def __init__(self):
        self.parameters = {}
        self.training = True # To control behavior of layers like Dropout

    def forward(self, *inputs):
        """Defines the forward pass of the model."""
        raise NotImplementedError

    def __call__(self, *inputs):
        """Allows us to call the model instance like a function."""
        return self.forward(*inputs)

    def train(self):
        """Sets the model to training mode."""
        self.training = True

    def eval(self):
        """Sets the model to evaluation mode."""
        self.training = False

To use this, you would refactor your MLP to inherit from Model:

# Example of adapting your MLP
class MLP(Model):
    def __init__(self, layer_dims):
        super().__init__()
        # Your from-scratch weight initialization logic
        self.parameters = initialize_parameters_deep(layer_dims)
        self.layer_dims = layer_dims
        # Store activation functions, etc.

    def forward(self, X):
        # Your from-scratch forward_propagation logic
        # Make sure it returns the final output AL
        AL, caches = forward_propagation(X, self.parameters)
        return AL

B. The DataHandler Class

This class will abstract away all the data loading and preparation logic.

class DataHandler:
    """Handles loading, splitting, and batching of data."""
    def __init__(self, X, y, test_size=0.2, val_size=0.2, random_state=42):
        # Using your train_test_split function from Module 1
        # First, split into training and temp (test)
        X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=test_size, random_state=random_state)
        # Split temp (train) into new training and validation
        # The new val_size is relative to the original training set
        val_split_ratio = val_size / (1 - test_size)
        self.X_train, self.X_val, self.y_train, self.y_val = train_test_split(
            X_train, y_train, test_size=val_split_ratio, random_state=random_state
        )
        self.X_test = X_test
        self.y_test = y_test

    def get_train_batches(self, batch_size):
        # Your mini-batch creation logic from the SGD lessons
        # This should yield batches of (X_batch, y_batch)
        pass

    def get_val_data(self):
        return self.X_val, self.y_val

    def get_test_data(self):
        return self.X_test, self.y_test

C. The Trainer Class

This is the heart of our pipeline. It brings everything together. It will manage the training loop, call the model and optimizer, and use your EarlyStopping class.

import time
# Assumes you have your EarlyStopping class from the previous lesson defined elsewhere
# from early_stopping import EarlyStopping

class Trainer:
    """Orchestrates the training and evaluation process."""
    def __init__(self, model, optimizer, loss_function):
        self.model = model
        self.optimizer = optimizer
        self.loss_function = loss_function

    def fit(self, data_handler, epochs, batch_size, patience=10):
        print("Starting training...")
        early_stopper = EarlyStopping(patience=patience)
        
        for epoch in range(epochs):
            epoch_start_time = time.time()
            self.model.train() # Set model to training mode
            
            # Your training loop over mini-batches
            for X_batch, y_batch in data_handler.get_train_batches(batch_size):
                # 1. Forward pass
                y_hat = self.model(X_batch)
                
                # 2. Compute loss
                loss = self.loss_function(y_hat, y_batch)
                
                # 3. Backward pass (assumes you have a backprop function)
                grads = your_backward_pass_function(y_hat, y_batch, caches) # You'll need to adapt this
                
                # 4. Update parameters
                self.optimizer.step(self.model.parameters, grads)

            # --- Validation Phase ---
            self.model.eval() # Set model to evaluation mode
            X_val, y_val = data_handler.get_val_data()
            y_val_hat = self.model(X_val)
            val_loss = self.loss_function(y_val_hat, y_val)
            
            epoch_time = time.time() - epoch_start_time
            print(f"Epoch {epoch+1}/{epochs} | Val Loss: {val_loss:.4f} | Time: {epoch_time:.2f}s")
            
            # Early stopping check
            early_stopper(val_loss, self.model.parameters)
            if early_stopper.early_stop:
                print("Early stopping triggered. Halting training.")
                break
        
        # Restore best model weights
        print("Training finished. Restoring best model parameters.")
        self.model.parameters = early_stopper.best_model_params
        return {"best_validation_loss": early_stopper.best_loss}
Test your understanding!

Looking at the Trainer.fit method, you can see it's designed to be generic. It calls self.model(X_batch) and self.optimizer.step(...). What specific requirements does this design place on any model and optimizer object you pass to the Trainer?

Show answer
  1. Model Requirements: Any model passed to the Trainer must be a callable object (i.e., have a __call__ method) that performs a forward pass and returns the model's predictions. It must also have a parameters attribute (a dictionary) that the optimizer can access and update. Our Model base class ensures this.

  2. Optimizer Requirements: Any optimizer must have a step(parameters, gradients) method that takes the model's parameters and the computed gradients and updates the parameters in place. All the optimizers you've built (SGD, Adam) should conform to this.


4. A Conceptual Look at a Library Pipeline

While we are building our own pipeline from scratch, it's helpful to see how a mature library like Scikit-learn handles this. The following video demonstrates sklearn.pipeline.Pipeline, which chains preprocessing steps and a final model estimator together. Our Trainer class, combined with the DataHandler, accomplishes a similar goal but with the custom, transparent components you prefer.

Machine Learning Pipelines in Python: Step-by-Step Guide with Scikit-Learn

The video 'Machine Learning Pipelines in Python' by Code with Josh gives a clear, step-by-step walkthrough of building a pipeline in Scikit-learn. Watch it to solidify your mental model of what a pipeline does.

Watch from 11:58 to 19:27. This covers: Creating separate preprocessing pipelines for numerical and categorical data. Combining them into a single preprocessor. Adding a model to form the final pipeline. Fitting the pipeline to the data. Notice how the final .fit() call triggers a whole sequence of operations. Our Trainer.fit() method plays a similar role.


Conclusion

You have now designed a complete, object-oriented framework for training and evaluating your from-scratch machine learning models. By separating logic into Model, DataHandler, and Trainer classes, you've created a system that is modular, reusable, and far easier to maintain and extend.

Key Takeaways:

  • Abstraction is Power: Using base classes like Model creates a contract that allows your Trainer to work with any model that adheres to it.
  • Separation of Concerns: The Model only knows how to compute, the DataHandler only knows how to manage data, and the Trainer only knows how to orchestrate the process. This makes the code clean and testable.
  • Pipelines Automate Workflow: A well-structured pipeline encapsulates the entire train-validate-test loop, including complex logic like early stopping, making experimentation fast and reliable.

Preview of the Next Lesson:
Your new pipeline is ready for action! The next logical step is to choose a standardized dataset to put it to the test. In the next lesson, you will select a suitable benchmark dataset. This will be the first step in the final capstone module, where you will use your powerful new pipeline to systematically train, evaluate, and compare every single algorithm you've built.