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.
session_hour land near zero importance, and prove that dropping it changes nothing. More columns is not more signal.
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.
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 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: callingfitfits the ColumnTransformer on the training rows only, andpredictreplays 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.
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']andpipe.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.
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.
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.
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.
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.
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
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.
This week ◐ 40 min total
- Engineer a feature. Add one you can compute at prediction time (for example spend-per-page,
prior_30d_spend / pages_viewed), fold it into the numeric lane, and re-fit the pipeline. Did it help? - Explain handle_unknown. Set
handle_unknown='ignore'and write why it matters - what happens in production when a channel appears that was not in the training data? - Rank and prune. Rank features by importance, remove the weakest one, and confirm the CV score does not fall. More columns is not more signal.
- Optional: swap LogisticRegression for a RandomForestClassifier in the same Pipeline - only the model step changes, and you read
feature_importances_instead ofcoef_.
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.
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.
Builder Session 9 cheat sheet · pin this
[('num', StandardScaler(), num), ('cat', OneHotEncoder(...), cat)]. Scales numbers, one-hots categories, in parallel.Pipeline([('pre', ct), ('model', ...)]) is one estimator. fit fits prep on train only; predict replays it. Leakage becomes structurally impossible.handle_unknown='ignore' so an unseen category in production does not crash the service.get_feature_names_out() for the expanded names, then coef_ (linear) or feature_importances_ (trees). Rank honestly.session_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.