Build an ML pipeline in scikit-learn that wires every preprocessing and modeling step into one object: ColumnTransformer for mixed data types, Pipeline chaining for reproducible runs, leak-proof preprocessing, and cross-validation that re-fits inside each fold.
The biggest source of bugs in machine learning is preprocessing, not the model. Pipelines make preprocessing reproducible and leak-proof.
after Andreas Mueller, scikit-learn core developer
Last Updated: July 2026 | Tested on: Python 3.14.6, scikit-learn 1.9.0 | Difficulty: Advanced | Reading Time: 11 minutes
A real machine learning (ML) project is never just “fit a model.” First you fill in missing values, scale the numbers, encode the text categories, maybe pick the best features, and only then train the model. Do all of that as loose, separate steps and you have to remember to repeat the exact same recipe, in the exact same order, on the test data and on tomorrow’s live data. Forget one step, or run them out of order, and the model quietly eats broken features and you never get an error. An sklearn pipeline ties every step into one object. You fit it once, and it applies the whole recipe the same way every single time.
Think of a coffee machine. You pour in beans and water, press one button, and it grinds, tamps, heats, and pours in the right order. You never reach in to grind by hand halfway through. An ML pipeline is that one button for your data: raw rows go in, a prediction comes out, and you cannot get the steps in the wrong order even if you try.
The scariest bug in ML is data leakage, which is when something from the test set sneaks into training. The classic slip is scaling your features using the mean of the whole dataset before you split. The moment you do that, your scaler has already peeked at the test rows, and your test score looks better than the model really is. An ML pipeline blocks this for free. During cross-validation, every fold re-fits the pipeline from scratch, so the scaler only ever learns from that fold’s training rows. The test rows stay hidden until prediction time, exactly like real life, where you genuinely have not seen tomorrow’s data yet.
Table of Contents
Prerequisites
Pipeline Architecture
This diagram shows the sklearn pipeline as one chain. Raw data flows top to bottom through the transformers (the ColumnTransformer runs a numeric branch and a categorical branch side by side, then feature selection), and the estimator at the bottom makes the prediction. Each step’s fit() sees only training data, and its transform() is replayed the same way on test data. That single design buys you three things at once: no data leakage, painless cross-validation, and clean deployment, because you save and load one object instead of juggling a scaler here, an encoder there, and a model somewhere else.
📄 full_pipeline.py: complete pipeline for mixed data types
import numpy as np
import pandas as pd
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.impute import SimpleImputer
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import cross_val_score
# Simulated employee churn data with mixed types
rng = np.random.default_rng(42)
n = 500
df = pd.DataFrame({
"age": rng.integers(22, 34, n).astype(float),
"salary": rng.integers(30000, 120000, n).astype(float),
"department": rng.choice(["Engineering", "Sales", "HR", "Marketing"], n),
"satisfaction": rng.uniform(1, 10, n),
"churned": rng.choice([0, 1], n, p=[0.8, 0.2]),
})
# Add some missing values
df.loc[rng.choice(n, 30), "salary"] = np.nan
df.loc[rng.choice(n, 20), "satisfaction"] = np.nan
X = df.drop("churned", axis=1)
y = df["churned"]
numeric_features = ["age", "salary", "satisfaction"]
categorical_features = ["department"]
# Sub-pipelines for each data type
numeric_pipeline = Pipeline([
("imputer", SimpleImputer(strategy="median")),
("scaler", StandardScaler()),
])
categorical_pipeline = Pipeline([
("imputer", SimpleImputer(strategy="most_frequent")),
("encoder", OneHotEncoder(handle_unknown="ignore")),
])
# Combine with ColumnTransformer
preprocessor = ColumnTransformer([
("num", numeric_pipeline, numeric_features),
("cat", categorical_pipeline, categorical_features),
])
# Full pipeline: preprocess + model
full_pipeline = Pipeline([
("preprocessor", preprocessor),
("classifier", RandomForestClassifier(n_estimators=100, random_state=42)),
])
# Cross-validation: preprocessing happens inside each fold!
scores = cross_val_score(full_pipeline, X, y, cv=5, scoring="f1")
print(f"Pipeline CV F1 scores: {scores}")
print(f"Mean F1: {scores.mean():.3f} (+/- {scores.std():.3f})")
print(f"\nPipeline steps: {[name for name, _ in full_pipeline.steps]}")
print(f"No data leakage: scaler fit only on training fold each time.")
▶ Output
Pipeline CV F1 scores: [0.07142857 0.14285714 0. 0.06451613 0. ] Mean F1: 0.056 (+/- 0.053) Pipeline steps: ['preprocessor', 'classifier'] No data leakage: scaler fit only on training fold each time.
What happened here: One full_pipeline object did all of it: filled missing salary and satisfaction values, scaled the three numeric columns, one-hot encoded the department text, and trained the random forest, all from a single fit. Inside cross_val_score, every fold re-fit that whole chain on its own training rows, so no test row ever touched the imputer or scaler. The F1 score here is tiny (about 0.06) on purpose: the labels are pure random noise with no real pattern, so a model that refuses to leak literally has nothing to learn. Swap in real churn data with genuine signal and the exact same pipeline starts scoring well. The architecture is the lesson, not the number.
Common Mistakes
Picture a teacher who lets students glance at the exam answer key the night before, then acts amazed when everyone scores 95 percent. That is exactly what preparing data before you split does. The number one ML pipeline mistake is preparing the data before you split it. Fitting a scaler, an imputer, or a feature selector on the full dataset and only then splitting feels harmless, but it lets the test rows shape the preprocessing. The result is a test score that looks great in your notebook and falls apart in production.
❌ The shape of the mistake
# BAD: the scaler sees test rows before you ever split # scaler.fit(X) # learns mean/std from ALL data, test included # X_scaled = scaler.transform(X) # X_train, X_test = train_test_split(X_scaled, ...) # GOOD: let the Pipeline handle the split boundary for you # pipeline.fit(X_train, y_train) # scaler learns from X_train only # pipeline.predict(X_test) # scaler reuses the train mean/std on X_test
Talk is cheap, so here is the leak doing real damage. We hand the model 1000 useless features and labels that are pure coin flips, so the honest accuracy is 0.5. Then we pick the “best” 5 features the leaky way (looking at every row) versus the clean way (selection re-fit inside each fold).
📄 leakage_demo.py: the same selector, leaky vs leak-proof
import numpy as np
from sklearn.datasets import make_classification
from sklearn.feature_selection import SelectKBest, f_classif
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score
from sklearn.pipeline import Pipeline
# 30 rows, 1000 junk features, labels are pure random noise
X, _ = make_classification(
n_samples=30, n_features=1000, n_informative=2,
n_redundant=0, shuffle=False, random_state=42,
)
rng = np.random.default_rng(42)
y = rng.integers(0, 2, size=30) # coin-flip labels, no real pattern
# LEAKY: choose the "best" 5 features using EVERY row, then score
selector = SelectKBest(f_classif, k=5)
X_selected = selector.fit_transform(X, y) # peeks at the test folds too
leaky = cross_val_score(LogisticRegression(max_iter=1000), X_selected, y, cv=5)
# CLEAN: put selection INSIDE a pipeline so it re-fits each fold
clean_pipe = Pipeline([
("select", SelectKBest(f_classif, k=5)),
("model", LogisticRegression(max_iter=1000)),
])
clean = cross_val_score(clean_pipe, X, y, cv=5)
print(f"Leaky accuracy (selection before split): {leaky.mean():.3f}")
print(f"Clean accuracy (selection inside pipeline): {clean.mean():.3f}")
print("Honest accuracy on coin-flip labels should sit near 0.5.")
▶ Output
Leaky accuracy (selection before split): 0.867 Clean accuracy (selection inside pipeline): 0.533 Honest accuracy on coin-flip labels should sit near 0.5.
What happened here: The labels are random, so no honest model can beat a coin toss. Yet the leaky version reports 0.867 accuracy. That score is a lie: the selector looked at all 30 rows, including the test folds, and cherry-picked the 5 features that happened to line up with the random labels. The pipeline version re-runs feature selection on each fold’s training rows only, so it lands at 0.533, right where honesty puts it. Same selector, same model, same data. The only difference is whether selection happened inside the pipeline or outside it. That gap, 0.867 versus 0.533, is exactly the inflated score that evaporates the day your model meets real traffic.
Practice Exercises
- Exercise 1: Take the
full_pipelineabove and add aSelectKBeststep between the preprocessor and the classifier. Confirm it still cross-validates in one call. - Exercise 2: Wrap the pipeline in
GridSearchCVand tuneclassifier__n_estimatorsover [50, 100, 200]. Note the double underscore that reaches into the named step. - Exercise 3: Fit the pipeline, save it with
joblib.dump, load it back, and check that predictions on the same rows match exactly.
Conclusion
You now know how to fold every preprocessing and modeling step into a single sklearn Pipeline. You saw how ColumnTransformer runs a numeric branch and a categorical branch side by side, how chaining the steps makes each run reproducible, and, most important, how putting preprocessing inside the pipeline stops data leakage cold. The leaky-versus-clean demo made the stakes concrete: a fake 0.867 accuracy collapsed to an honest 0.533 the moment feature selection re-fit inside each fold. That gap is the exact score that vanishes the day your model meets real traffic.
Next up, you will take these clean, leak-proof ML pipeline habits into anomaly detection with Isolation Forest, where the goal flips from predicting a known label to spotting the rare rows that do not belong. For the complete learning path, from Python basics through production machine learning, head back to the Python + AI/ML tutorial series home.
Frequently Asked Questions
Can I tune hyperparameters inside an sklearn pipeline?
Yes. Pass the whole sklearn pipeline to GridSearchCV or RandomizedSearchCV as the estimator. Reach a nested parameter with double underscore notation: classifier__n_estimators tunes the n_estimators of the step named classifier. The search re-fits the full pipeline for every combination, so preprocessing stays leak-free.
What is data leakage and why does it matter?
Data leakage is when information from the test set influences training. The most common form is fitting a scaler or imputer on the full dataset before splitting. This inflates test scores artificially. In production, the model performs worse than expected because it never had access to future data during training.
Can I save and load a pipeline?
Yes. Use joblib.dump(pipeline, ‘model.joblib’) to save the entire pipeline including preprocessing. Loading with joblib.load reproduces the exact same transformation + prediction. This is the recommended way to deploy models.
How do I add custom transformers to a pipeline?
Create a class that inherits from BaseEstimator and TransformerMixin, implementing fit() and transform() methods. Then include it as a step in the Pipeline like any sklearn transformer.
Interview Questions on ML Pipelines
Interviewers rarely ask for definitions. They ask what happens in situations like these.
Q: What problem does ColumnTransformer solve, and when do you reach for it?
Real datasets mix numeric columns (age, salary) with categorical text columns (department), and each type needs a different recipe: impute-then-scale for numbers, impute-then-one-hot-encode for categories. ColumnTransformer lets you point each sub-pipeline at a specific list of columns and run them side by side, then stitches the outputs back into one feature matrix. You reach for it any time a single StandardScaler or OneHotEncoder cannot sensibly touch every column.
Q: Why does running preprocessing inside a Pipeline during cross-validation prevent data leakage?
When you pass a Pipeline to cross_val_score, every fold re-fits the whole chain from scratch using only that fold’s training rows. The scaler learns its mean and standard deviation, and the imputer learns its fill values, from training data alone, then those fixed values are applied to the held-out rows. If you had scaled the full dataset up front, the scaler would have already seen the validation rows, quietly inflating the score.
Q: What is the difference between Pipeline and make_pipeline?
They build the same object. With Pipeline you supply explicit (name, step) tuples, so you control the step names used in parameter grids, like classifier__n_estimators. make_pipeline skips the names and auto-generates them from the lowercased class name, such as randomforestclassifier. Use Pipeline when you plan to grid-search, because you want stable, readable step names.
Q: How do you tune a nested step’s hyperparameter with GridSearchCV?
Use double underscore notation to reach into a named step: for a step called classifier, the key classifier__n_estimators targets that estimator’s n_estimators. You can tune preprocessing too, for example preprocessor__num__imputer__strategy to switch between “median” and “mean”. GridSearchCV re-fits the entire pipeline for every combination, so preprocessing stays leak-free throughout the search.
Q: Your model scores 0.94 accuracy in the notebook but only 0.62 once it is live. What do you check first?
Suspect data leakage in preprocessing before anything else. Check whether a scaler, imputer, or feature selector was fit on the full dataset before the train/test split rather than inside a pipeline. Rebuild the flow so every transformer sits inside the Pipeline and is fit only within cross-validation folds, then re-measure. A notebook-versus-production gap that large is almost always a leak, not a modeling problem.
Q: At prediction time your pipeline crashes because the live data contains a department value that never appeared in training. How do you fix it?
Set handle_unknown=”ignore” on the OneHotEncoder. With that flag, an unseen category is encoded as all zeros across the known category columns instead of raising an error, so the pipeline keeps predicting. This is essential in production, where new categories (a freshly created department, a new city) show up all the time and must not take the service down.
Q: How do you save a trained pipeline and use it later, and what exactly gets saved?
Call joblib.dump(pipeline, “model.joblib”) to persist the whole object, then joblib.load to bring it back. Crucially, the saved file contains the fitted preprocessing too: the scaler’s learned mean and standard deviation, the imputer’s fill values, and the encoder’s category mapping, all alongside the trained model. That is why deploying one pipeline object is far safer than saving a model and trying to recreate the preprocessing by hand.
Series: Python + AI/ML Cookbook (Part 5: Machine Learning)
Go deeper: when you outgrow this post, the official Python documentation is the next stop.
Related Posts
Previous: ML: Hyperparameter Tuning (Grid, Random, Bayesian)
Next: ML: Anomaly Detection with Isolation Forest
Series Home: Python + AI/ML Tutorial Series

No comment