Negative Sampling: Efficient Word2Vec Training

Michael BrenndoerferApril 4, 202547 min read

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 wtw_t, the probability of a context word wcw_c is:

P(wcwt)=exp(wcwt)k=1Vexp(wkwt)P(w_c | w_t) = \frac{\exp(\mathbf{w}'_c \cdot \mathbf{w}_t)}{\sum_{k=1}^{V} \exp(\mathbf{w}'_k \cdot \mathbf{w}_t)}

where:

  • P(wcwt)P(w_c | w_t): probability of context word wcw_c given center word wtw_t
  • wc\mathbf{w}'_c: context embedding vector for word wcw_c
  • wt\mathbf{w}_t: center embedding vector for word wtw_t
  • VV: 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 O(V)O(V) to O(k)O(k), where kk 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

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 kk 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.

In[4]:
Code
# 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",
]
Out[5]:
Console
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 σ(x)=1/(1+ex)\sigma(x) = 1 / (1 + e^{-x}) 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:

P(D=1wc,wt)=σ(wcwt)=11+exp(wcwt)P(D = 1 | w_c, w_t) = \sigma(\mathbf{w}'_c \cdot \mathbf{w}_t) = \frac{1}{1 + \exp(-\mathbf{w}'_c \cdot \mathbf{w}_t)}

where:

  • P(D=1wc,wt)P(D = 1 | w_c, w_t): probability that the word pair (wc,wt)(w_c, w_t) is a observed context pair from the data
  • DD: binary indicator variable (1 = observed pair from data, 0 = fake pair)
  • σ(x)\sigma(x): sigmoid function, mapping any real number to the range (0,1)(0, 1)
  • wc\mathbf{w}'_c: context embedding vector for word wcw_c
  • wt\mathbf{w}_t: center embedding vector for word wtw_t
  • wcwt\mathbf{w}'_c \cdot \mathbf{w}_t: 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 P(D=1)0P(D = 1) \approx 0, or equivalently P(D=0)1P(D = 0) \approx 1. Using the property that 1σ(x)=σ(x)1 - \sigma(x) = \sigma(-x), we write:

P(D=0wn,wt)=1σ(wnwt)=σ(wnwt)P(D = 0 | w_n, w_t) = 1 - \sigma(\mathbf{w}'_n \cdot \mathbf{w}_t) = \sigma(-\mathbf{w}'_n \cdot \mathbf{w}_t)

where:

  • wnw_n: a negative (randomly sampled) word that did not appear in the context
  • wn\mathbf{w}'_n: context embedding vector for the negative word wnw_n

When the embeddings are dissimilar (negative dot product), σ(wnwt)\sigma(-\mathbf{w}'_n \cdot \mathbf{w}_t) approaches 1, correctly indicating that the pair is likely fake.

In[6]:
Code
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)
Out[7]:
Console
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.

Out[8]:
Visualization
S-shaped sigmoid curve showing probability output versus dot product, with positive and negative example points marked.
The sigmoid function maps dot products to probabilities. Large positive dot products (similar embeddings) yield probabilities near 1, indicating the model believes the pair comes from the corpus. Large negative dot products (dissimilar embeddings) yield probabilities near 0. The positive and negative example pairs from our computation are marked, showing that a trained model separates them decisively.

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 (wt,wc)(w_t, w_c) from the corpus, we sample kk negative words {wn1,wn2,,wnk}\{w_{n_1}, w_{n_2}, \ldots, w_{n_k}\} from a noise distribution Pn(w)P_n(w). The objective for this training example combines both goals into a single expression:

L=logσ(wcwt)+i=1klogσ(wniwt)\mathcal{L} = \log \sigma(\mathbf{w}'_c \cdot \mathbf{w}_t) + \sum_{i=1}^{k} \log \sigma(-\mathbf{w}'_{n_i} \cdot \mathbf{w}_t)

