← Math in Practice
Concept Math behind AI

Sampling strategies (greedy, top-k, top-p)

See how a next-token distribution becomes one chosen token, and keep decoding behavior separate from truth.

A support assistant gives different wording on two runs of the same question. A reviewer asks whether that is a bug, a sampling setting, or evidence that the system is unreliable. To investigate, follow one step of generation: the model scores candidate tokens, a decoding rule changes which candidates may be chosen, and a sampler selects one.

The judgment to keep

Greedy decoding always selects the highest-probability candidate. Top-k keeps a fixed number of candidates; top-p keeps the smallest high-probability prefix whose cumulative probability reaches p. Sampling then renormalizes the retained mass. These rules control variation, not whether a statement is supported by evidence.

TypeScriptGo Logits · temperature · greedy decoding · top-k · top-p · renormalization · reproducibility
01 / Read the output review

A changed sentence can be expected behavior without being a useful answer.

Imagine a team evaluating a support assistant that drafts a password-reset explanation. With the same prompt, one run says “open the account page” and another says “visit account settings.” The wording differs, so the reviewer wants to know what changed. First compare the exact prompt, model/version, decoding settings, and any retrieved context. Then inspect the next-token scores and the sampling rule rather than guessing from the finished paragraph.

We will use five artificial logits for one generation step. A real model has a much larger vocabulary and context-dependent scores. This small set makes truncation and renormalization visible; it is not a model of answer quality.

Case file / Support assistant reviewSame request, two phrasings, and an open question about expected variation.
Observation
The two outputs use different wording; correctness has not yet been checked.
Competing explanations
Sampling, changed model or prompt, nondeterministic serving, or a change in retrieved context.
First evidence
Record model/version, full input, decoding parameters, seed if supported, and the source material used to answer.
02 / Start with candidate scores

Logits are relative scores; softmax turns them into a distribution.

For this step, let five candidate tokens have logits 4, 3, 2, 1, and 0. A logit is an unnormalized model score, not a percent. At temperature 1, softmax assigns each score exp(logit) / Σ exp(logit). Subtracting the largest logit before exponentiating keeps the arithmetic numerically stable and does not change the probabilities.

The resulting approximate probabilities are 0.636, 0.234, 0.086, 0.032, and 0.012. They sum to 1 before any truncation. The model's largest score corresponds to “the,” but the rest of the distribution describes alternatives available at this single position, not complete sentence probabilities.

Illustrative logits at temperature 1; probabilities rounded to three decimals.
CandidateLogitApprox. probability
the40.636
a30.234
our20.086
this10.032
one00.012
03 / Compare decoding rules

Each rule changes the candidate set before a token is drawn.

Greedy selects the top candidate, so its probability becomes 1 after truncation. It is deterministic for fixed scores and tie-breaking, though a full service can still vary because of model or serving changes.

Top-k retains the k highest-scoring tokens. With k = 3, the unnormalized retained mass is approximately 0.636 + 0.234 + 0.086 = 0.956. Renormalizing gives about 0.665, 0.245, and 0.090. The lower two candidates cannot be chosen at this step.

Top-p, also called nucleus sampling, sorts candidates from most to least likely and retains the shortest prefix whose cumulative probability reaches p. At p = 0.8, the first two sum to 0.870, so only those two remain; after renormalization their probabilities are about 0.731 and 0.269. The number of candidates changes with the shape of the distribution.

Greedy1 candidate

Highest score only; no random choice at this position.

Top-k, k = 33 candidates

Fixed count; retain 0.956 of original mass, then rescale to 1.

Top-p, p = 0.82 candidates

Smallest prefix reaching 0.8; retain 0.870, then rescale.

04 / Change the temperature

Temperature reshapes scores before top-k or top-p filters them.

For positive temperature T, softmax uses logit / T. Below 1, gaps become larger and probability concentrates on the highest scores. Above 1, gaps shrink and the distribution flattens. At T = 1 the original softmax is unchanged. Temperature does not create new candidates or make weak evidence reliable.

