Part of Language AI Handbook
Explains how negative sampling reduces Word2Vec training from O(V) to O(k), covering the binary classification objective, unigram^0.75 sampling distribution.
Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.
Article links
Make inline references clickable
Negative Sampling
The Skip-gram model learns powerful word representations, but it has a computational bottleneck: the softmax denominator. Every training step requires computing a sum over the entire vocabulary. Given a center word , the probability of a context word is:
where:
- : probability of context word given center word
- : context embedding vector for word
- : center embedding vector for word
- : vocabulary size
For a vocabulary of 100,000 words, this means 100,000 dot products and exponentials per training step. With billions of training examples, full softmax becomes impractical. Training would take months instead of days.
Negative sampling solves this problem by reformulating the task as binary classification. Instead of predicting the exact probability of a context word, we ask: is this word pair a observed co-occurrence from the corpus, or a fake pair we constructed? This transformation reduces the computational cost from to , where is a small constant (typically 5 to 20), making large-scale training feasible.
This chapter develops negative sampling from first principles. We'll derive the objective function, understand why the sampling distribution matters, implement efficient training, and see how this approximation achieves nearly the same quality as full softmax at a fraction of the cost.
The Core Insight: Binary Classification Instead of Softmax
The full Skip-gram model answers: "What is the probability of each vocabulary word being a context word?" This requires computing a probability distribution over 100,000+ words. Negative sampling asks a simpler question: "Is this specific word pair a real context pair or a fake one?" This is binary classification, requiring only a single probability per pair.
Negative sampling approximates the full softmax objective by converting the multi-class classification problem into multiple binary classification problems. For each positive (center, context) pair from the corpus, we sample negative pairs and train the model to distinguish real pairs from fake ones.
Consider the sentence "The quick brown fox jumps." When "fox" is the center word, "brown" is a observed context word. But "elephant" or "democracy" don't appear near "fox" in this context. They're negatives, and we can use them to train the model.
# A genuine training pair from our corpus
center_word = "fox"
positive_context = "brown" # Actually appears near "fox"
# Negative samples: words that don't appear near "fox" in this context
negative_contexts = [
"elephant",
"democracy",
"refrigerator",
"quantum",
"bicycle",
]Negative Sampling Training Example: -------------------------------------------------- Center word: 'fox' Positive context (from corpus): 'brown' -> label: 1 Negative contexts (sampled randomly): 'elephant' -> label: 0 'democracy' -> label: 0 'refrigerator' -> label: 0 'quantum' -> label: 0 'bicycle' -> label: 0 Training task: distinguish real pair from fake pairs
The model learns by maximizing the probability for the positive pair while minimizing it for the negative pairs. Through millions of such updates, the embedding space organizes so that words appearing in similar contexts end up nearby.
From Softmax to Sigmoid
The mathematical reformulation is straightforward. Instead of softmax, we use the sigmoid function to output a probability for each pair independently.
For a positive (center, context) pair, we want the model to output a high probability that this pair comes from the corpus. The sigmoid function maps the dot product of the embeddings to a probability:
where:
- : probability that the word pair is a observed context pair from the data
- : binary indicator variable (1 = observed pair from data, 0 = fake pair)
- : sigmoid function, mapping any real number to the range
- : context embedding vector for word
- : center embedding vector for word
- : dot product between the context and center embeddings, measuring their similarity
When the embeddings are similar (large positive dot product), the sigmoid output approaches 1, indicating high confidence that the pair comes from the corpus.
For negative pairs, we want , or equivalently . Using the property that , we write:
where:
- : a negative (randomly sampled) word that did not appear in the context
- : context embedding vector for the negative word
When the embeddings are dissimilar (negative dot product), approaches 1, correctly indicating that the pair is likely fake.
def sigmoid(x):
"""Compute sigmoid function with numerical stability."""
x = np.clip(x, -500, 500)
return 1 / (1 + np.exp(-x))
# Example: probability computation for word pairs
dot_product_positive = 2.5 # High similarity (learned)
dot_product_negative = -1.0 # Low similarity (learned)
prob_positive = sigmoid(dot_product_positive)
prob_negative = sigmoid(dot_product_negative)Sigmoid Probabilities for Word Pairs: -------------------------------------------------- Positive pair dot product: 2.5 P(real pair) = sigma(2.5) = 0.9241 Negative pair dot product: -1.0 P(real pair) = sigma(-1.0) = 0.2689 A well-trained model assigns high probability to observed pairs and low probability to fake pairs.
The positive pair with dot product 2.5 receives probability 0.92, indicating high confidence that this is a observed context pair. The negative pair with dot product -1.0 receives only 0.27 probability, correctly reflecting low confidence.

