EVERYTHING AIAI engineering, made visual
0/23 complete
LESSON 13 · MATHEMATICS × AI · BUILD

The number line
is full of holes.

0.1 + 0.2 = 0.30000000000000004 A float is a sign, an exponent and about seven significant digits. Everything past those digits is rounding — and the crashes of training live in the gaps.

70 MIN · 8 CHAPTERSPREREQ · LESSONS 01–04
FIG. 13 / ONE VALUE ACROSS 86 DECADES
VALUE 1e−46 · FP16 0 log₁₀ axis f32 bf16 f16
LESSON 13TYPE · BUILD~70 MINPREREQ · LESSONS 01–04ORIGINAL LESSON ↗
THE 60-SECOND VERSIONThen show me the bits ↓
01 / SIGN, EXPONENT, MANTISSA

A float is scientific notation in bits.

One bit says which way, eight bits say how far, twenty-three bits say how precisely. The exponent buys range; the mantissa buys significant digits. float32 resolves about 7 decimal digits, float16 about 3.

value = (−1)ˢ × 2^(e−127) × 1.mantissa
02 / RANGE VS PRECISION

The 16-bit formats split the budget.

float16 keeps 10 mantissa bits and tops out at 65,504. bfloat16 keeps float32's exponent range and only 7 mantissa bits. Training spikes past 65,504, so bfloat16 usually wins there; inference wants precision, so float16 wins there.

float16: precision · bfloat16: range
03 / SUBTRACT THE MAX FIRST

Keep the largest exponent at zero.

exp overflows past 88.7 in float32 and 11.1 in float16. Shifting every logit by −max leaves the probabilities identical — the factor cancels — while the biggest exponent becomes e⁰ = 1, which always fits.

c + log Σ e^(x−c), c = max x
MENTAL MODEL IN ONE SENTENCE

A float is scientific notation with a fixed budget — one bit for direction, eight for scale, twenty-three for about seven digits — and every stability trick either keeps a result inside that budget (stable softmax, clamping, loss scaling) or rearranges the formula so the missing digits never matter (two-pass variance, factored differences, relative gradient checks).

By the end you will be able to read every dtype’s trade-off on sight, spot catastrophic cancellation before it ships, write stable softmax and log-sum-exp from memory, choose a gradient-check h that matches the precision of your code, and explain why bfloat16 trains without loss scaling.

THE BOX NUMBERS LIVE IN

A float is scientific notation
with a fixed bit budget.

IEEE-754 gives every float three fields: a sign, an exponent, and a mantissa. The exponent decides how far the number line reaches; the mantissa decides how finely it is marked.

A float32 stores a real number in 32 bits: one sign bit, eight exponent bits, and twenty-three mantissa bits. The value it represents is

float32 layout: [1 sign] [8 exponent] [23 mantissa] value = (−1)^sign × 2^(exponent − 127) × 1.mantissa sign 0 for positive, 1 for negative exponent the scale, 2^(field − 127) mantissa the significant digits after an implicit leading 1

Plain English: the exponent says how many places the binary point moves — that sets the range — and the mantissa records the leading digits — that sets the precision. In decimal scientific notation 6.02 × 10²³, the mantissa is 6.02 and the exponent is 23; here the base is 2 instead of 10 and the budget is fixed. The leading 1 is implicit, so float32 really has 24 bits of significant information, about 7 decimal digits.

Numbers that are sums of powers of two — 6.5, 2.25, 0.125 — fit exactly. Everything else is rounded to the nearest representable float. The gap between neighbouring floats is called one ulp (unit in the last place), and it is what makes floating point leaky.

Float inspector

Type any number. See its float32 bits, where it sits between its neighbours, and what float16 and bfloat16 would store instead.

you typed 0.1 float64 stores 0.1000000000000000055511151231257827021181583404541015625 float32 stores 0.100000001490116119384765625 float16 stores 0.0999755859375 (max 65,504) bfloat16 stores 0.10009765625 bit pattern 0 01111011 10011001 100110011001101 sign exponent mantissa exponent field 01111011₂ = 123 → 2^-4 next float (+∞) 0.10000000894069671630859375 gap (1 ulp) 7.4506e−9 relative 7.451e−8

The gap is the value’s whole resolution: add anything smaller than half of it and the sum rounds back to where it started. Near 1.0 that gap is 1.19e−7 for float32 — machine epsilon.

