AI Knowledge Tree

Word embeddings

Train word2vec (skip-gram with negative sampling) and contrast it with GloVe's matrix-factorization view.

⏱ 26 minPrerequisites:Multi-layer perceptron

A neural network cannot consume raw words; it needs vectors. The word embedding is the first deep-learning idea to take over NLP: learn a dense vector for each vocabulary word so that distances in embedding space track semantic similarity. The two recipes that established the field were word2vec (predict context from a word) and GloVe (factorize the global co-occurrence matrix). Both produce embeddings with the same striking property: vector arithmetic captures analogies.

The setup

Build a vocabulary VV of words. Assign each word w∈Vw \in V two dd-dimensional vectors: an input embedding uwu_w used when ww is the focus word, and an output embedding vwv_w used when ww appears in context. Typical dd is 100-300.

The goal: words appearing in similar contexts should have similar embeddings.

word2vec: skip-gram with negative sampling

Mikolov et al. (2013)[1]. Given a center word wcw_c in a sentence, predict the surrounding context words within a window of size LL.

Vanilla softmax loss:

L=−∑(c,o)log⁡exp⁡(uc⊤vo)∑w∈Vexp⁡(uc⊤vw).\mathcal{L} = -\sum_{(c, o)}\log \frac{\exp(u_c^\top v_o)}{\sum_{w \in V}\exp(u_c^\top v_w)}.

The denominator sums over the entire vocabulary, which is enormous (10510^5 to 10710^7 words). Computing it for every training pair is infeasible.

Negative sampling replaces the softmax with a binary classification: for each true (center, context) pair, sample kk random "negative" words and train the model to distinguish positives from negatives.

L=−log⁡σ(uc⊤vo)−∑i=1klog⁡σ(−uc⊤vwi),\mathcal{L} = -\log \sigma(u_c^\top v_o) - \sum_{i=1}^k \log \sigma(-u_c^\top v_{w_i}),

with wiw_i sampled from a noise distribution (typically P(w)∝f(w)3/4P(w) \propto f(w)^{3/4}, where f(w)f(w) is unigram frequency).

This makes training O(k)O(k) per pair instead of O(∣V∣)O(|V|) and scales to billions of pairs. Trained on 100B tokens, word2vec produces embeddings that solve the famous analogy "king - man + woman ≈ queen" in vector space.

GloVe: matrix factorization view

Pennington et al. (2014) showed that word2vec is implicitly factorizing a shifted log of the co-occurrence matrix[2]. GloVe does this explicitly:

  1. Count XijX_{ij} = the number of times word jj appears in the context of word ii across the corpus.
  2. Find vectors ui,vju_i, v_j and biases bi,cjb_i, c_j minimizing
L=∑i,jf(Xij) (ui⊤vj+bi+cj−log⁡Xij)2,\mathcal{L} = \sum_{i, j}f(X_{ij})\,(u_i^\top v_j + b_i + c_j - \log X_{ij})^2,

where ff is a weighting function that downweights very frequent and very rare pairs.

Trained on the same corpus, GloVe produces embeddings of comparable quality to word2vec, slightly faster to train, and with a cleaner mathematical interpretation.

What the embeddings encode

A few invariants that emerged from word2vec / GloVe:

  • Semantic similarity: cos⁡(uking,uqueen)\cos(u_{\text{king}}, u_{\text{queen}}) is large.
  • Syntactic similarity: cos⁡(urunning,ujumping)\cos(u_{\text{running}}, u_{\text{jumping}}) is large.
  • Analogies: uking−uman+uwoman≈uqueenu_{\text{king}} - u_{\text{man}} + u_{\text{woman}} \approx u_{\text{queen}}.
  • Polysemy is averaged: "bank" gets one embedding regardless of meaning.

These are not qualitative claims; cosine similarity puts a number on the geometry. The closing code trains skip-gram on a toy corpus and reads off the cosines, which separate cleanly by co-occurrence:

  • Two words that share contexts (words 1 and 2 both appear in the sentences [0,1,2,3] and [1,2,7,8]): cos⁡=0.66\cos = 0.66. The predictive objective pulled their vectors into near-alignment.
  • Two words that never co-occur (words 1 and 9, in disjoint sentences): cos⁡=0.37\cos = 0.37. No training signal ever tied them, so they drift toward the random baseline.

That gap, 0.660.66 against 0.370.37 on the same trained model with the same random initialization, is the whole mechanism in one contrast: the only thing separating the two pairs is whether the words occurred together, and that is what the geometry recovers.

