Pipelines in scikit-learn

One object holding every step, so that what is fitted on the training fold stays fitted on the training fold - by construction rather than by discipline.

Overview

What it is

A Pipeline chains transformers together and ends with an estimator, and the result behaves as a single estimator.

That last sentence is the important one. A pipeline has fit, predict and score, so anywhere a model can go — cross_val_score, GridSearchCV, a pickle file, a comparison loop — a whole pipeline can go instead, with no changes to the surrounding code.

What it does internally is straightforward. fit calls fit_transform on each step in turn, passing the output of one to the next, and fit on the final estimator. predict calls only transform on each step, then predict on the last.

Worth knowing

A pipeline is an estimator: it has fit, predict and score, and goes anywhere one goes.
fit calls fit_transform on each step and fit on the last; predict calls only transform.
That asymmetry is the whole point - the held-out fold is transformed, never fitted on.
make_pipeline names the steps after their classes; Pipeline lets you name them, which makes the search grid readable.
step__parameter, with two underscores, is how a search reaches a setting inside a step.
Pickle the pipeline, not the model - a model deployed without its preprocessing gets raw features and is confidently wrong.

Pipelines: A Practical Guide

A pipeline is not a convenience. It is the mechanism that makes every score in this track honest, and using one turns a rule you have to remember into a property of the code.

Two ways to build one

The same object; the difference is whether you choose the step names.

example_01.pyscikit-learn
Output

Reaching inside a fitted pipeline

The steps are still there, fitted, and gettable three different ways.

example_02.pyscikit-learn
Output

Three steps, refitted inside every fold

Imputation and scaling both learn from data, and both are inside.

example_03.pyscikit-learn
Output

Tuning a parameter inside a step

Two underscores between the step name and the parameter, and a search can reach anything.

example_04.pyscikit-learn
Output

Saving the whole thing

The preprocessing is part of the model, so it has to travel with it.

example_05.pyscikit-learn
Output

Switching a step off

'passthrough' turns a step into a no-op, which makes the step itself something a search can choose.

example_06.pyscikit-learn
Output

The asymmetry is the point

Look again at those two sentences: fit fits the transformers; predict only transforms.

That asymmetry is what makes a pipeline more than plumbing. Inside a cross-validation loop, fit is called on the training folds — so the scaler computes its mean there, the imputer its median, the encoder its category list. Then predict runs on the held-out fold, which is transformed using those numbers and never contributes to them.

Doing the same thing by hand means remembering, every time, to fit each transformer on the training part only, and to apply it to the test part without refitting. That is four or five opportunities per experiment to make a mistake that inflates the score and produces no error.

The cross-validation module measured what that mistake costs: feature selection applied outside the folds reported 0.825 accuracy on data that was pure noise. A pipeline makes that particular mistake impossible to write.

Two constructors

make_pipeline(StandardScaler(), LogisticRegression()) names the steps automatically, lowercasing the class names. It is shorter and right for quick work.

Pipeline([("scale", StandardScaler()), ("clf", LogisticRegression())]) lets you name them. The names matter once you are tuning, because they appear in every parameter key: clf__C is far more readable than logisticregression__C, and it does not change if you swap the classifier for a different one.

The rule of thumb: make_pipeline while exploring, Pipeline with short names once there is a search grid.

Every step except the last must be a transformer — it must have transform. The last can be anything, including another transformer if the pipeline's purpose is preprocessing rather than prediction.

Getting at the fitted parts

A fitted pipeline still contains its fitted steps, and there are three ways in.

pipe.named_steps["scale"] uses the name. pipe["scale"] is the shorter form of the same thing. pipe[0] and pipe[-1] index by position, and pipe[-1] for the final estimator is the one you will write most.

This is how you read coef_ off the model at the end, or check what the scaler learned, or pull get_feature_names_out() from an encoder in the middle. The pipeline hides nothing; it only changes how you reach it.

pipe[:-1] slices, returning a new pipeline of everything except the last step — occasionally useful for inspecting what the model actually receives.

Tuning through a pipeline

Hyperparameters inside a pipeline are addressed as stepname__parameter, with two underscores.

{"clf__C": [0.01, 0.1, 1]} tunes C on the step named clf. Nesting goes deeper with more underscores — a parameter inside a step inside a column transformer is preprocess__num__imputer__strategy — which looks unwieldy and is entirely mechanical.

The consequence worth appreciating: a search can tune the preprocessing as well as the model. Whether to scale with the mean, which imputation strategy to use, how many PCA components to keep, and how the model is configured, all in one grid, all evaluated with the same honest cross-validation.

"passthrough" extends this to whole steps. Setting a step to that string makes it a no-op, so {"pca": ["passthrough", PCA(5), PCA(10)]} treats the existence of the PCA step as itself a hyperparameter. The same trick lets a search choose between two entirely different models in one grid.

Deployment is where it pays off

A model without its preprocessing is not a model.

Saving only the estimator and applying scaling by hand at prediction time means reimplementing the transformation somewhere else, with the numbers copied across, and keeping the two in step forever. Every one of those is a place for the training and serving paths to diverge — and when they do, nothing raises. The model receives features on a different scale from the ones it was fitted on and produces confident nonsense.

Pickling the pipeline saves the transformers and their learned parameters alongside the model, so the object that comes back does the whole job from raw features to prediction. The editor above round-trips one and confirms the predictions are identical.

Two cautions on pickles that apply to any scikit-learn object. They are not a secure format — unpickling untrusted data executes code — and they are not portable across versions, so a pipeline pickled with one scikit-learn and loaded with another may fail or, worse, behave differently. Recording the version alongside the file is the minimum, and skops is the alternative for anything that has to be shared.