Worked check: encode 6.5 and 0.1, then find the gap at 1.0
  1. 6.5 exactly. In binary, 6.5 = 110.1₂ = 1.101₂ × 2². The exponent field is 2 + 127 = 129 = 10000001₂, and the mantissa is 101 followed by twenty zeros. Every bit fits, so 6.5 is stored with zero error — the float inspector confirms bit pattern 0 10000001 10100000000000000000000.
  2. 0.1 approximately. 0.1 = 1.6 × 2⁻⁴, and 1.6 in binary is 1.1001100110011… repeating forever, like 1/3 in decimal. After 23 mantissa bits the stored value is exactly 0.100000001490116119384765625 — the inspector prints it. That is an absolute error of 1.49e−9, about 1.5e−8 relative.
  3. Machine epsilon. Near 1.0 the exponent is 0, so consecutive floats differ by 2⁻²³ = 1.1920928955078125e−7. Add 2⁻²⁴ (half a step) to 1.0 and the tie rounds to even, back to 1.0; add 2⁻²³ and you reach 1.0000001192092896. So 2⁻²³ is the smallest x with 1.0 + x ≠ 1.0: the machine epsilon.
  4. The gap grows with the value. At 2^24 = 16777216 the spacing is 2.0, so 2^24 + 1 is not representable: float32 stores 16777216. Slide the inspector past any power of two and watch the ulp double.
0.1 (float32) = 0.100000001490116119384765625 0.1 (float64) = 0.1000000000000000055511151231257827… machine epsilon float32 = 2^−23 = 1.1920928955078125e−7 gap at 16,777,216 = 2.0 (so 16,777,217 → 16,777,216)
Quick check

float32 stores 6.5 exactly but only approximates 0.1. Which field of the float decides that?

RANGE AND PRECISION

Four formats,
two separate budgets.

Exponent bits buy range; mantissa bits buy precision. The 16-bit formats split those 16 bits in opposite ways — and that choice is why one is for training and the other for inference.

float32 and float64 are the everyday formats. float16 and bfloat16 halve the memory and speed up matmul on Tensor Cores, but each must give something up: float16 trades range for mantissa bits, bfloat16 trades mantissa bits for range. The source table, with exact extremes:

FormatBitsExp.MantissaDigitsLargestSmallest normal
float64641152~161.8e3082.2e−308
float3232823~73.4e381.2e−38
bfloat161687~2–33.4e381.2e−38
float1616510~365,5046.1e−5

Precision ranger

Move one value across 650 decades. Each format’s bar is its representable range; red is overflow to inf, blue is underflow to zero. The small tick marks the gap at 1.0 — its precision.

value = 1.000000e−1 float64 = 1.000e−1 float32 = 1.000e−1 bfloat16 = 1.001e−1 float16 = 9.998e−2 float16 max 65,504 float32 max 3.4e38 float16 eps 9.8e−4 bfloat16 eps 7.8e−3 float32 eps 1.19e−7 float64 eps 2.2e−16

Range and precision are separate budgets: bfloat16 spends its 16 bits on float32’s exponent range and keeps only 7 mantissa bits; float16 does the opposite. Training needs range first.

Rounding is the everyday symptom. The number 0.1 is a repeating fraction in binary, so it cannot be stored exactly at any finite precision. The nearest float64 is 0.1000000000000000055511…, and the nearest float32 is 0.100000001490116119384765625. Add the stored values and the error that was invisible surfaces:

float64 (Python, JavaScript): 0.1 + 0.2 = 0.30000000000000004 0.1 + 0.2 == 0.3 → False the sum is off by 5.55e−17 — exactly one ulp at 0.3 (2⁻⁵⁴) float32: fl(0.1) = 0.100000001490116119384765625 fl(0.2) = 0.20000000298023223876953125 exact sum = 0.300000004470348358154296875 fl(0.3) = 0.300000011920928955078125

Plain English: every decimal you type is first snapped to the nearest float, and arithmetic then happens on the snapped values. The fix is not a better formula — it is to compare with a tolerance and to expect drift when millions of tiny terms accumulate.

Derivation: decimal digits ≈ mantissa bits × log₁₀ 2

One bit of mantissa resolves a factor of two; in decimal that is a factor of log₁₀ 2 ≈ 0.30103. Multiply by the number of bits:

float32: 23 × 0.30103 = 6.92 → about 7 digits float64: 52 × 0.30103 = 15.65 → about 16 digits float16: 10 × 0.30103 = 3.01 → about 3 digits bfloat16: 7 × 0.30103 = 2.11 → about 2 digits numeric check: float32 can separate 1.0000001 from 1.0000002 — their gap of 1e−7 is about one 1.19e−7 step at 1.0 — but it cannot separate 1.00000001 from 1.00000002: their 1e−8 gap rounds both literals back to 1.0. float16 can resolve 1.001 (1 + 2^−10) but not 1.0001 — a gap of 1e−4 is much smaller than its 9.77e−4 spacing at 1.

