Random variables, expectation, variance, and normal draws by Box-Muller
Overview
Section titled “Overview”| Module | M07.0 · build · Python · Pass 2 · 2 to 3 h |
| You build | python/tinyllm/prob/rv.py: expectation(values, probs), variance(values, probs) (two-pass), box_muller(u1, u2), normal(rng, n) (standard normals in the cross-language order of spec/pcg32.md) |
| Contract | course/contracts/py/tinyllm/prob/rv.pyi · draw order: spec/pcg32.md |
| Tests | course/tests/M07.0/test_rv.py (what they check: section 4) |
| Needs | M06.3 the PCG32 generator every caller passes in (or --ref-deps). Reading: M00.2 (cos, sin, the unit circle), Calculus 2 (the Gaussian integral) |
| Used by | M03.3 orthogonal initialization draws its Gaussian matrix here · M07.3 normal_init and variance propagation through layers |
| Milestone | MS-P2 (the foundations gate) |
| Optional depth | Blitzstein and Hwang, Introduction to Probability, ch. 3 to 5 and 7.5 (Box-Muller); Grinstead and Snell, Introduction to Probability, ch. 6; Box and Muller, “A Note on the Generation of Random Normal Deviates” (1958); Welford, “Note on a Method for Calculating Corrected Sums of Squares and Products” (1962) |
Key Takeaways
Section titled “Key Takeaways”- A discrete random variable is a table of values and probabilities; its expectation is the probability-weighted mean and its variance the expected squared distance from that mean (
test_die_hand_example). - : shifting does nothing, scaling scales by the square.
M07.3sizes every initialization with this rule (test_variance_shift_and_scale_laws). - Compute the variance in two passes (mean first, then squared deviations). subtracts two huge, nearly equal numbers and can return 0 or a negative variance (
test_variance_has_no_catastrophic_cancellation). - Box-Muller turns two independent uniforms into two independent standard normals: a radius and an angle (
test_box_muller_hand_example). Drawn in the spec’s order, the same seed gives the same normals in Python, Rust, and Go (test_normal_matches_spec_vectors).
How to work this chapter
Section titled “How to work this chapter”ol start M07.0 # stubs python/tinyllm/prob/rv.py into your repool tests M07.0 # read the test catalog first: rung R0, you write no tests hereol check M07.0 # exit code is the verdictol check M07.0 --ref-deps # only if your M06.3 is not passing yetol diff M07.0 # after passing: your code against the reference1. Why now
Section titled “1. Why now”Your system now has a reproducible generator (M06.3) that produces uniform numbers in . Neural networks are not initialized with uniform numbers: almost every weight in this course, from the first Linear layer (L0.4) to the Llama blocks (L7.9), starts as a draw from a normal distribution with a carefully chosen spread, and the next module that needs one is M03.3, whose orthogonal initializer is the QR factorization of a Gaussian matrix. Choosing the spread is a variance calculation (M07.3): too large and activations explode through 20 layers, too small and they vanish. This module defines random variables, expectation, and variance from the beginning, and turns your uniforms into normals with Box-Muller, in exactly the order the Rust and Go ports will use, so that one seed means the same weights everywhere.
2. Principles
Section titled “2. Principles”| Symbol | Meaning | Type |
|---|---|---|
| a random variable: a quantity whose value is drawn at random | ||
| the values of a discrete and their probabilities, , | float64[k] each | |
| expectation (mean) | scalar | |
| variance; is the standard deviation | scalar | |
| a uniform random variable on | ||
| a probability density: | ||
| the normal distribution; is the standard normal | ||
| the standard normal CDF, | ||
| two uniform draws | float | |
| polar radius and angle | float |
2.1 Random variables and distributions
Section titled “2.1 Random variables and distributions”A random variable assigns a number to each outcome of a random experiment. A discrete one takes values with probabilities , where every and ; that table is its distribution. A table whose probabilities do not sum to 1 (say, raw counts) or contain a negative entry describes no random variable, and the functions in this module reject it rather than average with it.
A continuous random variable has a density with ; probabilities are areas under , and any single value has probability 0. , uniform on , has on that interval.
2.2 Expectation and variance
Section titled “2.2 Expectation and variance”The expectation is the probability-weighted average of the values,
the long-run average of many independent draws. It is linear: , and for any .
The variance measures spread, as the expected squared distance from the mean:
From linearity: . A shift moves the distribution without spreading it; a scale by spreads it by , so the variance grows by . For independent and , variances add: . A neuron with independent zero-mean terms therefore has variance , which is why M07.3 scales initial weights by .
Two examples recur everywhere. A Bernoulli() variable (1 with probability , else 0) has and : every accuracy you will ever measure is an average of these. A point mass (one value with probability 1) has variance exactly 0.
2.3 Computing the variance without cancellation
Section titled “2.3 Computing the variance without cancellation”Expanding the square gives the textbook shortcut , which is algebraically equal and numerically dangerous. Take with a fair coin: the true variance is . But and , and float64 numbers near are spaced 128 apart, so their difference is a multiple of 128 near zero: the answer comes out 0 or , every digit wrong, sometimes negative. The two-pass method computes first, then : the deviations are small numbers computed with small errors, and the squares are all non-negative. (M09.2 returns to this as Welford’s and Kahan’s algorithms.)
2.4 The normal distribution
Section titled “2.4 The normal distribution”The standard normal has density : mean 0, variance 1, symmetric, with about 68% of its mass within one standard deviation and 99.7% within three. is . The constant comes from the Gaussian integral , computed by squaring it and switching to polar coordinates, and the same trick in reverse is how normals are generated.
2.5 Box-Muller
Section titled “2.5 Box-Muller”Two independent standard normals form a point in the plane whose density depends only on the distance from the origin. In polar coordinates :
- the angle is uniform on , by the rotational symmetry;
- the radius satisfies (integrate the density outside the circle of radius ), so is exponential with mean 1.
To sample , invert its survival function: if is uniform on , has exactly that distribution. With two uniforms :
Using instead of matters at the edge: uniform() can return exactly 0.0 (probability , but over draws not never), and would produce an infinite “normal”; lies in , so the log is finite and is finite. Both outputs are independent standard normals; throwing the sine away wastes half the work.
The order is a contract (spec/pcg32.md): for each pair, draw then , emit (cosine) then (sine). normal(rng, n) fills values pair by pair; for odd it still draws a whole last pair and drops the final sine. (The spec’s generator-level normal() keeps that sine as a spare for the next call; normal(rng, n) is a free function over any generator, so it keeps no state between calls: course/DEVIATIONS.md, M070-01.) Every other port reproduces this order, which is how the parity suite can compare normals across languages.
3. Worked example by hand
Section titled “3. Worked example by hand”A fair die (test_die_hand_example). Values , each with probability :
The deviations from 3.5 are , so
Box-Muller by hand (test_box_muller_hand_example). Choose and . Then , so ; and . The pair is .
The first normals of seed 0 (test_normal_matches_spec_vectors). PCG32(0) gives and (M06.3 section 3). Then , , (just under , so the point lies left of the origin, slightly up), , , and the first two normals are and : the first two entries of normal["0"] in spec/pcg32.vectors.json.
4. The interface
Section titled “4. The interface”def expectation(values, probs) -> float: ... # sum p_i x_i; ValueError unless a distributiondef variance(values, probs) -> float: ... # sum p_i (x_i - mu)^2, two passesdef box_muller(u1: float, u2: float) -> tuple[float, float]: ... # (r cos t, r sin t)def normal(rng, n: int) -> NDArray: ... # float64 [n]; u1, u2 per pair; cosine firstrng is anything with a uniform() method returning the spec’s 53-bit doubles: your tinyllm.num.rng.PCG32 in the system, the frozen course/tests/_lib/pcg32.py copy in the course tests (D35). Both give the same uniforms bit for bit.
What the tests check
Section titled “What the tests check”| Test | KIND | Checks | Why it matters downstream |
|---|---|---|---|
test_die_hand_example | unit, smoke | section 3: , | you and the tests agree on the definitions |
test_bernoulli_and_point_mass | unit | , ; a point mass has variance 0 | every accuracy metric is a Bernoulli mean (M07.4) |
test_variance_has_no_catastrophic_cancellation | boundary | gives exactly | statistics of large-mean quantities (token counts, timestamps) |
test_variance_shift_and_scale_laws | property | and on 50 random tables | the scaling rule of M07.3 |
test_rejects_tables_that_are_not_distributions | boundary | sums other than 1, negative entries, shape mismatches, empty and 2-D tables raise | unnormalized counts are caught upstream |
test_box_muller_hand_example | unit, smoke | section 3: , and at | the transform itself |
test_box_muller_at_u1_zero_is_finite | boundary | gives radius 0; just below 1 gives a large finite value | no infinite weights |
test_normal_matches_spec_vectors | golden | the first 8 normals of seeds 0, 1, within 4 ulp | the Rust and Go ports draw the same normals |
test_odd_n_draws_whole_pairs_and_drops_the_last_sine | unit | normal(rng, 3) uses 4 uniforms and equals the first 3 of normal(rng, 4); n = 0 and n < 0 | the stream position after a call |
test_normal_moments_within_three_standard_errors | statistical | 20 000 draws: mean and variance within 3 standard errors of 0 and 1 | a missing factor 2 halves every initial variance |
test_normal_shape_by_chi_square | statistical | 20 bins equally likely under : Pearson statistic below 43.82 (19 degrees of freedom, ) | the right moments are not the right shape |
test_pair_halves_are_uncorrelated | statistical | the cosine and sine halves have correlation within 3 standard errors of 0 | independent draws |
5. Pitfalls
Section titled “5. Pitfalls”| Pitfall | Symptom | Caught by |
|---|---|---|
| averaging the values without the probabilities | right only for uniform tables | test_bernoulli_and_point_mass (mutant s01) |
| returning the standard deviation as the variance | instead of | test_die_hand_example (mutant s02) |
| variance 0 or negative for large means | test_variance_has_no_catastrophic_cancellation (mutant s03) | |
| not validating the table | silent nonsense from unnormalized counts | test_rejects_tables_that_are_not_distributions (mutant s04) |
| instead of | an infinite value once in draws; different values from every port | test_box_muller_at_u1_zero_is_finite (mutant s05) |
| without the 2 | variance : every initial weight too small by | test_normal_moments_within_three_standard_errors (mutant s06) |
| angle instead of | the sine half is always positive; wrong shape | test_normal_shape_by_chi_square (mutant s07) |
| sine first | statistically fine, but matches no other language | test_normal_matches_spec_vectors (mutant s08) |
| both halves from the cosine | perfectly correlated pairs | test_pair_halves_are_uncorrelated (mutant s09) |
6. Where it’s used next
Section titled “6. Where it’s used next”| Direction | Module | How it uses this |
|---|---|---|
| Back | M06.3 | the generator: two uniform() calls per pair, the same uniforms in every language |
| Back | M00.2 | cosine, sine, and the unit circle (reading) |
| Back | Calculus 2 | the Gaussian integral behind (reading; S-M02 checks it) |
| Forward | M03.3 | orthogonal_init factors normal(rng, rows * cols) |
| Forward | M07.3 | normal_init, Xavier and Kaiming normals, and the variance propagation rule |
| Forward | M07.1 | categorical sampling builds on the same uniforms (reading) |
If you skip this module, ol check M03.3 stops with BLOCKED ... needs M07.0: build it, or pass --ref-deps.
Going further
Section titled “Going further”| Your piece | Production equivalent | What it adds | Where to look |
|---|---|---|---|
| Box-Muller | the Ziggurat algorithm (numpy’s standard_normal, Rust rand_distr) | almost always one uniform and one table lookup per normal, no log, sin, or cos | numpy numpy/random/src/distributions/distributions.c, random_standard_normal |
| Box-Muller | the Marsaglia polar method | rejection inside the unit disc replaces and | Marsaglia and Bray (1964) |
| two-pass variance | Welford’s online algorithm, parallel merging (Chan et al.) | one pass over a stream, mergeable across workers | torch.var_mean, Welford (1962) |
normal(rng, n) | torch.nn.init.normal_, jax.random.normal | per-device counter-based generators, many values per call | torch/nn/init.py, jax/_src/random.py |