Because top-p looks at cumulative probability, changing temperature can change how many candidates cross the p threshold. With top-k, the retained count remains k, though the probabilities within that set still change. Order remains the same for finite positive T when logits are unchanged.

Diagnostic question: If output diversity changed after a deployment, compare the score distribution and temperature first, then inspect top-k/top-p values and implementation defaults. Do not attribute the change to temperature without checking the model, prompt, and serving path.
05 / Inspect a toy distribution

Change one setting and watch the retained mass get renormalized.

The simulator uses the same five fixed logits from the table. The seeded draw exists so the exercise can be replayed; its small linear-congruential generator is not suitable for security, and a seed alone may not reproduce outputs across different model versions or serving systems.

Distribution after filtering and renormalization. Removed candidates have probability zero.
TokenLogitRetainedFinal probability
the4yes0.731
a3yes0.269
our2no0.000
this1no0.000
one0no0.000
Replayable teaching drawthe

With this toy sampler and seed 7, the selected token is shown above. This is a single draw, not a claim about which continuation is best.

06 / Practice in code

Keep sorting, truncation, and renormalization explicit.

The examples apply softmax stably by subtracting the maximum scaled logit, choose a retained prefix, then normalize only its weights. Both languages use the same artificial candidates and include input guards. They illustrate one distribution step, not a full text-generation engine.

Compare the same decoding rules in TypeScript and Go.

Both snippets use the five illustrative logits and the same top-p cutoff.

TypeScriptFilter and renormalize a token distribution
sampling-strategies.ts
export type Candidate = { token: string; logit: number };
export type Strategy = 'greedy' | 'top-k' | 'top-p';
export type RankedCandidate = { token: string; probability: number; retained: boolean };

export const exampleCandidates: Candidate[] = [
	{ token: 'the', logit: 4 },
	{ token: 'a', logit: 3 },
	{ token: 'our', logit: 2 },
	{ token: 'this', logit: 1 },
	{ token: 'one', logit: 0 }
];

export function distribution(
	candidates: Candidate[],
	strategy: Strategy,
	options: { temperature: number; k: number; p: number }
): RankedCandidate[] {
	const { temperature, k, p } = options;
	if (!Number.isFinite(temperature) || temperature <= 0)
		throw new RangeError('temperature must be > 0');
	if (!Number.isInteger(k) || k < 1) throw new RangeError('k must be an integer >= 1');
	if (!Number.isFinite(p) || p <= 0 || p > 1) throw new RangeError('p must be in (0, 1]');
	if (candidates.length === 0) return [];
	if (candidates.some((candidate) => !Number.isFinite(candidate.logit))) {
		throw new TypeError('candidate logits must be finite');
	}

	const sorted = candidates
		.map((candidate, index) => ({ ...candidate, index }))
		.sort((a, b) => b.logit - a.logit || a.index - b.index);
	const maxLogit = sorted[0].logit;
	const weights = sorted.map((candidate) => Math.exp((candidate.logit - maxLogit) / temperature));
	const total = weights.reduce((sum, weight) => sum + weight, 0);
	const probabilities = weights.map((weight) => weight / total);
	let retainedCount = sorted.length;
	if (strategy === 'greedy') retainedCount = 1;
	if (strategy === 'top-k') retainedCount = Math.min(k, sorted.length);
	if (strategy === 'top-p') {
		let cumulative = 0;
		retainedCount = 0;
		for (const probability of probabilities) {
			cumulative += probability;
			retainedCount += 1;
			if (cumulative >= p) break;
		}
	}
	const retainedWeight = weights.slice(0, retainedCount).reduce((sum, weight) => sum + weight, 0);
	return sorted.map((candidate, index) => ({
		token: candidate.token,
		probability: index < retainedCount ? weights[index] / retainedWeight : 0,
		retained: index < retainedCount
	}));
}

