← All Posts
Deep Learning · Data Preparation and Preprocessing · Encoding Categorical Features

Binary Encoding

Binary encoding assigns each category an integer ID, writes that ID in base two, and gives the model one column per bit. It can distinguish many category names with fewer columns than a full one-hot representation.

For seven names A through G, use IDs 1 through 7 and reserve ID 0 for unknown names. Three bits are enough: C has ID 3, written 011. The model receives three separate values, [0, 1, 1].

Read a code as a pattern of bits

From left to right, our bit positions represent 4, 2, and 1. C’s pattern means 0 × 4 + 1 × 2 + 1 × 1 = 3. Those place values reconstruct the ID; they are not model coefficients. Click a category to follow the conversion.

Category → fixed ID → three bit columns
Place 40
Place 21
Place 11
C → ID 3 → [0, 1, 1]. The active places sum to 2 + 1 = 3.
Known names get distinct patterns. The all-zero pattern is reserved for unknown inputs in this example; it is not an eighth learned category effect.

How many columns do we save?

With $d$ binary columns, there are $2^d$ possible patterns. For $K$ known categories plus one reserved unknown pattern, choose the smallest $d$ with enough patterns:

$$2^d\ge K+1,\qquad d=\left\lceil\log_2(K+1)\right\rceil.$$

The ceiling means round up to the next integer. Seven known categories and one unknown pattern need three columns. Eight known categories need four under this convention. For 1,000 known categories, ten columns suffice instead of 1,000 full one-hot columns.

The extra pattern is a policy choice. If every bit pattern were assigned to a known name, a different mechanism would be needed for unknowns. Libraries can use additional missing or unknown indicators, so inspect the fitted output columns rather than assuming this formula covers every configuration.

Compact codes introduce a geometry

The original names A, B, C, and D have no stated similarity. Their codes nevertheless have different distances. For zero-or-one vectors, the squared Euclidean distance equals the number of differing bits:

PairBit patternsDifferent bits
A and C001 · 0111
A and B001 · 0102
C and D011 · 1003
Code neighbors need not be semantic neighbors. A is closer to C than to B under this representation. C and D differ in every bit even though their integer IDs are consecutive.

Changing the category-to-ID assignment changes these relationships. Distance-based models may react to them. Full one-hot vectors instead put distinct categories at the same distance within a feature block.

Binary encoding also differs from feeding the integer itself into one numerical column. Bit columns do not force effects to follow the numeric ID’s order. They introduce their own pattern of sharing.

Fewer columns change what the model can express

A linear model gives one coefficient to each bit. For seven categories, three bit coefficients and an intercept provide only four adjustable numbers. They cannot represent seven arbitrary independent category effects.

See one constraint without fitting a model

B and E have codes 010 and 101. C and D have codes 011 and 100. Adding either pair gives [1, 1, 1]. Any additive linear predictor must therefore give the same sum of predictions to B + E as to C + D; both sums also contain two intercepts.

If the desired effects were B = 0, E = 0, C = 0, and D = 1, that equality would fail. A full one-hot linear model can assign those effects independently. Bit interactions or a nonlinear model can add expressive power, but compression alone does not supply it.

A decision tree can test several bits to identify a category. One bit test usually groups several names, so the required splits differ from one-hot tests. Compare the complete encoding-and-model combination rather than treating bit conversion as a neutral storage trick.

Save the mapping, width, and bit order

The encoder must use the same ID for each name at prediction time. Fit the vocabulary on training data, or supply a domain vocabulary fixed independently of the held-out data. Save the category map, number of bits, left-to-right bit order, and unknown policy alongside the model.

Our all-zero fallback distinguishes unknown inputs from every known pattern, but the model does not learn an unknown effect if that pattern never appeared in training. It also merges all unseen names. Treat missing inputs separately when needed; these examples assume non-missing strings.

Python: a small encoder with an explicit vocabulary
class BinaryMap:
    def __init__(self, vocabulary):
        if not vocabulary or len(set(vocabulary)) != len(vocabulary):
            raise ValueError("use a nonempty vocabulary with unique names")
        self.codes = {name: i + 1 for i, name in enumerate(vocabulary)}
        self.width = len(vocabulary).bit_length()

    def transform(self, values):
        result = []
        for name in values:
            code = self.codes.get(name, 0)  # Reserved all-zero unknown code.
            result.append([(code >> shift) & 1
                           for shift in range(self.width - 1, -1, -1)])
        return result

encoder = BinaryMap(["A", "B", "C", "D", "E", "F", "G"])
print(encoder.transform(["A", "C", "D", "new"]))
assert encoder.transform(["A", "C", "D", "new"]) == [
    [0, 0, 1], [0, 1, 1], [1, 0, 0], [0, 0, 0]
]
[[0, 0, 1], [0, 1, 1], [1, 0, 0], [0, 0, 0]]

Do not append a new category to this map while serving an existing model. It may change the required width, and even when spare patterns exist, the model has not learned the new category’s effect. Retraining creates a new encoder-and-model version.

Choose it for a measured width tradeoff

Binary codes reduce the output width, but the encoder still stores one mapping entry per category. Hashing avoids a learned vocabulary at the cost of collisions. Our binary mapping is collision-free among known categories; its modeling constraints come from sharing bit columns.

One-hot input can also be stored sparsely, while a binary row may contain several ones. Fewer columns do not automatically mean a smaller sparse input matrix or better predictions. Measure memory and validation performance, including behavior on rare and unseen names.

Keep the vocabulary construction inside each training fold. If category order is chosen using target statistics, that is an additional supervised step with its own leakage boundary; the simple code here uses only a supplied list of names.

C++: convert a saved ID into separate bits

The constructor reserves zero and rejects empty or duplicate vocabularies. Each lookup returns bits from the most significant position to the least significant one. It never adds an unseen name to the map.

Compile the fixed binary map
#include <cstddef>
#include <iostream>
#include <stdexcept>
#include <string>
#include <unordered_map>
#include <vector>

class BinaryMap {
    std::unordered_map<std::string, std::size_t> codes;
    std::size_t width = 0;
public:
    explicit BinaryMap(const std::vector<std::string>& vocabulary) {
        if (vocabulary.empty()) throw std::invalid_argument("empty vocabulary");
        for (std::size_t i = 0; i < vocabulary.size(); ++i)
            if (!codes.emplace(vocabulary[i], i + 1).second)
                throw std::invalid_argument("duplicate category");
        for (std::size_t n = vocabulary.size(); n; n >>= 1) ++width;
    }
    std::vector<int> encode(const std::string& name) const {
        const auto found = codes.find(name);
        const std::size_t code = found == codes.end() ? 0 : found->second;
        std::vector<int> bits(width);
        for (std::size_t i = 0; i < width; ++i)
            bits[i] = static_cast<int>((code >> (width - 1 - i)) & 1);
        return bits;
    }
};

int main() {
    const BinaryMap encoder({"A", "B", "C", "D", "E", "F", "G"});
    for (const std::string name : {"A", "C", "D", "new"}) {
        std::cout << name << ":";
        for (int bit : encoder.encode(name)) std::cout << " " << bit;
        std::cout << "\n";
    }
}
A: 0 0 1
C: 0 1 1
D: 1 0 0
new: 0 0 0

References and further reading