,

sklearn Pipeline and ColumnTransformer: preprocessing that cannot leak

LEARN · FEATURE ENGINEERING & THE SCIKIT-LEARN TOOLKIT

The leakage problem is usually hiding in preprocessing

This tutorial targets scikit-learn 1.9.0, the current stable release as of August 2026. The current documentation describes Pipeline as the mechanism for assembling preprocessing and estimators so they can be cross-validated together, while ColumnTransformer applies different transformations to different column subsets and concatenates their outputs.

That sounds like convenient code organization. It is much more important than that.

A correctly constructed pipeline changes when preprocessing is fitted.

That difference is what prevents one of the most common forms of machine-learning evaluation leakage.

Consider StandardScaler. It does not merely apply a fixed mathematical function. During fit, it calculates statistics such as the mean and variance of each feature and stores them for later transformations. Those statistics therefore contain information about every row used during fitting.

SimpleImputer behaves similarly. A median imputer has to calculate medians. A most-frequent imputer has to determine modes. Those learned values are stored in statistics_.

OneHotEncoder learns which categories exist and, when configured with min_frequency, which categories count as infrequent.

These are all fitted models in the broad sense.

If you fit them using rows that later become validation rows, your validation data has already influenced training.

The estimator may never have seen the validation labels. Leakage has still occurred.

Why “I split before testing” is not enough

The obvious leakage pattern looks like this:

from sklearn.model_selection import cross_val_score
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression

X_scaled = StandardScaler().fit_transform(X)

scores = cross_val_score(
    LogisticRegression(),
    X_scaled,
    y,
    cv=5,
)

The problem happens before cross_val_score gets a chance to create its folds.

StandardScaler().fit_transform(X) sees all rows.

Suppose five-fold cross-validation later chooses 80% of those rows for training and 20% for validation. The validation values have already influenced the mean and variance used to scale the training rows.

The official scikit-learn guidance explicitly warns against preprocessing an entire dataset before splitting and recommends pipelines as the standard defense against this class of leakage.

There is a subtler version that catches experienced developers too:

from sklearn.model_selection import GridSearchCV, train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression

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

scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)

search = GridSearchCV(
    LogisticRegression(),
    {"C": [0.1, 1.0, 10.0]},
    cv=5,
)

search.fit(X_train_scaled, y_train)

You correctly protected the final test set.

But you still leaked information inside GridSearchCV.

GridSearchCV divides X_train_scaled into its own training and validation folds. Because the scaler was fitted before that division, every internal validation fold influenced the scaling statistics supplied to its corresponding training fold.

Your holdout test estimate may remain clean, but the hyperparameter selection process is contaminated.

That is the key rule:

Anything that learns from data must be fitted inside the cross-validation fold that uses it.

A Pipeline makes that rule structural instead of relying on programmer discipline.

The architecture we want

For mixed tabular data, the useful pattern is:

  • Numerical columns:

    • imputation

    • scaling

  • Categorical columns:

    • imputation

    • one-hot encoding

  • Concatenate both transformed feature spaces

  • Fit the model

  • Put GridSearchCV around the entire object

Conceptually:

raw DataFrame
    |
    +-- numeric columns --> SimpleImputer --> StandardScaler --+
    |                                                          |
    +-- categorical columns --> SimpleImputer --> OneHotEncoder+--> LogisticRegression
                                                               |
                                                        one Pipeline
                                                               |
                                                        GridSearchCV

ColumnTransformer exists for exactly this heterogeneous-data structure: each named transformer handles its selected columns, and their transformed outputs are concatenated into one feature matrix.

Install a current environment

The official 1.9 release supports current Python versions and can be installed normally with pip.

For a reproducible environment, pin the major tutorial dependency:

python -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install "scikit-learn==1.9.0" pandas numpy joblib

The examples below use synthetic retail-session data rather than a downloaded dataset. That gives us mixed numerical and categorical columns, missing values, and a binary outcome without depending on an external service.

Build a complete leakage-resistant workflow

Here is the full runnable example first. We will dissect it afterward.

import numpy as np
import pandas as pd

from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report, roc_auc_score
from sklearn.model_selection import GridSearchCV, StratifiedKFold, train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler


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

