Skip to content

Series convergence, EMA as a geometric series, bias correction

ModuleM02.2 · build · Python · Pass 2 · 2 to 3 h
You buildpython/tinyllm/num/ema.py: ema_weights and the class EMA (update, value, t, value_debiased)
Contractcourse/contracts/py/tinyllm/num/ema.pyi
Testscourse/tests/M02.2/test_ema.py (what they check: section 4)
NeedsM00.3 geometric and geometric_sum, which give the weights and their total (or --ref-deps)
Used byL0.5’s train_step smooths the training-loss curve with EMA (ema=, reported as loss_ema), C1 keeps an EMA of the weights, and M10.3 (Adam) applies the same bias correction to its moments (section 6)
MilestoneMS-P2 (the Pass 2 gate)
Optional depthOpenStax, Calculus Volume 2 (free), sections 5.2 and 5.3 (infinite series, the geometric series, divergence); Kingma and Ba, “Adam” (2015), section 3 (initialization bias correction)
  • A series converges when its partial sums settle on a number; the geometric series ∑j≥0rj\sum_{j \ge 0} r^j converges to 11−r\frac{1}{1 - r} exactly when ∣r∣<1|r| < 1, and its partial sum is 1−rt1−r\frac{1 - r^t}{1 - r} (test_weights_sum_to_one_minus_beta_power).
  • The exponential moving average mt=βmt−1+(1−β)xtm_t = \beta m_{t-1} + (1 - \beta) x_t from m0=0m_0 = 0 is a weighted sum of all inputs with geometric weights (1−β)βt−i(1 - \beta)\beta^{t-i} (test_weights_hand_example, test_update_equals_weighted_sum).
  • Those weights add up to 1−βt1 - \beta^t, not 1, so early values are biased toward 0; dividing by 1−βt1 - \beta^t fixes it, and the debiased average of a constant is that constant at every step (test_hand_example, test_debiased_constant_is_exact).
  • The bias fades on its own as βt→0\beta^t \to 0, after roughly 11−β\frac{1}{1 - \beta} steps; with β=0.999\beta = 0.999 that is a thousand steps of a misleading curve (test_bias_fades_without_correction).
  • One nan in the input poisons every later average, so update rejects non-finite values and keeps its state (test_rejects_nonfinite_input_and_keeps_state).
Terminal window
ol start M02.2 # stubs python/tinyllm/num/ema.py into your repo
ol tests M02.2 # read the test catalog first: rung R0, you write no tests here
ol check M02.2 # exit code is the verdict
ol check M02.2 --ref-deps # only if your M00.3 is not passing yet
ol diff M02.2 # after passing: your code against the reference

The loss your training loop prints (L0.5) jumps around from batch to batch, and you want to see its trend. The standard tool is an exponential moving average, and the first thing it does is lie: started from zero, it reports a loss of 0.55 when every batch says 5.5, and climbs toward the truth over hundreds of steps. Adam (M10.3), the optimizer you will train every model with, keeps two such averages per parameter and would take wildly wrong first steps without the correction this module derives. Both the lie and the fix are a fact about one infinite sum, the geometric series. This module defines convergence of series, sums the geometric one exactly, and builds the EMA with its bias correction on top of the sums you wrote in M00.3.

SymbolMeaningType / shape
a0,a1,a2,…a_0, a_1, a_2, \ldotsa sequence of numbers
St=∑j=0t−1ajS_t = \sum_{j=0}^{t-1} a_jthe tt-th partial sumfloat
rrthe ratio of a geometric sequence, aj=arja_j = a r^jfloat
xtx_tthe input at step t=1,2,…t = 1, 2, \ldotsfloat or array
β\betathe decay, 0≤β<10 \le \beta < 1float
mtm_tthe EMA after tt updates, m0=0m_0 = 0float or array
wi(t)w_i^{(t)}the weight of xix_i in mtm_tfloat
m^t\hat m_tthe bias-corrected average mt/(1−βt)m_t / (1 - \beta^t)float or array

