Beam Search › Implementation with Priority Queues

Michael BrenndoerferMay 22, 20255 min read
Language AI HandbookAll chapters
Part 12 of 67Sequence-to-Sequence
Chapter 85Beam Search › Implementation with Priority Queues
Reading section 10 of 17 · Implementation with Priority Queues · 5 minSection 10 of 17 · 5 min

Book contents

Section 10 of 17 · 5 min read

Beam Search › Implementation with Priority QueuesOne section · Sections in this chapter10 / 17 ↓
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

A clean beam search implementation uses a priority queue (min-heap) to efficiently maintain the top-k hypotheses. The data structures involved are:

  • A hypothesis object containing the current token sequence and the cumulative log-probability.
  • A priority queue indexed by negative log-probability (since Python's heapq is a min-heap, we negate scores so the highest-scoring hypotheses are at the top when popped).
  • A completed list for hypotheses that have produced EOS.

Let's implement beam search from scratch on a toy language model.

In[3]:
Code
from typing import List

import numpy as np

# A simple toy language model using random but reproducible probabilities
np.random.seed(42)

VOCAB = [
    "<EOS>",
    "the",
    "cat",
    "sat",
    "on",
    "mat",
    "dog",
    "ran",
    "away",
    "quickly",
]
VOCAB_SIZE = len(VOCAB)
EOS_ID = 0


def toy_lm_step(prev_tokens: List[int]) -> np.ndarray:
    """
    Toy language model: returns log-probabilities over the vocabulary.
    In a real system, this would be a transformer or RNN forward pass.
    """
    rng = np.random.RandomState(seed=sum(prev_tokens) % 2**31)
    logits = rng.randn(VOCAB_SIZE)
    # Suppress EOS early, encourage it later
    if len(prev_tokens) < 3:
        logits[EOS_ID] -= 5.0
    elif len(prev_tokens) >= 6:
        logits[EOS_ID] += 3.0
    # Softmax
    logits -= logits.max()
    probs = np.exp(logits)
    probs /= probs.sum()
    return np.log(probs)
In[4]:
Code
class Hypothesis:
    """A beam search hypothesis: token sequence + cumulative log-prob."""

    def __init__(self, tokens: List[int], score: float):
        self.tokens = tokens
        self.score = score

    def __lt__(self, other: "Hypothesis") -> bool:
        # Sort descending by score: higher score is "less than" for min-heap inversion
        return self.score > other.score

    def __eq__(self, other: "Hypothesis") -> bool:
        return self.score == other.score
In[5]:
Code
from typing import List, Tuple


def length_penalty(length: int, alpha: float = 0.6) -> float:
    """Google's length penalty from Wu et al. 2016."""
    return ((5 + length) ** alpha) / (6**alpha)


def beam_search(
    start_token: int,
    beam_width: int = 3,
    max_length: int = 8,
    alpha: float = 0.6,
) -> List[Tuple[List[int], float]]:
    """
    Run beam search and return the top completed hypotheses.
    Returns list of (token_sequence, normalized_score) tuples.
    """
    # Initialize beam with a single hypothesis containing the start token
    beam = [Hypothesis(tokens=[start_token], score=0.0)]
    completed = []

    for step in range(max_length):
        candidates = []
        for hyp in beam:
            # Get log-probs from the language model
            log_probs = toy_lm_step(hyp.tokens)
            # Expand this hypothesis by every vocabulary token
            for token_id, log_prob in enumerate(log_probs):
                new_tokens = hyp.tokens + [token_id]
                new_score = hyp.score + log_prob
                candidates.append(Hypothesis(new_tokens, new_score))

        # Sort all candidates, keep top beam_width
        candidates.sort()  # Sorts by neg_score ascending = score descending
        beam = candidates[:beam_width]

        # Move completed hypotheses (ending in EOS) to the completed list
        still_active = []
        for hyp in beam:
            if hyp.tokens[-1] == EOS_ID:
                # Apply length normalization (exclude start token from length)
                seq_len = len(hyp.tokens) - 1
                normalized = hyp.score / length_penalty(seq_len, alpha)
                completed.append((hyp.tokens, normalized))
            else:
                still_active.append(hyp)

        beam = still_active

        if not beam:
            break

    # Also collect remaining beam hypotheses with length normalization
    for hyp in beam:
        seq_len = len(hyp.tokens) - 1
        normalized = hyp.score / length_penalty(seq_len, alpha)
        completed.append((hyp.tokens, normalized))

    # Sort by normalized score (descending)
    completed.sort(key=lambda x: x[1], reverse=True)
    return completed
Out[6]:
Console
Beam Search Results (start='the'):
============================================================

Beam width k=1:
  Best sequence: the dog away the ran quickly <EOS>
  Normalized score: -4.9220

Beam width k=3:
  Best sequence: the on cat <EOS>
  Normalized score: -2.8314
  Top 3 hypotheses:
    the on cat <EOS>                         score=-2.8314
    the dog away the <EOS>                   score=-4.3814
    the dog away on dog ran <EOS>            score=-4.8244

Beam width k=5:
  Best sequence: the on cat <EOS>
  Normalized score: -2.8314
  Top 3 hypotheses:
    the on cat <EOS>                         score=-2.8314
    the dog sat <EOS>                        score=-3.3536
    the dog away the <EOS>                   score=-4.3814

The output shows how beam width affects the search. With k=1k = 1 (greedy), we get the single locally optimal path. With larger beams, we explore more of the probability space and often find sequences with higher normalized scores.

Out[7]:
Console

Effect of Length Normalization (alpha parameter):
============================================================

alpha=0.0 (no normalization) - top 3:
  [3 tokens] the on cat <EOS>                         score=-3.3648
  [4 tokens] the dog away the <EOS>                   score=-5.5882
  [6 tokens] the dog away on dog ran <EOS>            score=-6.9405

alpha=0.6 (moderate normalization) - top 3:
  [3 tokens] the on cat <EOS>                         score=-2.8314
  [4 tokens] the dog away the <EOS>                   score=-4.3814
  [6 tokens] the dog away on dog ran <EOS>            score=-4.8244

With no normalization (α=0\alpha = 0), the algorithm often prefers shorter sequences because each additional token adds a negative contribution. With α=0.6\alpha = 0.6, sequences of moderate length are rewarded, and the top hypotheses tend to be longer and more complete.