learn-feature-engineering-with-phoebe / Practitioner session 3 of 10
Learn Feature Engineering with Phoebe · Practitioner track · Session 3 of 10

Categorical encoding

Encoding was the single biggest jump on b1's ladder - held-out AUC from 0.696 to 0.785 - because it hands the model columns it previously could not read at all. Tonight you learn one-hot properly, including the parameters that keep it from exploding, and then you meet target encoding: the technique that solves high cardinality and, done naively, manufactures a training AUC of 0.931 out of a column that knows absolutely nothing.

🟡 Practitioner track DS · analysts · ML engineers Python · live workbench on key pages 45 min
0-3 · Welcome 3-18 · One-hot and its limits 18-42 · Build-along: high cardinality without leaking 42-45 · Q&A
Part 1 · covers Google MLCC "Working with categorical data" + sklearn OneHotEncoder

Why not just number the categories 8 min live

The tempting shortcut is to map channel to 0 through 8 and move on. Google's crash course explains the damage in one sentence, and it is worth reading slowly: "the model would treat the indexed values as continuous floating-point numbers. The model would then consider 'purple' six times more likely than 'orange.'"

An index is not a label. It is a number with an order and a magnitude, and a linear model will happily fit a single weight to it - meaning your model now believes affiliate traffic is exactly nine times whatever email is. Nothing about your data said that. You said it, accidentally, when you chose the integers.

One-hot removes the arithmetic. Nine categories become nine binary columns, exactly one of which is 1 for any given row. No order, no magnitude, one weight learned per category independently.

channel · 9 levels email paid_search organic direct social affiliate + sms, display, referral One-hot: 9 binary columns, exactly one is 1 eml 1 pdS 0 org 0 dir 0 soc 0 aff 0 sms 0 dsp 0 ref 0 [1, 0, 0, 0, 0, 0, 0, 0, 0] is the row for channel = email No order is implied, so no category can be "more" than another Rare-level grouping: min_frequency = 0.01 sms · display · referral each under 1% of all rows infrequent_sklearn one column replaces three 9 becomes 7 cols min_frequency and max_categories fold rare levels; np.nan and None stay separate categories.
🔍 Click to zoom - nine levels out, nine binary columns back, three of them folded

sklearn's OneHotEncoder carries the parameters that turn this from a demo into something production-safe. These five are the ones you will actually set.

ParameterWhat it doesWhy you set it
handle_unknownerror · ignore · infrequent_if_existA category appears in production that was not in training. error crashes, ignore emits all zeros, infrequent_if_exist routes it to the rare bucket
min_frequencyAn integer count or a fraction of rowsLevels below the threshold are grouped into one infrequent category instead of getting their own barely-populated column
max_categoriesA hard ceiling on output columnsCaps the blast radius. Note the docs' fine print: "max_categories includes the feature that combines infrequent categories"
drop'first' or 'if_binary', giving n-1 columns"Useful to avoid co-linearity" in linear models, where the full set of dummies is perfectly redundant
sparse_outputReturn a sparse matrix or a dense arraySparse is the default and the right choice at scale - and it is why b2's MaxAbsScaler advice exists
LiveThe gotcha that costs an afternoon3 min▶

np.nan and None are treated as separate categories by OneHotEncoder. If your pipeline reads some rows from a CSV (missing values arrive as np.nan) and others from a JSON API (missing values arrive as None), you will silently produce two different "missing" columns describing the same absence, each with half the data and half the statistical power.

The fix is dull and reliable: normalize your nulls to one representation before the encoder ever sees them. Do it in the ingest step, not in the model code, so every downstream consumer inherits the same convention.

Real world

Missing is often signal, not absence. A Lumen session with no channel is usually direct traffic that failed attribution, which is a meaningfully different customer from one that clicked a paid ad. Treating that null as its own category is frequently the right call rather than a failure to impute - which is exactly the argument b7 makes at full length.

Self-studyWhen to drop a column, and when not to3 min read▶

