Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
Transformer internals eventually produce logits. Decoding strategy controls how that final probability distribution becomes one visible completion. Compare greedy decoding, beam search, temperature sampling, top-k, nucleus sampling, and min-p so you can tune output quality intentionally.
The earlier decoding foundations chapter built stable softmax and baseline greedy, sampling, and beam mechanics. Here those mechanics become product decisions: strategy trade-offs, min-p, length penalties, sampler order, and runtime behavior under real output constraints.
An incident-status assistant that hasn't chosen its next token yet may assign a 70% chance to stable, a 20% chance to degraded, and a 10% chance to rollback. Language models do this at every generation step: they produce raw scores for many possible next tokens, convert those scores into probabilities, then use a decoding policy to pick what appears on screen.
That final choice has real product consequences. Runbook assistants usually need stable, factual phrasing, while brainstorming tools need more variety. Code generators often benefit from determinism; story generators may sound lifeless if they always pick the highest-probability token. Decoding is the control surface that turns the same model distribution into those different behaviors.
For how decoding fits into the broader inference pipeline and techniques like speculative decoding, see those articles. Speculative decoding accelerates generation by asking a draft model to propose tokens that the target model can verify in parallel.[1]

Greedy decoding
Why can greedy decoding be bad even when it always picks the most likely next token?
Answer
The most likely next token is a local choice. It can lead the model into a generic or repetitive path even when a slightly less likely token would make the full answer more useful. In runbook assistance, that can mean repeatedly choosing safe boilerplate instead of entering the rollback or escalation workflow the user needs.
Human-written text often doesn't follow the model's single most likely continuation. Holtzman et al. show that maximum-likelihood decoding can drift toward bland, repetitive text, which is why pure argmax-style decoding often doesn't sound human.[2]
1first_step = {"degraded": 0.55, "needs": 0.45}
2continuations = {"degraded": {".": 0.40}, "needs": {"review": 0.90}}
3
4greedy_first = max(first_step, key=first_step.get)
5path_probabilities = {
6 "degraded .": first_step["degraded"] * continuations["degraded"]["."],
7 "needs review": first_step["needs"] * continuations["needs"]["review"],
8}
9highest_sequence = max(path_probabilities, key=path_probabilities.get)
10
11print(f"greedy first token: {greedy_first}")
12print(f"sequence probabilities: {path_probabilities}")
13print(f"higher-probability sequence: {highest_sequence}")
14
15assert greedy_first == "degraded"
16assert highest_sequence == "needs review"1greedy first token: degraded
2sequence probabilities: {'degraded .': 0.22000000000000003, 'needs review': 0.405}
3higher-probability sequence: needs reviewThe algorithm
At each step, select the token with the highest probability:
Reading the formula
At each step, look at every possible next token, pick the one with the highest probability, and commit to it. Simple and fast, but myopic: the next-token winner can still start a weak full sequence.
This basic PyTorch implementation assumes a single prompt in the batch, a model whose forward call returns .logits, and an integer EOS token ID. The loop repeatedly takes the argmax token and stops when it reaches EOS or the maximum number of decoding steps:
1import torch
2
3def greedy_decode(
4 model: torch.nn.Module,
5 prompt_ids: torch.Tensor,
6 max_length: int,
7 eos_token_id: int
8) -> torch.Tensor:
9 """Generate one sequence with greedy decoding (batch size 1)."""
10 input_ids = prompt_ids
11 with torch.no_grad():
12 for _ in range(max_length):
13 logits = model(input_ids).logits[:, -1, :]
14 next_token = logits.argmax(dim=-1, keepdim=True)
15 input_ids = torch.cat([input_ids, next_token], dim=-1)
16 if next_token[0, 0].item() == eos_token_id:
17 break
18 return input_idsStrengths
- Deterministic and fast: per step (single argmax over vocabulary)
- Useful baseline for constrained tasks: classification-style outputs or extraction when output variability is undesirable
Limitations
- Suboptimal globally: The locally best token doesn't imply the best sequence
- Repetitive in open-ended generation: Maximum-likelihood decoding can fall into loops or generic output in evaluated settings.[2]
Beam search
Algorithm
Instead of keeping only the best token, maintain B (beam width) candidate sequences and expand each step with next-token candidates. Exact beam search considers the full vocabulary (or a very large candidate set) for every beam, then keeps the top-B extended sequences. The snippet below uses a common pruned expansion: each beam only proposes its top-beam_width tokens before the global top-B reselection. That cheaper demo can miss a high-scoring path that starts with a lower-ranked next token, so it isn't exhaustive beam search. This simplified PyTorch version assumes batch size 1, a model whose forward call returns .logits, and the highest-scoring partial sequences at every step. For readability, it leaves out batching and length normalization, which appear in the next subsection:
1import torch
2
3def beam_search(
4 model: torch.nn.Module,
5 prompt_ids: torch.Tensor,
6 beam_width: int = 5,
7 max_length: int = 100,
8 eos_token_id: int = 2
9) -> torch.Tensor:
10 """Generate one sequence with beam search (batch size 1)."""
11 # Each beam: (sequence, cumulative_log_prob)
12 beams = [(prompt_ids, 0.0)]
13 completed = []
14
15 with torch.no_grad():
16 for _ in range(max_length):
17 all_candidates = []
18 for seq, score in beams:
19 # If this beam already ended, record it
20 if seq[0, -1].item() == eos_token_id:
21 completed.append((seq, score))
22 continue
23
24 # After step 1, beams have different lengths, so process each separately.
25 # In production you would use KV caching or padded batching instead.
26 logits = model(seq).logits[:, -1, :]
27 log_probs = torch.log_softmax(logits, dim=-1)
28 top_k = log_probs.topk(beam_width, dim=-1)
29
30 for i in range(beam_width):
31 new_seq = torch.cat([seq, top_k.indices[:, i:i+1]], dim=-1)
32 new_score = score + top_k.values[:, i].item()
33 all_candidates.append((new_seq, new_score))
34
35 if not all_candidates:
36 beams = []
37 break
38
39 # Keep top beam_width candidates
40 beams = sorted(all_candidates, key=lambda x: x[1],
41 reverse=True)[:beam_width]
42
43 # Active beams become truncated final candidates at the length cutoff.
44 # Compare them with EOS-completed beams instead of discarding either set.
45 final_candidates = completed + beams
46 return max(final_candidates, key=lambda x: x[1])[0]An EOS token finalizes a beam early. Reaching max_length also finalizes every beam that is still active, but labels it as truncated in a full implementation. Production beam search usually keeps finished flags on beams still in the active set and applies length normalization continuously, rather than only when a beam is moved to a side list. Selection must compare both completed and still-active sets under the same scoring rule. Returning completed whenever that list is non-empty silently discards active candidates at the cutoff, even when one has the best score.
Length penalty
One common length penalty, used in the Google Neural Machine Translation system, is:[3]
Reading the formula
Without correction, longer sequences receive lower unnormalized probability as each extra token multiplies the sequence probability by a value smaller than 1. The exponent controls how strongly you compensate for that short-sequence bias. When , there's no correction. Larger values reduce the bias toward short completions.
| Effect | How to use it | |
|---|---|---|
| 0 | No compensation for short-sequence bias | Useful baseline |
| 0.6 | Moderate compensation | Candidate value near the range GNMT often found best on its development sets |
| 1.0 | Stronger compensation for longer outputs | Compare on your task before adopting |
Tune on a validation set. It isn't a universal translation or summarization default: the right value depends on model behavior, stopping criteria, and the task metric.
1def gnmt_score(log_probability, length, alpha=0.6):
2 penalty = ((5 + length) / 6) ** alpha
3 return log_probability / penalty
4
5short = {"text": "late", "log_probability": -1.00, "length": 2}
6complete = {"text": "needs review", "log_probability": -1.08, "length": 5}
7
8raw_choice = max([short, complete], key=lambda item: item["log_probability"])["text"]
9normalized_choice = max(
10 [short, complete],
11 key=lambda item: gnmt_score(item["log_probability"], item["length"]),
12)["text"]
13print(f"raw sequence score chooses: {raw_choice}")
14print(f"length-normalized score chooses: {normalized_choice}")
15
16assert raw_choice == "late"
17assert normalized_choice == "needs review"1raw sequence score chooses: late
2length-normalized score chooses: needs reviewA summarization model with beam width 8 keeps returning clipped one-line outputs instead of complete summaries. Which scoring term do you inspect first?
Answer
Inspect length normalization or length penalty first. Without it, beam search over-rewards short sequences because every extra token lowers sequence probability, so the beam prefers prematurely ending outputs.
When to use beam search
- Good fit: machine translation. The output usually has one target meaning to preserve.
- Good fit: structured summarization. The output should stay close to source evidence.
- Poor fit: open-ended chat or creative writing. High-likelihood paths often become generic.
Counterintuitively, beam search with larger beams can produce more likely but less interesting text.[4] In open-ended generation, increasing beam width can reduce output quality because the most probable sequence is often generic and repetitive.
Beam width also has a serving cost. A width- search keeps up to active continuations at each step. Efficient runtimes batch those hypotheses and reorder their KV-cache state, but larger beams still increase decoder work and memory pressure relative to greedy decoding.
Temperature scaling
What temperature does
Before adding randomness, you need a dial that controls how much randomness to allow. Temperature is that dial. It reshapes the probability distribution before we sample from it.
Return to our running example. The raw model gives us these probabilities for the next token after "The canary status is":
| Token | Original Probability |
|---|---|
degraded | 0.45 |
in | 0.30 |
on | 0.15 |
broken | 0.02 |
| tail | 0.08 |
This is what happens when we apply different temperatures and then run softmax:
| Token | (sharp) | (original) | (flat) |
|---|---|---|---|
degraded | ~0.78 | 0.45 | ~0.37 |
in | ~0.20 | 0.30 | ~0.28 |
on | ~0.02 | 0.15 | ~0.18 |
broken | ~0.00 | 0.02 | ~0.05 |
| tail | ~0.00 | 0.08 | ~0.12 |
For this worked table only, the tail is treated as one aggregated category and each original probability is reshaped proportionally to before renormalization. A real decoder divides each individual token logit by before softmax; it never aggregates the tail first. At , degraded is so dominant that sampling usually picks it. At , the probabilities spread out, and even broken becomes a possible sample. As , decoding collapses toward greedy argmax. As , the distribution approaches uniform.
Temperature controls distribution sharpness like a sampler strictness dial. Low temperature () keeps the model focused on the top choices. High temperature () spreads probability across more tokens, allowing less common recovery actions when the situation is ambiguous.
The formula
Temperature modifies the logit distribution before softmax (the function that converts raw model scores into probabilities):
Reading the formula
Divide the raw logits by temperature before applying softmax. When , the division amplifies differences between logits, making the top choice even more dominant. When , it compresses differences, spreading probability more evenly across options. An implementation should special-case temperature=0 as deterministic decoding rather than divide by zero.
This function applies temperature scaling to raw logits. It divides logits by the temperature before they pass through softmax. A temperature below 1.0 sharpens the distribution; a value above 1.0 flattens it:
1import torch
2
3def apply_temperature(logits: torch.Tensor, temperature: float = 1.0) -> torch.Tensor:
4 """Scale logits by temperature. Handle temperature=0 as greedy decoding elsewhere."""
5 if temperature <= 0:
6 raise ValueError("temperature must be > 0; use greedy decoding for temperature=0")
7 return logits / temperature
8
9scaled = apply_temperature(torch.tensor([2.0, 1.0]), temperature=0.5)
10print("scaled logits:", scaled.tolist())
11try:
12 apply_temperature(torch.tensor([2.0, 1.0]), temperature=0.0)
13except ValueError as error:
14 print("zero temperature:", error)
15
16assert scaled.tolist() == [4.0, 2.0]1scaled logits: [4.0, 2.0]
2zero temperature: temperature must be > 0; use greedy decoding for temperature=0Mathematical intuition
Entropy measures how spread out a probability distribution is:
Low entropy means most probability sits on a few candidates. Higher entropy means more candidates carry meaningful mass. The illustration computes entropy over the same five displayed buckets, including the aggregated tail bucket, so its values are directly comparable across temperatures.
| Temperature | Effect on Distribution | Entropy |
|---|---|---|
| Approaches one-hot (greedy) | near 0 | |
| Original model distribution | Baseline | |
| Flattened (more uniform) | Increases | |
| Approaches uniform random | near |
1from math import exp
2
3def softmax(logits):
4 shifted = [logit - max(logits) for logit in logits]
5 weights = [exp(logit) for logit in shifted]
6 total = sum(weights)
7 return [weight / total for weight in weights]
8
9def apply_temperature(logits, temperature):
10 if temperature <= 0:
11 raise ValueError("temperature must be > 0; use greedy decoding for temperature=0")
12 return [logit / temperature for logit in logits]
13
14logits = [2.0, 1.0, 0.0]
15low_t = softmax(apply_temperature(logits, 0.5))
16high_t = softmax(apply_temperature(logits, 2.0))
17
18assert low_t[0] > high_t[0]
19assert high_t[-1] > low_t[-1]
20assert round(sum(high_t), 12) == 1.0
Why it works
Dividing logits by amplifies differences between logits, making the distribution peakier. When , the same operation shrinks differences, making probabilities more similar. This controls the sharpness of the sampling distribution.
Starting sweep candidates
| Evaluation slice | Temperature candidates | What to measure |
|---|---|---|
| Code generation | 0.0, 0.2, 0.5 | Tests passed, format validity, diversity |
| Factual QA / runbook lookup | 0.0, 0.2, 0.5 | Grounded accuracy and unsupported claims |
| General chat / runbook assistance | 0.3, 0.7, 1.0 | Helpfulness, repetition, policy adherence |
| Creative writing / descriptions | 0.7, 1.0, 1.3 | Diversity and coherence |
Why should one global temperature be questioned in evaluation?
Answer
Different endpoints tolerate different variance. Runbook lookup and rollback eligibility prioritize grounded accuracy; creative drafting may value variety. Evaluate sampler settings per task slice rather than accepting a single preset without measurements.
Top-k Sampling
Algorithm
Top-k sampling was popularized in neural story generation as a way to restrict sampling to the top most probable tokens, then renormalize the distribution.[5] The running example shows how that restriction changes the distribution.
Suppose the model gives these probabilities after the prompt "The canary status is":
| Token | Probability | Cumulative |
|---|---|---|
degraded | 0.45 | 0.45 |
in | 0.30 | 0.75 |
on | 0.15 | 0.90 |
broken | 0.02 | 0.92 |
| omitted tail tokens, individually below 0.02 | 0.08 total | 1.00 |
With top-k where , we keep only degraded and in, renormalize their probabilities to sum to 1, and sample from that smaller set. on, broken, and the tail are locked out.
This implementation of top-k sampling limits the probability distribution to only the most likely next tokens. It masks out all other tokens by setting their logits to negative infinity before applying softmax and sampling from the remaining probability mass:
1import torch
2
3def top_k_sampling(logits: torch.Tensor, k: int = 50) -> torch.Tensor:
4 """Sample from the top-k most probable tokens."""
5 if k < 1:
6 raise ValueError("k must be >= 1")
7 k = min(k, logits.shape[-1])
8 top_k_values, top_k_indices = logits.topk(k, dim=-1)
9 filtered = torch.full_like(logits, float('-inf'))
10 filtered.scatter_(dim=-1, index=top_k_indices, src=top_k_values)
11 probs = torch.softmax(filtered, dim=-1)
12 return torch.multinomial(probs, 1)The fixed-k problem
The primary limitation of top-k sampling is its rigidity. A useful value for varies sharply depending on the context of the generation:
| Context | Distribution Shape | Candidate values to evaluate |
|---|---|---|
| "The rollback runbook says" | Potentially peaked | 1, 3, 10 |
| "This product is great for" | Potentially flatter | 10, 30, 50 |
| "The service" | Prompt-dependent | Measure rather than assume |
When the distribution is highly peaked, a large like 50 forces the sampler to include dozens of irrelevant tail tokens. If the temperature is high, those tail tokens can accumulate enough probability mass to be selected, causing off-topic generation. Conversely, when the distribution is flat, a small like 10 can cut off valid continuations and make the output too narrow. This inability to adapt to distribution shape motivated dynamic truncation approaches.
Why does top-k fail differently on peaked and flat distributions?
Answer
Top-k always keeps the same number of tokens. On a peaked distribution, a large k admits irrelevant tail tokens. On a flat distribution, a small k cuts off valid alternatives. The fixed count ignores how confident the model is.
1def top_k_tokens(tokens_and_probs, k):
2 return [token for token, _ in sorted(tokens_and_probs, key=lambda item: item[1], reverse=True)[:k]]
3
4def top_p_tokens(tokens_and_probs, p):
5 kept = []
6 cumulative = 0.0
7 for token, probability in sorted(tokens_and_probs, key=lambda item: item[1], reverse=True):
8 kept.append(token)
9 cumulative += probability
10 if cumulative >= p:
11 break
12 return kept
13
14distribution = [
15 ("degraded", 0.45),
16 ("in", 0.30),
17 ("on", 0.15),
18 ("paused", 0.04),
19 ("held", 0.03),
20 ("broken", 0.02),
21 ("rollback", 0.01),
22]
23
24assert top_k_tokens(distribution, 2) == ["degraded", "in"]
25assert top_p_tokens(distribution, 0.8) == ["degraded", "in", "on"]Nucleus (top-p) Sampling
Algorithm
Top-k keeps exactly 50 candidate tokens if , even when only two are plausible or hundreds deserve consideration. Top-p keeps adding candidate tokens until their cumulative probability covers the requested mass. On an easy incident-status reply, only a few candidates qualify; on an ambiguous escalation, the candidate set is much longer. This dynamic threshold adapts naturally to each context.
Instead of fixing , dynamically select the smallest high-probability prefix whose cumulative probability reaches or exceeds threshold . This approach, also known as nucleus sampling, addresses the fixed candidate-count limitation of top-k.[2]
Using the same probability table:
| Token | Probability | Cumulative |
|---|---|---|
degraded | 0.45 | 0.45 |
in | 0.30 | 0.75 |
on | 0.15 | 0.90 |
broken | 0.02 | 0.92 |
| omitted tail tokens, individually below 0.02 | 0.08 total | 1.00 |
With top-p where , we include tokens until the cumulative probability reaches 0.8. That means degraded, in, and on are in the nucleus. broken and the tail are excluded. If the distribution were more peaked and degraded had 0.85 probability, the nucleus would contain only that one token.
where are sorted by decreasing probability.
1def nucleus_distribution(tokens_and_probs, threshold):
2 kept = []
3 mass = 0.0
4 for token, probability in sorted(tokens_and_probs, key=lambda item: item[1], reverse=True):
5 kept.append((token, probability))
6 mass += probability
7 if mass >= threshold:
8 break
9 return [(token, round(probability / mass, 3)) for token, probability in kept]
10
11tokens = [("degraded", 0.45), ("in", 0.30), ("on", 0.15), ("broken", 0.02)]
12nucleus = nucleus_distribution(tokens, threshold=0.8)
13print("renormalized nucleus:", nucleus)
14
15assert nucleus == [("degraded", 0.5), ("in", 0.333), ("on", 0.167)]
16assert round(sum(probability for _, probability in nucleus), 3) == 1.01renormalized nucleus: [('degraded', 0.5), ('in', 0.333), ('on', 0.167)]Reading the formula
Sort all tokens by probability, then include tokens from the top until their cumulative probability reaches (e.g., 0.9). On easy predictions where one token has 95% probability, only that token qualifies. On hard predictions where many tokens share probability, a large set is included. The candidate set adapts to the distribution shape.
Nucleus sampling is straightforward to implement in PyTorch. The function sorts the logits, computes cumulative probability mass, masks away tokens outside the nucleus, and then samples from the renormalized distribution:
1import torch
2
3def nucleus_sampling(logits: torch.Tensor, p: float = 0.9) -> torch.Tensor:
4 """Top-p (nucleus) sampling: dynamic vocabulary truncation."""
5 if not 0.0 < p <= 1.0:
6 raise ValueError("p must be in (0, 1]")
7
8 sorted_logits, sorted_indices = torch.sort(logits, descending=True, dim=-1)
9 sorted_probs = torch.softmax(sorted_logits, dim=-1)
10 cumulative_probs = torch.cumsum(sorted_probs, dim=-1)
11
12 # Remove tokens with cumulative probability above threshold
13 sorted_indices_to_remove = cumulative_probs > p
14 # Keep at least one token
15 sorted_indices_to_remove[..., 1:] = sorted_indices_to_remove[..., :-1].clone()
16 sorted_indices_to_remove[..., 0] = False
17
18 indices_to_remove = torch.zeros_like(logits, dtype=torch.bool)
19 indices_to_remove.scatter_(dim=-1, index=sorted_indices, src=sorted_indices_to_remove)
20
21 filtered_logits = logits.clone()
22 filtered_logits[indices_to_remove] = float('-inf')
23 probs = torch.softmax(filtered_logits, dim=-1)
24 return torch.multinomial(probs, 1)How distribution shape changes nucleus size
At the same threshold, the number of survivors depends entirely on the probabilities presented at that generation step:
| Sorted probabilities | Smallest prefix reaching 0.9 | Survivors |
|---|---|---|
| 0.92, 0.04, 0.02, 0.01, 0.01 | 0.92 | 1 |
| 0.45, 0.30, 0.15, 0.07, 0.03 | 0.45 + 0.30 + 0.15 = 0.90 | 3 |
| 0.22, 0.19, 0.17, 0.15, 0.13, 0.08, 0.06 | 0.22 + 0.19 + 0.17 + 0.15 + 0.13 + 0.08 = 0.94 | 6 |
The threshold stays fixed while the candidate count changes. Real nucleus size depends on the model, tokenizer, prompt, temperature, and exact probability shape.
Top-p's weakness: the long tail problem
Top-p has a failure mode to evaluate at high temperatures (): temperature flattening can cause many individually weak tokens to enter the cumulative-mass set. In those settings, a nucleus can expand substantially. This is one motivation for newer confidence-scaled variants such as min-p.
Why can top-p admit too much tail at high temperature?
Answer
High temperature flattens the distribution, so many weak tokens gain enough combined probability mass to enter the cumulative top-p set. The nucleus can become large even though many individual tokens are poor choices.
1from math import exp
2
3def probabilities(logits, temperature):
4 weights = [exp(logit / temperature) for logit in logits]
5 total = sum(weights)
6 return [weight / total for weight in weights]
7
8def nucleus_size(probs, threshold):
9 mass = 0.0
10 for index, probability in enumerate(sorted(probs, reverse=True), start=1):
11 mass += probability
12 if mass >= threshold:
13 return index
14
15logits = [4.0, 2.0, 1.0, 0.0, -0.5, -1.0]
16focused = nucleus_size(probabilities(logits, 0.7), threshold=0.9)
17flattened = nucleus_size(probabilities(logits, 1.5), threshold=0.9)
18print(f"nucleus size at T=0.7: {focused}")
19print(f"nucleus size at T=1.5: {flattened}")
20
21assert flattened > focused1nucleus size at T=0.7: 1
2nucleus size at T=1.5: 3Beyond nucleus: min-p
The insight
Top-p ranks candidate next tokens and keeps enough of them to cover 90% of total probability mass. Concentrated probability produces a small shortlist; spread-out probability produces a long one. Min-p instead says "only keep candidates whose probability is at least 10% of the top-ranked candidate." A dominant top candidate sets a high bar. A weak top candidate lowers the bar. The threshold adapts to relative peak probability, not a fixed cumulative cutoff.
Min-p, proposed by Nguyen et al. and published as an ICLR 2025 oral paper, scales the cutoff by the top token's probability instead of using a cumulative-mass threshold:[6]
Reading the formula
Find the most likely token, then keep tokens whose probability is at least fraction of that top token's probability. If the top token has 80% probability and , tokens above 8% are kept. If the top token has 5%, the bar drops to 0.5%, adapting to distribution shape.
What min-p changes relative to top-p
| Scenario | Top-p () | Min-p () |
|---|---|---|
| Model confident () | Keeps enough tokens to reach 90% mass, which can still include a long tail | Keeps only tokens with |
| Model uncertain () | Still targets 90% cumulative mass | Lowers the cutoff to , so more alternatives survive |
| High temperature () | Flattened tails can enter the nucleus | Relative cutoff can trim more of that tail |

