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].

Worked example

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.

one-hot for "dog" (id 2) 0 0 1 0 0 0 1 × V (V = 6) × E (V × d) row 0: the row 1: cat row 2: dog row 3: sat row 4: mat row 5: . d = 4 numbers per row, all learned = row 2: dog 1 × d = E[2] (just index it)
Figure 1. A one-hot vector times the embedding matrix picks out one row. The zeros kill every other row, so in practice we skip the multiplication and index directly. The two views are mathematically identical, which matters for understanding gradients and weight tying later.
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$
STEP 1
Tokenize
Text becomes integer ids $i_1, \dots, i_L$ (previous chapter).
STEP 2
Look up
For each id, take row $E_{i,:}$. Equivalent to $e_i E$ but free.
STEP 3
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.
STEP 4
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.

InteractiveOne-hot × matrix = row lookupclick a token

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)
torch.Size([1, 3, 768])
Gradients only touch the rows you used

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.

Where it came from

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.

Worked example

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.

InteractiveA toy embedding mapclick two words, or pick an analogy

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.

Common confusion

"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 small50,25776838.6M31%
GPT-2 XL50,2571,60080.4M5%
Llama 3 8B128,2564,096525M (×2, untied)13%
Llama 3 70B128,2568,1921.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.

InteractiveEmbedding budget calculatordrag the sliders

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.

id i EV × d: pick row i transformerN blocks Eᵀd × V: dot with every row logits tied: the same V × d numbers, used forwards at the start and transposed at the end score for token j = h · E[j] → "predict tokens whose embedding matches the final hidden state"
Figure 2. Weight tying. The embedding matrix is used once to enter the model and once, transposed, to leave it. The output score for token $j$ becomes a dot product between the final hidden vector and $j$'s embedding.

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.

Common confusion

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.

layer 0 (after embedding) layer 12 (after the blocks) "the river bank" the river bank "the bank approved" the bank approved both "bank" vectors are identical: E[bank] (the lookup never saw the neighbours) bank₁ shore water bank₂ loan money same starting row, moved apart by attention and MLPs
Figure 3. The embedding is context-free: both "bank"s begin as the same row. Only after the transformer blocks have mixed in the neighbours do they become distinct, contextual vectors.
Why not make the embedding contextual directly?

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}$.

Worked example

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.

the same 768-d table, three 2-d shadows PCA: honest, blurry t-SNE, seed 1: neat clusters t-SNE, seed 2: same data, new map
Figure 4. Dimensionality reduction is a shadow, not the object. Which clusters exist is fairly reliable; where they sit relative to each other and how big they look is not. Check claims with cosine similarities 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.

Exercise 1 — prove the identity, then time it

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.

Exercise 2 — nearest neighbours in GPT-2's real table

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.

Exercise 3 — tied versus untied

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.

Check yourself
What does nn.Embedding(V, d) compute in its forward pass?
The layer is a matrix, and the forward pass selects the row for each id, which equals multiplying the one-hot vector by the matrix.
Why are one-hot vectors a poor representation on their own?
All one-hot dot products are zero, so "cat" and "dog" are exactly as related as "cat" and "parliament". And each vector has V − 1 zeros.
Two tokens have embeddings a = [2, 0] and b = [4, 0]. Their cosine similarity is:
Same direction, different length: cosine = 8 / (2 × 4) = 1. Cosine ignores magnitude.
Why does the same token have the same vector at layer 0 regardless of the sentence?
Row lookup cannot see neighbouring tokens. Attention and MLP layers make the representation contextual afterwards.
Weight tying means:
Tying reuses E: entering the model selects a row; leaving it computes h · E[j] for every j. It saves V × d parameters.

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.Embedding is 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