X = pd.DataFrame(
    {
        "basket_value": rng.lognormal(mean=3.2, sigma=0.7, size=n),
        "items_in_cart": rng.poisson(lam=3.5, size=n),
        "session_minutes": rng.gamma(shape=2.0, scale=4.0, size=n),
        "days_since_last_order": rng.exponential(scale=35.0, size=n),
        "channel": rng.choice(
            ["organic", "paid_search", "email", "social"],
            size=n,
            p=[0.35, 0.30, 0.20, 0.15],
        ),
        "device": rng.choice(
            ["mobile", "desktop", "tablet"],
            size=n,
            p=[0.60, 0.33, 0.07],
        ),
        "region": rng.choice(
            ["north", "south", "east", "west"],
            size=n,
        ),
    }
)

for column in ["basket_value", "session_minutes", "channel"]:
    missing = rng.random(n) < 0.03
    X.loc[missing, column] = np.nan

basket_for_target = X["basket_value"].fillna(X["basket_value"].median())
session_for_target = X["session_minutes"].fillna(
    X["session_minutes"].median()
)
channel_for_target = X["channel"].fillna("unknown")

logit = (
    -2.8
    + 0.018 * basket_for_target
    + 0.20 * X["items_in_cart"]
    + 0.035 * session_for_target
    - 0.010 * X["days_since_last_order"]
    + 0.50 * (channel_for_target == "email").astype(float)
    + 0.30 * (X["device"] == "desktop").astype(float)
)

probability = 1.0 / (1.0 + np.exp(-logit))
y = rng.binomial(1, probability, size=n)

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

numeric_features = [
    "basket_value",
    "items_in_cart",
    "session_minutes",
    "days_since_last_order",
]

categorical_features = [
    "channel",
    "device",
    "region",
]

numeric_pipeline = Pipeline(
    steps=[
        ("imputer", SimpleImputer(strategy="median")),
        ("scaler", StandardScaler()),
    ]
)

categorical_pipeline = Pipeline(
    steps=[
        ("imputer", SimpleImputer(strategy="most_frequent")),
        (
            "encoder",
            OneHotEncoder(
                handle_unknown="ignore",
                min_frequency=10,
            ),
        ),
    ]
)

preprocess = ColumnTransformer(
    transformers=[
        ("num", numeric_pipeline, numeric_features),
        ("cat", categorical_pipeline, categorical_features),
    ],
    remainder="drop",
    verbose_feature_names_out=False,
)

pipeline = Pipeline(
    steps=[
        ("preprocess", preprocess),
        ("model", LogisticRegression(max_iter=1000)),
    ]
)

cv = StratifiedKFold(
    n_splits=5,
    shuffle=True,
    random_state=42,
)

param_grid = {
    "preprocess__num__imputer__strategy": [
        "median",
        "mean",
    ],
    "preprocess__cat__encoder__min_frequency": [
        None,
        10,
    ],
    "model__C": [
        0.1,
        1.0,
        10.0,
    ],
    "model__class_weight": [
        None,
        "balanced",
    ],
}

search = GridSearchCV(
    estimator=pipeline,
    param_grid=param_grid,
    scoring="roc_auc",
    cv=cv,
    n_jobs=-1,
    refit=True,
    return_train_score=False,
)

search.fit(X_train, y_train)

best_pipeline = search.best_estimator_

test_probability = best_pipeline.predict_proba(X_test)[:, 1]
test_prediction = best_pipeline.predict(X_test)

print(f"Best CV ROC AUC: {search.best_score_:.3f}")
print(
    f"Holdout ROC AUC: "
    f"{roc_auc_score(y_test, test_probability):.3f}"
)

print("Best parameters:")
for key, value in search.best_params_.items():
    print(f"  {key}: {value}")

print()
print("Classification report:")
print(
    classification_report(
        y_test,
        test_prediction,
        digits=3,
    )
)

There are several things worth noticing here.

First, train_test_split receives the raw DataFrame. Nothing has been imputed, standardized, encoded, selected, projected, or otherwise fitted before the split.

Second, X_test stays raw until the final chosen pipeline evaluates it.

Third, GridSearchCV receives the complete Pipeline, not a matrix produced by preprocessing somewhere else.

Those three choices are the heart of the design.

What happens inside one cross-validation fold

Understanding one fold removes most of the mystery.

Suppose GridSearchCV is evaluating:

  • SimpleImputer(strategy="median")

  • OneHotEncoder(min_frequency=10)

  • LogisticRegression(C=1.0)

and the cross-validator produces:

fold training rows:   0, 1, 3, 4, 7, ...
fold validation rows: 2, 5, 6, 11, ...

At a conceptual level, scikit-learn fits a fresh version of the pipeline on the fold’s training rows.

