Categorical sampling: inverse CDF, Gumbel-max, alias method
Overview
Section titled “Overview”| Module | M07.1 · build · Python · Pass 3 · 3 to 4 h |
| You build | python/tinyllm/prob/sampling.py: sample_categorical, gumbel_noise, gumbel_max, AliasTable (prob, alias, sample), exponential_icdf, poisson_arrivals |
| Contract | course/contracts/py/tinyllm/prob/sampling.pyi · the sampler’s op order: spec/sampling.md · the generator: spec/pcg32.md |
| Tests | course/tests/M07.1/ (what they check: section 4) |
| Needs | no code from earlier modules · reading: M06.3 PCG32 (your rng), M07.0 random variables and the uniform, M00.1 logs |
| Used by | later: L8.1 the sampler’s step 11 · L2.3 word2vec negative sampling · L6.2 BERT’s 80/10/10 masking · M07.4 and M07.6 resampling · load.01 re-implements the Poisson schedule in Go · later: L6.3 |
| Milestone | MS-P3 (tokens and data) |
| Optional depth | Devroye, Non-Uniform Random Variate Generation (1986), ch. 2 and 3; Vose, “A Linear Algorithm for Generating Random Numbers with a Given Distribution” (1991) |
Key Takeaways
Section titled “Key Takeaways”- The inverse CDF turns one uniform into id exactly when , an interval of length ; strict keeps zero-probability ids out (
test_hand_example_inverse_cdf,test_inverse_cdf_exact_enumeration). - Adding independent Gumbel noise to logits and taking the argmax samples exactly, with no normalization and no running sum (
test_gumbel_max_distribution_is_softmax). - The alias method spends once to split the distribution into equal columns of two ids each, then draws in from one uniform (
test_alias_table_encodes_the_distribution,test_alias_sample_chi_square). - Every sampler here is a deterministic function of its uniforms, so the same seed gives the same draws in Python, Rust, and Go (
test_alias_one_uniform_per_draw,test_hand_example_poisson_arrivals).
How to work this chapter
Section titled “How to work this chapter”ol start M07.1 # stubs sampling.py into your repool tests M07.1 # read the test catalog firstol check M07.1 # exit code is the verdictol diff M07.1 # after passing: your code against the reference1. Why now
Section titled “1. Why now”Your tracer samples text with three lines buried inside BigramLM.sample: a numpy generator, a cumulative sum, a search. Pass 3 needs sampling in places that line cannot serve. word2vec (L2.3) draws millions of negative words from a 50 000-word distribution, and a cumulative sum per draw is 50 000 additions each time. BERT’s masking (L6.2) and the bootstrap (M07.4) need many small draws with a known generator position. The sampler (L8.1) must return the same token as your Rust engine (L10.1) for the same seed, which only works if the last step, uniform to id, is pinned down to the comparison (spec/sampling.md, step 11). And the load generator (load.01) needs request arrival times that look like real traffic. This module turns uniforms into draws three exact ways, each with a different cost, and makes each one a pure function of its uniforms.
2. Principles
Section titled “2. Principles”| Symbol | Meaning | Type / shape |
|---|---|---|
| a categorical distribution: , | float64[n] | |
| the cumulative distribution (CDF); | float64[n] | |
| , | a uniform random variable on , and one draw of it | float |
| a logit: , | float64[n] | |
| a standard Gumbel random variable | float | |
| , | the alias table: column keeps with probability , else gives | float64[n], int64[n] |
| a rate: events per unit time | float | |
| an exponential waiting time, | float |
Randomness comes from outside. None of these functions creates a generator. They take a uniform , or an rng with a uniform() method (your PCG32 from M06.3, which turns two 32-bit draws into one float64 in ). So each is a plain function from uniforms to outcomes: the same uniforms give the same outcomes in every language, and a test can feed chosen uniforms to check edge cases that a real generator would hit once in draws.
Inverse CDF (discrete). Lay the probabilities end to end on : id owns the interval , whose length is . Draw and return the id whose interval contains it, the first with . Then
Two details decide correctness. The comparison is strict: an id with owns the empty interval , and with instead, would return id 0 even when . And floating point: ten additions of give , so the largest uniform, , is not below any running sum. Then the answer is the last id with , never simply the last id. The walk costs per draw; binary search over a stored makes it .
Inverse CDF (continuous). The same idea works for any increasing continuous CDF : if is uniform then has . The exponential distribution has , so
Write it with log1p(-u), which is accurate when is tiny, and note gives , never . (Using is the same distribution, since is uniform too, but a different schedule from the same seed, and breaks it.)
Poisson arrivals. Requests that arrive independently at an average rate have independent gaps. Adding gaps until the time passes the horizon gives the arrival times on ; their count is Poisson with mean and variance . This is the open-loop schedule load.01 replays against your gateway.
Gumbel-max. Draw independent and return . The Gumbel CDF is , with density . Id wins when and every other :
Substitute (): the integral is with , so . Three consequences: the logits need no normalization (a constant shifts every sum equally); a masked logit stays and never wins; and there is no running sum, so every id is processed independently, which is why GPU samplers use this form. It costs uniforms per draw. The sign matters: is the Gumbel of the minimum, and with it the argmax samples the wrong distribution.
The alias method. Scale the probabilities by , so they average 1, and picture columns of height 1. A column whose scaled mass is below 1 is topped up from a column above 1, and records whom it borrowed from. Vose’s algorithm does this in one pass: keep a list of “small” ids (scaled mass ) and “large” ids (); repeatedly pop one small and one large , set to ‘s mass and , and give the mass it has left, , which goes back to the small or large list. Each step finishes one column, so it ends after at most steps. Whatever remains is a whole column () up to rounding. Column then holds of id and of id , so
A draw picks a column uniformly and flips a biased coin. One uniform does both: , column , coin , which is uniform on and independent of . Keep the column when (strict again: a column with is never kept, even at ). Building costs once; every draw costs whatever is.
Which one when. Inverse CDF: one draw from a distribution that changes every time (the sampler’s next token), and the op order the spec fixes. Alias: many draws from one fixed distribution (negatives from a unigram table). Gumbel-max: no normalization, vectorized, and the root of Gumbel-top-, which draws ids without replacement by keeping the largest .
3. Worked example by hand
Section titled “3. Worked example by hand”Take , five ids.
Inverse CDF. The running sums are . Id 4 owns , which is empty.
| first with | id | |
|---|---|---|
| 0.05 | 0 | |
| 0.10 | is false; | 1 |
| 0.25 | 1 | |
| 0.35 | 2 | |
| 0.99 | 3 |
Alias table (Vose). Scaled mass . Small (below 1): . Large: . Pop from the end of each list:
| step | small | large | set | keeps | goes to |
|---|---|---|---|---|---|
| 1 | 4 () | 3 () | , | large | |
| 2 | 0 () | 3 () | , | small | |
| 3 | 3 () | 2 () | , | large | |
| end | ids 1, 2 left in large: |
So , . Check id 3: its own column keeps 0.5, column 0 gives it , column 4 gives it ; . Id 2: .
A draw with : , column 0, coin , so the alias: id 3. With : column 0, coin : id 0. With : column 4, coin : id 3.
Gumbel-max. Logits . Uniforms give noise . Sums: . The argmax is id 0, the least likely id, because its uniform was lucky; that happens with probability exactly 0.1. Four equal uniforms add equal noise and leave the argmax of : id 3.
Poisson arrivals. Rate , horizon 2, uniforms . Gaps : , , . Times: 0.3466, 1.0397, then 2.1910, which is past the horizon: two arrivals, three uniforms used.
These numbers are the first cases in section 4: test_hand_example_inverse_cdf, test_hand_example_alias_table, test_hand_example_gumbel_max, and test_hand_example_poisson_arrivals.
4. The interface
Section titled “4. The interface”def sample_categorical(probs: ArrayLike, u: float) -> int # first i with u < F_idef gumbel_noise(u: ArrayLike) -> NDArray # -log(-log u), u in (0, 1)def gumbel_max(logits: ArrayLike, gumbels: ArrayLike) -> int # ties to the lowest idclass AliasTable: prob: NDArray # float64 [n] alias: NDArray # int64 [n] def __init__(self, probs: ArrayLike) -> None def __len__(self) -> int def sample(self, rng: UniformSource, n: int) -> NDArray # one rng.uniform() per drawdef exponential_icdf(u: float, rate: float) -> float # -log1p(-u) / ratedef poisson_arrivals(rate: float, horizon: float, rng: UniformSource) -> NDArrayUniformSource is anything with uniform() -> float: your PCG32, or in the tests the frozen one, or a script of fixed values. probs must be 1-D, finite, non-negative, and sum to 1 within ; anything else is a ValueError, as is .
What the tests check
Section titled “What the tests check”| Test | KIND | Checks | Why it matters downstream |
|---|---|---|---|
test_hand_example_inverse_cdf | unit | the five draws of section 3 | you and the test agree on strict |
test_hand_example_alias_table | unit | your table encodes ; draws follow the rule on your arrays and on the chapter’s table | the contract’s draw rule |
test_hand_example_gumbel_max | unit | the noise values and the winner of section 3 | the trick by hand |
test_hand_example_poisson_arrivals | unit | two arrivals, three uniforms | the schedule load.01 replays |
test_inverse_cdf_exact_enumeration | statistical | a 1000-point grid of hits each id times | exact, no noise |
test_zero_probability_is_never_returned | boundary | with | a filtered token never leaks |
test_rounding_fallback_skips_a_zero_tail | boundary | when the sums stop below it | spec step 11’s fallback |
test_last_step_of_the_sampling_spec | unit | the spec’s worked example ends on id 3 | L8.1 calls this for step 11 |
test_rejects_bad_arguments | boundary | sums other than 1, negatives, NaN, 2-D, bad | upstream bugs fail loudly |
test_gumbel_noise_values | unit | ; rejected | the sign of the noise |
test_gumbel_max_distribution_is_softmax | statistical | 20 000 draws fit softmax (chi-square, ) | the theorem of section 2 |
test_gumbel_max_masks_and_ties | boundary | never wins; ties to the lowest id; invalid logits raise | masks from top-k and grammars |
test_alias_table_encodes_the_distribution | property | random with zeros, up to 64: within | any pairing order is fine |
test_alias_exact_enumeration | statistical | grid uniforms give exact counts | column and coin from one |
test_alias_sample_chi_square | statistical | 20 000 PCG32 draws fit ; the id never appears | the negative sampler of L2.3 |
test_alias_one_uniform_per_draw | unit | 7 draws use 7 uniforms; n = 0 and n < 0 | the generator’s position is known |
test_alias_zero_probability_never_drawn_at_column_edges | boundary | exactly | strict coin |
test_alias_single_outcome | boundary | for any | the smallest table |
test_exponential_icdf | unit | , , tiny accurate, bad args raise | log1p, not log |
test_poisson_arrivals_horizon_and_draws | boundary | horizon 0, a gap of 0, invalid rate or horizon | is half open |
test_poisson_arrivals_rate | statistical | count mean and dispersion, gap mean from one long run | the process, not just the formula |
5. Pitfalls
Section titled “5. Pitfalls”| Pitfall | Symptom | Caught by |
|---|---|---|
1. u <= c (or searchsorted(..., side="left")) | returns an id with | test_zero_probability_is_never_returned (mutant s01) |
| 2. falling back to the last id | a zero-probability tail id comes out when rounding leaves the sums below | test_rounding_fallback_skips_a_zero_tail (mutant s02) |
| 3. adding the noise to probabilities, or with the wrong sign | a sampler that looks random and has the wrong distribution | test_gumbel_max_distribution_is_softmax (mutants s03, s04) |
4. argmax over a reversed array, or no check for all | ties go to the highest id; a fully masked row returns id 0 | test_gumbel_max_masks_and_ties (mutants s12, s13) |
5. coin f <= prob | at a column edge a zero-probability column is kept | test_alias_zero_probability_never_drawn_at_column_edges (mutant s08) |
| 6. two uniforms per alias draw | correct distribution, different draws from the same seed, and the generator ends in the wrong place | test_alias_one_uniform_per_draw (mutant s09) |
| 7. for the exponential | same distribution, different schedule; gives infinity | test_exponential_icdf (mutant s10) |
| 8. columns not scaled by , the donor’s leftover as , or the coin read from the alias column | the table encodes some other distribution | test_alias_table_encodes_the_distribution, test_hand_example_alias_table (mutants s05, s06, s07) |
| 9. keeping the arrival that crossed the horizon | one extra request per window | test_hand_example_poisson_arrivals (mutant s11) |
10. not checking that probs sums to 1 | an unnormalized row samples as if its tail were empty | test_rejects_bad_arguments (mutant s14) |
6. Where it’s used next
Section titled “6. Where it’s used next”| Forward | L6.3 | Registered call site uses this module. |
| Forward | M07.6 | Registered call site uses this module. |
| Direction | Module | How it uses this |
|---|---|---|
| Back | M06.3 | PCG32’s uniform() is the rng every sampler here reads |
| Back | M07.0 | random variables, the uniform distribution, and CDFs |
| Back | M00.1 | and in the Gumbel noise and the exponential |
| Forward | L8.1 | the sampler’s step 11 is sample_categorical(q, rng.uniform()) |
| Forward | L2.3 | word2vec draws negatives from AliasTable(unigram ** 0.75) |
| Forward | L6.2 | BERT’s 80/10/10 masking draws the replacement with sample_categorical |
| Forward | M07.4, M07.6 | the bootstrap’s resampling and speculative decoding’s residual draw |
| Forward | load.01 | re-implements poisson_arrivals in Go from the same rule (a port, not a call) |
Going further
Section titled “Going further”| Your piece | Production equivalent | What it adds | Where to look |
|---|---|---|---|
gumbel_max | vLLM’s sampler | the exponential race: probs / Exp(1) then argmax, the same theorem without logs, batched on GPU | vllm/v1/sample/ops/topk_topp_sampler.py (random_sample) |
sample_categorical | numpy Generator.choice(p=...) | a cumulative sum and a binary search (searchsorted) per batch of uniforms | numpy/random/_generator.pyx |
AliasTable | word2vec’s unigram table | a quantized inverse CDF: an array of ids filled in proportion to , one index per draw | word2vec.c (InitUnigramTable) |