Part 1 · Tokenization and embeddings · module 2 of 2 · 18 min
Embedding spaces
How token ids become geometry — static vs. contextual embeddings, cosine similarity, position embeddings, and training embeddings yourself.
The essence
An embedding is a row of a lookup table: one dense vector of a few hundred to a few thousand numbers per token id. Training arranges those vectors so that geometric relationships — distance, direction, angle — line up with semantic ones. Meaning becomes a location, and similarity becomes an angle you can measure.
Why it matters
Embeddings are where 'the model understands language' actually lives, and they are the only part of an LLM you can inspect directly without running anything. Nearly all interpretability work is measurement on these vectors, and the static-vs-contextual distinction is the single most important thing to get straight: word2vec gives a word one vector forever, a transformer gives it a different one in every sentence.
Topics covered (17)
- 01Word2Vec vs. GloVe vs. GPT vs. BERT — what each 'embedding' means
- 02Exploring pretrained GloVe embeddings
- 03Comparing embeddings trained on different corpora (Wikipedia vs. Twitter)
- 04Exploring GPT-2 and BERT embeddings
- 05Arithmetic with tokens and embeddings
- 06Cosine similarity, and its relation to correlation
- 07Cosine similarities in GPT-2
- 08Unembedding: turning vectors back into tokens
- 09Position embeddings
- 10Exploring learned position embeddings
- 11Training embeddings from scratch
- 12Building a data loader for embedding training
- 13Building the model that learns the embeddings
- 14Choosing the loss function
- 15Training and evaluating the embedding model
- 16How embeddings change during training
- 17How stable embeddings are across training runs
From an integer to a direction in space
The embedding matrix has one row per vocabulary entry and one column per dimension — for GPT-2 that is 50,257 rows by 768 columns. Tokenizing gives you row indices; the embedding layer looks up those rows. That is the whole operation.
What makes it interesting is that the rows are learned. Nothing forces 'king' and 'queen' near each other at initialisation — they start random. Training on prediction pushes tokens that appear in similar contexts toward similar positions, and semantic structure appears as a side effect of getting the next word right.
The four things called 'embeddings'
| Model | Kind | Trained on | One vector per |
|---|---|---|---|
| word2vec | Static | Predicting nearby words | Word type |
| GloVe | Static | Global co-occurrence counts | Word type |
| BERT | Contextual, bidirectional | Masked-token prediction | Token occurrence |
| GPT | Contextual, causal | Next-token prediction | Token occurrence |
Cosine similarity, and why not Euclidean distance
Cosine similarity is the dot product of two vectors divided by their lengths — the cosine of the angle between them. It runs from 1 (same direction) through 0 (orthogonal, unrelated) to −1 (opposite).
Direction is used rather than distance because vector magnitude in these spaces tracks token frequency far more than meaning. Two words can be semantically identical while one is far longer simply because it appeared more often. Normalising away the length removes that nuisance.
There is a neat identity worth knowing: cosine similarity on mean-centred vectors is exactly the Pearson correlation between them. So 'these two embeddings are similar' and 'these two vectors correlate' are the same statement, which is why so much interpretability analysis reduces to correlation matrices.
import numpy as np
def cosine(a, b):
return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b)))
a = np.random.randn(300)
b = a + 0.5 * np.random.randn(300)
print(cosine(a, b)) # high: b is mostly a
print(cosine(a, 100 * a)) # 1.0 — scale is irrelevant
# Mean-centre first and cosine IS Pearson correlation.
ac, bc = a - a.mean(), b - b.mean()
print(cosine(ac, bc), np.corrcoef(a, b)[0, 1]) # identical
# Batched: normalise the rows, then one matmul gives every pair.
def cosine_matrix(X):
Xn = X / np.linalg.norm(X, axis=1, keepdims=True)
return Xn @ Xn.TNote: Normalise once and multiply once. Looping over pairs is the standard beginner performance mistake here.
import gensim.downloader as api
glove = api.load("glove-wiki-gigaword-300")
print(glove.most_similar("python")[:5])
# king - man + woman ~= queen. Directions encode relations.
print(glove.most_similar(positive=["king", "woman"], negative=["man"])[0])
# The caveat: the query words are excluded from the result by default.
# Without that exclusion the nearest vector is usually just 'king' again,
# because the analogy shifts the point only a little. The effect is real
# but considerably weaker than the popular demonstration implies.
# Corpus matters as much as algorithm: the Twitter-trained vectors and the
# Wikipedia-trained vectors disagree sharply on informal and topical words,
# because 'similar context' means something different in each corpus.Contextual embeddings, and unembedding
In a transformer the input embedding is only the starting point. Each layer adds to it, so the representation of a token at layer 12 has absorbed information from the surrounding tokens. That evolving vector is what the residual stream carries.
At the output the process runs backwards. The final hidden state is multiplied by an unembedding matrix — often the transpose of the input embedding, a trick called weight tying — to produce one score per vocabulary entry. Softmax turns those scores into a distribution over next tokens.
You can apply the unembedding to any layer's hidden state, not just the last. Doing so shows what the model would have predicted had it stopped there, which is the whole idea behind the logit lens later in the curriculum.
import torch
from transformers import AutoModel, AutoModelForCausalLM, AutoTokenizer
tok = AutoTokenizer.from_pretrained("gpt2")
model = AutoModel.from_pretrained("gpt2", output_hidden_states=True)
s1 = "I sat on the river bank."
s2 = "I deposited it at the bank."
def bank_vector(sentence, layer=-1):
ids = tok(sentence, return_tensors="pt")
with torch.no_grad():
out = model(**ids)
pieces = tok.convert_ids_to_tokens(ids["input_ids"][0])
i = next(k for k, p in enumerate(pieces) if "bank" in p)
return out.hidden_states[layer][0, i]
v1, v2 = bank_vector(s1), bank_vector(s2)
print(torch.cosine_similarity(v1, v2, dim=0)) # well below 1: senses separate
# The static input embedding, by contrast, is identical in both sentences.
row = tok(" bank")["input_ids"][0]
print(model.get_input_embeddings().weight[row][:5])
# Unembedding: hidden state -> vocabulary scores.
lm = AutoModelForCausalLM.from_pretrained("gpt2")
logits = lm.lm_head(v1) # one score per token
print(tok.decode(logits.argmax()))Position embeddings
Attention has no inherent sense of order — it computes a weighted sum, and sums do not care about arrangement. Without positional information, 'the dog bit the man' and 'the man bit the dog' would be identical inputs.
The fix is to add a position-dependent vector to each token embedding before the first layer. GPT-2 learns these vectors, one per position up to the context length, which is why its context length is a hard architectural limit. Sinusoidal schemes compute them instead, and modern models mostly use rotary embeddings, which rotate the query and key vectors by an angle proportional to position — encoding relative distance rather than absolute index and generalising better beyond the training length.
Inspecting learned position embeddings is instructive: nearby positions have high cosine similarity and it decays smoothly with distance, so the model has discovered a notion of 'close by' without ever being told to.
import torch
from transformers import AutoModel
model = AutoModel.from_pretrained("gpt2")
pos = model.wpe.weight.detach() # [1024, 768]
pn = pos / pos.norm(dim=1, keepdim=True)
sim = pn @ pn.T # similarity between every pair
print(sim[0, :5]) # neighbours of position 0
print(sim[500, 495:506]) # a smooth ridge around the diagonal
# Position 0 is often an outlier: it sees no prior context and ends up
# specialised, which is why the first token behaves strangely in many analyses.Training embeddings yourself
Building this end to end is what makes it concrete. You need four pieces: a data loader that yields (context, target) pairs from a tokenized corpus; a model that embeds the context and projects to vocabulary scores; a loss that measures how wrong those scores are; and a training loop.
The loss is cross-entropy on the next token, which is where the semantics come from. To lower it, the model must place tokens that predict similar continuations near each other — so the geometry is not an objective, it is a consequence.
import torch, torch.nn as nn
from torch.utils.data import DataLoader, Dataset
class NextTokenData(Dataset):
"""Fixed-length windows; the target is the window shifted by one."""
def __init__(self, ids, block=16):
self.ids, self.block = torch.tensor(ids), block
def __len__(self):
return len(self.ids) - self.block - 1
def __getitem__(self, i):
return self.ids[i : i + self.block], self.ids[i + 1 : i + self.block + 1]
class EmbeddingLM(nn.Module):
def __init__(self, vocab, dim=64):
super().__init__()
self.emb = nn.Embedding(vocab, dim)
self.out = nn.Linear(dim, vocab)
def forward(self, x):
return self.out(self.emb(x)) # [B, T, vocab]
model = EmbeddingLM(vocab_size)
opt = torch.optim.AdamW(model.parameters(), lr=1e-3)
loss_fn = nn.CrossEntropyLoss()
loader = DataLoader(NextTokenData(train_ids), batch_size=32, shuffle=True)
for epoch in range(5):
for x, y in loader:
logits = model(x)
# Flatten to [B*T, vocab] and [B*T] — cross-entropy wants 2-D input.
loss = loss_fn(logits.reshape(-1, logits.size(-1)), y.reshape(-1))
opt.zero_grad()
loss.backward()
opt.step()
print(epoch, loss.item())
# Snapshot model.emb.weight each epoch to watch the geometry form:
# similarities among related tokens rise while unrelated pairs stay flat.Note: CrossEntropyLoss expects raw logits, not softmax output — it applies log-softmax internally. Passing softmax probabilities is a silent, common bug that just trains badly.
What stability tells you
Retrain with a new seed and measure the correlation between the two similarity matrices. Frequent tokens land in highly reproducible relative positions; rare tokens move a great deal, because a handful of occurrences is not enough evidence to place them.
That gives you a practical reliability check: any interpretability claim about a rare token, a specific dimension, or a single neuron should be repeated across seeds before you believe it. Much of the observational work later in this curriculum lives or dies on exactly this discipline.
Worth remembering
- Static embeddings (word2vec, GloVe) give one vector per word; contextual embeddings (GPT, BERT) give one per occurrence.
- Cosine similarity is the correlation of two mean-centred vectors — direction matters, magnitude does not.
- Embeddings are only defined up to rotation, so two training runs give different-looking but equally valid spaces.