where:

  • L\mathcal{L}: objective function to maximize (higher is better)
  • wt\mathbf{w}_t: center word embedding vector for word wtw_t
  • wc\mathbf{w}'_c: context embedding vector for the positive context word wcw_c
  • wni\mathbf{w}'_{n_i}: context embedding vector for the ii-th negative sample word
  • kk: number of negative samples per positive pair
  • σ()\sigma(\cdot): sigmoid function
  • logσ(wcwt)\log \sigma(\mathbf{w}'_c \cdot \mathbf{w}_t): positive term, maximized when the positive pair has high similarity
  • logσ(wniwt)\log \sigma(-\mathbf{w}'_{n_i} \cdot \mathbf{w}_t): negative term, maximized when the negative pair has low similarity (negative dot product)

The positive term logσ(wcwt)\log \sigma(\mathbf{w}'_c \cdot \mathbf{w}_t) rewards the model for assigning high probability to observed pairs. When the dot product is large and positive, the sigmoid approaches 1, and log(1)=0\log(1) = 0. When the dot product is small or negative, the sigmoid drops toward 0, and log(small number)\log(\text{small number}) is a large negative penalty.

The negative terms i=1klogσ(wniwt)\sum_{i=1}^{k} \log \sigma(-\mathbf{w}'_{n_i} \cdot \mathbf{w}_t) reward the model for correctly identifying fake pairs. Notice the negative sign inside the sigmoid. For a negative pair to score well, we need σ(dot product)1\sigma(-\text{dot product}) \approx 1, 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.

In[9]:
Code
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)
Out[10]:
Console
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.

Out[11]:
Visualization
Green line plot showing positive term loss decreasing from upper left to lower right.
Positive term loss as a function of dot product. The loss decreases as the dot product increases, rewarding the model for placing observed context words near the center word. The steep gradient near zero focuses learning on uncertain or incorrect predictions.
Red line plot showing negative term loss decreasing from upper right to lower left.
Positive term loss as a function of dot product. The loss decreases as the dot product increases, rewarding the model for placing observed context words near the center word. The steep gradient near zero focuses learning on uncertain or incorrect predictions.

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 L=logσ(wcwt)+i=1klogσ(wniwt)\mathcal{L} = \log \sigma(\mathbf{w}'_c \cdot \mathbf{w}_t) + \sum_{i=1}^{k} \log \sigma(-\mathbf{w}'_{n_i} \cdot \mathbf{w}_t), we compute gradients using the chain rule and the sigmoid derivative dσ(x)dx=σ(x)(1σ(x))\frac{d\sigma(x)}{dx} = \sigma(x)(1 - \sigma(x)).

Gradient for the center word embedding wt\mathbf{w}_t:

Lwt=(σ(wcwt)1)wc+i=1kσ(wniwt)wni\frac{\partial \mathcal{L}}{\partial \mathbf{w}_t} = (\sigma(\mathbf{w}'_c \cdot \mathbf{w}_t) - 1) \mathbf{w}'_c + \sum_{i=1}^{k} \sigma(\mathbf{w}'_{n_i} \cdot \mathbf{w}_t) \mathbf{w}'_{n_i}

where:

  • (σ(wcwt)1)(\sigma(\mathbf{w}'_c \cdot \mathbf{w}_t) - 1): always negative since σ()(0,1)\sigma(\cdot) \in (0, 1), so this term pulls the center embedding toward the positive context
  • σ(wniwt)\sigma(\mathbf{w}'_{n_i} \cdot \mathbf{w}_t): the model's (mistaken) confidence that the ii-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 wc\mathbf{w}'_c:

Lwc=(σ(wcwt)1)wt\frac{\partial \mathcal{L}}{\partial \mathbf{w}'_c} = (\sigma(\mathbf{w}'_c \cdot \mathbf{w}_t) - 1) \mathbf{w}_t

Since the coefficient (σ1)(\sigma - 1) is negative, this gradient points toward the center word during gradient ascent, pulling the context embedding closer.

Gradient for each negative word embedding wni\mathbf{w}'_{n_i}:

Lwni=σ(wniwt)wt\frac{\partial \mathcal{L}}{\partial \mathbf{w}'_{n_i}} = \sigma(\mathbf{w}'_{n_i} \cdot \mathbf{w}_t) \mathbf{w}_t

The positive coefficient σ()\sigma(\cdot) 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.

Out[12]:
Visualization
Line plot showing gradient magnitudes for positive and negative terms crossing at zero, with annotations marking high and low learning regions.
Gradient magnitude as a function of dot product for positive and negative terms. For positive pairs (green), the gradient |sigma - 1| is largest when the dot product is negative (wrong prediction) and smallest when positive (correct prediction). For negative pairs (red), the gradient |sigma| is largest when embeddings are incorrectly similar and smallest when already dissimilar. Both curves cross at zero where uncertainty is maximal, and the self-regulating behavior focuses learning effort on incorrect predictions.

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 Pn(w)f(w)0.75P_n(w) \propto f(w)^{0.75}

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 1/V1/V 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:

Pn(w)=f(w)0.75wf(w)0.75P_n(w) = \frac{f(w)^{0.75}}{\sum_{w'} f(w')^{0.75}}

where:

  • Pn(w)P_n(w): probability of sampling word ww as a negative sample
  • f(w)f(w): frequency (count) of word ww in the corpus
  • f(w)0.75f(w)^{0.75}: the frequency raised to the power 0.75, which compresses the range
  • wf(w)0.75\sum_{w'} f(w')^{0.75}: normalizing constant summing over all vocabulary words ww'
  • 0.750.75: 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, 100000.75/100.7517810000^{0.75} / 10^{0.75} \approx 178. The ratio shrinks from 1000:1 to about 178:1, boosting the representation of rare words without ignoring frequency entirely.

In[13]:
Code
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)
Out[14]:
Console
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.

Out[15]:
Visualization
Bar chart on log scale showing raw frequency probabilities dominated by high-frequency words.
Raw frequency sampling distribution. Common words like 'the' account for over 40% of samples, dominating training signal.
Bar chart on log scale showing smoothed probabilities with reduced gap between frequent and rare words.
Raw frequency sampling distribution. Common words like 'the' account for over 40% of samples, dominating training signal.
Bar chart showing ratio of smoothed to raw probability, with green bars for rare words and red bars for frequent words.
Raw frequency sampling distribution. Common words like 'the' account for over 40% of samples, dominating 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.

Out[16]:
Visualization
Line plot on log scale showing sampling probabilities for each word under five different exponents from 0.0 to 1.0.
Effect of the smoothing exponent on sampling probability across words sorted by frequency. As the exponent decreases from 1.0 (raw frequency, orange) toward 0.0 (uniform, purple), the distribution flattens. The chosen exponent 0.75 (green) reduces the gap between frequent and rare words while still respecting the overall frequency ordering. The log scale reveals the full range of probabilities across words spanning five orders of magnitude in raw frequency.

How Many Negative Samples? The kk Hyperparameter

The number of negative samples kk 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.

Choosing k

Typical values for kk 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.

In[17]:
Code
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
)
Out[18]:
Console
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.

Out[19]:
Visualization
Log-log scale line plot showing softmax cost increasing steeply with vocabulary while negative sampling remains flat.
Operations per training step for full softmax and negative sampling (k=10) across vocabulary sizes on a log-log scale. Softmax operations grow proportionally with vocabulary size while negative sampling requires a constant 11 operations regardless of vocabulary size.
Bar chart showing speedup factor increasing from hundreds to tens of thousands as vocabulary size grows.
Operations per training step for full softmax and negative sampling (k=10) across vocabulary sizes on a log-log scale. Softmax operations grow proportionally with vocabulary size while negative sampling requires a constant 11 operations regardless of vocabulary size.

The trade-off between the number of negative samples and computational cost is linear: doubling kk doubles the work per training step. But the relationship between kk and embedding quality is sublinear, with diminishing returns as kk increases. The sweet spot at k=5k = 5 to 1515 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.

Noise Contrastive Estimation (NCE)

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 Pd(wc)P_d(w | c) (context words given center) and negative samples from a known noise distribution Pn(w)P_n(w), then trains a classifier to distinguish them.

Since we draw 1 data sample and kk noise samples, the prior odds are 1:k1 : k. The Bayes-optimal decision rule becomes:

P(D=1w,c)=Pd(wc)Pd(wc)+kPn(w)P(D = 1 | w, c) = \frac{P_d(w | c)}{P_d(w | c) + k \cdot P_n(w)}

where:

  • P(D=1w,c)P(D = 1 | w, c): posterior probability that sample ww came from the data distribution given context cc
  • Pd(wc)P_d(w | c): true data distribution (probability of observing word ww in the context of word cc)
  • Pn(w)P_n(w): noise distribution probability for word ww (assumed independent of context)
  • kk: number of noise samples drawn per data sample
  • kPn(w)k \cdot P_n(w): accounts for the kk-fold higher chance of seeing noise samples

NCE models this probability using a learnable score function:

P(D=1w,c)=exp(s(w,c))exp(s(w,c))+kPn(w)P(D = 1 | w, c) = \frac{\exp(s(w, c))}{\exp(s(w, c)) + k \cdot P_n(w)}

where:

  • s(w,c)s(w, c): the model's unnormalized log-probability score for word pair (w,c)(w, c), typically the dot product wc\mathbf{w}' \cdot \mathbf{c}
  • exp(s(w,c))\exp(s(w, c)): 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 exp(s(w,c))Pd(wc)\exp(s(w, c)) \propto P_d(w | c). 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 exp(s(w,c))\exp(s(w, c)) 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 σ(s(w,c))\sigma(s(w, c)). 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.

In[20]:
Code
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]
Out[21]:
Visualization
Line plot comparing NCE probability curve with negative sampling sigmoid curve, showing close alignment in the middle range.
Comparison of NCE and negative sampling probability functions over the score range [-4, 4]. NCE (blue) computes the probability by dividing the model score by the sum of score and noise contribution, explicitly accounting for the noise distribution P_n and the k-to-1 noise-to-data ratio. Negative sampling (orange dashed) uses the simpler sigmoid function. The two curves closely track each other in the central region where most training examples fall, explaining why the NCE simplification preserves embedding quality despite discarding theoretical guarantees.

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 O(logV)O(\log V) per sample after O(V)O(V) preprocessing.

In[22]:
Code
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)
Out[23]:
Console
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
Out[24]:
Visualization
Grouped bar chart comparing expected and observed sampling probabilities for each vocabulary word.
Verification that the negative sampler produces the intended distribution. The chart compares expected probabilities (from the smoothed unigram distribution) against observed sampling frequencies from 1000 batches of 5 samples each. The close alignment between expected and observed values confirms the implementation is correct, with small deviations attributable to sampling noise across the 5000 total draws.

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 WW and context embeddings WW'), performs forward and backward passes, and updates weights via stochastic gradient ascent on the negative sampling objective.

In[25]:
Code
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:

In[26]:
Code
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)
Out[27]:
Console
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
In[28]:
Code
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)
Out[29]:
Console
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.

Out[30]:
Visualization
Line plot with shading showing rapidly decreasing training loss that flattens after epoch 30, with start and end values annotated.
Training loss curve for Skip-gram with negative sampling over 50 epochs. The loss decreases rapidly in early epochs as the model moves embeddings from their random initialization into positions that reflect co-occurrence patterns. The plateau around epoch 30 indicates convergence, after which additional training provides diminishing returns. The final loss reflects how confidently the model discriminates real context pairs from sampled negatives.

Evaluating the Learned Embeddings

With the model trained, we can examine whether it has captured meaningful semantic relationships:

In[31]:
Code
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
        ]
Out[32]:
Console
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.

Out[33]:
Visualization
Scatter plot of 2D PCA-projected word embeddings with color-coded semantic groups showing visible cluster formation.
Two-dimensional PCA projection of word embeddings learned with negative sampling. Words from the same semantic category tend to cluster together: royalty terms (purple), people terms (blue), animal terms (red), emotion terms (orange), and movement terms (green). The spatial separation between clusters demonstrates that the binary classification objective causes semantically related words to occupy nearby regions of the embedding space, even though no explicit category labels were provided during training.

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:

In[34]:
Code
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))
Out[35]:
Visualization
Line plot showing decreasing loss curves for both full softmax and negative sampling over 50 epochs.
Training loss over 50 epochs for full softmax (orange) and negative sampling (blue). The objectives are on different scales because they measure different things: cross-entropy over the vocabulary versus binary classification confidence.
Bar chart comparing within-group cosine similarity for full softmax and negative sampling, showing similar values.
Training loss over 50 epochs for full softmax (orange) and negative sampling (blue). The objectives are on different scales because they measure different things: cross-entropy over the vocabulary versus binary classification confidence.

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:

