Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
A status assistant has one small-looking decision: finish The canary status is without turning a plausible token into a misleading incident update. A transformer gives its decoder a score for every next token, not a sentence.
Once those scores exist, decoding decides what reaches the user, one token at a time. That decision trades reproducibility, variety, and serving cost.
Keep this running example in view. Treat each word below as a single token. One model step might look like this:
| Token | Probability | Plausible continuation |
|---|---|---|
degraded | 0.45 | degraded |
in | 0.30 | in recovery |
on | 0.15 | on track |
broken | 0.02 | rare status word |
| tail | 0.08 | the rest of the vocabulary, each token tiny |
Greedy decoding writes degraded because it wins this step. A sampling policy might write in and continue into in recovery; on could lead to on track.
Same model, same prompt, different policy, different sentence. The decoder is choosing how much uncertainty to let through.
For top-p sampling at p = 0.90, the first three tokens reach exactly 0.45 + 0.30 + 0.15 = 0.90. Sampling then renormalizes that retained mass, so degraded, in, and on have probabilities 0.50, 0.333..., and 0.166....
The boundary matters: a cutoff that drops the token that reaches the threshold changes both candidate set and draw probabilities.
The earlier decoding algorithms lesson built stable softmax from raw next-token scores (logits) and covered baseline greedy, sampling, truncation, and beam mechanics. This lesson turns those mechanics into product decisions. We will predict a policy's next move, inspect a small implementation, then use its failure mode to choose a setting.
Speculative decoding is a serving trick that drafts tokens for the target model to verify. It isn't a sampling policy, so it doesn't replace the choices below.[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 a status reply, that can mean writing "degraded." instead of entering "in recovery".
Greedy's weakness appears when a first-step winner leads to a weak continuation. Keep the same two leading tokens and give each a single continuation:
| Path | First token | Continuation | Sequence probability |
|---|---|---|---|
degraded . | 0.45 | 0.40 | 0.18 |
in recovery | 0.30 | 0.90 | 0.27 |
Argmax at step one writes degraded and never looks at the other prefix. Multiply each pair to inspect what that commitment hides: in recovery is the better two-token sequence.
1first_step = {"degraded": 0.45, "in": 0.30}
2continuations = {"degraded": {".": 0.40}, "in": {"recovery": 0.90}}
3
4greedy_first = max(first_step, key=first_step.get)
5path_probabilities = {
6 "degraded .": first_step["degraded"] * continuations["degraded"]["."],
7 "in recovery": first_step["in"] * continuations["in"]["recovery"],
8}
9highest_sequence = max(path_probabilities, key=path_probabilities.get)
10
11print(f"greedy first token: {greedy_first}")
12print(f"sequence probabilities: { {key: round(value, 3) for key, value in path_probabilities.items()} }")
13print(f"higher-probability sequence: {highest_sequence}")
14
15assert greedy_first == "degraded"
16assert highest_sequence == "in recovery"
17assert path_probabilities["in recovery"] > path_probabilities["degraded ."]1greedy first token: degraded
2sequence probabilities: {'degraded .': 0.18, 'in recovery': 0.27}
3higher-probability sequence: in recoveryThe rule
Now name the policy. At each step, select the token with the highest probability:
The decoder scans the vocabulary, picks the winner, and commits. That costs per step: one pass over candidates. There is no search or random draw. If the local winner starts a weak sentence, decoder has no route back.
Use greedy when variability is a bug: extraction, classification-style labels, or a tightly constrained status enum. Open-ended chat has a different objective.
Holtzman et al. show that maximum-likelihood decoding can drift toward bland, repetitive text, while human-written text often sits at higher perplexity than those high-likelihood beams.[2] DeepSeek-R1 reported a similar failure on long reasoning traces: greedy decoding raised repetition and varied across checkpoints, which is why its published pass@1 setup samples instead.[3]
Beam search
Greedy keeps one prefix. Beam search keeps B scored prefixes and expands each of them. That extra memory is useful when a slightly weaker first token may unlock a stronger continuation.
Exact search considers the full vocabulary (or a very large candidate set) at every beam, then retains the top-B extended sequences. A cheaper pruned expansion lets each beam propose only its own top- next tokens before global reselection. That shortcut can miss a high-scoring path that starts with a lower-ranked token, so it isn't exhaustive search.
Run width 2 on the same two-step status reply. Predict first: which prefix survives step one, and which full sequence should win after step two?
1first_step = [("degraded", 0.45), ("in", 0.30), ("on", 0.15)]
2continuations = {
3 "degraded": [(".", 0.40)],
4 "in": [("recovery", 0.90)],
5 "on": [("track", 0.70)],
6}
7
8def keep_top(candidates, width):
9 return sorted(candidates, key=lambda item: item[1], reverse=True)[:width]
10
11step_one = keep_top(first_step, width=2)
12step_two = []
13for prefix, prefix_prob in step_one:
14 for token, token_prob in continuations[prefix]:
15 step_two.append((f"{prefix} {token}", prefix_prob * token_prob))
16
17winner = keep_top(step_two, width=2)[0]
18greedy = f"{first_step[0][0]} {continuations[first_step[0][0]][0][0]}"
19
20print(f"beam after step 1: {step_one}")
21print(f"beam after step 2: {[(text, round(prob, 3)) for text, prob in keep_top(step_two, width=2)]}")
22print(f"greedy path: {greedy}")
23print(f"beam winner: {winner[0]}")
24
25assert [token for token, _ in step_one] == ["degraded", "in"]
26assert greedy == "degraded ."
27assert winner[0] == "in recovery"1beam after step 1: [('degraded', 0.45), ('in', 0.3)]
2beam after step 2: [('in recovery', 0.27), ('degraded .', 0.18)]
3greedy path: degraded .
4beam winner: in recoveryThe result keeps degraded (0.45) and in (0.30) after step one, drops on (0.15), and returns in recovery at 0.27 after step two. Beam search recovered the path greedy discarded because it kept that second prefix alive.
Beam candidates can finish in two ways. An EOS token finalizes one early; hitting max_length finalizes every beam that is still active.
At the cutoff, compare completed and still-active candidates under the same score. A non-empty completed list can silently discard a truncated hypothesis that may be better.
Production decoders usually keep a finished flag on beams that remain in the active set and apply length normalization continuously, rather than only when a beam is moved aside.
Length penalty
Without a correction, longer sequences lose. Every extra token multiplies sequence probability by a value smaller than 1, making its log-probability more negative.
Google's Neural Machine Translation system divides sequence log-probability by a length term:
When , denominator is 1 and you are back to raw log-probability. Larger reduces short-sequence bias.
Wu et al. first tried dividing by and often landed near to on development data. The formula above is the later GNMT term.
In reported experiments they used with a separate coverage penalty unless noted otherwise. Neither number is a universal default. Tune on validation data for your model, stopping rule, and metric.[4]
The arithmetic below uses only to show ranking flip, not as a recommended setting. Watch which candidate wins before reading output.
1def gnmt_score(log_probability, length, alpha=0.6):
2 penalty = ((5 + length) / 6) ** alpha
3 return log_probability / penalty
4
5short = {"text": "degraded.", "log_probability": -1.00, "length": 2}
6complete = {"text": "in recovery mode", "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 == "degraded."
17assert normalized_choice == "in recovery mode"1raw sequence score chooses: degraded.
2length-normalized score chooses: in recovery modeA 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 a wider beam hurts
Beam search fits outputs with a constrained target meaning: translation, structured summarization, and some structured extraction. Open-ended chat is usually a poor fit. Holtzman et al. show that high-likelihood paths in open-ended generation are often generic and repetitive.[2]
There is a second warning even for machine translation. Meister et al. find that under a plain maximum a posteriori (MAP) objective, quality can drop as beam width grows. Searching farther into high-probability sequences can move you closer to a mode that is not the text people prefer.[5]
Width also has a serving cost. A width- search keeps up to active continuations. Efficient runtimes batch those hypotheses and reorder their KV-cache state, but larger beams still increase decoder work and memory relative to greedy.
For how that cache sits in the inference pipeline, wait until that later chapter. The practical rule is simple: pay for a wide beam only when the task wants sequence search.
Temperature scaling
Beam search asks which sequence scores highest. Sampling asks a different question: how much uncertainty should survive at each step?
Return to The canary status is. The original probabilities are 0.45, 0.30, 0.15, 0.02, and 0.08. Temperature changes their shape by acting on logits before softmax:
If you already have probabilities, renormalizing each individual is equivalent. It is not equivalent if you first glue the tail into one bucket and then raise that bucket to .
The table and figure below aggregate the tail on purpose so you can compare five readable numbers across temperatures. A real decoder never aggregates the tail first; it divides each token's logit by .
| Token | |||
|---|---|---|---|
degraded | 0.777 | 0.45 | 0.372 |
in | 0.201 | 0.30 | 0.284 |
on | 0.020 | 0.15 | 0.179 |
broken | 0.000 | 0.02 | 0.047 |
| tail | 0.002 | 0.08 | 0.118 |
| entropy (bits, five buckets) | 0.88 | 1.85 | 2.06 |
Read the columns as a prediction about variance. At , sampling usually still writes degraded; at , on and even broken are in play.
As , the draw collapses toward greedy. As , it approaches uniform over the vocabulary.
As a separate edge case, zero is not a valid divisor. Route temperature=0 to deterministic decoding instead of sending it through sampling math.
1from math import log2
2
3probs = [0.45, 0.30, 0.15, 0.02, 0.08]
4
5def temperature_from_probs(values, temperature):
6 if temperature <= 0:
7 raise ValueError("temperature must be > 0; use greedy decoding for temperature=0")
8 weights = [value ** (1.0 / temperature) for value in values]
9 total = sum(weights)
10 return [weight / total for weight in weights]
11
12def entropy_bits(values):
13 return -sum(value * log2(value) for value in values if value > 0)
14
15low_t = temperature_from_probs(probs, 0.3)
16high_t = temperature_from_probs(probs, 1.5)
17print("T=0.3:", [round(value, 4) for value in low_t])
18print("T=1.5:", [round(value, 4) for value in high_t])
19print("entropy bits:", round(entropy_bits(low_t), 3), round(entropy_bits(probs), 3), round(entropy_bits(high_t), 3))
20
21assert round(low_t[0], 4) == 0.7766
22assert round(high_t[0], 4) == 0.3724
23assert entropy_bits(low_t) < entropy_bits(probs) < entropy_bits(high_t)
24
25try:
26 temperature_from_probs(probs, 0.0)
27except ValueError as error:
28 print("zero temperature:", error)1T=0.3: [0.7766, 0.201, 0.0199, 0.0, 0.0025]
2T=1.5: [0.3724, 0.2842, 0.179, 0.0467, 0.1177]
3entropy bits: 0.883 1.854 2.061
4zero temperature: temperature must be > 0; use greedy decoding for temperature=0
Dividing logits by stretches differences, so the top token takes more mass. Dividing by compresses those differences. Temperature is a global sharpness knob.
Temperature sees neither retrieved evidence nor factual support, and it leaves the tail in play. Put a temperature sweep beside task metrics, not in their place:
| Evaluation slice | Temperature candidates | What to measure |
|---|---|---|
| Code generation | 0.0, 0.2, 0.5 | Tests passed, format validity, diversity |
| Factual QA / status 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. Status 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
Temperature still leaves the whole vocabulary in play. Top-k sampling, popularized for neural story generation, keeps only the most probable tokens, then renormalizes and samples.[6]
On the canary-status step, keeps degraded and in, then drops on, broken, and the tail. Their 0.45 and 0.30 weights become 0.60 and 0.40 after renormalization.
The fixed count is both top-k's appeal and its weakness. Ask how peaked each context is before choosing :
| Context | Distribution shape | Candidate values to evaluate |
|---|---|---|
The rollback runbook says | Potentially peaked | 1, 3, 10 |
The incident summary should | Potentially flatter | 10, 30, 50 |
The service | Prompt-dependent | Measure rather than assume |
When the distribution is peaked, drags in dozens of irrelevant tail tokens. A high temperature can give that tail enough mass to be drawn.
When distribution is flat, can cut off valid continuations. The count ignores confidence, which is why later methods moved to a dynamic cutoff.
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 ranked = sorted(tokens_and_probs, key=lambda item: item[1], reverse=True)
3 return [token for token, _ in ranked[:k]]
4
5def top_p_tokens(tokens_and_probs, p):
6 kept = []
7 cumulative = 0.0
8 for token, probability in sorted(tokens_and_probs, key=lambda item: item[1], reverse=True):
9 kept.append(token)
10 cumulative += probability
11 if cumulative >= p:
12 break
13 return kept
14
15distribution = [
16 ("degraded", 0.45),
17 ("in", 0.30),
18 ("on", 0.15),
19 ("paused", 0.04),
20 ("held", 0.03),
21 ("broken", 0.02),
22 ("rollback", 0.01),
23]
24
25assert top_k_tokens(distribution, 2) == ["degraded", "in"]
26assert top_p_tokens(distribution, 0.8) == ["degraded", "in", "on"]Nucleus (top-p) sampling
Top-k with keeps fifty tokens even when two are plausible or when hundreds deserve a look. Nucleus sampling, also called top-p, changes the question from “how many?” to “how much mass?”
It keeps adding highest-probability tokens until cumulative mass reaches threshold .[2]
Using the same canary-status table, includes degraded, in, and on (cumulative 0.90) and excludes broken plus the tail. If degraded had 0.85, nucleus would be one token.
Here are sorted by decreasing probability. After truncation, divide each kept probability by kept mass so nucleus sums to 1.
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)]At fixed , survivor count is a function of distribution shape, not a hidden constant:
| Sorted probabilities | Smallest prefix reaching 0.90 | 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.94 | 6 |
Real nucleus size also depends on model, tokenizer, prompt, and temperature. The threshold stays fixed; candidate count moves with confidence.
The long-tail failure at high
Top-p has a failure mode to measure at high temperatures (). Flattening gives many weak tokens enough combined mass to enter the cumulative set.
A nucleus can therefore get large even when no individual tail token is a good next word. That is one motivation for confidence-scaled cutoffs 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 focused == 1
22assert flattened == 3
23assert flattened > focused1nucleus size at T=0.7: 1
2nucleus size at T=1.5: 3Beyond nucleus: min-p
Top-p asks how many ranked tokens are needed to cover a chosen mass. Min-p asks a different question: is each token strong enough relative to the best token? Keep tokens whose probability is at least a fraction of that peak.
A dominant top token sets a high bar; a weak top token lowers it. The cutoff follows relative confidence, not a fixed cumulative sum. Nguyen et al. (ICLR 2025 oral) define the kept set as:[7]
If the top token has probability 0.80 and , anything below 0.08 is dropped. If the top token has only 0.05, the bar falls to 0.005, so more alternatives survive.
| 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 |
| High temperature () | Flattened tails can enter the nucleus | Relative cutoff can trim more of that tail |
Use the figure as a prediction test. On a peaked split, which rule should keep the fewest candidates? On a flatter split, which rule should expand its set? The same token names make the three answers easy to compare.

