Skip to content

Variance propagation and initialization

ModuleM07.3 · build · Python · Pass 2 · 2 to 3 h
You buildpython/tinyllm/nn/init.py: fans, calculate_gain, xavier_uniform, xavier_normal, kaiming_normal, normal_init, scaled_residual_std
Contractcourse/contracts/py/tinyllm/nn/init.pyi · draw order: spec/pcg32.md
Testscourse/tests/M07.3/test_init.py (what they check: section 4); torch’s gain table and fan rule in course/fixtures/M07.3/init_torch.json
NeedsM07.0 its normal(rng, n) draws every normal weight (or --ref-deps). Reading: M01.3 (activations), M06.3 (PCG32), S-M02 and S-M04 (the Gaussian integrals)
Used byL0.4 initializes every Linear and Embedding with it · later L3.2 (LSTM), L7.9 (the modern decoder), C1 · later: L3.3, L3.6, L5.4, L5.5, L6.1, L6.2, L6.3
MilestoneMS-P2 (Pass 2 closes with every math module it teaches passing)
Optional depthGlorot and Bengio, “Understanding the difficulty of training deep feedforward neural networks” (2010), section 4.2; He et al., “Delving Deep into Rectifiers” (2015), section 2.2; Radford et al., “Language Models are Unsupervised Multitask Learners” (GPT-2, 2019), section 2.3
  • For z=∑j=1nwjxjz = \sum_{j=1}^{n} w_j x_j with independent zero-mean weights, Var(z)=n Var(w) E[x2]\mathrm{Var}(z) = n\, \mathrm{Var}(w)\, \mathbb{E}[x^2]: the variance of a layer’s output is set by its fan-in and the weights’ variance (test_hand_example_linear_4x3).
  • Xavier picks Var(w)=2/(nin+nout)\mathrm{Var}(w) = 2/(n_{\text{in}} + n_{\text{out}}) for layers that are linear near 0; Kaiming picks 2/nin2/n_{\text{in}} for ReLU, which zeroes half its inputs (test_empirical_variance_matches_the_formula).
  • With Kaiming the signal survives 20 ReLU layers; with Xavier it shrinks by about 2192^{19} (test_relu_signal_survives_20_layers).
  • A gain folds the activation’s effect into one factor; fans of a convolution or a Linear weight follow torch’s (out, in, *kernel) rule (test_fans_and_gains_match_torch).
  • Weights are drawn in row-major order, one draw per element, from your PCG32, so a seed fixes the model in every language (test_normal_inits_draw_spec_normals_in_c_order).
Terminal window
ol start M07.3 # stubs python/tinyllm/nn/init.py, contract alongside
ol tests M07.3 # read the test catalog first: rung R0, you write no tests here
ol check M07.3 # exit code is the verdict
ol check M07.3 --ref-deps # only if your M07.0 is not passing yet
ol diff M07.3 # after passing: your code against the reference

L0.4 builds layers next, and a layer needs starting weights. The obvious choices fail. All zeros makes every unit in a layer compute the same thing and receive the same gradient forever. Standard normal weights make a layer with 512 inputs multiply the size of its input by about 512≈23\sqrt{512} \approx 23, so after a few layers the activations overflow float32 or saturate every tanh, and the gradients are 0 or NaN. Weights a little too small do the opposite: the signal shrinks geometrically and the deep layers learn nothing. Your MS-L0 milestone trains an MLP on digits from your own initialization, and C1 trains a 10M-parameter Transformer the same way. This module derives the one number that decides which of these happens, the variance of the weights, from expectation and variance (M07.0), and builds the initializers every later model calls.