The exponent budget works the same way: float32’s 8 exponent bits span 2⁻¹²⁶ to 2¹²⁷, roughly 1.2e−38 to 3.4e38; float16’s 5 bits span 2⁻¹⁴ to 2¹⁵, or 6.1e−5 to 65,504. Drop below the smallest normal and you enter the subnormal range, where precision degrades; drop below the smallest subnormal (5.96e−8 for float16) and the value is zero.

Quick check

In Python (float64), why is 0.1 + 0.2 == 0.3 false?

WHEN DIGITS CANCEL

Subtraction can delete
everything you knew.

When two nearly equal numbers are subtracted, the digits they share vanish and the rounding noise hiding in their last bits becomes the answer. The error never grew — the result shrank to the size of the error.

Every stored float carries a small absolute error, roughly its size times machine epsilon. If a subtraction cancels the leading digits, that error stays the same size while the result becomes tiny — so the relative error explodes. The textbook example, in float32:

what you mean: 1.0000001 − 1.0000000 = 0.0000001 (1e−7) what float32 stores: fl(1.0000001) = 1.0000001192092896 fl(1.0000000) = 1.0000000000000000 computed difference = 0.0000001192092896 relative error = (1.1920928956e−7 − 1e−7) / 1e−7 = 19.2%

One subtraction, a 19% error, and no exception raised. The same pattern appears in three places every ML engineer meets: computing a variance as E[x²] − E[x]², applying the quadratic formula when b² ≫ 4ac, and choosing too small a step h for a finite-difference gradient.

The cancellation bench

Subtraction deletes shared digits. Two classic casualties: the variance formula E[x²] − E[x]², and a² − b² when a and b are close.

n = 1300000 mean = exactly 1300001 naive float32 0 two-pass float32 0.6666666865348816 true variance 0.6667 relative error 1.00e+0 (naive) 2.98e−8 (two-pass)

The fix is never “more digits” — it is rearranging the formula so the large, nearly equal numbers never meet in a subtraction.

Derivation: the naive variance dies, and two ways to rescue it

For data with a large mean, the naive formula squares numbers that are all nearly equal. The squares have only ~7 significant digits in float32, so their difference — the variance — is swamped. The source example, [1000000, 1000001, 1000002], has population variance exactly 2/3 = 0.6667:

naive in float32: mean = 1000001 (exact) x² = 999999995904, 1000002027520, 1000003993600 E[x²] = 1000002027520 (the same float as mean²) variance = E[x²] − mean² = 0 → 100% relative error two-pass (subtract the mean first): deviations −1, 0, 1 are exact variance = (1 + 0 + 1)/3 = 0.6666666865348816 relative error = 3.0e−8 Welford, one pass, no big subtractions: n=1: mean 1000000, M2 = 0 n=2: delta 1, mean 1000000.5, M2 = 0 + 1 × (1000001 − 1000000.5) = 0.5 n=3: delta 1.5, mean 1000001, M2 = 0.5 + 1.5 × (1000002 − 1000001) = 2.0 population variance = M2 / n = 2/3 ✓

The quadratic formula has the same disease. For x² − 1000000x + 1 = 0, the roots are near 1e6 and 1e−6; subtracting the nearly equal 1e6 from √(1e12 − 4) destroys the small root. Since the product of the roots is the constant term, compute the small root as C / (large root):

naive: (1000000 − √(1000000² − 4)) / 2 float32: √(999999995904) rounds to exactly 1000000 (1000000 − 1000000) / 2 = 0 → the small root vanishes stable: large = (1000000 + √(…)) / 2 ≈ 1000000 small = C / large = 1 / 1000000 = 9.99999997e−7 (float32) true small root ≈ 1.000000000001e−6 (a correction of one part in 10¹² that the naive subtraction never had a chance to preserve) same equation, same inputs — only the order of operations changed.
Quick check

You subtract two float32 numbers that agree in their first six digits. What happens to the relative error of the result?

INF, NAN, AND THE EDGE

Every format has
an edge, and it is sharp.

Overflow turns a result into infinity, underflow turns it into zero, and undefined operations turn it into NaN. From there, one bad value can poison every later calculation.

The boundaries are exact, not fuzzy. A float32 can hold 3.4028234663852886e38, the largest value below 2¹²⁸; anything larger rounds to inf. Its smallest positive value is the subnormal 1.401298464324817e−45; anything smaller than half of that (below 2⁻¹⁵⁰ ≈ 7.006e−46) rounds to 0.0. Taking the natural log of the overflow point gives the exp() domain limit:

