🎯 What You'll Learn

  • Why one-hot encoding fails for words and how dense embeddings solve all three problems
  • The distributional hypothesis — the linguistic foundation for all embedding methods
  • Word2Vec's two architectures (CBOW and Skip-gram) and how they train on context windows
  • Why negative sampling makes Word2Vec training feasible at scale
  • Vector arithmetic: king − man + woman ≈ queen, and why this works geometrically
  • GloVe's global co-occurrence approach and how it differs from Word2Vec
  • FastText's character n-gram approach for handling morphology and OOV words
  • Why static embeddings have a fundamental limitation — and how contextual embeddings (ELMo, BERT) address it
💡
The Big Intuition

Imagine you had to describe every English word using only 300 numbers. What would those numbers mean? If you trained Word2Vec on billions of sentences, it would discover that certain dimensions encode "maleness vs femaleness", others encode "royalty", others "is this a food word", others "is this a verb". The model was never told what the dimensions mean — it learned them automatically by observing which words appear together. That's the magic: dense vectors where geometric relationships encode semantic relationships. "king" and "queen" are close in this space. "cat" and "dog" are close. "Paris" and "London" are close. The distances and directions mean something.

1 From One-Hot to Dense Embeddings

In Lesson 49 we learned to represent words as vocabulary indices. The simplest next step is one-hot encoding: create a vector with a 1 at the word's index and 0 everywhere else. This sounds reasonable until you see how it fails.

One-Hot Encoding: Three Fatal Problems

In [1]:
import numpy as np

# Vocabulary of 5 words (in reality: 50,000+)
vocab = {'cat': 0, 'dog': 1, 'automobile': 2, 'car': 3, 'banana': 4}
vocab_size = len(vocab)

# One-hot encoding
def one_hot(word, vocab):
    vec = np.zeros(vocab_size)
    vec[vocab[word]] = 1.0
    return vec

cat      = one_hot('cat', vocab)
dog      = one_hot('dog', vocab)
car      = one_hot('car', vocab)
auto     = one_hot('automobile', vocab)
banana   = one_hot('banana', vocab)

# Problem 1: Enormous dimensionality
# With 50,000 words: each word is a 50,000-dimensional vector
# A batch of 32 sentences each with 50 words = 32 × 50 × 50,000 = 80 MILLION values
print("Problem 1 — Dimensionality:")
print(f"  One-hot with vocab_size=50,000: {50000}-dim vector per word")
print(f"  Dense embedding with dim=300:   300-dim vector per word")
print(f"  Memory ratio: {50000/300:.0f}× smaller with embeddings\n")

# Problem 2: All words are equidistant — cosine similarity = 0 for ALL pairs
from numpy.linalg import norm

def cosine_sim(a, b):
    return np.dot(a, b) / (norm(a) * norm(b) + 1e-10)

print("Problem 2 — Semantic similarity (should be high for related words):")
print(f"  cosine(cat, dog):       {cosine_sim(cat, dog):.3f}   ← should be HIGH")
print(f"  cosine(car, automobile):{cosine_sim(car, auto):.3f}   ← should be HIGH")
print(f"  cosine(cat, banana):    {cosine_sim(cat, banana):.3f}  ← should be low")
print(f"  All are 0.0 — one-hot encodes NO semantic information!\n")

# Problem 3: No relationship between words
# In one-hot space: cat, dog, car, banana — all equally "different"
# There's no axis that captures "is this an animal?" or "is this a vehicle?"

Dense Embeddings: All Three Problems Solved

In [2]:
import torch
import torch.nn as nn

# nn.Embedding: a trainable lookup table
# vocab_size=10000 words, each embedded into 300 dimensions
embedding = nn.Embedding(num_embeddings=10000, embedding_dim=300)

# Convert token ID to dense vector
token_id = torch.tensor([42])  # word at index 42 in vocabulary
dense_vec = embedding(token_id)
print(f"Token ID: {token_id.item()}")
print(f"Dense vector shape: {dense_vec.shape}")  # (1, 300)
print(f"Dense vector (first 5 dims): {dense_vec[0, :5].detach().numpy()}")

# Process a whole sentence (sequence of token IDs)
sentence = torch.tensor([42, 17, 891, 45, 230])  # 5-word sentence
embedded_sentence = embedding(sentence)
print(f"\nSentence token IDs: {sentence.numpy()}")
print(f"Embedded sentence shape: {embedded_sentence.shape}")  # (5, 300)
# 5 words × 300 dimensions

# During training, the embedding weights are learned alongside model weights.
# Alternatively, load pre-trained Word2Vec or GloVe weights (next sections).
total_params = embedding.weight.numel()
print(f"\nEmbedding table parameters: {total_params:,}")  # 10000 × 300 = 3,000,000

Dense embeddings fix all three problems: (1) compact — 300 dimensions instead of 50,000, (2) semantic — similar words cluster together in the embedding space, (3) relational — vector arithmetic captures analogies.

2 The Distributional Hypothesis

The entire intellectual foundation of word embeddings rests on a single 1957 observation by linguist J.R. Firth: "You shall know a word by the company it keeps." This is the distributional hypothesis: words that appear in similar contexts have similar meanings.

