Skip to content

Categorical sampling: inverse CDF, Gumbel-max, alias method

ModuleM07.1 · build · Python · Pass 3 · 3 to 4 h
You buildpython/tinyllm/prob/sampling.py: sample_categorical, gumbel_noise, gumbel_max, AliasTable (prob, alias, sample), exponential_icdf, poisson_arrivals
Contractcourse/contracts/py/tinyllm/prob/sampling.pyi · the sampler’s op order: spec/sampling.md · the generator: spec/pcg32.md
Testscourse/tests/M07.1/ (what they check: section 4)
Needsno code from earlier modules · reading: M06.3 PCG32 (your rng), M07.0 random variables and the uniform, M00.1 logs
Used bylater: 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
MilestoneMS-P3 (tokens and data)
Optional depthDevroye, Non-Uniform Random Variate Generation (1986), ch. 2 and 3; Vose, “A Linear Algorithm for Generating Random Numbers with a Given Distribution” (1991)
  • The inverse CDF turns one uniform uu into id ii exactly when Fi−1≤u<FiF_{i-1} \le u < F_i, an interval of length pip_i; strict << keeps zero-probability ids out (test_hand_example_inverse_cdf, test_inverse_cdf_exact_enumeration).
  • Adding independent Gumbel noise −log⁡(−log⁡U)-\log(-\log U) to logits and taking the argmax samples softmax\mathrm{softmax} exactly, with no normalization and no running sum (test_gumbel_max_distribution_is_softmax).
  • The alias method spends O(n)O(n) once to split the distribution into nn equal columns of two ids each, then draws in O(1)O(1) 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).
Terminal window
ol start M07.1 # stubs sampling.py into your repo
ol tests M07.1 # read the test catalog first
ol check M07.1 # exit code is the verdict
ol diff M07.1 # after passing: your code against the reference

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.

SymbolMeaningType / shape
p=(p0,…,pn−1)p = (p_0, \dots, p_{n-1})a categorical distribution: pi≥0p_i \ge 0, ∑ipi=1\sum_i p_i = 1float64[n]
Fi=p0+⋯+piF_i = p_0 + \dots + p_ithe cumulative distribution (CDF); F−1=0F_{-1} = 0float64[n]
UU, uua uniform random variable on [0,1)[0, 1), and one draw of itfloat
ziz_ia logit: p=softmax(z)p = \mathrm{softmax}(z), pi=ezi/∑jezjp_i = e^{z_i} / \sum_j e^{z_j}float64[n]
G=−log⁡(−log⁡U)G = -\log(-\log U)a standard Gumbel random variablefloat
probi\mathrm{prob}_i, aliasi\mathrm{alias}_ithe alias table: column ii keeps ii with probability probi\mathrm{prob}_i, else gives aliasi\mathrm{alias}_ifloat64[n], int64[n]
λ\lambdaa rate: events per unit timefloat
T∼Exp(λ)T \sim \mathrm{Exp}(\lambda)an exponential waiting time, P(T>t)=e−λtP(T > t) = e^{-\lambda t}float

Randomness comes from outside. None of these functions creates a generator. They take a uniform uu, or an rng with a uniform() method (your PCG32 from M06.3, which turns two 32-bit draws into one float64 in [0,1)[0, 1)). 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 2532^{53} draws.

Inverse CDF (discrete). Lay the probabilities end to end on [0,1)[0, 1): id ii owns the interval [Fi−1,Fi)[F_{i-1}, F_i), whose length is pip_i. Draw uu and return the id whose interval contains it, the first ii with u<Fiu < F_i. Then

P(return i)=P(Fi−1≤U<Fi)=Fi−Fi−1=pi.P(\text{return } i) = P(F_{i-1} \le U < F_i) = F_i - F_{i-1} = p_i.