The Negative Sampling Objective
Having established that we can use sigmoid to score individual word pairs, we now face a key question: what exactly should the model optimize? The answer lies in constructing a clever objective function that captures our core intuition: observed context pairs should score high, while fake pairs should score low.
For each positive pair from the corpus, we sample negative words from a noise distribution . The objective for this training example combines both goals into a single expression:
where:
- : objective function to maximize (higher is better)
- : center word embedding vector for word
- : context embedding vector for the positive context word
- : context embedding vector for the -th negative sample word
- : number of negative samples per positive pair
- : sigmoid function
- : positive term, maximized when the positive pair has high similarity
- : negative term, maximized when the negative pair has low similarity (negative dot product)
The positive term rewards the model for assigning high probability to observed pairs. When the dot product is large and positive, the sigmoid approaches 1, and . When the dot product is small or negative, the sigmoid drops toward 0, and is a large negative penalty.
The negative terms reward the model for correctly identifying fake pairs. Notice the negative sign inside the sigmoid. For a negative pair to score well, we need , which happens when the dot product itself is negative (dissimilar embeddings). If the model mistakenly places a negative word close to the center word, the dot product becomes positive, the sigmoid drops, and the log becomes a large penalty.
Together, these terms create opposing forces: the first pulls observed context words toward the center word, while the second pushes random words away. Through millions of such updates, the embedding space self-organizes so that semantically related words cluster together.
def negative_sampling_loss(center_vec, context_vec, negative_vecs):
"""
Compute the negative sampling loss for one training example.
Args:
center_vec: Embedding of the center word
context_vec: Embedding of the positive context word
negative_vecs: List of embeddings for negative samples
Returns:
loss: The negative of the objective (to be minimized)
"""
# Positive term: log sigma(context . center)
pos_dot = np.dot(context_vec, center_vec)
pos_loss = np.log(sigmoid(pos_dot) + 1e-10)
# Negative terms: sum of log sigma(-negative . center)
neg_loss = 0
for neg_vec in negative_vecs:
neg_dot = np.dot(neg_vec, center_vec)
neg_loss += np.log(sigmoid(-neg_dot) + 1e-10)
# We maximize the objective, so return negative for minimization
return -(pos_loss + neg_loss)
# Example with random vectors
np.random.seed(42)
embedding_dim = 50
center = np.random.randn(embedding_dim) * 0.5
context = np.random.randn(embedding_dim) * 0.5
negatives = [np.random.randn(embedding_dim) * 0.5 for _ in range(5)]
loss = negative_sampling_loss(center, context, negatives)Negative Sampling Loss Example: -------------------------------------------------- Embedding dimension: 50 Number of negative samples: 5 Center . Context (positive): 1.0506 sigma(dot) = 0.7409 Center . Negatives: Negative 1: dot = -1.3478, sigma(-dot) = 0.7938 Negative 2: dot = 0.8562, sigma(-dot) = 0.2981 Negative 3: dot = 3.7310, sigma(-dot) = 0.0234 Negative 4: dot = 2.8612, sigma(-dot) = 0.0541 Negative 5: dot = -1.9121, sigma(-dot) = 0.8713 Total loss (to minimize): 8.5505
With random initial embeddings, the dot products are near zero, producing sigmoid values around 0.5. The model is uncertain about all pairs. Training pushes these probabilities toward 1 for positive pairs and toward 0 for negatives.


The loss landscape reveals the asymmetric pressure applied by each term. For positive pairs (left), the loss drops sharply as the dot product increases, strongly encouraging the model to push observed context words closer. For negative pairs (right), the loss drops as the dot product becomes negative. The steepest gradients occur near zero, where the model is most uncertain.
Gradient Computation
The objective function creates a tug-of-war in embedding space. Each training step applies forces that reshape the geometry of word representations. Understanding these gradients reveals how semantic structure emerges from local updates.
During backpropagation, two types of forces act on each embedding. An attractive force from positive pairs pulls the center word embedding toward its true context word. A repulsive force from negative pairs pushes the center word away from each sampled negative. The magnitude of each force depends on the current prediction confidence. If the model already correctly predicts a positive pair, the gradient is small. If it is uncertain or wrong, the gradient is large, focusing learning where it matters most.
Starting from the objective function , we compute gradients using the chain rule and the sigmoid derivative .
Gradient for the center word embedding :
where:
- : always negative since , so this term pulls the center embedding toward the positive context
- : the model's (mistaken) confidence that the -th negative pair comes from the corpus; during gradient ascent, this pushes the center embedding away from each negative
Gradient for the positive context word embedding :
Since the coefficient is negative, this gradient points toward the center word during gradient ascent, pulling the context embedding closer.
Gradient for each negative word embedding :
The positive coefficient means we subtract this during gradient ascent, pushing each negative embedding away from the center word. The key observation is that this gradient is proportional to how much the model currently confuses the negative for a real pair: if the model already assigns low probability to this negative pair, the gradient is small and the update is minor.