The numerical branch therefore learns:

  • numeric medians from the fold training rows

  • numeric means from the fold training rows

  • numeric variances from the fold training rows

The categorical branch learns:

  • most-frequent replacement values from the fold training rows

  • observed categories from the fold training rows

  • category frequencies from the fold training rows

Then those fitted transformers call transform on the validation rows.

The validation rows contribute nothing to those learned preprocessing values.

That happens again with a fresh fitted pipeline for every fold and every parameter candidate.

This behavior follows directly from Pipeline being treated as the estimator supplied to cross-validation. GridSearchCV searches parameters of that estimator using cross-validation, and nested estimator parameters are addressable with the <component>__<parameter> convention.

That is why the construction is so powerful.

You are no longer hoping that every developer remembers to fit a scaler at exactly the right time.

The object graph enforces the ordering.

Why ColumnTransformer belongs inside the outer Pipeline

There are two different kinds of composition in the example.

The first is sequential:

numeric data
    -> imputer
    -> scaler

That is a Pipeline.

The second is parallel:

numeric columns     -> numeric pipeline
categorical columns -> categorical pipeline

That is a ColumnTransformer.

Finally, another sequential pipeline connects the combined preprocessing stage to the classifier:

ColumnTransformer
    -> LogisticRegression

The resulting estimator hierarchy can be pictured as:

Pipeline
|
+-- preprocess: ColumnTransformer
|   |
|   +-- num: Pipeline
|   |   |
|   |   +-- imputer: SimpleImputer
|   |   +-- scaler: StandardScaler
|   |
|   +-- cat: Pipeline
|       |
|       +-- imputer: SimpleImputer
|       +-- encoder: OneHotEncoder
|
+-- model: LogisticRegression

The hierarchy matters because it also defines your parameter names.

Reading double-underscore parameter paths

This grid:

param_grid = {
    "preprocess__num__imputer__strategy": [
        "median",
        "mean",
    ],
    "preprocess__cat__encoder__min_frequency": [
        None,
        10,
    ],
    "model__C": [
        0.1,
        1.0,
        10.0,
    ],
}

is not special syntax understood only by GridSearchCV.

It follows the nested-estimator parameter convention used throughout scikit-learn. The current Pipeline and GridSearchCV APIs document parameters of nested components using names separated by __.

Read:

preprocess__num__imputer__strategy

from left to right:

outer Pipeline
    preprocess
        ColumnTransformer branch "num"
            Pipeline step "imputer"
                parameter "strategy"

And:

model__C

means:

outer Pipeline
    model
        LogisticRegression parameter C

This means model selection can tune preprocessing and modeling decisions together.

That is an underappreciated benefit of pipelines.

Preprocessing is itself part of the model-selection problem.

Should numerical missing values use a mean or median?

Should rare categorical levels be grouped?

Should regularization be weak or strong?

The correct answer may depend on interactions among all three choices. Searching them together allows cross-validation to evaluate the whole learning procedure rather than treating preprocessing as an unquestionable fixed prelude.

Grid search is fitting more pipelines than you may think

Our grid contains:

  • 2 imputation strategies

  • 2 categorical-frequency settings

  • 3 values of C

  • 2 class-weight settings

That gives:

2 × 2 × 3 × 2 = 24 candidates

With five-fold cross-validation:

24 × 5 = 120 fold fits

Then refit=True fits the winning configuration again using all of X_train.

The current GridSearchCV API defines refit=True as refitting the selected estimator on the complete data passed to fit, making the resulting estimator available through best_estimator_.

That final refit is exactly what we want.

During model selection:

each validation fold stays invisible to its preprocessing fit

After model selection:

best configuration learns from all available training data

Then, and only then, do we evaluate the untouched holdout.

Why the holdout test set still matters

Cross-validation answers:

Which candidate learning procedure seems best on the training data?

The untouched holdout answers a different question:

How well does the selected procedure generalize to data that played no role in choosing it?

Those are not interchangeable jobs.

If you repeatedly inspect your test score and alter the model until the score looks good, the test set becomes part of model development. Eventually it is no longer an honest final estimate.

A sensible workflow is:

raw dataset
    |
    +-- training set
    |      |
    |      +-- GridSearchCV
    |             |
    |             +-- fold-local preprocessing
    |             +-- fold-local model fitting
    |             +-- model selection
    |
    +-- untouched test set
           |
           +-- one final evaluation