Run the small implementation after making that prediction. It returns survivors before any renormalization, so the threshold is visible in isolation.
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 = [("degraded", 0.80), ("in", 0.09), ("broken", 0.01)]
7uncertain = [("degraded", 0.05), ("in", 0.04), ("on", 0.03)]
8
9assert min_p_tokens(confident, rho=0.1) == ["degraded", "in"]
10assert min_p_tokens(uncertain, rho=0.1) == ["degraded", "in", "on"]Min-p is a newer truncation heuristic to evaluate, not a settled replacement for nucleus sampling. Nguyen et al. propose it for the high-temperature tail that top-p can admit.[7]
A later critical reanalysis reports that min-p did not reliably improve quality-diversity tradeoffs against commonly used samplers in its experiments, and disputes broad adoption claims.[8]
Treat any published setting, including DeepSeek-R1's temperature 0.6 with top-p 0.95 pass@1 configuration, as an experiment input for that model, not a preset for yours.[3]
| 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
When a generated answer loops, the sampler is reusing tokens that remain too attractive:
"The deploy rolled back. The deploy rolled back. The deploy rolled..."
CTRL introduced penalized sampling: discount logits of tokens already in the generated list. Keskar et al. reported that near-greedy sampling with balanced truthful generation against repetition in their experiments.[9]
Their original rule scales the logit of a seen token through a temperature multiplier . Modern runtimes expose related variants; inspect the transform instead of inferring it from the name.
Common runtimes such as Hugging Face Transformers expose a related sign-aware processor.
For a repeated token, divide a positive logit by the penalty and multiply a negative logit by it. Both move down in preference rather than toward zero. See RepetitionPenaltyLogitsProcessor.
The function below mirrors that runtime behavior:
1def apply_repetition_penalty(logits, generated_ids, penalty=1.2):
2 if penalty <= 0:
3 raise ValueError("penalty must be positive")
4 adjusted = list(logits)
5 for token_id in set(generated_ids):
6 if adjusted[token_id] > 0:
7 adjusted[token_id] /= penalty
8 else:
9 adjusted[token_id] *= penalty
10 return adjusted
11
12logits = [2.4, -0.5, 0.7]
13penalized = apply_repetition_penalty(logits, generated_ids=[0, 1], penalty=1.2)
14print("original logits:", logits)
15print("penalized logits:", [round(value, 3) for value in penalized])
16
17assert penalized[0] < logits[0]
18assert penalized[1] < logits[1]
19assert penalized[2] == logits[2]1original logits: [2.4, -0.5, 0.7]
2penalized logits: [2.0, -0.6, 0.7]Token 0 was positive, so the penalty divides it (2.4 / 1.2 = 2.0). Token 1 was negative, so the penalty multiplies it (-0.5 × 1.2 = -0.6). Token 2 never appeared, so its logit stays unchanged.
A naive “divide every seen logit” rule would move -0.5 toward zero and make that token more likely. The sign branch is the important detail.
Frequency and presence penalties
A different pair of controls changes how often a token has appeared:
The frequency penalty grows with each reuse: five copies of a token cost . The presence penalty is a one-time hit after first appearance, which pushes the model off a topic rather than off a word.
Exact formulas and defaults vary by stack. Neither knob repairs a missing fact. They only make already-seen tokens less attractive.
Combining strategies in production
At each generation step, a production system may stack several logit transforms, then branch into deterministic or stochastic selection. Before reading the diagram, predict where a temperature=0 request should go.