The gradient curves cross at zero, where the model is maximally uncertain. For positive pairs, the model learns strongly when embeddings are dissimilar but relaxes when they are already similar. For negative pairs, the opposite holds. This elegant self-regulation emerges naturally from the sigmoid function.
The Sampling Distribution: Why
The choice of how to sample negative words turns out to be surprisingly consequential. A naive approach might sample words uniformly at random. But consider what happens: "the" appears in nearly every sentence, yet with uniform sampling it would be drawn as a negative in only of cases. Meanwhile, "aardvark" appears rarely in real text but would be sampled just as often as "the."
This mismatch creates problems. Common function words occur near almost every word in the corpus, so sampling them as negatives is confusing because they are often legitimate context words too. Rare words sampled too often as negatives cause the model to push everything away from them, hurting their embedding quality.
The Word2Vec authors discovered that a modified unigram distribution works best:
where:
- : probability of sampling word as a negative sample
- : frequency (count) of word in the corpus
- : the frequency raised to the power 0.75, which compresses the range
- : normalizing constant summing over all vocabulary words
- : smoothing exponent determined empirically by the Word2Vec authors
The key insight is that raising to a power less than 1 compresses the range of values. If word A appears 10,000 times and word B appears 10 times, their raw frequency ratio is 1000:1. After applying the 0.75 exponent, . The ratio shrinks from 1000:1 to about 178:1, boosting the representation of rare words without ignoring frequency entirely.
def create_sampling_distribution(word_counts, alpha=0.75):
"""
Create the negative sampling distribution using smoothed unigram frequencies.
Args:
word_counts: Dictionary mapping words to their corpus frequencies
alpha: Exponent to smooth the distribution (default 0.75)
Returns:
words: List of words
probs: Sampling probabilities
"""
words = list(word_counts.keys())
freqs = np.array([word_counts[w] for w in words], dtype=np.float64)
smoothed = freqs**alpha
probs = smoothed / smoothed.sum()
return words, probs
# Example corpus frequencies
word_frequencies = {
"the": 100000,
"a": 80000,
"is": 50000,
"word": 1000,
"embedding": 500,
"neural": 400,
"semantic": 200,
"vector": 800,
"aardvark": 5,
"quixotic": 2,
}
words, probs = create_sampling_distribution(word_frequencies, alpha=0.75)Comparison of Sampling Distributions: ----------------------------------------------------------------- Word Raw Freq Uniform Freq Prob Smoothed ----------------------------------------------------------------- the 100,000 0.1000 0.429356 0.393092 a 80,000 0.1000 0.343485 0.332516 is 50,000 0.1000 0.214678 0.233734 word 1,000 0.1000 0.004294 0.012431 embedding 500 0.1000 0.002147 0.007391 neural 400 0.1000 0.001717 0.006252 semantic 200 0.1000 0.000859 0.003718 vector 800 0.1000 0.003435 0.010515 aardvark 5 0.1000 0.000021 0.000234 quixotic 2 0.1000 0.000009 0.000118 Sum of smoothed probs: 1.0000
The comparison shows how smoothing redistributes probability mass. Common words like "the" drop from dominating the distribution (raw frequency ~43%) to a more moderate share. Rare words like "aardvark" and "quixotic" see their sampling probability increase relative to their raw frequency. This ensures they receive adequate training signal.



Why 0.75? Intuition and Alternatives
The exponent 0.75 was found empirically by the Word2Vec authors. Consider the intuition by looking at the extremes. An exponent of 1.0 (raw frequency) means frequent words dominate: "the" might account for 7% of samples, wasting computation on a word the model has already learned to handle. An exponent of 0.0 (uniform) treats rare words as often as frequent ones, but rare words share contexts with fewer other words, making them less informative negatives. The value 0.75 strikes a balance: common words still appear often enough to learn good embeddings, while rare words get meaningful representation in the negative samples.

