Chapter 11 — Encoding Categorical Features
Chapter overview
Categorical variables describe membership, type, status, level, region, device family, product group, or other qualitative concepts. Most machine-learning estimators cannot consume raw strings directly, so these variables must be transformed into numerical representations. The transformation is not merely a technical conversion: it encodes assumptions about similarity, order, rarity, and information content.
This chapter develops a practical framework for choosing among ordinal encoding, one-hot encoding, rare-category grouping, frequency encoding, target encoding, and hashing. Particular attention is given to leakage prevention, unknown categories at prediction time, sparse matrices, high cardinality, and production robustness.
Learning objectives
- Explain why raw category labels cannot usually be supplied directly to numerical learning algorithms.
- Distinguish nominal and ordinal categories and preserve their semantic meaning during encoding.
- Apply ordinal and one-hot encoding safely with scikit-learn.
- Control dimensionality by grouping infrequent categories and using frequency thresholds.
- Evaluate strategies for high-cardinality features, including frequency encoding, target encoding, and feature hashing.
- Prevent target leakage by fitting target-dependent encoders only within training folds.
- Configure encoders so unseen categories do not break production predictions.
- Construct separate numerical and categorical preprocessing procedures in a ColumnTransformer pipeline.
Running example Throughout the chapter, we use a customer-subscription dataset containing numerical variables such as age and monthly_spend, categorical variables such as region and plan_type, an ordered satisfaction_level, and a binary target renewed. The same principles apply to healthcare, manufacturing, education, finance, and other tabular domains. |
11.1 Why categorical variables require encoding
A categorical variable represents a finite set of labels rather than a physical numerical scale. Examples include payment method, city, product family, subscription plan, blood group, device type, and education level. A learning algorithm needs a numerical representation of these concepts before it can estimate distances, coefficients, split points, or gradients.
Machine learning algorithms expect numerical inputs
Many estimators operate on a numerical feature matrix X. Linear and logistic regression compute weighted sums; K-nearest neighbors and support vector machines depend on distances or inner products; neural networks use numerical tensors. A column containing values such as "Basic", "Premium", and "Enterprise" therefore requires a transformation before training.
Python example 11.1 — Inspect categorical columns
| import pandas as pd df = pd.DataFrame({ "age": [22, 41, 35, 29], "plan_type": ["Basic", "Premium", "Basic", "Enterprise"], "region": ["North", "South", "West", "North"], "renewed": [0, 1, 1, 1], }) print(df.dtypes) print(df.select_dtypes(include="object").columns.tolist()) |
Category values do not automatically have numerical meaning
Replacing labels by arbitrary integers can accidentally introduce an order or a distance that does not exist. Coding {North=0, South=1, West=2} implies that West is numerically farther from North than South is, even though such geometry has no semantic basis. The encoding method must therefore match the variable’s meaning.
Variable | Semantic type | Unsafe shortcut | Why unsafe |
|---|---|---|---|
| region | Nominal | North=0, South=1, West=2 | Creates false order and false distances. |
| satisfaction_level | Ordinal | Alphabetical codes | Alphabetical order may not match Low < Medium < High. |
| plan_type | Nominal | Basic=1, Premium=2, Enterprise=3 | Numbers imply a quantitative spacing not necessarily justified. |
| is_student | Binary | No issue if 0/1 is meaningful | Binary variables already have a natural numerical representation. |
Core principle Encoding is part of feature engineering. A good encoding preserves useful structure while avoiding invented structure, leakage, excessive dimensionality, and brittle behavior on future data. |
11.2 Ordinal encoding
Ordinal encoding is appropriate when categories possess a genuine, domain-defined order. The encoder maps each ordered category to an integer while preserving the ranking. The numerical spacing between consecutive codes should not automatically be interpreted as an equal physical distance.
Ordered categories
Examples include Low < Medium < High, Bronze < Silver < Gold, beginner < intermediate < advanced, or an agreement scale from strongly disagree to strongly agree. The order must be justified by domain semantics, not by alphabetical order or convenience.
Mapping categories to ordered numbers
Python example 11.2 — Explicit ordinal mapping with pandas
| order = { "Low": 0, "Medium": 1, "High": 2, } df["satisfaction_code"] = df["satisfaction_level"].map(order) print(df[["satisfaction_level", "satisfaction_code"]]) |
Python example 11.3 — OrdinalEncoder with a declared order
| from sklearn.preprocessing import OrdinalEncoder encoder = OrdinalEncoder( categories=[["Low", "Medium", "High"]], handle_unknown="use_encoded_value", unknown_value=-1, ) encoded = encoder.fit_transform(df[["satisfaction_level"]]) |
Appropriate examples
Variable | Valid order | Interpretation |
|---|---|---|
| service_quality | Poor < Fair < Good < Excellent | Ranked qualitative assessment. |
| education_stage | Primary < Secondary < Undergraduate < Graduate | Ordered stage, although intervals are not equal. |
| risk_band | Low < Medium < High < Critical | Operational risk ranking. |
| shirt_size | XS < S < M < L < XL | Ordered size categories. |
Risks of imposing false order
Ordinal encoding becomes dangerous when used for nominal variables. A model may treat the numerical codes as ordered or distance-bearing. In a linear model, moving from code 0 to code 1 has the same algebraic increment as moving from 1 to 2. In KNN, the codes influence distance directly. Tree models are less sensitive to numeric spacing but still split using thresholds, which can create artificial category groupings.
Do not confuse rank with distance Ordinal encoding preserves order, not equal spacing. If the difference between "Low" and "Medium" is not known to equal the difference between "Medium" and "High", interpret the codes as ordered labels rather than measurements. |
11.3 One-hot encoding
One-hot encoding represents each category using a separate binary indicator column. It is the standard default for low- to moderate-cardinality nominal variables because it avoids imposing an artificial order.
Binary columns for each category
Python example 11.4 — One-hot encoding with pandas
| encoded = pd.get_dummies( df, columns=["plan_type", "region"], dtype=int, ) print(encoded.head()) |
Python example 11.5 — OneHotEncoder in scikit-learn
| from sklearn.preprocessing import OneHotEncoder encoder = OneHotEncoder( handle_unknown="ignore", sparse_output=True, ) X_cat = encoder.fit_transform(df[["plan_type", "region"]]) print(encoder.get_feature_names_out()) print(X_cat.shape) |
Dropping a reference category
For some linear models, one category may be dropped to create a reference level. With a variable containing k categories, k−1 indicators are enough to reconstruct category membership when an intercept is present. This can reduce exact multicollinearity, although regularized estimators often work acceptably with all indicators.
Python example 11.6 — Drop a reference level
| encoder = OneHotEncoder( drop="first", handle_unknown="ignore", sparse_output=True, ) X_cat = encoder.fit_transform(df[["plan_type", "region"]]) |
Unknown categories
Training data may contain North, South, and West, while a future request contains East. An encoder configured to raise an error can interrupt a prediction service. For robust inference, configure unknown-category behavior explicitly and test it before deployment.
Sparse matrices
One-hot matrices contain mostly zeros. Sparse representations store only nonzero entries, often reducing memory dramatically. This matters when many categorical variables or categories are present. Not every estimator accepts sparse matrices, so the downstream model and pipeline must be checked.
Python example 11.7 — Inspect sparse output
| X_cat = encoder.fit_transform(df[["plan_type", "region"]]) print(type(X_cat)) print("Shape:", X_cat.shape) print("Stored non-zero values:", X_cat.nnz) |
High-dimensional output
If a feature contains 10,000 unique categories, ordinary one-hot encoding can create up to 10,000 columns. The resulting feature space may consume memory, slow optimization, increase variance, and make interpretation difficult. Rare-category grouping or specialized high-cardinality encoders may be preferable.
Strength | Limitation |
|---|---|
| No false ordinal relationship for nominal categories. | Dimension grows with the number of categories. |
| Easy to interpret in linear models. | Very rare indicators may overfit. |
| Supported by most tabular ML workflows. | Unknown categories need explicit handling. |
| Works naturally with sparse matrices. | Feature names and model artifacts can become large. |
11.4 Handling rare categories
A rare category appears only a small number of times or represents only a tiny fraction of the training data. Rare categories are statistically difficult: their estimated effects are uncertain, a model can memorize them, and future datasets may contain new variants that were absent during training.
Grouping infrequent categories
A common strategy replaces infrequent labels with a shared category such as "Other" or "Rare". The grouping rule must be learned from the training data and then applied unchanged to validation, test, and production data.
Python example 11.8 — Group categories using a frequency threshold
| counts = df["city"].value_counts() min_count = 20 frequent = counts[counts >= min_count].index df["city_grouped"] = df["city"].where( df["city"].isin(frequent), "Other", ) |
Minimum frequency thresholds
Thresholds can be expressed as a count (for example, at least 20 rows) or as a proportion (for example, at least 1% of training observations). The threshold is a modeling choice and should be assessed with cross-validation rather than selected solely for convenience.
Python example 11.9 — OneHotEncoder with built-in infrequent-category handling
| encoder = OneHotEncoder( handle_unknown="infrequent_if_exist", min_frequency=0.01, sparse_output=True, ) X_cat = encoder.fit_transform(df[["city", "plan_type"]]) |
The “Other” category
An "Other" group increases support by pooling categories, but it is semantically heterogeneous. Its effect should not be interpreted as a coherent real-world group. The category is a modeling device that trades detailed identity for statistical stability.
Risks of rare-category overfitting
- A category appearing once can perfectly coincide with one target value by chance.
- A tree may isolate a tiny category and create an unstable leaf.
- Target encoding can produce extreme estimates for tiny groups without smoothing.
- One-hot coefficients for rare levels can have high variance.
- A category may disappear or change spelling in production.
Training-only rule Compute rare-category membership from the training partition only. If the full dataset is used to decide which categories are rare, information about validation or test composition enters preprocessing and weakens the honesty of evaluation. |
11.5 High-cardinality categorical features
High-cardinality features contain hundreds, thousands, or even millions of distinct values. Examples include product IDs, merchant IDs, postal codes, URLs, device models, job titles, and user-generated tags. Ordinary one-hot encoding may be impractical or statistically weak.
Problems with thousands of categories
- Large sparse matrices and high memory usage.
- Slow training and hyperparameter search.
- Rare-level overfitting.
- Unstable categories between training and production.
- Large model artifacts and difficult feature inspection.
- Potential identity leakage when the feature is actually an identifier.
Frequency encoding
Frequency encoding replaces each category with its count or proportion in the training data. It is compact and target-independent, but different categories with the same frequency receive the same value. The encoding expresses prevalence, not category identity.
Python example 11.10 — Frequency encoding
| train_freq = X_train["merchant"].value_counts(normalize=True) X_train["merchant_freq"] = X_train["merchant"].map(train_freq) X_valid["merchant_freq"] = ( X_valid["merchant"].map(train_freq).fillna(0.0) ) |
Target encoding
Target encoding replaces a category with a statistic of the target, commonly the mean target value for that category. For binary classification, this can approximate the historical positive-class rate. It is compact and powerful, especially for high-cardinality variables, but it directly uses labels and therefore has a serious leakage risk.
Python example 11.11 — Naive target encoding (illustrates the risky pattern)
| # WARNING: do not compute this on all rows and evaluate on the same rows. category_mean = train_df.groupby("merchant")["renewed"].mean() train_df["merchant_te_naive"] = train_df["merchant"].map(category_mean) |
Leakage risks in target encoding
If a row contributes to the statistic used to encode itself, the encoded feature contains information about its own target. The problem is particularly severe for rare categories: a category appearing once receives exactly that row’s target. Validation scores can become unrealistically high.
Leakage warning Target-dependent encoders must be trained inside the model-validation process. Never fit a target encoder once on the complete dataset before cross-validation or before the train/test split. |
Cross-validated encoding
For training rows, out-of-fold target encoding computes each row’s encoded value using only other folds. For validation or test rows, the encoder is fitted on the available training data and applied to unseen rows. Smoothing is often added so that small groups shrink toward the global mean.
Python example 11.12 — Simple out-of-fold target encoding
| import numpy as np from sklearn.model_selection import KFold X = train_df[["merchant"]].copy() y = train_df["renewed"].copy() encoded = pd.Series(index=X.index, dtype=float) kf = KFold(n_splits=5, shuffle=True, random_state=42) for fit_idx, hold_idx in kf.split(X): fit_x = X.iloc[fit_idx] fit_y = y.iloc[fit_idx] means = fit_y.groupby(fit_x["merchant"]).mean() global_mean = fit_y.mean() encoded.iloc[hold_idx] = ( X.iloc[hold_idx]["merchant"].map(means).fillna(global_mean) ) train_df["merchant_te_oof"] = encoded |
Hashing
Feature hashing maps categories into a fixed number of columns using a hash function. The dimensionality is controlled regardless of the number of unique values, and no category vocabulary needs to be stored. The trade-off is collisions: different categories may map to the same column, and direct interpretability is reduced.
Python example 11.13 — Feature hashing
| from sklearn.feature_extraction import FeatureHasher records = [{"merchant=" + value: 1} for value in df["merchant"]] hasher = FeatureHasher( n_features=32, input_type="dict", alternate_sign=False, ) X_hash = hasher.transform(records) print(X_hash.shape) |
Method | Uses target? | Output size | Main strength | Main caution |
|---|---|---|---|---|
| One-hot | No | Number of categories | Transparent and widely supported | Can explode in dimension. |
| Rare grouping + one-hot | No | Reduced categories | Simple and stable | Groups heterogeneous labels. |
| Frequency encoding | No | 1 column/feature | Compact and easy | Loses category identity. |
| Target encoding | Yes | 1 column/feature | Often strong for high cardinality | Leakage and overfitting risk. |
| Hashing | No | Fixed | Bounded memory; no vocabulary | Collisions and lower interpretability. |
11.6 Unknown categories at prediction time
An unknown category is a label observed during transformation that was absent when the encoder was fitted. This is normal in real systems: new cities, products, merchants, devices, campaigns, or job titles appear continuously. Production-safe preprocessing treats unknown categories as an expected condition rather than an exceptional failure.
Configuring encoders safely
Python example 11.14 — Safe one-hot handling of unknown categories
| from sklearn.preprocessing import OneHotEncoder encoder = OneHotEncoder( handle_unknown="ignore", sparse_output=True, ) encoder.fit(train_df[["region"]]) new_rows = pd.DataFrame({"region": ["North", "East"]}) X_new = encoder.transform(new_rows) |
Python example 11.15 — Safe ordinal handling with an explicit unknown code
| from sklearn.preprocessing import OrdinalEncoder encoder = OrdinalEncoder( categories=[["Low", "Medium", "High"]], handle_unknown="use_encoded_value", unknown_value=-1, ) encoder.fit(train_df[["satisfaction_level"]]) |
Ensuring production robustness
- Fit encoders only on training data and persist the fitted preprocessing pipeline.
- Define behavior for unknown and missing categories explicitly.
- Validate input schemas before prediction.
- Log the frequency of unseen categories in production.
- Monitor category drift and the growth of the "Other" group.
- Use stable category definitions or controlled vocabularies when the domain allows them.
- Include unknown-category test cases in automated tests.
Python example 11.16 — Test production behavior explicitly
| test_cases = pd.DataFrame({ "region": ["North", "NeverSeenBefore"], "plan_type": ["Basic", "NewPlan"], }) try: transformed = preprocessor.transform(test_cases) print("Transformation succeeded:", transformed.shape) except Exception as exc: print("Preprocessing failed:", exc) |
Encoding decision guide
Question | If yes | Recommended direction |
|---|---|---|
| Does the category have a real order? | Yes | Ordinal encoding with explicit category order. |
| Is the feature nominal with modest cardinality? | Yes | One-hot encoding. |
| Are many categories very rare? | Yes | Group infrequent categories, then encode. |
| Is cardinality very high? | Yes | Consider frequency, leakage-safe target encoding, or hashing. |
| Will new labels appear in production? | Yes | Configure unknown handling and monitor drift. |
| Does the encoding use y? | Yes | Fit inside folds/pipelines; prevent leakage. |
| Is the column actually an identifier? | Yes | Usually remove it or use entity-aware strategy rather than encode blindly. |
Recommended default For ordinary tabular supervised learning: start with one-hot encoding for nominal variables and ordinal encoding only for genuinely ordered variables. Add rare-category handling when needed. Move to specialized high-cardinality techniques only when the simple baseline is inadequate or operationally too expensive. |
Practical lab — Build separate numerical and categorical preprocessing procedures
Scenario
A subscription company wants to predict whether a customer will renew. The dataset contains numerical features, nominal categories, an ordered satisfaction variable, and a high-cardinality city field. Students must construct a leakage-safe preprocessing pipeline and verify that unseen categories can be transformed successfully.
Lab objectives
- Identify numerical, nominal, and ordinal features.
- Impute missing values separately by feature type.
- Scale numerical features.
- One-hot encode nominal categories with safe unknown handling.
- Ordinally encode the satisfaction level with a declared order.
- Group infrequent city labels automatically.
- Combine all transformations in ColumnTransformer.
- Fit preprocessing only on the training split.
- Inspect generated feature names and transformed dimensions.
- Verify an unseen production row can be processed.
Step 1 — Create the example dataset
Lab code 11.1 — Dataset
| import numpy as np import pandas as pd rng = np.random.default_rng(42) n = 600 df = pd.DataFrame({ "age": rng.integers(18, 75, n), "monthly_spend": rng.normal(65, 20, n).round(2), "plan_type": rng.choice(["Basic", "Plus", "Premium"], n), "region": rng.choice(["North", "South", "East", "West"], n), "satisfaction_level": rng.choice(["Low", "Medium", "High"], n), "city": rng.choice([f"City_{i}" for i in range(45)], n), }) df["renewed"] = ( (df["monthly_spend"] > 55).astype(int) + (df["satisfaction_level"] == "High").astype(int) + rng.integers(0, 2, n) >= 2 ).astype(int) # Introduce a few missing values for preprocessing practice. df.loc[rng.choice(df.index, 20, replace=False), "monthly_spend"] = np.nan df.loc[rng.choice(df.index, 15, replace=False), "region"] = np.nan |
Step 2 — Separate X and y, then split
Lab code 11.2 — Train/test split before fitting encoders
| from sklearn.model_selection import train_test_split X = df.drop(columns="renewed") y = df["renewed"] X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.20, random_state=42, stratify=y, ) |
Step 3 — Define feature groups
Lab code 11.3 — Feature groups
| numeric_features = ["age", "monthly_spend"] nominal_features = ["plan_type", "region", "city"] ordinal_features = ["satisfaction_level"] satisfaction_order = [["Low", "Medium", "High"]] |
Step 4 — Build separate pipelines
Lab code 11.4 — Numerical, nominal, and ordinal pipelines
| from sklearn.pipeline import Pipeline from sklearn.impute import SimpleImputer from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder, StandardScaler numeric_pipe = Pipeline([ ("imputer", SimpleImputer(strategy="median")), ("scaler", StandardScaler()), ]) nominal_pipe = Pipeline([ ("imputer", SimpleImputer(strategy="most_frequent")), ("onehot", OneHotEncoder( handle_unknown="infrequent_if_exist", min_frequency=0.02, sparse_output=True, )), ]) ordinal_pipe = Pipeline([ ("imputer", SimpleImputer(strategy="most_frequent")), ("ordinal", OrdinalEncoder( categories=satisfaction_order, handle_unknown="use_encoded_value", unknown_value=-1, )), ]) |
Step 5 — Combine transformations
Lab code 11.5 — ColumnTransformer
| from sklearn.compose import ColumnTransformer preprocessor = ColumnTransformer([ ("num", numeric_pipe, numeric_features), ("nom", nominal_pipe, nominal_features), ("ord", ordinal_pipe, ordinal_features), ]) X_train_ready = preprocessor.fit_transform(X_train) X_test_ready = preprocessor.transform(X_test) print("Train shape:", X_train_ready.shape) print("Test shape:", X_test_ready.shape) |
Step 6 — Inspect generated feature names
Lab code 11.6 — Feature names
| feature_names = preprocessor.get_feature_names_out() print("Number of output features:", len(feature_names)) print(feature_names[:20]) |
Step 7 — Add the model to the pipeline
Lab code 11.7 — End-to-end classification pipeline
| from sklearn.linear_model import LogisticRegression from sklearn.metrics import accuracy_score, classification_report model = Pipeline([ ("preprocess", preprocessor), ("classifier", LogisticRegression(max_iter=1000)), ]) model.fit(X_train, y_train) pred = model.predict(X_test) print("Accuracy:", accuracy_score(y_test, pred)) print(classification_report(y_test, pred)) |
Step 8 — Test an unseen production category
Lab code 11.8 — Robustness test
| new_customer = pd.DataFrame({ "age": [34], "monthly_spend": [72.50], "plan_type": ["Ultra"], # unseen plan "region": ["Central"], # unseen region "satisfaction_level": ["High"], "city": ["Brand_New_City"], # unseen city }) prediction = model.predict(new_customer) probability = model.predict_proba(new_customer)[:, 1] print("Predicted renewal:", prediction[0]) print("Renewal probability:", probability[0]) |
Student deliverable
- A notebook containing the complete preprocessing and model pipeline.
- A table classifying each input feature as numerical, nominal, ordinal, or high-cardinality.
- An explanation of why each categorical encoding method was selected.
- The transformed training and test dimensions.
- A list of generated output feature names.
- A demonstration that unseen categories do not crash preprocessing.
- A short note explaining where leakage could occur if target encoding were introduced.
Evidence-based questions
- Why is satisfaction_level encoded ordinally rather than one-hot encoded in this lab?
- Why is city allowed to use an infrequent-category mechanism?
- What happens to an unseen region when handle_unknown is configured safely?
- Why is preprocessor.fit_transform used only on X_train while X_test uses transform?
- Would target encoding city be automatically better? What validation would be required before choosing it?
Common mistakes and corrections
Mistake | Why it is a problem | Correction |
|---|---|---|
| Integer-code nominal labels | Invents order and distance. | Use one-hot or another nominal encoding. |
| Fit encoder on full dataset | Leaks test-set category information. | Fit preprocessing on training data only. |
| Target encode before CV | Target information leaks into validation folds. | Use out-of-fold or pipeline-aware encoding. |
| Ignore unknown categories | Production predictions may fail. | Configure and test unknown handling. |
| One-hot encode a huge ID column | Creates massive, unstable feature space. | Question whether ID belongs in model; consider grouping/hashing if meaningful. |
| Treat missing as ordinary string accidentally | Missingness semantics become inconsistent. | Impute or encode missingness intentionally. |
| Drop every rare category | May remove important but real signals. | Assess rarity policy with domain knowledge and validation. |
Chapter summary
- Categorical encoding converts qualitative labels into numerical representations while preserving useful semantics.
- Ordinal encoding is appropriate only when the categories have a genuine order.
- One-hot encoding is a strong default for low- and moderate-cardinality nominal variables.
- Rare-category grouping can improve stability and control dimensionality.
- High-cardinality features may require frequency encoding, target encoding, or hashing.
- Target encoding must be cross-validated or otherwise fitted without allowing each row to reveal its own target.
- Unknown-category behavior must be deliberately configured for robust production systems.
- ColumnTransformer and Pipeline provide a clean way to apply different preprocessing strategies to different feature groups without leakage.
Knowledge check
1. A feature contains Red, Green, and Blue with no natural ranking. Which encoding is the safest default?
Answer: One-hot encoding.
2. Why is coding Red=0, Green=1, Blue=2 risky?
Answer: It creates an artificial order and numerical distance.
3. What is the main benefit of sparse one-hot output?
Answer: It stores mostly-zero matrices efficiently.
4. What is the central danger of naive target encoding?
Answer: Target leakage and severe overfitting, especially for rare categories.
5. How should a fitted encoder behave when a new category appears in production?
Answer: Use an explicit safe strategy such as ignore, infrequent grouping, or an unknown code, depending on the encoder.
6. Should rare-category thresholds be determined from the test set?
Answer: No. They must be learned from training data only.
Expected outcome Students should be able to classify categorical variables by semantic type, choose an appropriate encoding strategy, build separate numerical and categorical preprocessing pipelines, prevent target-encoding leakage, and ensure that future unseen categories are handled safely. |