Chapter 1 · Section 1.5 · 110 min

Learning embeddings with word2vec

In §1.4 we got embeddings by counting contexts and compressing the counts. Now we take the other road. We predict contexts with a tiny neural model and let the embeddings fall out as its parameters. This is word2vec (Mikolov et al., 2013a,b), one of the most influential small models in the history of NLP. It's worth knowing down to the last gradient, because its ingredients reappear everywhere. They include a softmax over a huge vocabulary, contrastive "real versus fake" training, negative sampling, and the dot product as a learned compatibility score.

By the end of this section you'll have derived every gradient by hand, implemented the training step and the samplers that interviewers love to ask for, trained a model in your browser, and seen why this "neural" method secretly computes the same thing as §1.4.

Watch word2vec learn meaning from scratch
Skip-gram with negative sampling (SGNS), trained live on a tiny synthetic corpus, with d = 2 so we can watch every vector move. Press play. Words never see their labels, only the words around them.
Loading interactive figure…

From counting to predicting

The first neural word embeddings came from language modeling. Bengio et al. (2003) trained a network to predict the next word from the previous few. It looked up their embeddings, concatenated them, applied an MLP, and finished with a softmax over the vocabulary. The embedding table was trained jointly, and similar words ended up with similar vectors because that helped prediction. (Andrej Karpathy rebuilds exactly this model in makemore part 2, linked from §1.2.)

Mikolov's insight was that if you only want the embeddings, you can throw away almost all of the model. Make the prediction task as simple as possible, with no hidden layer and just a dot product. Then train on billions of words.

Skip-gram predicts the neighbors

The skip-gram task is simple. Given a center word wtw_t at position tt, predict each word within cc positions of it (here cc is the window half-width).

... the  quick  [brown]  fox  jumps ...        center = brown, c = 2
          ↑       ↑       ↑     ↑
  pairs: (brown, the) (brown, quick) (brown, fox) (brown, jumps)

Every word gets two vectors, vw∈Rd\mathbf{v}_w \in \mathbb{R}^d when it is the center (row ww of the input table Ein\mathbf{E}_{\text{in}}) and uc∈Rd\mathbf{u}_c \in \mathbb{R}^d when it is a context (row cc of the output table Eout\mathbf{E}_{\text{out}}). From here on, cc inside a subscript names a context word. The model says that the probability of context oo given center ww is a softmax over dot products,

p(o∣w)  =  exp⁡(uo⊤vw)∑c∈Vexp⁡(uc⊤vw).p(o \mid w) \;=\; \frac{\exp(\mathbf{u}_o^\top \mathbf{v}_w)}{\sum_{c \in \mathcal{V}} \exp(\mathbf{u}_c^\top \mathbf{v}_w)} .

In plain words, score every possible context word by how well its output vector aligns with the center's input vector, then turn the scores into probabilities.

Training maximizes the average log-probability of all observed (center, context) pairs in a corpus of TT tokens,

J(θ)=1T∑t=1T  ∑−c≤j≤cj≠0log⁡p(wt+j∣wt),J(\theta) = \frac{1}{T}\sum_{t=1}^{T}\;\sum_{\substack{-c \le j \le c \\ j \neq 0}} \log p(w_{t+j}\mid w_t),

where θ\theta is both tables. In plain words, reward the model for giving high probability to the words that actually appeared around each center word.

The full-softmax gradient is expected minus observed

Write the loss for one pair as L=−log⁡p(o∣w)=−uo⊤vw+log⁡∑cexp⁡(uc⊤vw)\mathcal{L} = -\log p(o\mid w) = -\mathbf{u}_o^\top\mathbf{v}_w + \log\sum_{c}\exp(\mathbf{u}_c^\top\mathbf{v}_w). Differentiating gives

∂L∂vw=−uo+∑c∈Vp(c∣w) uc=Ec∼p(⋅∣w)[uc]⏟what the model expects−uo⏟what we observed,∂L∂uc=(p(c∣w)−1[c=o]) vw.\frac{\partial \mathcal{L}}{\partial \mathbf{v}_w} = -\mathbf{u}_o + \sum_{c\in\mathcal V} p(c\mid w)\,\mathbf{u}_c = \underbrace{\mathbb{E}_{c\sim p(\cdot\mid w)}[\mathbf{u}_c]}_{\text{what the model expects}} - \underbrace{\mathbf{u}_o}_{\text{what we observed}}, \qquad \frac{\partial \mathcal{L}}{\partial \mathbf{u}_c} = \big(p(c\mid w) - \mathbb{1}[c = o]\big)\,\mathbf{v}_w .
∂ Step-by-step derivation