SymbolMeaningType / shape
E[X]\mathbb{E}[X], Var(X)\mathrm{Var}(X)expectation and variance of a random variable, Var(X)=E[(X−EX)2]\mathrm{Var}(X) = \mathbb{E}[(X - \mathbb{E}X)^2] (M07.0)scalars
W∈Rnout×ninW \in \mathbb{R}^{n_{\text{out}} \times n_{\text{in}}}a Linear weight, one row per output (torch’s layout)float32[out, in]
x∈Rninx \in \mathbb{R}^{n_{\text{in}}}the layer’s input; z=Wxz = W x its pre-activationvectors
nin,noutn_{\text{in}}, n_{\text{out}}fan-in and fan-out: inputs feeding one output, outputs fed by one inputint
σ2=Var(w)\sigma^2 = \mathrm{Var}(w)the variance every weight is drawn withfloat
ffthe activation, h=f(z)h = f(z)function
ggthe gain of ff: the factor that corrects the variance for itfloat
U(−a,a)\mathcal{U}(-a, a), N(0,s2)\mathcal{N}(0, s^2)uniform on [−a,a)[-a, a); normal with mean 0 and standard deviation ssdistributions
LLthe number of Transformer blocksint

2.1 Expectation and variance of sums and products

Section titled “2.1 Expectation and variance of sums and products”

Two rules from M07.0 do all the work. Expectation is linear: E[aX+bY]=a EX+b EY\mathbb{E}[aX + bY] = a\,\mathbb{E}X + b\,\mathbb{E}Y for any random variables. Variance adds for independent ones: Var(X+Y)=Var(X)+Var(Y)\mathrm{Var}(X + Y) = \mathrm{Var}(X) + \mathrm{Var}(Y), because the cross term 2 E[(X−EX)(Y−EY)]2\,\mathbb{E}[(X - \mathbb{E}X)(Y - \mathbb{E}Y)] is a product of two zero means. For a product of independent ww and xx with E[w]=0\mathbb{E}[w] = 0:

Var(wx)=E[w2x2]−(E[w] E[x])2=E[w2] E[x2]=σ2 E[x2].\mathrm{Var}(w x) = \mathbb{E}[w^2 x^2] - (\mathbb{E}[w]\,\mathbb{E}[x])^2 = \mathbb{E}[w^2]\,\mathbb{E}[x^2] = \sigma^2\, \mathbb{E}[x^2] .

Note E[x2]\mathbb{E}[x^2], not Var(x)\mathrm{Var}(x): the input’s mean counts too (after a ReLU, x≥0x \ge 0 has a positive mean).

One output of a layer is zi=∑j=1ninWijxjz_i = \sum_{j=1}^{n_{\text{in}}} W_{ij} x_j, a sum of ninn_{\text{in}} independent products, each with mean 0. By 2.1:

Var(zi)=nin σ2 E[x2].\mathrm{Var}(z_i) = n_{\text{in}}\, \sigma^2\, \mathbb{E}[x^2] .

To keep the signal the same size from layer to layer, set this equal to the previous layer’s value. Linear activations (h=zh = z, zero mean): E[x2]=Var(x)\mathbb{E}[x^2] = \mathrm{Var}(x) and the condition is ninσ2=1n_{\text{in}} \sigma^2 = 1. The backward pass multiplies gradients by W⊤W^\top, whose rows have noutn_{\text{out}} entries, so it wants noutσ2=1n_{\text{out}} \sigma^2 = 1. Xavier (Glorot and Bengio) splits the difference:

σ2=2nin+nout.\sigma^2 = \frac{2}{n_{\text{in}} + n_{\text{out}}} .

ReLU, f(z)=max⁡(z,0)f(z) = \max(z, 0), keeps the positive half of a symmetric zz and zeroes the rest, so E[f(z)2]=12Var(z)\mathbb{E}[f(z)^2] = \frac{1}{2}\mathrm{Var}(z). The next layer then sees E[x2]=12Var(z)\mathbb{E}[x^2] = \frac12 \mathrm{Var}(z), and preserving the variance needs twice as much weight variance. Kaiming (He et al.):

σ2=2nin(forward, "fan_in"),σ2=2nout(backward, "fan_out").\sigma^2 = \frac{2}{n_{\text{in}}} \quad (\text{forward, "fan\_in"}), \qquad \sigma^2 = \frac{2}{n_{\text{out}}} \quad (\text{backward, "fan\_out"}).

