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.
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 at position , predict each word within positions of it (here 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, when it is the center (row of the input table ) and when it is a context (row of the output table ). From here on, inside a subscript names a context word. The model says that the probability of context given center is a softmax over dot products,
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 tokens,
where 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 . Differentiating gives
∂ Step-by-step derivation
Let and . Then .
For the center vector, , so
An output vector appears in only, with . The term contributes only when , and contributes . Together they give .
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 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.
Full-softmax skip-gram, loss and gradients
For one (center, context) pair, implement softmax_sg(v, U, o) where v has shape (d,), U 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).
The problem is that sum over the vocabulary
Look at the denominator again, . Every single training pair touches every output vector. With and , that is 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 words is the context?" (a -way classification), ask "is this (center, context) pair real, or did I make it up?" That's a binary question, answered with a sigmoid,
For each real pair from the corpus, we draw negative words at random from a noise distribution . Those pairs are (almost certainly) fake. The loss for that single example is
In plain words, make the real pair's score high, and make each fake pair's score low. (Note , the probability of "fake".) The cost per pair is now multiply-adds instead of . With and that is 1,800 instead of 300 million, a factor of about 170,000.
The gradients
Using and , we get
∂ Derive it yourself (two lines of calculus)
First, . So . And by the chain rule, .
For the positive term, with has derivative with respect to . Multiply by or .
For the negative terms, has derivative with respect to . Multiply by or .
Each coefficient is an error signal. is how far the real pair falls short of "definitely real". 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 , one negative, and
The scores are and , so and . The loss is . The center gradient is
The output gradients are for the positive and for the negative. Take one step with learning rate , updating all three vectors from these gradients. The positive score rises to 0.166, the negative score falls to , 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 . The initial loss is therefore exactly , which is for . If your training curve doesn't start there, your initialization is not what you think it is.
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 , computed stably (np.logaddexp(0, -x) is your friend).
Choosing the negatives with the 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,
where is the word's relative frequency in the corpus. In plain words, raising frequencies to the power flattens the distribution, so frequent words are sampled less than their frequency and rare words more. Two words with frequencies and get noise probabilities of about and . 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 to 20 for small datasets and to 5 for very large ones.
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 for the binary search.
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 entries in which each word appears in proportion to , 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 sampling with only two arrays of length . Picture columns of equal capacity 1, one per item, and scale every probability by , so the heights average exactly 1. Some columns overflow () and some fall short (). Repeatedly take a short column and an overflowing column , keep of column for itself, and fill its remaining with mass taken from , recording as 's alias. Column shrinks by , 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 . Scaled by 4, the heights are . 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 in place with aliases . Check item 0. It gets the whole of column 0, plus of column 1 and of column 3, so . 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.
An alias-table negative sampler
Implement build_alias(P) returning (prob, alias), two length- arrays, and alias_sample(prob, alias, shape, rng) that draws samples with work each, fully vectorized (one uniform integer and one uniform float per sample). Building may use Python loops (it is 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.
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 before training, with probability
where is the subsampling threshold. In plain words, a word is kept with probability , so the more frequent it is, the more of its occurrences are thrown away. Some numbers with . A word as common as the (, one token in twenty) is kept only 1.4% of the time. A word with is kept 32% of the time. Any word rarer than 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 , 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 is drawn uniformly from to . A context word at distance is therefore used with probability , so nearer words count more. With , 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 .
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 and . The order of the pairs doesn't matter. Loop over the offsets only, never over positions.
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.
Vectorized mini-batch SGNS step
Implement sgns_step(Vin, Uout, w, c, neg, lr), which takes the following.
VinandUoutare(V, d)tables, modified in placewandcare(B,)center and positive-context IDsnegholds(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.
Train word2vec in your browser
Now we put everything together, a synthetic corpus, subsampling, pair extraction (with a fixed window, for simplicity), the noise distribution, linear learning-rate decay (as in the original code), and the vectorized step. It runs in a few seconds.
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.)
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.
- Subsample
idswith keep probability , with frequencies from the original counts. - Build pairs with dynamic windows drawn uniformly from to
window(use the providedskipgram_pairs). - Draw
knegatives per pair from (from the original counts). - Shuffle the pairs every epoch, and decay the learning rate linearly from
lr0toward 0 over all steps. - Call the provided
sgns_stepon 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 ).
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 with the average (using the input table for contexts) and predict with the output table. It's faster, since there's one prediction per window instead of , 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 of §1.4 (GloVe's name for ) and asks the vectors to explain the log counts directly,
with word vectors , context vectors , scalar biases , , and . 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 keeps very rare pairs (noisy) from mattering much and caps very frequent pairs (like of the), so a pair seen 10 times gets weight 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, and , while for words related to both or neither the ratio is near 1 ( for water, for fashion). If we want a function of vector differences to reproduce such ratios, , then is the natural choice, because it turns differences into ratios. That forces , and absorbing 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 .
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 , 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 , where is the set of the word's n-grams and the vector of n-gram . 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 is large enough that every dot product can be set independently, then the SGNS objective is maximized exactly when
(with PMI computed using the noise distribution for the context marginal, so with , this is the smoothed PMI from §1.4). In plain words, word2vec implicitly factorizes the PMI matrix, shifted down by , into . In practice 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 appears as a positive times. As a negative, it appears in expectation times (each of the training pairs with center draws negatives, each equal to with probability ). With , the objective to maximize is
If is large enough, each can be chosen independently. Setting for one cell gives
With (unigram) and the number of pairs, . The second derivative, , 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.
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 smoothing, subsampling, and so on), and why acts like a shift that pushes rare, weakly associated pairs below zero. It also shows that is not a log-probability, since nothing forces to equal 1, which is the NCE point from earlier made concrete.
The matrix SGNS is secretly factorizing
Implement shifted_pmi(C, k) returning for every cell with (and -np.inf where ). Use plain unigram marginals, from row sums and 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.)
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.
| method | objective | strengths | weaknesses |
|---|---|---|---|
| skip-gram + NS | real-vs-noise pairs | great for rare words, simple, streams data | many updates per token |
| CBOW + NS | predict center from averaged context | fast, good for frequent words | averaging blurs order and rare words |
| hierarchical softmax | exact softmax as a binary tree | normalized probabilities, | slower than NS, tree choice matters |
| GloVe | weighted least squares on | uses global counts, fast once counted | needs the co-occurrence matrix in memory |
| fastText | SGNS over character n-grams | handles unseen words and morphology | larger model, n-gram hashing collisions |
| PPMI + SVD (§1.4) | exact low-rank factorization | no SGD, transparent | matrix, less scalable |
Read more
- Mikolov et al. (2013a). Efficient Estimation of Word Representations in Vector Space. CBOW and skip-gram.
- Mikolov et al. (2013b). Distributed Representations of Words and Phrases and their Compositionality. Negative sampling (§2.2) and subsampling (§2.3).
- Rong (2014). word2vec Parameter Learning Explained and Goldberg & Levy (2014), word2vec Explained. Every gradient, derived.
- Dyer (2014). Notes on Noise Contrastive Estimation and Negative Sampling. Exactly how negative sampling differs from NCE, in four pages.
- Pennington, Socher & Manning (2014). GloVe. Weighted least squares on log counts, and the co-occurrence ratio argument.
- Bojanowski et al. (2017). Enriching Word Vectors with Subword Information. fastText.
- Levy & Goldberg (2014). Neural Word Embedding as Implicit Matrix Factorization. SGNS equals shifted PMI.
- Zhang et al. Dive into Deep Learning, §15.1 to §15.7 and Lilian Weng, Learning Word Embedding. Two excellent complementary walkthroughs.