In the previous lesson we left one branch of the ColumnTransformer pending: the categorical columns. The customer's city, the product category and the MercaFresh subscription plan are text, and Machine Learning models only know how to operate on numbers: they multiply, add up distances, compute gradients. You can't multiply "Barcelona". Encoding means translating categories into numbers, and it's no mechanical formality: each encoding method tells the model a different story about the data, and choosing badly (for example, imposing an order that doesn't exist) introduces false patterns the model will obediently learn. In this lesson you'll see the main methods, their traps and a clear guide on when to use each one.

Contents

  1. Why models need numbers
  2. Nominal vs ordinal: the distinction that governs everything
  3. Label and ordinal encoding
  4. One-hot encoding
  5. The high-cardinality problem
  6. Frequency and target encoding
  7. Comparison table and decision
  8. Full MercaFresh case: city, category and plan

Why models need numbers

Internally, almost every algorithm in module 4 boils its work down to arithmetic on matrices: regression computes weighted sums (weight1 * x1 + weight2 * x2 + ...), K-NN measures distances between rows, neural networks multiply matrices. The moment a column contains "Barcelona", that machinery grinds to a halt: there is no such thing as 0.7 * "Barcelona".

The naive temptation is to assign numbers by hand: Barcelona = 1, Madrid = 2, Valencia = 3. It works syntactically... and that's exactly the danger: the model will believe the numbers. It will interpret that Valencia is "three times" Barcelona, that Madrid sits "between" the two, that the average of Barcelona and Valencia is Madrid. We've invented an order and distances that don't exist in reality. This whole lesson revolves around avoiding that deception.

Nominal vs ordinal: the distinction that governs everything

From module 2 (lesson 02-01) you'll remember the classification of variables. For encoding, the crucial distinction is this:

Type Is there a real order? MercaFresh examples Natural encoding
Nominal No: just labels city, product category, payment method One-hot
Ordinal Yes: order with meaning plan (basic < standard < premium), satisfaction (low < medium < high) Ordinal (ordered integers)

The control question: does it make sense to say one category is "greater" than another? Premium > basic, yes; Madrid > Sevilla, no. Answering this question before touching the keyboard prevents 90% of encoding mistakes.

Label and ordinal encoding

Both assign an integer to each category; the difference lies in who controls the order.

Ordinal encoding: when the order is real

For the MercaFresh subscription plan, the order is information: a premium customer pays more and behaves differently. Here ordered integers are the right encoding, and we must fix the order explicitly:

import pandas as pd
from sklearn.preprocessing import OrdinalEncoder

df = pd.DataFrame({
    "customer_id": [101, 104, 105, 106, 107],
    "plan": ["basic", "premium", "standard", "basic", "premium"],
})

# categories fixes the order: basic=0 < standard=1 < premium=2
enc = OrdinalEncoder(categories=[["basic", "standard", "premium"]])
df["plan_code"] = enc.fit_transform(df[["plan"]])
print(df)
#    customer_id      plan  plan_code
# 0          101     basic        0.0
# 1          104   premium        2.0
# 2          105  standard        1.0
# ...

Without the categories parameter, OrdinalEncoder sorts alphabetically — which here would produce basic=0, premium=1, standard=2, and with ["low", "high", "medium"] would produce high=0, low=1, medium=2: a silent disaster either way. With ordinals, always dictate the order yourself.

In pure pandas, the equivalent is an explicit mapping, just as valid:

order_map = {"basic": 0, "standard": 1, "premium": 2}
df["plan_code"] = df["plan"].map(order_map)

Label encoding: almost exclusively for the target variable

scikit-learn's LabelEncoder does the same thing (category → integer) but is meant for the target variable (for example, churn "yes"/"no" → 1/0), not for features: it works on a single column and assigns the alphabetical order without letting you control it.

from sklearn.preprocessing import LabelEncoder

le = LabelEncoder()
y = le.fit_transform(["no", "yes", "no", "no", "yes"])   # [0 1 0 0 1]
print(le.classes_)                                        # ['no' 'yes']

The danger: false order on nominals

