Module 0: Mathematical and programming foundations 40 min9 min quick readMathematics
Numerical stability
Spot overflow, underflow, and cancellation before they turn a training run into NaN, and fix them with log-sum-exp, log-softmax, the right float format, loss scaling, and gradient checks.
Before this: Information theory, Calculus for ML, The Python toolkit
Quick read: the intuition, interactive visualization, summary, and interview questions. The math, implementation, engineering, and knowledge-check sections are hidden.
Mental model. A float is a number written in scientific notation with a fixed number of digits and a fixed range of exponents. Most of the time that is invisible. It bites in three places: a number gets too big (overflow to
inf), too small (underflow to 0), or you subtract two nearly equal numbers and only rounding noise is left. Every stable ML formula is a rewrite that keeps values away from those three edges.
You will learn to
- Read a float format's range and precision (fp32, fp16, bf16) and predict where it overflows or underflows.
- Derive the log-sum-exp trick and use it for stable softmax and cross-entropy from logits.
- Recognize catastrophic cancellation and rearrange a formula to avoid it.
- Explain why bf16 usually trains without loss scaling and fp16 does not.
- Check an analytic gradient with centered finite differences and read the relative error.
Why it matters. Loss turning into NaN hours into a run is one of the most common deep-learning failures, and a frequent interview question. Nearly every case traces back to one exp, log, division, or low-precision cast. The fixes are a few lines each, and they are built into the library functions you should be calling.
1. Intuition
Imagine a calculator that shows only 7 significant digits and can't display anything above . That is float32.
Overflow. Ask it for (about ) and it shows inf. Softmax computes for every logit, so a logit of 100 breaks it. But softmax only cares about differences between logits: adding the same number to every logit doesn't change the output. Subtract the largest logit first and the biggest exponent becomes . Same answer, no overflow.
Underflow. Ask for and it shows 0. Now take the log of that 0 to compute a loss and you get . The fix is to never leave log space: compute the log-probability directly as "logit minus log-sum-exp", which is a modest number like −200, instead of a probability that rounds to 0.
Cancellation. With 7 digits, 10001.0 and 10004.0 are stored fine, but their squares (around ) are stored only to the nearest 8. Subtract two such squares to get a variance and the rounding error is bigger than the answer. Center the data first and the problem disappears.
Precision formats. Half-precision formats trade digits for speed and memory. fp16 keeps more digits but tops out at 65,504. bf16 keeps fewer digits but has float32's exponent range. Training spikes often exceed 65,504, and small gradients fall below fp16's smallest value, so bf16 is the usual training choice on hardware that supports it.
2. Visualization

