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 to , 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 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 with input embedding , the probability of context word under standard softmax is:
where:
- : probability of context word given center word
- : output embedding vector for word
- : the input embedding of the center word
- : 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 for every single word in the vocabulary, then summing all 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 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 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.
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}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.144The 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.


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 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 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 -way classification into approximately 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 to turning left and to turning right. The left subtree distributes its entire probability mass across all words in that subtree, and the right subtree distributes 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 , 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.

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 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:
- Place each word (with its frequency) into a priority queue ordered by frequency ascending.
- Extract the two lowest-frequency entries.
- Create a new parent node whose frequency is the sum of the two.
- Insert the parent node back into the queue.
- 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.
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)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 levelsHuffman 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.

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 has a learned vector of the same dimension as the word embeddings. To make the left-right decision at node , we compare this node vector with the current context embedding using a dot product, then pass the result through the sigmoid function:
where:
- : the sigmoid function, mapping any real number to
- : the learned vector at internal node
- : the context embedding (center word's input embedding)
This output gives the probability of going left. The probability of going right is . 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 , making it a natural candidate for probabilities. More importantly, the symmetry property 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 given context embedding is the product of probabilities at each decision point along the root-to-leaf path:
where:
- : the total number of nodes along the path from root to word (including root and leaf)
- : the -th node along the path to , with being the root
- : the direction code at step , equal to if the next node is the left child and if it is the right child
- : the learned vector at the -th internal node
- : the context embedding
The direction encoding is the key mechanism. When (go left), we compute , which is high when the dot product is positive. When (go right), we compute , which is the complementary probability. This compact encoding lets a single formula handle both directions without needing two separate parameters per node.
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 and , 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 . 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.
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
)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.125000The output shows how each binary decision multiplies into the running probability. The final value is close to 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:
where:
- : the center word
- : the observed context word
- : the input embedding of center word
- : computed as the product of path probabilities
Substituting the path probability formula and applying the logarithm:
where:
- : log-probability of context word
- : direction code at step along the path to
- : the learned node vector at step
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:
This loss has a pleasant interpretation that makes the training dynamics intuitive. Each term is the binary cross-entropy loss for a single binary classifier at node . The "correct label" at each node is determined by the direction : if we should go left, the correct output is , which corresponds to a positive dot product. If we should go right, the correct output is , 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 and the context embedding 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 at internal nodes, and the input embeddings (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 at step along the path, the loss contribution is:
Let denote the signed dot product at step . Then .
Using the identity , we can derive the gradient of with respect to the node vector by applying the chain rule:
where:
- : the current sigmoid output at node (the model's current left-probability)
- : the error at this node, measuring how far from certainty the current prediction is
- : direction code, propagating sign for left vs right
- : context embedding, which provides the gradient direction in embedding space
When the model makes a correct and confident prediction at node (say is close to 1 when ), the error term 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 accumulates contributions from every node along the path:
where the sum runs over all internal nodes on the path to the target word .
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 , 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 only requires reading and updating node vectors, not all 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.
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)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.1646The gradient norms reveal which nodes are receiving the largest updates. Nodes where the model made more uncertain or wrong decisions (larger error term ) 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 . 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 , the signed version , and the sigmoid. The loss is the sum of at each step. Gradients flow back: each node vector gets a small update, and the accumulated gradient is applied to the center word embedding.
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)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%
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 shrinking toward zero and reducing gradient magnitude accordingly. The final loss asymptotically approaches a lower bound determined by how well the context embedding 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 before diverging. During training, every (center, "cat") pair and every (center, "dog") pair will update the node vector at that shared node. This shared training creates a form of parameter coupling: the node vector 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.
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)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: 11The 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.
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# 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)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
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 for center words and an output embedding matrix for context words. After training, you typically use as the word representations, or average and .
Hierarchical softmax replaces with a matrix of node vectors , 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 . 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 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 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 negative words and train a sigmoid classifier to distinguish real pairs from noise. Each training step updates the 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.
| Property | Hierarchical Softmax | Negative Sampling |
|---|---|---|
| Per-step complexity | , typically 5-20 | |
| Valid probability distribution | Yes | No |
| Parameters per step | node vectors | word vectors |
| Updates frequent words often | Via Huffman coding | Via sampling distribution |
| Implementation complexity | Higher (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 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.


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 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 to . 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: , where 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
Reference
Citation details
Cite or share this article.
Continue with the full handbook
This chapter is part of Language AI Handbook. Use the handbook page to browse the complete table of contents and continue reading in sequence.
Explore Language AI HandbookStay up to date
Get articles, book updates, and news delivered to your inbox.
No spam, unsubscribe anytime.
Join the community
Sign in to remove popups, track your reading progress, and join the discussion.

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