Numerical Foundations
Floating point and numerical stability
Why computers get arithmetic slightly wrong
A real number line is infinite and continuous; a 32-bit register holds exactly patterns. So the machine cannot store most numbers - it stores the nearest representable one and quietly rounds. Usually the error is invisible. But in machine learning you sum millions of these, exponentiate them, and divide - and the rounding compounds until a probability that should be comes back as NaN. This lesson opens the bits, shows exactly where they bite, and gives you the standard fixes: subtract the max, and work in log-space.
The idea in one line
A real number in 32 bits
The IEEE-754 float32 ("single precision") splits its 32 bits into three fields: 1 sign bit, 8 exponent bits, and 23 mantissa bits (also called the fraction). For a normal number the value is reconstructed as:
The decoding formula
The sign flips positive/negative. The stored exponent is biased: the real power of two is , so ranges over the actual exponents . The mantissa contributes a fraction on top of an implicit leading 1 - you get bits of precision for free, about 7 decimal digits. Two exponent codes are special: encodes zero and tiny subnormal numbers (the implicit 1 becomes 0, exponent pinned at ), and encodes (mantissa zero) and NaN (mantissa nonzero). The spacing between adjacent floats - one step in the last mantissa bit - is , which doubles every time the exponent does.
Enough theory - open the hood. The demo below has two panels. The first is a live bit inspector: toggle any bit and watch the exact value and the gap to the next representable number update. The second runs softmax, the function that turns model scores (logits) into probabilities, and shows it detonate into NaN - then survive. Softmax and its overflow-proof cousin log-sum-exp are defined here so both panels make sense:
Softmax, and why the max comes out
The two fractions are algebraically identical - multiply top and bottom of the left one by and it becomes the right one. Softmax is shift-invariant: adding the same constant to every logit changes nothing. The naive left form computes directly, which overflows float32 the moment any exceeds about (because , the largest finite float32). The right form first subtracts , so the biggest exponent is - nothing can overflow. Its log-space partner is the log-sum-exp identity .
Started at 0.1 - notice the value is not exactly one tenth. It cannot be, in binary.
- type
- normal
- sign
- 0 (positive)
- exponent
- e = 123 → 2^(-4)
- mantissa
- f = 5033165 → fraction 0.600000
- reconstruct
- +1.6000000 × 2^(-4)
- value
- 0.10000000149011612
- gap to next float
- 7.451e-9
| exp(50) | 5.18e+21 | p=1 |
| exp(2) | 7.389 | p=1.43e-21 |
| exp(0) | 1 | p=1.93e-22 |
| Σ | 5.18e+21 |
| exp(50−50) | 1 | p=1 |
| exp(2−50) | 1.43e-21 | p=1.43e-21 |
| exp(0−50) | 1.93e-22 | p=1.93e-22 |
| Σ | 1 |
✓ x = 50 ≤ 88.7 - both paths agree: softmax = [1, 1.43e-21, 1.93e-22]. Drag x past ~88.7 to overflow the naive path while the stable one holds.
What the inspector is telling you
1.0 with ▲ next float: the value jumps by . Do it near a big number and the jump is enormous - past the gap exceeds , so float32 literally cannot represent consecutive integers. That is why accumulating a long sum in low precision silently loses the small terms, and why 0.1 - needing an infinite binary expansion - lands on instead.Four ways floats bite
Every one of these is the same rounding, viewed from a different angle:
- Rounding error. , , are all non-terminating in binary, so each is stored approximately. In double precision the errors don't cancel: . This is not a bug - it is the format working exactly as specified.
- Overflow. Exceed the largest finite value ( for
float32) and you get . One stray then poisons everything: and are both NaN, and NaN spreads through every later operation. - Underflow. Numbers smaller than round to . A product of many probabilities underflows to exactly zero, and - which is precisely why training works with sums of logs rather than products.
- Catastrophic cancellation. Subtract two nearly equal numbers and the matching leading digits annihilate, leaving only the noisy low bits. The relative error of the tiny result can explode by orders of magnitude - the classic trap in computing a variance as .
Never test floats with ==
0.1 + 0.2 == 0.3 is false. Compare with a tolerance instead: check that for a small like . The same discipline underlies gradient checking, where an analytic gradient is accepted only if it matches a finite-difference estimate to within relative tolerance - never bit-for-bit.The fix: subtract the max, live in log-space
The stable panel above is the recipe every deep-learning framework uses internally. Subtract the max before exponentiating (kills overflow at no mathematical cost), and carry log-probabilities rather than probabilities (kills underflow, turns fragile products into robust sums). Cross-entropy loss reads those log-probs directly, which is why libraries pair softmax and log into one fused, numerically-stable log_softmax.
import numpy as np
def softmax_naive(z): # z: logits, float32
e = np.exp(z) # exp(z) overflows to inf past ~88.7
return e / e.sum() # inf / inf -> nan: output destroyed
def softmax_stable(z):
m = z.max() # the largest logit
e = np.exp(z - m) # every exponent <= 0, so exp(...) in (0, 1]
return e / e.sum() # identical result, nothing overflows
def logsumexp(z): # log of the denominator, overflow-proof
m = z.max()
return m + np.log(np.exp(z - m).sum())
def log_softmax(z): # what training actually optimizes
return z - logsumexp(z) # cross-entropy reads these log-probs
z = np.array([120., 2., 0.], dtype=np.float32)
softmax_naive(z) # [nan, 0., 0.] -- exp(120) is inf in float32
softmax_stable(z) # [ 1., 0., 0.] -- correct and finiteCheck yourself
Why is subtracting the max before softmax mathematically safe?
Why does the spacing between float32 values grow for larger numbers?
Recall: what identity makes log-sum-exp overflow-proof, and why is subtracting the max valid for softmax?
Try to state it, then check.
Primary source
Ask your teacher
bfloat16 keeps all 8 exponent bits but throws away mantissa? Ask and I'll build it. The tie-in worth planting now (connection C31): this is why the softmax you met in Lesson 18 and Lesson 37 subtracts the max first, why training carries log-probabilities instead of raw probabilities, and why quantization (Lesson 80) is even possible - squeezing weights from 32 bits down to 8 or 4 is a deliberate, controlled trade of mantissa precision for memory. It is all about the bits.