ln(3.4028234663852886e38) = 88.72283905206835 exp(88.7) = 3.33e38 fits (barely) exp(89.0) = 4.49e38 → inf ln(65,504) = 11.089866488461016 for float16 exp(11.0) = 59,874 fits exp(11.1) = 66,080 → inf underflow: exp(−103.9) = 7.53e−46 → rounds up to the smallest subnormal 1.4e−45; exp(−104) = 6.81e−46 is below the 7.006e−46 midpoint, so it rounds to 0.0 exactly. log: log(0.0) = −inf, log(−1.0) = nan, log(1e−45) = −103.28 (fine).

Plain English: exp amplifies size, so it is the main source of overflow in ML — softmax, sigmoid, and probability math are full of it. log runs the other direction: its input must be strictly positive, and an underflowed 0 becomes −inf. The dangerous composition is log(exp(x)) without a shift.

-120-80-4004080input x to exp(x)float32−inf → 0→ infexp(88) = 1.65e38 fits · exp(89) = inf · exp(−104) = 0float16−inf → 0→ infexp(11) = 59,874 fits · exp(11.1) = inf · exp(−17.4) = 0
The safe strip for exp(x) in each format. Inside it, the output is a normal float; to the left, exponentials round down to zero (so a later log sees 0); to the right, they overflow to infinity (so a later normalization sees inf/inf). Both ends feed the NaN family.

NaN (Not a Number) comes from undefined operations: 0.0 / 0.0, inf − inf, inf × 0, √(−1), log(−1). Infinity comes from overflow or division by zero. Both propagate: any arithmetic touching a NaN produces NaN, and one NaN gradient makes its weight NaN, which makes every later output NaN. Training dies within a step. Detection is cheap — math.isnan, math.isinf, math.isfinite — and worth running after every forward pass while debugging.

softmax([100, 101, 102]) in float32, naive: exp(100) = inf, exp(101) = inf, exp(102) = inf sum = inf probs = inf/inf = [nan, nan, nan] prevention list: 1. subtract max before exp (stable softmax / log-sum-exp) 2. clamp inputs: exp(clamp(x, −80, 80)) 3. add epsilon inside logs and denominators 4. clip gradients to a maximum norm 5. assert finite after a forward pass while developing
SOFTMAX, MADE SAFE

Subtract the max.
The probabilities never change.

Softmax exponentiates logits and divides by their sum. The division is what makes the answer scale-free — and that same scale-freedom is the loophole that makes the computation safe.

Softmax turns K real-valued logits into K probabilities that sum to 1:

softmax(zᵢ) = e^(zᵢ) / Σⱼ e^(zⱼ) stable form: softmax(zᵢ) = e^(zᵢ − m) / Σⱼ e^(zⱼ − m), m = max(z)

Plain English: exponentiate to make every score positive and to exaggerate the gaps, then divide by the total so the results are proportions. Subtracting the same number m from every logit multiplies numerator and denominator by e^(−m), and the factor cancels — the probabilities are mathematically identical. Choose m = max(z) and the biggest exponent becomes e⁰ = 1, which cannot overflow, while the sum is at least 1, so the divisor is never 0.

The lesson’s source values: with logits [2, 1, 0.1] both routes give [0.659, 0.242, 0.099]. With logits [100, 101, 102] the naive route computes e¹⁰⁰ = inf in float32 and returns [nan, nan, nan]; the stable route shifts to [−2, −1, 0], gets exponentials [0.135, 0.368, 1.000] summing to 1.503, and returns [0.090, 0.245, 0.665] — exactly what a perfect implementation should.

Softmax with and without the max shift

Add a growing offset to every logit [2, 1, 0.1]. Shifting all logits must not change the probabilities — the naive code only disagrees because eᶻ left the format’s range.

logits = [102.0, 101.0, 100.1] max = 102.0 naive exps [inf, inf, inf] naive sum inf naive probs [nan, nan, nan] stable exps [1.000e+0, 3.679e−1, 1.496e−1] stable sum 1.5174 stable probs [0.659, 0.242, 0.099] NAIVE FAILS: exp overflowed the format.

In float32, eᶻ overflows once z passes ≈ 88.7. Subtracting the max makes the largest exponent 0, and exp(0) = 1 always fits.

Derivation: why the shift is free, and what log-sum-exp buys

Both facts follow from the exponent rule e^(a+b) = e^a · e^b. For the shift:

softmax(z − m)ᵢ = e^(zᵢ − m) / Σⱼ e^(zⱼ − m) = e^(zᵢ)·e^(−m) / (e^(−m)·Σⱼ e^(zⱼ)) = e^(zᵢ) / Σⱼ e^(zⱼ) ✓ identical numeric check, z = [100, 101, 102], m = 102: e^(z−m) = [0.135335, 0.367879, 1.000000] Σ = 1.503214 probs = [0.090030, 0.244728, 0.665241] sum of probs = 1.000000 ✓

