TAI BUI
Back to curriculum
BuildPython

Word Embeddings — Word2Vec from Scratch

A word is the company it keeps. Train a shallow net on that idea and geometry falls out.

TIME: ~75 minutesPREREQUISITES: Phase 5 · 02 (BoW + TF-IDF), Phase 3 · 03 (Backpropagation from Scratch)

Learning Objectives

  • Explain the distributional hypothesis and how skip-gram and CBOW use it to learn word vectors from raw text.
  • Implement skip-gram training pairs, the embedding tables, and the negative-sampling objective from scratch in NumPy.
  • Use gensim's Word2Vec to train production embeddings and tune sg, window, negative, and vector_size.
  • Identify where Word2Vec still wins in 2026 (lightweight, on-device, interpretable retrieval) versus where it fails (polysemy, out-of-vocabulary words), and explain why contextual embeddings fix the polysemy failure.

The Problem

TF-IDF knows dog and puppy are different words. It has no idea they mean nearly the same thing. Train a classifier on reviews full of dog and it won't generalize to one about puppy. You can paper over that with a synonym list, but the list breaks on rare terms, domain jargon, and any language you didn't think to cover.

What you actually want is a representation where dog and puppy land close together in space. Where king - man + woman lands near queen. Where a model trained on dog gives you some signal on puppy for free.

Word2Vec gave us that space: a two-layer neural network, trained on roughly 100 billion tokens of Google News text, published in 2013. The architecture is almost embarrassingly simple. The results reshaped NLP for a decade.

Warm-up0/4
1. The previous lesson represented each document as a vector of TF-IDF scores, one column per vocabulary word. In that scheme, are 'dog' and 'puppy' represented as similar vectors or as unrelated ones?Warm-up
2. A classifier's output layer has to score every class before picking the most likely one. If the number of classes grows from 10 to 100,000, would scoring every class at each training step get cheaper, stay the same, or get more expensive?Warm-up
3. A vocabulary has 50,000 words. How many entries of a one-hot vector for a single word are nonzero?Warm-up
4. In gradient descent the update is w -= lr * grad. If grad is negative for some weight, what happens to w?Warm-up

The Concept

Distributional hypothesis (Firth, 1957): "You shall know a word by the company it keeps." If two words appear in similar contexts, they probably mean similar things.

Word2Vec comes in two flavors, both exploiting that idea.

  • Skip-gram. Given a center word, predict the surrounding words. cat -> (the, sat, on) with window size 2.
  • CBOW (continuous bag of words). Given surrounding words, predict the center. (the, sat, on) -> cat.

Skip-gram is slower to train but handles rare words better. It became the default.

The network has one hidden layer with no nonlinearity. Input is a one-hot vector over the vocabulary. Output is a softmax over the vocabulary. After training, you throw away the output layer. The hidden layer weights are the embeddings.

one-hot(center) ── W ──▶ hidden (d-dim) ── W' ──▶ softmax(vocab)
                          ^
                          this is the embedding

The trick: softmax over 100k words is prohibitively expensive. Word2Vec uses negative sampling to turn the problem into binary classification instead: did this context word actually appear near the center word, yes or no? Sample a handful of negative, non-co-occurring words per training pair, and you never have to compute softmax over the whole vocabulary.

Check: The Concept0/9
1. What is a word embedding?Define
2. What does the distributional hypothesis claim?Define
3. What is the difference between skip-gram and CBOW?Define
4. What is negative sampling?Define
5. What is a static embedding?Define
6. Why does Word2Vec use negative sampling instead of a full softmax output?Explain
7. Why did skip-gram become the default over CBOW?Explain
8. How is the Word2Vec network built, and which part becomes the embeddings?Explain
9. Why does training on raw text place 'dog' and 'puppy' close together?Explain

Build It

Step 1: training pairs from a corpus

python
def skipgram_pairs(docs, window=2):
    pairs = []
    for doc in docs:
        for i, center in enumerate(doc):
            for j in range(max(0, i - window), min(len(doc), i + window + 1)):
                if i == j:
                    continue
                pairs.append((center, doc[j]))
    return pairs
