AI Knowledge Tree

Word2vec: CBOW and skip-gram

Train word vectors with the skip-gram negative-sampling objective; see why nearby tokens share embedding space and how analogies emerge.

⏱ 32 minPrerequisites:N-gram language models

N-gram models treat vocabulary items as discrete symbols -- "cat" and "kitten" are as unrelated as "cat" and "democracy." Word embeddings map each token to a continuous vector such that semantically and syntactically related words land nearby. This geometric structure is not hand-coded; it emerges from predicting which words appear in which contexts.

The distributional hypothesis

"A word is characterized by the company it keeps" (Firth, 1957).

Words that appear in similar contexts tend to have similar meanings. "Bank" and "river" often co-occur (river bank); "bank" and "finance" also co-occur (financial bank). An embedding that captures co-occurrence statistics will place both senses of "bank" near their respective neighbors.

Two model architectures

CBOW (Continuous Bag of Words): predict the center word from its context. Skip-gram: predict context words from the center word.

Both architectures come from the original word2vec work[1]. Skip-gram with negative sampling (SGNS)[2] is the workhorse and produces better representations for rare words; we derive it in full.

Skip-gram with negative sampling

The full softmax over a VV-word vocabulary is expensive. Negative sampling replaces it with a binary classification task:

For each (center ww, context cc) pair from the corpus, maximize:

L=log⁡σ(vw⋅uc)+∑k=1KEck∼Pn[log⁡σ(−vw⋅uck)],\mathcal{L} = \log \sigma(\mathbf{v}_w \cdot \mathbf{u}_c) + \sum_{k=1}^{K} \mathbb{E}_{c_k \sim P_n} \left[ \log \sigma(-\mathbf{v}_w \cdot \mathbf{u}_{c_k}) \right],

where vw\mathbf{v}_w is the center embedding of ww, uc\mathbf{u}_c is the context embedding of cc, and Pn(w)∝f(w)3/4P_n(w) \propto f(w)^{3/4} is the noise distribution (unigram frequencies raised to the 3/4 power, which smooths the distribution toward rare words)[2]. K≈5−15K \approx 5{-}15 negative samples per positive pair.

The sigmoid σ(x)=1/(1+e−x)\sigma(x) = 1/(1 + e^{-x}) turns dot products into probabilities. Maximizing log⁡σ(vw⋅uc)\log \sigma(\mathbf{v}_w \cdot \mathbf{u}_c) pushes true context pairs to high dot product; maximizing log⁡σ(−vw⋅uck)\log \sigma(-\mathbf{v}_w \cdot \mathbf{u}_{c_k}) pushes negative pairs to low dot product.

Why analogies emerge

After training, the embedding space satisfies:

vking−vman+vwoman≈vqueen.\mathbf{v}_{\text{king}} - \mathbf{v}_{\text{man}} + \mathbf{v}_{\text{woman}} \approx \mathbf{v}_{\text{queen}}.

This is not hand-engineered. It emerges because "king" and "queen" share all contexts except those relating to gender, while "man" and "woman" differ in exactly those gender-related contexts. The difference vking−vqueen\mathbf{v}_{\text{king}} - \mathbf{v}_{\text{queen}} encodes the gender direction, and adding vwoman\mathbf{v}_{\text{woman}} traverses it.

Implementation with numpy

python · numpy
import numpy as np
from collections import Counter

# Toy corpus
sentences = [
    ["the", "cat", "sat", "on", "the", "mat"],
    ["the", "dog", "sat", "on", "the", "floor"],
    ["cats", "and", "dogs", "are", "animals"],
    ["animals", "live", "on", "the", "floor", "and", "mat"],
]
vocab = list(set(w for s in sentences for w in s))
w2i = {w: i for i, w in enumerate(vocab)}
V = len(vocab)

# Hyperparameters
d = 10        # embedding dimension
window = 2    # context window size
K = 3         # negative samples
lr = 0.05
n_epochs = 200

# Unigram^(3/4) noise distribution
counts = Counter(w for s in sentences for w in s)
noise_weights = np.array([counts[w] ** 0.75 for w in vocab])
noise_weights /= noise_weights.sum()

# Embeddings: center (V) and context (U)
rng = np.random.default_rng(42)
V_emb = rng.normal(0, 0.1, (V, d))   # center embeddings
U_emb = rng.normal(0, 0.1, (V, d))   # context embeddings

