ML Systems Lab Open interactive version →
Intermediate 45 min read encodingone-hottarget encodingordinalcardinality

Categorical Encoding

Convert categories into numbers without lying to your model about distance, order, or information from the target.

You are predicting customer churn, and one of your columns is `city` — with 5,000 different values. Models only eat numbers, so you have to turn those city names into numbers somehow. And *how* you do it turns out to matter a lot, because every method quietly makes a *claim* about the categories, and the wrong claim hurts you.


The obvious way, and why it strains at scale

The default is one-hot encoding: make one yes/no column per city (is it San Francisco? is it Austin? …). For a handful of categories this is perfect. But 5,000 cities means 5,000 near-empty columns, and with 100,000 rows many cities have only a few examples each — so their column is basically noise. The model can "memorise" that San Francisco churned a lot in your training window without learning anything that generalises.


Target encoding: one dense, informative column

A better move at high cardinality is target encoding: replace each city with the average churn rate *of that city*. San Francisco, with plenty of examples, gets a stable estimate; "Smalltown, OH" with three examples gets nudged toward the overall average so its tiny sample cannot run wild. Instead of 5,000 sparse columns you get *one* meaningful number per row — and for tree models this often beats one-hot outright.

But target encoding has a sharp, specific trap: leakage. If you compute a city's average churn using the whole dataset and then feed it back as a feature, each row's encoded value was partly computed from *its own label* — you have handed the model the answer. A city with a single row gets encoded with that row's exact outcome. The fix is to compute the averages *out-of-fold* — each row encoded using only *other* rows — which the `category_encoders` library does for you with one setting.


A quick decision guide

- Low cardinality (under ~15: payment method, device type): one-hot is fine — few columns, no leakage worry. - Medium (15–50): one-hot, unless there is a *real* order (education: high-school < college < grad), in which case ordinal encoding respects it. Do not impose an order where none exists — a linear model reads ordinal integers as literal numeric magnitude, so on an *unordered* feature like city, a category coded 49 is treated as having roughly 49x the weighted effect of a category coded 1, purely because of where it landed in an arbitrary label order, not because of any real signal. One-hot avoids this by giving every category its own independent coefficient. - High (50+: cities, merchants): target encoding (out-of-fold), or frequency encoding. - Very high, with a neural network (user IDs, product IDs): embeddings — the network learns a small dense vector per category, and categories that behave alike end up close together in that space.

One myth to retire: "LightGBM handles categoricals natively, so I can skip all this." Its built-in handling is fine for modest cardinality but degrades past a couple hundred values — for a 5,000-city column, explicit out-of-fold target encoding still wins, and it is one line of code.


Target encoding with smoothing: the actual formula

"Nudge rare categories toward the global average" has a precise form worth carrying. The smoothed encoding for a category is a *count-weighted blend* of its own mean and the global mean:

encoded = (n·category_mean + m·global_mean) / (n + m)

where n is how many rows that category has and m is a smoothing strength. When a category is common (n ≫ m) the encoding ≈ its own mean; when it's rare (n small) it's pulled toward the global mean, so "Smalltown with 3 rows" can't produce a wild estimate. Bigger m means more shrinkage. This smoothing is what makes target encoding safe on long-tailed categories.


The full CV-safe protocol

Getting target encoding right end-to-end has three stages, and mixing them up is the classic bug. For the training set, use out-of-fold encoding (each row encoded from the *other* folds) so no row sees its own label. For the test set (and inference), encode using statistics computed from the *entire* training set — the test rows never contribute to any mean. So: OOF within train, full-train stats applied to test. Fitting one encoder on all the data, or using OOF stats for the test set, both break it.


Frequency and count encoding

A cheaper cousin of target encoding: replace each category with how often it appears (its count or frequency). It's useful precisely when *popularity itself is predictive* — a rare merchant may be riskier than a common one — and it has a nice property target encoding lacks: it doesn't touch the label, so there's no leakage to guard against. It handles high cardinality in one dense column at almost no cost. It's weaker than target encoding when the outcome, not the frequency, is what matters — but it's a strong, safe default to try alongside.


