learn-intro-ml-with-phoebe / Builder session 9 of 10
Learn Intro to Machine Learning with Phoebe · Builder session 9 of 10

Feature prep, and the Pipeline that makes leakage impossible

Real data is a mess of mixed types - numbers next to categories - and a model needs it all as clean numeric columns. The naive way is a pile of loose preprocessing steps, and it quietly leaks the test set into training. This session does it the right way: a ColumnTransformer that scales the numbers and one-hot encodes the categories, wrapped in a Pipeline so the exact same preparation is fit on train and applied everywhere else. One object, no skew, no leakage - and a clean look at what the model actually leaned on.

🔴 Hardest Builders · DA / DE / DS Python · scikit-learn 45 min
0-3 · Why prep 3-16 · ColumnTransformer 16-40 · Build it in Python 40-45 · Wrap
Part 0

The prep is where models leak - so bind it to the model

Every session so far quietly hand-picked numeric features or one-hot encoded on the whole frame. That is fine for a demo and dangerous in production: if you fit a scaler or an encoder on all the data before splitting, the test set has already influenced training, and your score lies. The fix is not more discipline - it is structure. A Pipeline binds the preparation to the model as one estimator, so the transforms are fit on train only and replayed identically on test and in production.

Live - built in session Self-study - run after ★ The one big idea The case: Lumen Skincare
★ What you walk out with today A single Pipeline object that scales numerics, one-hot encodes categoricals via a ColumnTransformer, and trains a model - all fit on train, all applied identically everywhere. You will read off what the model leaned on (prior spend, new-vs-returning, channel), watch the planted noise session_hour land near zero importance, and prove that dropping it changes nothing. More columns is not more signal.
Part 1 · one object, two lanes

ColumnTransformer inside a Pipeline 6 min live

Mixed-type data needs two different treatments. Numbers want scaling so no single large-valued column dominates; categories want one-hot encoding so a model can use them as 0/1 flags. A ColumnTransformer runs both lanes in parallel on the right columns, and a Pipeline glues that prep to the model as one estimator.

ONE Pipeline estimator (fit on train only) Raw mixed table prior_30d_spend 38.0 pages_viewed 5 channel "email" device "mobile" ColumnTransformer - two lanes scale lane · StandardScaler numeric cols -> mean 0, SD 1 one-hot lane · OneHotEncoder category cols -> 0/1 columns Model .fit / .predict One estimator in, one estimator out - so train, test, and production all get the identical transforms.
🔍 Click to zoom - two prep lanes, boxed with the model as one estimator
LiveWhy one object, not a pile of steps3 min▶

You could scale, encode, and fit as three separate lines - and thousands of leaked models prove why you should not. The danger is fitting any transformer on data the model will later be tested on:

✗ LOOSE STEPS scaler can see test rows early score looks great, then leaks train/serve skew in production ✓ ONE PIPELINE fits prep on train rows only predict replays it exactly same object ships to production A Pipeline makes the correct order structural - you cannot leak prep by accident.
🔍 Click to zoom - leakage never throws an error, it just quietly inflates the score
  • Loose steps leak. Fit a scaler on all rows, then split, and the test rows have already shaped the mean and SD the model trained against. The score is optimistic and wrong.
  • A Pipeline binds prep to model. Pipeline([('pre', ct), ('model', ...)]) is one estimator: calling fit fits the ColumnTransformer on the training rows only, and predict replays those exact transforms.
  • No train/serve skew. The same fitted object goes to production, so the transforms applied at prediction time are byte-for-byte the ones learned in training. No divergence.
Why this matters Leakage is the number-one beginner mistake, and it never shows up as an error - only as a score that looks great and collapses in production. A Pipeline makes the correct behaviour the structural default, so you cannot leak by accident.
Self-studyInspecting what the model leaned on3 min▶

A fitted Pipeline still lets you read the model inside it. For a linear model you read coef_; for a tree or forest you read feature_importances_. The trick is matching those numbers back to the expanded column names the ColumnTransformer produced.

  • Reach the fitted steps with pipe.named_steps['pre'] and pipe.named_steps['model'].
  • Recover the post-encoding column names with pipe.named_steps['pre'].get_feature_names_out().
  • Pair names with weights or importances to rank the features honestly.

This is how you confirm the model learned what the business expects - and how you catch it leaning on noise. On Lumen, the strong features are prior spend, new-vs-returning, and channel; the planted session_hour lands near zero.

★ everyone builds · the leak-proof estimator

Build the Pipeline, then expose the noise 22 min · everyone builds

We go back to the classification task - predict converted - because it uses every column type Lumen has. We build one Pipeline, cross-validate it cleanly, read what it leaned on, and then prove the noise column carries nothing.

Reload and name the column groups. Declare which columns are numeric and which are categorical - the ColumnTransformer routes each group to the right lane.

columns.py
import numpy as np, pandas as pd

df = pd.read_parquet("lumen_sessions.parquet")
y = df["converted"]
X = df.drop(columns=["converted", "order_value"])

num = ["prior_30d_spend", "pages_viewed", "session_hour"]
cat = ["channel", "device", "new_vs_returning", "product_category"]
print("numeric:", num)
print("categorical:", cat)

