EDA and Feature Engineeringpandas 3.0 · scikit-learn 1.9 · seaborn 0.13 · Python 3.12+
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
72 small wins to finish your pathNext lesson →

Pipelines and pandas output

A Pipeline is a list of named steps that behaves as one estimator: fit runs every step in order on the training rows, and predict reuses what they learned.

Last updated: 12 Oct, 2026 · pandas 3.0 · scikit-learn 1.9

ColumnTransformer put the preparation of a mixed table into one object. A Pipeline puts the preparation and the model into one object, which is what makes the split rule hold by construction: there is only one fit call, and it is given the training rows.

A Pipeline drawn as one estimator. Inside the box are three named steps in order: prepare, a ColumnTransformer whose fit learns the medians, the mode and the labels; select, a SelectFromModel whose fit learns which columns survive; and logreg, a LogisticRegression whose fit learns the coefficients. A green arrow labelled fit on the training rows enters from the left: every step is fitted, top to bottom, on the training rows only. An orange arrow labelled predict enters from the right: no step is fitted again, each one transforms with what it already learned.

Syntax: every step but the last is a transform; the last one can be a model.

python
model = Pipeline([
    ('prepare', pre),                              # a transform
    ('logreg', LogisticRegression(max_iter=1000))  # the estimator at the end
])
model.fit(X_train, y_train)   # fits every step, in order
model.score(X_test, y_test)   # transforms with what they learned, then scores

Turning on pandas output for every step

By default a scikit-learn transform returns a plain array and the column names are lost after the first step. set_config(transform_output="pandas") makes every transform in the process return a DataFrame instead, which removes the pd.DataFrame(scaler.transform(...), columns=...) dance and makes the middle of a pipeline readable.

python
from sklearn import set_config

set_config(transform_output="pandas")   # for every transform in this process
# per object instead: step.set_output(transform="pandas")

Assembling the steps

The preparation is the ColumnTransformer from the previous lesson. The model goes on the end, and the whole thing is one object with a name per step.

python
numbers = Pipeline([('fill', SimpleImputer(strategy='median')),
                    ('scale', StandardScaler())])
words = Pipeline([('fill', SimpleImputer(strategy='most_frequent')),
                  ('code', OneHotEncoder(handle_unknown='ignore', sparse_output=False))])
pre = ColumnTransformer([
    ('num', numbers, make_column_selector(dtype_include='number')),
    ('cat', words, make_column_selector(dtype_include='str')),
])
model = Pipeline([('prepare', pre), ('logreg', LogisticRegression(max_iter=1000))])

Fitting, scoring and looking inside

ExampleOne object from raw columns to a prediction
import pandas as pd
from sklearn import set_config
from sklearn.compose import ColumnTransformer, make_column_selector
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split, cross_val_score
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

set_config(transform_output="pandas")

URL = ("https://raw.githubusercontent.com/krishnaik06/"
       "Feature-Engineering-Live-sessions/master/titanic.csv")
df = pd.read_csv(URL)
X = df[['Pclass', 'Age', 'Fare', 'SibSp', 'Sex', 'Embarked']]
y = df['Survived']
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, random_state=1, stratify=y)

numbers = Pipeline([('fill', SimpleImputer(strategy='median')),
                    ('scale', StandardScaler())])
words = Pipeline([('fill', SimpleImputer(strategy='most_frequent')),
                  ('code', OneHotEncoder(handle_unknown='ignore', sparse_output=False))])
pre = ColumnTransformer([
    ('num', numbers, make_column_selector(dtype_include='number')),
    ('cat', words, make_column_selector(dtype_include='str')),
])
model = Pipeline([('prepare', pre), ('logreg', LogisticRegression(max_iter=1000))])

model.fit(X_train, y_train)
print("test accuracy:", round(model.score(X_test, y_test), 4))
print("steps:", list(model.named_steps))
middle = model[:-1].transform(X_test)
print("what the model sees:", type(middle).__name__, middle.shape)
print(middle.head(2).round(3).to_string())
scores = cross_val_score(model, X, y, cv=5)
print("five fold accuracy:", scores.round(4).tolist())
print("mean:", round(scores.mean(), 4))
raw = X_test.iloc[[0]]
print(raw.to_string())
print("predicted survival:", int(model.predict(raw)[0]))
  • One fit, one score. No separate imputer, scaler or encoder is left lying around to be applied in the wrong order later.
  • model[:-1] is the preparation. Slicing a pipeline gives a pipeline, so you can transform a frame and read exactly what the model was given, with its column names intact.
  • cross_val_score refits the whole object per fold. The five numbers come from five separate fits, each with its own medians, mode and label list, so no fold prepared itself with the others.
  • The last two lines take a raw passenger. Six original columns, one of them text, straight into predict. That is what a served model needs.

Putting the feature selection inside the pipeline

Cross-validation is where leakage is easiest to miss, because the splitting is hidden inside the call. A selection done once, before cross_val_score, has already seen every fold's test rows. The same selection written as a step is refitted inside each fold. The data below is random noise again, so the honest answer is chance.

ExampleThe same selection, once outside and once as a step
import numpy as np
import pandas as pd
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

rng = np.random.default_rng(0)
X = pd.DataFrame(rng.normal(size=(200, 2000)),
                 columns=[f"f{i}" for i in range(2000)])
y = pd.Series(rng.integers(0, 2, size=200))

# the selection happens once, outside, over every row
chosen = SelectKBest(f_classif, k=10).fit(X, y)
X_small = chosen.transform(X)
print("selected outside:", round(cross_val_score(LogisticRegression(), X_small, y, cv=5).mean(), 4))

# the selection is a step, so it is refitted inside each fold
inside = Pipeline([('select', SelectKBest(f_classif, k=10)),
                   ('logreg', LogisticRegression())])
print("selected inside :", round(cross_val_score(inside, X, y, cv=5).mean(), 4))

0.765 against 0.55 on data with no signal in it. The only difference is whether the selection could see the rows it was about to be scored on. Any step that learns anything belongs inside the pipeline, which is the practical reason to build one.

A pipeline against loose steps

Loose transforms and a modelOne Pipeline
Number of fit calls to get rightone per transform, in orderone
Order mistakespossible, and silentfixed by the step list
Cross-validationleaks unless you rebuild it per foldhonest by construction
Grid search over a preparation choicehand written loopsparameters are named, such as prepare__num__fill__strategy
Serving one rowre-run every step by handpredict on the raw row

Where a pipeline is the only sane option

  • Any score you will quote. Cross-validation around a pipeline is the cheapest honest number you can produce.
  • Any search over preparation choices. Median against mean, with scaling against without: these become parameters of one object, searchable in the same grid as the model's.
  • Anything that leaves your notebook. One fitted object can be saved and loaded, and it carries every learned number with it.
Watch out. A step that is fitted before the pipeline is built, and then passed in already fitted, is refitted the moment you call fit. Pass unfitted objects and let the pipeline do the fitting, otherwise you are keeping two versions of the same step in your head.
Try it yourself
  • Print model.get_params() and find the key prepare__num__fill__strategy. Set it to 'mean' with model.set_params(...), refit and score again.
  • Delete the set_config line and print type(middle). It becomes a numpy array and the column names are gone.
  • Change k=10 to k=2000 in both halves of the second example. With every column kept there is nothing to choose, and both lines print 0.45.

Little by little, you're building something great.