drop='first' removes one dummy per feature, leaving n-1 columns. sklearn's stated reason is that it is "useful to avoid co-linearity in the input matrix" - with all n dummies present, any one of them is exactly one minus the sum of the others, and an unregularized linear model has no unique solution.

  • Drop for plain linear or logistic regression when you intend to read the coefficients. Each remaining weight becomes "relative to the dropped baseline category", which is usually what you wanted to say anyway.
  • Do not drop for tree-based models, where collinearity is not an issue and dropping a level just makes one category harder to isolate with a single split.
  • Do not drop when you are using L1 or L2 regularization, which handles collinearity on its own and where the penalty applies asymmetrically once a baseline is implicit.
  • drop='if_binary' is the tidy middle ground: it drops only for two-level features, where the second column is pure redundancy no matter what model you use.
Part 2 · covers Google MLCC dimensionality + Rules of ML #21

The high-cardinality problem 6 min live

One-hot works beautifully at nine levels. It stops working somewhere before a thousand, and Google names the failure directly: "Categorical data tends to produce high-dimensional feature vectors... High dimensionality increases training costs and makes training more difficult."

This course adds postal_code to the Lumen case precisely to make the problem concrete. It has roughly 1,200 levels across 40,000 rows. One-hot encoding it adds 1,200 columns, most of which are 1 in a few dozen rows and 0 in every other row. The model is now being asked to learn 1,200 weights from evidence that, for most of them, amounts to a handful of observations.

Rules of ML #21, the sentence that sizes the problem "The number of feature weights you can learn in a linear model is roughly proportional to the amount of data you have." 40,000 rows does not support 1,200 well-estimated postal-code weights. Every column you add is a claim on the same fixed budget of evidence.
OptionHow it handles 1,200 levelsCost
Rare groupingmin_frequency folds everything below a threshold into one infrequent columnSimple and safe. You lose the identity of every small area
HashingHash each level into a fixed number of buckets, say 64No fitting and no vocabulary to store, but collisions are silent and irreversible
Target encodingReplace each level with a smoothed statistic of the target for that level - one column totalPowerful. Leaks the target unless you cross-fit. This is Part 3
EmbeddingsLearn a dense low-dimensional vector per levelBest on very high cardinality with real structure. Needs more data and more machinery (b9)
Self-studyRare grouping is underrated3 min read▶

Before reaching for anything clever, try the boring option and measure it. Setting min_frequency=0.01 on postal_code keeps only the areas that appear in at least 1% of sessions and folds the rest into a single bucket. Twelve hundred columns typically become a few dozen, and the model gets to learn each of those few dozen weights from a real sample rather than a rumour.

Two practical benefits people forget:

  • It gives you a natural landing zone for unseen categories in production. Pair it with handle_unknown="infrequent_if_exist" and a postal code that never appeared in training is routed to the infrequent bucket rather than crashing or vanishing into all-zeros.
  • It is fully reversible and fully explainable. You can show a stakeholder exactly which levels were merged. Neither hashing nor an embedding lets you do that.
Part 3 · covers sklearn TargetEncoder + Pargent et al. 2021

Target encoding, done right 10 min live

Target encoding replaces a category with a statistic of the target for that category: postal code 048624 becomes the conversion rate of sessions from 048624. One column instead of 1,200. The idea is obvious and the implementation is full of trapdoors, which is why sklearn documents the maths rather than hiding it.

For a binary target, each level i is encoded as a blend of its own rate and the global rate:

The formulaS_i = lambda_i * (n_iY / n_i) + (1 - lambda_i) * (n_Y / n) lambda_i = n_i / (m + n_i) m = the smoothing factor n_i = rows in category i n_iY = positive rows in category i n = total rows n_Y = total positive rows smooth="auto" uses an empirical Bayes estimate: m = sigma_i^2 / tau^2

Read lambda_i as a confidence dial. A category with thousands of rows has lambda_i close to 1, so it is encoded as its own rate. A category with three rows has lambda_i close to 0, so it is dragged back toward the global mean where it can do less damage. sklearn states the effect in one line: "Large smoothing factors put more weight on the global mean."

Real world

sklearn's own demonstration of the dial. On the same three categories, smooth=5000 produces encodings of [44.1, 44.4, 44.3] - the categories have been smoothed into near-identity with the global mean, and the feature carries almost nothing. smooth=1.0 on the identical data produces [21, 80.8, 43.2] - the raw category rates, barely regularized, and now trusting three separate small samples completely. Neither extreme is the answer; the parameter is the whole game.

