Embeddings from scratch
August 26, 2026 · 11 min read
The previous two posts got us as far as integer ids. A word-level tokenizer turns
"Shall I hear more" into [8761, 5032, 4705, 6430]
(Why word-level tokenizers break), and BPE does the same
with subword pieces so nothing is ever out-of-vocabulary
(Building BPE from scratch). That is enough to represent
the text, but it leaves the model with a slightly absurd situation: id 5032 is no closer to
5033 than to 9000. The integers are names, not meaning. Hand a network those raw ids and
there is no useful relationship for it to generalize from.
This is where embeddings come in. Give every id a vector, then learn those vectors rather than assigning them by hand. The training signal is simple and self-supervised: use a token to predict its neighbors, or use the neighbors to predict the token. Tokens that occur in similar contexts get pushed toward similar predictions, so their vectors tend to end up near one another. That is the word2vec idea (Mikolov et al., 2013), and it is small enough to build from scratch.
What is an embedding, really?
Before training one, I find it useful to make the word vector less mysterious. An embedding
is just a list of numbers that describes something along a set of axes. If we choose the axes
ourselves, a bear might be big, furry, wild, and not very royal; a dog might share the
furriness while scoring lower on wildness. Similar things get similar lists. That is the whole
intuition - the interesting part is how a model finds useful axes without us naming them.
For a more abstract version, imagine axes for royalty, gender, and age. king is roughly
royal, male, adult; queen is royal, female, adult; man and woman share the gender
direction without the royalty. The famous analogy then becomes ordinary arithmetic:
king − man removes the male component, and adding woman puts the female component back,
landing near queen. In other words, we are estimating the queen's features from the other
three vectors. The figure below lets you switch into arithmetic mode and try it.
Loading feature vectors…
These axes are a teaching cartoon. I picked and labelled them, and every number in the figure is invented. Real embeddings do not come with a clean "royalty" slider: the model discovers its own directions from raw text with no labels, and those directions are tangled and unnamed. That gap is what the rest of this post is about. The analogy is also much noisier in real embeddings; the standard evaluation has to exclude the input words to get the textbook answer (more on that at the end). Treat this as the picture, not the promise.
So the goal is not to hand-design vectors like these. It is to learn them from text. That leaves the first mechanical question: where do the vectors actually live, and how do you look one up?
The lookup table
Start with the mechanism, before any learning. An embedding layer is a matrix W of shape
(vocab_size, embedding_dim) - one row per token. "Embedding token i" means taking row
i of W.
You can write that as a matrix multiply: encode id i as a
one-hot vector
and compute one_hot @ W. That multiplication only selects the row where the 1 is, so the
detour is wasted work. nn.Embedding skips it and indexes the row directly; it is the
row-select, nothing more.
Loading embedding table…
The rows above are hand-picked to make the point - king and queen sit close, apple
sits apart. In a fresh model W is random, and the rows mean nothing. The whole job of
training is to move those rows into positions where distance encodes something. For a gentle,
picture-first tour of the same idea, Jay Alammar's
The Illustrated Word2vec is excellent.
Where the signal comes from
There are no labels here - just a stream of tokens. The trick of word2vec is to manufacture a
supervised task out of raw text: for each position, treat the token as the target and the
tokens around it as the context. Slide a fixed window across the corpus and every position
yields one (target, context) example. With window_size=2 the context is the two tokens on
each side:
def create_context_windows(token_ids, window_size=2):
windows = []
for i in range(window_size, len(token_ids) - window_size):
target = token_ids[i]
context = token_ids[i - window_size:i] + token_ids[i + 1:i + window_size + 1]
windows.append((target, context))
return windowsLoading context window…
There is one problem with feeding this stream straight in. A handful of tokens - newlines,
punctuation, the, and, of - dominate the counts (the same
Zipfian
skew from the tokenization posts), and their sheer frequency drowns out the signal from rarer
content words. Word2vec's fix is subsampling: drop a token with a probability that grows with
its frequency, so common tokens survive less often
(Mikolov et al., 2013 - Distributed Representations):
subsample_t = 1e-4
def keep_prob(token_id):
freq = token_counts[token_id] / n_tokens
return min(1.0, np.sqrt(subsample_t / freq))A rare token has freq near subsample_t, so keep_prob is close to 1 and it is almost
always kept; a token like the has a large freq, so the square root pulls its keep
probability far below 1. On Karpathy's Tiny Shakespeare, encoded with GPT-2's tiktoken
tokenizer and remapped to the 11,706 ids that actually occur, this turns ~300k tokens into
roughly 128k training tokens. Those "words" are GPT-2 subword pieces, not whole words.
Skip-gram vs CBOW
There are two ways to point the prediction. Skip-gram predicts each context token from the target; CBOW (continuous bag-of-words) predicts the target from the averaged context. Both optimize a softmax over the whole vocabulary. For skip-gram, the probability of a context token given target is:
Training maximizes that probability for the (target, context) pairs the corpus actually contains, which is the same as minimizing cross-entropy of the logits against the true context ids. Skip-gram is one embedding lookup followed by one linear layer; the complete pair of classes is in the collapsible below.
CBOW has the same two layers, but embeds all four context tokens and averages them before
the linear layer. mean(dim=1) collapses the window into a single vector, so the order of the
context tokens is thrown away (hence "bag of words"). The complete pair of classes is in the
collapsible below.
One mechanic in the skip-gram training loop is worth slowing down on. The model produces one
logit vector per target, but a target has four context tokens to predict. The loop expands
the single prediction into four independent classifications with repeat_interleave:
pred = skipgram(x)
# One target predicts every context slot: tile the logits so each
# (target, context_token) pair becomes an independent classification.
_, n_context = y.shape
pred = pred.repeat_interleave(n_context, dim=0)
y = y.flatten()
loss = loss_fn(pred, y)So a batch of 128 targets becomes 512 (target, context) classification problems, each pushing the same target logits toward a different neighbor. That is the standard skip-gram objective - "predict each surrounding word independently" - realized with a tile-and-flatten instead of a loop.
These are first-iteration implementations, built for clarity. For the full mathematical derivation of both objectives, Xin Rong's word2vec Parameter Learning Explained works through every gradient; Chris McCormick's skip-gram tutorial is the friendlier walk-through.
Training, and an honest result
Both models use a full softmax:
the linear layer produces one logit for each of the 11,706 vocabulary ids, and cross-entropy
normalizes over all of them. There is no negative sampling here; this is the plain, expensive
version. Skip-gram runs 60 epochs, CBOW 80, both at lr=1e-3.
Loading loss curves…
The two curves tell different stories. Skip-gram falls from ~9.0 to ~5.65 and flattens: it is solving four hard predictions per target and never gets very confident. CBOW drops from ~8.8 to ~0.26. That looks better, but it is mostly a warning sign. Averaging the context gives CBOW an easier target and enough capacity to memorize this small corpus rather than generalize.
The neighbors confirm the suspicion. Ranking every content token by cosine similarity on the 128k training tokens gives semantically thin results. A few real skip-gram examples:
| query | nearest neighbors (cosine) |
|---|---|
you | especially (0.38), thee (0.32), declare (0.31), chat (0.31), grace (0.30) |
good | smells (0.34), carb (0.33), entle (0.31), Paul (0.31), dislike (0.31) |
lord | yesterday (0.37), relate (0.36), chang (0.35), whoever (0.35), gracious (0.34) |
the | senate (0.34), ifled (0.33), Kin (0.32), fell (0.31), insurrection (0.31) |
There are faint hits - you finds thee and grace, lord finds gracious - but the top
scores hover around 0.3, and most neighbors are noise or broken subword fragments like
entle and ifled. That is what raw, from-scratch word2vec on this corpus honestly produces:
the famous clean analogies come from models trained on billions of tokens, not one megabyte
of Shakespeare.
The bottleneck here is corpus size, not the algorithm. word2vec's published results used corpora of hundreds of millions to billions of tokens; 128k is three to four orders of magnitude short. More training epochs cannot manufacture statistical signal the corpus does not contain - past a point they just help CBOW memorize it faster.
Seeing the space
Even weak embeddings have structure worth looking at, but 128 dimensions do not fit on a screen. The map below projects the top-60 content tokens down to 2D with t-SNE, with skip-gram on one side and CBOW on the other. Hover a point for its nearest neighbors.
Loading embedding map…
I used t-SNE rather than PCA because this view is about local neighborhoods, not the directions of greatest global variance. That is also what cosine similarity reads from the embeddings, so the projection is at least showing the kind of structure we are asking about.
Read a t-SNE plot for who is next to whom, not for distances or cluster sizes. The method has tunable knobs (perplexity, iterations) and its between-cluster gaps and blob areas are largely artifacts of the layout, not the data - the same embeddings can look like tight islands or one smear depending on settings. Distill's How to Use t-SNE Effectively shows exactly how it misleads.
For a cleaner read, here is a pairwise cosine-similarity heatmap over a curated 16-word
Shakespeare vocabulary (king, queen, lord, love, death, ...), for each model. Bright
cells are pairs the model placed close together:
Loading cosine matrix…
What we skipped, and where this goes
Two useful pieces were left out on purpose.
Negative sampling. The full softmax normalizes over all 11,706 ids on every example - fine here, but hopeless at real vocabulary sizes. Word2vec's speed trick replaces it with a binary problem: push the true pair together and a few randomly sampled negative pairs apart. That turns an 11,706-way softmax into a handful of logistic updates (Mikolov et al., 2013 - Distributed Representations; McCormick's negative sampling tutorial walks through it). That is the single change that made word2vec trainable at scale.
The king − man + woman ≈ queen analogy. It does hold on large models, but the usual demo
quietly excludes the three input words when looking for the nearest vector. Without that
exclusion the answer is often just king again. The effect is real, but the evaluation detail
matters. Later count-based methods like GloVe reach
similar geometry from co-occurrence statistics directly.
The deeper limitation is that these vectors are trained in isolation, on a proxy task, and
then frozen. The next direction in this series drops the proxy: put an nn.Embedding at the
front of a GPT and let the language-modeling loss shape it end-to-end, so the embeddings are
learned for the task that actually uses them - and on enough text for the geometry to mean
something.
Further reading
Tokenization
Why word-level tokenizers break
A model never sees text - it sees integer ids from a fixed vocabulary. Building the simplest possible tokenizer makes the encode/decode contract concrete, and shows exactly where word-level tokenization loses information.
Tokenization
Building BPE from scratch
Byte-Pair Encoding fixes word-level tokenization by working with subword pieces: it starts from single-byte characters - so text in its range is never out-of-vocabulary - and repeatedly merges the most frequent adjacent pair. Here it is built and trained from scratch, with a live playground.