LECTURE SERIES

Machine Learning
Design Patterns

Architectural Blueprints for Reusable, Scalable & Maintainable AI Systems

What are Design Patterns in ML?

Reusable solutions to recurring software engineering challenges in data processing, model training, inference, and deployment pipelines.

Core Objectives
  • Decouple System Components
  • Optimize Hardware Execution
  • Extend Library Functionality
  • Maintain Clean Codebase

Patterns Overview

Data & Workflows
  • 1. Pipeline
  • 2. Workflow (DAG)
  • 3. Function as Data
  • 4. Iterator Pattern
Execution & Serving
  • 5. Job Queues
  • 6. Callbacks / Observer
  • 7. Learner Pattern
  • 8. Batch & Vectorization
Architecture & Frontiers
  • 9. Decorator Pattern
  • 10. Strategy Pattern
  • Advanced: Autograd
  • Distributed Patterns

1. Pipeline Pattern

Sequential processing of data using an arbitrary chain of transformations. Essential for data preprocessing and inference frameworks.

  • Process sequence: preprocess ➔ inference ➔ postprocess
  • Constraint: Output type of stage N must match input type of stage N+1.
  • Decouples individual processing steps.
Preprocess Inference Postprocess

1. Pipeline — Code Implementation

Function Sequence Pipeline:


from typing import Union, List

def preprocess(input: Union[str, Image]) -> Tensor:
    return load_and_transform(input)

def inference(input: Tensor) -> Tensor:
    return model(input)

def postprocess(input: Tensor) -> str:
    return decode_logits(input)

# Pipeline Execution Loop
pipeline = [preprocess, inference, postprocess]
data = "sample_input.jpg"
for step in pipeline:
    data = step(data)
return data
                        

Keras-style Sequential Layer Pipeline:


class KerasModel:
    def __init__(self):
        self.layers = []
    
    def add_layer(self, layer):
        self.layers.append(layer)
    
    def forward(self, input):
        for layer in self.layers:
            input = layer(input)
        return input

# Usage
model = KerasModel()
model.add_layer(Linear(784, 128))
model.add_layer(ReLU())
output = model.forward(raw_tensor)
                        

2. Workflow Pattern (DAG)

A generalization of pipelines into a Directed Acyclic Graph (DAG). Handles non-linear dependencies, branching, and parallel execution.

  • Powers workflow engines: Apache Airflow, Metaflow, TorchServe Ensembles.
  • Nodes represent execution tasks/models; edges define data flow dependencies.
  • Enables parallel execution of independent branches.
Input A B E C D

2. Workflow DAG — Code Structure

1. Graph Definition (Adjacency List):


# Representing DAG with Python dictionary
graph = {
    'input': ['a'],
    'a': ['b', 'e'],
    'b': ['c', 'd'],
    'd': ['e']
}
                        
Orchestrator Responsibilities: Dependency resolution, resource scheduling (CPU/GPU allocation), retries & failure recovery.

2. Workflow Execution Engine Concept:


class Step:
    def __init__(self, inputs, outputs):
        self.inputs = inputs
        self.outputs = outputs
        self.dependencies_met = False
        self.resources = {"cpu": 2, "gpu": 1}
    
    def execute(self):
        if self.dependencies_met:
            # Run model / transformation step
            pass

class WorkflowEngine:
    def __init__(self, dag):
        self.dag = dag
    def execute(self):
        # Resolve topological order & run
        pass
                        

3. Function as Data

Originating from LISP (homoiconicity), code and data share the same representation. Functions can be treated as inspectable, serializable data objects.

LISP Concept:
(+ 1 2)   ;; Executable Function call
'(+ 1 2)  ;; Quoted String / Data structure
Allows static optimizers to analyze and transform code into evaluated results (e.g. constant folding to 3).
PyTorch Model Duality:

A model is a Callable Function (forward pass), but its parameters are Data Arrays (tensors).


model = myModel()

# 1. Used as a Function:
output = model(torch.randn(100))

# 2. Inspected as Data (Weights / Pickle):
weights = model.state_dict()
serialized_bytes = pickle.dumps(model)
                        

4. Iterator Design Pattern

Provides a clean, uniform interface to sequentially access elements or mini-batches from a data source without exposing its underlying storage logic.

Target ML Goal:
for batch in dataset:
    output = model(batch)

Implemented via Python's standard __iter__() and __next__() magic methods.


from typing import List

class CustomDataset:
    def __init__(self, data: List[str]):
        self.data = data
        self.index = 0

    def __iter__(self):
        self.index = 0
        return self

    def __next__(self, batch_size: int = 1):
        if self.index >= len(self.data):
            raise StopIteration
        
        batch = self.data[self.index : self.index + batch_size]
        self.index += batch_size
        return batch
                        

5. Job Queues Pattern

Decouples client request reception from model inference execution using an asynchronous queue system. Essential for multi-model serving.

  • Prevents server overload when inference latency is high.
  • Allows spawning background Python processes for inference.
  • Exposes outputs back via REST APIs or message brokers.
Client API Job Queue [Job 1, Job 2] JobProcessor (PyTorch Model)

5. Job Queue — Python Implementation


from dataclasses import dataclass
from typing import Union, Tuple, List

@dataclass
class Job:
    model_name: str
    input_data: Union[str, bytes]
    endpoint: Tuple[str, int]  # (host, port)

class JobProcessor:
    def __init__(self):
        self.jobs: List[Job] = []
    
    def add_job(self, job: Job):
        self.jobs.append(job)
    
    def process_next(self):
        if not self.jobs:
            return None
        job = self.jobs.pop(0)  # FIFO Queue
        return self.execute(job)
    
    def execute(self, job: Job):
        # Run inference using model registry & publish result to endpoint
        result = run_model_inference(job.model_name, job.input_data)
        return self.expose(result, job.endpoint)
                

