Scikit-learn Pipelines
A pipeline binds preprocessing steps and the model into a single estimator: one fit, one predict, and - crucially - each transformer is fitted only on the training rows of each cross-validation fold, which makes data leakage structurally impossible. If you have ever fitted a scaler on the full dataset before splitting, a pipeline is the fix you keep.
The Basic Pipeline
from sklearn.pipeline import Pipeline, make_pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
pipe = Pipeline([
("imputer", SimpleImputer(strategy="median")),
("scaler", StandardScaler()),
("model", LogisticRegression(max_iter=2000)),
])
pipe.fit(Xtr, ytr) # imputer and scaler see only the train rows
pipe.predict(Xte) # all steps run in order, automatically
pipe.predict_proba(Xte)[:, 1] # methods are forwarded to the final estimator
Every step must implement fit and transform; the final step implements fit and predict. make_pipeline builds the same object with auto-generated step names from the class names - faster to type, but the explicit names are what you address in a search grid, so prefer them for anything real.
Addressing Parameters Inside a Pipeline
The step__parameter syntax reaches into any step, which is what makes pipelines tunable as a unit:
from sklearn.model_selection import GridSearchCV
grid = {
"model__C": [0.01, 0.1, 1.0, 10.0],
"model__penalty": ["l1", "l2"],
"imputer__strategy": ["mean", "median"],
}
search = GridSearchCV(pipe, grid, cv=5, n_jobs=-1)
search.fit(Xtr, ytr)
print(search.best_params_)
# every configuration is evaluated with the fit-on-train discipline
# per fold - the search cannot leak by construction
Change a step's parameters after construction the same way, with pipe.set_params(model__C=0.5). The fitted state of each step lives under pipe.named_steps["scaler"], which is how you inspect a trained transformer or reuse its scaling at inference time.
Separate Paths with ColumnTransformer
Real tables mix numeric and categorical columns that need different treatment; ColumnTransformer applies a sub-pipeline per column group and joins the results side by side:
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OneHotEncoder
num = Pipeline([("scaler", StandardScaler())])
cat = Pipeline([("ohe", OneHotEncoder(handle_unknown="ignore",
sparse_output=False))])
pre = ColumnTransformer([
("num", num, ["age", "income", "tenure"]),
("cat", cat, ["region", "plan"]),
])
pipe = Pipeline([("pre", pre), ("model", LogisticRegression(max_iter=2000))])
handle_unknown="ignore" is the production detail: a category first seen in production becomes an all-zeros row instead of an exception. This two-part preprocessor plus a model is the standard shape for tabular data, and it is what the end-to-end project assembles.
Why It Matters
- Leakage-proof: transforms are fit inside each CV fold on the fold's training rows only.
- One object to ship:
joblib.dump(pipe, "model.joblib")serializes preprocessing and model together; deployment can no longer forget the scaler. - One interface: pipelines expose the estimator API, so cross-validation, search, and scoring treat the whole chain like a single model.
Summary
Build every real model as a Pipeline of named steps ending in the estimator: it enforces fit-on-train-only per fold, makes hyperparameter search addressable with step__param, and serializes the entire transform chain with the model. Add a ColumnTransformer for mixed-type tables, and the class of bug where the scaler is fitted on the wrong rows simply stops existing.