A series ∑j≥0aj\sum_{j \ge 0} a_j is the sequence of its partial sums St=a0+⋯+at−1S_t = a_0 + \cdots + a_{t-1}. It converges to SS when the partial sums settle on SS in the sense of a limit (M01.1): for every tolerance there is a tt beyond which every StS_t is within it. Otherwise it diverges. Small terms are necessary but not enough: 1+12+13+14+⋯1 + \frac12 + \frac13 + \frac14 + \cdots diverges, because the terms from 12k+1\frac{1}{2^k + 1} to 12k+1\frac{1}{2^{k+1}} are 2k2^k numbers each at least 12k+1\frac{1}{2^{k+1}}, adding to at least 12\frac12 for every kk, so the sum passes any bound. A useful test, the ratio test: if ∣aj+1/aj∣|a_{j+1}/a_j| eventually stays below some q<1q < 1, the series converges, because from there on its terms are smaller than those of a geometric series with ratio qq (M02.1 used exactly this to bound the tail of the erf series).

For aj=arja_j = a r^j, multiply the partial sum by rr and subtract:

St−rSt=a−art⟹St=a 1−rt1−r(r≠1),St=at(r=1).S_t - r S_t = a - a r^t \quad\Longrightarrow\quad S_t = a\,\frac{1 - r^t}{1 - r} \quad (r \neq 1), \qquad S_t = a t \quad (r = 1) .

If ∣r∣<1|r| < 1, rt→0r^t \to 0 and the series converges to a1−r\frac{a}{1 - r}; if ∣r∣≥1|r| \ge 1 the terms do not shrink and it diverges. When rr is close to 1, 1−rt1 - r^t subtracts two nearly equal numbers, and M00.3’s geometric_sum evaluates it as expm1(tln⁡r)r−1\frac{\mathrm{expm1}(t \ln r)}{r - 1} to keep full precision; this module calls it rather than re-deriving it.

The update mt=βmt−1+(1−β)xtm_t = \beta m_{t-1} + (1 - \beta)x_t keeps a fraction β\beta of the old average and mixes in 1−β1 - \beta of the new input. Unroll it from m0=0m_0 = 0:

mt=(1−β)xt+β(1−β)xt−1+β2(1−β)xt−2+⋯+βt−1(1−β)x1=∑i=1t(1−β)βt−i⏟wi(t) xi.m_t = (1 - \beta)x_t + \beta(1 - \beta)x_{t-1} + \beta^2(1 - \beta)x_{t-2} + \cdots + \beta^{t-1}(1 - \beta)x_1 = \sum_{i=1}^{t} \underbrace{(1 - \beta)\beta^{t-i}}_{w_i^{(t)}}\, x_i .

The newest input has weight 1−β1 - \beta, and each step back in time multiplies the weight by β\beta: a geometric sequence. ema_weights(beta, t) returns w1(t),…,wt(t)w_1^{(t)}, \ldots, w_t^{(t)} oldest first, which is geometric(1 - beta, beta, t) reversed. The effective window is 11−β\frac{1}{1 - \beta} (the sum of the infinite weight sequence divided by its largest weight): 10 steps for β=0.9\beta = 0.9, 1000 for β=0.999\beta = 0.999. The half-life, after which an input’s weight has halved, is ln⁡12ln⁡β\frac{\ln \frac12}{\ln \beta}: 6.6 steps for β=0.9\beta = 0.9.

The weights add up to a geometric partial sum:

∑i=1twi(t)=(1−β)1−βt1−β=1−βt.\sum_{i=1}^{t} w_i^{(t)} = (1 - \beta)\frac{1 - \beta^t}{1 - \beta} = 1 - \beta^t .

An average whose weights add to less than 1 is pulled toward 0, which is where it started. If every xi=cx_i = c, then mt=c(1−βt)m_t = c(1 - \beta^t): after one step with β=0.9\beta = 0.9, only 0.1c0.1c. Dividing by the total turns the weights back into an average,

m^t=mt1−βt,\hat m_t = \frac{m_t}{1 - \beta^t} ,