python
>>> skipgram_pairs([["the", "cat", "sat", "on", "mat"]], window=2)
[('the', 'cat'), ('the', 'sat'),
 ('cat', 'the'), ('cat', 'sat'), ('cat', 'on'),
 ('sat', 'the'), ('sat', 'cat'), ('sat', 'on'), ('sat', 'mat'),
 ...]

Every (center, context) pair in a window is a positive training example.

Step 2: embedding tables

Two matrices. W is the center-word embedding table (the one you keep). W' is the context-word table (often discarded, sometimes averaged with W).

python
import numpy as np
 
 
def init_embeddings(vocab_size, dim, seed=0):
    rng = np.random.default_rng(seed)
    W = rng.normal(0, 0.1, size=(vocab_size, dim))
    W_prime = rng.normal(0, 0.1, size=(vocab_size, dim))
    return W, W_prime

Small random init. Vocab size 10k and dim 100 is realistic; for teaching, 50 vocab x 16 dim is enough to see the geometry.

Step 3: negative sampling objective

For each positive pair (center, context), sample k random words from the vocabulary as negatives. Train the model so the dot product W[center] · W'[context] is high for positives and low for negatives.

python
def sigmoid(x):
    return 1.0 / (1.0 + np.exp(-np.clip(x, -20, 20)))
 
 
def train_pair(W, W_prime, center_idx, context_idx, negative_indices, lr):
    v_c = W[center_idx]
    u_pos = W_prime[context_idx]
    u_negs = W_prime[negative_indices]
 
    pos_score = sigmoid(v_c @ u_pos)
    neg_scores = sigmoid(u_negs @ v_c)
 
    grad_center = (pos_score - 1) * u_pos
    for i, u in enumerate(u_negs):
        grad_center += neg_scores[i] * u
 
    W[context_idx] = W[context_idx]
    W_prime[context_idx] -= lr * (pos_score - 1) * v_c
    for i, neg_idx in enumerate(negative_indices):
        W_prime[neg_idx] -= lr * neg_scores[i] * v_c
    W[center_idx] -= lr * grad_center

The magic formula: logistic loss on positive pair (want sigmoid near 1) plus logistic loss on negative pairs (want sigmoid near 0). Gradients flow to both tables. Full derivation is in the original paper; walk through it once with pencil and paper if you want it to stick.

Step 4: train on a toy corpus

python
def train(docs, dim=16, window=2, k_neg=5, epochs=100, lr=0.05, seed=0):
    vocab = build_vocab(docs)
    vocab_size = len(vocab)
    rng = np.random.default_rng(seed)
    W, W_prime = init_embeddings(vocab_size, dim, seed=seed)
    pairs = skipgram_pairs(docs, window=window)
 
    for epoch in range(epochs):
        rng.shuffle(pairs)
        for center, context in pairs:
            c_idx = vocab[center]
            ctx_idx = vocab[context]
            negs = rng.integers(0, vocab_size, size=k_neg)
            negs = [n for n in negs if n != ctx_idx and n != c_idx]
            train_pair(W, W_prime, c_idx, ctx_idx, negs, lr)
    return vocab, W

After enough epochs on a large corpus, words that share contexts have similar center embeddings. On a toy corpus, you see the effect faintly. On billions of tokens, you see it dramatically.

Step 5: the analogy trick

python
def nearest(vocab, W, target_vec, topk=5, exclude=None):
    exclude = exclude or set()
    inv_vocab = {i: w for w, i in vocab.items()}
    norms = np.linalg.norm(W, axis=1, keepdims=True) + 1e-9
    W_norm = W / norms
    target = target_vec / (np.linalg.norm(target_vec) + 1e-9)
    sims = W_norm @ target
    order = np.argsort(-sims)
    out = []
    for i in order:
        if i in exclude:
            continue
        out.append((inv_vocab[i], float(sims[i])))
        if len(out) == topk:
            break
    return out
 
 
def analogy(vocab, W, a, b, c, topk=5):
    v = W[vocab[b]] - W[vocab[a]] + W[vocab[c]]
    return nearest(vocab, W, v, topk=topk, exclude={vocab[a], vocab[b], vocab[c]})

