Hierarchical Softmax › Implementation: Complete Hierarchical Softmax Layer

Michael BrenndoerferUpdated February 8, 20265 min read
Language AI HandbookAll chapters
Part 4 of 67Word Embeddings
Chapter 22Hierarchical Softmax › Implementation: Complete Hierarchical Softmax Layer
Reading section 10 of 16 · Implementation: Complete Hierarchical Softmax Layer · 5 minSection 10 of 16 · 5 min

Book contents

Section 10 of 16 · 5 min read

Hierarchical Softmax › Implementation: Complete Hierarchical Softmax LayerOne section · Sections in this chapter10 / 16 ↓
Reading tools Definitions & inline links

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

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.