Part VII — Model Evaluation, Validation & Feature Engineering · Chapter 6

Encoding categorical variables

Hook

A model only understands numbers. So what do you do with a column like city, whose values are "NYC", "LA", "SF" — and why does the most tempting fix quietly hand the model the answer key?

Intuition
CityLabel yEncoded
NYC11
LA00
NYC11
SF12
LA00
NYC01

Same six rows, same city column, three different ways to turn it into numbers. Ordinal encoding just assigns each city an integer. One-hot spreads it into a binary column per city. Naive target encoding replaces each city with the average outcome for that city — watch the lone SF row especially closely as you switch to it.

Formalize

Three ways to turn a category into numbers, each with a different failure mode:

ordinal(c)=index of c,target(c)=1{i:ci=c}i:ci=cyi\text{ordinal}(c) = \text{index of } c, \qquad \text{target}(c) = \frac{1}{|\{i : c_i = c\}|}\sum_{i \,:\, c_i = c} y_i
  • cc — a category value, e.g. "SF".
  • index of cc — ordinal encoding's arbitrary position for that category in some fixed list.
  • yiy_i — the label of row ii.
  • target(c)(c) — the mean label among rows sharing category cc; naive target encoding includes the current row in that average, leave-one-out excludes it.
  1. Ordinal encoding invents an ordering

    Mapping LA→0, NYC→1, SF→2 makes SF numerically "greater than" NYC, which means nothing — a model that uses distance or magnitude will treat that fake ordering as real information.

  2. One-hot encoding avoids false order, at the cost of width

    Three categories become three binary columns, each independent. No fake ordering, but a column per category — fine for a handful of cities, unworkable for a column with thousands of distinct values.

  3. Target encoding is compact, and dangerous by default

    Compressing a category down to "the average outcome for rows like this" packs in real signal in one column — but if that average is computed using the row's own label, the row is handed a feature derived from the very thing you're trying to predict.

Play
CityLabel yEncoded
NYC10.667
LA00.000
NYC10.667
SF11.000
LA00.000
NYC00.667

Look at the SF row: it's the only row with that city, so naive target encoding's "average outcome for SF" is an average of exactly one number — its own label. The encoded value and the true label become the same number. Leave-one-out encoding excludes the current row before averaging, so a singleton category has no other rows left to average — it falls back to the global mean instead of leaking.

Worked example

Six rows: NYC,1 LA,0 NYC,1 SF,1 LA,0 NYC,0 — global mean label =3/6=0.5= 3/6 = 0.5:

  1. NYC appears three times: 1, 1, 0

    Naive target encoding for every NYC row is the same number, 1+1+03=0.667\tfrac{1+1+0}{3} = 0.667 — safe, since no single row dominates the average.

  2. SF appears exactly once: label 1

    Naive target encoding averages that one label with itself: 11=1\tfrac{1}{1} = 1 — identical to the row's own label. The "feature" is the answer, restated.

  3. Leave-one-out catches it
    • For the SF row, leave-one-out excludes the row itself — and there are no other SF rows to average, so it falls back to the global mean, 0.50.5.
    • For an NYC row, e.g. the one labeled 00, leave-one-out averages the other two NYC labels (1,11, 1), giving 1.01.0 — a real average of other rows, not a copy of its own answer.
Checkpoint

Switch between naive and leave-one-out target encoding until the SF row's encoded value exactly matches its own label (1) — the sign of the leak.

CityLabel yEncoded
NYC10.500
LA00.000
NYC10.500
SF10.500
LA00.000
NYC01.000
Pick a scheme to try it
Summary
targetnaive(ci) includes yi    leakage, worst for rare categories\text{target}_{\text{naive}}(c_i) \ \text{includes } y_i \implies \text{leakage, worst for rare categories}

One-hot and ordinal encoding never see the label at all, so they can't leak it — their tradeoff is width versus false ordering. Target encoding is the one that touches yy directly, which makes it powerful and risky in the same breath: the fix isn't to avoid it, it's to always exclude each row's own label from its own encoding, exactly the way cross-validation excludes each fold from its own evaluation. Encoded or not, not every column deserves to be in the model at all — the next chapter turns to deciding which features earn their place.