The polysemy limitation is what motivated contextual embeddings (ELMo, BERT): instead of one vector per word, produce a vector per word in context. The next lessons cover the architectures that produce them.

Modern usage

Word2vec / GloVe embeddings are still useful baselines and feature inputs for small models. For state-of-the-art NLP, the embeddings are learned end-to-end inside a Transformer, typically with subword tokenization (Byte-Pair Encoding) so that out-of-vocabulary words are composed from learned pieces.

But the conceptual contribution of word2vec is permanent: distributed representations of discrete symbols, trained by a self-supervised predictive objective, generalizing across NLP, biology (protein embeddings), recommendation systems (item embeddings), and beyond.

Skip-gram with negative sampling learns vectors whose geometry mirrors co-occurrence: a 2D PCA of the embeddings cleanly separates the corpus's topical groups.
Skip-gram with negative sampling learns vectors whose geometry mirrors co-occurrence: a 2D PCA of the embeddings cleanly separates the corpus's topical groups.

Check it on arrays

python · numpy
import numpy as np

rng = np.random.default_rng(0)

# Tiny corpus: each "sentence" is a sequence of word indices
V = 12                                                     # vocab size
sentences = [
    [0, 1, 2, 3], [4, 5, 6], [1, 2, 7, 8], [9, 10, 11],
    [0, 1, 7], [2, 3, 5], [4, 5, 8, 10],
] * 50

# Skip-gram with negative sampling: train embeddings
d = 8
U = rng.normal(scale=0.1, size=(V, d))                     # input embeddings
W = rng.normal(scale=0.1, size=(V, d))                     # output embeddings
window = 2
k_neg = 5

def sigmoid(z): return 1 / (1 + np.exp(-z))

for epoch in range(50):
    for sent in sentences:
        for i, c in enumerate(sent):                       # center
            for j in range(max(0, i - window), min(len(sent), i + window + 1)):
                if j == i: continue
                o = sent[j]                                # positive context
                # Positive pair
                p_pos = sigmoid(U[c] @ W[o])
                grad_c = (p_pos - 1) * W[o]
                grad_o = (p_pos - 1) * U[c]
                U[c] -= 0.1 * grad_c
                W[o] -= 0.1 * grad_o
                # Negative samples (exclude the positive context and the center)
                for _ in range(k_neg):
                    n = int(rng.integers(0, V))
                    while n == o or n == c:
                        n = int(rng.integers(0, V))
                    p_neg = sigmoid(U[c] @ W[n])
                    grad_c = p_neg * W[n]                  # compute both grads BEFORE either update
                    grad_n = p_neg * U[c]
                    U[c] -= 0.1 * grad_c
                    W[n] -= 0.1 * grad_n

# Cosine similarity between learned embeddings
def cos(u, v): return u @ v / (np.linalg.norm(u) * np.linalg.norm(v) + 1e-12)
print("similarity(word 1, word 2) =", round(cos(U[1], U[2]), 3),
      "(both appear in sentences [0,1,2,3] and [1,2,7,8])")
print("similarity(word 1, word 9) =", round(cos(U[1], U[9]), 3),
      "(word 1 and word 9 never co-occur)")

Words that co-occur in the toy corpus learn similar embeddings; words that never co-occur do not.

Exercises

Exercise 1. A skip-gram model has center vector uc=(1,0)u_c = (1, 0) and a true context vector vo=(2,1)v_o = (2, 1). Compute the positive-pair loss −log⁡σ(uc⊤vo)-\log \sigma(u_c^\top v_o) and the gradient ∂L/∂uc=(σ(uc⊤vo)−1) vo\partial \mathcal{L} / \partial u_c = (\sigma(u_c^\top v_o) - 1)\, v_o by hand. Use σ(2)≈0.8808\sigma(2) \approx 0.8808 and ln⁡(0.8808)≈−0.1269\ln(0.8808) \approx -0.1269.

Solution

Step 1. The score is uc⊤vo=1⋅2+0⋅1=2u_c^\top v_o = 1 \cdot 2 + 0 \cdot 1 = 2, so σ(2)≈0.8808\sigma(2) \approx 0.8808.

Step 2. The positive loss is −log⁡σ(2)=−ln⁡(0.8808)≈0.1269-\log \sigma(2) = -\ln(0.8808) \approx 0.1269.

Step 3. The gradient prefactor is σ(2)−1≈−0.1192\sigma(2) - 1 \approx -0.1192, so ∂L/∂uc=−0.1192⋅(2,1)≈(−0.2384,−0.1192)\partial \mathcal{L} / \partial u_c = -0.1192 \cdot (2, 1) \approx (-0.2384, -0.1192).