For serious benchmarking, you can go further with nested cross-validation, but the principle remains the same: information used to select a procedure must not masquerade as independent evaluation data. Scikit-learn’s own nested-CV example explicitly discusses the optimistic bias that appears when the same observations influence model selection and evaluation.

Every learned preprocessing step belongs inside

Scaling gets most of the attention because the mistake is easy to visualize.

But the rule is broader.

Suppose you do this before cross-validation:

imputer = SimpleImputer(strategy="median")
X_imputed = imputer.fit_transform(X)

The medians contain validation-fold information.

Suppose you do this:

encoder = OneHotEncoder(handle_unknown="ignore")
X_encoded = encoder.fit_transform(X)

The vocabulary of categories contains validation-fold information.

Suppose you calculate quantile thresholds, PCA components, feature-selection scores, text vocabularies, learned embeddings, or frequency mappings before creating validation folds.

Same fundamental problem.

A useful diagnostic question is:

Does this operation have a fit, fit_transform, or otherwise calculate something from the observed sample?

If yes, assume that it belongs inside cross-validation until you have a rigorous reason otherwise.

Why handle_unknown="ignore" matters in real inference

There is another benefit to keeping encoding attached to the model.

Imagine your training data contains:

organic
paid_search
email
social

Tomorrow a production request contains:

affiliate

With OneHotEncoder(handle_unknown="ignore"), an unseen category is represented by zeros for that input feature’s learned one-hot columns rather than raising an error. That behavior is part of the current encoder API.

It does not magically make the new category meaningful.

It does make the inference contract predictable.

And because the encoder travels inside the fitted pipeline, callers can submit raw tabular rows rather than manually reproducing training-time category mappings.

Inspect what the winning pipeline actually learned

A fitted ColumnTransformer can expose the transformed feature names.

After running the main example:

feature_names = (
    best_pipeline
    .named_steps["preprocess"]
    .get_feature_names_out()
)

coefficients = (
    best_pipeline
    .named_steps["model"]
    .coef_[0]
)

importance = (
    pd.DataFrame(
        {
            "feature": feature_names,
            "coefficient": coefficients,
        }
    )
    .assign(
        abs_coefficient=lambda frame: (
            frame["coefficient"].abs()
        )
    )
    .sort_values(
        "abs_coefficient",
        ascending=False,
    )
)

print(
    importance[
        ["feature", "coefficient"]
    ]
    .head(12)
    .to_string(index=False)
)

Because we set:

verbose_feature_names_out=False

the resulting names are easier to read.

You will see numerical columns alongside generated one-hot features such as:

basket_value
items_in_cart
channel_email
device_desktop
region_west

This is a practical reason to preserve preprocessing as part of the fitted estimator: there is one authoritative object describing how raw input becomes model input.

You can inspect the fitted scaler too

The final refitted scaler is reachable through the estimator hierarchy:

fitted_scaler = (
    best_pipeline
    .named_steps["preprocess"]
    .named_transformers_["num"]
    .named_steps["scaler"]
)

print("Means:")
print(fitted_scaler.mean_)

print("Variances:")
print(fitted_scaler.var_)

Those values were learned during the final refit=True fit on all of X_train.

They were not learned from X_test.

During grid search, each fold used its own separately fitted scaler with statistics calculated from that fold’s training portion.

This distinction is precisely the behavior we wanted.

Production inference should receive raw rows

Once the pipeline is fitted, prediction should start with data in the same raw schema used during training.

For example:

new_sessions = pd.DataFrame(
    [
        {
            "basket_value": 84.50,
            "items_in_cart": 5,
            "session_minutes": 11.2,
            "days_since_last_order": 8.0,
            "channel": "email",
            "device": "mobile",
            "region": "north",
        },
        {
            "basket_value": np.nan,
            "items_in_cart": 2,
            "session_minutes": 3.5,
            "days_since_last_order": 71.0,
            "channel": "affiliate",
            "device": "desktop",
            "region": "west",
        },
    ]
)

probabilities = best_pipeline.predict_proba(
    new_sessions
)[:, 1]

print(probabilities)

Notice what the calling code does not contain:

  • no median lookup

  • no manual missing-value filling

  • no scaler

  • no category-to-column dictionary

  • no manually enforced one-hot column order

The pipeline owns those transformations.

This reduces training-serving skew: the risk that your training notebook and production service implement slightly different preprocessing.

Save the entire fitted object, not just the classifier

For the same reason, persistence should preserve the whole fitted pipeline:

import joblib

