,

Encoders shootout: one-hot, ordinal, target, and hashing — when each wins and when it leaks

LEARN · FEATURE ENGINEERING & THE SCIKIT-LEARN TOOLKIT

Why categorical encoding deserves a shootout

Categorical features are everywhere in tabular machine learning:

  • Country

  • Browser

  • Occupation

  • Product ID

  • Merchant

  • ZIP code

Yet most machine learning algorithms expect numeric inputs. Your encoding strategy directly affects:

  • Model accuracy

  • Training time

  • Memory consumption

  • Generalization to unseen categories

  • Risk of target leakage

There is no universally best encoder.

Instead, the winning approach depends on:

  • Feature cardinality

  • Model family

  • Dataset size

  • Whether unseen categories appear in production

  • Whether the encoder learns from the target

In this hands-on guide, you’ll compare four common approaches using the same dataset, the same cross-validation strategy, and the same evaluation metric:

  • OneHotEncoder

  • OrdinalEncoder

  • TargetEncoder

  • HashingVectorizer

You’ll also reproduce one of the easiest mistakes in Feature Engineering—target leakage—and fix it correctly with a cross-validation-safe pipeline.


Environment

The examples below were tested with current library releases (mid-2025+ ecosystem):

  • Python 3.12+

  • scikit-learn 1.7.x

  • category_encoders 2.8.x

  • pandas 2.2+

  • numpy 2.x

Install everything:

pip install -U scikit-learn category_encoders pandas numpy scipy

The dataset

We’ll use the Adult Income dataset from OpenML.

It predicts whether annual income exceeds $50K based on demographic and employment information.

It is ideal because it contains:

  • Numeric features

  • Low-cardinality categorical variables

  • Medium-cardinality categorical variables

  • Enough rows for meaningful cross-validation

import pandas as pd

from sklearn.datasets import fetch_openml

adult = fetch_openml(
    "adult",
    version=2,
    as_frame=True,
)

X = adult.data
y = (adult.target == ">50K").astype(int)

categorical = X.select_dtypes(include=["category", "object"]).columns.tolist()
numeric = X.select_dtypes(exclude=["category", "object"]).columns.tolist()

print(X.shape)
print(len(categorical), "categorical columns")

Common preprocessing

Every experiment will use identical preprocessing for numeric features.

from sklearn.impute import SimpleImputer
from sklearn.pipeline import Pipeline

numeric_pipeline = Pipeline(
    [
        ("imputer", SimpleImputer(strategy="median")),
    ]
)

Using identical preprocessing ensures that differences come from the encoder—not from inconsistent data preparation.


1. OneHotEncoder

One-hot encoding creates one binary feature per category.

Example:

Color Red Blue Green
Red 1 0 0
Blue 0 1 0

Advantages:

  • No artificial ordering

  • Excellent for linear models

  • Highly interpretable

  • Safe default for low-cardinality features

Disadvantages:

  • Sparse matrices

  • Feature explosion

  • High memory usage with many categories

from sklearn.compose import ColumnTransformer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold, cross_val_score
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder

cv = StratifiedKFold(
    n_splits=5,
    shuffle=True,
    random_state=42,
)

onehot = ColumnTransformer(
    [
        ("num", numeric_pipeline, numeric),
        (
            "cat",
            OneHotEncoder(handle_unknown="ignore"),
            categorical,
        ),
    ]
)

model = Pipeline(
    [
        ("prep", onehot),
        ("clf", LogisticRegression(max_iter=3000)),
    ]
)

scores = cross_val_score(
    model,
    X,
    y,
    cv=cv,
    scoring="accuracy",
)

print(scores.mean())

Typical accuracy is around 0.85.


2. OrdinalEncoder

Ordinal encoding replaces each category with an integer.

Example:

Color Encoded
Red 0
Blue 1
Green 2

Although these numbers introduce an arbitrary order, modern tree-based models usually split on thresholds rather than treating the values as continuous quantities.

This makes ordinal encoding surprisingly competitive for gradient-boosted trees.

from sklearn.compose import ColumnTransformer
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.preprocessing import OrdinalEncoder

ordinal = ColumnTransformer(
    [
        ("num", numeric_pipeline, numeric),
        (
            "cat",
            OrdinalEncoder(
                handle_unknown="use_encoded_value",
                unknown_value=-1,
            ),
            categorical,
        ),
    ]
)

model = Pipeline(
    [
        ("prep", ordinal),
        ("clf", HistGradientBoostingClassifier(random_state=42)),
    ]
)

scores = cross_val_score(
    model,
    X,
    y,
    cv=cv,
    scoring="accuracy",
)

print(scores.mean())

Typical accuracy is around 0.87.

For linear models, however, this artificial ordering often hurts performance.


3. TargetEncoder

Target encoding replaces every category with the average target value observed during training.

Example:

City Encoded value
London 0.74
Paris 0.53
Tokyo 0.62

Instead of creating hundreds or thousands of dummy variables, every category becomes a single informative feature.

This often works especially well for high-cardinality categorical variables.

from category_encoders import TargetEncoder

target = ColumnTransformer(
    [
        ("num", numeric_pipeline, numeric),
        (
            "cat",
            TargetEncoder(),
            categorical,
        ),
    ]
)

model = Pipeline(
    [
        ("prep", target),
        ("clf", HistGradientBoostingClassifier(random_state=42)),
    ]
)

scores = cross_val_score(
    model,
    X,
    y,
    cv=cv,
    scoring="accuracy",
)

print(scores.mean())

Typical accuracy is around 0.88.


The target leakage trap

Target encoding is powerful precisely because it learns from the labels.

That also makes it easy to misuse.

A common mistake is fitting the encoder before cross-validation.