The negative prefactor pushes ucu_c in the direction of +vo+v_o (gradient descent subtracts the gradient), pulling the center vector toward its true context, which is the alignment the cosine plot shows.

Exercise 2. GloVe weights its squared error by f(Xij)f(X_{ij}). The standard choice is f(x)=(x/xmax⁡)αf(x) = (x / x_{\max})^\alpha for x<xmax⁡x < x_{\max} and f(x)=1f(x) = 1 otherwise, with α=3/4\alpha = 3/4 and xmax⁡=100x_{\max} = 100. Compute f(1)f(1), f(10)f(10), and f(100)f(100), and explain in one line what the weighting buys.

Solution

Step 1. For x<xmax⁡x < x_{\max}, f(x)=(x/100)0.75f(x) = (x / 100)^{0.75}. At x=1x = 1: f(1)=(0.01)0.75=10−1.5≈0.0316f(1) = (0.01)^{0.75} = 10^{-1.5} \approx 0.0316.

Step 2. At x=10x = 10: f(10)=(0.1)0.75=10−0.75≈0.1778f(10) = (0.1)^{0.75} = 10^{-0.75} \approx 0.1778.

Step 3. At x=100=xmax⁡x = 100 = x_{\max}, the cap applies: f(100)=1f(100) = 1.

Rare co-occurrences (count 1) get weight 0.030.03 while frequent ones reach weight 11, and the cap stops extremely frequent pairs from dominating the loss; the weighting downweights both noisy rare counts and saturating common ones.

Exercise 3 (code). Verify on the trained toy embeddings that the skip-gram objective separates co-occurring words from non-co-occurring words: the mean cosine similarity over pairs that share a sentence should exceed the mean over pairs that never share one.

Solution
python · numpy
import numpy as np

rng = np.random.default_rng(0)

V = 12
sentences = [
    [0, 1, 2, 3], [4, 5, 6], [1, 2, 7, 8], [9, 10, 11],
    [0, 1, 7], [2, 3, 5], [4, 5, 8, 10],
] * 50

d = 8
U = rng.normal(scale=0.1, size=(V, d))
W = rng.normal(scale=0.1, size=(V, d))
window, k_neg = 2, 5

def sigmoid(z): return 1 / (1 + np.exp(-z))

for epoch in range(50):
    for sent in sentences:
        for i, c in enumerate(sent):
            for j in range(max(0, i - window), min(len(sent), i + window + 1)):
                if j == i: continue
                o = sent[j]
                p_pos = sigmoid(U[c] @ W[o])
                gc, go = (p_pos - 1) * W[o], (p_pos - 1) * U[c]
                U[c] -= 0.1 * gc; W[o] -= 0.1 * go
                for _ in range(k_neg):
                    n = int(rng.integers(0, V))
                    while n == o or n == c:
                        n = int(rng.integers(0, V))
                    p_neg = sigmoid(U[c] @ W[n])
                    gc, gn = p_neg * W[n], p_neg * U[c]
                    U[c] -= 0.1 * gc; W[n] -= 0.1 * gn

def cos(a, b): return a @ b / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-12)

# Build the set of unordered pairs that share at least one sentence
cooccur = set()
for sent in sentences:
    for a in sent:
        for b in sent:
            if a < b: cooccur.add((a, b))

co_sims, non_sims = [], []
for a in range(V):
    for b in range(a + 1, V):
        s = cos(U[a], U[b])
        (co_sims if (a, b) in cooccur else non_sims).append(s)

mean_co = np.mean(co_sims)
mean_non = np.mean(non_sims)
assert mean_co > mean_non
print("mean cosine, co-occurring pairs:    ", round(mean_co, 3))
print("mean cosine, non-co-occurring pairs:", round(mean_non, 3))

The co-occurring pairs sit at a higher mean cosine than the disjoint pairs, the geometric signature of the predictive objective on a corpus whose only structure is which words share sentences.

The next lesson tackles sequences directly with recurrent networks.


References

  1. [1]Mikolov, T., Chen, K., Corrado, G., & Dean, J. (2013). "Efficient estimation of word representations in vector space (word2vec)". arXiv:1301.3781. Introduces skip-gram and CBOW with negative sampling.↩
  2. [2]Pennington, J., Socher, R., & Manning, C. D. (2014). "GloVe: global vectors for word representation". EMNLP. doi:10.3115/v1/D14-1162. The global co-occurrence matrix-factorization view of embeddings.↩