Let sc=uc⊤vws_c = \mathbf{u}_c^\top\mathbf{v}_w and Z=∑cescZ = \sum_c e^{s_c}. Then L=−so+log⁡Z\mathcal{L} = -s_o + \log Z.

For the center vector, ∂sc∂vw=uc\frac{\partial s_c}{\partial \mathbf{v}_w} = \mathbf{u}_c, so

∂L∂vw=−uo+1Z∑cesc uc=−uo+∑cp(c∣w) uc.\frac{\partial \mathcal{L}}{\partial \mathbf{v}_w} = -\mathbf{u}_o + \frac{1}{Z}\sum_c e^{s_c}\,\mathbf{u}_c = -\mathbf{u}_o + \sum_c p(c\mid w)\,\mathbf{u}_c .

An output vector uc\mathbf{u}_c appears in scs_c only, with ∂sc∂uc=vw\frac{\partial s_c}{\partial \mathbf{u}_c} = \mathbf{v}_w. The −so-s_o term contributes −vw-\mathbf{v}_w only when c=oc=o, and log⁡Z\log Z contributes escZvw=p(c∣w)vw\frac{e^{s_c}}{Z}\mathbf{v}_w = p(c\mid w)\mathbf{v}_w. Together they give (p(c∣w)−1[c=o])vw\big(p(c\mid w) - \mathbb{1}[c=o]\big)\mathbf{v}_w.

This is the standard softmax-cross-entropy gradient, "predicted probabilities minus one-hot target" (§0.2), which you'll see again in every classifier and language model in this book.

The update pulls vw\mathbf{v}_w toward the observed context's vector and away from the average of what the model currently predicts. When the prediction is right, the two terms cancel and learning stops.

Core

Full-softmax skip-gram, loss and gradients

For one (center, context) pair, implement softmax_sg(v, U, o) where v =vw=\mathbf{v}_w has shape (d,), U =Eout=\mathbf{E}_{\text{out}} has shape (V, d), and o is the context index. Return (loss, dv, dU). Use a numerically stable log-sum-exp, because scores can be large and np.exp(1000) overflows (§0.4 covers the trick).

Hidden tests, hints, and AI help, from $1.99 a month.

The problem is that sum over the vocabulary

Look at the denominator again, ∑c∈Vexp⁡(uc⊤vw)\sum_{c\in\mathcal V}\exp(\mathbf{u}_c^\top\mathbf{v}_w). Every single training pair touches every output vector. With V=106V = 10^6 and d=300d = 300, that is 3×1083\times10^8 multiply-adds for one pair, and a corpus of a billion tokens with a window of 5 produces ten billion pairs. Word2vec offers two escapes, hierarchical softmax and negative sampling. Negative sampling is the one that won, and its core idea (contrast the real thing against sampled fakes) is the ancestor of the contrastive losses behind modern embedding models and joint image and text spaces.

Negative sampling asks "real or fake?" instead of "which word?"

Change the question. Instead of "which of the VV words is the context?" (a VV-way classification), ask "is this (center, context) pair real, or did I make it up?" That's a binary question, answered with a sigmoid,

P(real∣w,c)=σ(uc⊤vw),σ(x)=11+e−x.P(\text{real} \mid w, c) = \sigma(\mathbf{u}_c^\top\mathbf{v}_w), \qquad \sigma(x) = \frac{1}{1+e^{-x}} .

For each real pair (w,o)(w, o) from the corpus, we draw kk negative words n1,…,nkn_1,\dots,n_k at random from a noise distribution PnP_n. Those pairs are (almost certainly) fake. The loss for that single example is

LSGNS=−log⁡σ(uo⊤vw)  −  ∑i=1klog⁡σ(−uni⊤vw).\mathcal{L}_{\text{SGNS}} = -\log\sigma(\mathbf{u}_o^\top\mathbf{v}_w) \;-\; \sum_{i=1}^{k}\log\sigma(-\mathbf{u}_{n_i}^\top\mathbf{v}_w) .