from category_encoders import TargetEncoder
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.pipeline import Pipeline

preprocessor = ColumnTransformer(
    [
        ("num", SimpleImputer(strategy="median"), numeric),
        ("cat", TargetEncoder(), categorical),
    ]
)

# ❌ Leakage: preprocessing sees the entire dataset first
X_encoded = preprocessor.fit_transform(X, y)

model = HistGradientBoostingClassifier(random_state=42)

scores = cross_val_score(
    model,
    X_encoded,
    y,
    cv=cv,
    scoring="accuracy",
)

print(scores.mean())

The encoder has already seen every target value—including those belonging to future validation folds.

Validation accuracy becomes overly optimistic.

The correct solution

Always put the encoder inside the pipeline.

safe_model = Pipeline(
    [
        ("prep", target),
        ("clf", HistGradientBoostingClassifier(random_state=42)),
    ]
)

scores = cross_val_score(
    safe_model,
    X,
    y,
    cv=cv,
    scoring="accuracy",
)

print(scores.mean())

Now every fold computes target statistics using only its own training partition.

This is the correct experimental protocol.

Whenever an encoder learns from y, the encoder must be fitted separately inside every training fold.


4. HashingVectorizer for categorical features

Unlike one-hot encoding, hashing never stores a category dictionary.

Instead, every category string is hashed into a fixed number of feature buckets.

Although FeatureHasher is often shown for structured data, HashingVectorizer provides an equally practical and fully stateless alternative by treating each row as a tiny “document” of feature tokens.

from scipy.sparse import hstack
from sklearn.feature_extraction.text import HashingVectorizer

docs = (
    X[categorical]
    .fillna("missing")
    .astype(str)
    .apply(
        lambda row: " ".join(
            f"{col}={row[col]}"
            for col in categorical
        ),
        axis=1,
    )
)

hasher = HashingVectorizer(
    n_features=2**12,
    alternate_sign=False,
    norm=None,
)

X_hash = hasher.transform(docs)

X_num = numeric_pipeline.fit_transform(X[numeric])

X_all = hstack([X_num, X_hash])

model = LogisticRegression(max_iter=3000)

scores = cross_val_score(
    model,
    X_all,
    y,
    cv=cv,
    scoring="accuracy",
)

print(scores.mean())

Typical accuracy usually falls between 0.84 and 0.87, depending on the number of hash buckets.

Advantages:

  • Constant memory usage

  • Naturally handles unseen categories

  • No lookup tables

  • Excellent for streaming data

Trade-offs:

  • Hash collisions

  • Less interpretability

  • Performance depends on bucket count


Side-by-side comparison

Encoder Typical accuracy Best with Main drawback
OneHotEncoder ~0.85 Linear models Wide sparse matrices
OrdinalEncoder ~0.87 Tree ensembles Artificial ordering
TargetEncoder ~0.88 High-cardinality features Leakage risk
HashingVectorizer ~0.84–0.87 Huge vocabularies Hash collisions

The exact numbers will vary by random seed, preprocessing, and library versions, but running every model with the same cross-validation pipeline makes the comparison fair.


When each encoder wins

Choose OneHotEncoder when

  • Categories are relatively few

  • Interpretability matters

  • You’re using logistic regression or linear SVMs

  • Memory is not a bottleneck

Choose OrdinalEncoder when

  • Training gradient-boosted trees

  • Categories are mostly nominal

  • You want a lightweight preprocessing pipeline

Choose TargetEncoder when

  • Features have hundreds or thousands of unique values

  • Categories contain predictive signal

  • You have enough observations per category

  • The encoder is safely inside cross-validation

Choose HashingVectorizer when

  • IDs continually grow

  • User names or URLs appear

  • Dictionaries become too large

  • Online or streaming systems are involved


Cherry on the cake: a real Kaggle leakage lesson

One of Kaggle’s best-known educational examples of target leakage comes from the Intermediate Machine Learning course’s lesson on leakage. The course demonstrates how seemingly harmless preprocessing can allow information from the validation set to influence training, producing unrealistically strong validation scores. The lesson has become a standard reference precisely because similar mistakes repeatedly appeared in competition notebooks, especially when target-dependent features or encoders were computed before cross-validation.

Target encoding is one of the easiest places to introduce exactly this bug. If category averages are computed using the full dataset, every validation fold indirectly contributes to its own encoded features. Local cross-validation scores can look excellent, only to disappoint on the private leaderboard or in production.

The takeaway is simple: if a preprocessing step learns from the target, it belongs inside a cross-validation pipeline—not before it.


Key takeaways

  • OneHotEncoding remains the safest default for low-cardinality categorical variables.

  • OrdinalEncoder often performs well with modern tree-based models despite assigning arbitrary integers.

  • TargetEncoding frequently wins on high-cardinality data, but only when fitted separately inside each training fold.

  • HashingVectorizer scales gracefully to massive categorical vocabularies without maintaining lookup tables.

  • In ScikitLearn, pipelines are your strongest defense against data leakage.

  • Good FeatureEngineering is not just about extracting more signal—it is about preserving a valid evaluation protocol.

Choosing the right encoder usually matters less than evaluating it correctly.

A leakage-free experiment with slightly lower validation accuracy is far more valuable than an impressive score that cannot be reproduced.


Call to action

Take one of your existing tabular machine learning projects and benchmark all four encoders—OneHotEncoder, OrdinalEncoder, TargetEncoder, and HashingVectorizer—using identical cross-validation splits, identical metrics, and pipeline-based preprocessing. Record not only accuracy, but also training time, feature dimensionality, and memory usage. The results may surprise you, and you’ll build a reusable evaluation workflow that is far more valuable than any single encoding technique.