The smoothing dial, on sklearn's own three categories global mean smooth = 5000 44.1 · 44.4 · 44.3 near-identity with the global mean smooth = 1.0 21 43.2 80.8 the raw category rates, barely regularized, trusting three small samples completely 0 25 50 75 100 sklearn: "Large smoothing factors put more weight on the global mean." Neither extreme is the answer - lambda is a confidence dial, and the parameter is the whole game.
🔍 Click to zoom - smoothing decides how much a small category is allowed to say

Now the part that matters more than the formula. If you compute each category's rate from the same rows you then train on, every row has contributed to its own encoding. The feature contains a whisper of the answer, and the model hears it.

Cross-fitting, in sklearn's words "In fit_transform, the training data is split into k folds (determined by the cv parameter) and each fold is encoded using the encodings learnt using the other k-1 folds." The stated purpose: to prevent target information from leaking into the train-time representation, especially for non-informative high-cardinality categorical variables.

Which produces the single most surprising line in the sklearn preprocessing docs, and the one to say out loud in your next code review: fit(X, y).transform(X) does not equal fit_transform(X, y). These are different operations by design. fit_transform cross-fits; fit followed by transform does not, and the docs explicitly discourage using that pair on training data.

Python · right and wrongfrom sklearn.compose import ColumnTransformer from sklearn.pipeline import Pipeline from sklearn.preprocessing import TargetEncoder, OneHotEncoder from sklearn.linear_model import LogisticRegression # RIGHT: inside a Pipeline. Pipeline.fit calls fit_transform on the training # fold, so the cross-fitting happens; test rows go through transform only. prep = ColumnTransformer([ ("high_card", TargetEncoder(smooth="auto", cv=5), ["postal_code"]), ("low_card", OneHotEncoder(min_frequency=0.01, handle_unknown="infrequent_if_exist"), ["channel", "device", "product_category"]), ]) model = Pipeline([("prep", prep), ("clf", LogisticRegression(max_iter=1000))]) model.fit(X_train, y_train) # WRONG: fit then transform on the SAME training rows. No cross-fitting. # Every row helped build its own encoding, and the docs discourage this. enc = TargetEncoder().fit(X_train, y_train) X_bad = enc.transform(X_train) # NOTE: shuffle and random_state are deprecated since sklearn 1.9. Do not # reach for them; cv and smooth are the parameters that matter.
Self-studyWhat the peer-reviewed evidence says3 min read▶

Target encoding has a reputation as a Kaggle trick, which undersells it. Pargent, Pfisterer, Thomas and Bischl (2021) benchmarked encoding strategies for high-cardinality categorical features across many datasets and models, and found that "regularized versions of target encoding... consistently provided the best results" compared with integer and one-hot encoding.

Note the word doing the work: regularized. The finding is not "target encoding wins". It is "target encoding with the smoothing and the cross-fitting wins". The unregularized version is the thing you are about to watch fail in the workbench, and the paper is not endorsing it.

This is also the honest answer to "should I always use target encoding?" - no. Use one-hot with rare grouping when cardinality is modest and you want interpretability. Reach for a regularized target encoder when one-hot would inflate the feature space beyond what your row count can support.

Build-along

Watch a useless column score 0.931 ★ 14 min · everyone runs it

The workbench below target-encodes Lumen's postal_code two ways on the same rows. Read the setup carefully before you press anything, because the setup is the whole point.

The setup. postal_code has 1,200 levels and is drawn independently of the target. It knows nothing. There is no signal in it to find, by construction - like sklearn's own near_unique demo column.

Naive encoding - fit the per-level rates on all training rows, then reuse them on those same rows: training AUC 0.931, held-out 0.493. A column containing no information just produced a training score you would celebrate.

Cross-fitted - each fold encoded using the encodings learnt from the other folds, which is what TargetEncoder.fit_transform does: training 0.509, held-out 0.493. The held-out number did not move. The training number told the truth for the first time.

Sit with the gap. Naive: 0.931 train against 0.493 held-out. Cross-fitted: 0.509 against 0.493. Cross-fitting did not improve the model - it improved your ability to see that there was never a model here at all.

