Stop Leaking Data: Build a scikit-learn Pipeline That Preprocesses Mixed Columns Safely
Manual scaling and encoding outside your model invites data leakage. A ColumnTransformer inside a Pipeline keeps numeric and categorical preprocessing consistent from training through prediction.
18 Feb 2026, 22:28 UTC

You have a CSV with age, income, and occupation. You need to train a classifier. The tempting path is to scale the numbers, one-hot encode the text column, split the data, and fit a model. That path feels fine until you deploy: new rows arrive with raw strings and unscaled floats, and your model expects a dense matrix of encoded categories. You write a second preprocessing script. It works—until it doesn't. A category appears in production that wasn't in training, or a column order shifts, and the model silently returns garbage.
The fix is to stop treating preprocessing as a separate phase. scikit-learn's ColumnTransformer and Pipeline let you define preprocessing as part of the estimator itself, so the same transformation logic runs during training, cross-validation, and inference.
The split-brain problem
When you preprocess manually, you create two code paths: one for fitting the model and one for predicting. They drift. A StandardScaler fit on the full dataset before a train/test split leaks test-set statistics into training. Even if you split first, you must remember to call transform (not fit_transform) on the test set, and then replicate that exact logic in your serving code. Cross-validation makes it worse: every fold needs the same manual dance, and it is easy to fit a scaler on the wrong partition.
ColumnTransformer and Pipeline together
ColumnTransformer acts as a router. You give it a list of tuples—each tuple names a transformer, the transformer object, and the columns it should receive. Numeric columns go to StandardScaler; categorical columns go to OneHotEncoder. The output is a single array that the next step can consume.
Wrapping that transformer in a Pipeline chains it with your final estimator. The pipeline becomes a single object with fit and predict methods. When you call fit, scikit-learn learns the scaler means, the encoder categories, and the model coefficients in one pass. When you call predict on raw data, it applies the learned transformations automatically. Because the pipeline is a valid estimator, you can hand it directly to cross_val_score or GridSearchCV and search hyperparameters end-to-end without leaking preprocessing statistics across folds.
A minimal worked example
Assume a pandas DataFrame X with columns age, income, and occupation, and a binary target y. The following builds a pipeline and evaluates it with cross-validation:
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score
numeric_features = ["age", "income"]
categorical_features = ["occupation"]
preprocessor = ColumnTransformer(
transformers=[
("num", StandardScaler(), numeric_features),
("cat", OneHotEncoder(handle_unknown="ignore"), categorical_features),
]
)
pipeline = Pipeline(
steps=[
("preprocessor", preprocessor),
("classifier", LogisticRegression()),
]
)
scores = cross_val_score(pipeline, X, y, cv=5)
Run this in a Python environment with scikit-learn 1.0 or later. The handle_unknown="ignore" argument tells the encoder to emit all zeros for categories unseen during training, which prevents crashes on new occupations in production. After fitting, pipeline.named_steps should contain 'preprocessor' and 'classifier', confirming the structure is intact.
Trade-offs and sharp edges
This approach is not free. First, ColumnTransformer requires scikit-learn 0.20 or newer; older installations will raise AttributeError because the class does not exist. If you are pinned to an older version, you must preprocess manually or upgrade.
Second, OneHotEncoder returns a sparse matrix by default. Linear models and many tree-based estimators accept sparse input, but some algorithms—particularly those expecting dense arrays—will fail. If your downstream estimator rejects sparse matrices, either set the encoder to return dense output or insert a densifier step. Note that the parameter name for controlling sparsity changed across scikit-learn versions (from sparse to sparse_output), so check the documentation for your installed version.
Third, debugging becomes slightly more indirect. A shape mismatch inside the transformer surfaces as a pipeline error rather than a preprocessing-script error, so you will inspect pipeline.named_steps more often.
Verify it before you trust it
Do not assume the pipeline is correct just because it runs. Check three things. First, confirm that cross_val_score returns finite values and that the pipeline fits without error. Second, compare the cross-validated score against a carefully written manual preprocessing approach; they should match within numerical tolerance. Third, call pipeline.predict on a small slice of raw, untransformed data and confirm it returns class labels without manual scaling or encoding. If those checks pass, the preprocessing logic is genuinely embedded in the estimator, and your training and serving paths can no longer drift apart.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.