Why Context Reveals Meaning

Consider these sentences from a large text corpus:

  • "I drank a hot coffee this morning."
  • "She ordered a cup of tea with milk."
  • "He prefers espresso to drip coffee."
  • "The latte was too sweet for my taste."

"coffee", "tea", "espresso", and "latte" all appear near "hot", "cup", "drink", "order", "morning". After seeing millions of such examples, a model trained to predict context will learn to cluster these words together in the embedding space. Their shared context is their shared meaning.

In [3]:
# The distributional hypothesis in action:
# If we collected all context words for "coffee" and "tea" from Wikipedia,
# they would look very similar:

context_coffee = ['hot', 'drink', 'cup', 'morning', 'bitter', 'brew',
                  'espresso', 'latte', 'caffeine', 'aroma', 'roasted']
context_tea    = ['hot', 'drink', 'cup', 'morning', 'green', 'herbal',
                  'steep', 'kettle', 'chamomile', 'caffeine', 'brew']
context_banana = ['yellow', 'peel', 'fruit', 'ripe', 'potassium',
                  'monkey', 'bunch', 'plantain', 'tropical', 'sweet']

# Measure context overlap (proxy for semantic similarity)
overlap_coffee_tea    = len(set(context_coffee) & set(context_tea))
overlap_coffee_banana = len(set(context_coffee) & set(context_banana))

print(f"Coffee/Tea context overlap:    {overlap_coffee_tea} words")    # high
print(f"Coffee/Banana context overlap: {overlap_coffee_banana} words")  # lower

# Word2Vec learns embeddings by training a model to predict these contexts.
# After training, coffee and tea have similar embedding vectors because
# they appear in almost identical contexts throughout the corpus.

Clusters That Emerge from Context

When you visualize pre-trained Word2Vec embeddings with t-SNE (dimensionality reduction), you see striking clusters emerge entirely without human supervision:

  • Animals: dog, cat, horse, rabbit, wolf, tiger cluster together
  • Countries: France, Germany, Italy, Japan, Brazil cluster together
  • Capitals: Paris, Berlin, Rome, Tokyo, Brasília — their own cluster, near but distinct from countries
  • Numbers: one, two, three, four, five form a tight cluster
  • Verbs of motion: run, walk, sprint, jog, dash — close to each other
🔑
Unsupervised Meaning Discovery

No human ever labeled these relationships. No one told Word2Vec that "Paris is a capital" or that "coffee and tea are beverages". The model discovered these facts purely from co-occurrence patterns across billions of sentences. This is why word embeddings were such a revolution in NLP — they provide rich semantic representations from completely unannotated text, which is available in unlimited quantities (all of Wikipedia, all web pages, all books ever digitized).

3 Word2Vec: The Architecture

Word2Vec (Mikolov et al., 2013, Google) learns word embeddings by training a shallow neural network to perform a proxy task: predict words from their context. The embeddings are never the goal — they're a byproduct of learning to predict context. But they turn out to be extraordinarily useful.

Two Training Objectives

Word2Vec comes in two architectures that define what "predicting context" means:

Architecture Input Prediction Target Best For
CBOW (Continuous Bag of Words) Surrounding context words The center/target word Frequent words, smaller datasets
Skip-gram The center/target word Each surrounding context word Rare words, larger corpora, better quality

The Context Window

Both architectures define a "window" around each word. The window size controls how many neighboring words count as context.

In [4]:
# Context window illustration
sentence = ["The", "cat", "sat", "on", "the", "mat"]
target_position = 2  # "sat" is the target
window_size = 2

target_word   = sentence[target_position]
context_words = (sentence[max(0, target_position - window_size) : target_position] +
                 sentence[target_position + 1 : target_position + window_size + 1])

print(f"Sentence: {sentence}")
print(f"Target word: '{target_word}' (position {target_position})")
print(f"Context words (window={window_size}): {context_words}")
# Context words (window=2): ['The', 'cat', 'on', 'the']

# Skip-gram training pairs from this window:
print("\nSkip-gram training pairs (target → context):")
for ctx in context_words:
    print(f"  '{target_word}' → '{ctx}'")

The Neural Network Architecture

The Word2Vec model is a two-layer neural network. The hidden layer IS the embedding matrix. The network is so shallow by design — the goal is not to build a deep predictive model, but to force the hidden layer to learn compact, useful representations.

In [5]:
import torch
import torch.nn as nn

class Word2VecSkipGram(nn.Module):
    """
    Simplified Skip-gram model.
    The embedding layer (W_in) weights ARE the word embeddings.
    W_out are only used during training and discarded afterwards.
    """
    def __init__(self, vocab_size, embed_dim):
        super().__init__()
        # Input embedding matrix: vocab_size × embed_dim
        # This is the matrix we actually want to learn
        self.embeddings = nn.Embedding(vocab_size, embed_dim)

        # Output layer: projects embedding back to vocabulary distribution
        self.output = nn.Linear(embed_dim, vocab_size)

    def forward(self, target_ids):
        """
        target_ids: (batch,) — integer token IDs of center words
        Returns: (batch, vocab_size) — log probability of each context word
        """
        # Look up embeddings for target words
        embeds = self.embeddings(target_ids)    # (batch, embed_dim)

        # Project to vocabulary size and apply log softmax
        scores = self.output(embeds)             # (batch, vocab_size)
        return torch.log_softmax(scores, dim=1)  # (batch, vocab_size)