1def min_p_tokens(tokens_and_probs, rho):
2 max_probability = max(probability for _, probability in tokens_and_probs)
3 threshold = rho * max_probability
4 return [token for token, probability in tokens_and_probs if probability >= threshold]
5
6confident = [("stable", 0.80), ("degraded", 0.09), ("purple", 0.01)]
7uncertain = [("stable", 0.05), ("degraded", 0.04), ("in", 0.03)]
8
9assert min_p_tokens(confident, rho=0.1) == ["stable", "degraded"]
10assert min_p_tokens(uncertain, rho=0.5) == ["stable", "degraded", "in"]The min-p sampling function dynamically calculates a threshold based on the maximum probability in the distribution. It takes raw logits, a min_p scaling factor, and a temperature as inputs. The function applies temperature, zeroes out token probabilities that fall below the dynamic threshold (calculated as min_p times the maximum probability), and renormalizes before returning a sampled token:
1import torch
2
3def min_p_sampling(logits: torch.Tensor, min_p: float = 0.1, temperature: float = 1.0) -> torch.Tensor:
4 """Min-p sampling: confidence-scaled dynamic truncation."""
5 if not 0.0 <= min_p <= 1.0:
6 raise ValueError("min_p must be in [0, 1]")
7 if temperature <= 0:
8 raise ValueError("temperature must be > 0; use greedy decoding for temperature=0")
9
10 # Apply temperature
11 logits = logits / temperature
12 probs = torch.softmax(logits, dim=-1)
13
14 # Dynamic threshold: min_p * max probability
15 max_prob = probs.max(dim=-1, keepdim=True).values
16 threshold = min_p * max_prob
17
18 # Zero out tokens below threshold
19 filtered_probs = probs.clone()
20 filtered_probs[probs < threshold] = 0.0
21
22 # Renormalize and sample
23 filtered_probs = filtered_probs / filtered_probs.sum(dim=-1, keepdim=True)
24 return torch.multinomial(filtered_probs, 1)When to think about min-p
Min-p is a newer truncation heuristic to evaluate, rather than a universal successor to nucleus sampling. Nguyen et al. propose it for controlling low-probability candidates admitted by top-p at higher temperatures.[6] A subsequent critical reanalysis reports that min-p didn't reliably improve quality-diversity tradeoffs against commonly used samplers in its experiments and disputes broad adoption claims.[7] Meanwhile, DeepSeek-R1 reports temperature 0.6 with top-p 0.95 in one published evaluation configuration.[8] Treat any of these settings as experiment inputs, not presets for a different model or product.
| Method | Threshold | Adapts to distribution shape? | Main tradeoff |
|---|---|---|---|
| Top-k | Fixed tokens | No | Simple, but rigid |
| Top-p | Fixed cumulative mass | Partly | Dynamic, but can admit a long tail |
| Min-p | Yes | Relative cutoff; compare empirically with top-p |
Repetition penalty
The problem
Autoregressive models can fall into degenerate repetition, where the generation process gets stuck in a loop. For example, a model might repeatedly generate the same phrase:
"The deploy rolled back. The deploy rolled back. The deploy rolled..."
How the penalty works
CTRL introduced penalized sampling and reported that a penalty around 1.2 balanced truthful generation against repetition in its experiments.[9] Common runtimes such as Hugging Face Transformers expose a related sign-aware logit processor: divide positive repeated-token logits and multiply negative repeated-token logits by the penalty, moving both downward in preference. This function mirrors that runtime behavior without mutating its input tensor:
1import torch
2
3def apply_repetition_penalty(
4 logits: torch.Tensor,
5 generated_ids: torch.Tensor,
6 penalty: float = 1.2
7) -> torch.Tensor:
8 """Penalize tokens that already appeared in the output (multiplicative)."""
9 if penalty <= 0:
10 raise ValueError("penalty must be positive")
11
12 adjusted = logits.clone()
13
14 for i in range(adjusted.shape[0]):
15 unique_tokens = torch.unique(generated_ids[i])
16
17 for token_id in unique_tokens:
18 if adjusted[i, token_id] > 0:
19 adjusted[i, token_id] /= penalty
20 else:
21 adjusted[i, token_id] *= penalty
22
23 return adjusted
24
25logits = torch.tensor([[2.4, -0.5, 0.7]])
26penalized = apply_repetition_penalty(logits, torch.tensor([[0, 1]]), penalty=1.2)
27print("original logits:", logits.tolist()[0])
28print("penalized logits:", [round(value, 3) for value in penalized.tolist()[0]])
29
30assert penalized[0, 0].item() < logits[0, 0].item()
31assert penalized[0, 1].item() < logits[0, 1].item()
32assert penalized[0, 2].item() == logits[0, 2].item()1original logits: [2.4000000953674316, -0.5, 0.699999988079071]
2penalized logits: [2.0, -0.6, 0.7]Frequency and presence penalties
A common formulation is:
Reading the formula
Two knobs discourage repetition. The frequency penalty grows with each use: if a token appears 5 times, it gets 5 times the penalty. The presence penalty is a flat one-time penalty the moment a token is used at all, encouraging topic diversity. Exact formulas and defaults vary by stack, but this captures the core idea.
Combining Strategies in Production
In practice, production systems stack several logit transformations together. The high-level mental model is stable:
- Start with raw logits.
- Apply logit processors such as penalties, masks, or forced-token constraints.
- Branch by policy. Deterministic decoding takes argmax from the adjusted logits.
- A sampling path applies warpers such as temperature, top-k, top-p, or min-p, then samples from the surviving distribution.
The exact order is implementation-specific. Some stacks apply temperature before truncation, while others place temperature later in the sampler chain. In interviews, don't memorize one canonical order. Know that these controls are layered transformations of the logits, and check the implementation of the stack you're using.
You move a chat product to a new inference engine and outputs change even though temperature and top-p stayed the same. What sampler detail do you verify first?
Answer
Verify the exact order of penalties, masks, temperature, truncation, and sampling. There is no universal sampler order across frameworks, so the same knobs can behave differently when those transformations are applied in a different sequence.
This concrete stack makes that layered mental model visible on one next-token step.

