S093 · Feature Engineering — Encoding, Scaling, Missing
The unglamorous 80% of ML that actually decides whether your model works. Learn the encoding, scaling, imputation, and leakage-prevention tricks that turn raw dataframes into model-ready matrices — and the exact ColumnTransformer + Pipeline pattern that keeps you out of trouble in production.
🎯 Build a leakage-proof feature pipeline that encodes categoricals, scales numerics, imputes missing values, and ships as one artifact you can pickle.
Why this session exists
Kaggle winners will tell you they spent 3 hours on the model and 3 weeks on features. Production ML teams will tell you 80% of their bugs live in the feature layer, not the model. This session covers the boring, high-leverage machinery: encode categoricals correctly, scale numerics where it matters, impute missing values honestly, and — the part most tutorials skip — do all of it inside a sklearn Pipeline so you can't accidentally leak test data into training. That last point alone will save your career.
- Pick the right encoder for a categorical column: one-hot vs ordinal vs target vs hashing.
- Explain when scaling matters (linear models, kNN, NN) and when it doesn't (trees).
- Impute missing values in three defensible ways and know when each one is the wrong choice.
- Build a ColumnTransformer + Pipeline that fits on train, transforms on test, and ships as one artifact.
- Spot and prevent the four common flavours of data leakage.
Prerequisites
- S086 · Data cleaning & preprocessing — you know how to handle a dataframe.
- S090 · Logistic regression — you have a model to feed engineered features to.
- S092 · Evaluation metrics — so you can measure whether your features actually helped.
(a) Intuition · 5 min
A restaurant that plates 300 covers a night doesn't chop onions during service. The prep team dices, brunoises, and pre-measures every ingredient into labelled containers before dinner starts. During service the line cook grabs, cooks, plates — no thinking about ingredients.
Prep is 90% of the workday. It's also what separates a Michelin kitchen from a college hostel: not the skill of the chef, but the discipline of the prep.
Feature engineering is prep. Raw dataframes have strings, missing values, wildly different units, and categorical labels the model can't understand. You could feed it all in raw and hope. You'll spend the next month debugging why the model works on Tuesday and fails on Wednesday.
Or you spend a disciplined afternoon building a pipeline that converts raw data → clean numeric matrix, once. Every model call afterwards is just pipe.predict(X_new). No thinking about ingredients during service.
The three problems feature engineering solves
- Representation — models eat numbers, not strings. Categoricals must become numeric in a way that doesn't imply false ordering.
- Scale — many models (linear, kNN, SVM, neural nets) are sensitive to the magnitude of inputs. Features on wildly different scales cause the optimiser to zig-zag or the distance metric to be dominated by one column.
- Completeness — real data has missing values. You must decide what missing means (unknown vs zero vs not-applicable) and impute in a way that doesn't destroy signal.
A quick history so you know why the world looks like this
- 1970sStatisticians formalise dummy variablesOne-hot encoding predates ML by decades — regression textbooks called them ‘dummy variables’.
- 1980sOrdinal encoding for tree-friendly modelsDecision trees don't care about magnitudes, so simple integer labels work.
- 2001Target encoding · Micci-BarrecaReplace a category by its mean target value. Powerful, dangerous — leaks target signal if done without care.
- 2013Feature hashing · Vowpal WabbitHash categoricals to a fixed-size vector. Enables online learning on high-cardinality features (URLs, user IDs).
- 2018sklearn ColumnTransformerFinally, a first-class API to apply different transformers to different columns. Kills a thousand hand-written wrappers.
(b) Visual walkthrough · 15 min
The end-to-end feature pipeline
The encoding decision tree
Low/Medium/High → OrdinalEncoder with explicit order. XS/S/M/L/XL → same.
One-hot encoding. Adds one binary column per category. Simple, safe, interpretable.
TargetEncoder (with CV to avoid leakage) OR feature hashing. One-hot would blow up the feature space.
Feature hashing to a fixed size (2^18 buckets, say). Accept some collisions.
OrdinalEncoder is often fine even for unordered categoricals — trees split on values, not distances.
The scaler zoo
z-score: (x - mean) / std
- Assumes roughly Gaussian input
- Sensitive to outliers (they blow up std)
- Default choice for linear models, logistic regression, PCA
- Do NOT apply to already-binary or one-hot columns
squash to [0, 1]
- Preserves shape of distribution
- Extremely sensitive to outliers (one bad value ruins the range)
- Good for neural nets that expect bounded input
- Not a default — use only when you know your min/max are stable
median + IQR based
- Uses median instead of mean, IQR instead of std
- Robust to outliers by design
- Best default when your numerics have long tails (income, latency, counts)
- Slightly slower to compute but the extra ms doesn't matter
Trees don't care
- Decision trees, random forests, gradient boosting → skip scaling entirely
- Trees split on threshold ≤ value; the units don't matter
- Saves you from a whole category of bugs
- But encoding still matters
Missing-value imputation strategies
Imputation strategies, ranked by common sense
"Feature engineering is mostly obsolete. Deep learning and gradient boosting learn representations automatically — I should spend my time on model architecture, not on hand-crafting columns."
Models learn to combine the information you give them; they cannot recover information that is absent. A booster given a raw Unix timestamp cannot discover "is this a weekend" — it can only cut the timestamp axis, so it must approximate a weekly periodicity with hundreds of splits and will fail entirely on future dates outside the training range. One derived column makes the pattern trivially learnable. Representation learning is real for perception data, where the raw signal genuinely contains the structure; on tabular data the structure usually lives in domain knowledge that exists nowhere in the columns.
The myth is sticky because it is emphatically true in the domain that produced the headlines. Convolutional and transformer architectures really did eliminate hand-crafted image and text features, and did so decisively — so the lesson generalised. But those signals are dense, high-dimensional, and translation-structured, and the architecture encodes that structure as an inductive bias. Tabular data has no such shared structure, which is why the winning approach there has stayed the same for a decade: strong features into a gradient booster.
Give a model the same information in two encodings and watch it matter more than the model choice:
import numpy as np
# raw: a single monotone axis. cyclicity is invisible to an axis cut.
X_raw = ts.astype('int64').values.reshape(-1, 1) // 10**9
# engineered: the structure is now explicit
hour = ts.dt.hour.values
X_eng = np.c_[
ts.dt.dayofweek.values >= 5, # weekend flag
np.sin(2*np.pi*hour/24), # cyclic: 23h is near 0h
np.cos(2*np.pi*hour/24),
]
# note sin/cos: raw 'hour' says 23 and 0 are 23 apart.
# the model has no way to know they are adjacent.Why must target encoding be computed out of fold? Replacing a category with the mean target for that category uses only training data and looks entirely legitimate. It is one of the most reliable ways to destroy a model, and the reason is precise.
- 1Target encoding replaces category c with the mean of y over rows having that category. For a row i in category c, that mean is computed from a set that includes row i itself.forced by · the naive implementation groups the whole training frame, and row i is in the frame
- 2So the feature value for row i contains a contribution from yᵢ. The model is being handed a partially-decoded copy of the answer it is asked to predict.forced by · the mean of a set is a function of every element, including the one you are predicting
- 3The severity scales inversely with group size. For a category appearing 10,000 times, one row's contribution is negligible. For a category appearing once, the encoded value equals yᵢ exactly — the feature is the label.forced by · the self-contribution to a mean is 1/n of it
- 4High-cardinality columns are exactly where target encoding is most attractive and where rare categories are most common. So the technique leaks hardest precisely in the situation that motivated using it.forced by · cardinality and small group sizes are the same phenomenon viewed from two sides
- 5Therefore the encoding for a row must be computed from data excluding that row — out-of-fold, leave-one-out, or from a prior time window — and shrunk toward the global mean in proportion to group size, so a category seen twice is not trusted like one seen ten thousand times.forced by · the only way to break the dependence on yᵢ is to exclude row i from the statistic that produces its feature
Therefore any feature built from the target must be constructed under the same information constraint the model will face at prediction time: it may see other rows' labels, never its own.
And note what this predicts: the symptom must be a suspiciously excellent cross-validation score that collapses in production, with the gap growing as cardinality grows. It also predicts the identical failure for any aggregate computed on the full dataset before splitting — a global mean for imputation, a scaler fitted on all rows, a count of future events. All of them leak by the same mechanism, which is why fitting transformations inside a Pipeline within each CV fold is not stylistic tidiness but the actual defence.
A model can only express patterns that are simple in the coordinate system you hand it. A tree cuts perpendicular to axes; a linear model draws hyperplanes. Feature engineering is choosing coordinates in which the true relationship becomes an axis cut or a straight line.
Ratios, differences, time-since-last-event, aggregates over a group, cyclic sin/cos pairs, and log transforms of skewed quantities are all the same move: rotating the problem until the signal lies along an axis the model can actually reach. A model does not "fail to learn" the ratio of two columns — it fails to represent it cheaply, and so spends its capacity approximating something you could have written down.
- Encode what the model cannot construct: ratios and differences (trees cannot divide), cyclic time (sin/cos), time-since-event, and group-relative values (x minus its group mean).
- Every feature must be computable at prediction time with only the information available then. If it needs the future or the label, it is leakage regardless of how good the CV score looks.
- Fit every transformation — scalers, imputers, encoders, target statistics — inside the CV fold, never on the full dataset. Use a Pipeline so this is structural rather than remembered.
- Missingness is often signal, not absence. Add an explicit
was_missingindicator alongside any imputation; the fact that a field was blank frequently predicts more than its imputed value.
Fire this the moment you see: a raw timestamp fed to a model · a CV score that seems too good · a scaler fitted before train_test_split · target encoding without folds · a categorical column with thousands of levels one-hot encoded · a feature whose name contains "total" or "final" in a model predicting an in-progress outcome.
You have a categorical feature with thousands of distinct values — merchant ID, product SKU, tenant. One-hot, target encoding, or a learned embedding?
Start with frequency encoding or grouping the long tail into an "other" bucket — both are trivial, leak-free, and frequently capture most of the available signal. Then try out-of-fold target encoding with smoothing, and measure the gain honestly against that baseline. Reach for embeddings only when you are already in a neural stack, since introducing one solely to encode a category is a very large infrastructure bill for a tabular problem a booster would win.
Whatever you pick, the discipline that matters more than the choice: build it inside a Pipeline so it is fitted per fold, and write down what happens at inference for a category the model has never seen. Unseen-category handling is where feature engineering most often breaks in production, and it is invisible in every offline evaluation because your test set was drawn from the same period as your training set.
(c) Hands-on · 25 min
Build a leakage-proof preprocessing pipeline on the Titanic dataset (small, mixed types, real missings) and compare "naive" vs "engineered" scoring. Save as feature_lab.py, uv run feature_lab.py.
"""feature_lab.py — a leakage-proof feature engineering pipeline on Titanic.
Compares:
A. Naive: drop non-numeric columns, forget scaling, no imputation.
B. Engineered: ColumnTransformer with imputers, scaler, one-hot.
Same model in both cases; the delta is entirely from feature engineering.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score, StratifiedKFold
from sklearn.datasets import fetch_openml
RNG = 42
# ---------------- data ----------------
def load_titanic() -> tuple[pd.DataFrame, pd.Series]:
ds = fetch_openml("titanic", version=1, as_frame=True, parser="auto")
df = ds.frame.copy()
y = (df["survived"].astype(int))
X = df.drop(columns=["survived", "name", "ticket", "cabin", "boat", "body", "home.dest"])
# Ensure clean dtypes
X["age"] = pd.to_numeric(X["age"], errors="coerce")
X["fare"] = pd.to_numeric(X["fare"], errors="coerce")
return X, y
# ---------------- naive baseline ----------------
def naive_score(X: pd.DataFrame, y: pd.Series) -> float:
"""Drop non-numeric, drop NaN rows, no scaling. The Bad Way."""
Xn = X.select_dtypes(include=[np.number]).dropna()
yn = y.loc[Xn.index]
clf = LogisticRegression(max_iter=2000)
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=RNG)
return cross_val_score(clf, Xn, yn, cv=cv, scoring="roc_auc").mean()
# ---------------- engineered pipeline ----------------
def engineered_pipeline() -> Pipeline:
numeric_cols = ["age", "fare", "sibsp", "parch"]
categorical_cols = ["sex", "embarked", "pclass"]
numeric_pipe = Pipeline(steps=[
("impute", SimpleImputer(strategy="median")),
("scale", StandardScaler()),
])
categorical_pipe = Pipeline(steps=[
("impute", SimpleImputer(strategy="constant", fill_value="__MISSING__")),
("onehot", OneHotEncoder(handle_unknown="ignore", sparse_output=False)),
])
pre = ColumnTransformer(transformers=[
("num", numeric_pipe, numeric_cols),
("cat", categorical_pipe, categorical_cols),
], remainder="drop", verbose_feature_names_out=False)
return Pipeline(steps=[
("pre", pre),
("clf", LogisticRegression(max_iter=2000)),
])
def engineered_score(X: pd.DataFrame, y: pd.Series) -> float:
pipe = engineered_pipeline()
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=RNG)
return cross_val_score(pipe, X, y, cv=cv, scoring="roc_auc").mean()
# ---------------- inspect what came out of the transformer ----------------
def inspect_features(X: pd.DataFrame, y: pd.Series) -> None:
pipe = engineered_pipeline()
pipe.fit(X, y)
pre = pipe.named_steps["pre"]
feat_names = pre.get_feature_names_out()
Xt = pre.transform(X.head(3))
df_view = pd.DataFrame(Xt, columns=feat_names, index=X.head(3).index)
print("\nFirst 3 rows after preprocessing:")
print(df_view.round(2).to_string())
if __name__ == "__main__":
X, y = load_titanic()
print(f"Loaded: {X.shape[0]} rows, {X.shape[1]} raw columns, missing per col:")
print(X.isna().sum().to_string())
naive = naive_score(X, y)
eng = engineered_score(X, y)
print(f"\n5-fold ROC-AUC:")
print(f" naive : {naive:.3f}")
print(f" engineered : {eng:.3f}")
print(f" Δ : {eng - naive:+.3f}")
inspect_features(X, y)Anatomy of the script
Anatomy of the script
Add a "cheating" version that fits the scaler on ALL of X before cross-validation:
def cheating_score(X, y):
from sklearn.preprocessing import StandardScaler
Xn = X.select_dtypes(include=[np.number]).fillna(X.median(numeric_only=True))
Xn = StandardScaler().fit_transform(Xn) # <-- fitted on full X!
clf = LogisticRegression(max_iter=2000)
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=RNG)
return cross_val_score(clf, Xn, y, cv=cv, scoring="roc_auc").mean()Run all three and compare. The cheating score will beat the engineered score by a small but visible margin — that gap is the amount of test signal you leaked. Now imagine the leak is 5% on a real model. That's how models die in production.
(d) Production reality · 15 min
A recurring pattern: participants apply TargetEncoder on the full dataset before doing cross-validation. The training folds see target values from the validation fold via the encoded feature. Validation score is spectacular. Public leaderboard is fine. Private leaderboard collapses by 20 percentage points.
TargetEncoder with built-in leave-one-out / smoothing. Even easier: use sklearn's new TargetEncoder (1.3+) which does this correctly by default.Early on, different Uber teams computed the same feature (‘rides in the last 7 days’) slightly differently in training vs online scoring. Model trained on the ‘correct’ definition; production served the ‘buggy’ one. Fraud model quietly lost 12% recall over three months before anyone noticed.
Michelangelo introduced a shared feature store: every feature is defined once, computed by one job for training (batch) and by a second job for online serving (streaming), with a contract test that they produce the same values on a held-out sample.
This is now standard architecture (Feast, Tecton, Feathr, Vertex Feature Store).
Point-in-time feature computation: every feature value used for a training example must be as-of the moment of that example, never later. Feature stores enforce this with time-travel queries.
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
If you can't teach these three to a friend without notes, redo the session:
- What is data leakage and how does a sklearn Pipeline prevent it?
- When do you scale features, and when is scaling a no-op?
- What is a feature store, and what problem does it solve?
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.