What happens if we encode city with integers? Barcelona=0, Madrid=1, Sevilla=2, Valencia=3. A linear model will learn a single coefficient for "city" and conclude things like "each step of city increases churn by 3%" — an absurd rule that depends on alphabetical order. Some tree models (04-03) survive this because they only make splits, but as a general rule: integers on nominals = false order = invented patterns. For nominals, the tool is the next one.

One-hot encoding

The idea: instead of one number per category, one binary column per category. Each row has a 1 in its own column and 0 in the rest — hence the name, "one hot".

df2 = pd.DataFrame({
    "customer_id": [101, 104, 105, 108, 110],
    "city": ["Barcelona", "Barcelona", "Valencia", "Madrid", "Sevilla"],
})

# With pandas: quick for exploring
dummies = pd.get_dummies(df2["city"], prefix="city", dtype=int)
print(pd.concat([df2, dummies], axis=1))
#    customer_id       city  city_Barcelona  city_Madrid  city_Sevilla  city_Valencia
# 0          101  Barcelona               1            0             0              0
# 1          104  Barcelona               1            0             0              0
# 2          105   Valencia               0            0             0              1
# ...

No more invented order or distances: each city is an independent dimension and the model learns its own effect for each one. It's the default encoding for nominals with few categories.

get_dummies vs OneHotEncoder

get_dummies is convenient, but it has a serious flaw in an ML workflow: it doesn't memorize the categories. If next month's batch happens to contain no customer from Sevilla, it will generate one column fewer and the model will receive a matrix with a different shape. OneHotEncoder follows the fit/transform pattern you already master: it learns the categories in fit and always guarantees the same columns.

from sklearn.preprocessing import OneHotEncoder

ohe = OneHotEncoder(sparse_output=False, handle_unknown="ignore")
X_city = ohe.fit_transform(df2[["city"]])
print(ohe.get_feature_names_out())
# ['city_Barcelona' 'city_Madrid' 'city_Sevilla' 'city_Valencia']
  • handle_unknown="ignore": if a customer from Bilbao shows up in production (a never-seen category), it encodes them as all zeros instead of raising an error. Essential in the real world.
  • sparse_output=False returns a regular matrix; with many columns it's better to keep the output sparse to save memory.
  • It slots directly in as the categorical branch of the ColumnTransformer from the previous lesson.

The multicollinearity trap, in passing

If you know a customer isn't from Barcelona, nor Madrid, nor Sevilla... you already know they're from Valencia: the last column is redundant (it always sums to 1 with the others). That redundancy, called multicollinearity, bothers some classic linear models. The drop="first" parameter (or drop_first=True in get_dummies) removes one column to avoid it. Keep the idea and the parameter name in mind; the deeper why belongs to linear regression (04-01) and regularization (07-01).

The high-cardinality problem

One-hot scales poorly. The city column with 4 values generates 4 columns; but what about MercaFresh's delivery postal code, with 800 distinct values? Or the product id with 5,000?

  • 800 new columns, almost all zeros: memory and runtime through the roof.
  • Categories with 2 or 3 examples: the model can't learn anything reliable from them (raw material for overfitting, which we'll formalize in 06-05).

Strategies against high cardinality:

  1. Group by business knowledge: postal codes → province or delivery zone.
  2. Group the rare tail: keep the frequent categories and merge the rest into "other" (OneHotEncoder(min_frequency=...) does it for you).
  3. Switch encodings: frequency or target encoding, which generate a single column regardless of cardinality.
# Group the tail: cities with fewer than 20 customers become "other"
counts = df_large["city"].value_counts()
rare = counts[counts < 20].index
df_large["city_grouped"] = df_large["city"].replace(rare, "other")

Frequency and target encoding

Two techniques that replace the category with a single informative number, designed for high cardinality. We present them at an introductory level: understand the idea and, above all, the risk.

Frequency encoding

Replaces each category with its frequency (how many times it appears, or its proportion):

freq = df_large["city"].value_counts(normalize=True)
df_large["city_freq"] = df_large["city"].map(freq)

Barcelona (40% of customers) becomes 0.40; a village with 2 customers, 0.0002. It's simple, doesn't explode dimensionality, and sometimes the frequency itself is predictive (big cities have better delivery times and less churn). Limitation: two categories with equal frequency become indistinguishable.

Target encoding