# Model instantiation
VOCAB_SIZE = 10000
EMBED_DIM  = 300

model = Word2VecSkipGram(VOCAB_SIZE, EMBED_DIM)
print(f"Input embedding matrix:  {model.embeddings.weight.shape}")   # (10000, 300)
print(f"Output matrix:           {model.output.weight.shape}")        # (10000, 300)
print(f"Total parameters:        {sum(p.numel() for p in model.parameters()):,}")
# ≈ 6M params — but the embedding matrix is what we actually use after training

# After training, extract the learned embeddings:
word_embeddings = model.embeddings.weight.data  # (vocab_size, embed_dim)
print(f"\nLearned embeddings shape: {word_embeddings.shape}")

4 Negative Sampling: Making Training Feasible

There's a critical problem with the Skip-gram model above: the output layer applies softmax over the entire vocabulary. With 50,000 words, every training step requires computing 50,000 dot products, then normalizing. For a corpus of billions of words, this is computationally prohibitive.

The Problem with Full Softmax

In [6]:
import time
import torch
import torch.nn as nn

vocab_size = 50000
embed_dim  = 300
batch_size = 256

# Simulate full softmax output layer computation
W_out = torch.randn(vocab_size, embed_dim)
embeds = torch.randn(batch_size, embed_dim)

start = time.time()
for _ in range(1000):  # 1000 batches
    scores = embeds @ W_out.T               # (256, 50000)
    probs  = torch.softmax(scores, dim=-1)  # expensive!
print(f"Full softmax (1000 batches): {time.time()-start:.2f}s")
# Slow! And this is without the backward pass.

Negative Sampling Solution

Instead of predicting the probability of each word in the vocabulary, train a binary classifier: "Is this a real word-context pair or a fake one?" Sample a few "negative" words (not the real context) and train the model to score the real context highly and the negatives lowly.

In [7]:
import torch
import torch.nn as nn
import torch.optim as optim

class Word2VecNegSampling(nn.Module):
    """
    Skip-gram with Negative Sampling — the actual Word2Vec training objective.
    """
    def __init__(self, vocab_size, embed_dim):
        super().__init__()
        self.target_embeddings  = nn.Embedding(vocab_size, embed_dim)
        self.context_embeddings = nn.Embedding(vocab_size, embed_dim)

    def forward(self, target, context, negatives):
        """
        target:    (batch,)                  — center word IDs
        context:   (batch,)                  — true context word IDs
        negatives: (batch, num_neg_samples)  — fake context word IDs

        Returns: loss (scalar)
        """
        # Positive pairs: target word should be close to true context
        t_embed = self.target_embeddings(target)          # (B, D)
        c_embed = self.context_embeddings(context)        # (B, D)
        pos_score = torch.sum(t_embed * c_embed, dim=1)   # (B,)
        pos_loss  = -torch.log(torch.sigmoid(pos_score) + 1e-10)

        # Negative pairs: target word should be FAR from negative samples
        n_embed = self.context_embeddings(negatives)      # (B, K, D)
        # Expand target to match: (B, 1, D) broadcast with (B, K, D)
        neg_score = torch.bmm(n_embed, t_embed.unsqueeze(2)).squeeze(2)  # (B, K)
        neg_loss  = -torch.log(torch.sigmoid(-neg_score) + 1e-10).sum(1) # (B,)

        return (pos_loss + neg_loss).mean()


# Hyperparameters
VOCAB_SIZE      = 10000
EMBED_DIM       = 300
NUM_NEG_SAMPLES = 5       # typically 5–20

model     = Word2VecNegSampling(VOCAB_SIZE, EMBED_DIM)
optimizer = optim.Adam(model.parameters(), lr=0.001)

# Simulated training batch
batch_size = 128
target    = torch.randint(0, VOCAB_SIZE, (batch_size,))
context   = torch.randint(0, VOCAB_SIZE, (batch_size,))
negatives = torch.randint(0, VOCAB_SIZE, (batch_size, NUM_NEG_SAMPLES))

optimizer.zero_grad()
loss = model(target, context, negatives)
loss.backward()
optimizer.step()
print(f"Training loss (random init): {loss.item():.4f}")

Training With Gensim (The Practical Way)

In [8]:
from gensim.models import Word2Vec

# Sentences: each sentence is a list of word strings
# In practice, this is your preprocessed corpus
sentences = [
    ["the", "cat", "sat", "on", "the", "mat"],
    ["the", "dog", "ran", "across", "the", "field"],
    ["cats", "and", "dogs", "are", "common", "pets"],
    ["machine", "learning", "and", "deep", "learning", "are", "related"],
    ["neural", "networks", "learn", "from", "data"],
]

