Numerical Methods & Stability
Floating point, conditioning, and the numerical-stability tricks that keep ML code from exploding — taught from zero, starting with why 0.1 + 0.2 isn't 0.3, and built up to the log-sum-exp trick, gradient checking, and mixed-precision training.
Start here — what this is really about
It is 3 a.m. Your model trained beautifully for six hours and then the loss printed nan. Nothing in your code changed. The math on the whiteboard is correct. The model is dead anyway.
That is what this page is about. Every idea below exists because of one uncomfortable fact:
A computer does not do arithmetic with real numbers. It does arithmetic with approximations of real numbers, and it rounds after every single operation.
Almost always, that rounding is far too small to notice. Occasionally, it is the difference between a working model and nan. Numerical methods is the craft of telling those two situations apart — and of writing the second kind of code so it behaves like the first.
It starts from first principles: no symbols until you already understand the idea they stand for. Flip the Depth switch at the top for formal notation, error bounds, and edge cases — and the panels open automatically once you've finished the prerequisite (Linear Algebra). Every number on this page was produced by actually running the code, not by hand-waving.
This is the least glamorous page in the math track and quite possibly the most useful. Almost nobody debugs a nan by rereading their model architecture — they debug it by knowing the four or five things that produce nan, all of which are on this page. It is also a favourite interview topic precisely because it separates people who have shipped training runs from people who have only read about them.
Part 1 — How a computer actually stores a number
The fixed budget of digits
A computer stores each number in a fixed number of bits — usually 32 or 64. A fixed number of bits means a fixed budget of digits. And a fixed budget of digits means most numbers simply cannot be stored exactly; the machine keeps the closest one it can store and throws away the rest.
is the scheme every computer uses to do this.
You are allowed to write a number only as d.dddddd × 10^k — exactly seven digits, then a power of ten. You can write the mass of the sun and the mass of an electron, because the exponent k moves the scale wherever you need. What you cannot do is write both of them and their difference: with only seven digits of room, adding a gram to the sun changes nothing on your card. The card is not broken. It is simply full.
A floating-point number spends its bits on two separate jobs. The exponent bits buy range — how enormous or how minuscule a number you can name at all. The significand bits buy precision — how many digits of detail you keep once you get there. These trade off against each other, and nearly every numerical disaster in machine learning is one of the two budgets running out. Overflow and underflow are range failures. Cancellation and stalled sums are precision failures.
The famous one: 0.1 + 0.2
Computers store numbers in binary, and in binary the fraction 0.1 does not terminate — the same way 1/3 does not terminate in decimal (0.3333…). So the machine stores the nearest number it can represent. Here is what a 64-bit float really holds when you type 0.1:
you type: 0.1
it stores: 0.1000000000000000055511151231257827021181583404541015625
Same for 0.2:
you type: 0.2
it stores: 0.200000000000000011102230246251565404236316680908203125
Now add the two stored values. Their exact sum is a hair above 0.3 — and the nearest storable number to that sum is not the same as the nearest storable number to 0.3 itself. So:
0.1 + 0.2gives0.300000000000000040.3on its own gives0.29999999999999998889776975374843...- Therefore
0.1 + 0.2 == 0.3is False
Nothing went wrong. Two roundings simply landed on different neighbours.
Run it yourself — the exact stored values are printed, so you can see there is no mystery, only truncation:
Never test floating-point numbers with ==. Test whether they are close: abs(a - b) < 1e-9, or np.allclose(a, b), or torch.allclose(a, b, atol=1e-6). Every equality assertion you have ever seen fail mysteriously in a test suite is this. The one safe exception is comparing against a value you stored rather than computed.
Your test asserts loss == 0.3 and it fails, printing 0.30000000000000004. Is this a bug in your loss function?
Hint: Think about what the machine can and cannot store exactly.
The gaps between numbers are not evenly spaced
Here is the single most important picture on this page. Because a float keeps a fixed number of significant digits, the gap between one storable number and the next grows with the size of the number. Near 1 the gaps are microscopic. Near a billion they are not.
That gap has a name: one . And the gap measured right next to 1.0 has its own famous name: .
Machine epsilon is roughly . float32's means about 7 decimal digits of trust. float64's means about 16. Every claim on the rest of this page is really a claim about how fast you burn through those 7 or 16 digits.
In float32, why does 1e8 + 1 give back exactly 1e8?
Part 2 — The three ways floating point bites
Rounding on its own is harmless. It becomes visible in exactly three ways, and every numerical bug you will ever meet is one of them.
Bite 1 — Rounding that piles up
One rounding costs you the sixteenth digit. A billion roundings can cost you the third.
Round one transaction and nobody notices. Round ten million transactions in the same direction and the drawer is meaningfully short. Long-running sums — a loss accumulated over an epoch, a running mean over a dataset — are exactly this.
The worst version is the stalled accumulator: once your running total is large enough that the next increment falls inside one ULP, the sum stops growing entirely, no matter how many more terms you add.
Bite 2 — Overflow and underflow (the range budget runs out)
and are the range failures, and the exponential function is where they nearly always start.
Your intuition says float32 holds numbers up to , which sounds like an enormous amount of headroom. But reaches that ceiling at . A logit of 90 — a perfectly ordinary number for a confident model — is enough to produce infinity. In float16 the ceiling is 65 504, and hits it at .
Underflow is quieter and often nastier: a probability of becomes exactly 0.0, and the very next line takes its logarithm and hands you . Nothing warned you, because zero is a perfectly legal number.
A model outputs a probability that underflows to 0.0, and the loss is computed as -log(p). What do you see, and what is the standard fix?
Hint: What is the logarithm of zero?
Bite 3 — Catastrophic cancellation (the precision budget vanishes at once)
This is the subtle one, and the one that separates people who know the topic from people who have merely heard of it.
does not create error. It reveals error that was already there.
Two surveyors estimate a loaded truck at 30 000 kg and 30 001 kg. Each is accurate to about 10 kg — excellent, a relative error of 0.03%. Now subtract to get the weight of the cargo: 1 kg, with an uncertainty of 10 kg. The answer is not merely imprecise, it is meaningless, and it could easily be negative. Both inputs were superb. The subtraction destroyed them.
Solve in float64, the format with sixteen good digits. The two roots are approximately and .
Use the formula everyone memorised, , for the small root:
- . But the gap between float64 neighbours near is 2, so the barely registers.
- , agreeing with in essentially all sixteen digits.
- subtracts two numbers that agree in sixteen digits. Every meaningful digit cancels.
- The computer returns . The true root is .
That is a 25% error, in the format with sixteen digits of precision. And here is the fix — multiply the formula by to get an algebraically identical expression with no dangerous subtraction:
- on top, and on the bottom — an addition of two same-signed numbers, since is positive here. Same-signed addition never cancels.
- The two expressions are equal in exact arithmetic — this is algebra, not an approximation.
- Evaluated in floating point, this one returns exactly : every digit correct.
- Why it matters: this is the whole discipline in miniature. The same mathematics, written two ways, gives 2 correct digits or 16. Which formula you type is a numerical decision, not a cosmetic one.
The machine-learning version of this trap is the "computational" variance formula, , which is still printed in textbooks:
Cancellation happens whenever you subtract two quantities that are close to each other and large compared to their difference. Once you know the shape, you see it everywhere: for small , for small , for small , numerical derivatives, sample variances, and computing a Euclidean distance as — which can go negative under the square root and hand you nan on your own embedding-similarity code.
Every one of those has a purpose-built, cancellation-free replacement. Two you should simply memorise:
Which of these subtractions is at risk of catastrophic cancellation?
Part 3 — Conditioning vs stability: the problem or the algorithm?
When a computation gives a bad answer there are exactly two suspects, and telling them apart is the central skill of this whole subject.
Conditioning is a property of the problem: how much the true answer moves when the input wobbles a little. Stability is a property of the algorithm: how much extra error your particular method adds on top. An ill-conditioned problem cannot be rescued by better code — the answer genuinely is that sensitive. An unstable algorithm on a well-conditioned problem is your bug, and it is fixable.
The problem — how heavy is this letter? — is perfectly well-conditioned; the true answer is stable and definite. The algorithm — step on the scale, note it, step on holding the letter, subtract — is catastrophically unstable, because it computes a few grams as the difference of two 80-kilogram readings. Same question, different method, and the method is what fails. Now imagine instead balancing a pencil on its tip and asking where it will fall: no measuring technique on earth saves you, because the problem is ill-conditioned.
Solve with two nearly parallel lines:
- Solving gives — clean and exact.
- Now nudge the last entry of from to . That is a relative change of about — smaller than a measurement error, far bigger than any rounding error.
- Solve again: .
The input moved by 0.0035% and the answer moved by 100%. No algorithm was harmed in the making of this disaster — solving a 2×2 system is about as stable as computation gets. The problem is the villain: two nearly parallel lines have an intersection point that slides enormously when either line tilts by a hair.
The number that measures this sensitivity is the . A problem with a large condition number is called .
The bound is not theoretical hand-waving — you can watch it happen. Below, the notoriously ill-conditioned Hilbert matrices (entry is ), solved for a known answer of all ones, at sizes 2 through 12:
Your linear solve returns garbage. You rewrite it three different ways and get three different garbage answers. What should you compute before writing a fourth version?
Hint: Is it the algorithm or the problem?
Part 4 — Numerical differentiation, and the U-curve everyone meets
Here is where the two enemies — truncation and rounding — meet head-on in one formula you will actually use: gradient checking, the standard way to verify that a hand-written backward pass agrees with the math.
The idea is the definition of the derivative with the limit removed:
- — the true slope at , the quantity you are trying to recover.
- — a small step you choose. The whole art is in this one number.
- — how much the function moved over that step.
- dividing by — rise over run, the ordinary slope you met in Calculus.
- instead of — because we stopped short of the limit . That shortfall is truncation error, and it shrinks as shrinks.
- Why it matters: this is how you check an autograd implementation. If your analytic gradient and this estimate disagree by more than about relatively, your backward pass has a bug.
Naively you would make as small as possible. Try it and something surprising happens:
Shrinking reduces truncation error — you are closer to a true limit. But it increases rounding error, because and become nearly identical numbers and subtracting them is catastrophic cancellation, then you divide the wreckage by a tiny number, magnifying it. Somewhere in between is the best you can do, and it is nowhere near as small as your instincts suggest.
You are gradient-checking a backward pass. Which setup gives the most trustworthy comparison?
Part 5 — The stability toolkit ML code actually uses
Everything so far has been diagnosis. Here is the treatment: the handful of transformations that keep real training runs alive. Each one rewrites a formula into an algebraically identical form that is numerically far better behaved — exactly the quadratic-formula move, applied to the operations deep learning does constantly.
The log-sum-exp trick (the single most important one)
Softmax turns a vector of scores into probabilities, and it does it with exp — the function you just watched overflow at 88.7.
Take logits [1000, 1001, 1002]. Nothing about them is pathological — they are just large.
The naive way:
- , , — all three overflow to
inf(the ceiling is ). - The denominator is
inf + inf + inf = inf. - Each output is
inf / inf, which isnan. All three probabilities arenan.
The stable way — subtract the largest logit first:
- Largest is . Subtract it from all:
[-2, -1, 0]. - =
[0.135, 0.368, 1.0]. No overflow — the biggest exponent is now exactly zero, so the biggest term is exactly 1. - Divide by their sum:
[0.0900, 0.2447, 0.6652].
And here is the crucial part: that is the exactly correct answer. It is bit-identical to the softmax of [0, 1, 2], because softmax only ever depended on the differences between logits. Subtracting a constant from every logit changes nothing mathematically, and everything numerically.
Stay in log-space: never compute log(softmax(x))
Even a stable softmax can underflow a small probability to zero, and cross-entropy immediately takes its log.
Hand raw logits to the loss function; never hand it probabilities.
• Use torch.nn.CrossEntropyLoss (or F.cross_entropy), not F.softmax followed by torch.log and NLLLoss.
• Use BCEWithLogitsLoss, not Sigmoid followed by BCELoss.
• Use F.log_softmax when you need log-probabilities, not torch.log(F.softmax(x)).
These fused versions do the max-subtraction internally and never materialise a probability that can underflow. This is the single highest-value habit on this page.
The epsilon in every denominator
You have seen a mysterious eps in the signature of every normalization layer and optimizer. Now you know exactly what it is for.
- — subtract the mean: centre the values on zero.
- — the variance of the batch or the layer. It can be exactly zero, when every value in the group happens to be identical — a dead channel, a constant feature, a batch of one.
- — a tiny constant, typically , added inside the square root.
- in the denominator — with present, the divisor can never be zero, so the layer returns a large finite number instead of
infornan. - Why it matters: without it, one degenerate batch anywhere in training kills the run permanently — a
nanpropagates into the weights on the very next optimizer step and never leaves. The costs nothing when is healthy (it is swamped) and saves the run when it is not. Adam's in does the identical job for a parameter whose gradient has been zero for a while.
Someone sets LayerNorm eps to 0 to be -more mathematically pure- and training runs fine for hours before producing nan. What happened?
Hint: What must have been true of one group of activations at that moment?
Part 6 — Deliberately using less precision
Everything above treats lost precision as a hazard. Modern training treats it as a budget to spend: half-precision numbers are half the memory and several times the throughput on current accelerators. The engineering question is which bits you can afford to lose.
Gradients span an enormous range of magnitudes and cluster near zero. float16 underflows to zero below about and overflows above 65 504, so a raw float16 training run loses small gradients and blows up on large activations. bfloat16 has float32's full range and simply carries fewer digits — and it turns out neural networks tolerate coarse gradients far better than they tolerate missing ones. Noise averages out over a batch; zeros do not.
Suppose a gradient value is — small but real, and cumulatively meaningful over thousands of steps.
- Store it in float16: the smallest representable magnitude is about , so it becomes exactly 0. The signal is gone, not approximated.
- Now multiply the loss by before calling backward. By the chain rule every gradient is scaled by the same , so this one becomes — comfortably representable in float16.
- Before the optimizer step, divide the gradients by in float32. You recover — the original value back, to three good digits.
The scale factor cancels exactly in the math and moves the whole gradient distribution up out of the underflow zone in the arithmetic. Frameworks pick automatically, raising it while things are fine and halving it whenever an inf appears (torch.cuda.amp.GradScaler).
- — the loss; — its gradient with respect to the parameters.
- — the loss-scale factor, a power of two (so multiplying and dividing by it are exact, changing only the exponent and never touching the mantissa).
- inside — scale up before backpropagating, so every intermediate gradient in the backward pass is times larger and clears the underflow floor.
- outside — scale back down after, in float32, before the weights are updated.
- — the two sides are exactly equal in real arithmetic, because differentiation is linear. The entire trick is a no-op mathematically and a rescue numerically.
- Why it matters: this is the difference between float16 training that diverges and float16 training that matches float32 accuracy. It is also why you must call
scaler.step(optimizer)rather thanoptimizer.step()— the scaler is what unscales, checks forinf, and skips the step if the batch overflowed.
Why does mixed-precision training keep a float32 master copy of the weights?
The NaN hunting checklist
When a run dies, work down this list. It is ordered by how often each cause is the culprit.
- Learning rate too high. By far the most common cause and nothing to do with numerics — the parameters diverged, activations exploded,
expoverflowed. Drop it 10× and see if the problem disappears before investigating anything else. - A raw
exp,log,sqrt, or division you wrote yourself. Search the diff for them. Is there a max-subtraction before theexp? Anepsin the denominator? A clamp before thelog? Is anything under asqrtprovably non-negative? - A probability handed to a loss instead of logits.
log(softmax(x))orBCELoss(sigmoid(x))— replace with the fused version. - A zero-variance normalization group, or a division by a count that can be zero (an empty mask, a class absent from the batch, a padded sequence).
- float16 overflow. Switch to bfloat16 or check that the grad scaler is enabled and actually stepping.
- Bad input data. A
nanorinfalready present in a feature, propagating in from the dataloader. Assert on it at the boundary:assert torch.isfinite(x).all().
To localise it, enable torch.autograd.set_detect_anomaly(True) — slow, but it points at the exact backward op that first produced the nan — and log grad_norm every step. The step where the norm spikes by orders of magnitude is the step before the death.
Where this shows up
Every item here is one of the ideas above, wearing work clothes:
CrossEntropyLosstakes logits, not probabilities — the log-sum-exp trick, fused.- The
epsin LayerNorm, BatchNorm, RMSNorm, Adam, and cosine similarity — a guarantee that a denominator is never zero. - Attention masks use rather than — avoiding
inf - infand0 × inf. GradScalerand bfloat16 defaults — range budgeting under mixed precision.- Ridge's and feature standardization — conditioning fixes as much as statistical ones.
- Gradient clipping — containment for the numerics you have not fixed.
torch.allclosein every test suite — because==on floats is a bug.- Non-reproducible results across GPUs — non-associative addition under a different reduction order.
Explain to a friend, without formulas, why subtracting two nearly equal numbers is dangerous — and then explain the difference between a problem being ill-conditioned and an algorithm being unstable, using the bathroom-scale picture. If you cannot say which of the two a bad answer is, reread Part 3.
- A computer stores a fixed budget of significant digits, so the gaps between storable numbers grow with magnitude; machine epsilon ( for float32, for float64) is that gap near 1.
- Floating point breaks three familiar rules:
0.1 + 0.2 != 0.3,==is unsafe, and addition is not associative — which is why reduction order changes results. - The three bites are accumulated rounding (stalled sums), overflow/underflow (a range failure, usually via
exp), and catastrophic cancellation (subtracting near-equals wipes out every significant digit). - Conditioning is the problem's sensitivity (, and you lose about digits); stability is the algorithm's own added error. Ill-conditioned problems need reformulating, not better code.
- Numerical derivatives have a U-curve: too-small is worse, not better. Use central differences with and expect agreement to about .
- The toolkit: subtract the max before
exp, stay in log-space, put anepsin every denominator, uselog1p/expm1, two-pass variance, clip gradients, accumulate in float32. - Mixed precision spends precision on purpose: bfloat16 for range, float32 master weights and accumulation, loss scaling to lift gradients out of the underflow zone.
Practice — and how to make it stick
• Retrieval practice: before scrolling up, try to name the three bites and one fix for each. Pulling them from memory beats rereading them.
• Spaced repetition: mark this topic complete and it joins your Review queue, resurfacing right before you would have forgotten it.
• Interleaving: mix these problems with Linear Algebra (condition numbers are singular-value ratios) and Optimization (an ill-conditioned Hessian is why momentum exists) rather than grinding this page alone.
- By hand: without running anything, predict what
(1e16 + 1) - 1e16and1e16 + (1 - 1e16)each return, and explain the difference in one sentence. - Find the cancellation: the expression loses all its digits for large . Rewrite it with no subtraction of near-equals (multiply by the conjugate), then check both versions at .
- Break a solver on purpose: build a Hilbert matrix at , solve for a known answer of all ones, and compare the error to
np.linalg.condtimes machine epsilon. - Gradient-check something real: implement a small function and its analytic gradient, then sweep from to with central differences and plot the U-curve yourself.
Start with the lab below — edit and run it right here, and if you get stuck or hit an error, ask Ada on the right: she can see your code and your terminal output.
Next: put this to work where it matters most — the numerics of low-precision training in Mixed Precision, and the conditioning story behind optimizers in Optimization.