Skip to content

Handling Text & Categorical Attributes

What you’ll learn

  • why OrdinalEncoderOrdinalEncoder makes a claim about your data, and when that claim is false
  • one-hot encoding, and the geometric reason it is the safe default
  • the memory arithmetic behind sparse matrices — 20,640 × 5 stored in 165 KB or 826 KB
  • the dummy variable trap, and when drop="first"drop="first" matters and when it does not
  • what happens when an unseen category arrives in production
  • four alternatives for high-cardinality columns, with the cost of each

Intuition

Almost no model accepts the string "NEAR BAY""NEAR BAY". Encoding turns text into numbers, and the encoding you pick is not a formatting decision — it tells the model what it is allowed to believe about those categories.

Map five categories to 0–4 and you have asserted that they lie on a line, evenly spaced, in that order. Sometimes true (small < medium < largesmall < medium < large). Here, false: ISLANDISLAND is not “between” INLANDINLAND and NEAR BAYNEAR BAY in any sense, yet a linear model will treat the numbers exactly that way.

diagram Diagram mermaid

The one text column

inspect.py
print(housing["ocean_proximity"].value_counts())
 
# <1H OCEAN     9136
# INLAND        6551
# NEAR OCEAN    2658
# NEAR BAY      2290
# ISLAND           5
inspect.py
print(housing["ocean_proximity"].value_counts())
 
# <1H OCEAN     9136
# INLAND        6551
# NEAR OCEAN    2658
# NEAR BAY      2290
# ISLAND           5
figureFive categories spanning three orders of magnitudematplotlib
Bar chart on a log scale of the five ocean_proximity categories, with ISLAND at 5 highlighted in red against thousands for the others.Bar chart on a log scale of the five ocean_proximity categories, with ISLAND at 5 highlighted in red against thousands for the others.
ISLAND appears in 5 districts out of 20,640. A random split has a real chance of putting zero of them in the training set — and then the encoder has never seen the category it meets at test time.

Ordinal encoding

ordinal.py
from sklearn.preprocessing import OrdinalEncoder
 
encoder = OrdinalEncoder()
encoded = encoder.fit_transform(housing[["ocean_proximity"]])
 
print(encoder.categories_)
# [array(['<1H OCEAN', 'INLAND', 'ISLAND', 'NEAR BAY', 'NEAR OCEAN'], dtype=object)]
print(encoded[:5].ravel())      # [3. 3. 3. 3. 3.]
ordinal.py
from sklearn.preprocessing import OrdinalEncoder
 
encoder = OrdinalEncoder()
encoded = encoder.fit_transform(housing[["ocean_proximity"]])
 
print(encoder.categories_)
# [array(['<1H OCEAN', 'INLAND', 'ISLAND', 'NEAR BAY', 'NEAR OCEAN'], dtype=object)]
print(encoded[:5].ravel())      # [3. 3. 3. 3. 3.]

Categories are assigned integers in alphabetical order, which is where the trouble starts. ISLANDISLAND becomes 2 purely because “I” sorts between “I” and “N” — and a linear model then reads that as: an island district is numerically between an inland one and a bay one, and twice as far from <1H OCEAN<1H OCEAN as INLANDINLAND is.

figureWhat each encoding assertsmatplotlib
Left: five category labels on a number line at positions 0 to 4, annotated to show the spacing is an unfounded claim. Right: a five by five identity matrix showing one-hot encoding, with every pair of rows equidistant.Left: five category labels on a number line at positions 0 to 4, annotated to show the spacing is an unfounded claim. Right: a five by five identity matrix showing one-hot encoding, with every pair of rows equidistant.
Ordinal places the categories on a line with distances. One-hot places them on the corners of a simplex, where every pair is exactly the same distance apart — the honest representation when no order exists.

Geometrically: under one-hot every pair of categories is 2\sqrt{2} apart, so no ordering and no spacing is implied. Under ordinal the distances are 1, 2, 3 and 4, and the model uses them.

See it move

Distances are the whole argument, so the sketch computes them. Hover a category to select it; the bars show how far every other category sits from your selection under each encoding.

sketch Distances the two encodings assert p5.js
Five ocean_proximity categories under ordinal and one-hot encoding. Selecting a category shows its distance to every other: the ordinal bars are unequal and imply an ordering, the one-hot bars are all the square root of two. Hover to change the selection.

The amber bars are the encoder’s uninvited claim. Nothing in the data says ISLANDISLAND sits between INLANDINLAND and NEAR BAYNEAR BAY; alphabetical order put it there, and a linear model reads it as a distance. The green bars assert only “different”, which is all that is true.

