Hierarchical Softmax

Michael BrenndoerferUpdated February 8, 202653 min read

Part of Language AI Handbook

Explains how hierarchical softmax reduces word embedding training from O(V) to O(log V) using Huffman-coded binary trees, path probabilities.

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

Hierarchical Softmax

The skip-gram model learns powerful word representations, but it carries a serious computational burden: the softmax denominator. Every training step requires summing over every word in the vocabulary to normalize probabilities. For a vocabulary of 100,000 words, that means 100,000 dot products and exponentials for a single prediction. With billions of training examples, standard softmax makes large-scale word embedding training impractically slow.

Hierarchical softmax solves this problem by reorganizing how we think about word prediction. Instead of choosing among all words at once, we arrange words as the leaves of a binary tree and decompose each prediction into a sequence of binary decisions. To predict any word, we trace a path from the root to that word's leaf, multiplying the probabilities of turning left or right at each internal node. This reduces per-step complexity from O(V)O(V) to O(logV)O(\log V), a transformation that makes training on large vocabularies tractable.

The elegance of hierarchical softmax lies in what it preserves alongside what it eliminates. Standard softmax computes a true probability distribution: all word probabilities sum to exactly 1. Hierarchical softmax achieves the same guarantee through a completely different mechanism. By ensuring that the left and right probabilities at every internal node sum to 1, the tree guarantees by induction that the total probability mass across all leaves also sums to 1. You get the mathematical correctness of a full probability distribution without paying the O(V)O(V) cost to compute it.

This chapter develops hierarchical softmax from the ground up. We begin with the computational problem that motivates it, then build intuition for how a binary tree decomposition works, derive the path probability formula and its gradients, implement a complete hierarchical softmax layer in NumPy, and finally compare the approach against negative sampling to understand when each technique is the right choice.

The Problem: Expensive Normalization

To understand why we need hierarchical softmax, we must first appreciate how expensive standard softmax really is. For a center word wcw_c with input embedding h\mathbf{h}, the probability of context word wjw_j under standard softmax is:

P(wjwc)=exp(wjh)k=1Vexp(wkh)P(w_j | w_c) = \frac{\exp(\mathbf{w}'_j \cdot \mathbf{h})}{\sum_{k=1}^{V} \exp(\mathbf{w}'_k \cdot \mathbf{h})}

where:

  • P(wjwc)P(w_j | w_c): probability of context word wjw_j given center word wcw_c
  • wj\mathbf{w}'_j: output embedding vector for word wjw_j
  • h\mathbf{h}: the input embedding of the center word wcw_c
  • VV: total vocabulary size

