Skip to content

Decoding Strategies: Beam Search vs Greedy vs Sampling

Overview

Decoding strategies determine how to select the next token during autoregressive generation. Different strategies offer different trade-offs between quality, diversity, and speed.

  • Greedy: Fastest, lowest diversity, sometimes lower quality
  • Beam Search: Balanced quality and diversity, slower
  • Sampling: Best diversity and quality, slower than greedy
  • Nucleus Sampling: Balanced quality and diversity
  • Trade-off: Speed vs. generation quality

Greedy Decoding

How It Works

At each step, select the token with highest probability:

P(t+1 | context) = softmax(logits)[1:vocab_size]
                 = [0.3, 0.25, 0.15, 0.1, 0.05, ...]

Greedy:
  - Select argmax = token with 0.3 (highest probability)

Result sequence:
  - [The, cat, sat, on, the, mat]
   (always picks most likely next token)

Pseudocode:
def greedy_decode(model, prompt, max_length=100):
    generated = list(prompt)

    for _ in range(max_length):
        # Forward pass
        logits = model(generated)[-1, :]  # Last position

        # Select token with highest probability
        next_token = argmax(logits)
        generated.append(next_token)

    return generated

# Cost: 1 forward pass per token (required)
# Time: Fast (no overhead)
# Diversity: Very low (deterministic)
# Quality: Often acceptable for factual tasks

Advantages

✅ Fast (no overhead)
✅ Deterministic (reproducible)
✅ Works reasonably well for many tasks

Limitations

❌ No diversity (same output every time)
❌ Can get stuck in repetition loops
❌ May select suboptimal token if high-probability path is bad
❌ Doesn't explore alternatives

How It Works

Instead of tracking 1 hypothesis, track top-k:

Step 1: Initial (beam size=3)
  - Probabilities: [0.3, 0.25, 0.15, ...]
  - Keep top-3 hypotheses:
    - [token_1, prob=0.3]
    - [token_2, prob=0.25]
    - [token_3, prob=0.15]

Step 2: Expand each hypothesis
  - For [token_1]: compute next probabilities
    - [token_1 → token_A, prob=0.3×0.4=0.12]
    - [token_1 → token_B, prob=0.3×0.3=0.09]
    - [token_1 → token_C, prob=0.3×0.2=0.06]
    - [token_1 → token_D, prob=0.3×0.1=0.03]
│
  - For [token_2]: compute next probabilities
    - [token_2 → token_A, prob=0.25×0.5=0.125]
    - [token_2 → token_B, prob=0.25×0.3=0.075]
    - [token_2 → token_C, prob=0.25×0.15=0.0375]
    - [token_2 → token_D, prob=0.25×0.05=0.0125]
│
  - For [token_3]: ...

Step 3: Select top-3 globally
  - All combinations: 3 × 4 = 12 options
  - Rank by probability:
    - [token_2 → A, 0.125] ← best
    - [token_1 → A, 0.12]
    - [token_2 → B, 0.075]
  - Keep top-3, discard others

Step 4: Repeat until max length or EOS

Implementation

def beam_search(model, prompt, beam_size=3, max_length=100):
    """
    Generate with beam search

    Args:
        beam_size: Number of hypotheses to track (3-5 typical)

    Returns:
        Top-scoring sequences
    """
    vocab_size = model.config.vocab_size
    device = next(model.parameters()).device

    # Initialize beam
    # Each entry: (sequence, score)
    batch_size = 1
    sequences = [(prompt, 0.0)]  # Start with initial prompt

    for step in range(max_length):
        # Candidates for this step
        candidates = []

        for seq, seq_score in sequences:
            # Forward pass
            with torch.no_grad():
                logits = model(seq.unsqueeze(0))[0, -1, :]

            # Get log probabilities
            log_probs = F.log_softmax(logits, dim=-1)

            # Get top beam_size next tokens
            top_log_probs, top_indices = torch.topk(log_probs, beam_size)

            # Expand each hypothesis
            for i in range(beam_size):
                new_seq = torch.cat([seq, top_indices[i].unsqueeze(0)])
                new_score = seq_score + top_log_probs[i].item()
                candidates.append((new_seq, new_score))

        # Keep top beam_size candidates globally
        candidates.sort(key=lambda x: x[1], reverse=True)
        sequences = candidates[:beam_size]

    # Return best sequence
    best_seq, best_score = sequences[0]
    return best_seq

# Cost: beam_size forward passes per token
# Time: ~3-5x slower than greedy (for beam_size=3-5)
# Diversity: Limited (only explores top hypotheses)
# Quality: Better than greedy, can find better paths

Analysis

Beam search quality vs speed:

Beam size  Quality   Speed    Diversity   Use case
──────────────────────────────────────────────────
1          Low       1x       None        Baseline (greedy)
3          Medium    3x       Low         Balanced
5          Slightly  5x       Low         Deliberate tasks
10         Marginal  10x      Low         Research only

Observation:
  - Diminishing returns after beam=3
  - beam=5 rarely better than beam=3
  - Speed cost not worth quality gain
  - Default: beam_size=3 for practical use

Sampling

Temperature-Based Sampling

Problem with greedy: Deterministic, can't escape bad paths
Problem with beam search: Still limited to high-prob paths

Solution: Sample from distribution!

Standard greedy:
  - P(next) = [0.3, 0.25, 0.15, 0.1, ...]
  - Sample argmax = token with 0.3
  - Deterministic