Tree models are the exception. A tree splits on thresholds, so ordinal <= 1.5ordinal <= 1.5 can isolate any group of categories the ordering happens to make contiguous. Ordinal encoding for trees is often fine and much cheaper than one-hot, which is why gradient boosting libraries offer native categorical support built on exactly this idea.

One-hot encoding

onehot.py
from sklearn.preprocessing import OneHotEncoder
 
encoder = OneHotEncoder()
one_hot = encoder.fit_transform(housing[["ocean_proximity"]])
 
print(one_hot)
# <Compressed Sparse Row sparse matrix of dtype 'float64'
#   with 20640 stored elements and shape (20640, 5)>
 
print(one_hot.shape)                       # (20640, 5)
print(encoder.get_feature_names_out())
# ['ocean_proximity_<1H OCEAN' ... 'ocean_proximity_NEAR OCEAN']
 
dense = one_hot.toarray()                  # only when you actually need it
print(dense[:3])
# [[0. 0. 0. 1. 0.]
#  [0. 0. 0. 1. 0.]
#  [0. 0. 0. 1. 0.]]
onehot.py
from sklearn.preprocessing import OneHotEncoder
 
encoder = OneHotEncoder()
one_hot = encoder.fit_transform(housing[["ocean_proximity"]])
 
print(one_hot)
# <Compressed Sparse Row sparse matrix of dtype 'float64'
#   with 20640 stored elements and shape (20640, 5)>
 
print(one_hot.shape)                       # (20640, 5)
print(encoder.get_feature_names_out())
# ['ocean_proximity_<1H OCEAN' ... 'ocean_proximity_NEAR OCEAN']
 
dense = one_hot.toarray()                  # only when you actually need it
print(dense[:3])
# [[0. 0. 0. 1. 0.]
#  [0. 0. 0. 1. 0.]
#  [0. 0. 0. 1. 0.]]

Why the output is sparse

A one-hot matrix is one 1 per row and zeros everywhere else. Storing the zeros is waste:

dense bytes=m×k×8sparse bytesm×(8+4)+overhead\text{dense bytes} = m \times k \times 8 \qquad \text{sparse bytes} \approx m \times (8 + 4) + \text{overhead}
RowsCategoriesDenseSparseSaving
20,6405826 KB248 KB
20,64010016.5 MB248 KB67×
1,000,00050,000400 GB12 MB33,000×

The last row is why sparse matrices exist. A one-hot encoding of a million user IDs is impossible dense and routine sparse. .toarray().toarray() on it will exhaust your memory, which is why scikit-learn makes you ask.

figureOne column per distinct value, with no upper boundmatplotlib
Log-scale bar chart of columns produced by one-hot encoding for six category types, from a binary flag at 2 up to user ID at 50,000, with a dashed line at 100 marking where one-hot stops being sensible.Log-scale bar chart of columns produced by one-hot encoding for six category types, from a binary flag at 2 up to user ID at 50,000, with a dashed line at 100 marking where one-hot stops being sensible.
Below about 50 distinct values one-hot is the obvious choice. Above a few hundred it starts to dominate the feature matrix, and something else is needed.

The dummy variable trap

Five one-hot columns always sum to exactly 1, so any one is perfectly predictable from the other four. The design matrix becomes rank-deficient, and for models solved by matrix inversion the solution is not unique.

x<1H OCEAN+xINLAND+xISLAND+xNEAR BAY+xNEAR OCEAN=1x_{\text{<1H OCEAN}} + x_{\text{INLAND}} + x_{\text{ISLAND}} + x_{\text{NEAR BAY}} + x_{\text{NEAR OCEAN}} = 1

drop="first"drop="first" removes one column, making the dropped category the reference level that the intercept absorbs.

ModelDoes it matter?
Linear/logistic regression with an intercept, no regularisationYes — drop one
Ridge, Lasso, Elastic NetNo — the penalty makes the solution unique anyway
Trees, forests, boostingNo — and dropping a column can hurt interpretability
Statistical inference (p-values, coefficient CIs)Yes, always

scikit-learn’s default is to keep all columns, which is right for regularised and tree-based models — the majority case.

Unseen categories in production

Your encoder learned five categories. Six months later a row arrives with "NEAR LAKE""NEAR LAKE".

unseen.py
import pandas as pd
from sklearn.preprocessing import OneHotEncoder
 
train = pd.DataFrame({"city": ["London", "Paris", "Tokyo"]})
live = pd.DataFrame({"city": ["London", "Berlin"]})       # Berlin is new
 
strict = OneHotEncoder().fit(train)
try:
    strict.transform(live)