Getting the factor wrong compounds: a variance too small by 2 per layer is 2−192^{-19} after 20 layers, and too large by 2 is 2192^{19}.

A gain gg writes the activation’s correction as a factor on the standard deviation: Kaiming is σ=g/n\sigma = g / \sqrt{n} with g=2g = \sqrt{2} for ReLU, and Xavier takes an optional gain, σ=g2/(nin+nout)\sigma = g\sqrt{2/(n_{\text{in}} + n_{\text{out}})}. torch’s table, which you match: g=1g = 1 for linear, convolutions, and sigmoid; 2\sqrt{2} for ReLU; 2/(1+s2)\sqrt{2/(1 + s^2)} for leaky ReLU with negative slope ss (default 0.010.01), because it keeps s2s^2 of the negative half’s second moment; 3/43/4 for SELU; and 5/35/3 for tanh. The tanh value is empirical: tanh has slope 1 at 0 but shrinks larger inputs, and with g=5/3g = 5/3 the mean square of a deep tanh stack settles near 0.42 instead of draining toward 0 (about 0.02 after 20 layers with g=1g = 1).

Fans come from the weight’s shape (out, in, *kernel): nin=in⋅rn_{\text{in}} = \mathit{in} \cdot r, nout=out⋅rn_{\text{out}} = \mathit{out} \cdot r, with rr the product of the kernel dimensions (1 for a Linear weight). A convolution with 4 input channels and a 3×33 \times 3 kernel has nin=36n_{\text{in}} = 36. Fewer than 2 dimensions have no fan-in and are an error.

Two distributions with the same variance work equally well at the start. A uniform U(−a,a)\mathcal{U}(-a, a) has variance a2/3a^2/3, so Xavier-uniform uses a=g6/(nin+nout)a = g\sqrt{6/(n_{\text{in}} + n_{\text{out}})}; element kk is −a+2a uk-a + 2a\,u_k with uku_k the kk-th rng.uniform(). A normal is ss times a standard normal from your M07.0 normal(rng, n). Either way the array is filled in row-major (C) order, one draw per element, and nothing else is drawn, so a model initialized layer after layer from one generator is reproducible, and the Rust port (L10.1) can rebuild the same weights from the same seed. Results are float32, the dtype of every parameter.

A Transformer block adds its outputs to a running residual stream: xℓ+1=xℓ+attn(xℓ)+mlp(⋅)x_{\ell+1} = x_\ell + \mathrm{attn}(x_\ell) + \mathrm{mlp}(\cdot), two additions per block, 2L2L in all. If each addition is independent with variance s2s^2, the stream’s variance grows to Var(x0)+2Ls2\mathrm{Var}(x_0) + 2L s^2. GPT-2 scales the standard deviation of the two output projections in every block to

s=sbase2L,so2L s2=sbase2s = \frac{s_{\text{base}}}{\sqrt{2L}}, \qquad \text{so} \qquad 2L\, s^2 = s_{\text{base}}^2

whatever the depth (scaled_residual_std, with sbase=0.02s_{\text{base}} = 0.02 in GPT-2 and your L7.9).

A Linear layer with 3 inputs and 4 outputs stores WW as shape (4,3)(4, 3): nin=3n_{\text{in}} = 3, nout=4n_{\text{out}} = 4.

InitializerFormulaValue
Xavier-uniform bounda=6/(3+4)a = \sqrt{6 / (3 + 4)}≈0.92582\approx 0.92582
Xavier-normal std2/7\sqrt{2 / 7}≈0.53452\approx 0.53452
Kaiming-normal std (ReLU, fan_in)2/3=2/3\sqrt{2} / \sqrt{3} = \sqrt{2/3}≈0.81650\approx 0.81650
GPT-2 residual std, L=12L = 120.02/240.02 / \sqrt{24}≈0.0040825\approx 0.0040825