In plain words, make the real pair's score high, and make each fake pair's score low. (Note σ(−x)=1−σ(x)\sigma(-x) = 1-\sigma(x), the probability of "fake".) The cost per pair is now (k+1)d(k+1)d multiply-adds instead of VdVd. With k=5k = 5 and d=300d = 300 that is 1,800 instead of 300 million, a factor of about 170,000.

The gradients

Using ddxlog⁡σ(x)=1−σ(x)\frac{d}{dx}\log\sigma(x) = 1-\sigma(x) and ddxlog⁡σ(−x)=−σ(x)\frac{d}{dx}\log\sigma(-x) = -\sigma(x), we get

∂L∂vw=(σ(uo⊤vw)−1) uo+∑i=1kσ(uni⊤vw) uni,\frac{\partial \mathcal{L}}{\partial \mathbf{v}_w} = \big(\sigma(\mathbf{u}_o^\top\mathbf{v}_w) - 1\big)\,\mathbf{u}_o + \sum_{i=1}^k \sigma(\mathbf{u}_{n_i}^\top\mathbf{v}_w)\,\mathbf{u}_{n_i}, ∂L∂uo=(σ(uo⊤vw)−1) vw,∂L∂uni=σ(uni⊤vw) vw.\frac{\partial \mathcal{L}}{\partial \mathbf{u}_o} = \big(\sigma(\mathbf{u}_o^\top\mathbf{v}_w) - 1\big)\,\mathbf{v}_w, \qquad \frac{\partial \mathcal{L}}{\partial \mathbf{u}_{n_i}} = \sigma(\mathbf{u}_{n_i}^\top\mathbf{v}_w)\,\mathbf{v}_w .
∂ Derive it yourself (two lines of calculus)

First, σ′(x)=σ(x)(1−σ(x))\sigma'(x) = \sigma(x)(1-\sigma(x)). So ddxlog⁡σ(x)=σ(x)(1−σ(x))σ(x)=1−σ(x)\frac{d}{dx}\log\sigma(x) = \frac{\sigma(x)(1-\sigma(x))}{\sigma(x)} = 1-\sigma(x). And by the chain rule, ddxlog⁡σ(−x)=−(1−σ(−x))=−σ(x)\frac{d}{dx}\log\sigma(-x) = -(1-\sigma(-x)) = -\sigma(x).

For the positive term, −log⁡σ(so)-\log\sigma(s_o) with so=uo⊤vws_o = \mathbf{u}_o^\top\mathbf{v}_w has derivative −(1−σ(so))=σ(so)−1-(1-\sigma(s_o)) = \sigma(s_o)-1 with respect to sos_o. Multiply by ∂so/∂vw=uo\partial s_o/\partial\mathbf{v}_w = \mathbf{u}_o or ∂so/∂uo=vw\partial s_o/\partial\mathbf{u}_o = \mathbf{v}_w.

For the negative terms, −log⁡σ(−si)-\log\sigma(-s_i) has derivative σ(si)\sigma(s_i) with respect to si=uni⊤vws_i = \mathbf{u}_{n_i}^\top\mathbf{v}_w. Multiply by uni\mathbf{u}_{n_i} or vw\mathbf{v}_w.

Each coefficient is an error signal. σ(so)−1∈(−1,0)\sigma(s_o)-1 \in (-1, 0) is how far the real pair falls short of "definitely real". σ(sni)∈(0,1)\sigma(s_{n_i}) \in (0,1) is how much the model wrongly believes a fake pair. When the model is right, the errors vanish.

One update, by hand

Numbers make this stick. Take d=2d = 2, one negative, and

vw=(0.5,−0.2),uo=(0.3,0.4),un=(−0.1,0.6).\mathbf{v}_w = (0.5, -0.2), \qquad \mathbf{u}_o = (0.3, 0.4), \qquad \mathbf{u}_{n} = (-0.1, 0.6).

The scores are so=0.15−0.08=0.07s_o = 0.15 - 0.08 = 0.07 and sn=−0.05−0.12=−0.17s_n = -0.05 - 0.12 = -0.17, so σ(so)=0.5175\sigma(s_o) = 0.5175 and σ(sn)=0.4576\sigma(s_n) = 0.4576. The loss is −log⁡0.5175−log⁡(1−0.4576)=0.6587+0.6118=1.2705-\log 0.5175 - \log(1 - 0.4576) = 0.6587 + 0.6118 = 1.2705. The center gradient is