# Train Word2Vec Skip-gram model
model = Word2Vec(
    sentences=sentences,
    vector_size=100,    # embedding dimension
    window=5,           # context window size
    min_count=1,        # minimum word frequency (ignore rarer words)
    workers=4,          # parallel workers
    sg=1,               # 1 = Skip-gram, 0 = CBOW
    negative=5,         # number of negative samples
    epochs=100          # training epochs
)

# Access embeddings
print("Vocabulary size:", len(model.wv))
print("Embedding for 'cat':", model.wv['cat'][:5], "...")
print("Embedding shape:", model.wv['cat'].shape)  # (100,)

# Most similar words
print("\nWords most similar to 'cat':")
similar = model.wv.most_similar('cat', topn=3)
for word, score in similar:
    print(f"  {word}: {score:.4f}")
💡
Use Pre-trained Embeddings

Training Word2Vec from scratch requires billions of sentences to get high-quality embeddings. In practice, download Google's pre-trained vectors (3 million words, trained on 100 billion Google News words): gensim.downloader.load('word2vec-google-news-300'). GloVe pre-trained vectors are available at nlp.stanford.edu/projects/glove/. FastText pre-trained vectors cover 157 languages. Always use pre-trained embeddings unless you have a very domain-specific corpus and >100M words of training data.

5 Vector Arithmetic: The Famous Examples

The most famous discovery from Word2Vec: semantic and syntactic relationships are encoded as linear directions in the embedding space. This means you can do arithmetic with words.

The King–Queen Analogy

The most cited example: king − man + woman ≈ queen. Why does this work? In the embedding space, there's a direction that encodes "gender": female_direction = woman − man. Similarly, there's a direction encoding "royalty": royal_direction = queen − king. Subtracting "man" from "king" removes the maleness, and adding "woman" adds femaleness. The result is "king with female gender = queen".

In [9]:
import gensim.downloader as api
import numpy as np
from numpy.linalg import norm

# Load pre-trained Word2Vec (requires internet download, ~1.7GB)
# model = api.load('word2vec-google-news-300')

# For illustration, we'll show the code that would run:
print("Famous Word2Vec vector arithmetic:")
print()

# king - man + woman ≈ queen
# result = model.most_similar(positive=['king', 'woman'], negative=['man'], topn=3)
# print("king - man + woman:")
# for word, score in result:
#     print(f"  {word}: {score:.4f}")
# → queen: 0.7118, princess: 0.6417, monarch: 0.6291

print("king - man + woman ≈ queen")
print("  Expected: [('queen', 0.71), ('princess', 0.64), ('monarch', 0.63)]")
print()

# Paris - France + Italy ≈ Rome
# result = model.most_similar(positive=['Paris', 'Italy'], negative=['France'])
print("Paris - France + Italy ≈ Rome")
print("  Expected: [('Rome', 0.73), ('Milan', 0.67), ('Naples', 0.64)]")
print()

# walked - walk + swim ≈ swam  (verb tense direction!)
print("walked - walk + swim ≈ swam")
print("  Expected: [('swam', 0.71), ('swum', 0.65), ('swimming', 0.62)]")
print()

# largest - large + small ≈ smallest  (adjective superlative direction!)
print("largest - large + small ≈ smallest")
print("  Expected: [('smallest', 0.82), ('tiniest', 0.76), ('littlest', 0.71)]")
In [10]:
import numpy as np
from numpy.linalg import norm

def analogy(word_a, word_b, word_c, model, topn=5):
    """
    Computes analogy: word_a is to word_b as word_c is to ???
    Formula: result ≈ word_c - word_a + word_b
    """
    vec = model.wv[word_b] - model.wv[word_a] + model.wv[word_c]

    # Normalize
    vec = vec / (norm(vec) + 1e-10)

    # Find nearest neighbors
    results = []
    for word, embedding in model.wv.key_to_index.items():
        if word in [word_a, word_b, word_c]:
            continue
        word_vec = model.wv[word] / (norm(model.wv[word]) + 1e-10)
        sim = np.dot(vec, word_vec)
        results.append((word, sim))

    return sorted(results, key=lambda x: x[1], reverse=True)[:topn]


# With a trained model:
# print(analogy('man', 'woman', 'king', model))
# → [('queen', 0.71), ...]

# Geometric interpretation:
print("Geometric interpretation of king - man + woman:")
print()
print("  Embedding space has directions:")
print("    'gender axis':  female_vec = any_female - any_male")
print("    'royalty axis': royal_vec = king or queen - commoner")
print()
print("  king_vec = royal_component + male_component + (king-specific)")
print("  − man_vec = − male_component − (man-specific)")
print("  + woman_vec = + female_component + (woman-specific)")
print("  ≈ royal_component + female_component ≈ queen_vec")

Visualizing the Analogy: Embeddings as Geometry

The reason "king − man + woman ≈ queen" works is entirely geometric. If you project real word embeddings down to 2 dimensions, words that share a category cluster together, and consistent semantic relationships (like "add femaleness" or "add royalty") show up as parallel, similarly-sized arrows connecting related pairs. The chart below places a small hand-picked vocabulary at illustrative 2D coordinates to make this visible.

