Skip to content

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 float is scientific notation in binary: a sign, an exponent that says roughly how big the number is, and a mantissa that fills in the leading digits. Because only the mantissa carries precision, the numbers you can represent are densely packed near zero and spread far apart out at large magnitudes - the gap between neighbours grows with the exponent. Almost every floating-point surprise is a consequence of that single fact.

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 .

Float32 bit inspector click any bit - exact value + gap to next float
sign · 1
exponent · 8 (bias 127)
mantissa · 23

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
Softmax under pressure drag the top logit past ~88.7 to overflow
z = [ 50, 2, 0 ]
Naive - exp(zᵢ) / Σ exp(zⱼ)
exp(50)5.18e+21p=1
exp(2)7.389p=1.43e-21
exp(0)1p=1.93e-22
Σ5.18e+21
Stable - subtract m = 50, then exp
exp(50−50)1p=1
exp(2−50)1.43e-21p=1.43e-21
exp(0−50)1.93e-22p=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

Step from 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 ==

Because results are rounded, 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 finite

Check 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

The canonical reference is David Goldberg's "What Every Computer Scientist Should Know About Floating-Point Arithmetic" - it derives the IEEE-754 format, rounding, cancellation, and the guard-digit rules in full rigor. For the gentle on-ramp to binary representation and how bits become numbers, Harvard's CS50x builds the mental model from the ground up in its early weeks.

Ask your teacher

Want to trace exactly how becomes an infinite binary fraction, or see why 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.