Two details decide correctness. The comparison is strict: an id with pi=0p_i = 0 owns the empty interval [Fi−1,Fi−1)[F_{i-1}, F_{i-1}), and with u≤Fiu \le F_i instead, u=0u = 0 would return id 0 even when p0=0p_0 = 0. And floating point: ten additions of 0.10.1 give 0.99999999999999990.9999999999999999, so the largest uniform, 1−2−531 - 2^{-53}, is not below any running sum. Then the answer is the last id with pi>0p_i > 0, never simply the last id. The walk costs O(n)O(n) per draw; binary search over a stored FF makes it O(log⁡n)O(\log n).

Inverse CDF (continuous). The same idea works for any increasing continuous CDF FF: if UU is uniform then X=F−1(U)X = F^{-1}(U) has P(X≤x)=P(U≤F(x))=F(x)P(X \le x) = P(U \le F(x)) = F(x). The exponential distribution has F(t)=1−e−λtF(t) = 1 - e^{-\lambda t}, so

t=F−1(u)=−log⁡(1−u)λ.t = F^{-1}(u) = -\frac{\log(1 - u)}{\lambda}.

Write it with log1p(-u), which is accurate when uu is tiny, and note u=0u = 0 gives t=0t = 0, never log⁡0\log 0. (Using −log⁡(u)/λ-\log(u)/\lambda is the same distribution, since 1−U1 - U is uniform too, but a different schedule from the same seed, and u=0u = 0 breaks it.)

Poisson arrivals. Requests that arrive independently at an average rate λ\lambda have independent Exp(λ)\mathrm{Exp}(\lambda) gaps. Adding gaps until the time passes the horizon HH gives the arrival times on [0,H)[0, H); their count is Poisson with mean λH\lambda H and variance λH\lambda H. This is the open-loop schedule load.01 replays against your gateway.

Gumbel-max. Draw independent Gi=−log⁡(−log⁡Ui)G_i = -\log(-\log U_i) and return arg⁡max⁡i(zi+Gi)\arg\max_i (z_i + G_i). The Gumbel CDF is P(G≤g)=exp⁡(−e−g)P(G \le g) = \exp(-e^{-g}), with density e−gexp⁡(−e−g)e^{-g}\exp(-e^{-g}). Id kk wins when zk+Gk=xz_k + G_k = x and every other zi+Gi<xz_i + G_i < x:

P(k)=∫e−(x−zk)e−e−(x−zk)∏i≠ke−e−(x−zi) dx=ezk∫e−xexp⁡(−e−x∑iezi)dx.P(k) = \int e^{-(x - z_k)} e^{-e^{-(x - z_k)}} \prod_{i \ne k} e^{-e^{-(x - z_i)}} \, dx = e^{z_k} \int e^{-x} \exp\Big(-e^{-x} \sum_i e^{z_i}\Big) dx.

Substitute s=e−xs = e^{-x} (ds=−e−xdxds = -e^{-x} dx): the integral is ∫0∞e−sZds=1/Z\int_0^\infty e^{-sZ} ds = 1/Z with Z=∑ieziZ = \sum_i e^{z_i}, so P(k)=ezk/Z=softmax(z)kP(k) = e^{z_k}/Z = \mathrm{softmax}(z)_k. Three consequences: the logits need no normalization (a constant shifts every sum equally); a masked logit −∞-\infty stays −∞-\infty 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 nn uniforms per draw. The sign matters: log⁡(−log⁡U)\log(-\log U) is the Gumbel of the minimum, and with it the argmax samples the wrong distribution.

The alias method. Scale the probabilities by nn, so they average 1, and picture nn 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 <1< 1) and “large” ids (≥1\ge 1); repeatedly pop one small ss and one large gg, set probs\mathrm{prob}_s to ss‘s mass and aliass=g\mathrm{alias}_s = g, and give gg the mass it has left, mg+ms−1m_g + m_s - 1, which goes back to the small or large list. Each step finishes one column, so it ends after at most nn steps. Whatever remains is a whole column (prob=1\mathrm{prob} = 1) up to rounding. Column ii then holds probi/n\mathrm{prob}_i / n of id ii and (1−probi)/n(1 - \mathrm{prob}_i)/n of id aliasi\mathrm{alias}_i, so