Replaces each category with the mean of the target variable within that category: each city becomes its own churn rate.

# Conceptual idea (NOT how you should do it in a real project — see the risk below)
churn_rate_by_city = df_large.groupby("city")["churn"].mean()
df_large["city_target"] = df_large["city"].map(churn_rate_by_city)

It's extremely powerful — it condenses exactly the category-target relationship into one number — and for that very reason it's the technique with the highest data leakage risk in all of preprocessing: we're putting the answer (churn) inside a feature. For a city with 3 customers of which 2 churned, the encoding 0.67 is practically whispering those customers' labels to the model. Symptoms and precautions:

  • Suspiciously perfect training evaluation and poor performance on new data.
  • Compute the means with training data only (the same principle from 03-02, here elevated to critical).
  • Use smoothing (blending the category mean with the global mean, weighting the global one more heavily the less data the category has) and the cross-validation schemes you'll see in 06-03. scikit-learn's TargetEncoder builds in these defenses.

A prudent rule for this course: one-hot as the default option; target encoding only when cardinality demands it and with its safeguards in place.

Comparison table and decision

Method Columns generated Imposes order? High cardinality Main risk When to use it
Ordinal encoding 1 Yes (you define it) Fine Badly defined order Real ordinals (plan, level)
Label encoding 1 Yes (alphabetical) False order Target variable only
One-hot 1 per category No Poor Column explosion Nominals with few categories (default)
Frequency 1 No Very good Frequency collisions High cardinality, quick fix
Target 1 No Very good Data leakage High cardinality, with smoothing and care
flowchart TD
    A{"Does the variable have<br/>a real order?"} -->|Yes| B["Ordinal encoding<br/>with explicit order"]
    A -->|No| C{"Few categories?<br/>(rule of thumb: < 15)"}
    C -->|Yes| D["One-hot encoding"]
    C -->|No| E{"Can I group with<br/>business criteria?"}
    E -->|Yes| F["Group and one-hot"]
    E -->|No| G["Frequency or target encoding<br/>(with safeguards)"]

Full MercaFresh case: city, category and plan

Let's apply the decision to the three categoricals of the churn dataset:

import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder

customers = pd.DataFrame({
    "customer_id": [101, 104, 105, 106, 107, 108],
    "city": ["Barcelona", "Barcelona", "Valencia", "Madrid", "Madrid", "Sevilla"],
    "favorite_category": ["fresh", "pantry", "fresh", "drinks", "fresh", "cleaning"],
    "plan": ["basic", "premium", "standard", "basic", "premium", "basic"],
    "total_spend": [1250.5, 890.0, 2100.75, 310.2, 15400.0, 670.4],
})

# Reasoned decisions:
# - city: nominal, 4 values              -> one-hot
# - favorite_category: nominal, 4 values -> one-hot
# - plan: real ordinal                   -> ordinal with explicit order
encoder = ColumnTransformer(transformers=[
    ("nominal", OneHotEncoder(sparse_output=False, handle_unknown="ignore"),
     ["city", "favorite_category"]),
    ("ordinal", OrdinalEncoder(categories=[["basic", "standard", "premium"]]),
     ["plan"]),
    ("numeric", "passthrough", ["total_spend"]),
])

X = encoder.fit_transform(customers)
print(encoder.get_feature_names_out())
print(pd.DataFrame(X, columns=encoder.get_feature_names_out()).round(2))

The result is a fully numeric matrix: 4 city columns + 4 category columns + 1 plan column + the spend. Notice how the ColumnTransformer introduced in 03-03 has absorbed the encoding as just another branch: the preprocessing is still a single object with fit and transform. Note also the city cleanup from 03-01: if we hadn't unified "BCN" and " barcelona ", the one-hot would have created ghost columns for each variant.

Common Mistakes and Tips

  • Arbitrary integers on nominals. City = 1, 2, 3 invents order and distances. If you can't say which one is "greater", don't use ordinal.
  • Letting OrdinalEncoder sort a real ordinal alphabetically. Always pass categories=[...] with the business order.
  • Using get_dummies in production. With no memory of the categories, each batch can generate different columns. In ML workflows, OneHotEncoder with handle_unknown="ignore".
  • One-hot on 800 postal codes. Column explosion and categories with no data: group first or switch techniques.
  • Target encoding computed on the whole dataset. It's data leakage in its purest form: per-category means are learned from training data only, with smoothing.
  • Encoding before cleaning the text. "Madrid" and "madrid " generate two columns. The normalization from 03-01 always goes before encoding.
  • Tip: after encoding, print get_feature_names_out() and check that the number and names of the columns are what you expected. Encoding errors are silent; this 5-second check catches nearly all of them.