def sigmoid(x):
    return 1.0 / (1.0 + np.exp(-np.clip(x, -10, 10)))

# Build (center, context) training pairs
pairs = []
for sent in sentences:
    for i, center in enumerate(sent):
        for j in range(max(0, i - window), min(len(sent), i + window + 1)):
            if j != i:
                pairs.append((w2i[center], w2i[sent[j]]))

for epoch in range(n_epochs):
    total_loss = 0.0
    for wi, ci in pairs:
        # Positive sample
        score_pos = V_emb[wi] @ U_emb[ci]
        prob_pos  = sigmoid(score_pos)
        grad_pos  = (1 - prob_pos)
        # Negative samples
        neg_ids = rng.choice(V, size=K, p=noise_weights)
        grad_v  = grad_pos * U_emb[ci].copy()
        grad_uc = grad_pos * V_emb[wi].copy()
        for ni in neg_ids:
            score_neg = V_emb[wi] @ U_emb[ni]
            prob_neg  = sigmoid(-score_neg)   # want to be 0
            grad_neg  = -(1 - prob_neg)
            grad_v   += grad_neg * U_emb[ni]
            U_emb[ni] += lr * grad_neg * V_emb[wi]
            total_loss -= np.log(prob_neg + 1e-9)
        V_emb[wi] += lr * grad_v
        U_emb[ci] += lr * grad_uc
        total_loss -= np.log(prob_pos + 1e-9)
    if (epoch + 1) % 50 == 0:
        print(f"Epoch {epoch + 1}: loss = {total_loss:.2f}")

# Nearest neighbors of "cat"
query = V_emb[w2i["cat"]]
sims  = V_emb @ query / (np.linalg.norm(V_emb, axis=1) * np.linalg.norm(query) + 1e-9)
top5  = sims.argsort()[-5:][::-1]
print("\nNearest to 'cat':", [vocab[i] for i in top5])
word2vec encodes relations as directions: the man→woman vector is nearly parallel to king→queen, so king − man + woman lands next to queen: analogy by arithmetic.
word2vec encodes relations as directions: the man→woman vector is nearly parallel to king→queen, so king − man + woman lands next to queen: analogy by arithmetic.

Exercises

Exercise 1. For a single positive (center, context) pair, the skip-gram objective is L=log⁡σ(vw⋅uc)\mathcal{L} = \log \sigma(\mathbf{v}_w \cdot \mathbf{u}_c). Derive ∂L/∂vw\partial \mathcal{L} / \partial \mathbf{v}_w by hand, then evaluate it for vw=(0.5,−0.2,0.3)\mathbf{v}_w = (0.5, -0.2, 0.3) and uc=(0.1,0.4,−0.1)\mathbf{u}_c = (0.1, 0.4, -0.1).

Solution