Log-sum-exp is the same trick seen through a logarithm. It is the quantity inside every log-probability and every cross-entropy:

log Σᵢ e^(xᵢ) = log Σᵢ e^(xᵢ − c + c) add and subtract c = log Σᵢ e^(xᵢ − c)·e^c e^(a+b) = e^a·e^b = log [ e^c · Σᵢ e^(xᵢ − c) ] factor e^c out of the sum = c + log Σᵢ e^(xᵢ − c) log(a·b) = log a + log b choose c = max(x): largest term is e⁰ = 1 → no overflow, ever at least one term is exactly 1 → Σ ≥ 1, so log never sees 0 numeric check, x = [500, 501, 502]: naive float32: e⁵⁰⁰ overflows → inf stable: 502 + log(1 + e⁻¹ + e⁻²) = 502 + log(1.5032147) = 502.407606 cross-entropy check, z = [2, 5, 1], true class 0: loss = logsumexp(z) − z₀ = 5.0658877 − 2 = 3.0658877 nats

The stable form is not an optimization; it is a requirement for correctness. Frameworks fuse softmax and cross-entropy so that the loss is computed from logits with log-sum-exp inside.

The three functions, exactly as the lesson builds thempython
import math

def softmax_stable(z):
    m = max(z)                                   # largest logit
    e = [math.exp(v - m) for v in z]             # largest exponent is 0
    s = sum(e)
    return [x / s for x in e]

def logsumexp(z):
    c = max(z)                                   # factor out e^c
    return c + math.log(sum(math.exp(v - c) for v in z))

def cross_entropy_stable(true_class, z):
    return logsumexp(z) - z[true_class]          # −log softmax(z)[t]
Every value in this chapter was recomputed from these formulas; the labs run the same arithmetic in your browser.

Log-sum-exp calculator (float32)

Edit up to three values. The naive route exponentiates immediately; the stable route subtracts the max first. Watch which one survives.

values [500, 501, 502] max c = 502 NAIVE log Σ eˣ eˣ inf, inf, inf Σ eˣ inf result inf STABLE c + log Σ e^(x−c) x−c -2, -1, 0 e^(x−c) 1.3534e−1, 3.6788e−1, 1.0000e+0 Σ 1.503215e+0 result 5.024076e+2 float64 reference 5.024076e+2 the two routes disagree only because float32 ran out of range
Quick check

Your stable softmax subtracts max(logits) before exp. The largest exponent is 0. Why does that guarantee no overflow?

SMALL NUMBERS, SWEET SPOTS

Too small is noise.
Too big is a lie.

Gradient checks and normalization layers both take a tiny constant — a step h, an epsilon ε — and both have a sweet spot. Push either one in the wrong direction and the stability they promise turns into the bug.

Lesson 05 introduced the centered difference (f(x+h) − f(x−h)) / (2h) as an independent referee for backpropagation. This chapter asks the numerical question behind it: which h? Two errors pull in opposite directions. Shrink h and the secant hugs the tangent — the truncation error falls like h². But shrink h too far and f(x+h) and f(x−h) agree in almost every digit, so their subtraction is catastrophic cancellation — the roundoff error grows like ε/h. The best h sits where they cross, and that crossing depends on the format.

relative error of the centered difference, f(x) = x³ at x = 2 h = 1e−1 num = 12.00999737 rel error 8.3e−4 ← truncation h = 1e−2 num = 12.00008392 rel error 7.0e−6 ← near the bottom h = 1e−3 num = 11.99984455 rel error 1.3e−5 ← roundoff taking over h = 1e−5 num = 12.01629639 rel error 1.4e−3 h = 1e−6 num = 12.15934753 rel error 1.3e−2 h = 1e−9 num = 0.00000000 rel error 1.0 ← both inputs round to 2

Plain English: every evaluation of f is rounded to the working precision. The difference f(x+h) − f(x−h) is supposed to be about 2h·f′(x); once rounding error in the two evaluations is comparable to that difference, dividing by 2h amplifies pure noise. The source recommends h = 1e−5 to 1e−7, which is right for float64; in float32 the table above puts the sweet spot nearer 5e−3.

Gradient check: the error vs step-size V

The centered difference from Lesson 05, now with the precision in charge. Sweep h across nine decades and watch truncation error and roundoff error meet at a sweet spot.

f: f(x) = x³ at x = 2 exact derivative 12.00000000 h 1.000e−3 numerical gradient 11.99984455 relative error 1.295e−5 suspicious — check the implementation truncation error ≈ h²/6 1.67e−7 roundoff error ≈ ε·|f|/(2h) 4.77e−4

