One-Hot Encoding
One-hot encoding gives each category its own indicator column. A known category activates its column with a 1 and leaves the other columns at 0. That lets a model distinguish names without placing them on an arbitrary numerical scale.
Take a support-channel feature with three categories: chat, email, and phone. Fix that column order. Email becomes [0, 1, 0], which simply means “not chat, yes email, not phone.”
A row selects one coordinate
The column order is part of the representation. The three numbers in [0, 1, 0] have meaning only when paired with the names chat, email, and phone. Swapping columns without updating the model would change what its weights mean.
Suppose an illustrative linear predictor starts at 20 and adds a channel-specific adjustment: 8 for chat, 3 for email, or 12 for phone. Click a category to see which adjustment survives:
In notation, let $I_{\mathrm{chat}}$, $I_{\mathrm{email}}$, and $I_{\mathrm{phone}}$ be those zero-or-one indicators. The figure calculates
Each category can contribute a separate amount. Compare that with one numerical code and one coefficient, which ties the effects to the code’s spacing.
“One hot” applies to each categorical feature block
If the input has both channel and region, each feature gets its own block. For channel = email and region = west, with region columns east and west, the combined row is [0, 1, 0 | 0, 1]. It contains two ones, one for each feature.
For a full block of known, mutually exclusive categories, distinct one-hot vectors have the same Euclidean distance: two coordinates differ by 1, giving distance $\sqrt{2}$. There is no invented order within that block. Overall distances still depend on other features and their scaling; one-hot encoding does not make every input equally similar in a useful task-specific sense.
A field that allows several simultaneous categories, such as a document with multiple tags, needs a multi-label or multi-hot representation. It is not the same as a single mutually exclusive category.
Freeze the vocabulary and preserve column names
An encoder can learn categories from training data or use a vocabulary fixed by the task definition. Either way, validation and serving rows must use the same output columns. Do not fit separate encoders to separate batches.
Python: fit once, then transform known and unseen categories
import numpy as np
from sklearn.preprocessing import OneHotEncoder
train = np.array([["email"], ["chat"], ["phone"], ["email"]])
encoder = OneHotEncoder(
categories=[["chat", "email", "phone"]],
handle_unknown="ignore", sparse_output=False,
).fit(train)
new = np.array([["phone"], ["chat"], ["fax"]])
encoded = encoder.transform(new)
assert encoded.tolist() == [[0., 0., 1.], [1., 0., 0.], [0., 0., 0.]]
print(encoder.get_feature_names_out(["channel"]).tolist())
print(encoded)
['channel_chat', 'channel_email', 'channel_phone']
[[0. 0. 1.]
[1. 0. 0.]
[0. 0. 0.]]
The final row contains the unseen category fax. We deliberately chose handle_unknown="ignore", which makes this feature block all zeros. The default policy raises an error instead. Neither policy learns an effect for fax from nothing.
Why people sometimes drop a category
For a known category, the three full indicator columns add to 1. An intercept column is also all ones. In an unregularized linear model with an intercept, that creates redundant columns: several coefficient settings can give the same predictions.
One conventional solution chooses a reference category and drops its indicator. With chat as the reference, the example’s predictions can be written with a baseline of 28, an email adjustment of −5, and a phone adjustment of +4. Chat activates neither remaining indicator; email gives 23 and phone gives 32.
What is redundant, and when does it matter?
In the full encoding, adding 1 to the intercept and subtracting 1 from every channel coefficient leaves all known-category predictions unchanged. An unregularized least-squares fit therefore cannot uniquely identify those coefficients without an additional convention.
This does not mean predictions are always impossible, or that every estimator requires a dropped column. A solver may choose one solution, and regularization changes the problem. Dropping a category also changes the symmetry of a regularized representation, so it need not produce the same fitted predictions as retaining all columns. Trees do not need this linear-model identifiability fix.
A dropped reference and an unknown category can collide
| Input | Email column | Phone column |
|---|---|---|
| Chat · reference | 0 | 0 |
| 1 | 0 | |
| Phone | 0 | 1 |
| Fax · unseen | 0 | 0 |
drop=["chat"] and handle_unknown="ignore", fax and chat produce the same block. A downstream model cannot distinguish them from these two columns alone.With full columns and unknowns ignored, the all-zero unknown block differs from every known category. But it still has no directly learned unknown-category effect if no such rows occurred during fitting. If missing and unknown inputs need separate treatment, preserve them deliberately; the missing-category example shows one explicit scheme.
Rare-category grouping offers another policy. Scikit-learn’s handle_unknown="infrequent_if_exist" sends unseen categories to a learned infrequent bucket if such a bucket exists; otherwise it falls back to all zeros. Read rare-category grouping before relying on that bucket.
Many columns need not mean a dense allocation
One-hot data is sparse: for one full categorical block, each known row stores one nonzero value. Scikit-learn returns a sparse representation by default. Keep it sparse through compatible preprocessing and estimators rather than immediately calling toarray() on a large result.
Compare the storage for one large block
With one million rows and 100,000 category columns, a dense float64 matrix needs 800 billion bytes, about 800 GB in decimal units. A CSR representation with one nonzero per row needs roughly 16 MB for float64 values, 32-bit column indices, and 32-bit row pointers, excluding the vocabulary and object overhead.
The sparse estimate is $8+4$ bytes per nonzero plus about 4 bytes per row pointer. More categorical features add more nonzeros per row. The estimator also needs its own parameters and working memory, so sparse input is not a guarantee that training will be cheap.
High cardinality also creates a statistical problem: many categories have little training support. More columns do not create more evidence about them. Compare grouping, hashing, or carefully validated target encoding when the feature and model warrant it.
Know what the representation leaves to the model
One-hot encoding distinguishes identities but supplies no semantic similarity between them. In an additive linear model, separate channel and region blocks also do not automatically learn an email-in-west interaction; that requires an interaction term or a model that can represent it.
Evaluate the complete fitted pipeline on the intended split. Inspect rare and unseen categories, the feature-name order, memory use, and the effect of dropping or grouping columns. A successful conversion to zeros and ones is only the start of that evaluation.
C++: a fixed dense block for a small vocabulary
This teaching implementation uses the same explicit column order and all-zero unknown policy as the Python example. It rejects duplicate categories and does not insert unseen strings into the map. Creating a dense output costs time proportional to vocabulary size; use a sparse representation for wide feature spaces.
Compile the one-hot map
#include <iostream>
#include <stdexcept>
#include <string>
#include <unordered_map>
#include <vector>
class OneHotMap {
std::unordered_map<std::string, std::size_t> columns;
std::size_t width;
public:
explicit OneHotMap(const std::vector<std::string>& vocabulary)
: width(vocabulary.size()) {
if (!width) throw std::invalid_argument("empty vocabulary");
for (std::size_t i = 0; i < width; ++i)
if (!columns.emplace(vocabulary[i], i).second)
throw std::invalid_argument("duplicate category");
}
// Deliberate policy: an unknown category produces an all-zero block.
std::vector<int> encode(const std::string& category) const {
std::vector<int> row(width, 0);
const auto found = columns.find(category);
if (found != columns.end()) row[found->second] = 1;
return row;
}
};
int main() {
const OneHotMap encoder({"chat", "email", "phone"});
for (const std::string category : {"phone", "chat", "fax"}) {
std::cout << category << ":";
for (int value : encoder.encode(category)) std::cout << " " << value;
std::cout << "\n";
}
}
phone: 0 0 1
chat: 1 0 0
fax: 0 0 0
References and further reading
- Scikit-learn: OneHotEncoder — column vocabularies, dropping, sparse output, and unknown-category behavior.
- Scikit-learn: categorical encoding guide — indicator blocks, reference categories, and preprocessing choices.
- SciPy: CSR sparse matrices — nonzero values, column indices, and row-pointer storage.