Step 1. Let z=vw⋅ucz = \mathbf{v}_w \cdot \mathbf{u}_c. Then L=log⁡σ(z)\mathcal{L} = \log \sigma(z) and by the chain rule ∂L∂z=σ′(z)σ(z)\frac{\partial \mathcal{L}}{\partial z} = \frac{\sigma'(z)}{\sigma(z)}.

Step 2. Use the sigmoid identity σ′(z)=σ(z)(1−σ(z))\sigma'(z) = \sigma(z)(1 - \sigma(z)), so ∂L∂z=1−σ(z)\frac{\partial \mathcal{L}}{\partial z} = 1 - \sigma(z).

Step 3. Since z=vw⋅ucz = \mathbf{v}_w \cdot \mathbf{u}_c, the gradient ∂z/∂vw=uc\partial z / \partial \mathbf{v}_w = \mathbf{u}_c, giving

∂L∂vw=(1−σ(z)) uc.\frac{\partial \mathcal{L}}{\partial \mathbf{v}_w} = (1 - \sigma(z))\,\mathbf{u}_c.

Step 4. Numerically, z=0.5(0.1)+(−0.2)(0.4)+0.3(−0.1)=−0.06z = 0.5(0.1) + (-0.2)(0.4) + 0.3(-0.1) = -0.06, so σ(z)=0.485\sigma(z) = 0.485 and 1−σ(z)=0.5151 - \sigma(z) = 0.515. The gradient is 0.515⋅(0.1,0.4,−0.1)=(0.0515,0.2060,−0.0515)0.515 \cdot (0.1, 0.4, -0.1) = (0.0515, 0.2060, -0.0515). The update vw←vw+η(1−σ(z)) uc\mathbf{v}_w \leftarrow \mathbf{v}_w + \eta (1 - \sigma(z))\,\mathbf{u}_c pushes the center vector toward its context vector, raising the dot product.

Exercise 2. The noise distribution is Pn(w)∝f(w)3/4P_n(w) \propto f(w)^{3/4}. Take a two-word vocabulary with unigram frequencies f(a)=0.9f(a) = 0.9, f(b)=0.1f(b) = 0.1. Compute Pn(a)P_n(a) and Pn(b)P_n(b), and compare with the raw unigram probabilities. Which word does the 3/43/4 power favor as a negative sample?

Solution

Step 1. Raise each frequency to the 3/43/4 power: 0.90.75=0.92400.9^{0.75} = 0.9240 and 0.10.75=0.17780.1^{0.75} = 0.1778.

Step 2. Normalize so the two sum to 1. The total is 0.9240+0.1778=1.10180.9240 + 0.1778 = 1.1018, so Pn(a)=0.9240/1.1018=0.8386P_n(a) = 0.9240 / 1.1018 = 0.8386 and Pn(b)=0.1778/1.1018=0.1614P_n(b) = 0.1778 / 1.1018 = 0.1614.

Step 3. Compare with the raw probabilities (0.9,0.1)(0.9, 0.1). The rare word bb went from 0.100.10 to 0.160.16 and the common word aa dropped from 0.900.90 to 0.840.84. The 3/43/4 power flattens the distribution toward the rare word, so rare words are sampled as negatives more often than their raw frequency would dictate.

Exercise 3 (code). The skip-gram negative-sampling gradient is hand-derived in the implementation above. Verify the full gradient with respect to vw\mathbf{v}_w (one positive context plus KK negatives) against a finite- difference estimate of the objective.

Solution
python · numpy
import numpy as np

def sigmoid(x):
    return 1.0 / (1.0 + np.exp(-x))

rng = np.random.default_rng(0)
d, K = 5, 4
v_w = rng.normal(0, 1, d)
u_c = rng.normal(0, 1, d)
U_neg = rng.normal(0, 1, (K, d))      # K negative context vectors

def objective(v):
    pos = np.log(sigmoid(v @ u_c))
    neg = np.sum(np.log(sigmoid(-(U_neg @ v))))
    return pos + neg

# Analytic gradient wrt v_w:
#   d/dv [log sigma(v.u_c)]      = (1 - sigma(v.u_c)) u_c
#   d/dv [log sigma(-v.u_k)]     = -(1 - sigma(-v.u_k)) u_k
g = (1 - sigmoid(v_w @ u_c)) * u_c
g -= np.sum((1 - sigmoid(-(U_neg @ v_w)))[:, None] * U_neg, axis=0)

# Central finite differences
eps = 1e-6
g_fd = np.zeros(d)
for i in range(d):
    e = np.zeros(d); e[i] = eps
    g_fd[i] = (objective(v_w + e) - objective(v_w - e)) / (2 * eps)

assert np.allclose(g, g_fd, atol=1e-5)
print("max abs diff:", np.abs(g - g_fd).max())

The analytic gradient matches the numerical one to within 10−510^{-5}: the positive term pulls vw\mathbf{v}_w toward uc\mathbf{u}_c while each negative term pushes it away from uk\mathbf{u}_k.

What word2vec captures (and misses)

Word2vec learns static embeddings: one vector per word type, regardless of context. "Bank" gets the same vector whether the sentence is about rivers or finance. This is the fundamental limitation that contextual embeddings (ELMo, BERT) address.

It also misses morphology: "play," "plays," "playing," and "played" are four separate vocabulary entries with independently trained embeddings. FastText (next lesson) fixes this by composing embeddings from character n-grams.


References

  1. [1]Mikolov, T., Chen, K., Corrado, G., & Dean, J. (2013). "Efficient estimation of word representations in vector space". arXiv:1301.3781. Introduces the CBOW and skip-gram architectures.↩
  2. [2]Mikolov, T., Sutskever, I., Chen, K., Corrado, G., & Dean, J. (2013). "Distributed representations of words and phrases and their compositionality". NeurIPS. arXiv:1310.4546. Negative sampling and the unigram^(3/4) noise distribution.↩