- Start with raw logits.
- Apply processors such as penalties, banned-token masks, or forced-token constraints.
- If the policy is deterministic, take argmax on the adjusted logits.
- If the policy samples, warp with temperature, then truncate with top-k, top-p, or min-p, then draw.
That list is a conceptual path, not a universal contract. Exact order is implementation-specific.
Some stacks apply temperature before truncation; others place temperature later. Don't memorize one canonical chain. Know that these controls are layered transforms, and read the stack you are shipping.
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.
Use the next figure as a dry run of that stack. degraded already appeared in the prompt, so a 1.2 penalty drops it from 2.4 to 2.0, below in at 2.1. Greedy now emits in.
After softmax, the penalized logits are approximately in 0.404, degraded 0.365, on 0.164, and broken 0.067. A nucleus keeps the first three (cumulative 0.933) and drops broken.
The illustrated sample is on, inside the nucleus but not the mode. That is the point of a sampling path: its draw need not be argmax.

Runnable code uses that same conceptual order: penalty, then a greedy branch at temperature 0, otherwise temperature plus nucleus sampling.
random.Random(0) makes the sampled token reproducible in this lesson.
1import math
2import random
3
4def apply_repetition_penalty(logits, generated_ids, penalty=1.2):
5 adjusted = list(logits)
6 for token_id in set(generated_ids):
7 if adjusted[token_id] > 0:
8 adjusted[token_id] /= penalty
9 else:
10 adjusted[token_id] *= penalty
11 return adjusted
12
13def softmax(logits):
14 peak = max(logits)
15 weights = [math.exp(logit - peak) for logit in logits]
16 total = sum(weights)
17 return [weight / total for weight in weights]
18
19def nucleus_sample(logits, threshold, rng):
20 probs = softmax(logits)
21 ranked = sorted(enumerate(probs), key=lambda item: item[1], reverse=True)
22 kept = []
23 mass = 0.0
24 for index, probability in ranked:
25 kept.append((index, probability))
26 mass += probability
27 if mass >= threshold:
28 break
29 tokens, weights = zip(*kept)
30 return rng.choices(tokens, weights=weights, k=1)[0]
31
32def sample_next_token(logits, generated_ids, temperature, top_p, rng):
33 logits = apply_repetition_penalty(logits, generated_ids, penalty=1.2)
34 if temperature == 0:
35 return max(range(len(logits)), key=lambda index: logits[index])
36 scaled = [logit / temperature for logit in logits]
37 return nucleus_sample(scaled, threshold=top_p, rng=rng)
38
39logits = [2.4, 2.1, 1.2, 0.3]
40generated_ids = [0]
41rng = random.Random(0)
42
43greedy_id = sample_next_token(logits, generated_ids, temperature=0, top_p=0.9, rng=rng)
44sampled_id = sample_next_token(logits, generated_ids, temperature=1.0, top_p=0.9, rng=rng)
45names = ["degraded", "in", "on", "broken"]
46print(f"greedy token: {names[greedy_id]}")
47print(f"sampled token: {names[sampled_id]}")
48
49assert greedy_id == 1
50assert names[greedy_id] == "in"
51assert sampled_id == 2
52assert names[sampled_id] == "on"1greedy token: in
2sampled token: onWith seed 0, the sampled path lands on on, matching the figure. Nucleus sampling can redraw the mode or another kept token.
on is one legal draw from this nucleus, not a second deterministic answer.
Evaluation 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[3] |
The first four rows are candidate sweeps, not recommended defaults. Compare them on held-out prompts. The right settings depend on model, tokenizer, task, and measured failure costs.
Choosing a policy by task
Choose a policy from the output contract, not from a favorite knob. First decide how much output variance the endpoint can tolerate and which failures cost most. Then compare candidates 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 |
The first five rows choose how candidates are searched or sampled. Temperature and repetition penalty modify that choice. A modifier can improve one symptom while making another worse, so evaluate the stack together.
What to check before moving on
Use these pass bars as predictions you can defend from examples, not as settings to memorize.
| 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
Answers became safer and more repetitive after you increased beam width. Inspect the objective first: beam search is pushing toward a most likely overall continuation, which is often generic in open-ended dialogue.
Keep beam search for translation or tightly grounded summarization, where several candidate paths can help. For chat, switch back to sampling and tune temperature plus truncation against a quality and latency target.
Temperature tweak didn't fix hallucinations
You lowered temperature, but the model still states wrong facts confidently. Temperature changes distribution sharpness, not factual grounding. If the model lacks evidence, a wrong continuation can still be high probability.
Keep decoding conservative for factual tasks, but fix retrieval, prompt grounding, or output constraints. Temperature is not a truth knob.
High temperature plus top-p pulled in nonsense tail tokens
Creative mode started producing weird fragments or off-topic words. Higher temperature flattened the distribution, so top-p admitted a long tail of individually weak candidates.
Lower temperature, tighten top-p, or test min-p. Min-p makes the cutoff track strength of the best token instead of only cumulative mass.
Fixed top-k clipped valid options
Outputs stayed narrow on creative prompts and erratic on factual prompts with the same k. Top-k uses the same cutoff in every shape: too small in flat contexts and too large in peaked ones.
Treat top-k as a simple baseline. Compare top-p or min-p when candidate-set size should react to model confidence.
Same knobs changed behavior after a runtime swap
Another engine gives different outputs even with matching documented settings. Sampler order, defaults, or special cases such as temperature=0 can differ across implementations.
Inspect the actual sampler path and log intermediate logits if needed. Validate representative prompts before blaming model weights.
temperature=0 was sent through the sampling math directly
The runtime crashes, returns NaNs, or behaves inconsistently when someone sets temperature to zero. The implementation divided logits by zero instead of treating temperature=0 as a greedy special case.
Handle temperature=0 explicitly as deterministic decoding. Keep sampling path only for temperatures greater than zero.
Truncated probabilities no longer sum to one
Logged or returned survivor probabilities sum to less than one after truncation. Tail probabilities were set to zero, but downstream code still expects a normalized distribution. Some sampling APIs accept unnormalized nonnegative weights; metrics and probability contracts expect normalized probabilities.
Mask logits with negative infinity and run softmax again, or divide surviving probabilities by remaining mass before returning them.
Practice drill
Create one decoding audit table for three production routes: a factual status answer, a creative incident narrative, and code completion. Each route has a different cost for variance, so run its experiment separately instead of letting one global preset decide.
- 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.
Finish by recording an evaluation-backed decoding policy for each task, model, and tokenizer. The record should explain which failure the setting is meant to prevent and which measurement would make you revisit it.