How Many Negative Samples? The Hyperparameter
The number of negative samples is a central hyperparameter that controls the trade-off between training signal quality and computational cost. More negatives provide stronger gradient information per positive pair but increase the work per training step linearly.
Typical values for range from 5 to 20. Smaller values (5 to 10) work well for large datasets where each word pair is seen many times. Larger values (15 to 25) help with smaller datasets or less frequent words. The original Word2Vec paper recommends 5 to 10 for large corpora and 15 to 25 for smaller ones.
The intuition behind these recommendations is straightforward. With a large corpus, each positive word pair appears many times throughout training. This provides ample signal to position that pair correctly. A modest number of negatives is sufficient to push away distractors. With a smaller corpus, each positive pair appears less frequently, so more negatives per step compensate by providing stronger repulsive signal that helps separate unrelated words even with limited data.
def estimate_training_cost(vocab_size, num_negatives, num_training_pairs):
"""
Compare computational cost of full softmax vs negative sampling.
Cost is measured in dot product operations per training step.
"""
softmax_cost = num_training_pairs * vocab_size
neg_sampling_cost = num_training_pairs * (1 + num_negatives)
speedup = softmax_cost / neg_sampling_cost
return softmax_cost, neg_sampling_cost, speedup
vocab_size = 100000
num_negatives = 10
num_training_pairs = 1_000_000_000 # 1 billion pairs
softmax_cost, neg_samp_cost, speedup = estimate_training_cost(
vocab_size, num_negatives, num_training_pairs
)Computational Cost Comparison: ------------------------------------------------------- Vocabulary size: 100,000 Training pairs: 1,000,000,000 Negative samples k: 10 Full softmax cost: 100,000,000,000,000 dot products Neg sampling cost: 11,000,000,000 dot products Speedup factor: 9,091x This is why negative sampling enables practical training!
For a 100,000-word vocabulary with 10 negative samples, negative sampling performs approximately 9,000 times fewer operations per training step than full softmax. What would take months with softmax can be completed in days with negative sampling.


The trade-off between the number of negative samples and computational cost is linear: doubling doubles the work per training step. But the relationship between and embedding quality is sublinear, with diminishing returns as increases. The sweet spot at to captures most of the quality gains while keeping training times manageable.
Noise Contrastive Estimation: The Theoretical Foundation
Negative sampling didn't emerge from thin air. It is a practical simplification of a more principled technique called Noise Contrastive Estimation (NCE), developed by Gutmann and Hyvärinen (2010). Understanding NCE illuminates why negative sampling works and what theoretical guarantees we sacrifice for efficiency.
NCE converts the problem of estimating a probability distribution into a binary classification problem: distinguish data samples from noise samples. Under certain conditions, the good classifier recovers the true data distribution up to a normalizing constant.
The core insight of NCE is elegant: if you can perfectly distinguish real data from noise, you must have learned what makes the data real. The procedure draws positive samples from the true data distribution (context words given center) and negative samples from a known noise distribution , then trains a classifier to distinguish them.
Since we draw 1 data sample and noise samples, the prior odds are . The Bayes-optimal decision rule becomes:
where:
- : posterior probability that sample came from the data distribution given context
- : true data distribution (probability of observing word in the context of word )
- : noise distribution probability for word (assumed independent of context)
- : number of noise samples drawn per data sample
- : accounts for the -fold higher chance of seeing noise samples
NCE models this probability using a learnable score function:
where:
- : the model's unnormalized log-probability score for word pair , typically the dot product
- : the model's estimate of the unnormalized data probability
When training converges and the classifier achieves optimal performance, comparing the two formulas reveals that the model must have learned . The model recovers the true data distribution up to a normalizing constant.
From NCE to Negative Sampling
Negative sampling departs from NCE in two important ways, each trading theoretical rigor for practical efficiency.
First simplification: ignoring the partition function. NCE explicitly models the normalization constant (the sum over all vocabulary words that makes probabilities sum to 1). This constant is precisely what makes softmax expensive. Negative sampling simply ignores it, treating as an unnormalized score. We lose the ability to compute true probabilities, but for learning embeddings, relative scores suffice.
Second simplification: using sigmoid directly. NCE's probability formula accounts for the ratio of data to noise samples and the noise distribution. Negative sampling replaces this with the simpler sigmoid . This makes implementation straightforward and gradients clean, at the cost of some theoretical precision.
What do we lose? Negative sampling no longer guarantees convergence to the true data distribution. The embeddings might not represent exact co-occurrence probabilities. But for downstream tasks like analogy completion, similarity search, and transfer learning, this rarely matters. The embeddings capture semantic relationships just as well, at a fraction of the computational cost.
def nce_probability(score, noise_prob, k):
"""
Compute NCE probability that a sample is from data (not noise).
Args:
score: Model's score s(w, c) for word pair
noise_prob: P_n(w) for the sampled word
k: Number of noise samples per data sample
"""
exp_score = np.exp(score)
return exp_score / (exp_score + k * noise_prob)
def neg_sampling_probability(score):
"""Compute negative sampling probability using sigmoid."""
return sigmoid(score)
# Compare the two for various scores
scores = np.linspace(-4, 4, 100)
noise_prob = 0.1 # Chosen so k*noise_prob ~ 1 at s=0, making curves comparable
k_nce = 10
nce_probs = [nce_probability(s, noise_prob, k_nce) for s in scores]
ns_probs = [neg_sampling_probability(s) for s in scores]
Implementing Efficient Negative Sampling
A practical implementation needs to sample from the noise distribution efficiently. Computing probabilities for 100,000 words on every sample would be slow. Instead, we precompute a sampling structure. The key approach uses a cumulative probability array and binary search, giving per sample after preprocessing.
class NegativeSampler:
"""
Efficient negative sampling using precomputed cumulative distribution.
Preprocessing: O(V)
Sampling: O(log V) per sample using binary search
"""
def __init__(self, word_counts, alpha=0.75):
"""
Initialize the sampler with word frequency counts.
Args:
word_counts: Dict mapping words or indices to frequencies
alpha: Smoothing exponent (default 0.75)
"""
self.words = list(word_counts.keys())
self.word_to_idx = {w: i for i, w in enumerate(self.words)}
freqs = np.array([word_counts[w] for w in self.words], dtype=np.float64)
smoothed = freqs**alpha
self.probs = smoothed / smoothed.sum()
# Precompute cumulative distribution for binary search sampling
self.cumsum = np.cumsum(self.probs)
def sample(self, k, exclude_idx=None):
"""
Sample k negative word indices.
Args:
k: Number of samples
exclude_idx: Index to exclude (typically the positive word)
Returns:
List of k word indices
"""
samples = []
while len(samples) < k:
r = np.random.random()
idx = np.searchsorted(self.cumsum, r)
if idx != exclude_idx:
samples.append(idx)
return samples
# Create sampler with our example vocabulary
sampler = NegativeSampler(word_frequencies, alpha=0.75)Negative Sampling Demonstration: ------------------------------------------------------- Drew 1000 batches of 5 negative samples each Total samples: 5000 Word sampling frequencies (should roughly match smoothed dist): Word Expected Observed Ratio --------------------------------------------- the 0.3931 0.3972 1.01 a 0.3325 0.3312 1.00 is 0.2337 0.2306 0.99 word 0.0124 0.0148 1.19 embedding 0.0074 0.0066 0.89 neural 0.0063 0.0056 0.90 semantic 0.0037 0.0036 0.97 vector 0.0105 0.0102 0.97

