Two ways to build one
The same object; the difference is whether you choose the step names.
Reaching inside a fitted pipeline
The steps are still there, fitted, and gettable three different ways.
Three steps, refitted inside every fold
Imputation and scaling both learn from data, and both are inside.
Tuning a parameter inside a step
Two underscores between the step name and the parameter, and a search can reach anything.
Saving the whole thing
The preprocessing is part of the model, so it has to travel with it.
Switching a step off
'passthrough' turns a step into a no-op, which makes the step itself something a search can choose.
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
- Confirm the interface. In the first editor, note that a pipeline has
fit and predict — it is an estimator, not a wrapper you unwrap. - Reach inside. In the second editor, try
pipe[:-1].transform(X)[:2] to see exactly what the model receives. - Tune the preprocessing. In the fourth editor, add
"scale__with_std": [True, False] to the grid and see whether it changes the winner. - 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.