Building a Leak‑Free scikit‑learn Pipeline with ColumnTransformer for Mixed‑Type Data
Learn how to combine ColumnTransformer and Pipeline in scikit‑learn to preprocess mixed numeric and categorical data without leaking information from the test set. Includes a ready‑to‑run example, verification steps, and a table of common pitfalls.
14 Jul 2026, 02:48 UTC

Why a Pipeline + ColumnTransformer is the safe default
When a dataset contains both numeric and categorical columns, the only way to guarantee that preprocessing is learned only on the training split is to wrap the entire transformation chain in a Pipeline. The ColumnTransformer handles column‑wise logic (scaling, encoding) while the outer Pipeline ensures that fit is never called on test data, eliminating leakage.
Worked configuration
The snippet below shows a minimal, production‑ready setup for a binary classification task. It assumes scikit‑learn ≥ 1.2 and a standard Python 3.9+ environment.
# -------------------------------------------------
# 1. Imports
# -------------------------------------------------
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.linear_model import LogisticRegression
import numpy as np
# -------------------------------------------------
# 2. Toy data – 3 columns: age (num), region (cat), income (num)
# -------------------------------------------------
X = np.array([
[25, 'A', 50000],
[30, 'B', 60000],
[22, 'A', 45000],
[35, 'C', 80000]
])
y = np.array([0, 1, 0, 1])
# -------------------------------------------------
# 3. ColumnTransformer definition
# -------------------------------------------------
preprocess = ColumnTransformer(
transformers=[
('num', StandardScaler(), [0, 2]), # columns 0 and 2 are numeric
('cat', OneHotEncoder(handle_unknown='ignore'), [1]) # column 1 is categorical
],
remainder='drop' # drop any column not listed (safe default)
)
# -------------------------------------------------
# 4. Full pipeline – preprocessing + estimator
# -------------------------------------------------
clf = Pipeline([
('prep', preprocess),
('logreg', LogisticRegression(max_iter=200, solver='saga')) # saga accepts sparse input
])
# -------------------------------------------------
# 5. Fit on training data only
# -------------------------------------------------
clf.fit(X, y)
# -------------------------------------------------
# 6. Predict on new rows (same column order!)
# -------------------------------------------------
print(clf.predict([[28, 'B', 55000]]))
What each part does
- ColumnTransformer – applies
StandardScalerto the numeric indices[0,2]andOneHotEncoderto the categorical index[1].handle_unknown='ignore'prevents errors when a new category appears at inference time. - Pipeline – chains the transformer and the estimator. Calling
fiton the pipeline runsfit_transformon the transformer once using only the training rows, then fits the logistic regression on the transformed matrix. - solver='saga' – LogisticRegression’s default solver (
lbfgs) densifies sparse matrices;sagaworks natively with the sparse output ofOneHotEncoder, keeping memory usage low.
Verification checklist
Run the following checks after you integrate the pipeline into your project. They require only a Python interpreter and the scikit-learn package (install with pip install scikit-learn if missing).
- Inspect pipeline steps –
print(clf.steps)should show('prep', ColumnTransformer(...))and('logreg', LogisticRegression(...)). - Confirm no test‑data mutation – after
clf.fit(X_train, y_train), verifyX_testandy_testare unchanged (e.g.,np.array_equal(X_test_before, X_test_after)). - Check transformed shape –
X_trans = clf.named_steps['prep'].transform(X_test); the number of columns must equallen(numeric_cols) + sum(n_unique_per_cat). IfOneHotEncoderreturns sparse,X_transwill be ascipy.sparse.csr_matrix. - Cross‑validation sanity check – compare
cross_val_score(clf, X, y, cv=5)against a naïve model that fits a scaler/encoder on the full data before splitting. The pipeline’s scores should be lower (more realistic) because leakage is prevented.
Limits and common mistakes
| Issue | Why it happens | Mitigation |
|---|---|---|
| Fixed column set at fit time | ColumnTransformer records column indices/names during fit. Adding new columns later raises a ValueError. |
Rebuild the transformer (or the whole pipeline) when the schema changes. Use make_column_selector with dtype‑based selection for more dynamic schemas. |
| Sparse output not supported by estimator | Many estimators (e.g., default LogisticRegression) densify sparse matrices, causing memory spikes on high‑cardinality categorical data. |
Choose a sparse‑compatible solver (saga, liblinear with dual=False) or explicitly set OneHotEncoder(sparse_output=False) if memory permits. |
Custom transformer missing fit/transform |
Pipeline validates the estimator API; a missing method raises AttributeError at fit time. |
Inherit from BaseEstimator and TransformerMixin and implement fit, transform (and optionally fit_transform). |
Data leakage via fit_transform on full data |
Calling preprocess.fit_transform(X) before splitting leaks statistics (mean, categories) from test rows. |
Always call pipeline.fit(X_train, y_train) only; never fit transformers separately on the full dataset. |
Practical way to confirm the result
After training, serialize the pipeline with joblib.dump(clf, 'model.pkl'). In a separate inference script, load it (joblib.load('model.pkl')) and run predict on raw rows that match the original column order. The prediction should be identical to the in‑process call, proving that the entire preprocessing state (scaler parameters, encoder categories) traveled with the model.
Diagram labels
- Pipeline steps
- ColumnTransformer branches
- Estimator (LogisticRegression)
- Sparse/dense flow
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.