S104 · Embeddings — word2vec, GloVe, Contrastive Learning
From one-hot to dense vectors — how 300 numbers per word powered the NLP revolution before Transformers, and how contrastive learning generalised the idea to images, audio, and code.
🎯 Understand what an embedding is, how word2vec learned to place king-man+woman near queen, and why contrastive learning is the modern universal recipe for representation learning.
Why this session exists
Embeddings are the single most important idea in modern ML — every LLM, retrieval system, recommender, and multimodal model runs on them. Before 2013, words were one-hot vectors (50,000 dims, one 1). After word2vec, they were 300-dim dense vectors where king - man + woman ≈ queen. Ten years later, the same idea powers CLIP (images + text in one space), Sentence-BERT (retrieval), and every RAG pipeline in production. If you don't feel embeddings in your bones, everything after this session will feel like magic.
- Explain why one-hot vectors are useless for meaning and why dense embeddings work.
- Describe the skip-gram objective used in word2vec — negative sampling and all.
- Compute cosine similarity by hand and explain why it's the standard for embedding comparison.
- Recognise the difference between static (word2vec) and contextual (BERT) embeddings.
- Train a small embedding model in PyTorch and query it for nearest neighbours.
Prerequisites
- S100 · PyTorch Fundamentals — you need
nn.Embeddingand cosine similarity. - S099 · Backpropagation — embeddings are learned by gradient descent, same as any weight.
- S097 · Linear Algebra for ML — dot products and cosine, the entire session assumes fluency.
(a) Intuition · 5 min
Imagine a giant map of a city where restaurants are placed by cuisine. Italian places cluster in one area, sushi in another, tacos in a third. If you know a restaurant's coordinates, you can guess its cuisine — even if you've never heard of it.
Now imagine the map is 300-dimensional. Every restaurant has 300 coordinates. Not just cuisine — price, ambience, kid-friendliness, hours, walking distance from a metro. Two restaurants close on the map share many attributes. Two far apart share few.
An embedding puts words on exactly this kind of map. Every word has 300 numbers. Words with similar meaning end up close (cat, kitten, feline). Analogies work because directions on the map mean things — the vector from "man" to "woman" is roughly the same as from "king" to "queen." Move a word by that vector and you change its gender.
The magic is that nobody hand-labelled these coordinates. They emerged from the simple objective: "words that appear in similar contexts should have similar vectors."
Why dense vectors beat one-hot
- Similarity — cosine(cat, dog) > cosine(cat, table). One-hot vectors are equidistant from each other; they carry no similarity information at all.
- Generalisation — a model trained on 'dog' data can transfer to 'puppy' because their vectors are close. One-hot makes them entirely independent tokens.
- Compactness — 300 dims for 50K words = 15M params. One-hot × any weight matrix = O(50K · d), 100× larger.
- Arithmetic — king - man + woman ≈ queen, Paris - France + Italy ≈ Rome. Directions encode relationships. This is impossible in one-hot space.
The embedding revolution timeline
- 1998Latent Semantic AnalysisSVD on term-document matrix. Words as vectors, but linear and shallow.
- 2013word2vec · Mikolov et al.Google researchers publish skip-gram + CBOW. First scalable neural word embeddings. king-man+woman≈queen goes viral.
- 2014GloVe · StanfordMatrix factorisation of global co-occurrence stats. Slightly better than word2vec on some tasks, tied on others.
- 2018ELMo + BERT · contextual embeddingsSame word → different vector depending on sentence. 'bank' in 'river bank' vs 'money bank' finally distinguished.
- 2020SimCLR · contrastive visionTwo augmentations of same image → close; different images → far. Learns image embeddings without labels.
- 2021CLIP · OpenAIContrastive on 400M image-text pairs. One embedding space for images AND text. Zero-shot classification everywhere.
(b) Visual walkthrough · 15 min
The skip-gram architecture
Skip-gram objective: given a center word, predict the surrounding context words in a window (typically ±5). The two embedding matrices (center + context) are the entire model. After training, keep only the center matrix — those are your word embeddings.
Negative sampling — the trick that made it fast
Walking through a training step
Sample a random position in the corpus. Center word + words within ±5 positions = context.
v_c = E_center[center_word_id]. One row of the embedding matrix.
For each context word: v_pos = E_context[ctx_id]. Also sample 5 random 'noise' words: v_neg = E_context[noise_ids].
score_pos = σ(v_c · v_pos), score_neg_i = σ(-v_c · v_neg_i). Both should approach 1.
L = -log(σ(v_c·v_pos)) - Σ log(σ(-v_c·v_neg_i)). Update E_center, E_context via SGD.
Slide window across the corpus for several epochs. Embeddings converge to something meaningful in a few hours on CPU.
word2vec vs GloVe vs BERT vs CLIP
Prediction-based
- Skip-gram + neg sampling
- 300-dim static vectors
- 'bank' = one vector, ignoring context
- Fast, small, good for baselines
Count-based (matrix factorisation)
- Factors global co-occurrence log-counts
- Pre-trained vectors ubiquitous (Twitter, Wikipedia)
- Similar quality to word2vec
- Cheaper to train
Contextual
- Same word gets different vector per sentence
- 12-24 Transformer layers
- Vectors capture syntax + semantics
- Foundation of modern NLP
Multimodal contrastive
- Image encoder + text encoder
- Trained: matching (img, caption) close, others far
- One shared embedding space
- Powers Stable Diffusion, zero-shot classification
Cosine similarity — the metric that runs the show
Why cosine, not Euclidean distance
"Embeddings capture meaning, so cosine similarity between two embeddings tells me how semantically similar the things are. If two items have similar vectors, they mean similar things."
Embeddings encode whatever similarity the training objective rewarded, which is usually distributional co-occurrence rather than meaning. Word2vec-style training places antonyms extremely close together, because "good" and "bad" appear in nearly identical contexts. High cosine similarity means "interchangeable in the contexts this model was trained on" — which coincides with semantic similarity often enough to be useful, and diverges precisely where it matters most.
The myth is sticky because the famous demonstrations are so compelling. king − man + woman ≈ queen genuinely works, and analogy arithmetic looks like proof that the space encodes meaning as geometry. But those examples are selected, the arithmetic is fragile (results often land on the input words themselves unless they are explicitly excluded), and the underlying training signal never saw a definition — only which words appeared near which. The distributional hypothesis is a good approximation to meaning, not meaning itself, and the antonym case is where the gap is unmissable.
Check the two things everyone assumes and neither of which holds cleanly:
# 1. antonyms are near-neighbours, not distant
print(model.similarity('good', 'bad')) # high
print(model.similarity('good', 'cheese')) # low
# similar context != similar meaning
# 2. a static embedding has ONE vector per word,
# so both senses collapse into one point
print(model.most_similar('bank')[:5])
# river senses and finance senses mixed in one neighbourhood
# -- the reason contextual embeddings existWhy does an embedding layer work at all, given it is literally a lookup table of learned vectors? It has no structure, no similarity metric built in, and is initialised randomly. Nothing tells it that "cat" and "dog" should end up near each other.
- 1An embedding layer is mathematically a one-hot vector multiplied by a weight matrix. The one-hot selects exactly one row, so the operation is a lookup — implemented as indexing purely to avoid materialising a huge sparse multiply.forced by · multiplying by a one-hot vector is row selection, and doing it literally would be enormously wasteful
- 2Since it is a weight matrix, it receives gradients like any other layer. The gradient for a row is non-zero only when that token appeared in the batch, so each vector is updated only by examples containing it.forced by · the unselected rows contribute nothing to the forward pass and therefore nothing to the gradient
- 3Now consider two tokens that appear in similar contexts and lead to similar downstream targets. Whenever either appears, the downstream network needs a similar input to produce the correct output, so both rows are pushed toward whatever value satisfies that requirement.forced by · gradient descent moves each row toward reducing the loss of the examples it participated in, and those losses are shaped by the same downstream weights
- 4Repeated across a corpus, tokens that are functionally interchangeable converge to nearby vectors — not because similarity was designed in, but because the downstream network cannot distinguish them and needs them to behave alike.forced by · tokens with identical downstream requirements have no gradient pressure separating them
- 5The geometry is therefore induced by the task, not intrinsic. Train the same lookup table against sentiment and "good" and "bad" separate sharply; train it against next-word prediction and they collapse together.forced by · the only force organising the space is the loss, so the space encodes exactly what the loss cared about
Therefore embeddings are not a special mechanism — they are ordinary learned weights whose geometry is a side effect of the objective. "Similar" always means "similar with respect to the training task".
And note what this predicts: a token that appears rarely receives few gradient updates and keeps a vector close to its random initialisation, so rare-token embeddings are essentially noise. That is exactly why subword tokenisation exists — it guarantees every piece appears often enough to be trained. It also predicts that embeddings are only comparable within the model that produced them: two separately trained models place their axes arbitrarily, so cosine similarity across models is meaningless without an explicit alignment.
One-hot encoding places every category on its own orthogonal axis, so every pair is exactly equidistant — the representation asserts that nothing is more similar to anything else. It also costs one dimension per category.
An embedding compresses those into a dense low-dimensional space where the model is free to choose the positions. Training arranges them so that items the task treats alike end up near each other. The dimensions have no inherent meaning; what carries information is the relative geometry — distances and directions — and only with respect to the objective that produced them.
- Embedding dimension is a capacity knob, not a semantic one. Common heuristics scale it with cardinality (roughly the fourth root, or a small multiple of it) and then tune.
- Cosine similarity for direction, dot product when magnitude carries information (often frequency or confidence). Normalise deliberately, and know which one your retrieval index assumes.
- Static embeddings assign one vector per token, so polysemy collapses. Contextual embeddings from transformers produce a different vector per occurrence, which is the entire advance.
- Embeddings are only comparable within the model and version that produced them. Re-training or upgrading the model invalidates every stored vector — which makes an embedding index a versioned artefact, not a cache.
Fire this the moment you see: one-hot encoding of a high-cardinality column · cosine similarity compared across two different models · a vector index not versioned with the model that filled it · rare categories with untrained embeddings · an assumption that nearest neighbours are semantically similar rather than distributionally similar · embeddings used for a task very different from the one they were trained on.
You need vector representations for a retrieval system. Do you use an off-the-shelf pretrained embedding model, fine-tune one on your domain, or train task-specific embeddings from scratch?
Start with a pretrained model and measure retrieval quality on real queries before doing anything else. It is a strong baseline, and a surprising number of embedding projects end there because it was already sufficient. Fine-tune when you have relevance signal and a measured gap, since that is the intervention with the best return once the baseline exists.
The operational point that outlasts the model choice: an embedding index is tightly coupled to the model version that produced it. Changing models means re-embedding everything, so plan for that from the start — version the index, keep the embedding job idempotent and re-runnable, and never mix vectors from two model versions in one index. That coupling causes more production incidents than embedding quality ever does.
(c) Hands-on · 25 min
Train a mini skip-gram word2vec on a text corpus. Then query it for nearest neighbours and analogies.
# mini_w2v.py — a from-scratch skip-gram with negative sampling.
# Usage: uv run mini_w2v.py path/to/corpus.txt
import sys
import random
import re
from collections import Counter
import torch
import torch.nn as nn
import torch.nn.functional as F
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
EMB_DIM = 100
WINDOW = 5
NEG_SAMPLES = 5
EPOCHS = 5
BATCH = 512
LR = 5e-3
MIN_COUNT = 5
def tokenise(text: str) -> list[str]:
return re.findall(r"[a-z']+", text.lower())
def build_vocab(tokens: list[str], min_count: int) -> tuple[dict[str, int], list[str], list[float]]:
counts = Counter(tokens)
vocab = [w for w, c in counts.most_common() if c >= min_count]
stoi = {w: i for i, w in enumerate(vocab)}
# Unigram distribution raised to 0.75 (Mikolov's trick) for negative sampling.
freqs = torch.tensor([counts[w] for w in vocab], dtype=torch.float)
weights = (freqs ** 0.75)
weights /= weights.sum()
return stoi, vocab, weights.tolist()
def make_pairs(tokens: list[str], stoi: dict[str, int], window: int) -> list[tuple[int, int]]:
ids = [stoi[t] for t in tokens if t in stoi]
pairs = []
for i, center in enumerate(ids):
w = random.randint(1, window)
for j in range(max(0, i - w), min(len(ids), i + w + 1)):
if i != j:
pairs.append((center, ids[j]))
return pairs
class SkipGram(nn.Module):
def __init__(self, vocab_size: int, dim: int):
super().__init__()
self.center = nn.Embedding(vocab_size, dim)
self.context = nn.Embedding(vocab_size, dim)
nn.init.uniform_(self.center.weight, -0.5 / dim, 0.5 / dim)
nn.init.zeros_(self.context.weight)
def forward(
self, center: torch.Tensor, pos: torch.Tensor, neg: torch.Tensor
) -> torch.Tensor:
v_c = self.center(center) # [B, D]
v_pos = self.context(pos) # [B, D]
v_neg = self.context(neg) # [B, K, D]
pos_score = (v_c * v_pos).sum(dim=1) # [B]
neg_score = torch.bmm(v_neg, v_c.unsqueeze(2)).squeeze(2) # [B, K]
loss = -F.logsigmoid(pos_score).mean() - F.logsigmoid(-neg_score).mean()
return loss
def train(path: str) -> None:
text = open(path, "r", encoding="utf-8").read()
tokens = tokenise(text)
stoi, vocab, neg_weights = build_vocab(tokens, MIN_COUNT)
print(f"vocab={len(vocab):,} tokens={len(tokens):,}")
pairs = make_pairs(tokens, stoi, WINDOW)
print(f"training pairs: {len(pairs):,}")
model = SkipGram(len(vocab), EMB_DIM).to(DEVICE)
opt = torch.optim.Adam(model.parameters(), lr=LR)
weights_t = torch.tensor(neg_weights)
for epoch in range(1, EPOCHS + 1):
random.shuffle(pairs)
total = 0.0
steps = 0
for i in range(0, len(pairs), BATCH):
batch = pairs[i : i + BATCH]
if len(batch) < BATCH:
continue
c, p = zip(*batch)
c = torch.tensor(c, device=DEVICE)
p = torch.tensor(p, device=DEVICE)
neg = torch.multinomial(weights_t, BATCH * NEG_SAMPLES, replacement=True)
neg = neg.view(BATCH, NEG_SAMPLES).to(DEVICE)
opt.zero_grad()
loss = model(c, p, neg)
loss.backward()
opt.step()
total += loss.item()
steps += 1
print(f"epoch {epoch} loss={total/steps:.4f}")
# Save + demo queries.
emb = F.normalize(model.center.weight.detach(), dim=1) # normalise for cosine
itos = {i: w for w, i in stoi.items()}
for word in ["king", "computer", "love", "war"]:
if word not in stoi:
continue
v = emb[stoi[word]]
sims = emb @ v
top = sims.topk(6).indices.tolist()
print(f"nearest to {word!r}: {[itos[i] for i in top if i != stoi[word]][:5]}")
if __name__ == "__main__":
train(sys.argv[1])Anatomy of the script
What the interesting lines do
def analogy(word_a: str, word_b: str, word_c: str, top: int = 5) -> list[str]:
ids = {w: stoi[w] for w in (word_a, word_b, word_c) if w in stoi}
if len(ids) < 3:
return []
v = emb[ids[word_a]] - emb[ids[word_b]] + emb[ids[word_c]]
v = F.normalize(v, dim=0)
sims = emb @ v
excluded = set(ids.values())
ranked = [i for i in sims.topk(top + 3).indices.tolist() if i not in excluded]
return [itos[i] for i in ranked[:top]]Small corpora produce noisy analogies; Wikipedia-scale ones nail them. This is exactly the demo Mikolov ran in 2013 that made word2vec go viral.
(d) Production reality · 15 min
Airbnb's search ranked listings using content features (price, room type, photos). Personalisation was weak — a user who'd only booked beach cabins would still see downtown apartments. The team tried word2vec on user click sessions (treating listings as 'words' and sessions as 'sentences') to get listing embeddings.
First version tanked in A/B tests. Listings with different countries were ending up close because they shared demographic click patterns — but a Paris apartment is not a substitute for a Tokyo hotel.
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
If you can't teach these three without notes, redo the session:
- Why do dense embeddings work for language when one-hot vectors don't?
- How does skip-gram + negative sampling actually learn embeddings?
- What is contrastive learning, and why is it the universal recipe for modern representation learning?
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.