The variance carries through. Feed the Kaiming layer an input with E[x2]=1\mathbb{E}[x^2] = 1: Var(z)=3⋅23⋅1=2\mathrm{Var}(z) = 3 \cdot \frac{2}{3} \cdot 1 = 2. ReLU keeps half the second moment: E[relu(z)2]=1\mathbb{E}[\mathrm{relu}(z)^2] = 1, exactly what the next layer received. With Xavier (σ2=2/7\sigma^2 = 2/7) the same input gives Var(z)=6/7\mathrm{Var}(z) = 6/7 and E[relu(z)2]=3/7\mathbb{E}[\mathrm{relu}(z)^2] = 3/7: the signal loses more than half per layer.

The draws. With PCG32(0) the first uniform is u0≈0.280312u_0 \approx 0.280312, so Xavier-uniform’s first weight is −a+2au0=0.92582⋅(2⋅0.280312−1)≈−0.406783-a + 2a u_0 = 0.92582 \cdot (2 \cdot 0.280312 - 1) \approx -0.406783. Kaiming’s first weight uses the first pair u0,u1≈0.489224u_0, u_1 \approx 0.489224: r=−2ln⁡(1−u0)≈0.81104r = \sqrt{-2 \ln(1 - u_0)} \approx 0.81104, cos⁡(2πu1)≈−0.99771\cos(2\pi u_1) \approx -0.99771, so the standard normal is ≈−0.80918\approx -0.80918 and the weight 0.81650⋅(−0.80918)≈−0.660710.81650 \cdot (-0.80918) \approx -0.66071. The first test, test_hand_example_linear_4x3, checks every one of these against the frozen generator.

def fans(shape: tuple[int, ...]) -> tuple[int, int] # (fan_in, fan_out)
def calculate_gain(nonlinearity: str, param: float | None = None) -> float
def xavier_uniform(shape, gain: float, rng) -> NDArray # float32, U(-a, a)
def xavier_normal(shape, gain: float, rng) -> NDArray # float32, N(0, s^2)
def kaiming_normal(shape, fan_mode: Literal["fan_in", "fan_out"], nonlinearity: str, rng) -> NDArray
def normal_init(shape, std: float, rng) -> NDArray # any shape, including 1-D
def scaled_residual_std(base_std: float, n_layers: int) -> float # base / sqrt(2 L)

rng is a PCG32: your M06.3 generator in your own code, the frozen course/tests/_lib/pcg32.py in the course tests. Uniforms come from rng.uniform(), normals from tinyllm.prob.rv.normal(rng, n). Every function raises ValueError for a shape without a fan (fewer than 2 dimensions, or a 0 fan), a negative gain or standard deviation, an unknown nonlinearity or fan mode, and n_layers < 1.