Build the ColumnTransformer inside a Pipeline. Two lanes, one model, one estimator. Because it is a single object, cross-validation fits the prep fresh on each fold - no leakage.

pipeline.py
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score

pre = ColumnTransformer([
    ("num", StandardScaler(), num),
    ("cat", OneHotEncoder(handle_unknown="ignore"), cat),
])
pipe = Pipeline([
    ("pre", pre),
    ("model", LogisticRegression(max_iter=1000, class_weight="balanced")),
])

auc = cross_val_score(pipe, X, y, cv=5, scoring="roc_auc")
print("ROC-AUC %.2f +/- %.2f" % (auc.mean(), auc.std()))   # ~ 0.71

The whole pipeline cross-validates cleanly at ROC-AUC ~0.71 - the same honest signal from b7, now produced with leak-proof prep.

Read what the model leaned on. Fit once, then pair the expanded feature names with the logistic coefficients to rank them by pull.

importance.py
pipe.fit(X, y)
names = pipe.named_steps["pre"].get_feature_names_out()
coefs = pipe.named_steps["model"].coef_[0]

rank = pd.Series(np.abs(coefs), index=names).sort_values(ascending=False)
print(rank.head(6).round(3))
# top: prior_30d_spend, new_vs_returning, channel_* ...
# session_hour ~ 0.00  (the planted noise, exposed)

Prior spend, new-vs-returning, and the best channels sit at the top - exactly the signal the canon planted. And session_hour sits near zero, because it was noise from day one.

Prove it: drop the noise. Remove session_hour and re-score. If it carried signal the number would drop; it does not budge.

Same score with or without the noise column WITH session_hour 0.71 WITHOUT session_hour 0.71 Dropping the noise column does not budge ROC-AUC - session_hour never carried signal.
🔍 Click to zoom - a feature that changes nothing when removed was never signal
drop_noise.py
num2 = ["prior_30d_spend", "pages_viewed"]     # session_hour removed

pre2 = ColumnTransformer([
    ("num", StandardScaler(), num2),
    ("cat", OneHotEncoder(handle_unknown="ignore"), cat),
])
pipe2 = Pipeline([("pre", pre2),
    ("model", LogisticRegression(max_iter=1000, class_weight="balanced"))])

auc2 = cross_val_score(pipe2, X, y, cv=5, scoring="roc_auc")
print("without session_hour: ROC-AUC %.2f" % auc2.mean())   # ~ 0.71, unchanged
Real world

When Lumen moved this model to production, the team shipped the Pipeline object itself, not a written-down list of prep steps. That mattered: the service applied the identical scaler means and encoder categories learned in training, so there was no train-serve skew and no leakage window. The engineer who inherited it did not need the notebook - the transforms travelled inside the model. Loose steps are how skew sneaks in between the data scientist's laptop and the live endpoint.

Before Session 10

This week ◐ 40 min total

Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · Why wrap preparation and model in a single Pipeline?

A Pipeline is one estimator: fit fits the transformers on the training data only, and predict replays those exact transforms on test and in production. That structure is what prevents the test set from leaking into training and stops the live service from diverging.

2 · What does OneHotEncoder do?

One-hot encoding expands a categorical column like channel into a set of 0/1 columns, one per category, so a model that only understands numbers can use it. Scaling is what StandardScaler does, in the other lane of the ColumnTransformer.

3 · The model shows session_hour with importance near zero. What does that tell you?

session_hour was deliberately built with no relationship to conversion, so a correct model gives it near-zero weight. Dropping it leaves the score unchanged, which is the whole lesson: adding columns adds noise, not information, unless the signal is real.

Source material

What this session covers

This builder session covers correct feature preparation - the leak-proof way to feed mixed-type data to a model - on the Lumen classification task, and sets up the capstone in b10.

✓ColumnTransformer + Pipeline; OneHotEncoder + StandardScaler; reading coef_ / feature_importances_Part 1 + demo
◐scikit-learn - Pipelines & composite estimators, ColumnTransformer, preprocessingthe prep spine
◐Deeper feature engineering -> learn-feature-engineering-with-phoebethe sibling course, named not taught

Builder Session 9 cheat sheet · pin this

ColumnTransformerroutes columns to lanes: [('num', StandardScaler(), num), ('cat', OneHotEncoder(...), cat)]. Scales numbers, one-hots categories, in parallel.
PipelinePipeline([('pre', ct), ('model', ...)]) is one estimator. fit fits prep on train only; predict replays it. Leakage becomes structurally impossible.
OneHotEncodercategory -> 0/1 columns. Always handle_unknown='ignore' so an unseen category in production does not crash the service.
StandardScalernumeric columns to mean 0, SD 1, fit on train. Keeps one large-valued column from dominating a linear model.
Read the modelget_feature_names_out() for the expanded names, then coef_ (linear) or feature_importances_ (trees). Rank honestly.
Noise is not signalsession_hour importance ~0; dropping it leaves ROC-AUC ~0.71 unchanged. More columns is not more information. Next: the capstone, then learn-experimentation-with-phoebe is the sequel.