Numerical Stability
Floating point is not arithmetic — it is arithmetic with a budget of about sixteen digits, and the failures come from spending that budget carelessly rather than from any bug in your logic.
Why Does This Exist?
Your loss becomes NaN at step 3,000 and the model is unrecoverable. Nothing in your code changed. There is no exception, no stack trace pointing at a line, and the run before this one was fine.
That is the failure this page prevents, and its causes are a short list: a value exceeded its format's ceiling, a subtraction destroyed every meaningful digit, or a logarithm met a zero. Each has a standard fix that is well known and easy to omit.
Three things you already use exist purely because of this:
log_softmaxrather thanlog(softmax(x))— the log-sum-exp trick, and the reason frameworks fuse softmax into cross-entropy.- The epsilon in every normalisation layer —
(x - μ) / sqrt(σ² + ε), guarding a division. - Loss scaling in mixed precision — multiplying the loss so small gradients survive
float16.
None of these are arbitrary constants. They are each answers to a specific arithmetic failure.
Think of It Like This
A calculator with a sixteen-digit window
Imagine every number you compute must fit in a window showing sixteen significant digits, plus an exponent. Large and small numbers are fine — the exponent handles those. What you cannot do is hold sixteen digits of a big number and details far below them.
Now subtract two nearly equal numbers: 1.2345678901234567 minus 1.2345678901234000. Both filled the window. Their difference is about , and only the last few digits contributed — the leading fifteen cancelled and carried no information into the answer. You started with two sixteen-digit numbers and ended with two or three meaningful digits.
Nothing overflowed and nothing warned you. You simply spent your precision on digits that then cancelled, and the survivors are mostly rounding noise. That is catastrophic cancellation, and it is the failure that produces wrong answers rather than obvious errors.
The other failure is simpler: the exponent has limits too. Push past the largest representable number and you get infinity, and infinity minus infinity is not a number.
How It Actually Works
Three failure modes, one fix each.
Overflow, and the log-sum-exp trick
float32 tops out near , reached by at only . float16 tops out at 65,504, reached at . Logits routinely exceed both.
Once a value is inf, the next operation usually produces nan: inf/inf, inf - inf, 0 × inf. And nan propagates through everything it touches, so one bad value poisons every parameter on the backward pass.
The fix rests on an exact identity. For any constant :
Choose . Now the largest exponent is , so nothing can overflow — and because the identity is exact, this is not an approximation. Softmax gets the same treatment: subtracting the maximum from every logit leaves the output unchanged, because the constant cancels between numerator and denominator.
This is why log_softmax is a single fused operation rather than a composition. Computing log(softmax(x)) forms probabilities first, and a small probability underflows to exactly 0, after which log(0) is . Folding the logarithm in algebraically never forms the probability at all.
Catastrophic cancellation
Subtracting nearly equal numbers discards their agreeing leading digits and promotes rounding noise into the result. Three places this bites in ML:
- Variance as . With large means both terms agree in their leading digits, so the difference is noise — and can come out negative, which is impossible for an average of squares.
- Numerical gradient checks. is a subtraction of nearly equal values, which is why shrinking makes the check worse.
- Any "compute then subtract the mean" pattern on uncentred data.
The fixes are structural rather than parametric. Welford's algorithm computes variance without ever forming large squares. np.linalg.norm factors out the largest component before squaring. log1p(x) computes accurately for tiny , and expm1(x) does the same for — both exist precisely to avoid a cancelling addition.
The U-shaped error curve
The diagram shows the pattern that governs every step-size choice. Truncation error — how wrong your approximation is — shrinks as the step shrinks. Round-off error — how much precision the arithmetic destroys — grows as the step shrinks, because you are subtracting ever-closer values and dividing by an ever-smaller number.
Total error is therefore U-shaped, with a minimum. For a central-difference derivative in float64 that minimum sits near . Smaller is not better; it is worse, and this surprises people every time.
Where epsilon goes
(x - μ) / sqrt(σ² + ε) puts the epsilon inside the square root, added to the variance. Writing sqrt(σ²) + ε instead is subtly different and worse: it fails to protect the square root's derivative, which is and blows up as the variance approaches zero. The gradient becomes enormous even though the forward value looked fine.
Typical values are for float32 and for float16, and they are dtype-dependent for a reason — an epsilon smaller than the format's own resolution does nothing at all.
Worked example
Softmax of the logits .
Naive. overflows even float64, whose ceiling is about . So the numerator is inf, the denominator is inf, and the result is nan.
Stable. Subtract the maximum, 1002, giving shifted logits :
Their sum is , so:
These sum to 1 ✓, and every intermediate value stayed between 0 and 1.5.
The log-sum-exp of the original logits follows from the identity:
A perfectly ordinary number, computed without any intermediate exceeding 1.5. And note the shift changed nothing: the softmax of is identical to the softmax of , because only the differences matter.
Show Me the Code
import numpy as np
def softmax_stable(x: np.ndarray) -> np.ndarray: shifted = x - x.max() # largest becomes 0, so exp() is safe e = np.exp(shifted) return e / e.sum()
logits = np.array([1000.0, 1001.0, 1002.0])print(np.round(softmax_stable(logits), 5)) # -> [0.09003 0.24473 0.66524]print(round(float(logits.max() + np.log(np.exp(logits - logits.max()).sum())), 6))# -> 1002.407606 the log-sum-exp
with np.errstate(over="ignore", invalid="ignore"): print(np.exp(logits) / np.exp(logits).sum()) # -> [nan nan nan]
# Cancellation: the one-pass variance formula on a large mean.big = np.array([1e9 + 4, 1e9 + 7, 1e9 + 13])print((big**2).mean() - big.mean() ** 2, round(float(big.var()), 4)) # -> wrong, 13.5556
# log1p keeps precision where log(1+x) cannot.x = 1e-16print(np.log(1 + x), np.log1p(x)) # -> 0.0 1e-16The stable softmax and the 1002.4076 match the hand calculation, and the naive path returns nan from the same input. The last two blocks are cancellation and its two standard remedies.
Watch Out For
Hunting a NaN at the line where it surfaced
nan propagates, so by the time your loss prints nan the cause is usually many operations upstream and possibly several steps earlier. Debugging at the point of discovery is the slow path.
The efficient approach is to make the failure loud at its origin. torch.autograd.set_detect_anomaly(True) runs the backward pass with checks and raises at the operation that first produced a non-finite gradient, including the forward traceback. It is slow, so enable it only while debugging — but it converts a mystery into a line number.
Failing that, bisect with assertions: assert torch.isfinite(x).all() after each block narrows the origin quickly.
The usual culprits, roughly in order: exponentiating an unbounded logit; log of something that reached zero — a probability, a variance, a count; dividing by a norm or standard deviation that collapsed; sqrt of a value that went slightly negative through rounding; and a float16 gradient that overflowed in a long chain-rule product.
One more, easy to miss: nan fails every comparison, including nan != nan. So if loss > threshold is False for nan and a guard written that way lets it straight through. Use torch.isnan or math.isfinite explicitly.
Adding epsilon in the wrong place, or at the wrong scale
An epsilon is a specific guard against a specific division or logarithm, and moving it changes what it protects.
sqrt(var + eps) protects both the division and the square root's derivative. sqrt(var) + eps protects only the division — the gradient of sqrt at a near-zero variance is , which explodes, so you get a finite forward pass and an enormous gradient. That is a real and confusing bug, and the two expressions look interchangeable.
Scale matters too, and it is dtype-dependent. float16's machine epsilon is about , so an epsilon of rounds away to nothing and protects nothing at all. Mixed-precision training needs larger epsilons than float32 — which is why frameworks change the default rather than exposing one constant.
Over-large epsilons cause the opposite problem: an epsilon comparable to your actual variance biases the normalisation, so activations are systematically under-scaled and the layer partly stops doing its job.
Two habits. Clamp rather than add where the quantity is genuinely bounded — probs.clamp(min=1e-7) before a log states the intent more clearly than adding. And prefer the fused operation whenever one exists: log_softmax, logsumexp, log1p, cross_entropy on logits. Each was written by someone who worked out where the epsilon goes, and using it means you do not have to.
The Quick Version
- Floating point carries about 16 significant digits in
float64and 7 infloat32. Both the range and the precision run out. expoverflowsfloat32at a logit of ~88 andfloat16at ~11. Theninfbecomesnan, andnanspreads everywhere.- Log-sum-exp: subtract the maximum before exponentiating. Exact, not an approximation.
- Softmax is unchanged by subtracting a constant from every logit, which is what makes the trick free.
- Use
log_softmaxandcross_entropyon raw logits.log(softmax(x))underflows tolog(0). - Catastrophic cancellation: subtracting nearly equal numbers destroys the leading digits. Source of negative variances and failed gradient checks.
- Use Welford for variance,
np.linalg.normfor norms,log1pandexpm1for near-zero arguments. - Total error is U-shaped in step size. For
float64central differences the best is about ; smaller is worse. - Epsilon goes inside the square root:
sqrt(var + eps). Outside leaves the derivative unprotected. - Epsilon scale is dtype-dependent — does nothing in
float16. - Debug
nanwithset_detect_anomaly(True), and remembernanfails every comparison silently.
What to Read Next
- Floating Point Formats is where the ranges and precisions come from, and what mixed precision trades.
- Automatic Differentiation is why finite differences are the wrong tool for gradients.
- Gradients covers the gradient-check step size this page explains.
- Cross-Entropy is the loss the log-sum-exp trick was invented for.
- Norms is where
sqrt(sum(x**2))overflows and the library version does not. - Definitions worth a look: Log-Sum-Exp, Numerical Overflow, Machine Epsilon, and Softmax.
- Related interview question: Implement a numerically stable softmax