Scaling Categorical Features: Practical Memory and Retrieval Techniques
Working with massive categorical data can be one of the most painful bottlenecks in applied machine learning. When cardinalities hit millions, naive embedding tables quickly consume memory and slow training. This article walks through practical strategies that balance memory efficiency, controlled hash collisions, and fast embedding retrieval. Examples use Python snippets and common libraries so you can adapt them to real pipelines.
Why feature hashing and embedding optimization matter
Embedding tables grow roughly as vocab_size times embedding_dim times bytes_per_value. That simple formula often surprises engineers. For instance, an embedding with 50 million categories and dimension 64 at float32 would require tens of gigabytes of memory. That makes direct storage impractical for many deployment scenarios. Feature hashing offers a space-efficient alternative but trades off the risk of collisions. The aim is to choose techniques that keep collisions manageable while shrinking memory and maintaining fast lookups.
- Memory estimation helps you decide between truncation, hashing, or tiered storage.
- Collision strategies include signed hashing, multiple independent hashes, and count-min sketches.
- Retrieval performance depends on sparse batching, GPU-friendly embeddings, and caching.
Quick memory math
Before choosing an approach, compute a few rough numbers. Use this generic formula as a guide and plug in example values when planning capacity:
- Memory for embeddings = vocab_size * embedding_dim * bytes_per_value
- Example: vocab_size = 10_000_000, embedding_dim = 64, bytes_per_value = 4 (float32) gives about 2.56 GB
- Consider overhead for indexing, alignment, and optimizer state when training (Adam/LARS can multiply memory usage)
Those calculations make it clear why strategies that reduce vocab_size or compress embeddings are attractive.
Feature hashing primer
Feature hashing maps high-cardinality categorical keys into a fixed-size integer space with a cheap hash function. The core idea is simple: hash the categorical key into an index in [0, m). A few practical additions reduce harmful effects.
- Signed hashing: assign a sign ±1 based on a second hash to reduce bias when collisions occur.
- Multiple hash functions: use two or three independent hashes and combine them to reduce collision probability for frequent items.
- Collision-aware updates: maintain a small frequent-item table to avoid hashing very popular categories.
Below is a compact Python example showing the hashing trick and a signed variant. Strings are hashed to int via Python’s built-in hash and modded into a table size. Adapt to a stable hash like xxhash in production.
import numpy as np
from collections import Counter
def hash_index(key, m):
h = hash(key)
return h % m
def signed_hash_index(key, m):
h = hash(key)
sign = 1 if (hash(str(key) + \"_sign\") & 1) == 0 else -1
return (h % m, sign)
# Example usage
keys = [\"user_1\", \"user_2\", \"item_42\", \"user_1\"]
m = 2_000_000
indices = [hash_index(k, m) for k in keys]
signed = [signed_hash_index(k, m) for k in keys]
print(indices[:4])
print(signed[:4])
Notes on the snippet: the second hash for sign can be any cheap, independent hash. Using deterministic, fast hash libraries makes behavior reproducible across processes.
Embedding table optimizations
After hashing or truncating the vocabulary, there are multiple ways to shrink the memory footprint of the actual embedding matrix.
- Quantization: reduce weights from float32 to float16, int8, or use per-row 8-bit quantization. This often reduces size by 2x to 4x with modest accuracy tradeoffs.
- Low-rank and hashing-based compression: factorize the embedding matrix or use product quantization. Libraries like Faiss offer useful implementations for nearest-neighbor compression.
- Sharding and tiered storage: keep hot embeddings in memory and cold ones on SSD, loading on demand. This helps when access is highly skewed.
- Sparse updates: use optimizers and frameworks that support sparse gradients to avoid materializing full optimizer state for every row.
For training, frameworks such as PyTorch and TensorFlow provide primitives that support efficient sparse lookup and embedding bags. The pattern is: map raw keys to indices, group indices per batch, look up embeddings, then aggregate.
import torch
# Basic EmbeddingBag usage for per-example sparse features
vocab_size = 5_000_000
dim = 64
device = torch.device(\"cpu\") # switch to cuda when available
emb = torch.nn.EmbeddingBag(vocab_size, dim, mode='mean', sparse=True).to(device)
# Simulate batch: indices and offsets for embeddingbag
indices = torch.tensor([10, 20, 20, 300, 10], dtype=torch.long)
offsets = torch.tensor([0, 2, 5], dtype=torch.long) # defines 3 examples
out = emb(indices, offsets)
print(out.shape) # batch_size x dim
EmbeddingBag avoids per-feature summation loops and can be faster and more memory friendly for sparse categorical data. When training at scale, consider frameworks built for large embeddings such as TorchRec or specialized parameter servers.
Collision diagnosis and mitigation
Collisions are the main downside of hashing. They mix signals from different categories and can be particularly harmful when a few collisions involve high-frequency keys. You can detect and mitigate collisions with a few tactics.
- Frequency-aware split: allocate explicit indices for top-K frequent categories and hash the rest. That reduces error for heavy hitters.
- Adaptive table sizing: monitor collision rates and adjust the hash space m dynamically, or use multiple hash tables and combine them.
- Collision logging: during development, log hash index occupancy counts to identify problematic hotspots.
Example approach: reserve indices for the top 100k users, hash the rest into a smaller table. This hybrid minimizes interference for the most important items without blowing up memory.
Retrieval strategies for latency-sensitive systems
Production systems often need both fast training and low-latency inference. Retrieval choices depend on server memory, GPU availability, and expected QPS.
- In-memory dense table: simplest and fastest for moderate vocab sizes.
- Sharded servers: split embeddings across machines; use consistent hashing for routing.
- NVMe or SSD backed cache: combine an LRU or frequency cache in RAM with on-disk storage for cold rows.
- Approximate nearest neighbor: when embeddings represent items for recommendation, use ANN indices like HNSW for retrieval, while keeping the actual embedding weights compressed.
For batch inference, group keys and perform vectorized lookups. For online per-request inference, keep latencies low with a hot-cache and avoid blocking I/O during critical paths.
Putting it together: a sample pipeline
Here is an outline of a pragmatic pipeline that combines hashing, hot-table reservation, and a compressed backing store. It is not exhaustive but shows how components slot together.
- Collect frequency statistics via a streaming counter.
- Reserve indices for top-K categories and persist a mapping.
- Hash remaining keys into a fixed-size table, using signed hashing to reduce bias.
- Store embedding rows in a quantized format and keep an in-memory cache for hot rows.
- During training use sparse optimizer support; during inference favor read-only float16 or dequantize cached rows.
# Sketch of mapping logic
hot_k = 100_000
m = 2_000_000 # hashed table size
# Assume freq_counter is a Counter mapping category->count
# Build hot mapping
hot_items = [k for k, _ in Counter([]).most_common(hot_k)] # placeholder
hot_map = {k: i for i, k in enumerate(hot_items)}
def map_key(key):
if key in hot_map:
return hot_map[key], False # direct index, not hashed
idx, sign = signed_hash_index(key, m)
return hot_k + idx, sign # offset hashed region after hot table
This hybrid strategy is commonly useful when access distribution is heavy-tailed. Hot items get stable indices, while the hashed region remains bounded.
Practical tips and caveats
- Avoid relying on Python’s built-in hash for cross-process determinism unless you control hash salt settings; use a stable library like xxhash or murmurhash when reproducibility matters.
- Quantization reduces memory but may require calibration to avoid quality drops. Try per-row scale and zero-point when using asymmetric quantization.
- Monitor model performance with and without hashing for a small sample to estimate information loss before full rollout.
- When using multiple hashes, ensure independence between hash functions or choose provably independent families if collisions are critical.
Finally, instrument collision metrics and per-key losses. That helps decide whether to increase table size, add more hot items, or change compression.
Final notes
Handling millions of categorical values requires tradeoffs. Feature hashing, signed hashing, hybrid hot tables, and embedding compression are practical tools that can reduce memory and keep retrieval fast. Experiment with a small controlled dataset, profile memory and latency, and iterate toward a design that matches your constraints.