Series convergence, EMA as a geometric series, bias correction
Overview
Section titled “Overview”| Module | M02.2 · build · Python · Pass 2 · 2 to 3 h |
| You build | python/tinyllm/num/ema.py: ema_weights and the class EMA (update, value, t, value_debiased) |
| Contract | course/contracts/py/tinyllm/num/ema.pyi |
| Tests | course/tests/M02.2/test_ema.py (what they check: section 4) |
| Needs | M00.3 geometric and geometric_sum, which give the weights and their total (or --ref-deps) |
| Used by | L0.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) |
| Milestone | MS-P2 (the Pass 2 gate) |
| Optional depth | OpenStax, 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) |
Key Takeaways
Section titled “Key Takeaways”- A series converges when its partial sums settle on a number; the geometric series converges to exactly when , and its partial sum is (
test_weights_sum_to_one_minus_beta_power). - The exponential moving average from is a weighted sum of all inputs with geometric weights (
test_weights_hand_example,test_update_equals_weighted_sum). - Those weights add up to , not 1, so early values are biased toward 0; dividing by 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 , after roughly steps; with that is a thousand steps of a misleading curve (
test_bias_fades_without_correction). - One nan in the input poisons every later average, so
updaterejects non-finite values and keeps its state (test_rejects_nonfinite_input_and_keeps_state).
How to work this chapter
Section titled “How to work this chapter”ol start M02.2 # stubs python/tinyllm/num/ema.py into your repool tests M02.2 # read the test catalog first: rung R0, you write no tests hereol check M02.2 # exit code is the verdictol check M02.2 --ref-deps # only if your M00.3 is not passing yetol diff M02.2 # after passing: your code against the reference1. Why now
Section titled “1. Why now”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.
2. Principles
Section titled “2. Principles”| Symbol | Meaning | Type / shape |
|---|---|---|
| a sequence of numbers | ||
| the -th partial sum | float | |
| the ratio of a geometric sequence, | float | |
| the input at step | float or array | |
| the decay, | float | |
| the EMA after updates, | float or array | |
| the weight of in | float | |
| the bias-corrected average | float or array |
2.1 Series and convergence
Section titled “2.1 Series and convergence”A series is the sequence of its partial sums . It converges to when the partial sums settle on in the sense of a limit (M01.1): for every tolerance there is a beyond which every is within it. Otherwise it diverges. Small terms are necessary but not enough: diverges, because the terms from to are numbers each at least , adding to at least for every , so the sum passes any bound. A useful test, the ratio test: if eventually stays below some , the series converges, because from there on its terms are smaller than those of a geometric series with ratio (M02.1 used exactly this to bound the tail of the erf series).
2.2 The geometric series
Section titled “2.2 The geometric series”For , multiply the partial sum by and subtract:
If , and the series converges to ; if the terms do not shrink and it diverges. When is close to 1, subtracts two nearly equal numbers, and M00.3’s geometric_sum evaluates it as to keep full precision; this module calls it rather than re-deriving it.
2.3 The EMA unrolled
Section titled “2.3 The EMA unrolled”The update keeps a fraction of the old average and mixes in of the new input. Unroll it from :
The newest input has weight , and each step back in time multiplies the weight by : a geometric sequence. ema_weights(beta, t) returns oldest first, which is geometric(1 - beta, beta, t) reversed. The effective window is (the sum of the infinite weight sequence divided by its largest weight): 10 steps for , 1000 for . The half-life, after which an input’s weight has halved, is : 6.6 steps for .
2.4 Bias correction
Section titled “2.4 Bias correction”The weights add up to a geometric partial sum:
An average whose weights add to less than 1 is pulled toward 0, which is where it started. If every , then : after one step with , only . Dividing by the total turns the weights back into an average,
and for a constant input at every , exactly in real arithmetic (and to a few in float64). If the inputs are random with the same mean , the expected value of is , so is an unbiased estimate of : the reason Adam’s update has the right size from its first step. The correction matters only early: falls below 1 percent after steps, and from then on .
Two consequences shape the code. The zero start is part of the definition: initializing with and then dividing by corrects a bias that is not there ( for ). And at the correction is : nothing has been averaged, so value_debiased raises instead of returning a number.
3. Worked example by hand
Section titled “3. Worked example by hand”, inputs , , :
| 1 | 0.1 | ||
| 2 | 0.19 | ||
| 3 | 0.271 |
Unrolled, : the same number, with weights adding to . The biased 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.
4. The interface
Section titled “4. The interface”def ema_weights(beta: float, t: int) -> NDArray: ... # oldest first, sum 1 - beta**tclass 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.
What the tests check
Section titled “What the tests check”| Test | KIND | Checks | Why it matters downstream |
|---|---|---|---|
test_hand_example | unit, smoke | section 3’s and , the counter, Python floats | you and the tests agree on the recursion and the correction |
test_weights_hand_example | unit | , total 0.271 | the unrolled form |
test_weights_sum_to_one_minus_beta_power | property | totals and ratio for five and five | the geometric partial sum of section 2.2 |
test_update_equals_weighted_sum | differential | the recursion equals the dot product with ema_weights on 200 seeded inputs | two definitions, one number |
test_debiased_constant_is_exact | property, smoke | for up to 0.9999, up to 3000 | the design’s property for this module |
test_matches_exact_rational_arithmetic | golden | the float64 and against the same recursion in exact rational arithmetic (fractions.Fraction), rounded once, after 200 seeded steps | rounding in the recursion stays at a few hundred ulps |
test_bias_fades_without_correction | property | how long the bias lasts | |
test_beta_zero_is_the_last_value | boundary | returns the latest input | annealed schedules |
test_arrays_are_averaged_elementwise | differential | an array EMA equals one scalar EMA per element; returned arrays are copies; shape changes raise | EMA weights over whole tensors in C1 |
test_rejects_nonfinite_input_and_keeps_state | boundary | nan and inf raise, state unchanged | one bad batch must not poison the curve |
test_debiased_before_update_raises | boundary | raises RuntimeError | 0/0 is not a number |
test_rejects_bad_beta | boundary | , , nan raise | never moves |
5. Pitfalls
Section titled “5. Pitfalls”| Pitfall | Symptom | Caught by |
|---|---|---|
| 1. and swapped in the update | the average follows the newest input with weight 0.9 and forgets at once | test_hand_example, test_update_equals_weighted_sum (mutant s01) |
| 2. starting from and still dividing by | for | test_debiased_constant_is_exact (mutant s02) |
| 3. an off-by-one power: or | for , or a division by zero at | test_debiased_constant_is_exact (mutants s03, s06) |
| 4. accepting nan or inf | every later average is nan | test_rejects_nonfinite_input_and_keeps_state (mutant s09) |
| forgetting the correction entirely | the hand example’s 0.1 reported as the average of 1 | test_hand_example (mutant s04) |
| weights listed newest first | a weighted sum with the wrong inputs emphasized | test_weights_hand_example (mutant s05) |
| rejecting | a valid, if trivial, average refused | test_beta_zero_is_the_last_value (mutant s07) |
returning the internal array from update | a caller’s in-place edit changes the average | test_arrays_are_averaged_elementwise (mutant s08) |
| no check at | ZeroDivisionError instead of a clear error | test_debiased_before_update_raises (mutant s10) |
6. Where it’s used next
Section titled “6. Where it’s used next”| Direction | Module | How it uses this |
|---|---|---|
| Back | M00.3 | geometric(1 - beta, beta, t) gives the weights and geometric_sum(1 - beta, beta, t) the correction total |
| Forward | L0.5 | train_step(..., ema=EMA(beta)) feeds each step’s loss to update and reports value_debiased() as loss_ema, the smoothed loss curve |
| Forward | M10.3 | Adam’s first and second moments are EMAs of the gradient and its square, divided by and |
| Forward | C1 | an EMA of the model weights, evaluated alongside the raw weights |
Going further
Section titled “Going further”| Your piece | Production equivalent | What it adds | Where to look |
|---|---|---|---|
EMA over weights | PyTorch torch.optim.swa_utils.AveragedModel with get_ema_multi_avg_fn | EMA of every parameter tensor and buffer, updated in place on the device | torch/optim/swa_utils.py |
| bias correction | Adam and AdamW | the same correction on two moments, often folded into the step size | torch/optim/adam.py; M10.3 |
| smoothed loss curve | TensorBoard’s smoothing slider | a debiased EMA of the plotted scalar, exactly this module | tensorboard/plugins/scalar (the smoothing weight) |
geometric_sum near | numpy.expm1, numpy.log1p | the functions that make accurate for | M00.3 section 2 |