Sampling:
  - P(next) = [0.3, 0.25, 0.15, 0.1, ...]
  - Sample from distribution: Can pick any token!
    - 30% chance: pick 0.3 token (most likely)
    - 25% chance: pick 0.25 token
    - 15% chance: pick 0.15 token
    - ...
  - Stochastic: different outputs each time!

Temperature control:

T = 0.5 (sharp):
  - Probabilities sharpen: [0.5, 0.3, 0.15, ...]
  - Most probable token even more likely
  - Conservative, deterministic behavior

T = 1.0 (original):
  - Probabilities unchanged: [0.3, 0.25, 0.15, ...]
  - Standard sampling

T = 2.0 (soft):
  - Probabilities flatten: [0.2, 0.2, 0.15, 0.15, ...]
  - More uniform distribution
  - Diverse, creative outputs

Implementation

def sample_decode(model, prompt, max_length=100, temperature=1.0, top_p=0.9):
    """
    Generate with sampling

    Args:
        temperature: Higher = more random
        top_p: Nucleus sampling (only sample from top p% probability)
    """
    generated = list(prompt)

    for _ in range(max_length):
        # Forward pass
        logits = model(generated)[-1, :]

        # Apply temperature
        logits = logits / temperature

        # Get probabilities
        probs = F.softmax(logits, dim=-1)

        # Nucleus sampling (optional, more stable)
        if top_p < 1.0:
            # Sort by probability
            sorted_probs, sorted_indices = torch.sort(probs, descending=True)
            cumsum_probs = torch.cumsum(sorted_probs, dim=-1)

            # Find threshold: keep top p% probability mass
            threshold_idx = torch.searchsorted(cumsum_probs, top_p)
            threshold_prob = sorted_probs[threshold_idx]

            # Zero out low-probability tokens
            probs[probs < threshold_prob] = 0
            probs = probs / probs.sum()  # Renormalize

        # Sample from distribution
        next_token = torch.multinomial(probs, num_samples=1)
        generated.append(next_token.item())

    return generated

# Cost: 1 forward pass per token (same as greedy)
# Time: Fast (no overhead over greedy)
# Diversity: High (stochastic sampling)
# Quality: Can be better or worse than greedy

Nucleus Sampling (Top-p)

How It Works

Pure sampling can generate low-probability nonsense:

Probabilities: [0.4, 0.3, 0.2, 0.05, 0.03, 0.01, 0.01, ...]
                                         ^ These are terrible but still possible!

Nucleus sampling (top_p=0.9):
  - Find cumulative probability threshold
  - Keep tokens until cumsum reaches top_p
    - 0.4 (sum: 0.4)
    - 0.3 (sum: 0.7)
    - 0.2 (sum: 0.9) ← STOP HERE
    - Drop 0.05, 0.03, 0.01, 0.01 (too low)
  - Sample only from kept tokens
  - Result: Probability = [0.4, 0.3, 0.2, 0, 0, 0, ...]

Effect:
  - High probability tokens: always available
  - Medium probability: available if top_p allows
  - Low probability: removed (prevents nonsense)
  - Balances diversity and quality!

Quality

Experiment: LLaMA 7B generation quality

Metric                Greedy  Beam(3)  Sample  Nucleus
─────────────────────────────────────────────────────
ROUGE (factual)       34.2    35.1     33.8    34.8
Diversity (TTR)       1.2     1.5      8.4     5.2
Human rating          3.2/5   3.5/5    3.8/5   3.9/5
Generation time       1x      3x       1x      1x

TTR (Type-Token Ratio): Higher = more diverse vocabulary

Observations:
  - Greedy: Fast, boring, decent quality
  - Beam: Slightly better, slow
  - Sample: Diverse, creative, sometimes weird
  - Nucleus: Best balance of diversity and quality
  - Recommendation: Use nucleus sampling (top_p=0.9)

Comparison and Recommendations

Which to Use?

Task                  Recommendation
──────────────────────────────────────
Code generation       Greedy (most consistent)
Factual QA            Beam search (beam_size=3)
Creative writing      Nucleus sampling (top_p=0.9)
Chatbot               Nucleus sampling (top_p=0.9)
Machine translation   Beam search (beam_size=5)
Summarization         Nucleus sampling (top_p=0.9)

Parameter Guidelines

Greedy:
  - No parameters (deterministic)

Beam Search:
  - beam_size: 3-5 (default 3)
  - length_penalty: 1.0 (no penalty)
    - > 1.0 favors longer sequences
    - < 1.0 favors shorter sequences
  - Note: Much slower, marginal quality gain over beam_size=1

Temperature Sampling:
  - T = 0.5-0.7: More deterministic, factual
  - T = 1.0: Neutral
  - T = 1.5-2.0: Creative, diverse
  - Usually use T=0.7-0.9

Nucleus Sampling:
  - top_p = 0.8-0.95: Typical range
  - 0.95: More exploratory
  - 0.85: Balanced
  - 0.75: More conservative
  - Default: 0.9 (good balance)

Combined: Temperature + Nucleus
  - Use together for best results!
  - Nucleus removes low-prob tokens
  - Temperature adjusts prob distribution

Key Takeaways

🎯 Greedy: Fast, deterministic, limited quality
📊 Beam Search: Better quality, 3-5x slower
🎲 Sampling: Diverse, creative, same speed as greedy
🎪 Nucleus: Best quality-diversity trade-off
⚙️ Use Nucleus Sampling (top_p=0.9) by default