On pre-trained 300d Google News vectors:

python
>>> analogy(vocab, W, "man", "king", "woman")
[('queen', 0.71), ('monarch', 0.62), ('princess', 0.59), ...]

king - man + woman = queen. Not because the model knows what royalty is. Because the vector (king - man) captures something like "royal", and adding it to woman lands near the royal-female region.

Check: Build It0/10
1. In negative sampling, what is the training objective for a positive (center, context) pair?Explain
2. After training Word2Vec, which weight matrix is kept as the word embeddings?Explain
3. What enables the analogy 'king - man + woman ~ queen'?Explain
4. What does skipgram_pairs([['the','cat','sat','on','mat']], window=2) pair with the center word 'cat'?Apply
5. skipgram_pairs is called on the single document [a, b, c] with window=1. How many (center, context) pairs does it return?Apply
6. In train_pair, pos_score is 0.2 for a real pair. What does the update do to W_prime[context_idx]?Apply
7. In train(), k_neg=5 draws give [7, 3, 3, 9, 12]. The center index is 3 and the context index is 12. How many negatives reach train_pair?Apply
8. In analogy(vocab, W, 'man', 'king', 'woman'), which vector is used as the search target?Apply
9. Which words can never appear in the results of analogy(vocab, W, 'man', 'king', 'woman')?Apply
10. On a 20-sentence corpus trained for 200 epochs, 'dog' is missing from the top 3 neighbors of 'cat'. What is the most likely cause?Analyze

Use It

Writing Word2Vec from scratch is teaching. Production NLP uses gensim.

python
from gensim.models import Word2Vec
 
sentences = [
    ["the", "cat", "sat", "on", "the", "mat"],
    ["the", "dog", "ran", "across", "the", "room"],
]
 
model = Word2Vec(
    sentences,
    vector_size=100,
    window=5,
    min_count=1,
    sg=1,
    negative=5,
    workers=4,
    epochs=30,
)
 
print(model.wv["cat"])
print(model.wv.most_similar("cat", topn=3))

For real work, you almost never train Word2Vec yourself. You download pre-trained vectors instead.

  • GloVe: Stanford's co-occurrence-matrix factorization approach. 50d, 100d, 200d, 300d checkpoints, good general coverage. Lesson 04 covers GloVe specifically.
  • fastText: Facebook's Word2Vec extension that embeds character n-grams, so it handles out-of-vocabulary words by composing subwords. Lesson 04.
  • Pretrained Word2Vec on Google News: 300d, 3M-word vocabulary, published 2013, and still downloaded daily.

When Word2Vec still wins in 2026

  • Lightweight domain-specific retrieval. Train on medical abstracts for an hour on a laptop and get specialized vectors no general model captures.
  • Analogy-style feature engineering. gender_vector = mean(man - woman pairs). Subtract it from other words to get a gender-neutral axis, a technique fairness researchers still rely on.
  • Interpretability. 100d is small enough to plot with PCA or t-SNE and actually watch clusters form.
  • Anywhere inference has to run on-device with no GPU: a Word2Vec lookup is a single row fetch.

Where Word2Vec fails

The polysemy wall is the big one. bank gets one vector, and river bank shares it with financial bank. table shares its vector across spreadsheet and furniture. A downstream classifier has no way to tell the senses apart from the vector alone.

Contextual embeddings fixed this. ELMo, BERT, and every transformer since produce a different vector for each occurrence of a word based on what surrounds it. That's the real jump from Word2Vec to BERT: static vectors to contextual ones. Phase 7 covers the transformer half.

The other failure is out-of-vocabulary words. If Zoomer-approved never showed up during training, Word2Vec has nothing for it, no fallback at all. fastText fixes this with subword composition (lesson 04).