The best h depends on the format: this sweep bottoms out near 6e−3 for float32 and near 3e−6 for float64. Lesson 05’s advice of 1e−5 to 1e−7 is tuned for float64.

Derivation: balance the two errors, then use a relative threshold

Expand both evaluations with Taylor series (Lesson 04) around x:

f(x+h) = f + f′h + f″h²/2 + f‴h³/6 + … f(x−h) = f − f′h + f″h²/2 − f‴h³/6 + … subtract: f(x+h) − f(x−h) = 2f′h + f‴h³/3 + … divide 2h: (f(x+h) − f(x−h))/2h = f′ + f‴h²/6 + … truncation error ≈ |f‴|·h²/6 falls as h shrinks roundoff error ≈ ε·|f|/(2h) rises as h shrinks balancing: h* ≈ (3ε|f| / |f‴|)^(1/3) f = x³ at x = 2, |f| = 8, |f‴| = 6: float64, ε = 2.2e−16: h* ≈ 1e−5 by this estimate float32, ε = 1.2e−7: h* ≈ 8e−3 by this estimate the sweep in the lab bottoms out near 3e−6 (float64) and 6e−3 (float32), with relative errors around 2e−12 and 1e−6. The constants are rough; the V shape is exact.

The check compares analytical and numerical gradients with a relative error so that scale does not matter: |a − n| / max(|a|, |n|, 1e−8). Below 1e−7 is excellent, below 1e−5 is acceptable, above 1e−3 there is a bug, and above 1 the gradient is simply wrong. This also explains a real debugging rule: match the precision of the check to the precision of the code. Running a float32 model’s gradients through a check with h = 1e−8 manufactures failures out of rounding noise.

Normalization layers use the same kind of tiny constant for the same kind of reason. LayerNorm recenters and rescales the features of each example; BatchNorm does the same across the batch. The source writes the layer as (x − mean) / (std + ε) · γ + β; PyTorch keeps the epsilon under the square root, (x − mean) / √(var + ε) · γ + β. Either way ε exists so that all-identical activations do not divide by zero:

x = [1, 1, 1, 1] mean = 1, variance = 0, std = 0 without ε: (1 − 1) / 0 = 0/0 = nan with ε = 1e−5: 0 / (0 + 1e−5) = 0 → outputs are 0, finite ✓ x = [1, 1, 1, 1.0001] mean = 1.000025, variance = 1.875e−9, std = 4.330e−5 normalized ≈ [−0.577, −0.577, −0.577, 1.732] with ε = 1e−5 the denominator is 4.330e−5 + 1e−5: the correction is about 23% here (an unusually tiny σ); for activations with spread ≫ 1e−5, ε barely moves the result.

The learned γ and β let the network restore any scale it needs, so the layer can be gentle. And because LayerNorm and BatchNorm are exactly the places where variance is computed, they are also where a two-pass or Welford statistic matters in half precision. Frameworks compute those statistics in float32 even when the activations are float16.

HALF PRECISION, FULL STABILITY

Run in 16 bits,
remember in 32.

Tensor Cores multiply float16 matrices several times faster than float32. Mixed precision takes the speed but keeps a float32 master copy of every weight — and loss scaling keeps the smallest gradients alive.

Pure float16 training breaks in two places at once: activations above 65,504 overflow, and gradients below about 3e−8 underflow to zero. The recipe that works, and that every automatic-mixed-precision (AMP) implementation follows, is:

1. keep float32 master copies of the weights 2. cast to float16 (or bfloat16) for the forward pass 3. compute the loss in float32 4. multiply the loss by a scale factor S = 2^16 = 65,536 5. backward in float16: every gradient is S× larger 6. unscale: divide gradients by S in float32 7. clip, then update the float32 master weights
float32master weightscastto float16 / bf16fast forwardTensor Coresloss × Sfloat32, scaledbackwardgradients × Sunscale ÷ S, clip by norm, update in float32 — repeat
Mixed precision keeps the numerically sensitive step (the weight update) in float32 while the matrix multiplications run in 16 bits. Loss scaling is the piece that keeps tiny gradients from disappearing in float16.
Derivation: scaling the loss scales every gradient exactly

Derivatives are linear — a constant multiplies out of the whole chain (Lesson 04): d(S·L)/dw = S · dL/dw. So scaling the loss scales every gradient in the network by exactly S, and dividing afterwards recovers the true gradient in a format that can hold it.

