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

Scikit-Learn Pipelines

Chain preprocessing and model steps into reproducible, production-safe Pipelines that prevent data leakage.

Real-World Scenario

A data scientist builds a customer churn model. The dataset has numeric features that need scaling, categorical features that need one-hot encoding, and missing values that need imputation. Without a Pipeline, they manually apply each step before train/test split — accidentally fitting the scaler on test data and inflating the evaluation metric by 4%. The model ships, performs worse than expected, and the team spends a week debugging. Pipelines make this class of bug impossible.

Basic Pipeline

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

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)

# Pipeline: named steps executed in order
# Each step except the last must implement fit_transform
# The last step must implement fit and predict
pipeline = Pipeline([
    ("scaler",     StandardScaler()),         # step 1: scale features
    ("classifier", LogisticRegression(max_iter=1000)),  # step 2: train model
])

# fit() calls scaler.fit_transform(X_train) then classifier.fit(X_train_scaled, y_train)
pipeline.fit(X_train, y_train)

# predict() calls scaler.transform(X_test) then classifier.predict(X_test_scaled)
accuracy = pipeline.score(X_test, y_test)
print(f"Pipeline accuracy: {accuracy:.4f}")

# Access individual steps
scaler = pipeline.named_steps["scaler"]
print(f"Feature mean (first 3): {scaler.mean_[:3].round(2)}")

ColumnTransformer — Different Preprocessing per Column

Real-world data has mixed column types. ColumnTransformer applies different transformations to different subsets of columns.

import pandas as pd
import numpy as np
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.impute import SimpleImputer
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report

rng = np.random.default_rng(42)
n = 1000

df = pd.DataFrame({
    "age":          rng.integers(18, 70, n).astype(float),
    "salary":       rng.uniform(20000, 200000, n),
    "tenure_years": rng.integers(0, 20, n).astype(float),
    "department":   rng.choice(["Engineering", "Sales", "Marketing", "HR"], n),
    "employment":   rng.choice(["full-time", "part-time", "contract"], n),
    "churned":      rng.integers(0, 2, n),
})

# Inject missing values
df.loc[rng.choice(n, 50), "age"] = np.nan
df.loc[rng.choice(n, 30), "salary"] = np.nan

X = df.drop("churned", axis=1)
y = df["churned"]

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

# Define column groups
numeric_cols     = ["age", "salary", "tenure_years"]
categorical_cols = ["department", "employment"]

# Preprocessing pipeline per column type
numeric_transformer = Pipeline([
    ("imputer", SimpleImputer(strategy="median")),   # fill missing with median
    ("scaler",  StandardScaler()),
])

categorical_transformer = Pipeline([
    ("imputer", SimpleImputer(strategy="most_frequent")),  # fill missing with mode
    ("encoder", OneHotEncoder(handle_unknown="ignore", sparse_output=False)),
])

# Combine into a ColumnTransformer
preprocessor = ColumnTransformer([
    ("num", numeric_transformer,    numeric_cols),
    ("cat", categorical_transformer, categorical_cols),
])

# Full pipeline: preprocess + model
full_pipeline = Pipeline([
    ("preprocessor", preprocessor),
    ("model",        GradientBoostingClassifier(n_estimators=200, random_state=42)),
])

full_pipeline.fit(X_train, y_train)
print(classification_report(y_test, full_pipeline.predict(X_test)))

Grid Search with Pipeline

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

X, y = load_iris(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()),
])

# Parameter grid — use step_name__param_name syntax
param_grid = {
    "scaler__with_mean": [True, False],    # whether to center data
    "svm__C":            [0.1, 1, 10],    # regularization strength
    "svm__kernel":       ["linear", "rbf"],
    "svm__gamma":        ["scale", "auto"],
}

search = GridSearchCV(
    pipe, param_grid,
    cv=5,                   # 5-fold cross-validation
    scoring="accuracy",
    n_jobs=-1,              # use all CPU cores
    verbose=1,
)
search.fit(X_train, y_train)

print(f"Best params:  {search.best_params_}")
print(f"Best CV acc:  {search.best_score_:.4f}")
print(f"Test acc:     {search.score(X_test, y_test):.4f}")

Custom Transformers

import numpy as np
import pandas as pd
from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler

class LogTransformer(BaseEstimator, TransformerMixin):
    """Apply log1p to skewed numeric columns."""

    def __init__(self, columns=None):
        self.columns = columns

    def fit(self, X, y=None):
        # Nothing to learn — return self
        return self

    def transform(self, X):
        X = X.copy()
        cols = self.columns or X.columns.tolist() if hasattr(X, "columns") else range(X.shape[1])
        for col in cols:
            X[col] = np.log1p(X[col].clip(lower=0))
        return X


# Use in a pipeline just like any built-in transformer
rng = np.random.default_rng(42)
df = pd.DataFrame({
    "salary":  rng.exponential(50000, 500),   # right-skewed
    "age":     rng.integers(18, 65, 500).astype(float),
    "churned": rng.integers(0, 2, 500),
})

X = df.drop("churned", axis=1)
y = df["churned"]

pipe = Pipeline([
    ("log_transform", LogTransformer(columns=["salary"])),
    ("scaler",        StandardScaler()),
])

X_transformed = pipe.fit_transform(X)
print(X_transformed.shape)  # (500, 2)

Frequently Asked Questions

What is data leakage and why do Pipelines prevent it?
Data leakage happens when information from the test set influences preprocessing (e.g., fitting a scaler on all data before splitting). Pipelines enforce the rule automatically: fit() on training data only, transform() applies the learned parameters to test data. Without a Pipeline, it's easy to accidentally leak.
Can I use a Pipeline with GridSearchCV?
Yes — and you should. Pass the Pipeline as the estimator to GridSearchCV. Use double-underscore syntax to reference parameters inside the Pipeline steps: 'scaler__with_mean' or 'classifier__n_estimators'. This is the standard production pattern.