The denominator is called the partition function, or normalization constant. It ensures the output is a valid probability distribution, but computing it requires evaluating exp(wkh)\exp(\mathbf{w}'_k \cdot \mathbf{h}) for every single word kk in the vocabulary, then summing all VV results. This is a full scan of the output embedding matrix, and it happens at every single training step.

To understand why this is so costly, consider the training scale that makes word embeddings useful. A typical word2vec training run processes two to ten billion word pairs. At each step, for each pair, we need to compute dot products with all VV output vectors to form the denominator. For a vocabulary of 100,000 words with embedding dimension 300, each softmax denominator requires 100,000 vector dot products of length 300, totaling 30 million multiplications and additions. Multiply by billions of training steps and the arithmetic quickly becomes untenable.

The problem is not just raw computation. The O(V)O(V) cost also creates a bottleneck that prevents parallelism from helping as much as it otherwise would. Even with multiple GPUs, the bottleneck of the full vocabulary scan limits how fast training can proceed. Approximation methods that reduce this cost per step are therefore not mere optimizations: they are what makes large-scale word embedding training feasible at all.

In[4]:
Code
import time

import numpy as np


def standard_softmax_cost(vocab_size, embedding_dim, num_samples=1000):
    """Measure the average time for one standard softmax computation."""
    h = np.random.randn(embedding_dim)
    W_prime = np.random.randn(vocab_size, embedding_dim)

    start = time.time()
    for _ in range(num_samples):
        z = W_prime @ h
        exp_z = np.exp(z - np.max(z))
        probs = exp_z / np.sum(exp_z)
    elapsed = time.time() - start

    return elapsed / num_samples * 1000  # ms per step


vocab_sizes = [1_000, 10_000, 50_000, 100_000]
softmax_times = {V: standard_softmax_cost(V, 100) for V in vocab_sizes}
Out[5]:
Console
Standard Softmax Computation Time per Training Step:
------------------------------------------------
 Vocabulary Size      Time (ms)
------------------------------------------------
           1,000          0.024
          10,000          0.149
          50,000          0.620
         100,000          1.144

The timing confirms the linear scaling: doubling vocabulary size roughly doubles computation time. At 100,000 words, each training step is already expensive. Scale this up to billions of examples, and the arithmetic becomes sobering. We need an approach that reduces per-step cost without sacrificing the quality of learned embeddings.

Two main approaches emerged to solve this problem. Negative sampling, covered in a companion chapter, reframes training as binary classification and sidesteps normalization entirely. Hierarchical softmax takes a more principled route: it keeps the probabilistic interpretation but restructures the computation so that normalization becomes implicit rather than explicit, and local rather than global.

Out[6]:
Visualization
Log-log plot with two curves: red O(V) line and green O(log V) line, showing the widening gap.
Comparison of O(V) linear scaling (standard softmax, red) versus O(log V) logarithmic scaling (hierarchical softmax, green) across vocabulary sizes from 100 to 1,000,000. The logarithmic curve remains nearly flat while the linear curve grows without bound, illustrating the fundamental advantage of the tree-based decomposition.
Semi-log plot showing speedup factor growing from about 150x at 1000 words to over 6000x at 100,000 words.
Speedup factor of hierarchical softmax over standard softmax, defined as V divided by log2(V). At a vocabulary of 100,000 words, hierarchical softmax requires roughly 6,000 fewer operations per training step, translating directly into faster training on real corpora.

At 100,000 words, hierarchical softmax achieves thousands of times fewer operations per prediction. This is not a marginal improvement. It is the difference between a training run that completes in hours and one that takes months.

The Binary Tree Structure

The core of hierarchical softmax is an organizational insight: we place all VV vocabulary words as the leaves of a binary tree. Every internal node (non-leaf) holds a learned vector parameter. To predict a word, we trace the unique path from the root to that word's leaf, making a binary left-or-right decision at each internal node. The probability of the word is the product of probabilities assigned to each correct turn along that path.

Hierarchical Softmax

Hierarchical softmax represents the vocabulary as leaves of a binary tree. The probability of a word is computed as the product of binary decisions along the root-to-leaf path. Each internal node has a learned vector that parameterizes its left-right decision. This decomposes one VV-way classification into approximately log2V\log_2 V binary classifications.

The binary decomposition works because of a fundamental property of binary trees: exactly one path exists from the root to each leaf. This uniqueness guarantees that we define a valid probability distribution over all vocabulary words. The probabilities at each internal node form complementary pairs (left and right always sum to 1), so summing across all leaf paths always yields exactly 1. This is the hidden magic of the approach: you never have to compute the partition function because the partition function is implicitly built into the tree structure itself.

To see why this is the case, consider what happens at the root. The root assigns some probability pp to turning left and 1p1-p to turning right. The left subtree distributes its entire probability mass pp across all words in that subtree, and the right subtree distributes 1p1-p across all words in its subtree. At every subsequent level, the same splitting occurs. By the time probability mass reaches the leaves, it has been divided exactly once per word (since each word occupies exactly one leaf), and the total across all leaves is still exactly 1. No separate normalization step is ever needed.

Consider a vocabulary of eight words in a balanced tree of depth 3. Predicting "dog" requires three binary decisions. Compare this to standard softmax on 8 words, which requires 8 exponentials. For 100,000 words, the tree has depth log2(100,000)17\log_2(100{,}000) \approx 17, so we need only 17 operations instead of 100,000. The tree converts a global normalization problem into a sequence of local binary classification problems, each of which requires only a single sigmoid computation.

Out[7]:
Visualization
Binary tree diagram with 8 leaves. Red path highlights the route from root to the leaf labeled 'dog'.
Hierarchical softmax binary tree with 8 vocabulary words as leaves. The red highlighted path shows the route to predict ''dog'': left at the root, right at the second node, right again at the third. Blue circles are internal nodes with learned vectors; the green leaf is the target word. Only 3 sigmoid computations are needed instead of 8 exponentials, illustrating the core efficiency gain.

The path in red requires three decisions. Each decision has a learned vector and uses the sigmoid function to produce a probability. The final word probability is the product of all three sigmoid outputs. Every other word in the vocabulary can be reached by a different sequence of left-right choices from the same root, and their probabilities are computed in exactly the same way along their respective paths. The total probability distributed to all eight leaves always sums to 1, regardless of the current values of the node vectors.

Huffman Coding: Optimizing the Tree

Not all trees are created equal. A randomly organized binary tree or a perfectly balanced one assigns paths of similar length to all words, giving every word the same computational cost per prediction. But natural language has a vastly skewed word frequency distribution: the word "the" appears billions of times in any large corpus, while "evanescent" might appear thousands of times and "mellifluous" perhaps hundreds. If we assign "the" a short path and "evanescent" a long path, frequent words get cheaper predictions on average, and the total computational cost weighted by training frequency drops substantially.

Huffman coding constructs precisely this kind of frequency-optimized tree. The algorithm builds the tree from the bottom up, starting with all individual words and repeatedly merging the two lowest-frequency nodes into a parent. Because low-frequency nodes are always merged first, high-frequency words end up closest to the root and receive the shortest paths.

Huffman Coding

Huffman coding is a greedy algorithm for constructing an optimal prefix-free binary tree. Given symbol frequencies, it repeatedly merges the two lowest-frequency nodes into a combined parent node, assigning that parent the sum of their frequencies. The resulting tree minimizes the expected code length when each symbol's code is its root-to-leaf path.

The algorithm is straightforward to implement:

  1. Place each word (with its frequency) into a priority queue ordered by frequency ascending.
  2. Extract the two lowest-frequency entries.
  3. Create a new parent node whose frequency is the sum of the two.
  4. Insert the parent node back into the queue.
  5. Repeat until only one node remains. That node is the root.

The reason this greedy approach is optimal deserves a moment of reflection. When we merge the two least-frequent nodes at each step, we are pushing them one level deeper in the tree. This is always safe because they are the least-frequent words, so they will be visited least often during training. Any other pair of nodes we could have chosen to merge would have resulted in a worse expected path length, because we would be pushing more frequently needed words deeper. The greedy strategy of always merging the two cheapest nodes turns out to produce the globally optimal tree.

The final expected path length under Huffman coding equals the entropy of the word frequency distribution, which is the theoretical minimum for any prefix-free binary tree. No other arrangement can achieve a shorter average path length. This is a well-known result from information theory: Huffman codes achieve Shannon's lower bound on average code length.

In[8]:
Code
import heapq
from dataclasses import dataclass
from typing import Optional


@dataclass
class HuffmanNode:
    """A node in the Huffman tree."""

    freq: int
    word: Optional[str] = None
    left: Optional["HuffmanNode"] = None
    right: Optional["HuffmanNode"] = None

    # Comparison uses freq only
    def __lt__(self, other):
        return self.freq < other.freq


def build_huffman_tree(word_freq: dict) -> HuffmanNode:
    """Build a Huffman tree from a word frequency dictionary."""
    heap = [HuffmanNode(freq=f, word=w) for w, f in word_freq.items()]
    heapq.heapify(heap)

    while len(heap) > 1:
        left = heapq.heappop(heap)
        right = heapq.heappop(heap)
        parent = HuffmanNode(
            freq=left.freq + right.freq,
            left=left,
            right=right,
        )
        heapq.heappush(heap, parent)

    return heap[0]  # root


def get_paths(root: HuffmanNode) -> dict:
    """Extract root-to-leaf paths for all words."""
    paths = {}

    def traverse(node, path, directions):
        if node.word is not None:  # leaf
            paths[node.word] = {
                "path_length": len(directions),
                "directions": directions,
            }
            return
        if node.left:
            traverse(node.left, path + [node], directions + [1])
        if node.right:
            traverse(node.right, path + [node], directions + [-1])

    traverse(root, [], [])
    return paths


# Build a small example vocabulary with realistic frequency skew
word_freq = {
    "the": 5000,
    "a": 4000,
    "is": 3000,
    "in": 2500,
    "cat": 500,
    "dog": 450,
    "run": 300,
    "jump": 200,
    "evanescent": 20,
    "sycophant": 15,
    "ephemeral": 10,
    "mellifluous": 5,
}

root = build_huffman_tree(word_freq)
paths = get_paths(root)
Out[9]:
Console
Huffman Tree Path Lengths:
---------------------------------------------
           Word  Frequency  Path Length
---------------------------------------------
             is      3,000            2
              a      4,000            2
            the      5,000            2
             in      2,500            3
            run        300            5
            dog        450            5
            cat        500            5
           jump        200            6
     evanescent         20            7
      sycophant         15            8
    mellifluous          5            9
      ephemeral         10            9

Expected path length:  2.46 nodes
Entropy lower bound:   2.42 bits
Balanced tree depth:   4.00 levels

Huffman coding assigns short paths to frequent words and long paths to rare words. The expected path length is close to the entropy of the frequency distribution, confirming the tree is nearly optimal. Frequent words like "the" and "a" get paths of length 2 to 3, while rare words like "mellifluous" get paths of 4 to 5. The average cost per training step is therefore much lower than a balanced tree would provide.

The connection to entropy is worth pausing on. Shannon entropy measures the average uncertainty in a random variable. A word frequency distribution with high entropy (many words with similar frequencies) will require a deeper Huffman tree because no words can be given especially short paths without penalizing others. A low-entropy distribution (dominated by a few very frequent words) allows the Huffman tree to put those words near the root and push the rare words far down, achieving dramatic savings in expected path length. Natural language frequency distributions are extremely low-entropy due to Zipf's law, which makes Huffman coding especially effective.

Out[10]:
Visualization
Bar chart showing path lengths for 12 words sorted by decreasing frequency, with shorter bars for frequent words.
Path lengths in a Huffman tree for a 12-word vocabulary with frequency-skewed distribution. Frequent words (left, blue) have shorter paths and thus cheaper probability computations. Rare words (right, red) have longer paths but are encountered far less often during training, keeping the weighted average path length near the theoretical entropy lower bound.

Path Probability: The Mathematics

With the tree structure defined, we can now write the probability formula precisely. The key insight is that the probability of a word equals the product of probabilities of all correct binary decisions along its path from root to leaf.

Binary Decisions with Learned Vectors

Each internal node nn has a learned vector vn\mathbf{v}_n of the same dimension as the word embeddings. To make the left-right decision at node nn, we compare this node vector with the current context embedding h\mathbf{h} using a dot product, then pass the result through the sigmoid function:

σ(vnh)=11+exp(vnh)\sigma(\mathbf{v}_n \cdot \mathbf{h}) = \frac{1}{1 + \exp(-\mathbf{v}_n \cdot \mathbf{h})}

where:

  • σ()\sigma(\cdot): the sigmoid function, mapping any real number to (0,1)(0, 1)
  • vn\mathbf{v}_n: the learned vector at internal node nn
  • h\mathbf{h}: the context embedding (center word's input embedding)

This output gives the probability of going left. The probability of going right is 1σ(vnh)=σ(vnh)1 - \sigma(\mathbf{v}_n \cdot \mathbf{h}) = \sigma(-\mathbf{v}_n \cdot \mathbf{h}). These two always sum to 1, which is what ensures the tree defines a valid probability distribution.

Why the sigmoid function specifically? The sigmoid maps real numbers to the interval (0,1)(0, 1), making it a natural candidate for probabilities. More importantly, the symmetry property σ(x)=1σ(x)\sigma(-x) = 1 - \sigma(x) means we can encode both the "go left" and "go right" probabilities using a single dot product and a sign flip. This symmetry is exploited compactly in the path probability formula.

The Path Probability Formula

The probability of word ww given context embedding h\mathbf{h} is the product of probabilities at each decision point along the root-to-leaf path:

P(wh)=j=1L(w)1σ ⁣(djvn(w,j)h)P(w | \mathbf{h}) = \prod_{j=1}^{L(w)-1} \sigma\!\left( d_j \cdot \mathbf{v}_{n(w,j)} \cdot \mathbf{h} \right)

where:

  • L(w)L(w): the total number of nodes along the path from root to word ww (including root and leaf)
  • n(w,j)n(w, j): the jj-th node along the path to ww, with n(w,1)n(w, 1) being the root
  • dj{+1,1}d_j \in \{+1, -1\}: the direction code at step jj, equal to +1+1 if the next node is the left child and 1-1 if it is the right child
  • vn(w,j)\mathbf{v}_{n(w,j)}: the learned vector at the jj-th internal node
  • h\mathbf{h}: the context embedding

The direction encoding djd_j is the key mechanism. When dj=+1d_j = +1 (go left), we compute σ(vh)\sigma(\mathbf{v} \cdot \mathbf{h}), which is high when the dot product is positive. When dj=1d_j = -1 (go right), we compute σ(vh)\sigma(-\mathbf{v} \cdot \mathbf{h}), which is the complementary probability. This compact encoding lets a single formula handle both directions without needing two separate parameters per node.

Why This Defines a Valid Probability Distribution

For hierarchical softmax to be valid, all leaf probabilities must sum to 1. This holds because at each internal node, the probabilities of its two children are σ(vh)\sigma(\mathbf{v} \cdot \mathbf{h}) and 1σ(vh)1 - \sigma(\mathbf{v} \cdot \mathbf{h}), which sum to 1. By induction starting from the root, the probability mass is conserved at every level and distributed exactly across all leaves.

Why Probabilities Can Be Small

For a vocabulary of 100,000 words, a Huffman tree assigns paths of roughly 15 to 20 nodes. The final probability is a product of 15 to 20 sigmoid values, each between 0 and 1. Even if each step has probability 0.8, the product of 17 steps is 0.8170.02250.8^{17} \approx 0.0225. This is expected behavior: 100,000 words share probability mass, so individual word probabilities must be small. The model is not failing; it is correctly distributing probability across a large vocabulary.

Working in log space helps numerically. Instead of multiplying probabilities (which can underflow to zero for long paths), we sum log-probabilities. This is numerically stable and is the natural form for the loss function we will derive below.

In[11]:
Code
import numpy as np


def sigmoid(x):
    """Numerically stable sigmoid."""
    return np.where(x >= 0, 1 / (1 + np.exp(-x)), np.exp(x) / (1 + np.exp(x)))


def compute_path_probability(h, path_vectors, path_directions):
    """
    Compute P(w | h) as the product of binary decisions along the path.

    Args:
        h: Context embedding (embedding_dim,)
        path_vectors: Internal node vectors along the path
        path_directions: +1 for left, -1 for right at each node

    Returns:
        Word probability and per-step probabilities
    """
    step_probs = []
    for v, d in zip(path_vectors, path_directions):
        step_prob = sigmoid(d * np.dot(v, h))
        step_probs.append(float(step_prob))

    word_prob = float(np.prod(step_probs))
    return word_prob, step_probs


np.random.seed(42)
dim = 50
h = np.random.randn(dim) * 0.3

# Simulate a path of length 4 (small vocabulary example)
path_vectors = [np.random.randn(dim) * 0.3 for _ in range(4)]
path_directions = [1, -1, -1, 1]  # left, right, right, left

word_prob, step_probs = compute_path_probability(
    h, path_vectors, path_directions
)
Out[12]:
Console
Path Probability Step-by-Step:
-----------------------------------------------------------------
 Step Direction  Dot Product  Step Prob   Cumulative
-----------------------------------------------------------------
    1      left       0.3782     0.5934     0.593443
    2     right      -0.4852     0.6190     0.367330
    3     right       0.3082     0.4235     0.155581
    4      left       1.3432     0.7930     0.123377
-----------------------------------------------------------------
Final P(word | h) = 0.123377

For comparison: 1 / vocab_size (8 words) = 0.125000

The output shows how each binary decision multiplies into the running probability. The final value is close to 1/80.1251/8 \approx 0.125 for a perfectly balanced 8-leaf tree, which is the expected word probability when the model assigns equal mass to all words. As training proceeds, the model adjusts node vectors so that these probabilities increase for observed word pairs and decrease for unobserved ones.

The Training Objective

The hierarchical softmax objective follows directly from maximum likelihood. We want to maximize the probability of the observed (center, context) pairs in the training corpus. Taking the log converts products into sums, which is numerically more stable and mathematically easier to differentiate:

L=(wc,wo)corpuslogP(wohwc)\mathcal{L} = \sum_{(w_c, w_o) \in \text{corpus}} \log P(w_o | \mathbf{h}_{w_c})

where:

  • wcw_c: the center word
  • wow_o: the observed context word
  • hwc\mathbf{h}_{w_c}: the input embedding of center word wcw_c
  • P(wohwc)P(w_o | \mathbf{h}_{w_c}): computed as the product of path probabilities

Substituting the path probability formula and applying the logarithm:

logP(woh)=j=1L(wo)1logσ ⁣(djvn(wo,j)h)\log P(w_o | \mathbf{h}) = \sum_{j=1}^{L(w_o)-1} \log \sigma\!\left( d_j \cdot \mathbf{v}_{n(w_o, j)} \cdot \mathbf{h} \right)

where:

  • logP(woh)\log P(w_o | \mathbf{h}): log-probability of context word wow_o
  • djd_j: direction code at step jj along the path to wow_o
  • vn(wo,j)\mathbf{v}_{n(w_o, j)}: the learned node vector at step jj

The product of probabilities becomes a sum of log-probabilities, which eliminates the risk of underflow for long paths. Converting the maximization to minimization (for gradient descent), we minimize the negative log-likelihood:

J=j=1L(wo)1logσ ⁣(djvn(wo,j)h)\mathcal{J} = -\sum_{j=1}^{L(w_o)-1} \log \sigma\!\left( d_j \cdot \mathbf{v}_{n(w_o, j)} \cdot \mathbf{h} \right)

This loss has a pleasant interpretation that makes the training dynamics intuitive. Each term logσ(djvjh)-\log \sigma(d_j \cdot \mathbf{v}_j \cdot \mathbf{h}) is the binary cross-entropy loss for a single binary classifier at node jj. The "correct label" at each node is determined by the direction djd_j: if we should go left, the correct output is σ>0.5\sigma > 0.5, which corresponds to a positive dot product. If we should go right, the correct output is σ<0.5\sigma < 0.5, corresponding to a negative dot product. The loss penalizes the model whenever any of these binary classifiers makes a wrong or uncertain decision.

Training pushes the model to make each binary decision more confidently correct. Concretely, for each node on the path to the target word, the node vector vj\mathbf{v}_j and the context embedding h\mathbf{h} are adjusted so that their dot product has the correct sign and magnitude. After sufficient training, the model should be able to traverse the tree decisively, assigning high probability to each correct turn and arriving at the target leaf with high confidence.

Gradient Computation Along Paths

The gradients needed for training involve two sets of parameters: the node vectors {vn}\{\mathbf{v}_n\} at internal nodes, and the input embeddings h\mathbf{h} (which is the embedding of the center word, and also affects embeddings of context words in the general formulation).

Gradient for a Single Node Vector

For a single internal node njn_j at step jj along the path, the loss contribution is:

Jj=logσ(djvjh)\mathcal{J}_j = -\log \sigma(d_j \cdot \mathbf{v}_j \cdot \mathbf{h})

Let sj=djvjhs_j = d_j \cdot \mathbf{v}_j \cdot \mathbf{h} denote the signed dot product at step jj. Then Jj=logσ(sj)\mathcal{J}_j = -\log \sigma(s_j).

Using the identity ddxlogσ(x)=1σ(x)=σ(x)\frac{d}{dx}\log \sigma(x) = 1 - \sigma(x) = \sigma(-x), we can derive the gradient of Jj\mathcal{J}_j with respect to the node vector vj\mathbf{v}_j by applying the chain rule:

Jjvj=vjlogσ(sj)=(1σ(sj))sjvj=(1σ(sj))djh=(σ(sj)1)djh\begin{aligned} \frac{\partial \mathcal{J}_j}{\partial \mathbf{v}_j} &= -\frac{\partial}{\partial \mathbf{v}_j} \log \sigma(s_j) \\ &= -(1 - \sigma(s_j)) \cdot \frac{\partial s_j}{\partial \mathbf{v}_j} \\ &= -(1 - \sigma(s_j)) \cdot d_j \cdot \mathbf{h} \\ &= \left(\sigma(s_j) - 1\right) d_j \mathbf{h} \end{aligned}

where:

  • σ(sj)\sigma(s_j): the current sigmoid output at node jj (the model's current left-probability)
  • 1σ(sj)1 - \sigma(s_j): the error at this node, measuring how far from certainty the current prediction is
  • djd_j: direction code, propagating sign for left vs right
  • h\mathbf{h}: context embedding, which provides the gradient direction in embedding space

When the model makes a correct and confident prediction at node jj (say σ(sj)\sigma(s_j) is close to 1 when dj=+1d_j = +1), the error term σ(sj)1\sigma(s_j) - 1 is near zero and the gradient is small. When the prediction is wrong or uncertain, the error term is large and the gradient drives a substantial update. This is the same feedback mechanism used in logistic regression.

Gradient for the Context Embedding

The gradient of the total path loss with respect to the context embedding h\mathbf{h} accumulates contributions from every node along the path:

Jh=j=1L(wo)1(σ(sj)1)djvj\frac{\partial \mathcal{J}}{\partial \mathbf{h}} = \sum_{j=1}^{L(w_o)-1} \left(\sigma(s_j) - 1\right) d_j \mathbf{v}_j

where the sum runs over all L(wo)1L(w_o) - 1 internal nodes on the path to the target word wow_o.

This gradient tells us how to adjust the center word's embedding. Each internal node on the path contributes a term proportional to its error (σ(sj)1)(\sigma(s_j) - 1), scaled by its direction code and node vector. Only nodes along the specific path to the target word contribute: nodes off the path have zero contribution to this gradient. This locality is what makes training efficient. A single training step for predicting word ww only requires reading and updating log2V\log_2 V node vectors, not all VV word vectors.

The locality also has an important consequence for what the model learns. The center word's embedding is updated in a direction that incorporates the geometry of all the node vectors along the path to the observed context word. This means the embedding is shaped by the cumulative evidence from every binary decision on the path. A single output vector cannot provide this cumulative signal. In practice, this often produces embeddings that capture hierarchical semantic structure, since semantically related words tend to share subtrees in a well-constructed tree.

In[13]:
Code
import numpy as np


def sigmoid(x):
    return np.where(x >= 0, 1 / (1 + np.exp(-x)), np.exp(x) / (1 + np.exp(x)))


def hs_loss_and_gradients(h, path_vectors, path_directions):
    """
    Compute hierarchical softmax loss and gradients.

    Args:
        h: Context embedding (dim,)
        path_vectors: Node vectors along the path
        path_directions: +1 for left, -1 for right

    Returns:
        loss: Negative log-probability
        grad_h: Gradient w.r.t. h
        grad_nodes: List of gradients w.r.t. each node vector
    """
    loss = 0.0
    grad_h = np.zeros_like(h)
    grad_nodes = []

    for v, d in zip(path_vectors, path_directions):
        s = d * np.dot(v, h)
        sig_s = float(sigmoid(s))
        # Loss: -log σ(s)
        loss += -np.log(sig_s + 1e-12)
        # Error at this node: σ(s) - 1
        error = sig_s - 1.0
        # Gradient for node vector v
        grad_v = error * d * h
        grad_nodes.append(grad_v)
        # Accumulate gradient for h
        grad_h += error * d * v

    return loss, grad_h, grad_nodes


np.random.seed(99)
dim = 50
h = np.random.randn(dim) * 0.3
path_vecs = [np.random.randn(dim) * 0.3 for _ in range(4)]
path_dirs = [1, -1, -1, 1]

loss, grad_h, grad_nodes = hs_loss_and_gradients(h, path_vecs, path_dirs)
Out[14]:
Console
Hierarchical Softmax: Loss and Gradients
--------------------------------------------------
Path length:          4 decisions
Total loss:           3.5567
||grad_h||:           2.3153

Per-node gradients:
 Node  Direction   ||grad_v||
--------------------------------
    1       left       1.0372
    2      right       1.4054
    3      right       1.2050
    4       left       1.1646

The gradient norms reveal which nodes are receiving the largest updates. Nodes where the model made more uncertain or wrong decisions (larger error term σ(sj)1|\sigma(s_j) - 1|) receive larger gradient updates. As training progresses, the model learns to make these decisions more confidently, reducing the loss at each node and causing the gradients to shrink toward zero.

A Worked Example: Tracing One Training Step

Let's trace through a complete training step to solidify the mechanics. We use a vocabulary of 12 words from the frequency distribution we built earlier, and predict "dog" as a context word given "the" as the center word.

The training step proceeds as follows. First, we look up the center word "the" in the input embedding matrix to get the context embedding h\mathbf{h}. Then we look up "dog" in the Huffman path table to get its sequence of node indices and directions. We iterate through each node on the path, computing the dot product vjh\mathbf{v}_j \cdot \mathbf{h}, the signed version djvjhd_j \cdot \mathbf{v}_j \cdot \mathbf{h}, and the sigmoid. The loss is the sum of logσ-\log \sigma at each step. Gradients flow back: each node vector gets a small update, and the accumulated gradient is applied to the center word embedding.

In[15]:
Code
import numpy as np


def sigmoid(x):
    return np.where(x >= 0, 1 / (1 + np.exp(-x)), np.exp(x) / (1 + np.exp(x)))


class SimpleHierarchicalSoftmax:
    """Minimal hierarchical softmax implementation for demonstration."""

    def __init__(self, word_freq: dict, embedding_dim: int):
        self.dim = embedding_dim
        # Build Huffman tree and extract paths
        self._build_tree(word_freq)
        # Initialize embeddings
        np.random.seed(0)
        vocab = list(word_freq.keys())
        self.word_to_idx = {w: i for i, w in enumerate(vocab)}
        n_words = len(vocab)
        # Input embeddings (for center words)
        self.W = np.random.randn(n_words, embedding_dim) * 0.01
        # Node vectors (for internal nodes)
        n_internal = n_words - 1
        self.node_vecs = np.random.randn(n_internal, embedding_dim) * 0.01

    def _build_tree(self, word_freq: dict):
        """Build Huffman tree and store paths as (node_indices, directions)."""
        import heapq

        # Build tree nodes
        nodes = [
            (freq, i, {"word": w, "left": None, "right": None, "idx": i})
            for i, (w, freq) in enumerate(word_freq.items())
        ]
        heapq.heapify(nodes)
        next_idx = len(word_freq)
        internal_nodes = {}

        while len(nodes) > 1:
            f1, _, n1 = heapq.heappop(nodes)
            f2, _, n2 = heapq.heappop(nodes)
            parent = {"word": None, "left": n1, "right": n2, "idx": next_idx}
            internal_nodes[next_idx] = parent
            heapq.heappush(nodes, (f1 + f2, next_idx, parent))
            next_idx += 1

        root = nodes[0][2]

        # Extract paths for each word
        self.paths = {}

        def traverse(node, node_ids, directions):
            if node["word"] is not None:
                self.paths[node["word"]] = (node_ids, directions)
                return
            internal_idx = node["idx"] - len(word_freq)
            traverse(node["left"], node_ids + [internal_idx], directions + [1])
            traverse(
                node["right"], node_ids + [internal_idx], directions + [-1]
            )

        traverse(root, [], [])

    def compute_loss(self, center_word: str, context_word: str) -> float:
        """Compute HS loss for one (center, context) pair."""
        h = self.W[self.word_to_idx[center_word]]
        node_ids, directions = self.paths[context_word]
        loss = 0.0
        for nid, d in zip(node_ids, directions):
            s = d * np.dot(self.node_vecs[nid], h)
            loss += -np.log(float(sigmoid(s)) + 1e-12)
        return loss

    def train_step(self, center_word: str, context_word: str, lr: float = 0.1):
        """One SGD update for a (center, context) pair."""
        center_idx = self.word_to_idx[center_word]
        h = self.W[center_idx].copy()
        node_ids, directions = self.paths[context_word]
        grad_h = np.zeros(self.dim)

        for nid, d in zip(node_ids, directions):
            v = self.node_vecs[nid]
            s = d * np.dot(v, h)
            error = float(sigmoid(s)) - 1.0
            grad_h += error * d * v
            self.node_vecs[nid] -= lr * error * d * h

        self.W[center_idx] -= lr * grad_h
        return self.compute_loss(center_word, context_word)


# Create model and run training
hs_model = SimpleHierarchicalSoftmax(word_freq, embedding_dim=16)

center_word = "the"
context_word = "dog"

# Record loss before and after update
loss_before = hs_model.compute_loss(center_word, context_word)
hs_model.train_step(center_word, context_word, lr=0.1)
loss_after = hs_model.compute_loss(center_word, context_word)

# Run 100 steps to observe convergence
losses = [loss_before]
for _ in range(100):
    l = hs_model.train_step(center_word, context_word, lr=0.05)
    losses.append(l)
Out[16]:
Console
Training step: ('the', 'dog')
Path length for 'dog': 5 nodes
Directions: ['right', 'left', 'left', 'right', 'left']

Loss before update: 3.4657
Loss after 1 step:  3.4654
Loss after 100 steps: 0.5013
Loss reduction: 85.5%
Out[17]:
Visualization
Line plot showing decreasing loss over 100 SGD training steps.
Hierarchical softmax loss over 100 training steps for the (center='the', context='dog') word pair. The loss decreases rapidly in early steps as the model quickly learns the correct direction at each binary decision node, then flattens as the binary classifications approach confident predictions near zero error.

The training curve shows the expected pattern. The loss drops sharply in early steps as the model quickly learns the correct direction at each binary decision point. It then levels off as the binary classifiers become increasingly confident, with the error terms σ(sj)1\sigma(s_j) - 1 shrinking toward zero and reducing gradient magnitude accordingly. The final loss asymptotically approaches a lower bound determined by how well the context embedding h\mathbf{h} can encode the information needed to follow the entire path.

Tree Structure Impact on Learning

The structure of the Huffman tree has an important but often overlooked effect on what the model learns. Unlike standard softmax, where all words compete symmetrically in a flat output layer, hierarchical softmax introduces structure through the tree topology. Words that share a subtree share internal node vectors during training, creating implicit parameter coupling between semantically or statistically related words.

Shared Node Vectors

Consider two words, "cat" and "dog", that share a portion of their paths from the root. Their paths might both pass through the same internal node njn_j before diverging. During training, every (center, "cat") pair and every (center, "dog") pair will update the node vector vnj\mathbf{v}_{n_j} at that shared node. This shared training creates a form of parameter coupling: the node vector vnj\mathbf{v}_{n_j} learns to encode a concept that is relevant to distinguishing both "cat" and "dog" from the rest of the vocabulary.

This coupling can be beneficial when semantically related words are placed in the same subtree, because their shared node vectors develop representations that capture the common features of that subtree's word group. In practice, the Huffman tree is constructed purely on frequency information, without any semantic knowledge. Two words with similar frequencies might end up in the same subtree regardless of their semantic relationship. Whether the tree layout happens to align with semantic structure is partly a matter of the frequency distribution and partly luck.

In contrast, nodes near the root are updated by every training example, since all paths pass through them. These root-adjacent nodes accumulate strong, well-trained representations of coarse semantic distinctions shared across the entire vocabulary. Nodes near the leaves are updated only by examples involving the small subtree beneath them, so they encode fine-grained distinctions among small groups of words. The tree effectively implements a hierarchy of abstraction levels, with coarser distinctions near the root and finer distinctions near the leaves.

Frequency Affects Gradient Exposure

Because Huffman trees assign shorter paths to frequent words, those words participate in training with fewer path nodes per occurrence but appear more often overall. A frequent word like "the" might have a path of length 2, so only 2 node vectors get updated per occurrence of "the" in training. A rare word like "ephemeral" with a path of length 5 updates 5 node vectors per occurrence, but it occurs far less often.

This asymmetry means the internal nodes near frequent-word leaves receive much more gradient signal than nodes near rare-word leaves. The tree effectively concentrates learning effort where the data is richest. This is desirable from an optimization perspective: the binary classifiers that matter most for common words get trained most thoroughly, while classifiers for rare words receive lighter but still meaningful training whenever those words appear.

The flip side is that rare words suffer from sparse gradient updates in two compounding ways. First, they appear infrequently in the corpus. Second, even when they do appear, their path nodes might be shared with other rare words that also appear infrequently. As a result, the path nodes for rare words may never converge to confident binary classifiers, leading to less reliable probability estimates for rare words than for frequent ones. This mirrors a general challenge in NLP: rare words are hard to model well regardless of the method.

In[18]:
Code
from collections import defaultdict


def count_node_updates(paths: dict, word_freq: dict, n_epochs: int = 1) -> dict:
    """
    Estimate how many times each node is updated per epoch,
    assuming each word appears proportional to its frequency.
    """
    total_freq = sum(word_freq.values())
    node_updates = defaultdict(float)

    for word, (node_ids, directions) in paths.items():
        freq = word_freq.get(word, 0)
        # Each training step for this word updates all nodes on its path
        for nid in node_ids:
            node_updates[nid] += freq * n_epochs

    return dict(node_updates)


# Get paths from the Huffman model built earlier
node_update_counts = count_node_updates(hs_model.paths, word_freq)
Out[19]:
Console
Node Update Frequency (higher = more gradient signal per epoch):
--------------------------------------------------
 Node ID      Updates   % of Total
--------------------------------------------------
      10       16,000        40.7%
       9        9,000        22.9%
       8        7,000        17.8%
       7        4,000        10.2%
       6        1,500         3.8%
       5          950         2.4%
       4          550         1.4%
       3          250         0.6%

Total node updates per epoch: 39,345
Number of internal nodes: 11

The most frequently updated nodes (those near the root, which lie on paths to frequent words) absorb the most gradient signal. This creates an implicit curriculum: coarse vocabulary distinctions are learned first and most thoroughly, while fine-grained distinctions among rare words are learned more slowly and with less confidence.

Implementation: Complete Hierarchical Softmax Layer

Let's now build a more complete implementation that handles a full training loop over a corpus. This version more closely mirrors how a real word2vec training run would work, building training pairs from a sliding window and running multiple epochs with a decaying learning rate.

The implementation has three main components. First, a HuffmanNode class and build_huffman_tree function to construct the tree and index the internal node vectors. Second, a HierarchicalSoftmaxModel class that holds the input embedding matrix and the node vector table, and exposes forward_loss and train_step methods. Third, a training loop that generates skip-gram pairs from a small corpus and calls train_step for each.

In[20]:
Code
import numpy as np


def sigmoid(x):
    return np.where(x >= 0, 1 / (1 + np.exp(-x)), np.exp(x) / (1 + np.exp(x)))


class HuffmanNode:
    def __init__(self, word=None, freq=0):
        self.word = word
        self.freq = freq
        self.left = None
        self.right = None
        self.index = None  # index into node_vecs array

    def __lt__(self, other):
        return self.freq < other.freq


def build_huffman_tree(word_freq: dict):
    """Build Huffman tree; return root and path lookup."""
    import heapq

    leaves = [HuffmanNode(word=w, freq=f) for w, f in word_freq.items()]
    heap = list(leaves)
    heapq.heapify(heap)

    internal_counter = [0]

    while len(heap) > 1:
        left = heapq.heappop(heap)
        right = heapq.heappop(heap)
        parent = HuffmanNode(freq=left.freq + right.freq)
        parent.index = internal_counter[0]
        internal_counter[0] += 1
        parent.left = left
        parent.right = right
        heapq.heappush(heap, parent)

    root = heap[0]

    # Assign indices to internal nodes via BFS
    from collections import deque

    queue = deque([root])
    idx = 0
    while queue:
        node = queue.popleft()
        if node.word is None:
            node.index = idx
            idx += 1
        if node.left:
            queue.append(node.left)
        if node.right:
            queue.append(node.right)

    # Extract paths
    paths = {}

    def extract_paths(node, node_indices, directions):
        if node.word is not None:
            paths[node.word] = (node_indices, directions)
            return
        extract_paths(node.left, node_indices + [node.index], directions + [1])
        extract_paths(
            node.right, node_indices + [node.index], directions + [-1]
        )

    extract_paths(root, [], [])
    n_internal = idx
    return root, paths, n_internal


class HierarchicalSoftmaxModel:
    """Skip-gram model with hierarchical softmax output layer."""

    def __init__(self, vocab: list, word_freq: dict, dim: int = 50):
        self.dim = dim
        self.vocab = vocab
        self.word_to_idx = {w: i for i, w in enumerate(vocab)}

        # Build Huffman tree
        _, self.paths, n_internal = build_huffman_tree(word_freq)

        # Learnable parameters
        np.random.seed(42)
        n_words = len(vocab)
        self.W_in = np.random.randn(n_words, dim) * 0.01  # input embeddings
        self.W_node = np.random.randn(n_internal, dim) * 0.01  # node vectors

    def forward_loss(self, center_word: str, context_word: str) -> float:
        """Compute loss for one training pair."""
        h = self.W_in[self.word_to_idx[center_word]]
        node_ids, directions = self.paths[context_word]
        loss = 0.0
        for nid, d in zip(node_ids, directions):
            s = d * float(np.dot(self.W_node[nid], h))
            loss -= np.log(float(sigmoid(s)) + 1e-10)
        return loss

    def train_step(
        self, center_word: str, context_word: str, lr: float = 0.025
    ):
        """SGD update for one (center, context) pair."""
        center_idx = self.word_to_idx[center_word]
        h = self.W_in[center_idx].copy()
        node_ids, directions = self.paths[context_word]
        grad_h = np.zeros(self.dim)

        for nid, d in zip(node_ids, directions):
            v = self.W_node[nid]
            s = d * np.dot(v, h)
            err = float(sigmoid(s)) - 1.0
            self.W_node[nid] -= lr * err * d * h
            grad_h += err * d * v

        self.W_in[center_idx] -= lr * grad_h

    def train(self, training_pairs: list, epochs: int = 5, lr: float = 0.025):
        """Train on a list of (center, context) pairs."""
        epoch_losses = []
        for epoch in range(epochs):
            np.random.shuffle(training_pairs)
            total_loss = sum(
                self.forward_loss(c, ctx) for c, ctx in training_pairs
            )
            epoch_losses.append(total_loss / len(training_pairs))
            for center, context in training_pairs:
                self.train_step(center, context, lr)
        return epoch_losses
In[21]:
Code
# Build a small corpus and train
corpus = (
    "the cat sat on the mat the dog ran in the yard "
    "a cat and a dog are animals cats run dogs jump "
    "the quick brown fox jumps over the lazy dog "
    "happy cat happy dog sad cat sad run walk jump "
    "a evanescent sycophant ephemeral mellifluous cat dog run walk"
).split()

# Build vocabulary and training pairs (window size = 2)
word_counts = Counter(corpus)
vocab = list(word_counts.keys())
window_size = 2
training_pairs = []
for i, center in enumerate(corpus):
    for offset in range(-window_size, window_size + 1):
        if offset == 0:
            continue
        j = i + offset
        if 0 <= j < len(corpus):
            training_pairs.append((center, corpus[j]))

model = HierarchicalSoftmaxModel(vocab, word_counts, dim=16)
epoch_losses = model.train(training_pairs, epochs=15, lr=0.05)
Out[22]:
Console
Training Progress:
----------------------------------------
 Epoch     Avg Loss
----------------------------------------
     1       3.2102
     2       3.2100
     3       3.2098
     4       3.2094
     5       3.2087
     6       3.2075
     7       3.2050
     8       3.2005
     9       3.1923
    10       3.1782
    11       3.1570
    12       3.1291
    13       3.0946
    14       3.0529
    15       3.0035

Total training pairs: 198
Vocabulary size: 30
Embedding dim: 16
Out[23]:
Visualization
Line plot of training loss decreasing over 15 epochs.
Training loss per epoch for the hierarchical softmax model on a small corpus. The average negative log-probability decreases steadily across 15 epochs, confirming the model is learning to assign higher probability to observed (center, context) pairs. Early epochs show the fastest improvement as the binary classifiers at each tree node establish the correct sign for their decisions.

The training loss curve decreases consistently across all 15 epochs, indicating the model is successfully learning to traverse the Huffman tree toward observed context words. In a production skip-gram training run, you would also apply learning rate decay over epochs, subsample frequent words like "the" to prevent them from dominating the gradient updates, and use a much larger corpus and vocabulary. The core mechanics demonstrated here, however, are identical to those used in the original word2vec training.

The Role of Node Vectors in Learned Representations

An aspect of hierarchical softmax that distinguishes it from standard softmax is the nature of its learnable parameters. Standard softmax has two parameter matrices: an input embedding matrix WinW_{in} for center words and an output embedding matrix WoutW_{out} for context words. After training, you typically use WinW_{in} as the word representations, or average WinW_{in} and WoutW_{out}.

Hierarchical softmax replaces WoutW_{out} with a matrix of node vectors WnodeW_{node}, one per internal node. These node vectors are not word embeddings in the usual sense: they do not correspond to specific vocabulary words and are not typically used as word representations after training. Their role is purely to parameterize the binary decisions in the tree. Each node vector learns to represent the split between the two halves of the vocabulary it divides, which makes it a kind of "discriminative boundary" rather than a "semantic embedding."

This distinction matters for practical use. After training with hierarchical softmax, the word embeddings you extract are the input embeddings from WinW_{in}. The node vectors serve their purpose during training and are then discarded. Unlike negative sampling, which produces two embedding matrices (both of which can potentially be used as word representations), hierarchical softmax produces only one directly useful embedding matrix.

There is a subtle benefit to this design. Because WinW_{in} is the only embedding matrix updated via the center word gradient, center word embeddings receive a single clear gradient signal from each training pair. In standard softmax and negative sampling, both input and output embeddings are updated for every word involved in a training pair, which can introduce noise. The well-defined separation in hierarchical softmax between "word embeddings" and "node vectors" can lead to more stable training dynamics in some settings.

Hierarchical Softmax vs Negative Sampling

Hierarchical softmax and negative sampling are both approximations that solve the same problem: making softmax tractable at large vocabulary sizes. They take fundamentally different approaches, and each has situations where it performs better.

Structural Comparison

The two approaches differ in what they approximate and how they update parameters.

Hierarchical softmax retains the full probabilistic structure. It still defines a valid probability distribution over all vocabulary words, just computed more efficiently via path products. Every training step updates only the parameters along the target word's path (roughly log2V\log_2 V node vectors plus the center embedding). No other words receive gradient signal during that step.

Negative sampling abandons the normalization requirement entirely and instead frames training as binary classification. For each positive (center, context) pair, we sample kk negative words and train a sigmoid classifier to distinguish real pairs from noise. Each training step updates the k+1k + 1 word embeddings directly, and the model never computes a full probability distribution over all words. The resulting embeddings are useful for semantic tasks even though the model does not define a proper probability distribution.

Comparison of hierarchical softmax and negative sampling.
PropertyHierarchical SoftmaxNegative Sampling
Per-step complexityO(logV)O(\log V)O(k)O(k), kk typically 5-20
Valid probability distributionYesNo
Parameters per steplog2V\approx \log_2 V node vectorsk+1k+1 word vectors
Updates frequent words oftenVia Huffman codingVia sampling distribution
Implementation complexityHigher (tree structure)Lower

When Each Approach Wins

Hierarchical softmax works better when:

  • The vocabulary has a very skewed frequency distribution and Huffman coding provides significant speedup. The more extreme the Zipf distribution, the greater the benefit of shorter paths for frequent words.
  • You need a true probability distribution over words, for tasks like language modeling perplexity evaluation, beam search decoding, or any application where you need to rank or compare probabilities across words.
  • You have a small number of training examples and cannot rely on stochastic negative sampling to converge reliably. Hierarchical softmax provides a more principled objective with lower variance per update.

Negative sampling works better when:

  • You want to learn high-quality embeddings for frequent words specifically. The kk negative samples and the sampling distribution can be tuned to emphasize certain word pairs, giving you control over what the model focuses on.
  • The vocabulary is large but frequencies are relatively flat, reducing Huffman coding's advantage. When all words have similar frequencies, a Huffman tree offers little benefit over a balanced tree.
  • Simplicity of implementation matters, and the probabilistic interpretation of the output is not needed downstream.
  • You are training with very large minibatches where the sampling randomness of negative sampling averages out and the approximation quality approaches that of full softmax.

In practice, negative sampling has become the dominant approach for training word2vec because it tends to produce slightly better embeddings for semantic tasks and is simpler to implement. Hierarchical softmax remains relevant when the probability distribution interpretation matters or when vocabulary frequency is highly skewed.

Out[24]:
Visualization
Line plot comparing O(log V) and O(k) complexity as vocabulary size increases.
Per-step operation count for hierarchical softmax (O(log V), green) versus negative sampling with k=10 (O(k), orange dashed line). For vocabularies above about 1,000 words, negative sampling requires fewer operations per step, explaining its popularity for large-scale embedding training. The crossover point shifts with k: smaller k values favor negative sampling at smaller vocabulary sizes.
Bar chart showing update distribution patterns for each method.
Estimated parameters updated per training step for hierarchical softmax versus negative sampling with k=10 at vocabulary size V=10,000. Hierarchical softmax updates log2(V) node vectors; negative sampling updates k+1 word vectors. For this setting, negative sampling updates fewer parameters per step.

For large vocabularies, negative sampling updates fewer parameters per step and is computationally cheaper. Hierarchical softmax spreads gradient updates through the tree structure, which provides broader vocabulary coverage but requires traversing more nodes per training step.

Key Parameters

The key configuration choices for hierarchical softmax are:

  • Tree construction method: Huffman coding is the standard choice, as it minimizes expected path length by assigning shorter paths to frequent words. A balanced binary tree is simpler to implement but ignores frequency information entirely and performs significantly worse on skewed vocabularies.
  • Embedding dimension: The dimensionality of both input embeddings and node vectors. Typical values are 100 to 300 for word2vec-style models. Larger dimensions can capture more semantic nuance but require more memory and training time. The node vectors must match the embedding dimension.
  • Learning rate: Controls the step size for gradient updates along paths. Typical starting values range from 0.025 to 0.05, often decayed linearly during training. Because hierarchical softmax updates only a small subset of parameters per step, it can tolerate slightly higher learning rates than methods that update all parameters globally.
  • Window size: The context window determines which (center, context) pairs are generated for training. Larger windows (5 to 10 words) capture broader semantic context and tend to produce embeddings useful for topic-level similarity. Smaller windows (1 to 3 words) capture tighter syntactic relationships and often produce embeddings better suited for analogy tasks.
  • Subsampling of frequent words: In practice, very frequent words like "the" and "a" are subsampled during training (randomly discarded from context windows with a probability proportional to their excess frequency). This prevents them from dominating gradient updates and improves embedding quality for less frequent words. Subsampling is equally applicable with hierarchical softmax and negative sampling.

Limitations and Impact

Hierarchical softmax was a critical innovation in making word embedding training practical at scale. When the original word2vec paper was published in 2013, training on billions of words was computationally prohibitive with standard softmax. Hierarchical softmax and negative sampling both enabled this capability, enabling the distributed word representations that transformed natural language processing. Before these approximations, neural language models were typically limited to smaller vocabularies or smaller training corpora, which constrained the quality and coverage of learned embeddings.

Hierarchical softmax has real limitations that have led to negative sampling becoming the more widely adopted technique in subsequent years.

The first and most fundamental limitation is that the tree structure introduces a topological bias into learning. Words that share a subtree share internal node parameters during training. This coupling is not inherently harmful, but it means the model's inductive bias is shaped by the Huffman tree, which is built purely on word frequency without any semantic knowledge. Two semantically related words may end up in completely different subtrees if they happen to have dissimilar frequencies, receiving no parameter sharing. Two unrelated words with similar frequencies may share several internal nodes and receive spurious coupling. This is particularly problematic for tasks that require fine-grained semantic discrimination, where the tree topology introduces noise that negative sampling avoids by treating each word's embedding independently.

The second limitation concerns gradient flow for rare words. During any single training step, only the log2V\log_2 V node vectors on the target word's path receive updates. Rare words that appear infrequently in training data also happen to have paths through nodes that are shared with other rare words, none of which receives many updates. The result is that path nodes for rare words may converge slowly or not at all on small corpora, leading to unreliable probability estimates for infrequent vocabulary items. Negative sampling mitigates this somewhat by using the noise distribution to control how often different words appear as negative examples, allowing some tuning of the attention given to rare words.

The third limitation is implementation complexity. Building the Huffman tree, maintaining a node vector table indexed by tree position, and correctly computing path directions and indices during both forward passes and gradient updates requires substantially more infrastructure than negative sampling. Negative sampling only needs a noise distribution sampler and a lookup table of word embeddings. This simplicity has made negative sampling the default choice in most open-source word embedding libraries, further reinforcing its dominance in practice.

Despite these limitations, hierarchical softmax remains the better choice in specific circumstances. When you need a valid probability distribution over the vocabulary, such as for language model perplexity evaluation or for probability-based ranking of candidate words, hierarchical softmax provides that guarantee while negative sampling does not. When the vocabulary frequency distribution is extremely skewed (which is common in domain-specific corpora where a few terms appear extremely often), Huffman coding provides greater expected path length reduction and more of the computational savings translate to practical speedup. And when you need to guarantee that all vocabulary words can receive gradient signal through the probability model (rather than only through negative sampling's stochastic selection), hierarchical softmax provides that coverage.

The broader impact of hierarchical softmax on NLP should not be underestimated. By enabling fast training of neural word embeddings on internet-scale text, it helped establish word2vec as a foundational tool and demonstrated that simple neural models trained on massive data could outperform carefully engineered linguistic features on many benchmarks. The word embeddings produced with hierarchical softmax in 2013 are essentially indistinguishable in quality from those produced with negative sampling, which means the key contribution was enabling scale, not changing representation quality. That scaling insight, that more data and faster training can substitute for more complex models, proved enormously influential for the field and laid the groundwork for the scaling-first philosophy that drives modern large language model development.

Summary

Hierarchical softmax solves the computational bottleneck of standard softmax by reorganizing vocabulary prediction into a sequence of binary decisions along a binary tree. The key ideas are:

  • Binary tree decomposition: The probability of any word is computed as a product of sigmoid decisions along its root-to-leaf path, reducing complexity from O(V)O(V) to O(logV)O(\log V). The tree structure ensures all leaf probabilities sum to 1 without any explicit normalization step.
  • Huffman coding: Assigns shorter paths to frequent words, minimizing the expected path length weighted by training frequency and concentrating cheaper computations on the most common words. The expected path length equals the Shannon entropy of the frequency distribution, which is theoretically optimal.
  • Node vectors: Each internal node has a learned vector that parameterizes the left-right decision at that node. These vectors, combined with sigmoid activations, produce valid conditional probabilities. They encode discriminative boundaries between vocabulary subsets, not word-level semantic representations.
  • Path probability formula: P(wh)=j=1L(w)1σ(djvjh)P(w | \mathbf{h}) = \prod_{j=1}^{L(w)-1} \sigma(d_j \cdot \mathbf{v}_j \cdot \mathbf{h}), where dj{+1,1}d_j \in \{+1, -1\} encodes the direction at each step and ensures complementary probabilities sum to 1.
  • Gradient locality: Only node vectors along the target word's path receive gradient updates per training step, making each update efficient and local. The center word embedding accumulates contributions from every node on the path, capturing hierarchical semantic structure through the path geometry.
  • Trade-offs vs negative sampling: Hierarchical softmax produces a valid probability distribution and benefits from frequency skew via Huffman coding, while negative sampling is simpler to implement, requires fewer operations per step at common vocabulary sizes, and often produces embeddings with slightly better semantic quality for downstream tasks.
  • Historical impact: Together with negative sampling, hierarchical softmax made billion-word training runs practical for the first time, enabling the word2vec embeddings that transformed how NLP researchers think about word representations and set the stage for scaling as a primary research strategy.

The next chapter on Word2Vec training covers how these approximation methods fit into a complete training pipeline, including subsampling of frequent words, learning rate scheduling, and the practical differences between training with hierarchical softmax and negative sampling on real corpora.

Quiz

Ready to test your understanding? Take this quick quiz to reinforce what you've learned about hierarchical softmax.

Hierarchical Softmax Quiz

Question 1 of 80 of 8 completed
What is the per-step computational complexity of hierarchical softmax for a vocabulary of size V?

Comments

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

Reference

Citation details

Cite or share this article.

BIBTEXAcademic
@misc{brenndoerfer2025hierarchicalsoftmax, author = {Michael Brenndoerfer}, title = {Hierarchical Softmax}, year = {2025}, url = {https://mbrenndoerfer.com/writing/hierarchical-softmax-word-embeddings}, organization = {mbrenndoerfer.com}, note = {Accessed: 2026-09-15} }
APAAcademic
Michael Brenndoerfer (2025). Hierarchical Softmax. Retrieved from https://mbrenndoerfer.com/writing/hierarchical-softmax-word-embeddings
MLAAcademic
Michael Brenndoerfer. "Hierarchical Softmax." 2026. Web. September 15, 2026. <https://mbrenndoerfer.com/writing/hierarchical-softmax-word-embeddings>.
CHICAGOAcademic
Michael Brenndoerfer. "Hierarchical Softmax." Accessed September 15, 2026. https://mbrenndoerfer.com/writing/hierarchical-softmax-word-embeddings.
HARVARDAcademic
Michael Brenndoerfer (2025) 'Hierarchical Softmax'. Available at: https://mbrenndoerfer.com/writing/hierarchical-softmax-word-embeddings (Accessed: September 15, 2026).
SimpleBasic
Michael Brenndoerfer (2025). Hierarchical Softmax. https://mbrenndoerfer.com/writing/hierarchical-softmax-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.