joblib.dump(
    best_pipeline,
    "retail_conversion_pipeline.joblib",
)

And inference can reload it as one object:

import joblib
import pandas as pd

pipeline = joblib.load(
    "retail_conversion_pipeline.joblib"
)

request = pd.DataFrame(
    [
        {
            "basket_value": 59.90,
            "items_in_cart": 4,
            "session_minutes": 8.5,
            "days_since_last_order": 14.0,
            "channel": "organic",
            "device": "mobile",
            "region": "south",
        }
    ]
)

probability = pipeline.predict_proba(
    request
)[:, 1]

print(probability)

The deployable unit is not:

model

It is:

raw-input transformation + model

That distinction becomes increasingly important as preprocessing grows more complex.

The most dangerous version: feature engineering outside the pipeline

Putting a scaler in a pipeline does not automatically sanitize everything you did earlier.

For example, imagine calculating a customer’s “average lifetime spend” using the entire dataset before cross-validation.

Even if your later scaler and classifier live in a perfect pipeline, that engineered feature may contain information from transactions occurring after the row being predicted.

The pipeline cannot undo that.

Similarly:

  • aggregating an entity using future observations can leak time

  • computing target averages globally can leak labels

  • selecting features using the full target vector can leak validation labels

  • constructing validation rows using information from related training entities can leak groups

Pipeline kills preprocessing leakage caused by fitting its contained transformations outside the folds.

It does not guarantee that your raw feature table itself is causally valid.

That qualification matters whenever someone says pipelines make leakage “impossible.”

They make a large and important class of leakage impossible by construction, provided all relevant learned preprocessing is actually inside the pipeline and the splitting strategy itself is appropriate.

Pipelines cannot rescue the wrong cross-validation splitter

Suppose you predict tomorrow’s server load from historical telemetry.

A perfectly constructed:

ColumnTransformer -> model

pipeline still cannot help if your CV splitter trains on records from December and validates on records from October.

For ordered observations, scikit-learn provides TimeSeriesSplit specifically because ordinary cross-validation can otherwise train on future data and evaluate on the past.

The same principle applies to grouped observations.

If multiple rows belong to the same customer, machine, store, household, or document, ordinary random folds may put nearly identical or strongly related observations on both sides of a split.

So there are two independent questions:

  1. Are my folds themselves valid?

  2. Within each valid fold, are all learned transformations fitted using training rows only?

A suitable splitter addresses the first.

A pipeline addresses the second.

You often need both.

The cherry on the cake: this anti-pattern is still easy to find in public notebooks

It is tempting to think “scale first, split second” is only a theoretical beginner mistake.

A targeted spot-check of public GitHub artifacts says otherwise.

Searching for combinations of StandardScaler, fit_transform, notebooks, and later calls to train_test_split surfaced at least five public notebook examples where the visible code sequence scales the complete feature matrix before splitting it:

  • an AI-club scikit-learn tutorial

  • a DNNR tutorial notebook

  • a machine-fault classification notebook

  • a lithology competition starter notebook

  • a classifier-comparison notebook

The search snippets show patterns equivalent to X = scaler.fit_transform(X) followed later by train_test_split(X, y, ...).

That count is deliberately not presented as a statistically representative estimate of GitHub notebooks. Search indexing is incomplete, queries affect the sample, and notebooks can contain demonstration code whose context matters.

The interesting fact is simply that five examples were easy to find with a small targeted search.

Even scikit-learn itself has historically received documentation feedback about an example that performed StandardScaler().fit_transform(X) before train_test_split; the corresponding GitHub issue explicitly identified the potential leakage and the risk of readers copying the pattern.

The lesson is useful precisely because the incorrect code looks so reasonable.

X = scaler.fit_transform(X)
X_train, X_test, y_train, y_test = train_test_split(
    X,
    y,
)

It is short.

It executes.

It often produces plausible metrics.

Nothing crashes to warn you that your experimental boundary has been compromised.

Pipeline-based code turns the boundary into architecture instead of convention.

A useful mental model: fit is privileged

When reviewing a machine-learning workflow, highlight every operation that learns something.

For our example:

SimpleImputer.fit
    learns replacement statistics

StandardScaler.fit
    learns means and variances

OneHotEncoder.fit
    learns categories and frequencies

LogisticRegression.fit
    learns coefficients

All four operations should obey the same visibility rule:

during a CV fold:
    fit may see fold-training rows
    fit may not see fold-validation rows

Then:

transform may use the fitted state
on fold-validation rows