TestKINDChecksWhy it matters downstream
test_hand_example_linear_4x3unitsection 3: fans, gains, the residual std, and the first weights of PCG32(0)you and the tests agree on every formula
test_fans_and_gains_match_torchgoldentorch’s gain table and fan rule for Linear and conv shapesL0.4 compares layers with torch
test_normal_inits_draw_spec_normals_in_c_orderuniteach normal initializer is std times the spec normals, row-major, odd sizes tooreproducible weights across languages
test_uniform_uses_one_draw_per_elementunitexact values, and the generator advanced by exactly one uniform per elementlayers initialized in sequence stay aligned
test_same_seed_same_weightspropertyequal seeds give equal bytes, different seeds differreplayable runs
test_empirical_variance_matches_the_formulastatisticalmean 0 and variance within 4 standard errors for all four variantsthe formulas of section 2.2, measured
test_relu_signal_survives_20_layerspropertyKaiming keeps the mean square within a factor 16; Xavier collapses it below 10−410^{-4}deep MLPs and Transformers train at all
test_tanh_signal_survives_with_xavierpropertygain 5/35/3 keeps a 20-layer tanh stack’s mean square in (0.1,1)(0.1, 1); gain 1 ends at less than half thattanh and gated recurrent layers (L3.2)
test_scaled_residual_keeps_the_stream_boundedproperty2Ls2=sbase22L s^2 = s_{\text{base}}^2 for several depthsGPT-2 style output projections (L7.9)
test_shapes_and_dtypeunitfloat32, C-contiguous, exact shapes, 1-D for normal_initparameters are float32 (L0.4)
test_rejects_bad_argumentsboundary1-D fans, unknown names, negative std or depth raise ValueErrorno silent wrong-scale default
PitfallSymptomCaught by
reading a (out, in) weight as (in, out)fan-in and fan-out swap; non-square layers get the wrong scaletest_hand_example_linear_4x3, test_fans_and_gains_match_torch (mutant s01)
ignoring the kernel dimensionsa 3×33 \times 3 convolution is initialized 9 times too large in variancetest_fans_and_gains_match_torch (mutant s02)
using 2/(nin+nout)\sqrt{2/(n_{\text{in}} + n_{\text{out}})} as the uniform boundthe uniform’s variance is a2/3a^2/3, so this is 3 times too smalltest_empirical_variance_matches_the_formula (mutant s03)
ReLU gain 2 (a variance) instead of 2\sqrt{2} (a standard deviation)activations double in mean square per layer: 2192^{19} after 20test_relu_signal_survives_20_layers (mutant s05)
GPT-2 residual scale 1/L1/\sqrt{L} instead of 1/2L1/\sqrt{2L}each block adds two outputs; the stream ends twice as largetest_scaled_residual_keeps_the_stream_bounded (mutant s08)
filling in column-major ordersame distribution, different weights: Python and the Rust port disagree for the same seedtest_normal_inits_draw_spec_normals_in_c_order (mutant s09)
making a new generator inside the initializerevery layer gets the same weights, and the seed does nothingtest_same_seed_same_weights (mutant s13)

| Forward | L3.3 | Registered call site uses this module. | | Forward | L3.6 | Registered call site uses this module. | | Forward | L5.4 | Registered call site uses this module. | | Forward | L5.5 | Registered call site uses this module. | | Forward | L6.1 | Registered call site uses this module. | | Forward | L6.2 | Registered call site uses this module. | | Forward | L6.3 | Registered call site uses this module. |

DirectionModuleHow it uses this
BackM07.0normal(rng, n) draws every normal weight; expectation and variance are its definitions
BackM01.3the activations whose second moments set the gains (reading)
BackM06.3the PCG32 generator passed in as rng (reading)
BackS-M02, S-M04the Gaussian integrals behind E[relu(z)2]=Var(z)/2\mathbb{E}[\mathrm{relu}(z)^2] = \mathrm{Var}(z)/2 (reading)
ForwardL0.4Linear and Embedding initialize their parameters with these functions
ForwardL3.2LSTM gate weights
ForwardL7.9 and C1normal_init(0.02) plus scaled_residual_std for the output projections of a Llama-style decoder

If you skip this module, ol check L0.4 stops with BLOCKED ... needs M07.3: build it, or pass --ref-deps.

Your pieceProduction equivalentWhat it addsWhere to look
xavier_*, kaiming_normaltorch.nn.initin-place initializers, trunc_normal_, orthogonal_ (your M03.3), kaiming_uniform_ with a = sqrt(5) (torch’s Linear default)torch/nn/init.py
scaled_residual_stdGPT-NeoX, Megatron scaled_init_method_normalthe same 1/2L1/\sqrt{2L} rule, applied by parameter namemegatron/core/utils.py
a fixed 0.020.02 base stdmuP (maximal update parametrization)width-dependent init and learning rates so hyperparameters transfer from small to large modelsYang et al., “Tensor Programs V” (2022)
variance analysis at initializationsignal propagation theorymean-field analysis of depth and the edge of chaos; Fixup and T-Fixup train without normalizationSchoenholz et al., “Deep Information Propagation” (2017)