Search Tech Journey

Find topics, journeys and posts

6-month learning plan93 / 130
back to blog
mlintermediate 55m read

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.

🤖Machine LearningM11 · Classical ML· Session 093 of 130 90 min

🎯 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.

You will be able to
  • 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

Cooking prep for a professional kitchen
🌍 Real world

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.

💻 Code world

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

Every preprocessing tool exists for one of these
  • 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

  1. 1970s
    Statisticians formalise dummy variables
    One-hot encoding predates ML by decades — regression textbooks called them ‘dummy variables’.
  2. 1980s
    Ordinal encoding for tree-friendly models
    Decision trees don't care about magnitudes, so simple integer labels work.
  3. 2001
    Target encoding · Micci-Barreca
    Replace a category by its mean target value. Powerful, dangerous — leaks target signal if done without care.
  4. 2013
    Feature hashing · Vowpal Wabbit
    Hash categoricals to a fixed-size vector. Enables online learning on high-cardinality features (URLs, user IDs).
  5. 2018
    sklearn ColumnTransformer
    Finally, 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

1ordinal?
Is the categorical ordered?

Low/Medium/High → OrdinalEncoder with explicit order. XS/S/M/L/XL → same.

2onehot?
Cardinality low (< ~20)?

One-hot encoding. Adds one binary column per category. Simple, safe, interpretable.

3targetenc?
Cardinality high (100s to 1000s)?

TargetEncoder (with CV to avoid leakage) OR feature hashing. One-hot would blow up the feature space.

4hash?
Cardinality huge (millions — URLs, user IDs)?

Feature hashing to a fixed size (2^18 buckets, say). Accept some collisions.

5trees?
Tree-based model?

OrdinalEncoder is often fine even for unordered categoricals — trees split on values, not distances.

The scaler zoo

StandardScaler

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
MinMaxScaler

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
RobustScaler

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
No scaler

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

SimpleImputer(strategy='median')
Numeric default. Robust to outliers, defensible in a code review.
numeric
SimpleImputer(strategy='most_frequent')
Categorical default. Fills with mode. Cheap and interpretable.
categorical
SimpleImputer(strategy='constant', fill_value='__MISSING__')
Better for categoricals when missing = information (e.g. optional survey field). Creates a real ‘missing’ category the model can use.
explicit
IterativeImputer / KNNImputer
Model-based — predicts missing values from other columns. More powerful, harder to defend, slower.
advanced
MissingIndicator
Adds a boolean flag column for ‘was this originally missing?’. Combine with any strategy above. Recovers information that imputation destroys.
signal

Common misconception
✗ What most people think

"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."

✓ What is actually true

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.

Why the myth is so sticky

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.

Prove it to yourself

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.
From first principles
Start with the question

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.

  1. 1
    Target 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
  2. 2
    So 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
  3. 3
    The 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
  4. 4
    High-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
  5. 5
    Therefore 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

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.

Mental modelMake the pattern visible in the model's coordinate system

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_missing indicator alongside any imputation; the fact that a field was blank frequently predicts more than its imputed value.
🔔 Fires when you see

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.

The tradeoff

You have a categorical feature with thousands of distinct values — merchant ID, product SKU, tenant. One-hot, target encoding, or a learned embedding?

One-hot encoding
+ you gain completely transparent, no leakage risk, no fitted statistics to version or recompute, and each coefficient remains individually interpretable; unseen categories at inference are simply all-zeros, which is a well-defined and safe behaviour
− you pay dimensionality equals cardinality, so the feature space explodes and each column becomes extremely sparse — most levels have too few rows to estimate anything reliably. Tree models suffer particularly: a split on one sparse binary column isolates a handful of rows, so the model is pushed toward memorisation.
pick when cardinality is genuinely low (roughly under 20–50 levels) and you want interpretability with zero leakage surface
Target / mean encoding
+ you gain collapses any cardinality into a single dense numeric column that is directly meaningful to the model, and it often delivers the largest single accuracy jump available on high-cardinality tabular data — which is why it dominates tabular competitions
− you pay the leakage risk derived above is real and easy to get wrong; it requires out-of-fold computation plus smoothing, which complicates the pipeline and means training and serving code must agree exactly; and it needs an explicit fallback for categories unseen at training time
pick when cardinality is high, the category genuinely carries signal, and you have a Pipeline discipline strong enough to keep the encoding inside the fold
Learned embedding
+ you gain learns a dense low-dimensional vector per category jointly with the task, so similar categories end up nearby and rare ones borrow strength from the geometry; it also transfers — the same embedding table can serve several downstream models
− you pay requires a neural model and the training infrastructure that comes with it, which is a large step up from a booster; needs substantially more data per category to learn anything meaningful; and it is opaque, so you lose the ability to explain what a category contributed
pick when you are already training a neural network, have high volume per category, or need the representation reused across multiple tasks
What a senior engineer actually does

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