∂L∂vw=(0.5175−1)(0.3,0.4)+0.4576 (−0.1,0.6)=(−0.1905, 0.0816).\frac{\partial\mathcal{L}}{\partial\mathbf{v}_w} = (0.5175 - 1)(0.3, 0.4) + 0.4576\,(-0.1, 0.6) = (-0.1905,\ 0.0816).

The output gradients are (0.5175−1) vw=(−0.2413,0.0965)(0.5175-1)\,\mathbf{v}_w = (-0.2413, 0.0965) for the positive and 0.4576 vw=(0.2288,−0.0915)0.4576\,\mathbf{v}_w = (0.2288, -0.0915) for the negative. Take one step with learning rate η=0.5\eta = 0.5, updating all three vectors from these gradients. The positive score rises to 0.166, the negative score falls to −0.283-0.283, and the loss drops to 1.175.

Notice one more number. word2vec initializes the output table to zeros, so every score starts at 0 and σ(0)=12\sigma(0) = \tfrac12. The initial loss is therefore exactly (k+1)log⁡2(k+1)\log 2, which is 6log⁡2≈4.1596\log 2 \approx 4.159 for k=5k = 5. If your training curve doesn't start there, your initialization is not what you think it is.

Core

SGNS loss and gradients for one example

Implement sgns(v, u_pos, U_neg) returning (loss, dv, du_pos, dU_neg) for one center vector v (d,), one positive context vector u_pos (d,), and k negative vectors U_neg (k, d). Make it numerically safe by using −log⁡σ(x)=log⁡(1+e−x)-\log\sigma(x) = \log(1+e^{-x}), computed stably (np.logaddexp(0, -x) is your friend).

Hidden tests, hints, and AI help, from $1.99 a month.

Choosing the negatives with the 3/43/4 power

Which words should be the "fakes"? Uniform sampling would mostly pick obscure words, which are too easy to reject. Sampling by raw frequency would mostly pick the, of, and. Mikolov et al. (2013b) found empirically that a compromise works best,

Pn(w)=f(w)3/4∑w′f(w′)3/4,P_n(w) = \frac{f(w)^{3/4}}{\sum_{w'} f(w')^{3/4}},

where f(w)f(w) is the word's relative frequency in the corpus. In plain words, raising frequencies to the power 3/43/4 flattens the distribution, so frequent words are sampled less than their frequency and rare words more. Two words with frequencies 0.90.9 and 0.10.1 get noise probabilities of about 0.840.84 and 0.160.16. Same order, gentler gap. It's the same flattening exponent we used for PPMI context smoothing in §1.4, and Levy, Goldberg and Dagan (2015) showed that the connection is not a coincidence (the derivation at the end of this section makes it exact).

How many negatives? The paper recommends k=5k = 5 to 20 for small datasets and k=2k = 2 to 5 for very large ones.

Warm-up

The noise distribution and an inverse-CDF sampler

Write noise_dist(counts, power=0.75) returning the normalized distribution, and sample_negatives(P, shape, rng) returning an int array of the given shape drawn from P. Do it without np.random.choice. Build the CDF and use np.searchsorted on uniform random numbers. Each draw costs O(log⁡V)O(\log V) for the binary search.

Hidden tests, hints, and AI help, from $1.99 a month.

Constant-time sampling with an alias table

Billions of negatives call for something faster than a binary search. Word2vec's C code precomputes a table of 10810^8 entries in which each word appears in proportion to PnP_n, so a sample is one random index into the table. That costs 400 MB of int32 and quantizes the probabilities.

The alias method (Walker, 1977; Vose, 1991) gets exact O(1)O(1) sampling with only two arrays of length VV. Picture VV columns of equal capacity 1, one per item, and scale every probability by VV, so the heights qi=Vpiq_i = V p_i average exactly 1. Some columns overflow (qi>1q_i > 1) and some fall short (qi<1q_i < 1). Repeatedly take a short column ss and an overflowing column ℓ\ell, keep qsq_s of column ss for itself, and fill its remaining 1−qs1 - q_s with mass taken from ℓ\ell, recording ℓ\ell as ss's alias. Column ℓ\ell shrinks by 1−qs1 - q_s, and goes back on the short or overflow list. When the lists empty, every column is exactly full and holds at most two items. To sample, pick a column uniformly and flip a biased coin to choose between the column's own item and its alias.

