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) —
Jr Codex ML Notes
Level: Intermediate–Advanced Prerequisites: Module 8: Reinforcement Learning for ML Practitioners Time to complete: ~25 minutes
Table of Contents
- From Notebook to Reliable System
- Revisiting scikit-learn's Pipeline
- Custom Transformers
- Combining Preprocessing and Model Selection
- Serializing an Entire Pipeline
- Version Control for Pipelines
- 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 callWhy 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
Pipelineobject 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
BaseEstimatorandTransformerMixin) 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.
joblibserializes 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
- Why does bundling preprocessing and the model into one
Pipelineobject eliminate a category of production bugs that manual chaining doesn't? - What two base classes does a custom transformer need to inherit from, and what does each provide?
- 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