Module 10 — Reproducible pipelines with scikit-learn
Ten modules of transformations. Applied by hand, in a notebook, they constitute a time bomb: an execution order you forget, a cell run twice, an imputation fitted on the whole dataset. This module turns everything that precedes into a single, reproducible object that is structurally immune to leakage.
The problem with hand-rolled preprocessing
Three flaws, all of which surface at the worst moment.
First, leakage: fit_transform on the whole dataset before splitting is the error flagged in every module of this course. Second, the training-production gap: the same transformations, in the same order, with the same learned parameters, must be replayed on every new data point — reproducing that by hand in another program guarantees divergence. Third, irreproducibility: a notebook whose cells were executed in a non-linear order can no longer be replayed, even by its author.
The Pipeline: one thing to fit, one thing to apply
A Pipeline chains transformations and a final model into an object exposing the same interface as a plain model.
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
pipe = Pipeline([
("imputation", SimpleImputer(strategy="median")),
("scaling", StandardScaler()),
("model", LogisticRegression()),
])
pipe.fit(X_train, y_train) # fits each step on training data only
pipe.predict(X_test) # replays exactly the same transformations
The mechanism is what makes leakage impossible. pipe.fit calls fit_transform on each step with training data only, then pipe.predict calls transform — never fit — on new data. The imputation median and the scaling mean necessarily come from training.
The benefit becomes decisive in cross-validation: preprocessing is refitted inside each fold, on that fold's training data only. This is the structural remedy announced in course 04 and recalled in every module of this one.
from sklearn.model_selection import cross_val_score, GridSearchCV
cross_val_score(pipe, X_train, y_train, cv=5) # no leakage possible
GridSearchCV(pipe, {
"imputation__strategy": ["median", "mean"],
"model__C": [0.1, 1, 10],
}, cv=5) # preprocessing itself becomes a hyperparameter
That last possibility deserves notice: the imputation strategy or the type of scaling is tuned like any hyperparameter, with the step_name__parameter syntax. The choices of modules 2 and 3 stop being hunches and become measured decisions.
ColumnTransformer: different treatments per column type
A real dataset is heterogeneous: numeric columns call for imputation and scaling, categorical ones for encoding, text for vectorization. ColumnTransformer applies a distinct treatment to each group and glues the result back together.
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OneHotEncoder
numeric = ["age", "income", "tenure"]
categorical = ["city", "contract_type"]
pre = ColumnTransformer([
("num", Pipeline([
("imputation", SimpleImputer(strategy="median")),
("scaling", StandardScaler()),
]), numeric),
("cat", Pipeline([
("imputation", SimpleImputer(strategy="constant", fill_value="Unknown")),
("encoding", OneHotEncoder(handle_unknown="ignore")),
]), categorical),
], remainder="drop")
pipe = Pipeline([("pre", pre), ("model", LogisticRegression())])
Two points of vigilance. remainder="drop" explicitly discards unlisted columns; this is the prudent behavior, preventing an identifier from slipping into the model. And handle_unknown="ignore" remains indispensable, an unknown category in production being the rule rather than the exception.
Industrializing a custom transformation
The features built in modules 5 and 7 — cycles, deviations from habit — do not exist in scikit-learn. For them to enter the pipeline, they must be wrapped.
The simplest case, when the transformation learns nothing from the data:
from sklearn.preprocessing import FunctionTransformer
import numpy as np
def cyclical_encoding(X):
return np.column_stack([np.sin(2 * np.pi * X / 12), np.cos(2 * np.pi * X / 12)])
FunctionTransformer(cyclical_encoding)
When the transformation must learn something from training data (per-customer means, a vocabulary), you write a class with fit and transform, inheriting from BaseEstimator and TransformerMixin. This is the only way to guarantee that learned statistics come from training and are replayed identically in production.
Saving and deploying
The complete pipeline serializes into one file, preprocessing and model together:
import joblib
joblib.dump(pipe, "model.joblib")
pipe = joblib.load("model.joblib")
pipe.predict(new_data) # raw, unpreprocessed
This is the culmination of the course: the prediction service receives raw data and calls predict. No transformation to reproduce, hence no possible divergence between training and production.
A Pipeline treats rows independently. The module 7 features requiring a history or a temporal sort — groupby, shift, rolling — do not fit naturally: they are computed upstream, in a versioned and tested data preparation step, or in a feature store (the subject of course 33). Furthermore, library versions matter: an object serialized with one version of scikit-learn does not necessarily reload with another. You must pin versions and keep a dependency file alongside the model.
Summary
- Manual preprocessing exposes you to leakage, the training-production gap and irreproducibility.
- A
Pipelinefits only with training data and replays transformations identically; in cross-validation, preprocessing is refitted within each fold. ColumnTransformerapplies distinct treatments per column type;remainder="drop"andhandle_unknown="ignore"protect production.- Custom transformations integrate via
FunctionTransformeror afit/transformclass; the pipeline serializes whole, and library versions must be pinned.
Final step: the recap and the 40-question exam validating the whole course.