A worked example with P=(0.5,0.3,0.15,0.05)P = (0.5, 0.3, 0.15, 0.05). Scaled by 4, the heights are (2.0,1.2,0.6,0.2)(2.0, 1.2, 0.6, 0.2). Item 3 (height 0.2) borrows 0.8 from item 0, leaving item 0 at 1.2. Item 2 (0.6) borrows 0.4 from item 1, leaving item 1 at 0.8, which is now short. Item 1 borrows 0.2 from item 0, leaving item 0 at exactly 1. The final table keeps probability (1.0,0.8,0.6,0.2)(1.0, 0.8, 0.6, 0.2) in place with aliases (⋅,0,1,0)(\cdot, 0, 1, 0). Check item 0. It gets the whole of column 0, plus 0.20.2 of column 1 and 0.80.8 of column 3, so 14(1+0.2+0.8)=0.5\tfrac14(1 + 0.2 + 0.8) = 0.5. Alias tables are not unique. Pairing short and tall columns in a different order gives a different table with the same implied distribution, which is why the tests below check the distribution rather than the table.

Interview

An alias-table negative sampler

Implement build_alias(P) returning (prob, alias), two length-VV arrays, and alias_sample(prob, alias, shape, rng) that draws samples with O(1)O(1) work each, fully vectorized (one uniform integer and one uniform float per sample). Building may use Python loops (it is O(V)O(V) and runs once).

The tests check the exact distribution your table implies, not just a histogram, so off-by-one bookkeeping can't hide behind sampling noise.

Hidden tests, hints, and AI help, from $1.99 a month.

Two more tricks that matter a lot

Subsampling frequent words. Words like the appear in millions of pairs, but "brown co-occurs with the" teaches almost nothing. Word2vec randomly discards each occurrence of word ww before training, with probability

Pdiscard(w)=1−tf(w),t≈10−5,P_{\text{discard}}(w) = 1 - \sqrt{\frac{t}{f(w)}}, \qquad t \approx 10^{-5},

where tt is the subsampling threshold. In plain words, a word is kept with probability t/f(w)\sqrt{t/f(w)}, so the more frequent it is, the more of its occurrences are thrown away. Some numbers with t=10−5t = 10^{-5}. A word as common as the (f≈0.05f \approx 0.05, one token in twenty) is kept only 1.4% of the time. A word with f=10−4f = 10^{-4} is kept 32% of the time. Any word rarer than tt is always kept. This speeds training up a lot and improves the vectors of rarer words. It also effectively widens the window, because deleting the brings meaningful words closer together. (The released C code uses a slightly different formula with the same spirit, and its defaults differ from the paper too. It trains CBOW unless told otherwise, with a subsampling threshold of 10−310^{-3}, a window of 5, and 5 negatives. Check the defaults of any implementation you use.)

Dynamic window. For each center word, the actual window size btb_t is drawn uniformly from 11 to cc. A context word at distance jj is therefore used with probability (c−j+1)/c(c-j+1)/c, so nearer words count more. With c=5c = 5, distances 1 through 5 are used with probabilities 1, 0.8, 0.6, 0.4, 0.2. GloVe gets a similar effect by weighting co-occurrence counts by 1/distance1/\text{distance}.

Core

Skip-gram pairs with dynamic windows, vectorized

Implement skipgram_pairs(ids, b) where ids is a (T,) token array and b a (T,) array of per-position window sizes (each at least 1). Return (centers, contexts), two int arrays holding every pair (ids[t], ids[t+j]) with 1≤∣j∣≤bt1 \le \lvert j\rvert \le b_t and 0≤t+j<T0 \le t+j \lt T. The order of the pairs doesn't matter. Loop over the offsets j=1,…,max⁡tbtj = 1, \dots, \max_t b_t only, never over positions.

Hidden tests, hints, and AI help, from $1.99 a month.

Implement the training step (the interview question)

Here is the version you'd write at a whiteboard or in a coding interview. It's a mini-batch SGNS step, fully vectorized, updating both tables in place. The crucial detail is the same one as in §1.2. The same word appears many times in a batch, so updates must be scatter-added.

Interview

Vectorized mini-batch SGNS step

