Machine Learning

ML In Production And Capstone

Building ML Pipelines

Every chapter in this curriculum has manually chained together preprocessing steps (Module 2), model training (Modules 3-6), and evaluation (various chapters) —

JrCodex·6 min read

Jr Codex ML Notes

Level: Intermediate–Advanced Prerequisites: Module 8: Reinforcement Learning for ML Practitioners Time to complete: ~25 minutes


Table of Contents

  1. From Notebook to Reliable System
  2. Revisiting scikit-learn's Pipeline
  3. Custom Transformers
  4. Combining Preprocessing and Model Selection
  5. Serializing an Entire Pipeline
  6. Version Control for Pipelines
  7. Summary & Next Steps

1. From Notebook to Reliable System

Every chapter in this curriculum has manually chained together preprocessing steps (Module 2), model training (Modules 3-6), and evaluation (various chapters) — reasonable for learning and experimentation, but fragile for anything meant to run reliably and repeatedly. This chapter formalizes the Pipeline concept Module 2, Chapter 1 introduced into a robust, production-ready practice.

Why Manual Chaining Breaks Down in Production
─────────────────────────────────────────
  - Easy to accidentally apply a DIFFERENT transformation to
    new data than was used during training (Module 1, Ch.5's
    leakage concern, now a PRODUCTION correctness bug)
  - Hard to REPRODUCE exactly which steps ran, in what order
  - Difficult to SAVE and RELOAD the entire process as one unit
─────────────────────────────────────────

2. Revisiting scikit-learn's Pipeline

Module 2, Chapter 1 introduced Pipeline and ColumnTransformer — this chapter treats them as the standard, non-negotiable practice for anything beyond a quick experiment.

from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.ensemble import RandomForestClassifier
 
numeric_features = ["age", "income"]
categorical_features = ["city"]
 
preprocessor = ColumnTransformer(transformers=[
    ("num", Pipeline([
        ("imputer", SimpleImputer(strategy="median")),
        ("scaler", StandardScaler()),
    ]), numeric_features),
    ("cat", Pipeline([
        ("imputer", SimpleImputer(strategy="most_frequent")),
        ("encoder", OneHotEncoder(handle_unknown="ignore")),
    ]), categorical_features),
])
 
full_pipeline = Pipeline([
    ("preprocessor", preprocessor),
    ("classifier", RandomForestClassifier(random_state=42)),
])
 
full_pipeline.fit(X_train, y_train)
predictions = full_pipeline.predict(X_test)      # preprocessing AND prediction, in ONE call
Why This Single Object Matters So Much for Production
─────────────────────────────────────────
  A SINGLE `full_pipeline` object encapsulates EVERY step —
  imputation, encoding, scaling, and the trained model itself.
  Deploying this ONE object guarantees new data goes through
  EXACTLY the same transformations the training data did —
  eliminating an entire category of subtle, hard-to-debug
  production bugs.
─────────────────────────────────────────

3. Custom Transformers

Real projects often need transformations beyond scikit-learn's built-ins (Module 2, Chapter 4's domain-specific feature engineering) — custom transformers let these fit into the same Pipeline structure.

from sklearn.base import BaseEstimator, TransformerMixin
import numpy as np
 
class RatioFeature(BaseEstimator, TransformerMixin):
    """A custom transformer creating Module 2, Ch.4's interaction ratio feature."""
    def __init__(self, numerator_idx, denominator_idx):
        self.numerator_idx = numerator_idx
        self.denominator_idx = denominator_idx
 
    def fit(self, X, y=None):
        return self      # nothing to LEARN here — a pure transformation
 
    def transform(self, X):
        ratio = X[:, self.numerator_idx] / (X[:, self.denominator_idx] + 1e-9)
        return np.column_stack([X, ratio])
 
 
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
 
