A Pipeline behaves exactly like a model — it has fit and predict — but internally runs a sequence of transformers before the final estimator.
The reason to use one is not tidiness. It is that inside a Pipeline, fit only ever sees training data. Vectorising or scaling before you split lets test-set statistics into your preprocessing and inflates your metrics. The Pipeline makes that mistake structurally impossible.
Contents
Pipelinecommon
Named steps, so GridSearchCV can address them
make_pipeline
Same thing, auto-names the steps
FeatureUnion
Run transformers in parallel and concatenate outputs
Worked examples 3
Runnable, with the arguments that change the outcome
called out. Output shapes are shown as comments where they help.
sklearn.pipeline
from sklearn.pipeline import Pipeline
from sklearn.model_selection import RandomizedSearchCV
pipe = Pipeline([
("tfidf", TfidfVectorizer(min_df=3)),
("clf", LogisticRegression(max_iter=1000, class_weight="balanced")),
])
# Step names become hyperparameter prefixes, separated by double underscore.
# This tunes the vectoriser and the classifier TOGETHER, which is correct -
# the best ngram_range depends on the model that consumes it.
search = RandomizedSearchCV(pipe, {
"tfidf__ngram_range": [(1, 1), (1, 2), (1, 3)],
"tfidf__min_df": [2, 3, 5],
"clf__C": [0.1, 1.0, 10.0],
}, n_iter=20, cv=5, scoring="f1_macro", n_jobs=-1, random_state=42)
search.fit(X_train, y_train)
print(search.best_params_)
Why a Pipeline instead of doing the steps yourself
Not tidiness. It makes a specific and very common leakage bug impossible.
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline
from sklearn.model_selection import cross_val_score
# WRONG - the vectoriser has seen the whole corpus, including the rows
# that will land in the validation fold. Vocabulary and IDF weights are
# computed from data the model is about to be tested on.
vec = TfidfVectorizer()
X_all = vec.fit_transform(texts)
print(cross_val_score(LogisticRegression(max_iter=1000), X_all, y, cv=5).mean())
# 0.9143 <- optimistic
# RIGHT - the vectoriser is refit on each training fold only
pipe = Pipeline([("tfidf", TfidfVectorizer()),
("clf", LogisticRegression(max_iter=1000))])
print(cross_val_score(pipe, texts, y, cv=5).mean())
# 0.8967 <- honest
# The gap is small here and much larger with target encoding, feature
# selection or imputation in the chain. It is always in the same
# direction: the wrong way flatters you.
Reach inside a fitted Pipeline
named_steps gives you the fitted objects, which is how you get coefficients, vocabularies and feature names back out.
pipe.fit(X_train, y_train)
vec = pipe.named_steps["tfidf"] # or pipe["tfidf"]
clf = pipe.named_steps["clf"]
print(len(vec.vocabulary_), "features")
print(clf.coef_.shape)
# Everything up to but not including the final step
X_transformed = pipe[:-1].transform(X_test)
print(X_transformed.shape)
# Feature names survive the chain in recent versions
print(pipe[:-1].get_feature_names_out()[:5])