Implement sgns_step(Vin, Uout, w, c, neg, lr), which takes the following.

  • Vin and Uout are (V, d) tables, modified in place
  • w and c are (B,) center and positive-context IDs
  • neg holds (B, k) negative IDs
  • it performs one SGD step on the sum of the per-example SGNS losses, and returns the mean loss

No Python loops over the batch. The tests compare against a slow reference that loops.

Hidden tests, hints, and AI help, from $1.99 a month.

Train word2vec in your browser

Now we put everything together, a synthetic corpus, subsampling, pair extraction (with a fixed window, for simplicity), the f3/4f^{3/4} noise distribution, linear learning-rate decay (as in the original code), and the vectorized step. It runs in a few seconds.

Python · cell 1
Python · cell 2

The words never saw their category labels. They clustered because they share contexts. Try changing k, d, or the window, or set t so that nothing is subsampled, and compare. (Without subsampling, the, and, and "." dominate the batches, and because a word that appears 60 times in a batch receives the sum of 60 gradients at once, the learning rate that worked above can make training diverge.)

Interview

Write the whole training loop and hit a target

Now write the loop yourself. Implement train_sgns(ids, V, d=16, k=5, window=2, epochs=3, lr0=0.05, batch=256, t=1e-2, seed=0) returning (Vin, losses), where losses is the list of per-batch mean losses. Your loop must do five things.

  1. Subsample ids with keep probability min⁡(1,t/f(w))\min(1, \sqrt{t/f(w)}), with frequencies from the original counts.
  2. Build pairs with dynamic windows drawn uniformly from 11 to window (use the provided skipgram_pairs).
  3. Draw k negatives per pair from Pn∝counts3/4P_n \propto \text{counts}^{3/4} (from the original counts).
  4. Shuffle the pairs every epoch, and decay the learning rate linearly from lr0 toward 0 over all steps.
  5. Call the provided sgns_step on each full batch.

The tests train on a fresh synthetic corpus and require three things. Every noun's nearest noun must be in its own category, the average within-category cosine must beat the average cross-category cosine by at least 0.25, and the final loss must end below 3.0 (it starts at 6log⁡2≈4.166\log 2 \approx 4.16).

Hidden tests, hints, and AI help, from $1.99 a month.

CBOW, hierarchical softmax, and the rest of the family

