Sampling from scratch in NumPy
By the end: You can implement greedy, temperature, top-k and nucleus sampling over a logits vector and explain, with numbers, why top-p adapts to the model's confidence where top-k cannot.
- Greedy decoding as argmax
- Temperature as a division before the softmax
- Top-k truncation with argpartition
- Nucleus sampling with a sorted cumulative sum
- Why a fixed k is wrong on both a confident and an uncertain step
- The order the dials are applied in
New to sampling & decoding? A model never picks the next token outright. It produces a probability for every token it could say next, and something has to choose one from that spread. Sampling is that choice, and temperature and top-p are the dials on it: low settings make the model take the likeliest option almost every time, higher settings let less likely ones through, which reads as more variety and more invention. Why it matters
You already know what temperature and top-p do to the output of an API. This section writes them, because the from-scratch version is a common warm-up question and because implementing nucleus sampling is the shortest way to understand the argument for it. Everything here operates on one vector of logits — the raw scores the model produces for every token in the vocabulary, before anything turns them into probabilities.
The softmax and greedy decoding#
Logits are unnormalised and can be any real number. The softmax turns them into a distribution; greedy decoding skips it entirely, because the largest logit is the largest probability and argmax does not care about the monotonic transformation in between.
import numpy as np
def softmax(logits):
z = logits - logits.max() # stability: the largest exponent becomes exp(0)
e = np.exp(z)
return e / e.sum()
def greedy(logits):
return int(np.argmax(logits)) # no softmax needed: argmax is unchanged by itTemperature#
Temperature divides the logits before the softmax. Below one, the gaps between logits widen and the distribution sharpens; above one they narrow and it flattens. In the limit as temperature goes to zero the distribution becomes one-hot on the argmax, which is why temperature zero and greedy are the same operation — and why a real implementation special-cases it rather than dividing by zero.
def sample_temperature(logits, temperature, rng):
if temperature == 0.0:
return greedy(logits) # the limit, not a division
p = softmax(logits / temperature)
return int(rng.choice(len(p), p=p))Note what temperature cannot do. It rescales the gaps between logits, so where one token is already far ahead of the rest, even a high temperature leaves it far ahead. Variety appears only where the model was genuinely undecided.
Top-k#
Top-k keeps the k highest-scoring tokens, renormalises over them and samples. The point is to cut off the long tail of tokens with tiny but nonzero probability, which collectively hold enough mass to be chosen more often than you would like over a few hundred steps. Use argpartition rather than a full sort: it finds the k largest in linear time without ordering them, and order does not matter here.
def sample_top_k(logits, k, temperature, rng):
logits = logits / temperature
# the k largest, unordered - cheaper than argsort over a 100k vocabulary
keep = np.argpartition(logits, -k)[-k:]
p = softmax(logits[keep]) # renormalise over the survivors only
return int(rng.choice(keep, p=p))Nucleus sampling#
Top-p keeps the smallest set of tokens whose probabilities add up to at least p, then renormalises over that set. The size of the set is not fixed: it is whatever the model's confidence makes it. Sort descending, take the cumulative sum, and cut at the first index where the total crosses the threshold — including that index, so the set always has at least one member even when the top token alone exceeds p.
def sample_top_p(logits, top_p, temperature, rng):
logits = logits / temperature
order = np.argsort(logits)[::-1] # descending by probability
p = softmax(logits[order])
cum = np.cumsum(p)
# first index where the running total reaches the threshold, kept inclusively
cut = int(np.searchsorted(cum, top_p)) + 1
keep, p = order[:cut], p[:cut]
return int(rng.choice(keep, p=p / p.sum()))Why top-p adapts and top-k does not#
The argument is easier to run than to read. Take two logit vectors over a vocabulary of fifty: one peaked, where a single token holds 95% of the mass, and one flat, where the top token holds under 4%.
def nucleus_size(logits, top_p, temperature=1.0):
p = np.sort(softmax(logits / temperature))[::-1]
return int(np.searchsorted(np.cumsum(p), top_p)) + 1
rng = np.random.default_rng(0)
peaked = np.full(50, -6.0)
peaked[7], peaked[3] = 6.0, 3.0 # one clear winner, one runner-up
flat = rng.normal(size=50) * 0.4 # no token much ahead of any other
print(nucleus_size(peaked, 0.9)) # 1
print(nucleus_size(flat, 0.9)) # 42Now set k to ten and ask what it does on each. On the peaked step it admits nine tokens the model had all but ruled out, one of which will eventually be drawn; that is the mechanism behind a model producing a fluent sentence with one word that does not belong. On the flat step it discards thirty-two tokens the model considered roughly as plausible as the ones it kept, which flattens variety precisely where variety was warranted.
with k = 10
peaked step: top-k keeps 10 | top-p(0.9) keeps 1
flat step: top-k keeps 10 | top-p(0.9) keeps 42Temperature is applied before truncation, and it changes the nucleus
The usual order is temperature, then top-k, then top-p. Temperature cannot change which tokens are in a top-k set, because dividing by a positive number preserves order, but it does change the size of a nucleus: flattening the distribution spreads the mass, so more tokens are needed to reach p. On the flat row above, the nucleus at p=0.9 is 36 tokens at temperature 0.5, 42 at 1.0 and 44 at 2.0. Raising both dials compounds rather than adds.
Two checks worth running#
Sampling code is easy to get subtly wrong and hard to notice, because wrong output still looks like text. Both of these are cheap.
# temperature approaching zero must agree with greedy
assert all(sample_temperature(flat, 1e-6, rng) == greedy(flat) for _ in range(20))
# top-k with k = vocabulary size must reproduce the full distribution
draws = [sample_top_k(flat, 50, 1.0, rng) for _ in range(5000)]
empirical = np.bincount(draws, minlength=50) / 5000
assert np.abs(empirical - softmax(flat)).max() < 0.02Where people go wrong
Cutting the nucleus exclusively, at the first index where the cumulative sum reaches p rather than after it. On any step where the top token alone holds more than p of the probability mass — which is most steps in the middle of a common word — the kept set is empty, and depending on the implementation you get a crash, a uniform draw over nothing, or a silent fallback. The +1 is the function.
If this comes up in an interview
Implement top-p sampling given a vector of logits.
Divide the logits by the temperature, sort descending, softmax, take the cumulative sum, and find the first index where it reaches p. Keep everything up to and including that index, renormalise those probabilities so they sum to one, and draw from them. The inclusive cut matters: without it a step where the top token alone exceeds p keeps an empty set.
Then they push further
Why not just use a fixed k?
Because the right number of candidates depends on how confident the model is at that step, and that varies enormously. On a step where one token holds 95% of the mass, a nucleus at 0.9 is one token and k=10 admits nine the model had ruled out. On a genuinely open step the nucleus might be forty tokens and k=10 cuts off thirty the model thought equally plausible. Top-p reads the cut off the distribution instead of fixing it in advance.
Does temperature change which tokens survive top-k?
No, because dividing by a positive number is monotonic, so the ranking is unchanged and the same k tokens survive. It does change the probabilities they are then sampled with. It changes a top-p set, though: flattening the distribution spreads the mass, so more tokens are needed to reach the threshold.
What gets marked up. The inclusive cut and the renormalisation are what get checked. Sorting and cumulative-summing is the easy part; candidates who return an empty set on a confident step, or who forget to renormalise after truncating, are sampling from something that is not a distribution.
Why is greedy decoding the same as temperature zero?
Dividing the logits by a temperature approaching zero sends every gap between logits to infinity, so the softmax converges to one on the largest logit and zero everywhere else. Sampling from that distribution always returns the argmax. In code you special-case it rather than dividing by zero, but it is a limit and not an exception.
Why use argpartition rather than argsort for top-k?
Top-k needs the identity of the k largest, not their order, and argpartition finds them in time linear in the vocabulary rather than the n log n a full sort costs. On a vocabulary of a hundred thousand, executed for every token generated, that is a real difference. Nucleus sampling does need the sort, because it depends on the running total in descending order.
Build this: Implement all four samplers over a logits vector and check them. Confirm temperature 1e-6 agrees with greedy, and that top-k with k set to the vocabulary size reproduces the softmax distribution to within sampling error over five thousand draws. Then take a real model's logits at two positions — one mid-word, one at the start of a sentence — and print the nucleus size at p=0.9 for each.
This is one section of Sampling & decoding, 9 hours across 3 sections. Read it in full to get the whole topic in order.
Stop 10 of 50 on the AI Engineer route
0 of 50 topics done · 0h behind you, 603h to go
What you have marked done is stored in this browser only. It does not reach a server, and clearing site data will clear it.