Complete Skip-gram Implementation with Negative Sampling
Now let's put everything together into a complete, trainable Skip-gram model with negative sampling. The model maintains two embedding matrices (center word embeddings and context embeddings ), performs forward and backward passes, and updates weights via stochastic gradient ascent on the negative sampling objective.
class SkipGramNegSampling:
"""
Skip-gram model with negative sampling.
Maintains separate embedding matrices for center words (W) and
context words (W'), updating both via the negative sampling objective.
"""
def __init__(
self,
vocab_size,
embedding_dim,
word_counts,
num_negatives=5,
alpha=0.75,
):
# Embedding matrices
self.W = (
np.random.randn(vocab_size, embedding_dim) * 0.01
) # Center word embeddings
self.W_prime = (
np.random.randn(vocab_size, embedding_dim) * 0.01
) # Context embeddings
self.vocab_size = vocab_size
self.embedding_dim = embedding_dim
self.num_negatives = num_negatives
self.sampler = NegativeSampler(word_counts, alpha=alpha)
def forward(self, center_idx, context_idx, negative_indices):
"""Forward pass: compute loss and cache values for backprop."""
center_vec = self.W[center_idx]
context_vec = self.W_prime[context_idx]
negative_vecs = self.W_prime[negative_indices]
pos_score = np.dot(context_vec, center_vec)
pos_sigmoid = sigmoid(pos_score)
neg_scores = negative_vecs @ center_vec
neg_sigmoids = sigmoid(-neg_scores) # Want these close to 1
loss = -np.log(pos_sigmoid + 1e-10) - np.sum(
np.log(neg_sigmoids + 1e-10)
)
cache = {
"center_idx": center_idx,
"context_idx": context_idx,
"negative_indices": negative_indices,
"center_vec": center_vec,
"context_vec": context_vec,
"negative_vecs": negative_vecs,
"pos_sigmoid": pos_sigmoid,
"neg_sigmoids": neg_sigmoids,
}
return loss, cache
def backward(self, cache, learning_rate):
"""Backward pass: compute gradients and update weights."""
center_idx = cache["center_idx"]
context_idx = cache["context_idx"]
negative_indices = cache["negative_indices"]
center_vec = cache["center_vec"]
context_vec = cache["context_vec"]
negative_vecs = cache["negative_vecs"]
pos_sigmoid = cache["pos_sigmoid"]
neg_sigmoids = cache["neg_sigmoids"]
# Gradient for center word: attractive from positive, repulsive from negatives
grad_center = (pos_sigmoid - 1) * context_vec
for i, neg_idx in enumerate(negative_indices):
neg_sigmoid_pos = 1 - neg_sigmoids[i]
grad_center += neg_sigmoid_pos * negative_vecs[i]
# Gradient for positive context embedding
grad_context = (pos_sigmoid - 1) * center_vec
# Gradients for negative embeddings
grad_negatives = np.zeros_like(negative_vecs)
for i in range(len(negative_indices)):
neg_sigmoid_pos = 1 - neg_sigmoids[i]
grad_negatives[i] = neg_sigmoid_pos * center_vec
# Update embeddings (gradient ascent on loss = gradient descent on neg loss)
self.W[center_idx] -= learning_rate * grad_center
self.W_prime[context_idx] -= learning_rate * grad_context
for i, neg_idx in enumerate(negative_indices):
self.W_prime[neg_idx] -= learning_rate * grad_negatives[i]
def train_pair(self, center_idx, context_idx, learning_rate=0.025):
"""Train on a single (center, context) pair."""
negative_indices = self.sampler.sample(
self.num_negatives, exclude_idx=context_idx
)
loss, cache = self.forward(center_idx, context_idx, negative_indices)
self.backward(cache, learning_rate)
return loss
def get_embedding(self, word_idx):
"""Get the embedding vector for a word."""
return self.W[word_idx]
def most_similar(self, word_idx, top_n=5):
"""Find most similar words by cosine similarity."""
word_vec = self.W[word_idx]
word_vec_norm = word_vec / (np.linalg.norm(word_vec) + 1e-10)
norms = np.linalg.norm(self.W, axis=1, keepdims=True)
normalized = self.W / (norms + 1e-10)
similarities = normalized @ word_vec_norm
similarities[word_idx] = -np.inf
top_indices = np.argsort(similarities)[::-1][:top_n]
return [(idx, similarities[idx]) for idx in top_indices]Training on a Semantic Corpus
Let's train the model on a corpus with clear semantic structure to observe whether the embeddings capture meaningful relationships:
from collections import Counter
training_sentences = [
"king queen prince princess royal throne crown palace",
"man woman boy girl child adult person human",
"cat dog pet animal fur paw tail whisker",
"happy sad angry joyful emotion feeling mood cheerful",
"run walk jump sprint move fast slow quick",
"king rules the kingdom with royal power",
"the queen sits on the throne in the palace",
"a man and woman walk with their child",
"the cat and dog are beloved pets",
"feeling happy brings a joyful mood",
"run fast and jump quick to move",
]
all_words = " ".join(training_sentences).lower().split()
vocab = sorted(set(all_words))
word_to_idx = {w: i for i, w in enumerate(vocab)}
idx_to_word = {i: w for w, i in word_to_idx.items()}
word_counts = Counter(all_words)
word_count_by_idx = {word_to_idx[w]: c for w, c in word_counts.items()}
def generate_pairs(sentences, word_to_idx, window=2):
pairs = []
for sentence in sentences:
words = sentence.lower().split()
for i, center in enumerate(words):
for j in range(max(0, i - window), min(len(words), i + window + 1)):
if j != i:
pairs.append((word_to_idx[center], word_to_idx[words[j]]))
return pairs
training_pairs = generate_pairs(training_sentences, word_to_idx, window=2)Training Corpus Statistics: --------------------------------------------- Number of sentences: 11 Total words: 84 Vocabulary size: 56 Training pairs: 270 Sample vocabulary: ['a', 'adult', 'and', 'angry', 'animal', 'are', 'beloved', 'boy', 'brings', 'cat', 'cheerful', 'child', 'crown', 'dog', 'emotion'] ... and 41 more words
np.random.seed(42)
model = SkipGramNegSampling(
vocab_size=len(vocab),
embedding_dim=30,
word_counts=word_count_by_idx,
num_negatives=5,
alpha=0.75,
)
num_epochs = 50
losses_per_epoch = []
for epoch in range(num_epochs):
np.random.shuffle(training_pairs)
epoch_loss = 0
for center_idx, context_idx in training_pairs:
loss = model.train_pair(center_idx, context_idx, learning_rate=0.1)
epoch_loss += loss
avg_loss = epoch_loss / len(training_pairs)
losses_per_epoch.append(avg_loss)Training Complete: --------------------------------------------- Epochs: 50 Learning rate: 0.1 Negative samples per pair: 5 Initial loss: 4.1587 Final loss: 1.1451 Reduction: 72.5%
The loss decreases substantially over training, indicating the model is learning to distinguish positive from negative pairs. The percentage reduction reflects how much better the model has become at the binary classification task.

