GloVe and fastText
Derive GloVe's log-bilinear objective from the co-occurrence ratio, and add fastText's subword composition to handle morphology and OOV.
Skip-gram trains on individual (center, context) pairs sampled sequentially. The same co-occurrence information is already summarized in the word-word co-occurrence matrix -- if we could fit directly to that matrix, we could exploit global statistics in one shot. GloVe (Global Vectors) does exactly this[1].
GloVe: global co-occurrence
The co-occurrence matrix
Let be the number of times word appears in the context of word (within a window), and . The ratio
encodes the relative affinity of probe word for context vs . This ratio is the key quantity: it equals approximately 1 for words equally associated with both and , very large for words specifically associated with , and very small for words specifically associated with .
Deriving the objective
We want embeddings such that some function captures the co-occurrence ratio. Requiring to be linear in the embedding differences and compatible with the ratio form forces:
This is the GloVe log-bilinear model: the inner product of center and context embeddings, plus per-word biases, should approximate the log co-occurrence count.
The GloVe objective is a weighted least-squares fit to this:
where the weighting function down-weights rare pairs (which have noisier counts) and caps the weight of very frequent pairs.
import numpy as np
from collections import defaultdict, Counter
# Build co-occurrence matrix from a tiny corpus
sentences = [
"the cat sat on the mat".split(),
"the dog sat on the floor".split(),
"the cat and the dog are animals".split(),
"animals live on the floor".split(),
]
vocab = list(set(w for s in sentences for w in s))
w2i = {w: i for i, w in enumerate(vocab)}
V = len(vocab)
window = 2
X = np.zeros((V, V))
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:
X[w2i[center], w2i[sent[j]]] += 1
# GloVe training
d = 8
x_max = 100.0
rng = np.random.default_rng(0)
V_emb = rng.normal(0, 0.1, (V, d))
U_emb = rng.normal(0, 0.1, (V, d))
b_v = np.zeros(V)
b_u = np.zeros(V)
lr = 0.05
for epoch in range(200):
loss = 0.0
for i in range(V):
for k in range(V):
if X[i, k] == 0:
continue
w = min((X[i, k] / x_max) ** 0.75, 1.0)
diff = V_emb[i] @ U_emb[k] + b_v[i] + b_u[k] - np.log(X[i, k])
loss += w * diff ** 2
grad = 2 * w * diff
v_i = V_emb[i].copy()
V_emb[i] -= lr * grad * U_emb[k]
U_emb[k] -= lr * grad * v_i
b_v[i] -= lr * grad
b_u[k] -= lr * grad
if (epoch + 1) % 100 == 0:
print(f"Epoch {epoch + 1}: loss = {loss:.3f}")
# Combine center and context embeddings (GloVe convention)
W = V_emb + U_embfastText: subword embeddings
Word2vec and GloVe treat each word as an atomic unit. This breaks on:
- Rare words: "antidisestablishmentarianism" has very few training occurrences.
- Morphological variants: "play," "plays," "playing" share a root but have separate embeddings with no guaranteed similarity.
- Out-of-vocabulary (OOV): a word not in the training vocabulary gets no embedding.
fastText (Bojanowski et al., 2017)[2] represents each word as the sum of its character n-gram embeddings plus its full-word embedding.
For word with character n-grams (for ):
where is the full-word embedding and is the embedding of n-gram (shared across all words containing that n-gram).
For example, "apple" (with and as boundary markers) generates n-grams:
<ap, app, ppl, ple, le>, <app, appl, pple, ple>, <appl, apple, pple>,
<apple>, plus the whole-word special token <apple>.
Key properties
Morphology: "play" and "playing" share n-grams pla, lay, <pl, etc., so their
embeddings are close by construction.
OOV: for a word not in the vocabulary, sum the embeddings of its character n-grams. The resulting vector is meaningful even if the word was never seen.
Rare words: each character n-gram is shared across many words, so even rare words receive informative updates from all words sharing the same substrings.
# fastText is best used via the official library
# pip install fasttext (or fasttext-wheel)
import fasttext
# Train on a corpus file
model = fasttext.train_unsupervised("corpus.txt", model="skipgram",
dim=100, minCount=1, minn=3, maxn=6)
# Embedding of an unseen word
vec = model.get_word_vector("antidisestablishmentarianism")
print("Vector shape:", vec.shape)
# Nearest neighbors (works for OOV too)
model.get_nearest_neighbors("plays")GloVe vs word2vec vs fastText
| Property | word2vec (SGNS) | GloVe | fastText |
|---|---|---|---|
| Training signal | Local windows | Global co-occurrence | Local windows + n-grams |
| OOV handling | None | None | Character n-grams |
| Morphology | None | None | Implicit via n-gram sharing |
| Training speed | Fast (streaming) | Slower (matrix factorization) | Fast |
| Typical use | General embeddings | General embeddings | Languages with rich morphology; OOV handling |
Exercises
Exercise 1. The GloVe weighting is with . Compute by hand for co-occurrence counts , and state in one sentence what the weighting does to the contributions of a count-1 pair versus a count-100 pair.
Solution
Step 1. For : , below the cap, so .
Step 2. For : , so .
Step 3. For : , at the cap, so .
Step 4. For : , so the clamps it to .
The rare count-1 pair contributes with weight , about times less than the count-100 pair at weight ; counts at or above all share the same cap of , so very frequent pairs do not dominate the fit.
Exercise 2. A word and its plural differ by one character. Using fastText's decomposition , explain why their vectors are close even if was never seen in training, and contrast this with what word2vec or GloVe would produce for an unseen .
Solution
Step 1. Enumerate the n-grams. With boundary markers, "play" yields n-grams
like <pl, pla, lay, ay> (plus longer ones and the whole-word token), while
"plays" yields <pl, pla, lay, ays, ys>. The two share <pl, pla, lay,
the entire stem.
Step 2. Since sums shared n-gram embeddings , the
two word vectors share most of their summands and differ only in the few n-grams
touching the final s. Their difference is small, so the vectors are close by
construction.
Step 3. If "plays" is out of vocabulary, fastText still composes a vector from its in-vocabulary n-grams, so the unseen word gets a meaningful, stem-aligned vector. Word2vec and GloVe assign one atomic vector per word type with no subword structure, so an unseen "plays" has no embedding at all: it falls back to a zero or generic unknown-token vector with no link to "play".
Exercise 3 (code). The GloVe update in the implementation above uses . Verify the analytic gradients of one squared term with respect to and against finite differences.
Solution
import numpy as np
rng = np.random.default_rng(0)
d = 6
v_i = rng.normal(0, 1, d)
u_k = rng.normal(0, 1, d)
b_i, b_k = 0.3, -0.2
X_ik, x_max = 12.0, 100.0
w = min((X_ik / x_max) ** 0.75, 1.0)
def term(v_i, u_k, b_i, b_k):
diff = v_i @ u_k + b_i + b_k - np.log(X_ik)
return w * diff ** 2
diff = v_i @ u_k + b_i + b_k - np.log(X_ik)
g_vi = 2 * w * diff * u_k # analytic: d term / d v_i
g_bi = 2 * w * diff # analytic: d term / d b_i
eps = 1e-6
g_vi_fd = np.zeros(d)
for j in range(d):
e = np.zeros(d); e[j] = eps
g_vi_fd[j] = (term(v_i + e, u_k, b_i, b_k)
- term(v_i - e, u_k, b_i, b_k)) / (2 * eps)
g_bi_fd = (term(v_i, u_k, b_i + eps, b_k)
- term(v_i, u_k, b_i - eps, b_k)) / (2 * eps)
assert np.allclose(g_vi, g_vi_fd, atol=1e-5)
assert np.isclose(g_bi, g_bi_fd, atol=1e-5)
print("v_i grad max diff:", np.abs(g_vi - g_vi_fd).max())Both analytic gradients match the finite-difference estimates to within , confirming the weighted least-squares update used in the training loop.
All three produce static embeddings -- one vector per word type. The next lesson introduces RNNs, which produce a hidden state that reads the entire sequence and can be used to build the first contextual representations.
References
- [1]Pennington, J., Socher, R., & Manning, C. D. (2014). "GloVe: global vectors for word representation". EMNLP. doi:10.3115/v1/D14-1162. The log-bilinear objective and weighting function.↩
- [2]Bojanowski, P., Grave, E., Joulin, A., & Mikolov, T. (2017). "Enriching word vectors with subword information". TACL, 5, 135-146. arXiv:1607.04606. The fastText subword model.↩