CBOW (continuous bag of words) flips skip-gram around. It averages the context vectors and predicts the center word. The math is identical once you replace vw\mathbf{v}_w with the average vˉ=12c∑0<∣j∣≤cvwt+j\bar{\mathbf{v}} = \frac{1}{2c}\sum_{0 < \lvert j\rvert \le c} \mathbf{v}_{w_{t+j}} (using the input table for contexts) and predict wtw_t with the output table. It's faster, since there's one prediction per window instead of 2c2c, and tends to do slightly better for frequent words. Skip-gram tends to do better for rare words and small datasets (per the word2vec authors' notes). Averaging also throws away word order inside the window, and blurs rare context words into the crowd.

GloVe regresses log counts

GloVe (Pennington, Socher & Manning, 2014) starts from the co-occurrence matrix X\mathbf{X} of §1.4 (GloVe's name for C\mathbf{C}) and asks the vectors to explain the log counts directly,

J=∑i,j: Xij>0f(Xij)(wi⊤w~j+bi+b~j−log⁡Xij)2,f(x)={(x/xmax⁡)αx<xmax⁡1otherwiseJ = \sum_{i,j:\,X_{ij}>0} f(X_{ij})\left(\mathbf{w}_i^\top\tilde{\mathbf{w}}_j + b_i + \tilde b_j - \log X_{ij}\right)^2, \qquad f(x) = \begin{cases}(x/x_{\max})^{\alpha} & x < x_{\max}\\ 1 & \text{otherwise}\end{cases}

with word vectors wi\mathbf{w}_i, context vectors w~j\tilde{\mathbf{w}}_j, scalar biases bi,b~jb_i, \tilde b_j, α=3/4\alpha = 3/4, and xmax⁡=100x_{\max} = 100. In plain words, it is a weighted least-squares fit that makes each word–context dot product (plus two biases) equal the log of how often they co-occurred. The weighting ff keeps very rare pairs (noisy) from mattering much and caps very frequent pairs (like of the), so a pair seen 10 times gets weight 0.10.75≈0.180.1^{0.75} \approx 0.18 and a pair seen 100 or more times gets weight 1.

The motivation is a nice piece of reasoning. Ratios of co-occurrence probabilities carry meaning. In the paper's 6-billion-token corpus, P(solid∣ice)/P(solid∣steam)=8.9P(\text{solid}\mid\text{ice}) / P(\text{solid}\mid\text{steam}) = 8.9 and P(gas∣ice)/P(gas∣steam)=0.085P(\text{gas}\mid\text{ice})/P(\text{gas}\mid\text{steam}) = 0.085, while for words related to both or neither the ratio is near 1 (1.361.36 for water, 0.960.96 for fashion). If we want a function of vector differences to reproduce such ratios, F((wi−wj)⊤w~k)=Pik/PjkF\big((\mathbf{w}_i - \mathbf{w}_j)^\top\tilde{\mathbf{w}}_k\big) = P_{ik}/P_{jk}, then F=exp⁡F = \exp is the natural choice, because it turns differences into ratios. That forces wi⊤w~k=log⁡Pik=log⁡Xik−log⁡Xi\mathbf{w}_i^\top\tilde{\mathbf{w}}_k = \log P_{ik} = \log X_{ik} - \log X_i, and absorbing log⁡Xi\log X_i into a bias (and adding a context bias for symmetry) gives the objective above. GloVe is a weighted matrix factorization that combines the global statistics of the counting approach with the trainable, scalable machinery of the predictive one. The final vectors are usually W+W~\mathbf{W} + \tilde{\mathbf{W}}.

fastText says words are made of pieces

Word2vec gives each word an atomic vector, so it knows nothing about a word it never saw, and nothing about the shared structure of run / runs / running. fastText (Bojanowski et al., 2017) represents a word as the sum of its character n-gram vectors (n from 3 to 6, with boundary markers) plus a vector for the whole word. For where with n=3n = 3, the n-grams are <wh, whe, her, ere, re>, plus the special sequence <where>. Then skip-gram with negative sampling trains exactly as before, with vw=∑g∈G(w)zg\mathbf{v}_w = \sum_{g\in G(w)}\mathbf{z}_g, where G(w)G(w) is the set of the word's n-grams and zg\mathbf{z}_g the vector of n-gram gg. To bound memory, the n-grams are hashed into 2 million buckets, so unrelated n-grams occasionally share a vector. Unseen words still get vectors (sum their n-grams), and morphology is shared. This idea of building words from subword pieces is exactly where Chapter 2 picks up.

The big reveal, SGNS is factorizing a PMI matrix

Why do count-based (§1.4) and predict-based methods end up so similar? Levy and Goldberg (2014) proved a beautiful result. If the dimension dd is large enough that every dot product can be set independently, then the SGNS objective is maximized exactly when

  vw⊤uc=PMI⁡(w,c)−log⁡k  \boxed{\;\mathbf{v}_w^\top\mathbf{u}_c = \operatorname{PMI}(w,c) - \log k\;}

(with PMI computed using the noise distribution for the context marginal, so with Pn∝f3/4P_n\propto f^{3/4}, this is the smoothed PMI from §1.4). In plain words, word2vec implicitly factorizes the PMI matrix, shifted down by log⁡k\log k, into EinEout⊤\mathbf{E}_{\text{in}}\mathbf{E}_{\text{out}}^\top. In practice dd is smaller, so SGNS finds a low-rank weighted approximation of it, much like the truncated SVD of §1.4 but with a different weighting of errors (frequent pairs count more, because they appear in more gradient steps).

∂ The proof sketch (worth following)

Sum the SGNS loss over the whole corpus and group terms by word pair. The pair (w,c)(w,c) appears as a positive #(w,c)\#(w,c) times. As a negative, it appears in expectation k⋅#(w)⋅Pn(c)k\cdot\#(w)\cdot P_n(c) times (each of the #(w)\#(w) training pairs with center ww draws kk negatives, each equal to cc with probability Pn(c)P_n(c)). With x=vw⊤ucx = \mathbf{v}_w^\top\mathbf{u}_c, the objective to maximize is

ℓ=∑w,c[#(w,c)log⁡σ(x)+k #(w) Pn(c)log⁡σ(−x)].\ell = \sum_{w,c} \Big[\#(w,c)\log\sigma(x) + k\,\#(w)\,P_n(c)\log\sigma(-x)\Big].

If dd is large enough, each xwcx_{wc} can be chosen independently. Setting ∂ℓ/∂x=0\partial\ell/\partial x = 0 for one cell gives

#(w,c) (1−σ(x))−k #(w)Pn(c) σ(x)=0    ⇒    σ(x)1−σ(x)=ex=#(w,c)k #(w) Pn(c).\#(w,c)\,(1-\sigma(x)) - k\,\#(w)P_n(c)\,\sigma(x) = 0 \;\;\Rightarrow\;\; \frac{\sigma(x)}{1-\sigma(x)} = e^{x} = \frac{\#(w,c)}{k\,\#(w)\,P_n(c)} .

With Pn(c)=#(c)/∣D∣P_n(c) = \#(c)/|D| (unigram) and ∣D∣|D| the number of pairs, x=log⁡#(w,c) ∣D∣#(w) #(c)−log⁡k=PMI⁡(w,c)−log⁡kx = \log\frac{\#(w,c)\,|D|}{\#(w)\,\#(c)} - \log k = \operatorname{PMI}(w,c) - \log k. The second derivative, −(#(w,c)+k#(w)Pn(c))σ(x)(1−σ(x))-\big(\#(w,c) + k\#(w)P_n(c)\big)\sigma(x)(1-\sigma(x)), is negative, so this stationary point is the maximum. ∎

You can check the one-cell claim numerically in a few lines. Pick counts, maximize the per-cell objective over a fine grid, and compare with the formula.

Python · cell 3

This connects the whole chapter. Counting and predicting are two routes to factorizing the same matrix. It also explains why the two approaches' tricks transfer (the 3/43/4 smoothing, subsampling, and so on), and why kk acts like a shift that pushes rare, weakly associated pairs below zero. It also shows that uc⊤vw\mathbf{u}_c^\top\mathbf{v}_w is not a log-probability, since nothing forces ∑ceuc⊤vw\sum_c e^{\mathbf{u}_c^\top\mathbf{v}_w} to equal 1, which is the NCE point from earlier made concrete.

Research

The matrix SGNS is secretly factorizing

Implement shifted_pmi(C, k) returning PMI⁡(w,c)−log⁡k\operatorname{PMI}(w,c) - \log k for every cell with Cwc>0C_{wc}>0 (and -np.inf where Cwc=0C_{wc}=0). Use plain unigram marginals, p(w)p(w) from row sums and p(c)p(c) from column sums. The tests check shifted_pmi.

Then investigate (optional). Train the browser model above with a large d (say 64) on this tiny corpus. Compute the co-occurrence counts of the training pairs, then scatter-plot Vin @ Uout.T against shifted_pmi(C, k) for cells with enough counts. How close does it get? Why isn't it perfect? (Ask the AI to discuss your plot.)

Hidden tests, hints, and AI help, from $1.99 a month.

In PyTorch

The production version uses two nn.Embedding tables with sparse gradients, logsigmoid for stability, and SparseAdam. On a laptop or a single GPU it trains on the text8 corpus (about 17 million tokens of Wikipedia) in minutes.

🔥 In PyTorchfor a machine with PyTorch
methodobjectivestrengthsweaknesses
skip-gram + NSreal-vs-noise pairsgreat for rare words, simple, streams datamany updates per token
CBOW + NSpredict center from averaged contextfast, good for frequent wordsaveraging blurs order and rare words
hierarchical softmaxexact softmax as a binary treenormalized probabilities, O(dlog⁡V)O(d\log V)slower than NS, tree choice matters
GloVeweighted least squares on log⁡X\log Xuses global counts, fast once countedneeds the co-occurrence matrix in memory
fastTextSGNS over character n-gramshandles unseen words and morphologylarger model, n-gram hashing collisions
PPMI + SVD (§1.4)exact low-rank factorizationno SGD, transparentV×VV\times V matrix, less scalable

Read more

Watch next

🎬 Word Embedding and Word2Vec, Clearly Explained!!! · StatQuest with Josh StarmerA gentle walk-through, including negative sampling.
🎬 Lecture 2 | Word Vector Representations: word2vec · Stanford University School of EngineeringThe classic CS224N derivation of the skip-gram gradient.
🎬 The Illustrated Word2vec - A Gentle Intro to Word Embeddings in Machine Learning · Jay AlammarA visual explanation of SGNS as binary classification.