Word2Vec: skip-gram and CBOW
24 min read
The previous lesson, on dense representations, built the table and left it empty: the embedding matrix has a shape, a size and a geometry you can now read, and not one number in it. The distributional hypothesis asserts where meaning lives and does not say how to compute it. Word2Vec was the first to close that gap at real scale, in 2013, and it comes from an unexpected direction: it computes no one's coordinates, it lets them fall out as the residue of another task.
The task is a guessing game. Take the dog barks in the yard of the school, cover everything but dog and ask what was around it; a model that answers barks before school has learned something, and to answer it needs numbers to score candidates with. Those numbers are in the matrix , which enters a model with random values and gets corrected until it gets them right (that is what training is), after which the model is thrown away and is kept. Word2Vec is that idea turned into a technique, and it is three pieces: a prediction task over windows of text, two ways of solving it (skip-gram and CBOW, continuous bag of words) and a simplification that makes the computation viable: negative sampling. What is left to decide is which parameters do the scoring, and what "getting it right" means once it is measured by a count that, written out with care, comes out unaffordable.
Slide a window of tokens on each side along the corpus: the middle one is the centre, the ones at the sides are its context, and one pair comes out for each neighbour. With , the centre yard of the sentence above produces four: with in, the, of and the again. The raw corpus already carries the pairs inside it: the distributional hypothesis's promise, collected.
Predicting the context: the skip-gram objective
Fix the window at and walk the corpus. The skip-gram takes each position , with its central token , and asks to predict each of its neighbours separately. Getting it right is written as a loss: a number, computed from the parameters and the whole corpus, that measures how badly the model does; correcting them until it gets it right is making that number small. The skip-gram's is the mean negative log-likelihood of the pairs:
where runs over the positions of the corpus and the minus sign turns the criterion around: the probability of the real pair has to go up, so has to go down. The essential thing is missing: what is.
Predicting is choosing among candidates, and choosing requires scoring them. We need a number that says how well the centre and a candidate context fit together. Word2Vec takes the simplest one between two vectors, their dot product: it grows when they point in similar directions and when they are long. We call it the pair's affinity.
For now we have only the table with one vector per entry; the affinity of a pair comes from reading two of its rows. That forces every entry to use the same vector as centre and as candidate, and the flaw shows in the pair of an entry with itself: , positive and larger the longer the vector is. The cosine of the previous lesson cancelled it by dividing by the norms; in the affinity there is no division. And the corpus asks for the opposite: dog almost never falls inside its own window, and it has to be possible to score that pair low. But lowering forces to shrink, and that lowers every affinity in which is the centre at the same time.
Hence the design decision that defines Word2Vec: each entry receives two vectors of coordinates, the for when it acts as centre and a new one, , for when it acts as context. They live in two tables of the same shape, , with row of equal to exactly as that of is . The affinity becomes , and that of an entry with itself, , can go down without shrinking.
Reading affinity as a probability asks for it to be made positive and for all of them to sum to 1; the softmax does both:
The exponential function preserves order, so the candidate with the most affinity takes the highest probability. And keep this about the denominator: it is there so that the affinities are a probability distribution over , and for nothing else.
The parameters are , and depends on nothing else. To minimise it we use gradient descent, which we will see in detail in Block 2, on the multilayer perceptron.
The gradient points in the direction of the loss's steepest increase. So, at each step , we update the parameters in the opposite direction,
where the subscript counts descent steps, not corpus positions, and is the learning rate, the size of the step. Choosing it is that block's business: too large and the descent diverges.
CBOW: the same idea with the roles swapped
CBOW inverts the question: it guesses the centre from the context. The neighbours enter averaged into a single vector,
and the probability of the centre is the same softmax with in place of . The average loses the order: the bag of words of the lesson on TF-IDF in miniature, and hence the name.
Both are used: CBOW is faster (one prediction per position instead of ), and skip-gram makes more of the rare entries, which lose out when they are averaged in. What they share is what matters now: a softmax over .
The sum that makes the objective unaffordable
Count the operations of a single probability. The numerator is a dot product, multiplications; the denominator, one per vocabulary entry:
Fifteen million for a single pair. With pairs per token and a corpus of , that gives pairs, and a whole pass costs operations, and training does several. No machine was going to pay that for an embedding, not in 2013 and not now.
Look at where the cost is, because that is where the way out comes from. Making barks score high next to dog costs 300 multiplications; the other 14,999,700 go on a sum whose only job is to make the probabilities sum to one. You pay for a distribution over 50,000 entries you are not going to use: when it ends you throw the model away and keep .
So the exact objective is abandoned, and it is worth saying so in full, because it is a move the course will make again, in Block 5, with attention: when the right quantity is unaffordable, you swap it for one you can afford with similar optima, and then check whether the swap broke anything. What you do not do is pretend the second is the first.
Negative sampling: from sharing probability out to telling pairs apart
The substitution changes the question. In place of asking, given dog, what probability each of the 50,000 entries has of going with it, it asks a different question: is this pair, drawn at random, from the corpus, or did I make it up?
False pairs are needed, and they are manufactured: to the real pair we add pairs with the context drawn at random. The original paper fixed the number by trying: from to with small corpora, from to with large ones. Take . Each pair's probability is computed by squashing its affinity into the interval with the logistic function
The loss of a real pair and its negatives is
and it reads left to right. The first term goes down when rises towards : when the centre moves closer to the real context. In the second, is the probability of "no", and each summand goes down when the affinity with an invented context turns negative. Move closer to one, away from the other five.
The negatives are not drawn uniformly at random, but from
with the frequency of the type in the corpus. The exponent compresses the distribution without flattening it, between two bad extremes: with the negatives would be the function words of the lesson on the bag of words, with the rarest entries, and neither costs anything to get right. There is no derivation behind it; it is the value that worked.
Now the count, the reason for all of this. A step touches the centre, the real context and the negatives, one dot product per vector:
against the 15 million of before. The factor is , and the whole pass drops from to : from impossible to an afternoon. Notice what has disappeared and what has not: the cost per pair stops depending on , and the pairs are still there.
The concession, out loud: this is not the softmax computed faster, it is a different loss, and raising does not make it converge to . Whether its optima place the vectors where they serve us is checked by looking at the neighbours that come out. The paper brings a second shortcut with the same end, hierarchical softmax, which this lesson does not go through.
The step needs , and it comes from two derivatives that say, in passing, where each term pushes.
Where the step comes from: the gradient of a pair's loss with respect to the centre vector
The derivative of the logistic is , from which
Write and , with and . By the chain rule, each term contributes
using in the second. Summing:
The coefficient of is negative, so the step moves towards the real context; those of the negatives are positive and push against it. And they switch themselves off: if the real pair already scores high, and its coefficient goes to zero.
Training a tiny skip-gram in NumPy
The cell trains for real, in miniature: 800 tokens of a toy corpus from templates, 20 entries,
, , four passes. The update is the gradient from the
derivation above: g = s - labels, where s is the output of the sigmoid applied to each pair's
affinity and labels is the vector with 1 for the real pair and 0 for the negatives. Subtracting it,
scaled, from each vector is gradient descent. The learning rate starts at and decays to
almost zero: the last steps refine instead of jumping. Run it and look at the neighbours of
dog before and after.
rng = np.random.default_rng(7)
# --- A toy corpus, written by templates so there is something to learn.
animals = ["dog", "cat", "horse", "mouse"]
foods = ["meat", "fish", "grass", "cheese"]
places = ["yard", "kitchen", "stable", "barn"]
drinks = ["water", "milk"]
sentences = []
for a in animals:
for l in places:
for c in foods:
sentences.append(f"the {a} eats {c} in the {l}")
for b in drinks:
sentences.append(f"the {a} drinks {b} in the {l}")
sentences.append(f"the girl sees the {a} in the {l}")
corpus = " ".join(sentences).split()
V = sorted(set(corpus))
pos = {w: i for i, w in enumerate(V)}
ids = np.array([pos[w] for w in corpus])
d_model, m, n_neg, eta0, epochs = 12, 2, 5, 0.05, 4
# The (centre, context) pairs: the raw corpus, with no one annotating anything.
centres, contexts = [], []
for t, w in enumerate(ids):
for j in range(max(0, t - m), min(len(ids), t + m + 1)):
if j != t:
centres.append(w)
contexts.append(ids[j])
centres, contexts = np.array(centres), np.array(contexts)
print("T =", len(corpus), "tokens |", len(V), "entries |", len(centres), "pairs")
# P_neg(w) proportional to f_w^0.75, and all the negatives at once.
f = np.bincount(ids, minlength=len(V)).astype(float)
P_neg = f ** 0.75
P_neg = P_neg / P_neg.sum()
n_steps = epochs * len(centres)
negatives = rng.choice(len(V), size=(n_steps, n_neg), p=P_neg)
E = 0.1 * rng.standard_normal((len(V), d_model))
U = 0.1 * rng.standard_normal((len(V), d_model))
# A 1 for the real pair and a 0 for each negative: that is all the supervision.
labels = np.zeros(n_neg + 1)
labels[0] = 1.0
def neighbours(w, k=3):
N = E / np.linalg.norm(E, axis=1, keepdims=True)
s = N @ N[pos[w]]
return [(V[i], round(float(s[i]), 2)) for i in np.argsort(-s) if i != pos[w]][:k]
print("before dog ->", neighbours("dog"))
print(" meat ->", neighbours("meat"))
print()
step = 0
for epoch in range(epochs):
loss = 0.0
for idx in rng.permutation(len(centres)):
w = centres[idx]
rows = np.concatenate(([contexts[idx]], negatives[step]))
eta = eta0 * max(1e-4, 1.0 - step / n_steps) # decays to almost zero
step += 1
v = E[w]
# Affinity of the real pair and of the negatives, squashed by the logistic.
s = 1.0 / (1.0 + np.exp(-np.clip(U[rows] @ v, -6.0, 6.0)))
loss += -np.log(np.where(labels == 1.0, s, 1.0 - s) + 1e-10).sum()
# The gradient derived above: (sigma - label) multiplies the other vector.
g = s - labels
E[w] = v - eta * (g @ U[rows])
np.add.at(U, rows, -eta * g[:, None] * v)
print("epoch", epoch + 1, " mean loss per pair", round(loss / len(centres), 3))
print()
print("after dog ->", neighbours("dog"))
print(" meat ->", neighbours("meat"))
print(" yard ->", neighbours("yard"))
print(" girl ->", neighbours("girl"))
print()
# What it cost, and what the exact objective would have cost.
print("dot products in", n_steps, "steps")
print(" with negatives ", n_steps * (n_neg + 1))
print(" exact softmax ", n_steps * len(V), " (|V| =", len(V), ")")
print(" at |V| = 50,000 the ratio would be", 50000 // (n_neg + 1))
The first run downloads the Python interpreter (~15 MB). After that it stays in the browser cache and is reused across every lesson.
Before training, the nearest neighbour to dog is kitchen at : pure noise. Four passes later they are cat, mouse and horse, all at least ; meat's are the other three foods and yard's the other three places. No one has said they resemble each other: only window pairs have gone in, and a or a per pair.
Two things, unvarnished. The corpus is artificial and the animals are interchangeable by construction: hence cosines that graze and do not come out with real text. And girl lands among the animals, next to dog and cat: it appears in a single mould (the subject of sees, the same slot the animals fill), and it is the mould that got learned, not the word.
Set n_neg = 0 and run it again. The loss collapses to (with no negatives, saying yes to
everything is perfect), and the neighbours turn to junk: cosines pinned near across the board,
dog now as close to the as to another animal. The negatives were not only the saving:
with nothing to push against, the loss goes down and the representation does not exist.
Test your intuition
Five questions: what is kept at the end, how many pairs a window gives, where the cost was, what negative sampling changes and what the exponent does.
You finish training a skip-gram. You have two tables, with rows and with rows , and a model that predicts contexts. What do you take away as the embedding?
A sentence of 12 tokens, a window of on each side. How many (centre, context) pairs does the skip-gram produce? Count the borders: the tokens at the start and the end have fewer neighbours.
A margin of ±0 is accepted.
What exactly is it that makes the skip-gram's exact objective unaffordable?
Tick everything that is true of negative sampling.
Select every correct option. This is graded all-or-nothing: there is no partial credit.
Drawing the negatives from , instead of from the frequency as it is, makes rare entries appear as negatives more often than their frequency alone would grant them.
The matrix is no longer empty; the debt the previous lesson ended on is paid. One peculiarity remains. Word2Vec does not see the corpus, it sees one window at a time, and it only concludes that dog and cat resemble each other by stumbling into thousands of those windows. But that is already told before any training: a single pass noting how many times each entry fell inside another's window gives a table of co-occurrences where the row of dog and that of cat already resemble each other. Word2Vec never gets around to writing it.
Making use of it is the lesson on GloVe and the limits of static embeddings: GloVe (global vectors) builds that table and looks for the vectors that explain it, another objective with results of the same family. And with the two techniques on the table, that lesson closes the block by charging what the word "static" hides: each entry has one vector, a single one, for ever.
Further reading3 sources · 2 papers, 1 interactive
Where this lesson comes from, and where to go next. None of it is needed to carry on with the course.
- Efficient Estimation of Word Representations in Vector Space
Presents skip-gram and CBOW, short and readable. Its complexity table is the operation count you do by hand here. It does not bring negative sampling; that arrives in Mikolov's other paper from the same year.
- Distributed Representations of Words and Phrases and their Compositionality
Negative sampling and the 3/4 exponent come from here, in its §2.2. It confirms what the lesson says: the 3/4 was chosen because it worked, and the paper gives no reason beyond that.
- Word2Vec Explorer
Pre-trained 300-dimension word2vec vectors, not your toy corpus: search neighbours by cosine as in the cell and try the arithmetic king - man + woman ≈ queen, the analogy the block leaves for later.