The difference between fit and transform is therefore not merely API trivia.

It expresses an information boundary.

That is why this is safe:

scaler.fit(X_fold_train)
scaler.transform(X_fold_train)
scaler.transform(X_fold_validation)

while this is contaminated:

scaler.fit(X_fold_train + X_fold_validation)
scaler.transform(X_fold_train)
scaler.transform(X_fold_validation)

A pipeline coordinates that boundary automatically.

What about transformations that do not learn anything?

Not every preprocessing operation needs fitting.

A deterministic operation such as converting metres to kilometres has no sample-level statistics to learn.

Likewise, a pure arithmetic transformation that is completely specified in advance does not inherently leak merely because it runs before a split.

The crucial distinction is not:

preprocessing versus modeling

It is:

data-dependent learning versus fixed transformation

Still, keeping deterministic feature transformations inside the same reusable preprocessing object is often worthwhile because it keeps training and inference behavior synchronized.

The leakage argument is strongest for learned transformations; the engineering argument applies even more broadly.

Why manual preprocessing is harder to audit

You can prevent leakage without Pipeline.

This is technically valid:

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

scaler = StandardScaler()

X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)

For a single fixed train/test split, those scaling calls use the correct information boundary.

But now add:

  • five-fold CV

  • median imputation

  • categorical encoding

  • rare-category grouping

  • feature selection

  • three candidate classifiers

  • 25 parameter combinations

You either need to manually repeat every preprocessing fit independently inside every fold, or delegate that lifecycle to estimator composition.

That is exactly what Pipeline is designed to do. The scikit-learn documentation explicitly describes pipelines as a way to ensure the same samples used to train estimators are also the samples used to fit preprocessing during cross-validation, avoiding leakage of test-fold statistics.

Manual code is possible.

Pipeline code is auditable.

A practical leakage-review checklist

When reviewing a tabular ML project, check these boundaries.

  • Split raw observations first. Your final test data should not influence any learned development-time statistic.

  • Put imputers inside the pipeline. Mean, median, mode, and other learned fill values are training statistics.

  • Put scalers inside the pipeline. Means, variances, minima, maxima, quantiles, and robust-scale statistics must be fold-local.

  • Put categorical encoders inside the pipeline. Categories and category frequencies are learned state.

  • Put feature selection inside the pipeline. Any selector fitted using features or labels must be refitted independently per fold.

  • Give GridSearchCV the complete pipeline. Do not hand it a globally preprocessed matrix.

  • Tune preprocessing through nested parameter names. Treat preprocessing choices as model hyperparameters where appropriate.

  • Keep one untouched final evaluation set when your workflow requires a final holdout estimate.

  • Use an appropriate CV splitter. A perfect pipeline cannot compensate for future leakage or entity overlap caused by bad folds.

  • Deploy the complete fitted pipeline. Production callers should ideally provide raw schema-compatible observations.

  • Audit feature engineering before the pipeline. Global aggregates, target-derived variables, timestamps, and entity histories can leak even when scikit-learn composition is flawless.

The core pattern to remember

Most of the implementation can be reduced to three objects:

preprocess = ColumnTransformer(
    transformers=[
        ("num", numeric_pipeline, numeric_features),
        ("cat", categorical_pipeline, categorical_features),
    ]
)

pipeline = Pipeline(
    steps=[
        ("preprocess", preprocess),
        ("model", LogisticRegression()),
    ]
)

search = GridSearchCV(
    estimator=pipeline,
    param_grid=param_grid,
    cv=5,
)

The important detail is not the particular classifier.

It is the nesting:

GridSearchCV(
    Pipeline(
        ColumnTransformer(
            learned preprocessing
        ),
        model
    )
)

Cross-validation sees one estimator.

That estimator’s fit begins with preprocessing.

Therefore preprocessing is refitted when the estimator is refitted.

That is the mechanism that closes the leakage hole.

Make this change in one of your real notebooks today

Open an existing tabular ML project and search for every fit_transform that appears before cross_val_score, cross_validate, GridSearchCV, or RandomizedSearchCV.

Then inspect every learned preprocessing step:

imputation
scaling
encoding
feature selection
dimensionality reduction
frequency calculation
learned feature extraction

Move those operations into Pipeline and, for mixed columns, ColumnTransformer.

Put the search object around the entire pipeline.

Then rerun your evaluation.

If the cross-validation score falls, that is not a regression in your model.

It may be the first trustworthy measurement you have had.