Stop Leaking Data: Why Your scikit-learn Preprocessing Belongs Inside a Pipeline
Fitting scalers and encoders before splitting your data leaks validation information into training. scikit-learn's Pipeline and ColumnTransformer make leakage structurally impossible — here's how, with a worked example and the real trade-offs.
11 May 2026, 21:41 UTC

Your model scored 0.91 in cross-validation and 0.74 in production. Before you blame distribution shift, check a more mundane culprit: you scaled your features before splitting the data. Fitting a StandardScaler or imputer on the full dataset lets statistics from validation rows bleed into training, inflating every offline metric you report. The fix is not more discipline — it is structural. Put preprocessing inside a Pipeline so scikit-learn makes leakage impossible by construction.
The thesis: fit preprocessing inside the fold, not before it
Any transformer that learns from data — scalers, imputers, encoders, feature selectors — must be fit only on training rows. When you call cross_val_score on a Pipeline, scikit-learn clones the whole chain for each fold, fits the transformers on that fold's training partition, and applies them to the held-out partition. The validation fold is genuinely unseen. Do the same steps by hand on a preprocessed DataFrame and you get optimistic bias that no amount of careful seeding will remove.
ColumnTransformer: routing mixed data without manual slicing
Real tables mix numbers and categories. ColumnTransformer applies different transformer chains to different column subsets and concatenates the results, replacing the fragile pattern of slicing a DataFrame, transforming pieces, and hstacking them back together. A typical setup:
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score
numeric = ["age", "income", "tenure_months"]
categorical = ["region", "plan_type"]
preprocess = ColumnTransformer([
("num", Pipeline([
("impute", SimpleImputer(strategy="median")),
("scale", StandardScaler()),
]), numeric),
("cat", OneHotEncoder(handle_unknown="ignore"), categorical),
])
model = Pipeline([
("prep", preprocess),
("clf", LogisticRegression(max_iter=1000)),
])
scores = cross_val_score(model, X, y, cv=5, scoring="roc_auc")
print(scores.mean(), scores.std())Run this in a training script or notebook with scikit-learn installed (1.2+ recommended; see the version note below). X is your raw feature DataFrame, y the target. No special permissions are needed. A sensible check: the scores should vary modestly across folds, and — importantly — they will often be lower than what you got from preprocessing-then-splitting. That drop is the leakage you just removed, not a regression.
Two details in this example earn their keep. handle_unknown="ignore" means a category that appears in production but not in training encodes as all zeros instead of raising an error — a common serving-time crash when data drifts. And selecting columns by explicit names rather than by dtype means a schema change (a numeric column arriving as strings, say) fails loudly instead of silently rerouting a column to the wrong transformer.
One artifact, no drift between training and serving
The whole pipeline — imputation medians, scaler means and variances, encoder categories, model coefficients — is one Python object. Serialize it with joblib.dump(model, "model.joblib") and load it in your serving process. This eliminates an entire class of bugs where a separate preprocessing script evolves on a different schedule than the model training code. Whatever predict does in production is exactly what cross-validation measured.
The trade-off: opacity
Pipelines hide intermediate state, and that is the real cost. When a prediction looks wrong, you cannot just inspect a preprocessed DataFrame — you have to reach through the pipeline: model.named_steps["prep"].get_feature_names_out() to see what columns the transformer produced, or model[:-1].transform(X.sample(10)) to inspect intermediate output. Debugging a silent shape mismatch inside a ColumnTransformer is genuinely more painful than debugging flat code. Teams that need heavy per-feature introspection sometimes keep a thin debugging wrapper around the pipeline rather than abandoning it.
One more sharp edge: OneHotEncoder emits sparse matrices by default. Most scikit-learn estimators handle sparse input fine, but some downstream tools silently densify it (a memory blow-up on wide categoricals) or reject it. Also note the parameter was renamed from sparse to sparse_output in scikit-learn 1.2, with the old name removed in 1.4 — pin your scikit-learn version in requirements and check the docs for your installed release.
What to do Monday morning
Take one existing training script that preprocesses before splitting and refactor it into a Pipeline plus ColumnTransformer. Then run both versions through the same cross-validation and compare scores. If the pipeline version scores lower, you have measured your leakage — and you now have a number to show anyone who asks why the refactor matters. Keep explicit column names, set handle_unknown="ignore", serialize the whole pipeline as a single artifact, and pin the library version. That is most of the preprocessing engineering a tabular model needs.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.