and for a constant input m^t=c\hat m_t = c at every tt, exactly in real arithmetic (and to a few ε/(1−β)\varepsilon/(1 - \beta) in float64). If the inputs are random with the same mean μ\mu, the expected value of mtm_t is (1−βt)μ(1 - \beta^t)\mu, so m^t\hat m_t is an unbiased estimate of μ\mu: the reason Adam’s update has the right size from its first step. The correction matters only early: βt\beta^t falls below 1 percent after t≈4.61−βt \approx \frac{4.6}{1 - \beta} steps, and from then on m^t≈mt\hat m_t \approx m_t.

Two consequences shape the code. The zero start is part of the definition: initializing mm with x1x_1 and then dividing by 1−βt1 - \beta^t corrects a bias that is not there (m^1=10 x1\hat m_1 = 10\,x_1 for β=0.9\beta = 0.9). And at t=0t = 0 the correction is 0/00/0: nothing has been averaged, so value_debiased raises instead of returning a number.

β=0.9\beta = 0.9, inputs x1=1x_1 = 1, x2=2x_2 = 2, x3=3x_3 = 3:

ttmt=0.9 mt−1+0.1 xtm_t = 0.9\,m_{t-1} + 0.1\,x_t1−0.9t1 - 0.9^tm^t\hat m_t
10.9⋅0+0.1⋅1=0.10.9 \cdot 0 + 0.1 \cdot 1 = 0.10.10.1/0.1=10.1/0.1 = 1
20.9⋅0.1+0.1⋅2=0.290.9 \cdot 0.1 + 0.1 \cdot 2 = 0.290.190.29/0.19=1.52631580.29/0.19 = 1.5263158
30.9⋅0.29+0.1⋅3=0.5610.9 \cdot 0.29 + 0.1 \cdot 3 = 0.5610.2710.561/0.271=2.07011070.561/0.271 = 2.0701107

Unrolled, m3=0.081⋅1+0.09⋅2+0.1⋅3=0.081+0.18+0.3=0.561m_3 = 0.081 \cdot 1 + 0.09 \cdot 2 + 0.1 \cdot 3 = 0.081 + 0.18 + 0.3 = 0.561: the same number, with weights [0.081,0.09,0.1][0.081, 0.09, 0.1] adding to 0.271=1−0.930.271 = 1 - 0.9^3. The biased m3=0.561m_3 = 0.561 is below even the smallest input; the debiased 2.07 is a weighted average of 1, 2, 3 that leans toward the newest. These are test_hand_example and test_weights_hand_example.

def ema_weights(beta: float, t: int) -> NDArray: ... # oldest first, sum 1 - beta**t
class EMA:
def __init__(self, beta: float) -> None: ... # 0 <= beta < 1, m_0 = 0
t: int # property: updates so far
value: float | NDArray # property: m_t (a copy)
def update(self, x: ArrayLike) -> float | NDArray: ...
def value_debiased(self) -> float | NDArray: ... # m_t / (1 - beta**t)

A number in gives a Python float out; an array in gives a new float64 array, averaged elementwise, and later updates must keep its shape. Non-finite input raises ValueError and leaves the state unchanged; value_debiased before any update raises RuntimeError.

