Python Primer·Chapter 18
Categories and Encoding
The categorical dtype that saves memory and prevents mistakes, and the four ways to turn a category into a number. One of those four leaks the target into your training data, which is why it needs its own section.
A category is a value from a fixed set: a city, a tier, a status, a product line. Models cannot consume text, so at some point every category becomes a number.
How you do that conversion matters more than beginners expect, and one popular method quietly cheats.
The scale of the problem is easy to underestimate. A retailer’s product catalogue is not three categories, it is several hundred thousand, and the naive approach turns that into several hundred thousand columns. Choosing well here is the difference between a model that trains overnight and one that never trains at all.
The Categorical Dtype
Before encoding, store categories properly. Pandas has a dedicated type that keeps one copy of each distinct value and stores integer codes for the rest.
df['tier'] = df.tier.astype('category')
The memory difference is not marginal:
s = pd.Series(['gold', 'silver', 'bronze'] * 100_000)
s.memory_usage(deep=True) # ~18,000,000 bytes
s.astype('category').memory_usage(deep=True) # ~100,000 bytes
Roughly a 180-fold reduction on this column. The rule of thumb: convert any text column where the number of distinct values is small relative to the number of rows.
for c in df.select_dtypes(['object', 'string']):
ratio = df[c].nunique() / len(df)
if ratio < 0.5:
df[c] = df[c].astype('category')
print(f"{c}: {df[c].nunique()} categories")
Ordered categories
Some categories have a natural order, and telling pandas unlocks comparison and sensible sorting:
from pandas.api.types import CategoricalDtype
sizes = CategoricalDtype(['small', 'medium', 'large'], ordered=True)
df['size'] = df['size'].astype(sizes)
df[df['size'] > 'small'] # works, because the order is declared
df.sort_values('size') # small, medium, large — not alphabetical
Without this, sorting gives you large, medium, small, which is alphabetical and useless.
Two traps
Filtering keeps unused categories. The category list is part of the type, not the data.
sub = df[df.tier == 'gold']
sub.tier.cat.categories # still lists silver and bronze
sub.groupby('tier').size() # rows of zero for the absent ones
Fix with observed=True on the groupby, or remove_unused_categories().
Concatenating frames with different category sets produces an object column, silently losing the type. Set the categories explicitly on both sides first.
Encoding for Models
Four approaches. Choosing between them is mostly about how many distinct values you have.
flowchart TD
Q{"How many<br/>distinct values?"}
Q -->|"2"| B["<b>Binary</b><br/>0 / 1"]
Q -->|"a handful,<br/>no order"| O["<b>One-hot</b><br/>a column each"]
Q -->|"a handful,<br/>with order"| R["<b>Ordinal</b><br/>map to 0,1,2..."]
Q -->|"many<br/>(hundreds+)"| T["<b>Target encoding</b><br/><i>leaks — see below</i>"]
style O fill:#eef1fc,stroke:#2141c8
style T fill:#f7e8e8,stroke:#c02020
One-hot encoding
One new column per category, holding 0 or 1. The default for unordered categories.
pd.get_dummies(df, columns=['city', 'tier'], drop_first=False)
For anything going into a model, use the scikit-learn version instead, so the categories are learned from training data and applied consistently:
from sklearn.preprocessing import OneHotEncoder
enc = OneHotEncoder(handle_unknown='ignore', sparse_output=False)
handle_unknown='ignore' decides what happens when production shows you a category training never saw. Without it your service raises; with it, the unseen value encodes as all zeros. Neither is obviously right, but choose deliberately rather than at three in the morning.
drop_first=True removes one column to avoid perfect collinearity. It matters for linear models and ordinary regression. It does not matter for trees, and it makes coefficients harder to interpret, so do not apply it reflexively.
The cost of one-hot is width. Fifty thousand product codes become fifty thousand columns, which is why the next options exist.
Ordinal encoding
Map categories to integers, in a meaningful order:
order = {'small': 0, 'medium': 1, 'large': 2}
df['size_code'] = df['size'].map(order)
Only do this when the order is real. Encoding cities as 0, 1, 2, 3 tells a linear model that Sydney is halfway between Delhi and Tokyo, which is nonsense.
Tree-based models are more forgiving, because they split on thresholds rather than assuming linearity. Arbitrary integer codes often work fine with gradient boosting and badly with regression.
Frequency encoding
Replace each category with how often it appears:
freq = df.city.value_counts(normalize=True)
df['city_freq'] = df.city.map(freq)
One column regardless of cardinality, and genuinely informative when rarity matters, which it often does in fraud and anomaly work.
Target encoding, and why it leaks
Replace each category with the average target value for that category.
# WRONG — and it is not obvious why
means = df.groupby('city').churned.mean()
df['city_encoded'] = df.city.map(means)
This looks reasonable and is a serious mistake. Each row’s encoding was computed using that row’s own target value. The feature contains a smeared copy of the answer.
The consequence: cross-validation scores look excellent, and the model fails in production. Worse, the effect is strongest for rare categories. A city appearing once gets encoded as exactly that row’s outcome, which is the answer, perfectly.
flowchart TB
subgraph BAD["Naive target encoding"]
B1["row's own target"] --> B2["group mean"] --> B3["that row's feature"]
B3 --> B4["the feature contains<br/>the answer"]
end
subgraph GOOD["Out-of-fold encoding"]
G1["split into folds"] --> G2["for each fold:<br/>compute means from<br/>the OTHER folds"]
G2 --> G3["encode this fold<br/>with those means"]
G3 --> G4["no row sees<br/>its own target"]
end
style BAD fill:#f7e8e8,stroke:#c02020
style GOOD fill:#eef1fc,stroke:#2141c8
The fix is out-of-fold encoding, computing each fold’s values from the other folds only:
from sklearn.model_selection import KFold
import numpy as np
def target_encode_oof(df, col, target, n_splits=5, smoothing=10):
"""Out-of-fold target encoding with smoothing toward the global mean."""
global_mean = df[target].mean()
encoded = pd.Series(np.nan, index=df.index)
for train_idx, val_idx in KFold(n_splits, shuffle=True, random_state=0).split(df):
fold = df.iloc[train_idx]
stats = fold.groupby(col)[target].agg(['mean', 'count'])
# shrink small groups toward the global mean
smoothed = ((stats['mean'] * stats['count'] + global_mean * smoothing)
/ (stats['count'] + smoothing))
encoded.iloc[val_idx] = df.iloc[val_idx][col].map(smoothed).values
return encoded.fillna(global_mean)
The smoothing term is the second half of the fix. A category seen twice should not be trusted as much as one seen ten thousand times, so its estimate is pulled toward the overall average. The smoothing parameter controls how hard.
Better still, use a library implementation inside a pipeline so the whole thing is refitted per fold automatically. The point of understanding the mechanism is knowing why that matters.
High Cardinality
When a column has thousands of distinct values, one-hot is impractical. The options, roughly in order of how often they are right:
- Group the rare tail. Keep the top N, call everything else
other. Simple and effective.
top = df.city.value_counts().nlargest(20).index
df['city_grouped'] = df.city.where(df.city.isin(top), 'other')
- Frequency encoding. One column, no leakage risk.
- Out-of-fold target encoding. Powerful, and only with the discipline above.
- Group by a meaningful attribute instead. Replace postcode with region, product with category. Domain knowledge beats any automated scheme.
- Embeddings, if you are already training a neural network. Overkill otherwise.
Ordering Matters for Charts
Categorical order affects plots as well as models:
counts = df.city.value_counts()
df['city'] = pd.Categorical(df.city, categories=counts.index, ordered=True)
Now every chart shows categories from most to least common, which is usually what a reader wants, rather than alphabetically, which is almost never what anyone wants.
What Carries Forward
Convert low-cardinality text to category. Large memory savings and clearer intent.
Declare the order when there is one. It makes sorting and comparison work.
One-hot for a handful of unordered categories, with handle_unknown='ignore'.
Never target-encode naively. Compute out of fold, and smooth small groups toward the global mean.
Group the rare tail before reaching for anything clever. It solves most high-cardinality problems.
That completes the data-wrangling section. Next: making charts that answer a question.