P(i)=1n(probi+∑j:aliasj=i(1−probj))=pi.P(i) = \frac{1}{n}\Big(\mathrm{prob}_i + \sum_{j : \mathrm{alias}_j = i} (1 - \mathrm{prob}_j)\Big) = p_i.

A draw picks a column uniformly and flips a biased coin. One uniform does both: x=nux = n u, column i=⌊x⌋i = \lfloor x \rfloor, coin f=x−if = x - i, which is uniform on [0,1)[0, 1) and independent of ii. Keep the column when f<probif < \mathrm{prob}_i (strict again: a column with probi=0\mathrm{prob}_i = 0 is never kept, even at f=0f = 0). Building costs O(n)O(n) once; every draw costs O(1)O(1) whatever nn 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-kk, which draws kk ids without replacement by keeping the kk largest zi+Giz_i + G_i.

Take p=(0.1,0.2,0.3,0.4,0.0)p = (0.1, 0.2, 0.3, 0.4, 0.0), five ids.

Inverse CDF. The running sums are F=(0.1,0.3,0.6,1.0,1.0)F = (0.1, 0.3, 0.6, 1.0, 1.0). Id 4 owns [1.0,1.0)[1.0, 1.0), which is empty.

uufirst ii with u<Fiu < F_iid
0.050.05<0.10.05 < 0.10
0.100.10<0.10.10 < 0.1 is false; 0.10<0.30.10 < 0.31
0.250.25<0.30.25 < 0.31
0.350.35<0.60.35 < 0.62
0.990.99<1.00.99 < 1.03

Alias table (Vose). Scaled mass m=5p=(0.5,1.0,1.5,2.0,0.0)m = 5p = (0.5, 1.0, 1.5, 2.0, 0.0). Small (below 1): [0,4][0, 4]. Large: [1,2,3][1, 2, 3]. Pop from the end of each list:

stepsmall sslarge ggsetgg keepsgg goes to
14 (m=0m = 0)3 (m=2m = 2)prob4=0\mathrm{prob}_4 = 0, alias4=3\mathrm{alias}_4 = 32+0−1=1.02 + 0 - 1 = 1.0large
20 (m=0.5m = 0.5)3 (m=1m = 1)prob0=0.5\mathrm{prob}_0 = 0.5, alias0=3\mathrm{alias}_0 = 31+0.5−1=0.51 + 0.5 - 1 = 0.5small
33 (m=0.5m = 0.5)2 (m=1.5m = 1.5)prob3=0.5\mathrm{prob}_3 = 0.5, alias3=2\mathrm{alias}_3 = 21.5+0.5−1=1.01.5 + 0.5 - 1 = 1.0large
endids 1, 2 left in large: prob=1\mathrm{prob} = 1

So prob=(0.5,1,1,0.5,0)\mathrm{prob} = (0.5, 1, 1, 0.5, 0), alias=(3,1,2,2,3)\mathrm{alias} = (3, 1, 2, 2, 3). Check id 3: its own column keeps 0.5, column 0 gives it 1−0.51 - 0.5, column 4 gives it 1−01 - 0; (0.5+0.5+1)/5=0.4(0.5 + 0.5 + 1)/5 = 0.4. Id 2: (1+0.5)/5=0.3(1 + 0.5)/5 = 0.3.

A draw with u=0.13u = 0.13: x=0.65x = 0.65, column 0, coin 0.65≥0.50.65 \ge 0.5, so the alias: id 3. With u=0.05u = 0.05: column 0, coin 0.25<0.50.25 < 0.5: id 0. With u=0.95u = 0.95: column 4, coin 0.75≥00.75 \ge 0: id 3.