except ValueError as exc:
    print("raised:", str(exc)[:60])
# raised: Found unknown categories ['Berlin'] in column 0 during
 
safe = OneHotEncoder(handle_unknown="ignore").fit(train)
print(safe.transform(live).toarray())
# [[1. 0. 0.]
#  [0. 0. 0.]]      <- Berlin becomes all zeros
unseen.py
import pandas as pd
from sklearn.preprocessing import OneHotEncoder
 
train = pd.DataFrame({"city": ["London", "Paris", "Tokyo"]})
live = pd.DataFrame({"city": ["London", "Berlin"]})       # Berlin is new
 
strict = OneHotEncoder().fit(train)
try:
    strict.transform(live)
except ValueError as exc:
    print("raised:", str(exc)[:60])
# raised: Found unknown categories ['Berlin'] in column 0 during
 
safe = OneHotEncoder(handle_unknown="ignore").fit(train)
print(safe.transform(live).toarray())
# [[1. 0. 0.]
#  [0. 0. 0.]]      <- Berlin becomes all zeros

Both behaviours are defensible. Raising catches a data-quality problem loudly; handle_unknown="ignore"handle_unknown="ignore" keeps the service running and encodes the unknown as “none of the above”. For anything serving live traffic, use handle_unknown="ignore"handle_unknown="ignore" and log the occurrence — an all-zero row is a silent degradation otherwise.

When one-hot is not an option

ApproachWhat it doesCost
Frequency encodingReplace each category with its count or shareCollides: two categories of equal frequency become identical
Target encodingReplace with the mean target for that categoryLeaks badly unless cross-fitted and smoothed
HashingHash into a fixed number of bucketsCollisions, and no way back to the category
EmbeddingsLearn a dense vector per categoryNeeds a neural network and plenty of data
GroupingKeep the top kk, bucket the rest as “other”Simple, effective, loses tail detail

Grouping is the underrated one. Keeping the 20 most common values and folding the remaining 4,980 into otherother usually captures most of the signal for a fraction of the width.

Pitfalls

quizCheck yourself
  1. Why is OrdinalEncoder a poor choice for ocean_proximity with a linear model?

    Show answer

    B — It asserts that the categories are ordered and evenly spaced — so the model treats ISLAND as numerically between INLAND and NEAR BAY — A linear model multiplies the integer code by a coefficient, so the arbitrary alphabetical spacing becomes a real quantitative claim about the categories.

  2. Why does OneHotEncoder return a sparse matrix by default?

    Show answer

    B — One-hot output is almost entirely zeros, and storing only the non-zero entries can be thousands of times smaller — One million rows by 50,000 categories is 400 GB dense and about 12 MB sparse. The default protects you from an allocation that cannot succeed.

  3. For which model does drop='first' genuinely matter?

    Show answer

    B — Unregularised linear regression with an intercept, where the redundant column makes the design matrix rank-deficient — The one-hot columns sum to 1, duplicating the intercept. Regularisation makes the solution unique regardless, and trees are unaffected — so scikit-learn keeps every column by default.

  4. A category unseen during training arrives in production. What should a serving pipeline do?

    Show answer

    B — Use handle_unknown='ignore' so the row encodes as all zeros, and log the event so the silent degradation is visible — Raising takes the service down; ignoring keeps it up but encodes an uninformative row. Logging turns a silent failure into a signal that the encoder needs refitting.

🧪 Try It Yourself

Exercise 1 – Ordinal-encode and inspect the ordering

Exercise 2 – One-hot encode and read the names

Exercise 3 – Measure the memory saving

Exercise 4 – Survive an unseen category

Exercise 5 – Confirm the dummy variable trap

Recap

  • The encoding is a claim about the categories. Ordinal claims an order and a spacing; one-hot claims neither.
  • OrdinalEncoderOrdinalEncoder sorts alphabetically — pass categories=[[...]]categories=[[...]] whenever the order is real.
  • Trees can live with ordinal encoding because they split on thresholds; linear models cannot.
  • One-hot output is sparse because it must be: a million rows by 50,000 categories is 400 GB dense and 12 MB sparse.
  • The one-hot columns sum to 1, so with an unregularised intercept model drop one. Regularised and tree-based models do not care.
  • Use handle_unknown="ignore"handle_unknown="ignore" in production, and log every occurrence.
  • Above a few hundred categories, group into a top-kk plus “other”, or use target encoding done properly with cross-fitting.

Exercise 6 – Measure what each encoding claims

Next

Continue to Feature Scaling (Normalization & Standardization) — every column is numeric now, and they span four orders of magnitude.

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did