Hashing: cheap and unbounded, but blind

The hashing trick maps categories into a fixed number of buckets via a hash function — so it handles *unbounded* vocabularies (new categories just hash into existing buckets) at fixed memory. The costs: collisions (different categories share a bucket, blurring them together — worse for rare categories that collide with common ones), total loss of interpretability (you can't read a bucket back to a category), and a real choice of bucket count (too few = heavy collisions, too many = sparse). Use it when the vocabulary is huge or streaming and you can tolerate some collision noise, not when you need to explain the model.


Embeddings: dimension, cold start, minimum frequency

Learned embeddings are powerful for neural nets but have their own knobs. Dimension: a common heuristic is min(50, cardinality^0.25 × 4) — bigger for higher-cardinality columns, but too large overfits. (The scaling constant varies by source; fastai's alternative rule of thumb is min(50, (cardinality+1)//2).) Minimum frequency: categories seen only a handful of times can't learn a good vector, so group rare ones into a shared "rare" token. Cold start: a brand-new category at inference has no trained embedding — you need a reserved "unknown" embedding to fall back on. And embeddings need enough data per category and some regularisation or they memorise. They're not free lunch; they're target encoding's expressive, data-hungry sibling.


Native categorical handling differs by library

"The tree library handles categoricals" is true but the *details* differ and matter. LightGBM has native categorical splits (good to a few hundred values). XGBoost added native categorical support more recently. CatBoost is the standout: it uses ordered target statistics — it draws a random permutation of the training rows, then encodes each row's category using only the target values of rows that come *before* it in that permutation, so a row's own label is structurally excluded from its own encoding (unlike plain target encoding, where every row's mean includes its own label unless you explicitly hold folds out) — which is why it often wins on categorical-heavy data with minimal preprocessing. So if your data is dominated by high-cardinality categoricals, CatBoost is worth trying specifically for this reason.


Rare categories and new categories in production

Two operational rules. Rare-category policy: fold categories below a minimum-count threshold into an explicit "Other" bucket rather than trusting three-row estimates. New-category monitoring: production will see categories that never appeared in training, and different encodings fail differently — one-hot has no column for an unseen city, so it silently encodes the row as all-zero; ordinal has no integer assigned to it at all; target encoding, hashing, and embeddings each need an explicit fallback (global mean for target encoding, a hash bucket, an "unknown" embedding). Define that fallback *and* monitor the rate of unseen categories — a rising unknown rate means your encoding is increasingly guessing, and it's an early signal that the category space has drifted and you should retrain.

Key points

Takeaway

Every encoding asserts something about category structure — the wrong assertion is not a preprocessing detail but a false claim the model learns as if it were true, and at high cardinality the wrong choice costs measurable AUC.

Recap

Check your understanding

Q1. You apply ordinal encoding to a 'city' feature with 50 unique values and train a linear regression. What exactly goes wrong?

Q2. Walk through exactly why target encoding without cross-validation causes data leakage.

Q3. At inference time, your model receives a city it has never seen in training. How does each encoding strategy handle this, and which is most robust?

Q4. A feature has 5,000 unique merchant IDs and you are training a neural network. Which TWO of the following are true reasons one-hot encoding is a bad choice here?

Q5. You target-encode a high-cardinality column and a category that appears only twice in training gets an encoded value equal to those two rows' average outcome — a wild, unreliable estimate. What technique tames this, and how does it work?

Q6. Your dataset is dominated by several high-cardinality categorical columns. A colleague suggests CatBoost specifically. What's the technical reason CatBoost is well-suited here?

Try it interactively

ML Systems Lab is a free interview-prep platform for ML engineers — work through the full interactive module, quizzes, and drills.

Open ML Systems Lab →