6. Callbacks / Observer Pattern

Defines a one-to-many dependency between objects. When the subject changes state (e.g., end of epoch), all registered observers are automatically notified.

Why Use Observers in ML?

Extends training framework functionality (TensorBoard logging, LR schedulers, early stopping) without mutating core framework code.

ModelSubject (Trainer Loop) LogObserver LRSchedulerObserver CheckpointObserver

6. Callbacks / Observer — Implementation


from abc import ABC, abstractmethod

class Observer(ABC):
    @abstractmethod
    def update(self, state: dict):
        pass

class ModelSubject:
    def __init__(self):
        self.observers: List[Observer] = []
        self.state = {}

    def attach(self, observer: Observer):
        self.observers.append(observer)

    def notify(self):
        for observer in self.observers:
            observer.update(self.state)
                        

class ChangeLRObserver(Observer):
    def update(self, state: dict):
        if state.get("loss_increased"):
            state["lr"] *= 0.1
            print("Reduced learning rate")

class LogObserver(Observer):
    def update(self, state: dict):
        with open("log.txt", "a") as f:
            f.write(f"Epoch {state['epoch']}: loss={state['loss']}\n")
                        

7. Learner Pattern

Standardized high-level abstraction interface (model.fit(data)) popularized by Scikit-Learn.

Encapsulated Training Loop:
  1. Forward pass computation
  2. Loss evaluation
  3. Autograd backward pass
  4. Optimizer parameter update

class LearnerModel:
    def __init__(self, model, loss_fn, optimizer):
        self.model = model
        self.loss_fn = loss_fn
        self.optimizer = optimizer
    
    def fit(self, dataset, epochs=5):
        for epoch in range(epochs):
            for X_batch, y_batch in dataset:
                # 1. Forward Pass
                preds = self.model(X_batch)
                loss = self.loss_fn(preds, y_batch)
                
                # 2. Gradient Update
                self.optimizer.zero_grad()
                loss.backward()
                self.optimizer.step()
                        

8. Batch Processing & Vectorization

⚠️ Avoid Python for loops in numerical code!

Sequential model invocation incurs severe GPU kernel launch overhead and low hardware utilization.

Vectorized Batch Execution: Combine inputs into a single multidimensional Tensor using torch.stack() and invoke the model ONCE.

# ❌ BAD: Slow Python Loop (O(N) GPU calls)
for item in inputs:
    output = model.forward(item)

# ✅ GOOD: Vectorized Batch (1 GPU call)
tensor_batch = torch.stack(inputs)
batch_output = model.forward(tensor_batch)
                        
Advanced Hardware Extensions:
  • CUDA Graphs: Pre-record GPU kernel dispatch sequences.
  • AVX-512 CPU Vectorization: Wide SIMD instructions for matrix math.

9. Decorator Pattern

Dynamically adds responsibilities or behaviors to functions/classes without altering their internal code.

Common standard decorators: @memoize, @lru_cache, @profile, @app.get().

Architectural Warning: Overuse of heavy framework decorators can tightly couple deployment infrastructure to code logic.

from line_profiler import LineProfiler

# Custom Profiling Decorator
def profile(func):
    def inner(*args, **kwargs):
        profiler = LineProfiler()
        profiler.add_function(func)
        profiler.enable_by_count()
        return func(*args, **kwargs)
    return inner

# Application Usage
@profile
def train_epoch_slow():
    # Model compute steps...
    pass
                        

10. Strategy Pattern

Defines a family of algorithms, encapsulates each one, and makes them interchangeable at runtime.

ML Example — Optimizers:

A Trainer class accepts an abstract Optimizer strategy interface. Users can swap between SGDOptimizer and AdamOptimizer seamlessly.


from abc import ABC, abstractmethod

class Optimizer(ABC):
    @abstractmethod
    def step(self, weights, grads) -> Tensor:
        pass

class SGDOptimizer(Optimizer):
    def step(self, weights, grads):
        return weights - 0.01 * grads

class AdamOptimizer(Optimizer):
    def step(self, weights, grads):
        # Adam momentum calculation step
        return updated_weights

class Trainer:
    def __init__(self, optimizer: Optimizer):
        self.optimizer = optimizer # Strategy injected
                        

Advanced Frontiers in ML Engineering

1. Automatic Differentiation

Dynamic construction of Directed Acyclic Computation Graphs (Tape-based Autograd) during forward execution for exact backpropagation.

2. Matrix Mult Optimizations

Cache-oblivious matrix algorithms and tiled block matrix multiplications to maximize CPU L1/L2/L3 cache hit ratios.

3. Distributed Patterns

Parallelism strategies for LLMs & massive models:
Data Parallel (DDP)
Pipeline Parallel
Tensor Parallel

Summary Cheat Sheet

Design Pattern Core Concept Primary ML Use Case
Pipeline Sequential step execution Preprocess ➔ Inference ➔ Postprocess
Workflow (DAG) Directed Acyclic Graph Airflow ETL, TorchServe Ensembles
Function as Data Code/Data duality Model Pickling & ONNX Serialization
Iterator Streaming batch accessor PyTorch DataLoader streaming
Job Queues Async request worker queue Multi-model web inference serving
Callbacks/Observer Event-driven state hooks TensorBoard logging, LR schedulers
Learner Unified model.fit() encapsulation Scikit-learn / Fast.ai interfaces
Batch/Vectorization SIMD tensor stacking Eliminating loops, GPU acceleration
Strategy Interchangeable algorithms Pluggable Optimizers & Losses

Thank You!

Questions & Architectural Discussion

Build Clean, Modular & Scalable AI Systems