Check: Use It0/12
1. What is a contextual embedding?Define
2. What does OOV mean, and what does Word2Vec do with such a word?Define
3. Why does Word2Vec fail on polysemy, for example 'bank'?Explain
4. Why do contextual embeddings fix the polysemy problem?Explain
5. When would you still choose Word2Vec over a transformer in 2026?Explain
6. In Word2Vec(sentences, vector_size=100, window=5, min_count=1, sg=1, negative=5, workers=4, epochs=30), what do sg=1 and negative=5 configure?Apply
7. In the gensim call, which argument would you change to get 300-number vectors instead of 100-number ones?Apply
8. After training, the nearest neighbors of 'bank' include both 'river' and 'loan'. What best explains this?Analyze
9. A query contains 'Zoomer-approved', which never appeared in training, and the Word2Vec lookup fails. Which change fixes the root cause?Analyze
10. You must build retrieval for medical abstracts on a laptop with no GPU, using jargon general models miss. Which design fits?Design
11. Search must tell 'table' in a spreadsheet from 'table' as furniture. Which approach fits?Design
12. You want a gender-neutral axis in word vectors. Which approach follows the lesson?Design

Ship It

Save as outputs/skill-embedding-probe.md:

markdown
---
name: embedding-probe
description: Inspect a word2vec model. Run analogies, find neighbors, diagnose quality.
version: 1.0.0
phase: 5
lesson: 03
tags: [nlp, embeddings, debugging]
---
 
You probe trained word embeddings to verify they are working. Given a `gensim.models.KeyedVectors` object and a vocabulary, you run:
 
1. Three canonical analogy tests. `king : man :: queen : woman`. `paris : france :: tokyo : japan`. `walking : walked :: swimming : ?`. Report the top-1 result and its cosine.
2. Five nearest-neighbor tests on domain-specific words the user supplies. Print top-5 neighbors with cosines.
3. One symmetry check. `similarity(a, b) == similarity(b, a)` to within float precision.
4. One degenerate check. If any embedding has a norm below 0.01 or above 100, the model has a training bug. Flag it.
 
Refuse to declare a model good on analogy accuracy alone. Analogy benchmarks are gameable and do not transfer to downstream tasks. Recommend intrinsic + downstream evaluation together.
Check: Ship It0/2
1. What must the embedding-probe skill refuse to do?Define
2. The embedding-probe finds one word whose embedding has norm 250. What does the skill conclude?Analyze
Author's Note

The part that trips people up the first time through is the two embedding tables. You train W and W' together, throw W' away, and keep W as "the" embedding. The first time I did this by hand I had a nagging feeling I'd thrown away half of something symmetric. I hadn't: skip-gram never asks for a symmetric answer, only for W[center] and W'[context] to agree on whether two words co-occur, and W alone already encodes that.

The other thing worth sitting with is negative sampling itself. Score the real pair up, score a few random pairs down: that's the entire trick, and it's the same trick CLIP and SimCLR run at internet scale a decade later. Word2Vec is where you get to learn it cheap, on a laptop, before you meet it again in a model that cost seven figures to train.

Exercises

  1. Easy. Run the training loop on a tiny corpus (20 sentences about cats and dogs). After 200 epochs, verify nearest(vocab, W, W[vocab["cat"]]) returns dog in its top 3. If not, increase epochs or vocabulary.
  2. Medium. Add subsampling of frequent words. Words with frequency above 10^-5 are dropped from training pairs with probability proportional to their frequency. Measure the effect on rare-word similarity.
  3. Hard. Train a model on the 20 Newsgroups corpus. Compute two bias axes: he - she and doctor - nurse. Project occupation words onto both axes. Report which occupations have the largest bias gap. This is the kind of probe fairness researchers use.

Key Terms

TermWhat people sayWhat it actually means
Word embeddingWord as a vectorA dense, low-dim (typically 100-300) representation learned from context.
Skip-gramWord2Vec trickPredict context words from center word. Slower than CBOW, better for rare words.
Negative samplingTraining shortcutReplace softmax over full vocab with binary classification against k random words.
Static embeddingOne vector per wordSame vector regardless of context. Fails on polysemy.
Contextual embeddingContext-sensitive vectorDifferent vector for each occurrence based on surrounding words. What transformers produce.
OOVOut of vocabularyWord not seen in training. Word2Vec cannot produce a vector for these.

Further Reading

Code

Ships With