Evaluating the Learned Embeddings
With the model trained, we can examine whether it has captured meaningful semantic relationships:
test_words = ["king", "cat", "happy", "run", "man"]
similarity_results = {}
for word in test_words:
if word in word_to_idx:
similar = model.most_similar(word_to_idx[word], top_n=5)
similarity_results[word] = [
(idx_to_word[idx], sim) for idx, sim in similar
]Learned Word Similarities (Negative Sampling): ------------------------------------------------------- Most similar to 'king': kingdom : +0.759 █████████████ on : +0.709 ████████████ sits : +0.700 ████████████ princess : +0.674 ████████████ queen : +0.647 ████████████ Most similar to 'cat': animal : +0.722 ████████████ are : +0.703 ████████████ sits : +0.594 ███████████ in : +0.582 ███████████ fur : +0.535 ███████████ Most similar to 'happy': joyful : +0.920 ██████████████ emotion : +0.688 ████████████ cheerful : +0.674 ████████████ sad : +0.657 ████████████ brings : +0.643 ████████████ Most similar to 'run': sprint : +0.866 █████████████ quick : +0.760 █████████████ jump : +0.753 █████████████ slow : +0.709 ████████████ to : +0.697 ████████████ Most similar to 'man': girl : +0.831 █████████████ brings : +0.623 ████████████ walk : +0.620 ████████████ boy : +0.617 ████████████ and : +0.563 ███████████
Words from the same semantic category cluster together. "King" finds other royalty terms, "cat" finds animal terms, and "happy" finds emotion words. This emerges purely from the binary classification objective applied to co-occurrence pairs.

