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.
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#
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))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:
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())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:
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))[[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:
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))[0.974 0.956 1. 0.965 0.973] 0.974Tuning a pipeline#
Parameters of any step can be tuned, using stepname__parameter names (two underscores):
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)){'svm__C': 10, 'svm__gamma': 0.01} 0.979More 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
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))7 0.967Key takeaway#
Next: Cross-Validation.
Finished this lesson?