The Embedding Layer
By the end of this chapter you will know exactly what an embedding matrix is (a lookup table, and also a matrix multiplication), why its numbers are learned rather than designed, how to measure whether two tokens are "similar", and what it costs.
The tokenizer hands the model a list of integers: [464, 3797, 3332] for "the cat sat". Here is the problem. Those integers are labels, not quantities. Token 3797 is not "more" than token 464, and token 3798 has nothing to do with "cat" just because it is next door. But a neural network can only add and multiply. Feed it the raw ids and it will happily conclude that 3797 is about eight times as much as 464, which is nonsense.
So we need a way to turn "which token is it?" into numbers that can be added and multiplied meaningfully. That is the job of the embedding layer, the first learned component in the pipeline. It is also the simplest, which makes it the right place to build intuitions we will need for everything after.
The problem: ids are names, not numbers
Let's be precise about what goes wrong. Suppose we feed the id directly as a one-dimensional input $x = 3797$. Any weight $w$ the model applies gives $w \cdot 3797$, and for the neighbouring token 3798 it gives $w \cdot 3798$, almost the same value. The model is forced to treat tokens with nearby ids as nearly identical, but ids were assigned by the order in which BPE happened to learn merges. The numbering carries no information, so any computation that uses it as a number is hallucinating structure.
What we actually want is a representation with two properties. First, it must not impose any accidental relationship between tokens: each token should start out as free to be similar or dissimilar to any other. Second, it should be able to learn relationships from data, so that "cat" and "dog" end up close and "cat" and "parliament" end up far. The first property is easy; the second is where the magic happens.
One-hot encoding: correct but wasteful
The textbook way to feed a categorical label into a network is a one-hot vector: a vector of length $V$ (the vocabulary size) that is all zeros except for a single 1 at the position of the token's id. With a five-token vocabulary [the, cat, sat, on, mat], "cat" is $[0, 1, 0, 0, 0]$ and "mat" is $[0, 0, 0, 0, 1]$.
This solves the first problem beautifully. Every one-hot vector is the same length, they are all at the same distance from each other, and their dot products are all zero: no token is accidentally "close" to any other. The representation is perfectly neutral.
It fails the second problem just as completely. Because every pair is orthogonal, one-hot vectors can never express that "cat" and "dog" are related. Similarity is exactly zero for every pair, forever. And they are enormous: for GPT-2, each token is a 50,257-dimensional vector with 50,256 zeros in it. Storing a 1,024-token context as one-hots would take fifty million numbers, almost all of them zero. The first thing any network would do with such an input is multiply it by a weight matrix to shrink it, so let's look at what that multiplication does.
Dense embeddings: a lookup table that is also a matrix multiply
Here is the key idea, and it is small. Multiply the one-hot vector $e_i$ (a 1 at position $i$) by a matrix $E$ of shape $V \times d$. What comes out?
$$e_i \, E = E_{i,:}$$The result is exactly the $i$-th row of $E$: a dense vector of $d$ numbers. A one-hot vector times a matrix selects a row. All the zeros contribute nothing, and the single 1 copies its row out. So instead of ever materialising the one-hot vector, we can simply index into $E$ and take row $i$. That is what nn.Embedding does: it is literally a $V \times d$ matrix, and the forward pass is E[ids].
Let $V = 4$, $d = 3$ and
$$E = \begin{bmatrix} 0.1 & 0.2 & 0.3 \\ 0.5 & -0.4 & 0.0 \\ -0.2 & 0.9 & 0.7 \\ 0.8 & 0.1 & -0.6 \end{bmatrix}$$The one-hot vector for token 2 is $e_2 = [0, 0, 1, 0]$. Multiply: the first output entry is $0(0.1) + 0(0.5) + 1(-0.2) + 0(0.8) = -0.2$; the second is $0 + 0 + 0.9 + 0 = 0.9$; the third is $0.7$. So $e_2 E = [-0.2, 0.9, 0.7]$, which is row 2 of $E$. Indexing gives the same answer with no arithmetic at all.
Symbols
$V$ = vocabulary size$d$ = embedding width ($d_{model}$)
$E \in \mathbb{R}^{V\times d}$ = embedding matrix
$e_i$ = one-hot for id $i$
Tokenize
Text becomes integer ids $i_1, \dots, i_L$ (previous chapter).Look up
For each id, take row $E_{i,:}$. Equivalent to $e_i E$ but free.Stack
The $L$ rows form an $L \times d$ matrix: one vector per position. Add position information (next chapter) and hand to the first block.Learn
During training the gradient flows back into exactly the rows that were used, nudging them.Click a token below and watch the three views line up: the one-hot vector, the row it selects in $E$, and the resulting dense vector. The numbers in $E$ are made up, but the mechanism is the real one.
The single 1 in the one-hot vector copies out one row of the matrix; every other row is multiplied by zero.
In PyTorch the whole layer is a few lines. The second function does the same thing the slow way, to prove the identity:
import torch, torch.nn as nn, torch.nn.functional as F
class Embedding(nn.Module):
def __init__(self, vocab_size, d_model):
super().__init__()
self.weight = nn.Parameter(torch.randn(vocab_size, d_model) * 0.02) # V x d
def forward(self, ids): # ids: (batch, seq) of integers
return self.weight[ids] # (batch, seq, d): pure row lookup
def embed_via_onehot(weight, ids):
onehot = F.one_hot(ids, num_classes=weight.shape[0]).to(weight.dtype) # (batch, seq, V)
return onehot @ weight # (batch, seq, d): same numbers, V times slower
emb = Embedding(50257, 768)
ids = torch.tensor([[464, 3797, 3332]])
assert torch.allclose(emb(ids), embed_via_onehot(emb.weight, ids))
print(emb(ids).shape)
Because the forward pass is a row selection, the backward pass is a row selection too: the gradient of the loss with respect to $E$ is zero everywhere except the rows of tokens that appeared in the batch. A token that never appears never learns. This is the mechanism behind "glitch tokens" from the tokenization chapter: their rows sit at random initialisation for the model's whole life.
What the numbers mean
Now the question everyone asks: what is dimension 17 of the embedding for "cat"? The honest answer is: nothing in particular. Nobody designed the coordinates. The 768 numbers in each row start as small random values and are adjusted by gradient descent, thousands of times, in whatever direction makes next-token prediction slightly less wrong. The coordinates are whatever they need to be.
And yet structure appears. Think about the pressure on two tokens like "cat" and "dog". They appear in similar contexts ("the ___ sat on", "I fed the ___"), so the model must produce similar next-token predictions after each. The cheapest way to do that is to give them similar vectors, so the rest of the network can treat them alike. Similarity of use becomes similarity of geometry, without anyone asking for it. This is the distributional hypothesis (Firth, 1957: "you shall know a word by the company it keeps") turned into an optimisation outcome.
Bengio et al. (2003) introduced learned word vectors inside a neural language model. Mikolov et al. (2013), word2vec, made them famous by training them cheaply on billions of words and showing that arithmetic on the vectors captured analogies. Every LLM embedding table is a descendant of that idea, now trained jointly with the rest of the network.
Measuring similarity: cosine
To say "cat is close to dog" we need a number. The natural candidate is the dot product, but it grows with the lengths of the vectors, and lengths in an embedding table vary a lot (frequent tokens often get longer vectors simply because they receive more gradient). To compare directions only, divide out the lengths. The result is the cosine of the angle between the two vectors:
$$\cos(a, b) = \frac{a \cdot b}{\|a\| \, \|b\|}$$It ranges from $1$ (same direction) through $0$ (perpendicular, unrelated) to $-1$ (opposite). The lengths cancel, so a token that is twice as "loud" but points the same way scores the same.
Take three-dimensional vectors $a = [1, 2, 0]$, $b = [2, 3, 1]$ and $c = [-1, 0, 2]$.
$a \cdot b = 1(2) + 2(3) + 0(1) = 8$. $\|a\| = \sqrt{1 + 4 + 0} = \sqrt{5} \approx 2.236$. $\|b\| = \sqrt{4 + 9 + 1} = \sqrt{14} \approx 3.742$. So $\cos(a,b) = 8 / (2.236 \times 3.742) = 8 / 8.367 \approx 0.956$: nearly the same direction.
$a \cdot c = -1 + 0 + 0 = -1$. $\|c\| = \sqrt{1 + 0 + 4} = \sqrt{5}$. So $\cos(a,c) = -1 / (2.236 \times 2.236) = -1/5 = -0.2$: slightly opposed. In a real embedding table you would say $a$ and $b$ are near-synonyms while $a$ and $c$ are unrelated.
Vector arithmetic and the famous analogy
The result that made embeddings a household name in NLP: in word2vec, $\text{vec}(\text{king}) - \text{vec}(\text{man}) + \text{vec}(\text{woman})$ lands close to $\text{vec}(\text{queen})$. The offset between "man" and "king" (roughly, "royalty") is the same as the offset between "woman" and "queen". Relations become directions.
Three honest caveats. First, the standard evaluation finds the nearest word to the result excluding the three input words; without that exclusion the nearest word is very often just "king" itself (Nissim et al., 2020). Second, the result is approximate; "queen" is usually near, not exactly there, and the analogy works far less reliably for most relations than the headline example suggests. Third, LLM input embeddings are trained for a different job than word2vec and their analogy structure is weaker; the cleanest "concept directions" in a modern model tend to live in the residual stream deeper in the network, not in row 0. The picture is real, but it is a picture.
The map below is a hand-placed two-dimensional toy: about thirty words in clusters. Because the space is only 2-d, cosine similarities are cruder than in a real 768-d table, but the mechanics of measuring and of vector arithmetic are exactly what you would run on real embeddings.
Words in the same cluster point in similar directions from the origin (high cosine). In analogy mode, the arrow from the first word to the second is copied onto the third; the nearest word to the tip is the answer.
"Dimension 42 encodes gender." Individual coordinates almost never mean anything on their own; concepts correspond to directions (combinations of many coordinates), and the coordinate axes themselves are arbitrary: rotating the whole table by any fixed rotation, and rotating the first layer's weights to match, gives a model with identical behaviour and completely different coordinates. Only relationships between vectors (angles, offsets, distances) carry meaning.
Choosing the embedding dimension
How wide should each row be? In a transformer the embedding width is the width of the residual stream, $d_{model}$, because the embedding is the initial residual stream. So $d$ is set by the overall model design, not by the embedding layer alone. Wider means every token can carry more information at once and the attention and MLP layers have more room, at a cost that grows as $d^2$ in every block.
| Model | $V$ | $d_{model}$ | Embedding params ($V \times d$) | Share of model |
|---|---|---|---|---|
| GPT-2 small | 50,257 | 768 | 38.6M | 31% |
| GPT-2 XL | 50,257 | 1,600 | 80.4M | 5% |
| Llama 3 8B | 128,256 | 4,096 | 525M (×2, untied) | 13% |
| Llama 3 70B | 128,256 | 8,192 | 1.05B (×2, untied) | 3% |
The pattern: the embedding table is a large fraction of a small model and a small fraction of a large one, because it grows with $V \times d$ while the rest of the model grows with $N \times d^2$. Use the calculator to see the split, including the cost of an untied output layer and the memory at half precision.
Parameters and memory for the embedding table, and its share of a model of the size you choose.
Weight tying with the output layer
Look again at Figure 1 of the introduction. The pipeline starts with a $V \times d$ matrix (embedding: id → vector) and ends with a $d \times V$ matrix (unembedding: vector → one score per id). Same shape, transposed. Both are maps between "which token" and "a point in $d$-dimensional space". Do we really need two of them?
Often, no. Weight tying (Press & Wolf, 2016; Inan et al., 2016) uses one matrix for both jobs: the logits are $h E^\top$, where $h$ is the final hidden vector. The score for token $i$ is then the dot product between $h$ and token $i$'s embedding row. Read that way, the output layer says: "predict tokens whose embeddings point in the direction the network ended up pointing". That is a sensible thing for the network to want.
Why tie
Three reasons. It removes $V \times d$ parameters, which for GPT-2 small would otherwise be another 31% of the model. It acts as a regulariser: every token's row now gets gradient both when the token appears as input and when it is the target, so rare tokens learn faster. And it tends to improve perplexity in small and medium models, which is why GPT-2 and many models of that generation tied.
Why untie
The input and output jobs are not quite the same. The input embedding wants tokens that are used similarly to be close. The output embedding wants tokens that are predicted in the same contexts to be close. These overlap heavily but not perfectly (think of "a" and "an": interchangeable as inputs, never interchangeable as predictions). In large models the parameter saving is negligible (a fraction of a percent), so the extra flexibility wins, and most large models today, including the Llama 3 family, use separate input and output matrices. Small models, where the table is a big share of the parameters, still commonly tie.
Tying does not make the output a "reverse lookup". The output multiplies the hidden state against every row and produces $V$ scores; nothing is being looked up. The input side selects one row; the output side compares against all rows. Same matrix, opposite operations.
Embeddings are context-free (and how that gets fixed)
Here is something people find surprising. The embedding row for "bank" is the same vector whether the sentence is "the river bank" or "the bank approved the loan". The lookup does not know the context; it cannot, because it only receives the id. So at layer 0, every occurrence of a token carries an identical vector (plus its positional information, next chapter).
The transformer's job is to fix this. In the first block, attention lets the vector at "bank" read from "river" or "loan" and mix in what it finds; the MLP then transforms the mixed vector. By the last block, the two occurrences of "bank" have drifted to very different points, one near "shore" and "water" concepts, the other near "money" and "loan". The embedding gives every token a starting point; the blocks move it to where it belongs in this sentence. You can see this in Figure 3, and the attention chapter shows the mechanism.
You could imagine a lookup keyed on (token, previous token), but that is an n-gram table again, with $V^2$ rows. The transformer's approach of a context-free lookup followed by learned mixing is what lets a single table of $V$ rows serve every context.
Initialisation: why the numbers start small
Before training, $E$ has to contain something. GPT-2 initialises the token embedding from a normal distribution with mean 0 and standard deviation 0.02 (and the positional table with 0.01). Why so small, and why not zero?
Not zero, because then every token would start identical and the first layer's gradient would be the same for every row: symmetric, and stuck. Random breaks the symmetry. Small, because of what happens downstream. The embedding is the residual stream's starting value, and the attention scores in the first block are dot products of projections of these vectors. The dot product of two random $d$-dimensional vectors with per-coordinate standard deviation $\sigma$ has standard deviation about $\sigma^2 \sqrt{d}$.
With $d = 768$ and $\sigma = 0.02$: $\sigma^2 \sqrt{d} = 0.0004 \times 27.7 \approx 0.011$. Attention scores start near zero, softmax starts near uniform, and every token can see every other while the network figures out what to attend to. With $\sigma = 1$ instead: $1 \times 27.7 \approx 28$. Scores of ±28 push the softmax to a near one-hot, gradients through it vanish, and training stalls before it starts. (The $1/\sqrt{d_k}$ scaling inside attention helps, but it is designed for unit-variance inputs, so the embedding scale still has to be sensible.)
The 0.02 is not sacred. Some models scale by $1/\sqrt{d}$, and models that use pre-normalisation are more forgiving. Some multiply the embedding output by $\sqrt{d}$ (the original transformer did this, with tied weights, so that the tied output logits and the input scale both come out reasonable). What matters is the principle: the initial residual stream should have a scale the rest of the network's initialisation expects. code/lumen/embeddings.py exposes the standard deviation as an argument so you can experiment.
Looking at embeddings: PCA, t-SNE, and a warning
A 768-dimensional table cannot be plotted. To look at it, people project it down to two dimensions. PCA finds the two directions of greatest variance and drops the rest; it is linear, deterministic, and preserves large-scale structure but usually squashes clusters into a blob because two directions cannot hold much of the variance. t-SNE (van der Maaten & Hinton, 2008) and its relative UMAP instead try to keep each point's nearest neighbours near it in the picture, which produces pretty, well-separated clusters.
The warning: those pictures are seductive and easy to over-read. t-SNE cluster sizes mean nothing, distances between clusters mean nothing, and different random seeds or perplexity settings give different maps from the same data (Wattenberg et al., 2016). A two-dimensional shadow of a 768-dimensional object discards almost everything. Use the plots to get a feel, then verify any claim with numbers: cosine similarities, nearest-neighbour lists, or a probe trained on the real vectors.
Practice
The companion file is code/lumen/embeddings.py. It has a from-scratch Embedding module, a cosine_neighbours(E, token, k) helper, and a flag for tied or untied output projections that the GPT-2 build in code/lumen/gpt2.py reads.
Implement Embedding.forward as a row lookup and a second function that builds the one-hot matrix and multiplies. Assert they agree on random ids for $V = 50257$, $d = 768$. Time both for a batch of 8 sequences of 1,024 tokens. Then check weight.grad after a backward pass through the lookup version: how many rows are non-zero, and does it match the number of distinct ids in the batch?
Solution sketch
The lookup is thousands of times faster and uses no extra memory; the one-hot version allocates $8 \times 1024 \times 50257$ floats (about 1.6 GB in fp32) just to multiply by mostly zeros. After loss.backward(), (emb.weight.grad.abs().sum(dim=1) > 0).sum() equals ids.unique().numel(): only used rows get gradient.
Use the loader in code/lumen/gpt2.py to get GPT-2 small's token embedding matrix (wte, shape 50257 × 768). For the tokens " cat", " Paris", " seven" and " running" (note the leading spaces), list the ten nearest rows by cosine similarity. Then try the analogy " king" − " man" + " woman" with the three inputs excluded. What do you find, and how does it compare with word2vec's famous result?
Solution sketch
Neighbours are sensible but noisier than word2vec: " cat" is near " cats", " dog", " kitten"; " seven" near other number words; capitalisation and space variants of the same word are usually close. The analogy may or may not return " queen" in the top few; GPT-2's input embeddings were trained to feed a transformer, not to satisfy analogies, and much of the relational structure lives deeper in the network. Report what you see honestly.
Train two tiny GPTs (2 layers, $d = 128$, vocab from code/lumen/tokenizer.py with 1,000 merges) on the corpus in code/lumen/data.py, one with tied output weights and one without, for the same number of steps. Compare validation loss and parameter count. Then compare the cosine similarity between the input row and the output row for a few tokens in the untied model. Do they agree?
Solution sketch
At this scale the tied model has substantially fewer parameters (the table is most of the model) and usually reaches equal or better loss in the same number of steps. In the untied model the input and output rows for a token are positively but not perfectly correlated, which is the argument for untying once the parameter cost stops mattering.
nn.Embedding(V, d) compute in its forward pass?Key takeaways
- Token ids are labels, not quantities. One-hot vectors fix that but are orthogonal and huge; dense embeddings are the learned fix.
nn.Embeddingis a $V \times d$ matrix. Looking up row $i$ is identical to multiplying the one-hot $e_i$ by the matrix. Only used rows receive gradient.- The coordinates are learned, not designed. Similar usage becomes similar direction; measure it with cosine similarity, which ignores vector length.
- Vector arithmetic (king − man + woman ≈ queen) is real but approximate, and needs the inputs excluded from the search.
- The table costs $V \times d$ parameters: a third of GPT-2 small, a few percent of a 70B model. Tying the output layer saves that much again and helps small models; large models usually untie.
- Embeddings are context-free at layer 0; the transformer makes them contextual. Initialise small (GPT-2: N(0, 0.02)) so attention starts near uniform.
Further reading
- Mikolov et al. (2013). Efficient Estimation of Word Representations in Vector Space. word2vec and the analogy result.
- Pennington, Socher & Manning (2014). GloVe: Global Vectors for Word Representation. Embeddings from co-occurrence statistics; a clear account of why geometry emerges.
- Press & Wolf (2016). Using the Output Embedding to Improve Language Models. The case for weight tying.
- Nissim, van Noord & van der Goot (2020). Fair is Better than Sensational: Man is to Doctor as Woman is to Doctor. Why analogy results must exclude the inputs, and how easily they mislead.
- van der Maaten & Hinton (2008). Visualizing Data using t-SNE. The method, and Wattenberg, Viégas & Johnson (2016), How to Use t-SNE Effectively, on how not to read it.
- Bengio et al. (2003). A Neural Probabilistic Language Model. The origin of learned embeddings inside a language model.