Line 26 · fetch_openml('titanic')
Uses openml because sklearn's built-in datasets don't include titanic. The real dataset — mixed types, real missings.
data
Line 37 · naive_score
The wrong way, on purpose. Drops non-numeric columns (loses ‘sex’, the single strongest feature) and drops rows with NaN (loses ~20% of the data).
baseline
Line 46 · numeric_pipe = Pipeline(...)
Impute → scale, in order. Each step's fit sees only the training fold when nested inside cross_val_score.
core
Line 51 · OneHotEncoder(handle_unknown='ignore')
Critical for production: an unseen category at inference time becomes all-zeros instead of crashing. Never omit this argument.
prod
Line 55 · ColumnTransformer(..., remainder='drop')
Explicit: any column not listed is dropped. Alternative 'passthrough' keeps unlisted columns as-is — usually a bug waiting to happen.
safety
Line 60 · Pipeline([('pre', ...), ('clf', ...)])
One artifact. pipe.fit → pipe.predict → pickle.dump(pipe, f). The whole preprocessing lives inside the model file.
core
Line 71 · cross_val_score(pipe, X, y, cv=cv)
This is where leakage prevention actually happens: for each fold, pipe.fit is called on the training rows only, so the scaler + encoder see only that fold's statistics.
safety
Line 77 · pre.get_feature_names_out()
Recovers the human-readable names after one-hot expansion — essential for coefficient inspection or SHAP.
debug
Try itProve the leakage-prevention magic of Pipeline

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.

💡 Hint · Bad way and good way produce different scores. The bad way's score is always optimistic — it saw the test statistics.

(d) Production reality · 15 min

War story Kaggle · common failure mode across competitions· 2019thousands of participants per competition
🔥 What broke

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.

🧯 The fix
Target encoding must be fit inside each CV fold, or use category_encoders' TargetEncoder with built-in leave-one-out / smoothing. Even easier: use sklearn's new TargetEncoder (1.3+) which does this correctly by default.
🎓 Lesson to steal
Any transformer that touches the target (target encoding, feature selection based on correlation, PCA whitening if labels are used) MUST live inside a Pipeline and be fit per fold. Never fit once on the full data ‘to save time.’
Post-mortem
War story Uber Engineering — Michelangelo feature platformthousands of features across hundreds of models
🔥 What broke

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.

🧯 The fix

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).

🎓 Lesson to steal
The single largest source of production ML bugs is training/serving skew: the feature was computed one way at training time and a different way at serving time. A shared feature definition + contract test is the only durable fix.
Post-mortem
War story Airbnb — search rankingbillions of listings scored
🔥 What broke
An engineer added ‘average price of listings booked in the last 30 days’ as a feature. Score jumped. Turned out the feature was computed including the currently-being-scored listing at training time (which had already been booked in the training window), but obviously not at serving time (the listing hasn't been booked yet — that's what we're predicting).
🧯 The fix

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.

🎓 Lesson to steal
Time-based leakage is invisible until deployment. Rule: for any time-series or event-based data, the feature computation must respect the temporal boundary of the example it's attached to. If you can't defend it in a code review with a timeline diagram, you have a leak.

Where this shows up in the rest of the plan

Feature engineering is the shared substrate of every subsequent ML session
S094 · Imbalanced data
Resampling techniques all sit inside the same pipeline pattern.
S095 · Model selection & CV
Cross-validation only prevents leakage if the pipeline includes preprocessing — that's this session.
S104 · Feature stores in production
Same pipeline, split into offline (training) and online (serving) halves with contract tests.
S117 · Tokenisation for LLMs
Text preprocessing is its own encoder — BPE/WordPiece — but the pipeline discipline is identical.
S128 · MLOps monitoring
Feature drift dashboards watch the distribution of the transformed columns you build here.
S130 · Capstone
Your final pipeline is one pickle-able artifact — the pattern from today.

(e) Recall + stretch · 10 min

Quick recall · click to reveal
★ = stretch question

Explain-out-loud test

If you can't teach these three to a friend without notes, redo the session:

  1. What is data leakage and how does a sklearn Pipeline prevent it?
  2. When do you scale features, and when is scaling a no-op?
  3. 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.