P(keep w)=tf(w)+tf(w)P(\text{keep } w) = \sqrt{\frac{t}{f(w)}} + \frac{t}{f(w)}

where:

  • P(keep w)P(\text{keep } w): probability of keeping an occurrence of word ww during training
  • f(w)f(w): relative frequency of word ww in the corpus, computed as count(w)(w) / total word count
  • tt: subsampling threshold, typically 10510^{-5}
  • t/f(w)\sqrt{t/f(w)}: dominant term for very frequent words, which provides aggressive subsampling
  • t/f(w)t/f(w): additive term that prevents the probability from dropping too sharply

The formula has an intuitive interpretation: when f(w)tf(w) \gg t (very frequent words), P(keep w)t/f(w)P(\text{keep } w) \approx \sqrt{t/f(w)}, which is much less than 1. If "the" has relative frequency f=0.05f = 0.05 and t=105t = 10^{-5}, then P(keep)105/0.050.014P(\text{keep}) \approx \sqrt{10^{-5}/0.05} \approx 0.014, meaning only about 1.4% of "the" occurrences are kept. When f(w)tf(w) \leq t (rare words), the formula yields values above 1, which are clamped to 1. This ensures rare words always contribute.

In[36]:
Code
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()
}
Out[37]:
Console
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 kk and α\alpha require tuning. The defaults from the original paper (k=5 to 10, α\alpha=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 O(V)O(V) to O(k)O(k) 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 Pn(w)f(w)0.75P_n(w) \propto f(w)^{0.75} balances representation between frequent and rare words better than either uniform or raw frequency sampling.
  • Number of negatives (kk): 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 O(V)O(V) to O(logV)O(\log V) 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

Question 1 of 80 of 8 completed
What is the main computational bottleneck that negative sampling addresses in the Skip-gram model?

Comments

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

Reference

Citation details

Cite or share this article.

BIBTEXAcademic
@misc{brenndoerfer2025negativesampling, author = {Michael Brenndoerfer}, title = {Negative Sampling: Efficient Word2Vec Training}, year = {2025}, url = {https://mbrenndoerfer.com/writing/negative-sampling-word-embeddings}, organization = {mbrenndoerfer.com}, note = {Accessed: 2026-09-15} }
APAAcademic
Michael Brenndoerfer (2025). Negative Sampling: Efficient Word2Vec Training. Retrieved from https://mbrenndoerfer.com/writing/negative-sampling-word-embeddings
MLAAcademic
Michael Brenndoerfer. "Negative Sampling: Efficient Word2Vec Training." 2026. Web. September 15, 2026. <https://mbrenndoerfer.com/writing/negative-sampling-word-embeddings>.
CHICAGOAcademic
Michael Brenndoerfer. "Negative Sampling: Efficient Word2Vec Training." Accessed September 15, 2026. https://mbrenndoerfer.com/writing/negative-sampling-word-embeddings.
HARVARDAcademic
Michael Brenndoerfer (2025) 'Negative Sampling: Efficient Word2Vec Training'. Available at: https://mbrenndoerfer.com/writing/negative-sampling-word-embeddings (Accessed: September 15, 2026).
SimpleBasic
Michael Brenndoerfer (2025). Negative Sampling: Efficient Word2Vec Training. https://mbrenndoerfer.com/writing/negative-sampling-word-embeddings

About the author

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 Handbook
Newsletter

Stay up to date

Get articles, book updates, and news delivered to your inbox.

No spam, unsubscribe anytime.

or

Join the community

Sign in to remove popups, track your reading progress, and join the discussion.