When not to reach for one

Pipelines handle a sequence of steps applied to all the columns. Two situations need something else.

Different steps for different columns — scaling the numeric ones and encoding the categorical ones — is what ColumnTransformer is for, and it slots into a pipeline as a single step.

Anything that changes the number of rows. Dropping outliers, resampling for imbalance, aggregating. Pipelines transform features and pass the target through untouched, so a step that removed rows would leave X and y out of step. The imbalanced-learn package provides its own pipeline for exactly this case, and row-level filtering otherwise belongs before the pipeline, applied to the training data only.

Caching, when the fitting gets slow

A grid search over a pipeline refits every step for every combination, and most of that work is repeated. Tuning only the classifier means the scaler and the imputer are refitted identically for each candidate, which is wasted time on anything expensive.

Pipeline(steps, memory="./cache") caches the fitted transformers, keyed on the step and the data they were given. The first combination pays for the preprocessing; the rest reuse it. On a pipeline whose expensive part is the preprocessing — text vectorisation, a costly feature construction — this can be the difference between a search that finishes and one that does not.

Two caveats. The cache is on disk and is not cleaned up automatically, so a long-running project accumulates directories. And it only helps when the transformer's inputs are genuinely identical, so it does nothing for a grid that tunes an early step.

Building your own step

A pipeline accepts anything with fit and transform, which includes classes you write.

For a stateless transformation — a log, a ratio between two columns, a date part — FunctionTransformer(func) wraps a plain function and needs nothing else. It is the shortest route into a pipeline and covers a surprising amount.

When the step needs to *learn* something, subclassing BaseEstimator and TransformerMixin gives you the rest of the interface for free: fit stores what it learned on self with a trailing underscore, transform applies it, and TransformerMixin supplies fit_transform. get_params and set_params arrive from BaseEstimator, which is what makes the custom step tunable by a search like any other.

The rule that keeps a custom step honest is the same as for the built-in ones: everything learned from data must be learned in fit, and transform must use only what fit stored. A transformer that computes a statistic inside transform recomputes it on the test fold, which is the leak the pipeline was supposed to prevent.

Can a pipeline end in a transformer? Yes. It then has transform rather than predict, which is useful when the pipeline exists to prepare features for something else.

How do I see what the model actually receives? pipe[:-1].transform(X) runs every step except the last and hands back the array the estimator was given.

Does a pipeline slow anything down? No. It calls the same methods in the same order; the only overhead is a few attribute lookups.

Can I add a step to a fitted pipeline? You can modify pipe.steps, and the result is unfitted from that point on. Building a new pipeline is clearer.

Reading a pipeline someone else wrote

An unfamiliar pipeline is quicker to understand than an unfamiliar script, because the steps are the structure and they are listed in order.

Three questions cover it. What are the steps? [name for name, _ in pipe.steps] prints the sequence, and the order is the order data flows through. What does each one learn? Every step's fitted attributes end in an underscore, so pipe["scale"].mean_ and pipe["encode"].categories_ say what it took from the data. What comes out? pipe[:-1].transform(X).shape tells you how many features the model is actually seeing, which is often surprising after one-hot encoding.

The display helps too. Printing a pipeline in a notebook renders a diagram of the steps, and sklearn.set_config(display="diagram") makes that the default. In a plain terminal, print(pipe) gives the nested repr, which is dense but complete — every step and every non-default hyperparameter.

What to look for when reviewing one: whether anything that learns from data sits *outside* it. A script that scales, imputes or selects features before building the pipeline has moved that step out of the folds, and the pipeline's presence gives a false impression of safety. The pipeline only protects what is inside it.

The order of the steps, again

Steps run in the order given, and some orders are wrong.

Imputation before scaling, because a scaler cannot compute a mean across missing values. Encoding before scaling, because a scaler cannot subtract a mean from a string. Both before feature selection, because the selector needs numeric, complete columns to score. And selection before the model, so the model sees what was chosen.

The sequence that covers most tabular problems is: impute, encode, transform any skew, scale, select, fit. Not every problem needs all six, and the relative order of the ones you use should follow that list.

Getting it wrong usually raises, which is fortunate. The exception is putting selection before scaling when the selector's criterion is scale-sensitive — that runs, and quietly selects the columns with the largest units.

Things to try

  1. Confirm the interface. In the first editor, note that a pipeline has fit and predict — it is an estimator, not a wrapper you unwrap.
  2. Reach inside. In the second editor, try pipe[:-1].transform(X)[:2] to see exactly what the model receives.
  3. Tune the preprocessing. In the fourth editor, add "scale__with_std": [True, False] to the grid and see whether it changes the winner.
  4. Break it deliberately. Fit the scaler outside a pipeline on all of X, then cross-validate the model alone, and compare against the pipeline version.

Where this leaves you

One object, fitted once, holding every step that learns anything. It makes cross-validation honest, makes the preprocessing tunable, and makes deployment a single file. There is no situation in this track where doing the steps by hand is preferable, and forming the habit early costs nothing.

Check yourself

0 of 4

Answer without scrolling back up.

  1. What does a pipeline's predict() do to the transformers?

  2. How do you tune C on a step named clf?

  3. What does setting a step to "passthrough" do?

  4. Why pickle the pipeline rather than the model?

Cheat sheet

Pipelines in scikit-learn

One object holding every step, so that what is fitted on the training fold stays fitted on the training fold - by construction rather than by discipline.

SCIKIT-LEARN · vizlearn.in/sklearn/pipelines.html

About the author

Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.