How a computer can know that “cat” is closer to “kitten” than to “carburetor”. This is the idea under every modern AI model, built from first principles and taken to interview depth.
Why computers need numbers for things
Here is a question that sounds too simple to be interesting. How do you hand the word "cat" to a computer?
A neural network is, in the end, a big pile of multiplications and additions. It eats numbers and produces numbers. But most of what we care about isn't numbers. It's words, users, products, songs, amino acids, chess moves, zip codes. Before any learning can happen, every one of these symbols has to become a list of numbers, a vector.
How we choose those vectors matters enormously. This chapter is about the best answer we have found, the embedding, and every model in the rest of this book, from GPT to CLIP to Whisper to V-JEPA, starts with one.
To appreciate the good answer, we first need to feel the pain of the obvious ones. We'll try three, and each will fail in an instructive way. (If the linear algebra or the gradients below feel rusty, Chapter 0 has everything this chapter uses.) But first we have to say what "the word cat" even is to a program.
First, text becomes a list of IDs
Text arrives as one long string of characters. Before we can attach a vector to "cat", we have to decide what the units are and give each unit a name the computer can index. Three terms do this work for the rest of the book.
- A token is one unit of text that the model reads. In this chapter a token is a word or a punctuation mark, found by splitting the text on spaces.
- The vocabulary is the set of distinct tokens the system knows, and is its size.
- A token's ID is its position in the vocabulary, an integer from to . It is a name, like a seat number, and it says nothing about the token itself.
Here is the whole pipeline on a corpus of two sentences.
Fourteen tokens, eight distinct ones, so , and "cat" is ID 1 only because it sorts second, right after the period. Everything downstream sees just the array of IDs. The string "cat" never enters the network. Its ID does, and the question of this chapter becomes which vector should we attach to each ID?
Splitting on spaces is a toy, and it breaks quickly. "cat" and "cats" get unrelated IDs, "Cat" with a capital is a third token, and a word that never appeared while we built the vocabulary has no ID at all. Systems that split by words reserve a special <unk> token ("unknown") for those. Real language models split text into subword tokens instead, pieces such as "embed" and "dings" chosen by an algorithm so that any string at all can be spelled, with vocabularies of tens of thousands to a few hundred thousand tokens. Chapter 2 builds those tokenizers from scratch. Nothing in this chapter depends on how the IDs were produced, so we'll use whole words, which are easier to read.
A vocabulary with a frequency cutoff and reserved IDs
Real vocabularies drop rare words and reserve a few IDs for special tokens. Implement build_vocab(tokens, min_count) returning a list with "<pad>" at ID 0 (used later to fill short sequences), "<unk>" at ID 1, and then every token that appears at least min_count times, most frequent first, with ties broken alphabetically. Then implement encode(tokens, vocab) returning an int64 array of IDs, in which every token missing from the vocabulary gets the ID of <unk>.
Attempt 1, just number them
We now have an integer for every word. The simplest idea is to hand that integer to the model directly.
| word | ID |
|---|---|
| cat | 0 |
| dog | 1 |
| carburetor | 2 |
| kitten | 3 |
IDs are perfect for storing and indexing words, which is what they were made for. But they are a terrible way to feed words into a model, because the model treats the IDs as quantities. To a linear model, the input 2 is twice the input 1, and 1 sits exactly halfway between 0 and 2.
That last fact has a sharp consequence. Any linear function satisfies , whatever and are. So a linear model fed raw IDs is forced to predict that a dog's score on anything (cuteness, price, sentiment) is the average of a cat's and a carburetor's. No amount of training can change that, because it's baked into the input.
The numbers impose an order and a magnitude that don't exist. Whatever arbitrary order we picked (alphabetical, by frequency, at random) gets baked into the model as if it meant something. A deep network can learn to undo some of this with enough units and data, but it has to spend capacity carving the number line back into separate pieces, one per word.
Attempt 2, one-hot vectors
The standard fix in classical machine learning is the one-hot encoding. If the vocabulary has words, word number becomes a vector of length that is all zeros except for a single 1 in position ,
With , the four words become
This fixes the fake ordering. No word is "bigger" than another, and no word sits between two others. Each one just gets its own axis. Let's write it in code. (Press ▶ Run or Shift+Enter. The first run downloads Python into your browser, which takes a few seconds.)
The last line prints the Gram matrix, the table of every dot product . It is the identity matrix . That one fact contains both the strength and the fatal weakness of one-hot codes.
One-hot encode a batch of token IDs
Write one_hot(ids, V) that turns an integer array of any shape into one-hot vectors, adding a final dimension of size V. For example, ids with shape (batch, seq) becomes (batch, seq, V).
Use no Python loops (NumPy indexing is enough), return float32, and raise a ValueError if any ID is out of range [0, V).
That -1 test is not pedantry. Negative indices silently wrap around in NumPy and PyTorch, so a bug that produces -1 IDs (a common "unknown token" sentinel) will quietly map them to the last word in your vocabulary. Here is the PyTorch version.
The fatal flaw, everyone is equally far from everyone
Now look at the geometry. For any two different words ,
In plain words, every pair of different words has a dot product of zero, sits exactly apart, and is at a right angle to every other. (The distance follows from .)
So "cat" is exactly as similar to "kitten" as it is to "carburetor". The representation contains no notion of similarity at all. Check it yourself.
Why this kills generalization
This isn't just ugly. It actively prevents learning from transferring. Feed a one-hot input into a linear layer with weight matrix . The product is
so the layer only ever reads column . The backward pass only ever writes column too. If the loss has gradient at the layer's output, the gradient of a linear layer (derived in §0.2) is the outer product
which is zero everywhere except column . In plain words, a training example about word can only change word 's private column. Everything the model learns about "cat" is stored in the cat column, and the kitten column receives exactly zero gradient from cat examples.
Here is a two-row example you can check by hand. With
which is just the third column (the code below counts from 0, so carburetor is index 2 there). We can watch the consequence. Below, a tiny linear model learns a "cuteness score" from examples that never mention kitten. With one-hot inputs, its prediction for kitten stays at exactly the initial value.
What a linear model on one-hot features actually learns
We can say exactly what a linear model on one-hot features converges to, and the answer is revealing. Stack training examples into a matrix whose row is , with targets , and fit weights by ridge regression,
Setting the gradient to zero gives the normal equations . Now look at the two pieces. Entry of counts the examples that are both word and word , which is impossible unless . So , where is how many examples are word . And entry of is , the sum of the targets of word 's examples. A diagonal system solves one coordinate at a time,
In plain words, each word's weight is its own average target, shrunk toward zero more strongly when the word is rare. The equations for different words never touch each other. A word never seen in training (, so ) gets exactly , whatever we know about its neighbors.
A worked example. Suppose cat appears 3 times with targets 1.0, 0.8, 0.9, kitten once with 1.0, and . Then and . Kitten's single glowing example is shrunk hard, and nothing about the three cat examples helps it. A fifth word with no examples gets 0. That is the "no transfer" problem written as a formula.
Closed-form ridge regression on one-hot features
Implement fit_onehot_ridge(ids, y, V, lam) that returns the ridge solution for one-hot features, using the closed form above. ids is an int array of shape (N,), y a float array of shape (N,). Use no Python loops and do not build the matrix (with a million examples and a million words it would hold entries).
The second flaw, size
Real vocabularies are large. Modern language-model tokenizers use to tokens. Recommender systems embed hundreds of millions of users and items. A one-hot vector for a single token in Llama 3's vocabulary () holds 128,256 numbers, of which 128,255 are zero.
Put numbers on a single training batch. Take sequences of tokens, so token positions. Stored as one-hot float32 rows, the batch takes bytes GB, before the model has done anything. Stored as int64 IDs, it takes bytes KB, which is 64,000 times less. Multiplying the one-hot batch by a matrix with costs floating-point operations, and all but a vanishing fraction of them multiply by zero. Copying the right rows instead costs number copies.
Play with the sizes below, and flip the left panel between one-hot and a small dense embedding.
Attempt 3, short codes that nobody learned
If one-hot vectors are too long, why not compress them? Write each ID in binary. With words you need only bits, so Llama 3's 128,256 tokens fit in 17 bits instead of 128,256 slots. Hashing tricks do something similar, mapping each item to a short pseudo-random code.
These codes are short and dense, but now similarity comes back wrong. With 2 bits, cat = 00, dog = 01, carburetor = 10, kitten = 11. The Hamming distance (number of differing bits) says cat and dog are neighbors (distance 1), while cat and kitten are as far apart as possible (distance 2). Worse, a linear model on these bits shares weights between items that happen to share a bit. Every update for dog (01) also moves kitten (11), because they share the last bit. That is transfer, but transfer between arbitrary strangers.
Binary codes, and the off-by-one that hides in them
Implement binary_code(ids, V) that maps an int array of any shape to its binary code with the most significant bit first, returning float32 of shape ids.shape + (n_bits,), where n_bits is the smallest number of bits that can represent every ID in [0, V) (and at least 1). Raise ValueError for out-of-range IDs.
The classic bug is computing n_bits with floating-point logs. int(np.log2(V)) + 1 gives 11 bits for V = 1024, one more than needed.
The idea, short and dense learned vectors
Here is the leap. Keep the vectors short and dense, as in Attempt 3, but let the model learn the numbers instead of fixing them in advance. Give each word a short list of real numbers (say ),
If training goes well, words used in similar ways end up with similar vectors. Then two things happen that none of our attempts could deliver.
- Similarity becomes geometry. "cat" and "kitten" are close together, and "carburetor" is far away. We can measure meaning with a ruler (§1.3).
- Learning transfers, between the right items. Every gradient update for "cat" nudges a vector that sits near "kitten", and any downstream weight that reads that region of space now treats kitten similarly. Rare words borrow strength from common neighbors.
This mapping from discrete things to dense vectors is called an embedding, and the vector is the embedding of "cat". Formally, we write
In plain words, an embedding is a function from the vocabulary to -dimensional space, stored as a matrix with one row per item. Item 's vector is row .
Two remarks keep this honest. First, has exactly as many numbers as the weight matrix of a linear layer on one-hot inputs with outputs. The embedding is not a new kind of parameter. It is that same layer, stored row-wise and computed by indexing, as §1.2 proves. What changes the outcome is what sits above it. The layers that read the vectors are shared by every word, so once cat and kitten get nearby vectors, everything those shared layers learn about one applies to the other.
Second, dense codes are combinatorially richer than one-hot ones. A one-hot code with units can name only items. A code where each of units can be on or off can name patterns, which is about a million for , and real-valued units can do far more. Goodfellow, Bengio and Courville (2016, §15.4) make this argument for distributed representations in general.
A tool for the rest of the chapter
Before we leave one-hot land, one coding problem that we'll reuse constantly. Every similarity search, nearest-neighbor check, and clustering step in this chapter needs all pairwise distances between a set of vectors, fast.
All pairwise distances, no loops
A classic interview question. Given X of shape (n, d) (one vector per row), return the (n, n) matrix of Euclidean distances .
Use no Python loops, and don't build the (n, n, d) difference tensor, since that blows up memory for large n (with and it would need 614 GB in float64). Use the identity
Watch out for floating-point error. The right-hand side can come out as a tiny negative number, and sqrt of that is nan.
The expansion trades a little accuracy for a lot of speed. When two points are very close compared with their distance from the origin, and are two huge, nearly equal numbers, and subtracting them loses most of the significant digits (catastrophic cancellation). Centering the data first (subtracting the mean vector, which leaves all distances unchanged) shrinks the norms and recovers most of the precision. torch.cdist (for larger inputs) and scikit-learn's euclidean_distances use the same expansion with a similar clamp.
| encoding | length per item | similarity it implies | learns? | transfer between items |
|---|---|---|---|---|
| integer ID | 1 number | fake order and magnitude | no | forced and wrong (linear in the ID) |
| one-hot | numbers | none (all pairs orthogonal) | no | none |
| binary or hashed code | to tens of numbers | arbitrary (shared bits) | no | between arbitrary items |
| learned embedding | numbers, | learned from data | yes | between items the data says are similar |
Read more
- Hinton, McClelland & Rumelhart (1986). Distributed Representations. The original argument for patterns over one-unit-per-concept codes.
- Bengio et al. (2003). A Neural Probabilistic Language Model. The learned lookup table as the cure for one-hot language models.
- Goodfellow, Bengio & Courville (2016). Deep Learning, §15.4 Distributed Representation. Why distributed codes are exponentially more expressive than local ones.
- Jurafsky & Martin. Speech and Language Processing, Chapter 5 (Embeddings). The best textbook companion for this whole chapter.
- Guo & Berkhahn (2016). Entity Embeddings of Categorical Variables. Embeddings as the replacement for one-hot features in tabular models.