Comparing Full Softmax and Negative Sampling
A natural question is how the quality of embeddings from negative sampling compares to those from full softmax. Let's train both models on the same corpus:
class SkipGramFullSoftmax:
"""Skip-gram with full softmax for comparison."""
def __init__(self, vocab_size, embedding_dim):
self.W = np.random.randn(vocab_size, embedding_dim) * 0.01
self.W_prime = np.random.randn(vocab_size, embedding_dim) * 0.01
self.vocab_size = vocab_size
self.embedding_dim = embedding_dim
def forward(self, center_idx, context_idx):
center_vec = self.W[center_idx]
scores = self.W_prime @ center_vec
exp_scores = np.exp(scores - np.max(scores))
probs = exp_scores / exp_scores.sum()
loss = -np.log(probs[context_idx] + 1e-10)
return loss, probs, center_vec
def backward(
self, center_idx, context_idx, probs, center_vec, learning_rate
):
grad_probs = probs.copy()
grad_probs[context_idx] -= 1
for j in range(self.vocab_size):
self.W_prime[j] -= learning_rate * grad_probs[j] * center_vec
grad_center = self.W_prime.T @ grad_probs
self.W[center_idx] -= learning_rate * grad_center
def train_pair(self, center_idx, context_idx, learning_rate=0.025):
loss, probs, center_vec = self.forward(center_idx, context_idx)
self.backward(center_idx, context_idx, probs, center_vec, learning_rate)
return loss
def get_embedding(self, word_idx):
return self.W[word_idx]
# Train both models with the same random seed for fair comparison
np.random.seed(42)
model_softmax = SkipGramFullSoftmax(vocab_size=len(vocab), embedding_dim=30)
model_negsamp = SkipGramNegSampling(
vocab_size=len(vocab),
embedding_dim=30,
word_counts=word_count_by_idx,
num_negatives=5,
)
losses_softmax = []
losses_negsamp = []
for epoch in range(50):
np.random.shuffle(training_pairs)
epoch_loss_sm = 0
epoch_loss_ns = 0
for center_idx, context_idx in training_pairs:
epoch_loss_sm += model_softmax.train_pair(
center_idx, context_idx, learning_rate=0.1
)
epoch_loss_ns += model_negsamp.train_pair(
center_idx, context_idx, learning_rate=0.1
)
losses_softmax.append(epoch_loss_sm / len(training_pairs))
losses_negsamp.append(epoch_loss_ns / len(training_pairs))