Gumbel-max. Logits z=ln⁡(0.1,0.2,0.3,0.4)=(−2.3026,−1.6094,−1.2040,−0.9163)z = \ln(0.1, 0.2, 0.3, 0.4) = (-2.3026, -1.6094, -1.2040, -0.9163). Uniforms (0.9,0.2,0.5,0.1)(0.9, 0.2, 0.5, 0.1) give noise G=−log⁡(−log⁡u)=(2.2504,−0.4759,0.3665,−0.8340)G = -\log(-\log u) = (2.2504, -0.4759, 0.3665, -0.8340). Sums: (−0.0522,−2.0853,−0.8375,−1.7503)(-0.0522, -2.0853, -0.8375, -1.7503). 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 zz: id 3.

Poisson arrivals. Rate λ=2\lambda = 2, horizon 2, uniforms 0.5,0.75,0.90.5, 0.75, 0.9. Gaps −log⁡(1−u)/2-\log(1 - u)/2: ln⁡2/2=0.3466\ln 2 / 2 = 0.3466, ln⁡4/2=0.6931\ln 4 / 2 = 0.6931, ln⁡10/2=1.1513\ln 10 / 2 = 1.1513. 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.

python/tinyllm/prob/sampling.py
def sample_categorical(probs: ArrayLike, u: float) -> int # first i with u < F_i
def gumbel_noise(u: ArrayLike) -> NDArray # -log(-log u), u in (0, 1)
def gumbel_max(logits: ArrayLike, gumbels: ArrayLike) -> int # ties to the lowest id
class 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 draw
def exponential_icdf(u: float, rate: float) -> float # -log1p(-u) / rate
def poisson_arrivals(rate: float, horizon: float, rng: UniformSource) -> NDArray

UniformSource 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 10−9n+10−1210^{-9} n + 10^{-12}; anything else is a ValueError, as is u∉[0,1)u \notin [0, 1).