tiny gradient: g = 3e−9 (from a real training run) float16 alone: smallest positive float16 = 2^−24 = 5.96e−8 halfway point = 2^−25 = 2.98e−8 3e−9 < 2.98e−8 → rounds to 0.0 the weight never moves with loss scaling S = 65,536 = 2^16: g × S = 1.966e−4 → a normal float16 value stored with float16's ~3 digits, then divided by S in float32: 1.966e−4 / 65,536 = 2.9999e−9 ≈ 3e−9 ✓ the update survives dynamic scaling: start at 65,536; if a step overflows to inf, halve S; after N clean steps, double it.

bfloat16 needs none of this for range: it keeps float32’s 8 exponent bits, so its smallest positive value is about 9.2e−41 and its largest is 3.39e38. It pays in precision — only 7 mantissa bits, about 2–3 decimal digits — but training tolerates that far better than it tolerates zeroed gradients. float16 keeps 10 mantissa bits and is preferred for inference, where values are bounded.

Gradients can also be too large. Exploding gradients from deep stacks — the eigenvalue story of Lesson 03, compounded — can corrupt every weight in a single step. The standard fix is clipping by norm: if the gradient vector is longer than c, rescale it to length c while keeping its direction.

g = [10, 20, 30] ‖g‖ = √(100 + 400 + 900) = 37.42 c = 5: g′ = g · (5 / 37.42) = [1.337, 2.673, 4.010] ‖g′‖ = 5.00 direction unchanged ratios 10 : 20 : 30 = 1 : 2 : 3 still hold proof: g′ = g·(c/‖g‖) → ‖g′‖ = (c/‖g‖)·‖g‖ = c g′/‖g′‖ = g·(c/‖g‖)/c = g/‖g‖ (same unit vector) typical c: 1.0 for transformers, 5.0 for smaller networks.

Clip by value is the simpler alternative — clamp each entry separately — but it can rotate the gradient direction, which clip by norm provably preserves. Normalization layers are the other half of the safety net: keeping activations recentered every layer stops the ten-fold growth that would hit inf by layer 50.

Quick check

Why is bfloat16 usually preferred over float16 for training, even though it has fewer mantissa bits?

SymptomLikely causeFix
Loss becomes NaNsoftmax overflow, or learning rate too highstable softmax, lower lr, clip gradients
Loss stuck at log(K)uniform outputs: dead ReLUs or wrong labelsLeakyReLU or GELU, check data and loss
Accuracy 1–3% below the paperfloat16 without loss scalingdynamic loss scaling (AMP), or bfloat16
Some layers’ gradients exactly 0dead ReLUs or float16 underflowas above; check initialization
Different results on different GPUsnon-deterministic summation orderaccept ~1e−6, or force deterministic algorithms
exp() returns inf in the lossraw logits into expuse log_softmax / log-sum-exp
CHECK YOURSELF

Five questions.
Then the terms worth keeping.

Answer before you look. The softmax and bfloat16 questions are the ones that show up in real training runs — and the exercises are all recomputed in exact IEEE-754 values.

0 / 5 answered · 0 correct

01What is the approximate range of numbers that float32 can represent?

02Why does 0.1 + 0.2 not equal 0.3 in floating-point arithmetic?

03In the stable softmax implementation, why subtract max(logits) before exponentiating?

04When using centered finite differences for gradient checking, what happens if the step size h is too small (e.g., 1e-15)?

05Why is bfloat16 generally preferred over float16 for neural network training?

Key terms, demystified

Click a card to swap the lazy description for what it actually means.

Exercises from the lesson