sklearn's own numbers, at scale. On 50,000 rows with a near-unique category column, their documented example reports test R² of 0.634 without cross-fitting versus 0.793 with it. Their docs call the first case "catastrophic overfitting" - not sloppy, not suboptimal. Catastrophic.

The same useless column, encoded two ways postal_code has 1,200 levels and is drawn independently of the target - it knows nothing NAIVE · FITTED ON ALL TRAINING ROWS, THEN REUSED training AUC 0.931 a training score you would celebrate held out 0.493 CROSS-FITTED · EACH FOLD ENCODED FROM THE OTHERS training AUC 0.509 held out 0.493 The held-out number never moved. Only the training number stopped lying. sklearn's own example: test R2 0.634 without cross-fitting against 0.793 with it.
🔍 Click to zoom - cross-fitting did not improve the model, it improved your view of it
The habit this session installs Any time a categorical feature makes the training score jump, ask how its encoding was fitted before you ask what it means. A suspiciously good training score on a high-cardinality column is a leakage symptom until proven otherwise - and b5 is where that instinct becomes a full method.
Homework

Try it yourself - this week ◐ 25-35 min total

Source material

Official sources covered

Tonight covers Google's categorical-data module for the reasoning and sklearn's encoder documentation for the mechanics, plus the peer-reviewed benchmark that backs the regularized approach. Certificates and graded exercises stay with the official providers. This page covers:

✓Google MLCC "Working with categorical data" - indexing, one-hot, dimensionalityParts 1-2 · feature crosses land in b6
✓sklearn OneHotEncoder - handle_unknown, min_frequency, max_categories, dropPart 1 · plus the np.nan versus None gotcha
✓sklearn TargetEncoder + the internal cross-fitting examplePart 3 · the formula, the smoothing dial, the 0.634 versus 0.793 result
◐Pargent, Pfisterer, Thomas, Bischl (2021) - regularized target encoding benchmarkThe evidence that regularization is the load-bearing part
✓fe-live encode canon - postal_code naive versus cross-fittedTrain 0.931 / held-out 0.493 against 0.509 / 0.493
Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · Why is mapping channel to the integers 0 through 8 the wrong move?

Google's example says it exactly: the model "would then consider 'purple' six times more likely than 'orange.'" You did not observe that ordering in the data - you invented it when you picked the integers.

2 · What does cross-fitting inside TargetEncoder.fit_transform actually prevent?

The training data is split into k folds and "each fold is encoded using the encodings learnt using the other k-1 folds". Without it, a column with no signal at all scored a training AUC of 0.931 in tonight's workbench, and sklearn's own 50,000-row example dropped from test R² 0.793 to 0.634 - which their docs call "catastrophic overfitting".

3 · When does target encoding beat one-hot encoding?

Rules of ML #21 sets the budget: learnable weights are "roughly proportional to the amount of data you have". Pargent et al. found "regularized versions of target encoding... consistently provided the best results" - regularized being the operative word. At nine levels, one-hot with rare grouping is simpler and more explainable.

Practitioner session 3 cheat sheet · pin this

Never index categories"The model would treat the indexed values as continuous floating-point numbers... consider 'purple' six times more likely than 'orange.'"
OneHotEncoder paramshandle_unknown (error / ignore / infrequent_if_exist) · min_frequency · max_categories · drop='first'|'if_binary' · sparse_output.
Two gotchasnp.nan and None are SEPARATE categories. And "max_categories includes the feature that combines infrequent categories."
High cardinality"High dimensionality increases training costs and makes training more difficult." Lumen's postal_code: 1,200 levels on 40,000 rows.
Rules of ML #21"The number of feature weights you can learn in a linear model is roughly proportional to the amount of data you have."
The formulaS_i = lambda_i * (n_iY / n_i) + (1 - lambda_i) * (n_Y / n), lambda_i = n_i / (m + n_i). smooth="auto" is empirical Bayes, m = sigma_i^2 / tau^2.
The smoothing dial"Large smoothing factors put more weight on the global mean." smooth=5000 → [44.1, 44.4, 44.3]. smooth=1.0 → [21, 80.8, 43.2].
Cross-fitting or bustfit(X, y).transform(X) does NOT equal fit_transform(X, y). Naive 0.931 train / 0.493 held-out; cross-fitted 0.509 / 0.493. shuffle and random_state deprecated since 1.9.