// A seeded sampler is useful for a reproducible teaching trace, not for secrets or production sampling.
export function sampleWithSeed(candidates: RankedCandidate[], seed: number): string {
	if (!Number.isInteger(seed) || seed < 0 || seed > 0xffffffff)
		throw new RangeError('seed must be uint32');
	const total = candidates.reduce((sum, candidate) => sum + candidate.probability, 0);
	if (total <= 0) throw new RangeError('distribution must have positive mass');
	const random = ((Math.imul(seed >>> 0, 1664525) + 1013904223) >>> 0) / 0x100000000;
	let cumulative = 0;
	for (const candidate of candidates) {
		cumulative += candidate.probability / total;
		if (random < cumulative) return candidate.token;
	}
	return candidates.at(-1)!.token;
}
GoFilter and renormalize a token distribution
sampling-strategies.go
package main

import (
	"errors"
	"fmt"
	"math"
	"sort"
)

type Candidate struct {
	Token string
	Logit float64
}

type RankedCandidate struct {
	Token       string
	Probability float64
	Retained    bool
}

func Distribution(candidates []Candidate, strategy string, temperature float64, k int, p float64) ([]RankedCandidate, error) {
	if math.IsNaN(temperature) || math.IsInf(temperature, 0) || temperature <= 0 {
		return nil, errors.New("temperature must be finite and greater than zero")
	}
	if k < 1 {
		return nil, errors.New("k must be at least one")
	}
	if math.IsNaN(p) || p <= 0 || p > 1 {
		return nil, errors.New("p must be in (0, 1]")
	}
	if strategy != "greedy" && strategy != "top-k" && strategy != "top-p" {
		return nil, errors.New("strategy must be greedy, top-k, or top-p")
	}
	for _, candidate := range candidates {
		if math.IsNaN(candidate.Logit) || math.IsInf(candidate.Logit, 0) {
			return nil, errors.New("candidate logits must be finite")
		}
	}
	sorted := append([]Candidate(nil), candidates...)
	sort.SliceStable(sorted, func(i, j int) bool { return sorted[i].Logit > sorted[j].Logit })
	if len(sorted) == 0 {
		return []RankedCandidate{}, nil
	}
	maxLogit := sorted[0].Logit
	weights := make([]float64, len(sorted))
	var total float64
	for i, candidate := range sorted {
		weights[i] = math.Exp((candidate.Logit - maxLogit) / temperature)
		total += weights[i]
	}
	retained := len(sorted)
	switch strategy {
	case "greedy":
		retained = 1
	case "top-k":
		if k < retained {
			retained = k
		}
	case "top-p":
		var cumulative float64
		retained = 0
		for _, weight := range weights {
			cumulative += weight / total
			retained++
			if cumulative >= p {
				break
			}
		}
	}
	var keptWeight float64
	for _, weight := range weights[:retained] {
		keptWeight += weight
	}
	result := make([]RankedCandidate, len(sorted))
	for i, candidate := range sorted {
		result[i] = RankedCandidate{Token: candidate.Token, Retained: i < retained}
		if i < retained {
			result[i].Probability = weights[i] / keptWeight
		}
	}
	return result, nil
}

func main() {
	candidates := []Candidate{{"the", 4}, {"a", 3}, {"our", 2}, {"this", 1}, {"one", 0}}
	for _, strategy := range []string{"greedy", "top-k", "top-p"} {
		distribution, err := Distribution(candidates, strategy, 1, 3, 0.8)
		if err != nil {
			panic(err)
		}
		fmt.Println(strategy)
		for _, candidate := range distribution {
			fmt.Printf("  %-5s retained=%-5t p=%.3f\n", candidate.Token, candidate.Retained, candidate.Probability)
		}
	}
}
07 / Check the claim

Decoding settings describe selection; evaluation establishes usefulness.

Greedy, top-k, and top-p answer how to choose a candidate from model scores. They do not prove the prompt is well-formed, the scores reflect the intended task, retrieved material is trustworthy, or a response is correct. A lower-entropy output can be consistently wrong; a varied output can still be well-grounded.

When debugging output differences, preserve a reproducible record: exact prompt and context, model/version, decoding settings, seed if the interface supports it, and output. Then evaluate each answer against explicit task criteria and cited evidence. If the same settings yield a different result, investigate version changes, parallel execution, tie-breaking, and service-level nondeterminism instead of assuming that the sampling parameter is the only cause.