The corresponding function makes that order executable: apply the repetition penalty, branch to argmax at temperature 0, otherwise scale the logits and run nucleus sampling.
1import torch
2
3def apply_temperature(logits: torch.Tensor, temperature: float = 1.0) -> torch.Tensor:
4 if temperature <= 0:
5 raise ValueError("temperature must be > 0; use greedy decoding for temperature=0")
6 return logits / temperature
7
8def nucleus_sampling(logits: torch.Tensor, p: float = 0.9) -> torch.Tensor:
9 if not 0.0 < p <= 1.0:
10 raise ValueError("p must be in (0, 1]")
11
12 sorted_logits, sorted_indices = torch.sort(logits, descending=True, dim=-1)
13 sorted_probs = torch.softmax(sorted_logits, dim=-1)
14 cumulative_probs = torch.cumsum(sorted_probs, dim=-1)
15
16 sorted_indices_to_remove = cumulative_probs > p
17 sorted_indices_to_remove[..., 1:] = sorted_indices_to_remove[..., :-1].clone()
18 sorted_indices_to_remove[..., 0] = False
19
20 indices_to_remove = torch.zeros_like(logits, dtype=torch.bool)
21 indices_to_remove.scatter_(dim=-1, index=sorted_indices, src=sorted_indices_to_remove)
22
23 filtered_logits = logits.clone()
24 filtered_logits[indices_to_remove] = float("-inf")
25 probs = torch.softmax(filtered_logits, dim=-1)
26 return torch.multinomial(probs, 1)
27
28def apply_repetition_penalty(
29 logits: torch.Tensor,
30 generated_ids: torch.Tensor,
31 penalty: float = 1.2,
32) -> torch.Tensor:
33 if penalty <= 0:
34 raise ValueError("penalty must be positive")
35
36 adjusted = logits.clone()
37 for batch_index in range(adjusted.shape[0]):
38 for token_id in torch.unique(generated_ids[batch_index]):
39 if adjusted[batch_index, token_id] > 0:
40 adjusted[batch_index, token_id] /= penalty
41 else:
42 adjusted[batch_index, token_id] *= penalty
43 return adjusted
44
45def sample_next_token(
46 logits: torch.Tensor,
47 generated_ids: torch.Tensor,
48 temperature: float = 0.8,
49 top_p: float = 0.9,
50 repetition_penalty: float = 1.1,
51) -> torch.Tensor:
52 """One common pipeline: penalties -> temperature -> nucleus sampling."""
53 logits = apply_repetition_penalty(logits, generated_ids, repetition_penalty)
54
55 if temperature == 0:
56 return logits.argmax(dim=-1, keepdim=True)
57
58 logits = apply_temperature(logits, temperature)
59 return nucleus_sampling(logits, p=top_p)
60
61torch.manual_seed(0)
62logits = torch.tensor([[2.0, 1.2, 0.2, -1.0]])
63generated_ids = torch.tensor([[0, 1, 1]])
64
65greedy_token = sample_next_token(logits, generated_ids, temperature=0.0)
66sampled_token = sample_next_token(logits, generated_ids, temperature=0.8, top_p=0.9)
67
68print(f"greedy token id: {greedy_token.item()}")
69print(f"sampled token id: {sampled_token.item()}")1greedy token id: 0
2sampled token id: 1Evaluation configurations
| Evaluation slice | Temperature candidates | Sampler candidates | Repetition-penalty candidates | Goal |
|---|---|---|---|---|
| Code generation | 0.0, 0.2, 0.5 | Greedy, top-p=0.95 | 1.0, 1.05 | Tests and format validity |
| Factual QA | 0.0, 0.2, 0.5 | Greedy, top-p=0.9 | 1.0, 1.05 | Grounded accuracy |
| Chat | 0.3, 0.7, 1.0 | Top-p=0.9, min-p=0.1 | 1.0, 1.1 | Helpfulness and repetition |
| Creative writing | 0.7, 1.0, 1.3 | Top-p=0.95, min-p=0.05 | 1.0, 1.1 | Diversity and coherence |
| Published DeepSeek-R1 pass@1 setting | 0.6 | Top-p=0.95 | Not reported | One evaluation configuration[8] |
The first four rows are candidate sweeps, not recommended defaults. The right settings depend on the model, tokenizer, task, and measured failure costs.
Choosing a policy by task
When choosing a decoding strategy for a new application, first determine whether output variance is allowed and which failures matter. Then compare candidate algorithms on task metrics, repetition, format validity, and latency.
| Strategy | Deterministic? | Adapts to context? | Candidate evaluation fit |
|---|---|---|---|
| Greedy | Yes | No | Extraction, classification |
| Beam search | Yes | No | Translation, summarization |
| Top-k | No | No | Simple truncation baseline |
| Top-p | No | Yes, by cumulative mass | General generation |
| Min-p | No | Yes | Confidence-scaled truncation to compare with top-p |
| Temperature | Modifier | Modifier | Controls global diversity |
| Rep penalty | Modifier | Modifier | Suppresses repeated-token reuse |
What to check before moving on
| Decision | Pass bar |
|---|---|
| Greedy vs sampling | You can name one task where greedy is the right default and one where it will likely sound degenerate. |
| Beam search | You can explain why beam width can help translation yet hurt open-ended chat, and when length penalty matters. |
| Temperature | You can connect lower or higher temperature to the actual shape change in one worked probability distribution. |
| Top-k vs top-p vs min-p | You can choose which truncation rule better fits a peaked distribution and which better fits a flat one. |
| Penalty knobs | You can explain when repetition, frequency, and presence penalties solve style loops versus when they don't touch factual errors. |
| Production choice | You can defend one sampler stack for factual QA, one for code, and one for creative chat under a real latency or quality goal. |
| Implementation bugs | You can name two runtime mistakes that change outputs even when the visible knobs look the same. |
Common failures and fixes
Beam search made chat sound robotic
-
Symptom: Answers became safer and more repetitive after you increased beam width.
-
Likely cause: Beam search is pushing toward the most likely overall continuation, which is often generic in open-ended dialogue.
-
Fix: Use beam search for translation or tightly grounded summarization. For chat, switch back to a sampling strategy and tune temperature plus truncation instead.
Temperature tweak didn't fix hallucinations
-
Symptom: You lowered temperature, but the model still states wrong facts confidently.
-
Likely cause: Temperature changes distribution sharpness, not factual grounding. If the model lacks evidence, it can still choose a wrong but high-probability continuation.
-
Fix: Keep decoding conservative for factual tasks, but fix retrieval, prompt grounding, or output constraints rather than treating temperature as a truth knob.
High temperature plus top-p pulled in nonsense tail tokens
-
Symptom: Creative mode started producing weird fragments or off-topic words.
-
Likely cause: Higher temperature flattened the distribution, so top-p admitted a long tail of individually weak candidates.
-
Fix: Lower temperature, tighten top-p, or test min-p so the cutoff tracks the strength of the best token.
Fixed top-k clipped valid options
-
Symptom: Outputs stayed narrow in creative prompts and erratic in factual prompts with the same
k. -
Likely cause: Top-k doesn't adapt to distribution shape. The same cutoff is too small in flat contexts and too large in peaked ones.
-
Fix: Treat top-k as a simple baseline. Compare top-p or min-p when candidate-set size should react to distribution shape.
Same knobs changed behavior after a runtime swap
-
Symptom: Another engine gives different outputs even with matching documented settings.
-
Likely cause: Sampler order, default values, or special cases such as
temperature=0differ across implementations. -
Fix: Inspect the actual sampler path, log intermediate logits if needed, and validate behavior on representative prompts before blaming the model.
temperature=0 was sent through the sampling math directly
-
Symptom: The runtime crashes, returns NaNs, or behaves inconsistently when someone sets temperature to zero.
-
Likely cause: The implementation divided logits by zero instead of treating
temperature=0as a greedy special case. -
Fix: Handle
temperature=0explicitly as deterministic decoding, and keep the sampling path only for temperatures greater than zero.
Truncated probabilities no longer sum to one
-
Symptom: Logged or returned survivor probabilities sum to less than one after truncation.
-
Likely cause: Tail probabilities were set to zero, but code that consumes or reports probabilities still expects a normalized distribution. Some sampling APIs accept unnormalized nonnegative weights, but metrics and probability contracts usually don't.
-
Fix: Either mask logits with negative infinity and run softmax again, or divide surviving probabilities by their remaining mass before returning them.
Sources and further study
The papers behind these techniques are cited throughout the article: Holtzman et al. (2020) on neural text degeneration,[2] Meister et al. (2020) on beam search paradox,[4] Fan et al. (2018) on top-k sampling,[5] Wu et al. (2016) on GNMT beam-search length normalization,[3] and Nguyen et al. (2025) on min-p as a newer confidence-scaled variant,[6] along with a 2025 critical reanalysis that questions min-p's reported gains.[7]
Related articles
- Inference Mechanics: TTFT, TPS, and KV Cache - How decoding fits into the broader inference pipeline.
- Speculative Decoding - Accelerating generation by drafting tokens with a smaller model.
- Perplexity and Language Model Evaluation - Understanding why human text has high perplexity.
Practice drill
Create a decoding audit table for three production routes: factual policy answer, creative merchandising copy, and code completion.
- Pick baseline settings for temperature, top-p or top-k, repetition penalty, and stop conditions.
- Write two failure examples per route: one too deterministic, one too random or unsupported.
- Compare outputs under at least three setting changes, then mark accuracy, diversity, latency, and parseability.
- Choose one launch setting per route and write the rollback trigger that would force you to change it.
Good decoding work ends with an eval-backed operating policy, not a favorite temperature.