Skip to content
GKgkml.dev
← LLM mechanisms

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)
  1. 01Word2Vec vs. GloVe vs. GPT vs. BERT — what each 'embedding' means
  2. 02Exploring pretrained GloVe embeddings
  3. 03Comparing embeddings trained on different corpora (Wikipedia vs. Twitter)
  4. 04Exploring GPT-2 and BERT embeddings
  5. 05Arithmetic with tokens and embeddings
  6. 06Cosine similarity, and its relation to correlation
  7. 07Cosine similarities in GPT-2
  8. 08Unembedding: turning vectors back into tokens
  9. 09Position embeddings
  10. 10Exploring learned position embeddings
  11. 11Training embeddings from scratch
  12. 12Building a data loader for embedding training
  13. 13Building the model that learns the embeddings
  14. 14Choosing the loss function
  15. 15Training and evaluating the embedding model
  16. 16How embeddings change during training
  17. 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'

ModelKindTrained onOne vector per
word2vecStaticPredicting nearby wordsWord type
GloVeStaticGlobal co-occurrence countsWord type
BERTContextual, bidirectionalMasked-token predictionToken occurrence
GPTContextual, causalNext-token predictionToken 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.

Cosine similarity, and the correlation identity
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.T

Note: Normalise once and multiply once. Looping over pairs is the standard beginner performance mistake here.

Static embeddings: the famous analogy, and its caveat
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.

Contextual embeddings and unembedding in practice
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.

Inspecting learned position embeddings
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.

The full loop: loader, model, loss, training
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.