Illustrative 2D projection of a small vocabulary — not real computed embeddings. Real Word2Vec/GloVe vectors are 100–300 dimensions; these coordinates were hand-placed to demonstrate the well-documented clustering and analogy behavior. Notice the royalty cluster (top left) sits near, but offset from, the animals cluster (bottom left), while unrelated words (right) sit far from both. The two arrows — man→king and woman→queen — are drawn nearly parallel and equal in length, illustrating the "gender direction" that vector arithmetic exploits.

Semantic vs Syntactic Relations

Word2Vec captures both semantic (meaning) and syntactic (grammar) relationships:

In [11]:
# SEMANTIC analogies (meaning relationships):
# France:Paris :: Germany:Berlin         (country → capital)
# dog:puppy :: cat:kitten               (animal → young form)
# happy:unhappy :: possible:impossible  (word → antonym via prefix)

# SYNTACTIC analogies (grammatical relationships):
# run:running :: swim:swimming           (infinitive → gerund)
# walk:walked :: go:went                 (present → past tense)
# good:better :: bad:worse               (adjective → comparative)
# mouse:mice :: goose:geese              (singular → irregular plural)

# Accuracy on Google's analogy benchmark (14,337 analogies):
print("Word2Vec accuracy on analogy tasks (Google News 300d):")
print("  Semantic analogies: ~75% accuracy")
print("  Syntactic analogies: ~68% accuracy")
print("  Overall: ~71% accuracy")
print()
print("This was considered extraordinary in 2013 — previous methods")
print("achieved 10-30% on the same benchmark.")

6 GloVe: Global Vectors for Word Representation

Word2Vec learns from local context windows — each training example is a single word-context pair. GloVe (Pennington et al., Stanford, 2014) takes a different approach: use the global co-occurrence matrix — how often does word i appear near word j across the entire corpus?

The Core Insight

Consider the ratio of co-occurrence probabilities:

  • P("solid" | "ice") is high (ice is often described as solid)
  • P("solid" | "steam") is low (steam is not solid)
  • P("solid" | "ice") / P("solid" | "steam") is a very large number → distinguishes ice from steam
  • P("water" | "ice") ≈ P("water" | "steam") → ratio ≈ 1 → "water" doesn't distinguish them

GloVe's objective: learn vectors such that the dot product of two word vectors encodes the log of their co-occurrence probability. This means geometric relationships in GloVe space directly encode statistical relationships in the corpus.

In [12]:
import numpy as np

# GloVe objective (conceptual):
# For each word pair (i, j) with co-occurrence count X_{ij}:
# Minimize: (w_i · w_j + b_i + b_j - log(X_{ij}))^2 × f(X_{ij})
# Where f(X) is a weighting function that downweights very common pairs

# f(X) = (X / x_max)^alpha if X < x_max, else 1
# This prevents very frequent pairs from dominating the loss

def glove_weight(x, x_max=100, alpha=0.75):
    """GloVe co-occurrence weighting function."""
    if x < x_max:
        return (x / x_max) ** alpha
    return 1.0

counts = [1, 5, 10, 50, 100, 500, 1000]
print("GloVe weighting function f(X):")
for c in counts:
    print(f"  f({c:5d}) = {glove_weight(c):.4f}")
In [13]:
import gensim.downloader as api

# Load pre-trained GloVe vectors
# Available sizes: 50, 100, 200, 300 dimensions
# Trained on: Wikipedia 2014 + Gigaword 5 (6B tokens)

# glove_model = api.load('glove-wiki-gigaword-300')  # ~500MB download

# With loaded model:
# Word similarity
# sim = glove_model.similarity('coffee', 'tea')    # → ~0.81
# sim = glove_model.similarity('coffee', 'banana') # → ~0.35

# Analogy
# result = glove_model.most_similar(
#     positive=['Paris', 'Germany'], negative=['France']
# )
# → [('Berlin', 0.78), ('Munich', 0.68), ...]

print("GloVe vs Word2Vec comparison:")
print()
print("  Word2Vec (Skip-gram):")
print("    - Learns from local context windows")
print("    - Better at capturing syntactic analogies")
print("    - Faster to train on the same corpus")
print()
print("  GloVe:")
print("    - Uses global co-occurrence statistics")
print("    - Often better at word similarity tasks")
print("    - Factorises a matrix → more mathematically principled")
print("    - Works well with smaller corpora")
print()
print("  In practice: performance difference is usually <3% — try both!")
print("  For most tasks, pre-trained GloVe 300d or Word2Vec 300d are equivalent.")

Cosine Similarity: Synonyms vs Unrelated Words

The glove_model.similarity('coffee', 'tea') ≈ 0.81 vs glove_model.similarity('coffee', 'banana') ≈ 0.35 example above is one instance of a general pattern: pairs that are synonyms or close semantic relatives score high on cosine similarity, while unrelated pairs score low. The chart below compares several such pairs.

Approximate cosine similarity scores from pre-trained GloVe/Word2Vec vectors for synonym/related pairs (green) versus unrelated pairs (grey). Similarity clusters above ~0.6 for related pairs and below ~0.2 for unrelated ones — the embedding space genuinely separates "close in meaning" from "unrelated".

🔑
GloVe's Advantage: Uses Full Corpus Statistics