Four short problems from the source lesson, worked end to end. The 3×2 layer gradient check is the one-dimensional version of the lab in chapter 06.

  1. Compute the variance of [1000000.0, 1000001.0, 1000002.0] with the naive formula E[x²] − E[x]² in float32, then with a centered two-pass method. The true population variance is 0.6667.
    Show one worked answer

    Naive in float32: the mean is exactly 1000001. Each square is rounded first: 1000000² → 999999995904, 1000001² → 1000002027520, 1000002² → 1000003993600. Their average also rounds to 1000002027520, and mean² = f32(1000001²) = 1000002027520, so E[x²] − E[x]² = 0 — a 100% error, even though the true variance is 0.6667. Two-pass: subtract the mean first (deviations −1, 0, 1, all exact), square (1, 0, 1), average → 0.6666666865348816, a relative error of 3.0e-8. Welford's online update gives the same: n=1 → M2=0; x=1000001, delta=1, mean=1000000.5, M2=0.5; x=1000002, delta=1.5, mean=1000001, M2 = 0.5 + 1.5·1 = 2.0, so variance = M2/n = 2/3 = 0.6667 (or M2/(n−1) = 1.0 for the sample variance). The fix is to subtract the big number before squaring, so the squares never see the mean.

  2. Find the smallest positive float32 value x such that 1.0 + x is different from 1.0. Check it against numpy.finfo(numpy.float32).eps.
    Show one worked answer

    Floats near 1.0 have exponent 0, so consecutive floats are one unit in the last mantissa bit apart: 2⁻²³ = 1.1920928955078125e-7. Adding exactly half a step, 2⁻²⁴ = 5.9604645e-8, is a tie and rounds to even — back to 1.0. So the smallest x with 1 + x ≠ 1 is 2⁻²³, and numpy.finfo(numpy.float32).eps returns 1.1920929e-07, the same number. Numeric check: 1.0 + 2⁻²³ = 1.0000001192092896, while 1.0 + 2⁻²⁴ = 1.0. The same recipe gives 2⁻¹⁰ = 9.765625e-4 for float16 and 2⁻⁵² ≈ 2.22e-16 for float64.

  3. Test the stable log-sum-exp on three edge cases: (a) all values equal, (b) one value much larger than the rest, (c) all values very negative (−1000, −1001, −1002). Where does the naive version fail?
    Show one worked answer

    (a) [4, 4, 4]: naive = ln(e⁴+e⁴+e⁴) = ln 3 + 4 = 5.098612; stable = 4 + ln(3·e⁰) = the same. Both fine. (b) [0, −800, −900]: in float64 e^−800 underflows to exactly 0, so naive = ln 1 = 0 and stable = 0 + ln(1 + 0 + 0) = 0 — both fine, because the dominant term carries the answer. (c) [−1000, −1001, −1002]: every exponential underflows to 0, so naive = ln 0 = −inf. Stable shifts by c = −1000: terms 1 + e⁻¹ + e⁻² = 1.5032147, so the answer is −1000 + ln(1.5032147) = −1000 + 0.407606 = −999.592394. The naive version overflows at the top too: [500, 501, 502] gives every eˣ beyond float32's 3.4e38 maximum, so it returns inf, while the stable version returns 502 + ln(1 + e⁻¹ + e⁻²) = 502.407606.

  4. Simulate float16 training with gradients drawn from [1e-9, 1e-3]: convert them to float16 and count what fraction becomes exactly zero. Then multiply by 1024 (loss scaling), convert, and count again.
    Show one worked answer

    float16's smallest positive value is 2⁻²⁴ = 5.96e-8, and anything below half of that (2⁻²⁵ = 2.98e-8) rounds to zero. Take six gradients [3e-9, 2e-8, 5e-8, 6e-8, 4e-7, 1e-6]: the first two are below the 2.98e-8 halfway line, so 2/6 ≈ 33% are flushed to 0. Multiply every gradient by 1024 first: [3.07e-6, 2.05e-5, 5.12e-5, 6.14e-5, 4.10e-4, 1.02e-3], all at or above the denormal floor, so 0% become zero. After backward, divide the scaled gradients by 1024 in float32: 3.07e-6 / 1024 = 2.998e-9, recovering the update that float16 alone had erased. The gradient still carries only float16's ~3 digits, which is why the master weights stay in float32.

Terms this lesson borrows from later lessons (or outside)

You do not need to master these here. Each one gets a proper treatment in its own lesson; the one-line meaning is enough to keep reading. Orange dotted underlines in the prose point back to this list.

  • transformerThe neural network architecture behind modern language models, built from attention and dense layers. (outside these lessons)
  • MCMC / VIApproximation methods for posteriors that have no closed formula: MCMC samples from them, variational inference fits a simpler distribution to them. Both lean on log-sum-exp. (Lesson 16)
  • PyTorchA deep learning framework: arrays (tensors) with automatic differentiation built in. (outside these lessons)
  • Tensor CoresHardware units on NVIDIA GPUs that multiply small float16/bfloat16 matrices extremely fast — the reason mixed precision exists. (outside these lessons)
  • inferenceUsing a trained model to make predictions, as opposed to training it. float16 is often the format of choice here. (outside these lessons)
  • centered differenceThe gradient estimate (f(x+h) − f(x−h)) / 2h from Lesson 05. This lesson asks how its error behaves as h changes with precision. (Lesson 05)
  • softmaxExponentiate each logit and divide by the sum, turning scores into probabilities. The stable version is this lesson's centerpiece. (Lesson 06)
KEEP GOING

A picture is a start.
Practice is the rest.

This lesson is a port of an open course. Everything here traces back to it — and the next step is running the code yourself.

Lesson text adapted from AI Engineering from Scratch (Phase 01, Lesson 13) and the Math Foundations Notebook reference build. Float inspector, precision ranger, cancellation bench, softmax/log-sum-exp labs and the gradient-check sweep are original to this page, and every displayed value is rounded exactly as IEEE-754 would round it. All labs run in your browser.