custom_pipeline = Pipeline([
    ("ratio_feature", RatioFeature(numerator_idx=0, denominator_idx=1)),
    ("scaler", StandardScaler()),
    ("classifier", LogisticRegression()),
])
Why Inherit from BaseEstimator and TransformerMixin
─────────────────────────────────────────
  BaseEstimator:       provides standard get_params()/set_params()
                         methods — needed for Module 7's
                         GridSearchCV/RandomizedSearchCV to work
                         with this custom step

  TransformerMixin:       automatically provides fit_transform()
                            (Module 2, Ch.3's key method) by
                            combining your fit() and transform()
─────────────────────────────────────────

Custom transformers make ANY domain-specific feature engineering (Module 2, Chapter 4) reproducible and leak-safe — exactly the same fit-on-training-only, transform-on-everything discipline from Module 1, Chapter 5 applies automatically.


4. Combining Preprocessing and Model Selection

Pipelines integrate directly with Module 7's tuning tools — hyperparameters for both preprocessing and the model can be searched together.

from sklearn.model_selection import GridSearchCV
 
param_grid = {
    "preprocessor__num__imputer__strategy": ["mean", "median"],      # PREPROCESSING hyperparameter
    "classifier__n_estimators": [50, 100, 200],                          # MODEL hyperparameter
    "classifier__max_depth": [5, 10, None],
}
 
grid_search = GridSearchCV(full_pipeline, param_grid, cv=5, n_jobs=-1)
grid_search.fit(X_train, y_train)
 
print(f"Best combination: {grid_search.best_params_}")
Why Tuning Preprocessing Choices Matters Too
─────────────────────────────────────────
  "mean" vs "median" imputation (Module 2, Ch.1) is itself a
  CHOICE that affects final performance — treating the ENTIRE
  pipeline (preprocessing AND model) as one tunable unit is
  more rigorous than tuning the model alone with preprocessing
  choices fixed arbitrarily.
─────────────────────────────────────────

5. Serializing an Entire Pipeline

Once a pipeline is trained and validated, it needs to be saved so it can be loaded later, without retraining, exactly as it was.

import joblib
 
# Save the ENTIRE fitted pipeline — preprocessing steps AND the trained model
joblib.dump(full_pipeline, "model_pipeline.joblib")
 
# Later (potentially in a completely different process/server) — reload it
loaded_pipeline = joblib.load("model_pipeline.joblib")
 
# Making a prediction with the RELOADED pipeline requires NO extra
# preprocessing code at all — it's already baked in
new_prediction = loaded_pipeline.predict(new_raw_data)
Why joblib Instead of Plain Python Pickling
─────────────────────────────────────────
  joblib is OPTIMIZED for objects containing large NumPy
  arrays (exactly what trained scikit-learn models are full
  of) — generally faster and more memory-efficient than
  Python's built-in pickle module for this specific use case.
─────────────────────────────────────────

This is the critical bridge to Chapter 2's deployment discussion — a serialized pipeline file is exactly what gets loaded into a production serving environment, with zero risk of preprocessing logic drifting out of sync between training and serving.


6. Version Control for Pipelines

A practical discipline worth establishing before Chapter 2's deployment: track which pipeline version is running, since models are retrained and improved over time (Chapter 3 covers exactly when/why).

import joblib
from datetime import datetime
 
def save_versioned_pipeline(pipeline, metrics, version_notes):
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    filename = f"model_pipeline_v{timestamp}.joblib"
 
    joblib.dump(pipeline, filename)
 
    # Also save METADATA alongside the model — critical for later debugging
    metadata = {
        "filename": filename,
        "timestamp": timestamp,
        "metrics": metrics,      # e.g. {"f1": 0.87, "auc": 0.91} from Module 4, Ch.6
        "notes": version_notes,
    }
    return metadata
 
metadata = save_versioned_pipeline(
    full_pipeline,
    metrics={"f1": 0.87},
    version_notes="Added ratio_feature transformer, tuned via grid search",
)
print(metadata)
Why This Matters for Production
─────────────────────────────────────────
  Without VERSIONING, "which model is currently live?" and
  "what changed between this version and the last?" become
  genuinely hard, sometimes impossible, questions to answer
  after the fact — a real operational risk once a model has
  been retrained several times (Chapter 3).
─────────────────────────────────────────

7. Summary & Next Steps

Key Takeaways

  • A Pipeline object encapsulates every preprocessing step and the trained model as one unit, guaranteeing new data is transformed exactly as training data was — eliminating a whole category of production bugs.
  • Custom transformers (inheriting BaseEstimator and TransformerMixin) let domain-specific feature engineering fit into the same pipeline structure, with the same leak-safety guarantees.
  • Preprocessing and model hyperparameters can be tuned together via Module 7's search tools, treating the entire pipeline as one tunable unit.
  • joblib serializes an entire fitted pipeline to a single file, which can be reloaded elsewhere with zero risk of preprocessing logic drifting out of sync with the trained model.
  • Versioning saved pipelines alongside their metrics and notes is essential once models are retrained over time, addressed fully in Chapter 3.

Concept Check

  1. Why does bundling preprocessing and the model into one Pipeline object eliminate a category of production bugs that manual chaining doesn't?
  2. What two base classes does a custom transformer need to inherit from, and what does each provide?
  3. Why is versioning a saved pipeline (not just the raw file) an important production practice?

Next Chapter

Chapter 2: Model Deployment Basics


Jr Codex — 1-on-1 Personalized Coaching | Back to Module Index