EduFlow

Section 4 · Doing It Right

Pipelines and ColumnTransformer

22 min3 min readPython exercise

Lesson at a glance

Build one Pipeline that imputes, scales and one-hot encodes the right columns with ColumnTransformer, so preprocessing is refit inside every cross-validation fold and travels with the model.

  1. 1Pipelines bundle steps into one estimator
  2. 2Different columns, different treatment
  3. 3More columns, worse model

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

Section 3 kept leaving data on the table. country and segment are text, which no scikit-learn model accepts directly. avg_satisfaction has gaps that logistic regression refuses. And you’ve been scaling by hand, keeping track of X_train_s and X_test_s. Each of those steps has to be learned from training data only, applied identically to new data, and redone inside every cross-validation fold. Doing that by hand is how leaks and bugs happen. A pipeline does it for you.

Pipelines bundle steps into one estimator

A Pipeline is a list of steps: any number of transformers (objects with fit and transform, such as StandardScaler) followed by one final estimator. The pipeline behaves like a single model:

  • pipe.fit(X, y) fits the first transformer, transforms the data, passes it to the next step, and so on, then fits the model on the result.
  • pipe.predict(X_new) runs X_new through the already-fitted transformers, then predicts.

You’ve used make_pipeline, which names steps automatically ("standardscaler", "logisticregression"). Pipeline([("prep", ...), ("model", ...)]) lets you choose the names, which you’ll want next lesson when you tune parameters by name.

The real payoff is in cross-validation. cross_validate clones the entire pipeline and refits it in every fold, so the scaler’s means, the imputer’s medians and the encoder’s category list are learned from that fold’s training rows only.

Different columns, different treatment

Not every column needs the same preprocessing. ColumnTransformer routes each group of columns through its own transformer and glues the outputs side by side.

The raw churn table splits into three column groups. Eight numeric columns go through StandardScaler. avg_satisfaction goes through SimpleImputer with an indicator, then StandardScaler. country and segment go through OneHotEncoder. The outputs are concatenated into 21 columns and fed to LogisticRegression. raw table 11 columns 8 numeric StandardScaler avg_satisfaction SimpleImputer + indicator, scaler country, segment OneHotEncoder 21 columns LogisticRegression
A ColumnTransformer inside a Pipeline

Three transformers do the work:

  • OneHotEncoder creates one 0/1 column per category: country_Egypt, country_Germany and so on. Set handle_unknown="ignore"; the default raises an error the first time a customer arrives from a country the training data didn’t include, which in production means a crashed prediction service.
  • SimpleImputer fills missing values with the mean, median, most frequent value or a constant, learned from training data. With add_indicator=True it also adds a column that is 1 where the value was missing. Half of Cartwheel’s customers never answered the survey, and that fact may matter more than the score itself.
  • StandardScaler, as before. A small make_pipeline lets one column group pass through two steps in order.
Python
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold, cross_val_score, train_test_split
from sklearn.pipeline import Pipeline, make_pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

churn = pd.read_csv("churn.csv")
X = churn.drop(columns=["customer_id", "churned"])
y = churn["churned"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=42, stratify=y)

numeric = ["tenure_days", "orders", "total_spent", "avg_order_value",
           "days_since_last_order", "used_coupon", "support_tickets", "mobile_share"]
preprocess = ColumnTransformer([
    ("num", StandardScaler(), numeric),
    ("sat", make_pipeline(SimpleImputer(strategy="median", add_indicator=True), StandardScaler()),
     ["avg_satisfaction"]),
    ("cat", OneHotEncoder(handle_unknown="ignore"), ["country", "segment"]),
])
pipe = Pipeline([("prep", preprocess), ("model", LogisticRegression())])

pipe.fit(X_train, y_train)
names = pipe.named_steps["prep"].get_feature_names_out()
print(len(names), "model inputs, e.g.", list(names[8:12]))

cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
print("all columns  ROC AUC:", cross_val_score(pipe, X_train, y_train, cv=cv, scoring="roc_auc").mean().round(3))
numeric_only = make_pipeline(StandardScaler(), LogisticRegression())
print("numeric only ROC AUC:", cross_val_score(numeric_only, X_train[numeric], y_train, cv=cv, scoring="roc_auc").mean().round(3))

The pipeline accepts the raw DataFrame, text and gaps included, and produces 21 model inputs: eight scaled numbers, the imputed satisfaction score and its missing flag, eight country columns and three segment columns. get_feature_names_out lists them with a prefix naming the transformer that made each one, which is how you match coefficients to columns later.