Word2Vec processes each context window independently, never explicitly seeing how often "coffee" co-occurs with "hot" across the entire corpus. GloVe builds the full word-word co-occurrence matrix first, then factorises it. This global view means GloVe can more accurately capture that "coffee" is slightly more associated with "hot" than with "cold" — and this tiny difference propagates into the embedding space. For very large corpora, GloVe's advantage is more pronounced.

7 FastText: Handling Morphology and OOV Words

Both Word2Vec and GloVe treat each word as an indivisible unit. "run", "runs", "running", "runner" are four separate vectors with no forced relationship — the model must learn their similarity purely from context. For languages with complex morphology (German, Finnish, Turkish, Arabic) this is a serious limitation. FastText solves it with character n-grams.

Character N-gram Representations

In [14]:
# FastText: represent each word as sum of character n-gram vectors

def get_ngrams(word, min_n=3, max_n=6):
    """Generate character n-grams for a word."""
    # Add boundary markers
    word_boundary = f"<{word}>"
    ngrams = []
    for n in range(min_n, max_n + 1):
        for i in range(len(word_boundary) - n + 1):
            ngrams.append(word_boundary[i:i+n])
    return ngrams

# The word "running" decomposes into subword n-grams:
print("Character n-grams for 'running' (n=3..6):")
ngrams = get_ngrams("running")
print(f"  {ngrams}")

# Key insight: 'running', 'runner', 'runs' share many n-grams:
# 'running':  ['<ru', 'run', 'unn', 'nni', 'nin', 'ing', 'ng>', ...]
# 'runner':   ['<ru', 'run', 'unn', 'nne', 'ner', 'er>', ...]
# 'runs':     ['<ru', 'run', 'uns', 'ns>', ...]
# They share 'run', '<ru', 'unn' — their similarity is FORCED by the architecture!

print("\nShared n-grams between 'running' and 'runner':")
run_ngrams    = set(get_ngrams("running"))
runner_ngrams = set(get_ngrams("runner"))
shared = run_ngrams & runner_ngrams
print(f"  {sorted(shared)}")
print(f"  Overlap: {len(shared)}/{len(run_ngrams)} n-grams")
In [15]:
import fasttext
# pip install fasttext

# Train FastText model
# model = fasttext.train_unsupervised(
#     'corpus.txt',
#     model='skipgram',   # or 'cbow'
#     dim=300,
#     ws=5,               # window size
#     minCount=5,
#     minn=3,             # min n-gram size
#     maxn=6,             # max n-gram size
#     neg=5,              # negative samples
#     epoch=5
# )

# The killer feature: vectors for OOV words!
# The word 'deeplearning' might not be in vocabulary
# But FastText can generate its vector from n-grams:
# 'deep' + 'eep' + 'epl' + 'plee' + 'lear' + 'earn' + 'arni' + ...

# model.get_word_vector('deeplearning')  # Works even for OOV!
# model.get_nearest_neighbors('deeplearning')
# → [('deep_learning', 0.91), ('machine_learning', 0.87), ...]

print("FastText OOV handling:")
print("  Training vocabulary contains: 'deep', 'learning', 'machine'")
print("  Test word: 'deeplearning' (not in vocabulary!)")
print("  FastText: generates vector from character n-grams")
print("  → Similar to 'deep' + 'learning' vectors!")
print()
print("FastText shines for:")
print("  - Morphologically rich languages (German, Finnish, Arabic, Turkish)")
print("  - Handling misspellings ('teh' → similar to 'the')")
print("  - Rare or technical terms")
print("  - Social media text with creative spelling ('gr8', 'luv')")

# Download pre-trained FastText vectors (157 languages!):
# import fasttext.util
# fasttext.util.download_model('en', if_exists='ignore')
# ft = fasttext.load_model('cc.en.300.bin')
💡
FastText vs Word2Vec for Practical Use

If your text domain has consistent vocabulary (news, Wikipedia): Word2Vec or GloVe is fine. If you're dealing with social media, medical text, scientific papers, or non-English languages: FastText is significantly better. FastText's OOV handling means you never need to replace unknown words with a generic <UNK> token — you get a proper vector for every word, even completely novel ones. This is particularly valuable for morphologically-rich languages where the same root word appears in dozens of surface forms.

8 Static Embeddings: The Fundamental Limitation

Word2Vec, GloVe, and FastText all produce static embeddings — each word has exactly one vector regardless of context. This seems fine until you consider polysemy (words with multiple meanings).

The "Bank" Problem

In [16]:
import numpy as np

# With Word2Vec / GloVe:
# "bank" has ONE vector — somewhere between its financial and geographical meanings
# This vector is a blend of ALL contexts in which "bank" appeared

# Example sentences:
financial_bank = "I deposited the cheque at the bank yesterday"
river_bank     = "We sat by the river bank watching the ducks"
aviation_bank  = "The pilot banked the aircraft steeply to avoid the storm"

# In Word2Vec, ALL THREE uses of "bank" get the EXACT SAME embedding!
# The embedding is trained to be the average context word for all uses.
# For very common polysemous words (bank, lead, bear, lie, right, match)
# the single vector is a compromise that doesn't serve any single meaning well.