TestKINDChecksWhy it matters downstream
test_hand_exampleunit, smokesection 3’s mtm_t and m^t\hat m_t, the counter, Python floatsyou and the tests agree on the recursion and the correction
test_weights_hand_exampleunit[0.081,0.09,0.1][0.081, 0.09, 0.1], total 0.271the unrolled form
test_weights_sum_to_one_minus_beta_powerpropertytotals 1−βt1 - \beta^t and ratio β\beta for five β\beta and five ttthe geometric partial sum of section 2.2
test_update_equals_weighted_sumdifferentialthe recursion equals the dot product with ema_weights on 200 seeded inputstwo definitions, one number
test_debiased_constant_is_exactproperty, smokem^t=c\hat m_t = c for β\beta up to 0.9999, tt up to 3000the design’s property for this module
test_matches_exact_rational_arithmeticgoldenthe float64 mtm_t and m^t\hat m_t against the same recursion in exact rational arithmetic (fractions.Fraction), rounded once, after 200 seeded stepsrounding in the recursion stays at a few hundred ulps
test_bias_fades_without_correctionpropertym50=10(1−0.950)m_{50} = 10(1 - 0.9^{50})how long the bias lasts
test_beta_zero_is_the_last_valueboundaryβ=0\beta = 0 returns the latest inputannealed schedules
test_arrays_are_averaged_elementwisedifferentialan array EMA equals one scalar EMA per element; returned arrays are copies; shape changes raiseEMA weights over whole tensors in C1
test_rejects_nonfinite_input_and_keeps_stateboundarynan and inf raise, state unchangedone bad batch must not poison the curve
test_debiased_before_update_raisesboundaryt=0t = 0 raises RuntimeError0/0 is not a number
test_rejects_bad_betaboundaryβ<0\beta < 0, β≥1\beta \ge 1, nan raiseβ=1\beta = 1 never moves
PitfallSymptomCaught by
1. β\beta and 1−β1 - \beta swapped in the updatethe average follows the newest input with weight 0.9 and forgets at oncetest_hand_example, test_update_equals_weighted_sum (mutant s01)
2. starting from m0=x1m_0 = x_1 and still dividing by 1−βt1 - \beta^tm^1=10 x1\hat m_1 = 10\,x_1 for β=0.9\beta = 0.9test_debiased_constant_is_exact (mutant s02)
3. an off-by-one power: 1−βt+11 - \beta^{t+1} or 1−βt−11 - \beta^{t-1}m^1=0.526\hat m_1 = 0.526 for x1=1x_1 = 1, or a division by zero at t=1t = 1test_debiased_constant_is_exact (mutants s03, s06)
4. accepting nan or infevery later average is nantest_rejects_nonfinite_input_and_keeps_state (mutant s09)
forgetting the correction entirelythe hand example’s 0.1 reported as the average of 1test_hand_example (mutant s04)
weights listed newest firsta weighted sum with the wrong inputs emphasizedtest_weights_hand_example (mutant s05)
rejecting β=0\beta = 0a valid, if trivial, average refusedtest_beta_zero_is_the_last_value (mutant s07)
returning the internal array from updatea caller’s in-place edit changes the averagetest_arrays_are_averaged_elementwise (mutant s08)
no check at t=0t = 0ZeroDivisionError instead of a clear errortest_debiased_before_update_raises (mutant s10)
DirectionModuleHow it uses this
BackM00.3geometric(1 - beta, beta, t) gives the weights and geometric_sum(1 - beta, beta, t) the correction total 1−βt1 - \beta^t
ForwardL0.5train_step(..., ema=EMA(beta)) feeds each step’s loss to update and reports value_debiased() as loss_ema, the smoothed loss curve
ForwardM10.3Adam’s first and second moments are EMAs of the gradient and its square, divided by 1−β1t1 - \beta_1^t and 1−β2t1 - \beta_2^t
ForwardC1an EMA of the model weights, evaluated alongside the raw weights
Your pieceProduction equivalentWhat it addsWhere to look
EMA over weightsPyTorch torch.optim.swa_utils.AveragedModel with get_ema_multi_avg_fnEMA of every parameter tensor and buffer, updated in place on the devicetorch/optim/swa_utils.py
bias correctionAdam and AdamWthe same correction on two moments, often folded into the step size α1−β2t/(1−β1t)\alpha \sqrt{1 - \beta_2^t}/(1 - \beta_1^t)torch/optim/adam.py; M10.3
smoothed loss curveTensorBoard’s smoothing slidera debiased EMA of the plotted scalar, exactly this moduletensorboard/plugins/scalar (the smoothing weight)
geometric_sum near r=1r = 1numpy.expm1, numpy.log1pthe functions that make 1−βt1 - \beta^t accurate for β=0.9999\beta = 0.9999M00.3 section 2