More columns, worse model

Then the surprise: the full pipeline ranks customers worse than the eight numeric columns alone, 0.776 against 0.807. Eleven one-hot columns and a satisfaction score with half its values imputed give logistic regression more coefficients to fit with only 298 rows, and on this data the extra columns carry more noise than signal. Egypt’s churn rate does look higher in the training data, but with 44 Egyptian customers in training that’s too thin to bet on.

That’s not a failure of the pipeline. It’s the pipeline doing its job: it made testing the extra columns a five-line change, and cross-validation gave a clear answer. For now, the numeric model stays the champion. The categorical columns are worth revisiting when Cartwheel has a few thousand customers, or with stronger regularisation.

A pipeline also gives every setting an address: model__C is the C of the step named model, and prep__sat__simpleimputer__strategy reaches the imputer. Next lesson uses those addresses to search for the best settings automatically.

Key takeaways

  • A Pipeline chains transformers and a final model into one estimator: fit fits every step in order, predict runs new data through the same steps.
  • ColumnTransformer applies different preprocessing to different columns and concatenates the results.
  • OneHotEncoder(handle_unknown="ignore") turns categories into 0/1 columns and won’t crash on a category it never saw in training.
  • SimpleImputer(add_indicator=True) fills gaps and adds a column recording which values were missing.
  • Because the whole pipeline is refit in each fold, preprocessing only ever learns from training rows, which keeps cross-validation honest.

Further reading

Check your understanding

4 questions

Question 1 of 4

During cross_validate(pipe, X_train, y_train, cv=5), where does the StandardScaler inside pipe get its means from?

Question 2 of 4

A customer from France (absent in training) reaches a pipeline whose OneHotEncoder uses the default handle_unknown. What happens?

Question 3 of 4

Why use SimpleImputer(strategy="median", add_indicator=True) for avg_satisfaction?

Question 4 of 4

Adding country and segment to the logistic pipeline lowers cross-validated ROC AUC from 0.807 to 0.776. What’s the best interpretation?

Practice

Python exercise

A pipeline that survives real data

Make the starter pipeline handle gaps and surprises.

  1. preprocess: scale the numeric columns; for avg_satisfaction, impute with the median and add a missing indicator, then scale; one-hot encode segment so that unseen categories are ignored instead of raising an error.
  2. pipe: steps named "prep" and "model" (a LogisticRegression), fitted on the training data. It should produce 13 model inputs.
  3. cv_auc: mean ROC AUC from cross_val_score with the provided cv.
  4. new_prob: the churn probability for new_customer, who comes from an unseen country, an unseen segment and has no satisfaction score.

Data files: churn.csv

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.model_selection import StratifiedKFold, cross_val_score, train_test_split
from sklearn.pipeline import Pipeline, make_pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

churn = pd.read_csv("churn.csv")
X = churn.drop(columns=["customer_id", "churned"])
y = churn["churned"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=42, stratify=y)
numeric = ["tenure_days", "orders", "total_spent", "avg_order_value",
           "days_since_last_order", "used_coupon", "support_tickets", "mobile_share"]

# A customer the training data never prepared you for
new_customer = pd.DataFrame([{
    "country": "France", "segment": "government", "tenure_days": 200, "orders": 2,
    "total_spent": 150.0, "avg_order_value": 75.0, "days_since_last_order": 100,
    "used_coupon": 0, "support_tickets": 0, "avg_satisfaction": np.nan, "mobile_share": 0.5}])

# 1. Preprocess: scale `numeric`; impute (median, with indicator) then scale avg_satisfaction;
#    one-hot encode `segment` so unseen categories don't crash
preprocess = ColumnTransformer([
    ("num", StandardScaler(), numeric),
    ("sat", StandardScaler(), ["avg_satisfaction"]),
    ("cat", OneHotEncoder(), ["segment"]),
])

# 2. Pipeline with steps named "prep" and "model" (LogisticRegression), fitted on the training data
pipe = Pipeline([("prep", preprocess), ("model", LogisticRegression())])

# 3. Mean cross-validated ROC AUC on the training data
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
cv_auc = None

# 4. Churn probability for new_customer
new_prob = None
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

(no output)

Checks

  • pipe has steps named prep and model
  • The fitted preprocessor produces 13 inputs
  • cv_auc is the mean cross-validated ROC AUC
  • new_prob is a valid probability for the unseen customer