Skip to main content
Scikit-Learn · lesson 5 of 12
Scikit-Learn intermediate Lesson 5 of 12

Also part of: Machine Learning

Scikit-Learn Pipelines

Chain preprocessing and models into scikit-learn Pipelines and ColumnTransformers, with a real run showing how preprocessing outside one leaks data.

By SkillByExample · Updated

A pipeline chains preprocessing steps and a model into one estimator, so fit and predict run every step in the right order on the right data. Every example here ran with scikit-learn 1.9.1.

Why Pipelines Exist: Leakage

These labels are random coin flips, so no honest model can beat about 50%. The first evaluation selects the “best” features on all rows before cross-validating.

import numpy as np
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 make_pipeline

rng = np.random.default_rng(0)
X = rng.standard_normal((100, 5000))      # 5000 pure-noise features
y = rng.integers(0, 2, 100)               # random labels

X_sel = SelectKBest(f_classif, k=20).fit_transform(X, y)    # sees every row
leaky = cross_val_score(LogisticRegression(), X_sel, y, cv=5).mean()

pipe = make_pipeline(SelectKBest(f_classif, k=20), LogisticRegression())
honest = cross_val_score(pipe, X, y, cv=5).mean()

print(f"select, then cross-validate:  {leaky:.3f}")
print(f"select inside each fold:      {honest:.3f}")
$ python leakage.py
select, then cross-validate:  0.850
select inside each fold:      0.480

With 5000 noise features, some correlate with the labels by chance, including in the rows later used for testing. Selecting them first bakes the test labels into the features, so the model scores far above chance on data with no signal at all. Inside the pipeline, selection only sees each fold’s training rows, and the score falls back to a coin flip.

A Basic Pipeline

Pipeline takes named steps. Every step but the last must transform; the last one predicts.

from sklearn.datasets import load_breast_cancer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler

X, y = load_breast_cancer(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

pipe = Pipeline([
    ("scaler", StandardScaler()),
    ("clf", LogisticRegression(max_iter=1000)),
])
pipe.fit(X_train, y_train)                 # scaler.fit_transform, then clf.fit
print(f"test accuracy: {pipe.score(X_test, y_test):.4f}")
print("scaler means (first 3):", pipe.named_steps["scaler"].mean_[:3].round(2))
$ python basic_pipeline.py
test accuracy: 0.9737
scaler means (first 3): [14.12 19.19 91.88]

fit learned the scaler’s means from the 455 training rows only; score reused them to transform the test rows before predicting. named_steps gives access to each fitted step.

ColumnTransformer for Mixed Data

Real tables mix numbers, categories and gaps. ColumnTransformer routes each group of columns to its own preprocessing.

import numpy as np
import pandas as pd

rng = np.random.default_rng(42)
n = 1000
df = pd.DataFrame({
    "tenure": rng.integers(0, 20, n).astype(float),
    "salary": rng.uniform(20_000, 200_000, n),
    "contract": rng.choice(["monthly", "yearly"], n),
    "dept": rng.choice(["Sales", "Eng", "HR"], n),
})
# churn is likelier for short-tenure monthly contracts
p = 0.15 + 0.6 * ((df["tenure"] < 4) & (df["contract"] == "monthly"))
df["churned"] = (rng.random(n) < p).astype(int)
df.loc[rng.choice(n, 60, replace=False), "tenure"] = np.nan    # gaps
df.to_csv("churn.csv", index=False)
print(df.head(3))
$ python mixed_data.py
   tenure         salary contract   dept  churned
0     NaN  152607.023927   yearly  Sales        1
1    15.0  179552.519610   yearly     HR        0
2    13.0  185790.295513   yearly  Sales        0

That builds a small churn table with a planted pattern and some missing tenure values. Now a single pipeline takes the raw DataFrame to predictions.

import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.impute import SimpleImputer
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

df = pd.read_csv("churn.csv")
X, y = df.drop(columns="churned"), df["churned"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=0)

pre = ColumnTransformer([
    ("num", make_pipeline(SimpleImputer(strategy="median"), StandardScaler()),
     ["tenure", "salary"]),
    ("cat", OneHotEncoder(handle_unknown="ignore"), ["contract", "dept"]),
])
model = make_pipeline(pre, GradientBoostingClassifier(random_state=0))
model.fit(X_train, y_train)
print(f"test accuracy: {model.score(X_test, y_test):.3f}")
print(pre.get_feature_names_out().tolist())
$ python column_transformer.py
test accuracy: 0.796
['num__tenure', 'num__salary', 'cat__contract_monthly', 'cat__contract_yearly', 'cat__dept_Eng', 'cat__dept_HR', 'cat__dept_Sales']

The numeric branch imputes then scales; the categorical branch one-hot encodes; the outputs are concatenated in the order listed in get_feature_names_out. Because the whole thing is one estimator, model.predict(new_raw_dataframe) repeats every step with the values learned from the training rows.

Grid Search Over a Pipeline

Parameters inside a pipeline are addressed as step__parameter.

from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import GridSearchCV, train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.svm import SVC

X, y = load_breast_cancer(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

pipe = Pipeline([("scaler", StandardScaler()), ("svm", SVC())])
grid = {"svm__C": [0.1, 1, 10], "svm__kernel": ["linear", "rbf"]}

search = GridSearchCV(pipe, grid, cv=5).fit(X_train, y_train)
print("best params:", search.best_params_)
print(f"best CV accuracy: {search.best_score_:.4f}")
print(f"test accuracy:    {search.score(X_test, y_test):.4f}")
$ python grid_pipeline.py
best params: {'svm__C': 0.1, 'svm__kernel': 'linear'}
best CV accuracy: 0.9736
test accuracy:    0.9825

Six combinations times five folds is 30 fits, and the scaler is refit inside every one. After the search, search refits the best pipeline on all training rows, which is what search.score evaluates on the untouched test set.

A Custom Transformer

A transformer learns something in fit and applies it in transform. This one learns percentile limits from the training data and clips outliers to them.

import numpy as np
from sklearn.base import BaseEstimator, TransformerMixin

class PercentileClipper(BaseEstimator, TransformerMixin):
    def __init__(self, lower=5, upper=95):
        self.lower = lower
        self.upper = upper

    def fit(self, X, y=None):
        self.low_ = np.percentile(X, self.lower, axis=0)     # learned state ends in _
        self.high_ = np.percentile(X, self.upper, axis=0)
        return self

    def transform(self, X):
        return np.clip(X, self.low_, self.high_)

train = np.arange(1, 101, dtype=float).reshape(-1, 1)     # 1..100
clipper = PercentileClipper().fit(train)
print("learned limits:", clipper.low_, clipper.high_)
print(clipper.transform(np.array([[-500.0], [50.0], [9999.0]])).ravel())
$ python custom_transformer.py
learned limits: [5.95] [95.05]
[ 5.95 50.   95.05]

The limits came from the training data (the 5th and 95th percentiles of 1-100), and transform applied them to new values it never saw. Because it follows the contract, the class works inside Pipeline, GridSearchCV (clipper__upper is tunable) and clone.

Practice

1. In leakage.py, raise k to 200. Does the leaky score get better or worse?
import numpy as np
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 make_pipeline

rng = np.random.default_rng(0)
X = rng.standard_normal((100, 5000))
y = rng.integers(0, 2, 100)

for k in [20, 200]:
    X_sel = SelectKBest(f_classif, k=k).fit_transform(X, y)
    leaky = cross_val_score(LogisticRegression(), X_sel, y, cv=5).mean()
    honest = cross_val_score(make_pipeline(SelectKBest(f_classif, k=k),
                                           LogisticRegression()), X, y, cv=5).mean()
    print(f"k={k:3d}  leaky {leaky:.3f}  honest {honest:.3f}")
$ python p1_leak_k.py
k= 20  leaky 0.850  honest 0.480
k=200  leaky 0.990  honest 0.400

The leak gets worse, climbing to 0.99 on pure noise: more selected features give the model more chance correlations to fit, and all of them were chosen using the test rows’ labels. The honest score stays near a coin flip for every k.

2. Tune the imputer strategy of the numeric branch in column_transformer.py with a nested parameter name.
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.impute import SimpleImputer
from sklearn.model_selection import GridSearchCV
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder

df = pd.read_csv("churn.csv")
X, y = df.drop(columns="churned"), df["churned"]

pre = ColumnTransformer([
    ("num", Pipeline([("imputer", SimpleImputer())]), ["tenure", "salary"]),
    ("cat", OneHotEncoder(handle_unknown="ignore"), ["contract", "dept"]),
])
pipe = Pipeline([("pre", pre), ("gb", GradientBoostingClassifier(random_state=0))])
grid = {"pre__num__imputer__strategy": ["mean", "median", "most_frequent"]}
search = GridSearchCV(pipe, grid, cv=5).fit(X, y)
for s, m in zip(search.cv_results_["param_pre__num__imputer__strategy"],
                search.cv_results_["mean_test_score"]):
    print(f"{s:14s} {m:.4f}")
$ python p2_nested_param.py
mean           0.8010
median         0.7910
most_frequent  0.8020

Each __ descends one level: pipeline step pre, transformer num, step imputer, parameter strategy. With only 6% of tenure missing, the choice barely moves the score.

3. Log-transform a right-skewed column with FunctionTransformer and check the effect.
import numpy as np
from sklearn.preprocessing import FunctionTransformer

rng = np.random.default_rng(0)
salary = rng.exponential(50_000, size=(1000, 1))      # right-skewed

log = FunctionTransformer(np.log1p, inverse_func=np.expm1)
out = log.fit_transform(salary)
print(f"raw:  mean {salary.mean():>9.0f}  median {np.median(salary):>9.0f}")
print(f"log1p: mean {out.mean():>8.2f}  median {np.median(out):>8.2f}")
print("round trip:", np.allclose(log.inverse_transform(out), salary))
$ python p3_function_transformer.py
raw:  mean     51102  median     37173
log1p: mean    10.28  median    10.52
round trip: True

Skew shows up as a mean well above the median; after log1p the two sit close together. FunctionTransformer learns nothing in fit, so it only suits stateless functions.

4. Put PercentileClipper in a pipeline and grid-search its upper limit.
import numpy as np
from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.datasets import load_diabetes
from sklearn.linear_model import Ridge
from sklearn.model_selection import GridSearchCV
from sklearn.pipeline import Pipeline

class PercentileClipper(BaseEstimator, TransformerMixin):
    def __init__(self, lower=5, upper=95):
        self.lower, self.upper = lower, upper
    def fit(self, X, y=None):
        self.low_ = np.percentile(X, self.lower, axis=0)
        self.high_ = np.percentile(X, self.upper, axis=0)
        return self
    def transform(self, X):
        return np.clip(X, self.low_, self.high_)

X, y = load_diabetes(return_X_y=True)
pipe = Pipeline([("clip", PercentileClipper()), ("ridge", Ridge())])
search = GridSearchCV(pipe, {"clip__upper": [90, 95, 99, 100]}, cv=5).fit(X, y)
print("best:", search.best_params_, f"R2 {search.best_score_:.4f}")
$ python p4_tune_custom.py
best: {'clip__upper': 100} R2 0.4094

Here upper=100, which means no clipping, scored best, so clipping didn’t help this dataset. GridSearchCV clones the pipeline for every fit, which only works because __init__ stores its arguments unchanged. That is the BaseEstimator contract get_params and clone rely on.

Frequently Asked Questions

What is data leakage and how do pipelines prevent it?
Data leakage is when information from evaluation rows influences training, for example by fitting a scaler or selecting features on the full dataset before cross-validation. The score then looks better than the model really is. A Pipeline refits every step inside each training fold, so evaluation rows never shape the preprocessing.
How do I tune parameters inside a Pipeline with GridSearchCV?
Pass the whole Pipeline to GridSearchCV and name parameters as step__parameter, such as svm__C or scaler__with_mean. Nested steps chain further, for example preprocessor__num__imputer__strategy. Tuning the pipeline rather than the bare model keeps preprocessing inside each fold.
What is a ColumnTransformer used for?
A ColumnTransformer applies different preprocessing to different columns, such as imputing and scaling numeric columns while one-hot encoding categorical ones, and concatenates the results. Placed as the first step of a Pipeline, it lets one object take a raw pandas DataFrame all the way to predictions.
How do I write a custom scikit-learn transformer?
Subclass BaseEstimator and TransformerMixin, learn anything you need in fit(X, y=None) and store it in attributes ending with an underscore, then apply it in transform(X). TransformerMixin supplies fit_transform. For a stateless function such as a log transform, FunctionTransformer(np.log1p) is simpler.