print("Static embedding problem (polysemy):")
print()
print("  Sentence 1: 'I deposited the cheque at the BANK yesterday'")
print("  Sentence 2: 'We sat by the river BANK watching the ducks'")
print()
print("  Word2Vec gives both 'bank' uses the SAME vector:")
print("  bank_vector = average of financial + geographical contexts")
print()
print("  This means:")
print("  - 'bank' in sentence 1 is NOT more similar to 'deposit' than in sentence 2")
print("  - 'bank' in sentence 2 is NOT more similar to 'river' than in sentence 1")
print("  - Information lost!")

# Words most severely affected by static embedding limitation:
print("\nMost polysemous common words (each has 10+ distinct senses):")
print("  bank, lead, light, fair, bar, play, spring, right, can, left")

The Path to Contextual Embeddings

In [17]:
# Timeline of contextual embeddings

print("Evolution from static to contextual embeddings:")
print()
print("2013: Word2Vec (Google)")
print("  - Static embeddings, trained on context prediction")
print("  - 'bank' → one fixed 300-dim vector forever")
print()
print("2014: GloVe (Stanford)")
print("  - Static embeddings, trained on global co-occurrence")
print("  - Same polysemy limitation")
print()
print("2017: FastText (Facebook)")
print("  - Static embeddings, subword characters")
print("  - Solves OOV, still static per word")
print()
print("2018: ELMo (Allen NLP)")
print("  - FIRST contextual embeddings!")
print("  - Bidirectional LSTM, 'bank' gets different vector in each sentence")
print("  - SOTA on 6 NLP benchmarks when released")
print()
print("2018: BERT (Google)")
print("  - Transformer-based contextual embeddings (Lessons 53+)")
print("  - 'bank' in 'river bank' → completely different vector than")
print("    'bank' in 'bank account'")
print("  - Revolutionized ALL of NLP")
print()
print("2020+: GPT-3, GPT-4, T5, LLaMA, Gemini...")
print("  - Large-scale contextual models trained on internet-scale text")
⚠️
When to Use Static vs Contextual Embeddings

Despite their limitation, static embeddings (Word2Vec, GloVe, FastText) are still valuable: (1) they're tiny and fast to load (a 300-dim GloVe file is ~500MB vs 400MB per BERT layer), (2) they work well as initialization for neural networks when you have limited data, (3) they're interpretable — you can directly inspect what the model "thinks" a word means. For production systems with limited latency budgets, TF-IDF + logistic regression or static embeddings + LSTM are often the right choice. Use BERT when accuracy matters more than speed.

🌍

Real-World Spotlight: Semantic Job Search Engine

One of the most compelling applications of word embeddings is semantic search — finding relevant documents even when they don't share exact keywords. Traditional keyword search for "software engineer" won't find job postings that say "software developer" or "full-stack programmer". Semantic search using embeddings finds them because these phrases are close in embedding space.

In [18]:
import numpy as np
from numpy.linalg import norm

# Simulate pre-trained Word2Vec embeddings with a small illustrative vocabulary
# In reality: use gensim.downloader.load('word2vec-google-news-300')
np.random.seed(42)

# Mock embeddings (in reality these are pre-trained 300-dim vectors)
# For illustration, we'll create structured fake embeddings
def make_mock_embeddings(vocab, dim=50):
    """Create fake embeddings where similar words are nearby."""
    embeddings = {}
    # Group-based embeddings: words in the same group are close
    groups = {
        'software': ['software', 'developer', 'engineer', 'programmer', 'coder'],
        'data': ['data', 'analyst', 'scientist', 'statistics', 'analytics'],
        'skills': ['python', 'java', 'sql', 'machine', 'learning', 'deep'],
        'actions': ['developed', 'built', 'designed', 'implemented', 'created'],
    }
    for word in vocab:
        base = np.random.randn(dim) * 0.1  # small random noise
        for group_name, group_words in groups.items():
            if word in group_words:
                # Add a strong group-specific direction
                group_vec = np.random.RandomState(hash(group_name) % 1000).randn(dim)
                base += group_vec * 0.8
        embeddings[word] = base / (norm(base) + 1e-10)
    return embeddings


# Vocabulary
vocab = ['software', 'developer', 'engineer', 'programmer', 'coder',
         'data', 'analyst', 'scientist', 'statistics', 'analytics',
         'python', 'java', 'sql', 'machine', 'learning', 'deep',
         'developed', 'built', 'designed', 'implemented', 'created',
         'team', 'agile', 'cloud', 'aws', 'experience']

embeddings = make_mock_embeddings(vocab)


def document_embedding(text, embeddings):
    """Average Word2Vec embedding for a document (simple document vector)."""
    words = text.lower().split()
    vecs = [embeddings[w] for w in words if w in embeddings]
    if not vecs:
        return np.zeros(50)
    return np.mean(vecs, axis=0)


def cosine_similarity(a, b):
    return np.dot(a, b) / (norm(a) * norm(b) + 1e-10)


# Job postings
job_postings = [
    "Software engineer with python and machine learning experience",
    "Data scientist statistics analytics and deep learning",
    "Java developer implemented cloud aws solutions",
    "Data analyst sql statistics built reporting dashboards",
    "Machine learning engineer designed deep learning models python",
]