Exercises

Exercise 1

Classify each MercaFresh variable as nominal or ordinal and state the appropriate encoding method: (a) payment method (card, PayPal, cash on delivery); (b) rating of the last delivery (bad, fair, good, excellent); (c) delivery neighborhood in Barcelona (73 neighborhoods); (d) customer type (individual, business).

Exercise 2

Encode with OrdinalEncoder the column rating = ["good", "bad", "excellent", "fair", "good"] respecting the real order. Show which (incorrect) encoding the default alphabetical order would have produced.

Exercise 3

Apply frequency encoding to the column neighborhood = ["Gracia", "Eixample", "Gracia", "Sants", "Eixample", "Gracia", "Raval", "Eixample"] using proportions. Which two neighborhoods become indistinguishable after encoding, and why is that a limitation of the method?

Solutions

Exercise 1

  • (a) Nominal (no payment method is "greater") → one-hot (3 categories, no problem).
  • (b) Real ordinal (bad < fair < good < excellent) → ordinal encoding with explicit order.
  • (c) High-cardinality nominal (73 values) → group by district if the business allows it; otherwise, frequency or target encoding with safeguards.
  • (d) Binary nominal → one-hot; with two categories a single 0/1 column suffices (it's exactly the case where drop="first" comes naturally).

Exercise 2

from sklearn.preprocessing import OrdinalEncoder
import pandas as pd

df = pd.DataFrame({"rating": ["good", "bad", "excellent", "fair", "good"]})

# Correct: explicit business order
enc = OrdinalEncoder(categories=[["bad", "fair", "good", "excellent"]])
print(enc.fit_transform(df))       # good=2, bad=0, excellent=3, fair=1

# Incorrect: default alphabetical order
enc_bad = OrdinalEncoder()
print(enc_bad.fit_transform(df))   # bad=0, excellent=1, fair=2, good=3

With the alphabetical order, "fair" (2) and "good" (3) would rank above "excellent" (1): the model would learn that middling ratings are "greater" than the best one.

Exercise 3

s = pd.Series(["Gracia", "Eixample", "Gracia", "Sants",
               "Eixample", "Gracia", "Raval", "Eixample"])
freq = s.value_counts(normalize=True)
print(s.map(freq))
# Gracia -> 0.375, Eixample -> 0.375, Sants -> 0.125, Raval -> 0.125

Gracia and Eixample collide at 0.375, and Sants and Raval at 0.125: different neighborhoods become identical to the model. Frequency encoding only preserves "how common the category is", not its identity — the price of compressing everything into one number.

Conclusion

You now know how to translate categories into numbers without lying to the model: ordinal encoding with explicit order for real ordinals, one-hot as the default for nominals (with OneHotEncoder and handle_unknown="ignore" in serious workflows), grouping strategies or frequency/target encoding for high cardinality — with target encoding flagged as the highest-leakage-risk zone — and everything integrated into the ColumnTransformer we've been building. The MercaFresh churn matrix is now fully numeric.

But take a look at it: the one-hot columns are 0 or 1, the plan runs from 0 to 2... and the total spend reaches 15,400. For algorithms that measure distances or descend gradients, that disparity of scales lets a single column drown out all the others. That is exactly the problem of the next lesson: normalization and standardization.

Machine Learning Course

Module 1: Introduction to Machine Learning

Module 2: Foundations of Statistics and Probability

Module 3: Data Preprocessing

Module 4: Supervised Machine Learning Algorithms

Module 5: Unsupervised Machine Learning Algorithms

Module 6: Model Evaluation and Validation

Module 7: Advanced Techniques and Optimization

Module 8: Model Implementation and Deployment

Module 9: Hands-On Projects

Module 10: Additional Resources

© Copyright 2026. All rights reserved