The comparison reveals a key insight: negative sampling produces embeddings of similar quality to full softmax, despite using a fundamentally different and much cheaper objective. This explains why negative sampling became the default training method for Word2Vec and similar models.
Practical Considerations
Subsampling Frequent Words
Very frequent words like "the" and "a" provide limited information because they appear in nearly every context. They don't help distinguish different semantic meanings. Word2Vec subsamples frequent words by randomly discarding some occurrences during training based on their frequency:
where:
- : probability of keeping an occurrence of word during training
- : relative frequency of word in the corpus, computed as count / total word count
- : subsampling threshold, typically
- : dominant term for very frequent words, which provides aggressive subsampling
- : additive term that prevents the probability from dropping too sharply
The formula has an intuitive interpretation: when (very frequent words), , which is much less than 1. If "the" has relative frequency and , then , meaning only about 1.4% of "the" occurrences are kept. When (rare words), the formula yields values above 1, which are clamped to 1. This ensures rare words always contribute.
def subsample_prob(word_freq, total_words, threshold=1e-5):
"""
Compute probability of keeping a word during training.
Frequent words are subsampled to reduce their dominance.
"""
freq_ratio = word_freq / total_words
if freq_ratio > threshold:
keep_prob = np.sqrt(threshold / freq_ratio) + (threshold / freq_ratio)
return min(keep_prob, 1.0)
return 1.0
total = sum(word_frequencies.values())
subsample_probs = {
w: subsample_prob(f, total) for w, f in word_frequencies.items()
}Subsampling Probabilities: -------------------------------------------------- Word Frequency Keep Prob -------------------------------------------------- the 100,000 0.0048 a 80,000 0.0054 is 50,000 0.0069 word 1,000 0.0506 vector 800 0.0569 embedding 500 0.0729 neural 400 0.0821 semantic 200 0.1196 aardvark 5 1.0000 quixotic 2 1.0000
The most frequent words like "the" and "a" have very low keep probabilities, meaning they are discarded during most training iterations. Rarer words like "aardvark" and "quixotic" have keep probability 1.0. This ensures every occurrence contributes to their embeddings.
Key Parameters
The key parameters for negative sampling training are:
- num_negatives (k): Number of negative samples per positive pair. Typical range is 5 to 10 for large datasets and 15 to 25 for smaller ones. More negatives improve quality with diminishing returns and increase training time linearly.
- alpha: Smoothing exponent for the noise distribution. The recommended value is 0.75. Values closer to 1.0 give more weight to frequent words; values closer to 0 approach uniform sampling.
- learning_rate: Initial learning rate. The Word2Vec implementation uses 0.025 by default with linear decay to 0.0001 over training.
- window_size: Context window size. Larger windows capture broader topical similarity; smaller windows capture tighter syntactic relationships.
Limitations and Impact
Negative sampling made Word2Vec practical, but it comes with limitations worth understanding.
The most fundamental limitation is that negative sampling does not optimize the true softmax objective. It optimizes a surrogate that happens to produce good embeddings for most downstream tasks, but tasks requiring precise probability estimates or calibrated outputs may be better served by exact methods. The theoretical connection to NCE provides some justification for why the surrogate works, but the guarantees are weaker.
The hyperparameters and require tuning. The defaults from the original paper (k=5 to 10, =0.75) work well across many settings, but they are not universally optimal. Low-resource languages, specialized technical vocabularies, or domains with very skewed frequency distributions may benefit from different choices.
Negative sampling also inherits the fundamental limitation of all Word2Vec models: it produces static embeddings. Each word receives a single fixed vector regardless of context. "Bank" in a financial document and "bank" near a river share the same embedding, even though they mean different things. This limitation motivated the development of contextual embedding models like ELMo, BERT, and GPT, which represent the next generation of word representations. We will explore these models in later parts of the book.
Despite these limitations, negative sampling's impact on NLP research was enormous. It made embedding training practical at billion-word scale, enabling the empirical discoveries about embedding geometry (analogies, semantic arithmetic) that captured the community's imagination. It influenced the design of subsequent models, and the core idea of binary discrimination objectives reappears in modern contrastive learning methods that train vision models, cross-modal systems, and sentence encoders. The same principle that makes negative sampling work for word embeddings powers frameworks like SimCLR, CLIP, and many others: rather than computing expensive normalizations over large output spaces, learn by distinguishing real examples from carefully sampled negatives.
Summary
Negative sampling transforms the Skip-gram training problem from an expensive multi-class classification (softmax over vocabulary) to efficient binary classification (real vs. fake pairs). This simple change reduces computational complexity from to per training step, making large-scale training feasible.
Key takeaways:
- Binary classification reformulation: Instead of predicting which word is the context, predict whether a given pair comes from the corpus or randomly sampled. This eliminates the need to compute probabilities over the entire vocabulary.
- Sigmoid instead of softmax: Each pair is scored independently using the sigmoid function. The positive term pulls context words closer; the negative terms push random words away.
- The sampling distribution matters: Using balances representation between frequent and rare words better than either uniform or raw frequency sampling.
- Number of negatives (): Typically 5 to 20, with smaller values for larger datasets. More negatives provide stronger training signal with diminishing returns and linear cost increase.
- Theoretical foundation in NCE: Negative sampling is a simplification of Noise Contrastive Estimation, trading theoretical guarantees for practical efficiency. It ignores the partition function and replaces the NCE probability formula with a plain sigmoid.
- Comparable quality to full softmax: Despite the approximation, embeddings from negative sampling rival those from full softmax in downstream tasks, which is why it became the standard training method.
The next chapter explores hierarchical softmax, an alternative approximation that organizes the vocabulary as a binary tree, reducing softmax complexity from to without the binary classification reformulation.
Quiz
Ready to test your understanding? Take this quick quiz to reinforce what you've learned about negative sampling.
Negative Sampling Quiz
Reference
Citation details
Cite or share this article.
Continue with the full handbook
This chapter is part of Language AI Handbook. Use the handbook page to browse the complete table of contents and continue reading in sequence.
Explore Language AI HandbookStay up to date
Get articles, book updates, and news delivered to your inbox.
No spam, unsubscribe anytime.
Join the community
Sign in to remove popups, track your reading progress, and join the discussion.

Comments
No comments yet. Be the first to share your thoughts!