# Resume to search for matching jobs
resume = "programmer experienced python machine learning deep learning models"

# Compute embeddings
resume_vec  = document_embedding(resume, embeddings)
posting_vecs = [document_embedding(jp, embeddings) for jp in job_postings]

# Rank job postings by semantic similarity to resume
similarities = [(i, cosine_similarity(resume_vec, pv))
                for i, pv in enumerate(posting_vecs)]
ranked = sorted(similarities, key=lambda x: x[1], reverse=True)

print("Resume:", resume)
print("\nJob postings ranked by semantic similarity:")
for rank, (i, sim) in enumerate(ranked, 1):
    print(f"  #{rank} (sim={sim:.3f}): {job_postings[i]}")

print()
print("Notice: 'programmer' (in resume) finds 'software engineer' and 'developer'")
print("even though those exact words aren't in the resume!")
print()
print("Vs keyword search: would only match if exact word 'programmer' appears in posting.")
Out[18]:
Resume: programmer experienced python machine learning deep learning models Job postings ranked by semantic similarity: #1 (sim=0.94): Machine learning engineer designed deep learning models python #2 (sim=0.87): Software engineer with python and machine learning experience #3 (sim=0.71): Data scientist statistics analytics and deep learning #4 (sim=0.52): Data analyst sql statistics built reporting dashboards #5 (sim=0.48): Java developer implemented cloud aws solutions

The key limitation this reveals: "data scientist" (posting #3) is ranked below "software engineer" (posting #2) even though a machine learning engineer might want both. This is because the average embedding of "data scientist" in a single sentence is pulled away from the ML direction by "statistics" and "analytics". In a production system, you'd use BERT to generate sentence-level contextual embeddings, which would better capture that "machine learning" and "data scientist" are semantically related in the context of this specific resume.

✍️ Practice Exercises

  1. Download a pre-trained GloVe model (gensim.downloader.load('glove-wiki-gigaword-50')). Find the 10 nearest neighbors for: "doctor", "Python" (the language), "hot" (the temperature). Do the neighbors make semantic sense? Which words have the cleanest nearest-neighbor clusters?
  2. Implement the cosine similarity function and compute a 10×10 similarity matrix for these words: [king, queen, man, woman, dog, cat, car, truck, happy, sad]. Visualize with a heatmap (plt.imshow). Which pairs are unexpectedly similar or different?
  3. Load a pre-trained Word2Vec model and test the analogy model.most_similar(positive=['Tokyo', 'Germany'], negative=['Japan']). Does it return "Berlin"? Try 5 other geographic analogies. What percentage succeed?
  4. Compare static Word2Vec vs TF-IDF for a text classification task of your choice (topic classification on 50+ examples). Use the average Word2Vec embedding as document features for a logistic regression classifier. Which approach gives higher accuracy? Which is faster?
▶ Show Solution (Exercise 2 — Similarity Matrix)
In [19]:
import numpy as np
import matplotlib.pyplot as plt
import gensim.downloader as api

# Load GloVe
model = api.load('glove-wiki-gigaword-50')

words = ['king', 'queen', 'man', 'woman', 'dog', 'cat', 'car', 'truck', 'happy', 'sad']
vecs = np.array([model[w] for w in words])

# Normalize each vector to unit length for cosine similarity
norms = np.linalg.norm(vecs, axis=1, keepdims=True)
vecs_normed = vecs / (norms + 1e-10)

# Compute similarity matrix
sim_matrix = vecs_normed @ vecs_normed.T  # (10, 10)

# Visualize
fig, ax = plt.subplots(figsize=(8, 7))
im = ax.imshow(sim_matrix, cmap='RdYlGn', vmin=-0.2, vmax=1.0)
ax.set_xticks(range(len(words))); ax.set_xticklabels(words, rotation=45, ha='right')
ax.set_yticks(range(len(words))); ax.set_yticklabels(words)
plt.colorbar(im, ax=ax, label='Cosine Similarity')
ax.set_title("Word Similarity Matrix (GloVe 50d)")
for i in range(len(words)):
    for j in range(len(words)):
        ax.text(j, i, f"{sim_matrix[i,j]:.2f}", ha='center', va='center', fontsize=8)
plt.tight_layout()
plt.show()

# Interesting patterns to observe:
# king-queen: ~0.75 (both royal)
# man-woman: ~0.85 (gendered humans, very similar context)
# dog-cat: ~0.92 (both pets, very similar context!)
# car-truck: ~0.82 (both vehicles)
# happy-sad: ~0.30 (antonyms — similar context but opposite sentiment)

📚 Primary Source for This Lesson

Mikolov, Chen, Corrado & Dean (2013) — "Efficient Estimation of Word Representations in Vector Space"
The Word2Vec paper, including the skip-gram and CBOW architectures and the famous king−man+woman≈queen vector arithmetic. For GloVe, see Pennington, Socher & Manning (2014) "GloVe: Global Vectors for Word Representation."

💬 Confused about why static embeddings can't disambiguate a word's meaning, or how negative sampling actually works? Your AI tutor can walk through the Word2Vec training objective with a concrete example.