Synthetic inputs. Left: the naive formula is exactly right until it suddenly isn't. Middle: finite differences have a sweet spot; too large an is inaccurate, too small is drowned by rounding. Right: bf16 matches float32's range but has the fewest digits.
Try it (predict, then run the script and check):
- In the left panel, the naive line dies near 88.3, not 88.7 (where
expalone overflows in float32). Why does summing three exponentials move the cliff left? - In the middle panel, the centered error bottoms out near . Predict the bottom for float32 inputs instead of float64: larger or smaller ?
- Cast 70,000 to fp16 and to bf16 in the script. Predict both results before running.
- Change the loss scale from 1024 to 65,536 and add a gradient of 1.0. Predict what happens to it in fp16.
3. The math
Symbols
| Symbol | Meaning | Shape |
|---|---|---|
| logits | vector of length | |
| scalar | ||
| log-sum-exp, | scalar | |
| true class index | integer | |
| machine epsilon: gap between 1 and the next float | scalar | |
| finite-difference step | scalar | |
| loss-scale factor | scalar |
Float formats
A float stores . Exponent bits set the range, fraction (mantissa) bits set the precision. Values below from np.finfo (bf16 from its bit layout):
| Format | Exponent bits | Fraction bits | Max | Smallest normal | Decimal digits | |
|---|---|---|---|---|---|---|
| float64 | 11 | 52 | ~16 | |||
| float32 | 8 | 23 | ~7 | |||
| float16 | 5 | 10 | 65,504 | ~3 | ||
| bfloat16 | 8 | 7 | ~2 |
Below the smallest normal, subnormal numbers fill the gap with lost precision; fp16's smallest positive subnormal is . Anything smaller rounds to 0.
Thresholds for exp in float32: is the largest that fits, and is the smallest subnormal; below that exp returns 0.
The log-sum-exp trick
For any constant :
Choose . Then every exponent , so no term exceeds 1 (no overflow), and the term for the max equals , so the sum is at least 1 and its log is at least 0 (no ).
The same shift gives stable softmax, , and stable cross-entropy straight from logits:
Worked example 1: softmax of [100, 101, 102] in float32
Naive: , which is larger than float32's max of , so all three exponentials are inf and inf / inf is NaN.
Stable: , shifted logits .
Worked example 2: cross-entropy when the softmax underflows
Logits , true class 0. The stable softmax gives class 0 the probability . , below float32's smallest subnormal, so it rounds to 0 and .
From logits: , so the loss is . Correct and finite. The model is badly wrong, and the loss says so with a usable gradient.
Worked example 3: catastrophic cancellation
Variance of in float32. The true variance is 1.25.
One-pass formula : both terms are about . float32 near can only represent multiples of 8 (since and the 23-bit fraction gives a spacing of ). Their difference is rounding noise; the script prints −8.0, a negative variance.
Two-pass: subtract the mean 10002.5 first, giving . Squares , mean . Exact.
Worked example 4: loss scaling in fp16
Gradients . fp16's smallest subnormal is :
- is less than half of that, so it rounds to 0: the update is lost.
- rounds to : a 19% error.
Multiply the loss by before backward, so every gradient is 1024 times larger: and , both well inside fp16's range. Cast, then divide by 1024 in float32: and . Both recovered. If any scaled gradient becomes inf, skip the step and halve (dynamic loss scaling).
Gradient checking
The centered difference has error ; the forward difference has :
Too small an makes and nearly equal, and cancellation takes over: total error is roughly , minimized near in float64. Run checks in float64. Rule of thumb: relative error below is right, around or more is a bug. In the script, the gradient of LSE (which is softmax) checks to ; a deliberate 1% bug in one component shows up as .
4. Implementation
Build it from scratch
Start with the naive and stable versions side by side. The only change is subtracting the max, which the math above proved doesn't change the result.
def softmax_naive(z):
e = np.exp(z)
return e / e.sum()
def softmax_stable(z):
e = np.exp(z - z.max()) # largest exponent is exp(0) = 1: cannot overflow
return e / e.sum()Predict: what does softmax_naive return for [-1000, -1001]? (Hint: overflow isn't the only failure.)
Log-sum-exp gets the same shift, plus it adds the max back outside the log.
def logsumexp_naive(z):
return np.log(np.exp(z).sum())
def logsumexp_stable(z):
m = z.max()
return m + np.log(np.exp(z - m).sum()) # sum >= 1, so the log is never log(0)Predict: for z = [-1000, -1001], the naive version returns -inf. What does the stable one return? (Answer: .)
Cross-entropy is where it matters most. Even a stable softmax can round a tiny probability to 0 before the log; computing log-softmax directly never forms the probability.
def cross_entropy_naive(z, y):
return -np.log(softmax_stable(z)[y]) # even a stable softmax can underflow to 0 here
def cross_entropy_stable(z, y):
return -(z[y] - logsumexp_stable(z)) # log-softmax: never takes log of a probabilityPredict: for logits [0, 200] and true class 1, do the two versions agree? (Yes, both give about 0: the danger is only for the class with the tiny probability.)
NumPy has no bfloat16, so emulate it: bf16 is the top 16 bits of a float32. Add half of the dropped part (with round-to-nearest-even) and mask.
def to_bf16(x):
"""Round float32 to bfloat16 (keep the top 16 bits, round to nearest even), return as float32."""
bits = np.asarray(x, dtype=np.float32).view(np.uint32).astype(np.uint64)
bits = (bits + 0x7FFF + ((bits >> 16) & 1)) & 0xFFFF0000
return bits.astype(np.uint32).view(np.float32)Predict: to_bf16(1 + 1/256) returns exactly 1.0. Why? (bf16 has 7 fraction bits, so the gap above 1 is , and is exactly halfway: ties go to the even neighbour, 1.0.)
Last, the gradient checker: perturb one coordinate at a time in both directions.
def numerical_grad(fn, x, h=1e-5):
g = np.zeros_like(x)
for i in range(x.size):
xp, xm = x.copy(), x.copy()
xp[i] += h
xm[i] -= h
g[i] = (fn(xp) - fn(xm)) / (2 * h) # centered difference: error O(h^2)
return g
def rel_error(a, b):
return np.abs(a - b).max() / max(np.abs(a).max(), np.abs(b).max(), 1e-12)Predict: this costs two function evaluations per parameter. How long would it take for a 7-billion-parameter model? (Which is why you gradient-check small layers, not whole models.)
Use the library
import torch, torch.nn.functional as F
torch.logsumexp(z, dim=-1) # the trick above
F.log_softmax(z, dim=-1) # z - logsumexp(z), never forms probabilities
F.cross_entropy(logits, target) # log_softmax + NLL fused; pass LOGITS, not probabilities
F.binary_cross_entropy_with_logits(z, y) # stable sigmoid + BCE (use instead of sigmoid then BCE)
with torch.autocast(device_type="cuda", dtype=torch.bfloat16): # bf16 mixed precision, no scaler
loss = model(x).loss
scaler = torch.amp.GradScaler("cuda") # fp16 needs dynamic loss scaling
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
torch.autograd.gradcheck(fn, inputs_double) # finite-difference check in float64
torch.autograd.set_detect_anomaly(True) # debugging only: reports the op that produced NaNMixed precision keeps a float32 master copy of the weights and runs matmuls in the low-precision type; reductions like softmax and loss are kept in float32 by autocast.
Run it
uv run code/00-foundations/numerical_stability.pyKey output:
float16 max=6.55e+04 min normal=6.104e-05 eps=0.0009766
bfloat16 max=3.39e+38 min normal=1.175e-38 eps=0.007812
variance of [10001.0, 10002.0, 10003.0, 10004.0] in float32: E[x^2]-E[x]^2 = -8.0000, two-pass = 1.2500
exp(z) = [inf inf inf] -> naive softmax = [nan nan nan]
z - max = [-2. -1. 0.] -> stable softmax = [0.09 0.2447 0.6652]
-log(softmax): inf log-softmax: 200.0000
70000.0: fp16 inf bf16 70144.0
cast to fp16 [0.0000000e+00 1.1920929e-07 2.0003319e-04]
x1024,fp16,/1024 [2.0023435e-08 1.0000076e-07 2.0003319e-04]
analytic [0.176542 0.032251 0.791207] numeric [0.176542 0.032251 0.791207] rel error 2.49e-11
with a 1% bug: rel error 2.23e-035. Engineering
Use fused, logits-in functions. F.cross_entropy(logits, y) and binary_cross_entropy_with_logits are stable. log(softmax(x)) and sigmoid followed by binary_cross_entropy are not.
Choosing a precision. On GPUs and TPUs with bf16 support (NVIDIA Ampere and later, TPUs, recent Apple and AMD hardware), bf16 mixed precision is the default for training: float32 range, no loss scaler. fp16 needs dynamic loss scaling. Inference increasingly uses fp16, bf16, or lower (int8, fp8, 4-bit) depending on hardware; see quantization. Keep optimizer states and the master weights in float32.
Epsilons. Normalization layers divide by with around ; Adam divides by with . An of is below fp16's smallest normal and rounds away, which is one reason these ops stay in float32.
Gradient clipping. Clip the global gradient norm (commonly 1.0 for transformers) so one bad batch can't throw the weights into overflow territory. Log the pre-clip norm: a rising trend is an early warning.
Determinism. Floating-point addition is not associative, so GPU reductions in different orders give slightly different results. Expect ~ relative differences across hardware; compare with tolerances, not ==.
Watch outFailure modes. Loss becomes
NaN(overflow inexp,log(0), division by ~0, or LR too high); loss stuck at from vanishing gradients or a broken label pipeline; small silent accuracy loss from fp16 gradient underflow without a scaler; a negative variance from one-pass formulas; flaky tests from==on floats.
Common mistakes
- Passing probabilities to a loss that expects logits (or the reverse). Double softmax trains, but badly.
- Clamping probabilities with
log(p + 1e-8)and calling it stable. It hides the bug and caps the gradient; compute in log space instead. - Running gradient checks in float32, then chasing "errors" of that are just rounding.
- Casting the whole model to fp16 (not mixed precision) and losing small updates entirely.
6. Knowledge check
Quiz: Numerical stability
What is the approximate largest finite value in float32?
Practice exercise. Compute by hand with the max trick, then the cross-entropy for true class 1.
Solution
. Shifted: . Exponentials: . Sum: . , so .
Cross-entropy for class 1: nats. Check: softmax of class 1 is , and .
Implementation challenge. Write a stable log_sigmoid(x) that is correct for in float64, and a stable bce_with_logits(x, y) built on it. Test against the naive formula at .
Solution
. For large negative , overflows. Use , which only exponentiates a non-positive number.
def log_sigmoid(x):
return np.minimum(x, 0) - np.log1p(np.exp(-np.abs(x)))
def bce_with_logits(x, y):
# -[y log σ(x) + (1-y) log σ(-x)], using log σ(-x) = log σ(x) - x
return -(y * log_sigmoid(x) + (1 - y) * (log_sigmoid(x) - x))log_sigmoid(-1000) is , log_sigmoid(1000) is . np.log1p keeps precision when is tiny. At , 1 - sigmoid(30) is about in float64 and loses most of its digits; in float32 it is exactly 0 and the naive BCE for is infinite, while bce_with_logits(30, 0) returns 30.0.
Summary
- Floats have finite range and precision.
expoverflows past about 88.7 in float32; probabilities underflow to 0; subtracting nearby numbers leaves noise. - Log-sum-exp: subtract the max, add it back outside the log. Softmax and cross-entropy from logits use it.
- Compute losses in log space from logits; never
log(softmax(x)). - bf16 keeps float32's range with fewer digits and usually trains without loss scaling; fp16 needs a dynamic loss scaler.
- Check gradients with centered differences in float64; relative error below about is a pass.
Next: The language of ML
Related: Information theory · Loss functions · Intro to PyTorch · Debugging neural networks
Parts of this lesson are adapted from AI Engineering from Scratch by Rohit Ghumare (MIT license, notice): Numerical Stability.
Interview angle
Your loss becomes NaN after a few thousand steps. How do you find the cause?
First find where the NaN starts. Log the loss, the global gradient norm before clipping, and the max absolute activation per layer every step. If the gradient norm or the logits grow steadily before the NaN, it's divergence: lower the learning rate, add warmup, add or tighten gradient clipping. If the jump is sudden, find the batch: save the step's inputs, replay it, and turn on anomaly detection (set_detect_anomaly) to get the op that produced the first NaN or inf. The usual culprits are a hand-written log of a softmax or sigmoid output that hit 0, a division by a norm or variance that hit 0, an exp of a large value, or fp16 overflow above 65,504 in an activation. Fixes: use fused logits-in losses, add the epsilon inside the sqrt, use bf16 or a dynamic loss scaler. Also check the data for NaN or extreme values in that batch.
Why do we subtract the max logit in softmax? Doesn't that change the answer?
It doesn't change the answer, because softmax is shift-invariant: multiplying numerator and denominator by e^−c cancels, so softmax(z − c) equals softmax(z) for any constant c. Choosing c as the max makes every exponent zero or negative, so no term exceeds 1 and nothing overflows, and the largest term is exactly 1, so the denominator is at least 1 and never underflows to zero. The same identity gives log-sum-exp: log Σ e^z = m + log Σ e^(z−m). It matters in practice because float32's exp overflows just past 88.7. Logits of [100, 101, 102] give inf over inf, which is NaN, while the shifted version gives 0.090, 0.245, 0.665. For the loss, go one step further and use log-softmax, z_y minus LSE, so you never take the log of a probability that rounded to zero.
fp16 or bf16 for training, and why?
bf16 where the hardware supports it. Both are 16 bits, but they split them differently. fp16 has 5 exponent bits and 10 fraction bits: about 3 decimal digits, but a max of 65,504 and a smallest subnormal near 6e−8. bf16 has float32's 8 exponent bits and 7 fraction bits: about 2 digits, but the same range as float32. Training cares more about range. Activation spikes exceed 65,504 and overflow in fp16, and many gradients are smaller than 6e−8 and flush to zero, which silently stops learning. fp16 therefore needs dynamic loss scaling: multiply the loss by a large S, unscale gradients, and halve S whenever an inf appears. bf16 usually needs none. Either way, keep master weights, optimizer states, and reductions like softmax and the loss in float32. fp16 is still fine for inference, where values are bounded and the extra digits help.
You implemented a custom layer's backward pass. How do you check it, and what step size do you use?
Compare against centered finite differences: for each input coordinate, (f(x+h) − f(x−h)) / 2h, where f is the scalar loss. Do it in float64 on a small random input. The centered form has error proportional to h², but too small an h makes f(x+h) and f(x−h) nearly equal, and the subtraction cancels to rounding noise. Total error behaves like h² + ε/h, which is smallest near the cube root of machine epsilon, about 6e−6 in float64, so h between 1e−5 and 1e−6 is typical. Report the relative error, max |analytic − numeric| over the larger magnitude. Below 1e−7 is a pass; around 1e−3 or larger is a bug. In my test, a deliberate 1% error in one component showed up as 2.2e−3. Avoid kinks like ReLU at 0 in the test points, since the derivative is undefined there. PyTorch's gradcheck automates this.