TestKINDChecksWhy it matters downstream
test_hand_example_inverse_cdfunitthe five draws of section 3you and the test agree on strict <<
test_hand_example_alias_tableunityour table encodes pp; draws follow the rule on your arrays and on the chapter’s tablethe contract’s draw rule
test_hand_example_gumbel_maxunitthe noise values and the winner of section 3the trick by hand
test_hand_example_poisson_arrivalsunittwo arrivals, three uniformsthe schedule load.01 replays
test_inverse_cdf_exact_enumerationstatisticala 1000-point grid of uu hits each id 1000pi1000 p_i timesexact, no noise
test_zero_probability_is_never_returnedboundaryu=0u = 0 with p0=0p_0 = 0a filtered token never leaks
test_rounding_fallback_skips_a_zero_tailboundaryu=1−2−53u = 1 - 2^{-53} when the sums stop below itspec step 11’s fallback
test_last_step_of_the_sampling_specunitthe spec’s worked example ends on id 3L8.1 calls this for step 11
test_rejects_bad_argumentsboundarysums other than 1, negatives, NaN, 2-D, bad uuupstream bugs fail loudly
test_gumbel_noise_valuesunitG(e−1)=0G(e^{-1}) = 0; u∈{0,1}u \in \{0, 1\} rejectedthe sign of the noise
test_gumbel_max_distribution_is_softmaxstatistical20 000 draws fit softmax (chi-square, p>10−3p > 10^{-3})the theorem of section 2
test_gumbel_max_masks_and_tiesboundary−∞-\infty never wins; ties to the lowest id; invalid logits raisemasks from top-k and grammars
test_alias_table_encodes_the_distributionpropertyrandom pp with zeros, nn up to 64: P(i)=piP(i) = p_i within 10−1210^{-12}any pairing order is fine
test_alias_exact_enumerationstatisticalgrid uniforms give exact countscolumn and coin from one uu
test_alias_sample_chi_squarestatistical20 000 PCG32 draws fit pp; the p=0p = 0 id never appearsthe negative sampler of L2.3
test_alias_one_uniform_per_drawunit7 draws use 7 uniforms; n = 0 and n < 0the generator’s position is known
test_alias_zero_probability_never_drawn_at_column_edgesboundaryu=k/nu = k/n exactlystrict coin
test_alias_single_outcomeboundaryn=1n = 1 for any uuthe smallest table
test_exponential_icdfunit0↦00 \mapsto 0, ln⁡2/2\ln 2 / 2, tiny uu accurate, bad args raiselog1p, not log
test_poisson_arrivals_horizon_and_drawsboundaryhorizon 0, a gap of 0, invalid rate or horizon[0,H)[0, H) is half open
test_poisson_arrivals_ratestatisticalcount mean and dispersion, gap mean from one long runthe process, not just the formula
PitfallSymptomCaught by
1. u <= c (or searchsorted(..., side="left"))u=0u = 0 returns an id with p=0p = 0test_zero_probability_is_never_returned (mutant s01)
2. falling back to the last ida zero-probability tail id comes out when rounding leaves the sums below uutest_rounding_fallback_skips_a_zero_tail (mutant s02)
3. adding the noise to probabilities, or with the wrong signa sampler that looks random and has the wrong distributiontest_gumbel_max_distribution_is_softmax (mutants s03, s04)
4. argmax over a reversed array, or no check for all −∞-\inftyties go to the highest id; a fully masked row returns id 0test_gumbel_max_masks_and_ties (mutants s12, s13)
5. coin f <= probat a column edge a zero-probability column is kepttest_alias_zero_probability_never_drawn_at_column_edges (mutant s08)
6. two uniforms per alias drawcorrect distribution, different draws from the same seed, and the generator ends in the wrong placetest_alias_one_uniform_per_draw (mutant s09)
7. −log⁡(u)-\log(u) for the exponentialsame distribution, different schedule; u=0u = 0 gives infinitytest_exponential_icdf (mutant s10)
8. columns not scaled by nn, the donor’s leftover as mg−msm_g - m_s, or the coin read from the alias columnthe table encodes some other distributiontest_alias_table_encodes_the_distribution, test_hand_example_alias_table (mutants s05, s06, s07)
9. keeping the arrival that crossed the horizonone extra request per windowtest_hand_example_poisson_arrivals (mutant s11)
10. not checking that probs sums to 1an unnormalized row samples as if its tail were emptytest_rejects_bad_arguments (mutant s14)

| Forward | L6.3 | Registered call site uses this module. | | Forward | M07.6 | Registered call site uses this module. |

DirectionModuleHow it uses this
BackM06.3PCG32’s uniform() is the rng every sampler here reads
BackM07.0random variables, the uniform distribution, and CDFs
BackM00.1log⁡\log and exp⁡\exp in the Gumbel noise and the exponential
ForwardL8.1the sampler’s step 11 is sample_categorical(q, rng.uniform())
ForwardL2.3word2vec draws negatives from AliasTable(unigram ** 0.75)
ForwardL6.2BERT’s 80/10/10 masking draws the replacement with sample_categorical
ForwardM07.4, M07.6the bootstrap’s resampling and speculative decoding’s residual draw
Forwardload.01re-implements poisson_arrivals in Go from the same rule (a port, not a call)
Your pieceProduction equivalentWhat it addsWhere to look
gumbel_maxvLLM’s samplerthe exponential race: probs / Exp(1) then argmax, the same theorem without logs, batched on GPUvllm/v1/sample/ops/topk_topp_sampler.py (random_sample)
sample_categoricalnumpy Generator.choice(p=...)a cumulative sum and a binary search (searchsorted) per batch of uniformsnumpy/random/_generator.pyx
AliasTableword2vec’s unigram tablea quantized inverse CDF: an array of 10810^8 ids filled in proportion to p0.75p^{0.75}, one index per drawword2vec.c (InitUnigramTable)