Skip to content
Laviyx Learn

Try

Machine Learning course menu

scikit-learn Pipelines and ColumnTransformer Explained

Combine preprocessing and models into one object with scikit-learn Pipeline and ColumnTransformer: different steps for numeric and categorical columns, preventing data leakage, cross-validating and tuning whole pipelines, and predicting on raw data.

Intermediate9 min readUpdated
On this page

Real models need several preparation steps (impute, scale, encode) before the algorithm. A pipeline chains them into one object that behaves like a single model: fit runs every step on the training data, and predict applies the same steps to new data. Pipelines prevent data leakage, keep code short, and make the whole model easy to save and deploy.

A simple pipeline#

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

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.25, random_state=0, stratify=y)

pipe = Pipeline([
    ("scale", StandardScaler()),
    ("model", LogisticRegression()),
])
pipe.fit(X_train, y_train)            # the scaler learns from training data only
print(round(pipe.score(X_test, y_test), 3))
print(pipe.named_steps["scale"].mean_[:3].round(2))
Output
0.958
[14.12 19.27 91.91]

make_pipeline(StandardScaler(), LogisticRegression()) builds the same thing with automatic step names.

Different steps for different columns#

Real tables mix numbers and categories, and each needs different treatment. ColumnTransformer sends each group of columns through its own preprocessing:

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

rng = np.random.default_rng(4)
n = 500
customers = pd.DataFrame({
    "age": rng.integers(18, 70, n).astype(float),
    "monthly_spend": rng.gamma(2, 900, n).round(0),
    "city": rng.choice(["Mumbai", "Delhi", "Pune", "Chennai"], n),
    "plan": rng.choice(["basic", "pro", "premium"], n, p=[0.6, 0.3, 0.1]),
})
customers.loc[rng.random(n) < 0.05, "age"] = np.nan
churn_score = -1 + 0.8 * (customers["plan"] == "basic") - customers["monthly_spend"] / 2000
customers["churned"] = (rng.random(n) < 1 / (1 + np.exp(-churn_score))).astype(int)

numeric = ["age", "monthly_spend"]
categorical = ["city", "plan"]
preprocess = ColumnTransformer([
    ("num", Pipeline([("impute", SimpleImputer(strategy="median")), ("scale", StandardScaler())]), numeric),
    ("cat", OneHotEncoder(handle_unknown="ignore"), categorical),
])
model = Pipeline([("prep", preprocess), ("clf", RandomForestClassifier(n_estimators=200, random_state=0))])

X = customers.drop(columns="churned")
y = customers["churned"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=0, stratify=y)
model.fit(X_train, y_train)
print("test accuracy:", round(model.score(X_test, y_test), 3))
print(model.named_steps["prep"].get_feature_names_out().tolist())
Output
test accuracy: 0.76
['num__age', 'num__monthly_spend', 'cat__city_Chennai', 'cat__city_Delhi', 'cat__city_Mumbai', 'cat__city_Pune', 'cat__plan_basic', 'cat__plan_premium', 'cat__plan_pro']

The pipeline takes the raw DataFrame, with missing ages and text categories, and handles everything inside. The feature names show what the model actually received: two scaled numeric columns and one-hot columns for each city and plan.

Predicting on raw data#

Because the preparation is inside, new data needs no manual processing:

Python
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.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

train = pd.DataFrame({
    "age": [25, 40, np.nan, 33, 51, 29],
    "city": ["Pune", "Delhi", "Pune", "Mumbai", "Delhi", "Mumbai"],
    "bought": [0, 1, 0, 1, 1, 0],
})
pipe = Pipeline([
    ("prep", ColumnTransformer([
        ("num", Pipeline([("impute", SimpleImputer(strategy="median")), ("scale", StandardScaler())]), ["age"]),
        ("cat", OneHotEncoder(handle_unknown="ignore"), ["city"]),
    ])),
    ("clf", LogisticRegression()),
]).fit(train[["age", "city"]], train["bought"])

new_customers = pd.DataFrame({"age": [45, np.nan], "city": ["Delhi", "Kochi"]})
print(pipe.predict_proba(new_customers).round(3))
Output
[[0.193 0.807]
 [0.542 0.458]]

The missing age and the never-seen city "Kochi" are handled by the imputer and handle_unknown="ignore", so the model still returns probabilities.

Why pipelines prevent leakage#

Scaling or imputing before splitting (or before cross-validation) lets statistics from the validation data influence training. Inside a pipeline, every step is re-fitted on each training fold only. That is why you should pass the whole pipeline to cross-validation and tuning tools:

Python
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import cross_val_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.svm import SVC

X, y = load_breast_cancer(return_X_y=True)
scores = cross_val_score(make_pipeline(StandardScaler(), SVC()), X, y, cv=5)
print(scores.round(3), round(scores.mean(), 3))
Output
[0.974 0.956 1.    0.965 0.973] 0.974

Tuning a pipeline#

Parameters of any step can be tuned, using stepname__parameter names (two underscores):

Python
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import GridSearchCV
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.svm import SVC

X, y = load_breast_cancer(return_X_y=True)
pipe = Pipeline([("scale", StandardScaler()), ("svm", SVC())])
search = GridSearchCV(pipe, {"svm__C": [0.1, 1, 10], "svm__gamma": ["scale", 0.01]}, cv=5)
search.fit(X, y)
print(search.best_params_, round(search.best_score_, 3))
Output
{'svm__C': 10, 'svm__gamma': 0.01} 0.979

More on searching in Hyperparameter Tuning.

Common mistakes#

Practice#

Practice exercise

Build a pipeline for the wine dataset that scales the features and fits a KNeighborsClassifier, then use GridSearchCV with 5 folds to choose n_neighbors from 3, 7 and 15. Print the best value and its cross-validated accuracy (3 decimals).

Show hint

Pipeline([("scale", StandardScaler()), ("knn", KNeighborsClassifier())]) and the grid {"knn__n_neighbors": [3, 7, 15]}.

Show solution
Python
from sklearn.datasets import load_wine
from sklearn.model_selection import GridSearchCV
from sklearn.neighbors import KNeighborsClassifier
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler

X, y = load_wine(return_X_y=True)
pipe = Pipeline([("scale", StandardScaler()), ("knn", KNeighborsClassifier())])
search = GridSearchCV(pipe, {"knn__n_neighbors": [3, 7, 15]}, cv=5).fit(X, y)
print(search.best_params_["knn__n_neighbors"], round(search.best_score_, 3))
Output
7 0.967

Key takeaway#

Next: Cross-Validation.

Finished this lesson?