ml-models / Mathematical foundations / Floating point, conditioning and numerical stability core

Floating point, conditioning and numerical stability

A computer stores numbers with a fixed number of significant bits, so almost every operation rounds. Usually the rounding is invisible. Sometimes it adds up over a long sum, wipes out a variance, turns a softmax into NaN or makes a solve lose most of its digits. This page gives the one model of rounding error that explains all of these, separates what the problem is sensitive to (conditioning) from what the algorithm adds (stability), and walks through the stable rewrites PyTorch and scikit-learn use: max-shifted logsumexp, log1p, losses on logits, cascade summation and Welford-style variance.

On this page
  1. Problem
  2. Intuition
  3. Mechanics
  4. The rounding model
  5. Summation
  6. Catastrophic cancellation
  7. Conditioning and backward stability
  8. Stable rewrites in machine learning
  9. Worked example
  10. Summing 0.1, 100,000 times, in float32
  11. A negative variance
  12. Softmax of large logits
  13. Conditioning: Hilbert systems
  14. Implementation
  15. Tradeoffs
  16. Check yourself
  17. Connections
  18. Read first
  19. Read next
  20. References

Problem

Add 0.1 to itself 100,000 times in float32, one term at a time, and the answer is 9998.5566 rather than 10,000. Compute the variance of four readings near 10⁹ with the textbook shortcut E[x²] − E[x]² in float64 and it comes out negative. Evaluate a softmax over the logits 1000, 999, 998 and every probability is NaN. None of these is a bug in the code that ran: each is ordinary rounding, amplified by the way the formula was arranged.

Machine learning runs into all of them because it works at scale and at low precision. Sums run over millions of terms, logits and log-probabilities span huge ranges, statistics are accumulated over streams, and training increasingly happens in float16, bfloat16 and smaller, where each rounding is a thousand times coarser than in float32. Knowing which formulas lose accuracy, and what to use instead, is the difference between a model that trains and one that silently produces garbage.

Intuition

A floating-point number is a significand with a fixed number of bits times a power of two. Within one power of two (one binade) the representable numbers are evenly spaced, and the spacing doubles at every power of two. So the absolute gap between neighbours grows with the size of the number, while the relative gap stays roughly constant. That relative gap is the whole story: every operation is exact up to a relative error of at most half of it.

The representable numbers of a toy float format A number line from 0 to 8 with a tick at every positive number a toy format with 2 fraction bits can represent between 0.5 and 8: 0.5, 0.625, 0.75, 0.875, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 3.5, 4.0, 5.0, 6.0, 7.0. The ticks are evenly spaced within each power-of-two interval and the spacing doubles from one interval to the next: 0.125, 0.25, 0.5 and 1. A value of 5.4 sits between 5 and 6 and rounds to 5, an error of 0.4, which is 7% of 5.4. 0 1 2 4 8 gap 0.125 gap 0.25 gap 0.5 gap 1 5.4 rounds to 5 every gap is a quarter of the binade's start (2 fraction bits), so the worst relative rounding error is the same in every binade

The numbers come from toy_floats in the page's helpers. float32 is the same picture with 23 fraction bits instead of 2, so its relative gap is 2⁻²³ ≈ 1.2 × 10⁻⁷ instead of ¼.

With that picture, two distinct questions decide whether a computed answer can be trusted. Conditioning belongs to the problem: if the inputs move by a tiny relative amount, how far can the exact answer move? Stability belongs to the algorithm: does it add more error than the unavoidable rounding of its inputs would cause anyway? A stable algorithm on a well-conditioned problem gives an accurate answer. On an ill-conditioned problem even a perfect algorithm cannot, and an unstable algorithm can ruin a well-conditioned one.

Mechanics

The rounding model

IEEE 754 arithmetic rounds the exact result of each +, −, ×, ÷ and √ to the nearest representable number. With p significand bits (counting the implicit leading 1), that gives the standard model

fl(x ∘ y) = (x ∘ y)(1 + δ),   |δ| ≤ u = 2−p,

where u is the unit roundoff: 2⁻²⁴ ≈ 6.0 × 10⁻⁸ for float32 and 2⁻⁵³ ≈ 1.1 × 10⁻¹⁶ for float64. The machine epsilon that torch.finfo(dtype).eps reports is the gap above 1.0, which is 2u. Two consequences follow at once. Rounding is relative, so a quantity is only known to about log₁₀(1/u) significant digits: 7 in float32, 16 in float64, 3 in float16 and 2 to 3 in bfloat16. And floating-point addition is not associative: (0.1 + 0.2) + 0.3 = 0.6000000000000001 but 0.1 + (0.2 + 0.3) = 0.6 in float64. Any reduction whose order changes, across threads, GPU blocks or devices, changes the last bits of the result, which is why bitwise reproducibility needs a fixed order.

Summation

Adding n numbers one at a time rounds after every addition, and each rounding is relative to the running total, not to the term. The error bound for this recursive summation is

|ŝ − s| ≤ (n − 1) u Σ|xi| + O(u²),

linear in n. Once the total is large, small terms lose most of their bits on the way in. Three standard fixes:

  • Pairwise (cascade) summation adds neighbours, then sums of neighbours, in a tree. Each term passes through only ⌈log₂ n⌉ additions, so the bound becomes ⌈log₂ n⌉ u Σ|xi|. PyTorch's CPU sum does this: it accumulates blocks of at least 16 elements into four levels of partial sums so that only numbers of similar size are added together.
  • Compensated (Kahan) summation keeps a second variable holding the low-order bits each addition dropped and feeds them back into the next term. The bound is 2u Σ|xi| + O(nu²)Σ|xi|, essentially independent of n, for four operations per term instead of one.
  • A wider accumulator: sum float16 or float32 inputs in float32 or float64. scikit-learn's _safe_accumulator_op runs sums and dot products in float64, and GPU kernels accumulate half-precision products in float32.
Error of a running float32 sum of 0.1 The error of the running total, after k additions of float32(0.1), against the exact sum k × float32(0.1), for k up to 100,000, sampled every 2,500 terms. The plain running sum drifts in straight segments whose slope changes each time the total crosses a power of two: down, then up to 5.46 near k = 80,000, then down again to -1.444, so the final error understates the worst one along the way. The Kahan-compensated sum stays at zero on this scale, ending -1.5e-04 away, which is just the rounding of the final total to float32. 6 4 2 0 -2 0 25,000 50,000 75,000 100,000 plain running sum Kahan terms added, k error

Both traces from f32_naive_sum and f32_kahan_sum, which round to float32 after every operation with struct. Between powers of two the total's gap is fixed, so each addition of 0.1 rounds the same way and the error grows linearly; at each power of two (8,192, 16,384, 32,768, 65,536) the gap doubles and the direction or size of the drift changes.

Catastrophic cancellation

Subtracting two nearly equal numbers is itself exact, but it removes their shared leading digits and leaves only the trailing ones, which are exactly the digits earlier roundings corrupted. If a and b each carry a relative error u, then a − b carries a relative error up to u(|a| + |b|)/|a − b|, which is enormous when a ≈ b. The textbook variance Σx²/n − (Σx/n)² is the classic case: both terms are about the square of the mean, their difference is the much smaller variance, and every digit of the variance smaller than u times the mean squared is lost. Subtract the mean first (two passes), or update the mean and the sum of squared deviations together as each point arrives (Welford's algorithm), and the large common part never enters a subtraction.

Conditioning and backward stability

The condition number of a problem is the worst-case ratio of the relative change in the answer to the relative change in the input. For solving Ax = b it is κ(A) = ‖A‖‖A⁻¹‖ = σmax/σmin, met on the linear algebra toolkit page. An algorithm is backward stable if its computed answer is the exact answer for a slightly perturbed input, with a relative perturbation of order u. Then the two ideas combine into the rule of numerical analysis:

forward error ≲ κ × backward error ≈ κ u.

LU with partial pivoting, Cholesky and Householder QR are backward stable in practice, so their error is as small as the problem allows: you lose about log₁₀ κ digits, and no algorithm can do better in that precision. Forming XᵀX for least squares squares κ before a solver ever sees it, which is why the QR route is preferred. The residual b − Ax̂ measures the backward error, so a tiny residual proves the algorithm did its job, not that x̂ is accurate.

Stable rewrites in machine learning

Most of the numerically delicate code in a deep learning framework is one of a few rewrites, each keeping a large intermediate from overflowing or a small one from vanishing:

  • logsumexp and softmax: with m = maxi xi, log Σ exi = m + log Σ exi − m. Every shifted exponent is at most 0, so nothing overflows, and one term equals 1, so the sum is between 1 and n and the log never sees 0. Softmax is ex − m / Σ ex − m, and log-softmax is x − logsumexp(x), computed directly instead of as log(softmax(x)), whose small probabilities would underflow to log 0.
  • log1p and expm1: for small z, 1 + z rounds away z's low digits before the log sees them; log1p(z) computes log(1 + z) without forming 1 + z.
  • softplus, log(1 + eˣ): overflows for large x and loses everything for very negative x. The stable form switches regimes: eˣ when x is very negative, log1p(eˣ), then x + e⁻ˣ, then just x. PyTorch's Softplus returns x once βx exceeds a threshold (20 by default).
  • losses on logits: binary cross-entropy through sigmoid and log loses accuracy once σ(z) rounds to 0 or 1. On the logit z directly, −log σ(z) = max(−z, 0) + log1p(e−|z|), which is what BCEWithLogitsLoss and cross_entropy compute. See entropy and KL and loss functions.
  • epsilons in denominators: normalization layers divide by √(var + ε) and Adam by √v̂ + ε, so a zero variance or second moment does not divide by zero. The ε must be representable in the dtype the division runs in: Adam's default 10⁻⁸ is below float16's smallest positive number, 6.0e-08, and rounds to 0.

The pieces of PyTorch and scikit-learn that implement these rules:

ComponentShape/ParamsMeaningSource
torch.logsumexp (reference) self, dim, keepdim Subtracts the amax along dim, with infinite maxima masked to 0 so all-−∞ rows give −∞ rather than NaN, then adds it back after the log. _refs/__init__.py
torch.log_softmax (reference) a, dim, dtype x − logsumexp(x, keepdim=True), never log(softmax(x)). _refs/__init__.py
torch.logaddexp (reference) a, b max + log1p(exp(min − max)), the two-term logsumexp, with a mask for equal infinities. _refs/__init__.py
aten._softmax decomposition x, dim, half_to_float Upcasts low-precision input to its computation dtype, subtracts the max and normalizes; _log_softmax is the same with a log of the sum. decompositions.py
log_sigmoid_forward self log σ(x) = min(x, 0) − log1p(exp(−|x|)): no exponential of a positive number. decompositions.py
binary_cross_entropy_with_logits self [logits], target, pos_weight (1 − y)·z − log σ(z), built on logsigmoid; the stable form of sigmoid followed by BCE. decompositions.py
nn.BCELoss probabilities in, reduction Clamps each log term to ≥ −100 so a probability of exactly 0 or 1 gives a finite loss; BCEWithLogitsLoss, which takes logits, describes itself as the more numerically stable choice. loss.py
nn.Softplus beta=1, threshold=20 (1/β)·log(1 + eβx), reverting to x when βx > threshold. activation.py
multi_row_sum, cascade_sum 4 levels, blocks of ≥ 16 The CPU sum kernel for floating types: tree-like accumulation so that only partial sums of similar size meet. SumKernel.cpp, cascade
_DTYPE_PRECISIONS dtype → (rtol, atol) The default tolerances of torch.testing.assert_close, scaled to each dtype's precision. _comparison.py
log1pexp double x scikit-learn's softplus with cutoffs at −37, −2, 18 and 33.3; the comment notes that only 18 matters for accuracy. _loss.pyx.tp
sum_exp_minus_max one row of raw predictions The shared max-shift for the multinomial loss, its gradient and its probabilities. _loss.pyx.tp
_incremental_mean_and_var X batch, running mean, variance, count Merges a batch into running statistics with the corrected two-pass update of Chan, Golub and LeVeque; behind StandardScaler.partial_fit. extmath.py
_safe_accumulator_op op, x Runs a reduction with a float64 accumulator when x is a smaller float. extmath.py
stable_cumsum arr, rtol, atol Cumulative sum in float64 that warns if its last element disagrees with sum; deprecated in 1.8 in favour of a float64 cumulative_sum. extmath.py

Worked example

Summing 0.1, 100,000 times, in float32

float32(0.1) is 0.10000000149, so the exact sum of 100,000 copies is 10000.000149. With u = 2⁻²⁴:

Methodfloat32 resultErrorBound
One at a time9998.5566-1.444(n − 1)uΣ|x| = 59.6
Pairwise, blocks of 810000.0000-1.5e-04⌈log₂ n⌉uΣ|x| = 0.010
Kahan10000.0000-1.5e-042uΣ|x| = 0.0012

The one-at-a-time sum is off in the fourth significant digit, far inside its loose bound but about 10,000 times worse than necessary. Pairwise and Kahan both return 10,000, the nearest float32 to the exact sum, so their only error is the final rounding.

A negative variance

Take the readings 4, 7, 13, 16, whose sample variance is 30, and add a constant c to all of them. The variance must not change. In float64:

Shift c(Σx² − (Σx)²/n)/(n − 1)Two-passWelford
0303030
10000303030
1e+0829.33333030
1e+09-170.6673030

At c = 10⁹, Σx² is about 4 × 10¹⁸, where adjacent float64 values are 512 apart. The difference the formula needs is 90, smaller than one gap, so it is pure rounding noise and comes out negative. Welford's update never forms those squares:

  1. x = 1000000004: mean = 1000000004.0, M₂ = 0
  2. x = 1000000007: mean = 1000000005.5, M₂ = 4.5
  3. x = 1000000013: mean = 1000000008.0, M₂ = 42
  4. x = 1000000016: mean = 1000000010.0, M₂ = 90

and M₂/(n − 1) = 30.

Softmax of large logits

For x = [1000, 999, 998], e¹⁰⁰⁰ overflows float64, so the direct logsumexp is inf and every softmax entry is ∞/∞ = NaN. Shifting by m = 1000 gives x − m = [0, -1, -2], and

logsumexp(x) = 1000 + log(1 + e⁻¹ + e⁻²) = 1000.407606,   softmax(x) = [0.6652, 0.2447, 0.0900].

The same comparison for softplus and for the cross-entropy of a positive label, −log σ(z):

zlog(1 + eᶻ)stable softplus−log(sigmoid(z))on the logit
-80000inf800
-4004.248e-184040
-10.31330.31331.3131.313
2020202.061e-092.061e-09
40404004.248e-18
800inf80000

At z = −40, 1 + e⁻⁴⁰ rounds to exactly 1 and the direct softplus returns 0, losing the whole answer; at z = −800 the sigmoid underflows to 0 and the loss becomes infinite, so the gradient is useless. The logit forms are accurate everywhere.

Conditioning: Hilbert systems

The Hilbert matrix Hij = 1/(i + j − 1) is famously ill-conditioned. Solve Hx = b with b chosen so that x = [1, …, 1], using LU with partial pivoting in float64 (u ≈ 1.1 × 10⁻¹⁶):

nκ(H)Forward error ‖x̂ − x‖/‖x‖κuBackward error ‖b − Hx̂‖/‖b‖
41.6e+041.4e-131.7e-129.1e-17
54.8e+053.7e-135.3e-111.6e-16
61.5e+072.5e-101.7e-099.5e-17
74.8e+081.3e-085.3e-081.6e-16
81.5e+103.6e-071.7e-062.4e-16
94.9e+118.6e-075.5e-051.5e-16
101.6e+137.2e-051.8e-031.7e-16

The residual stays at the level of u for every n: LU is backward stable. The forward error tracks κu, and by n = 10 only about four correct digits are left. Nothing about the algorithm can fix that; only a better-conditioned formulation or more precision can.

Forward and backward error of LU on Hilbert systems A log-scale chart for Hilbert systems of size 4 to 10. The line κu rises from 1.7e-12 to 1.8e-03. The measured forward error rises just under it, from 1.4e-13 to 7.2e-05. The backward error, the relative residual, stays flat between 10⁻¹⁷ and 10⁻¹⁵ for every size. 1e0 1e-4 1e-8 1e-12 1e-16 4 5 6 7 8 9 10 κu forward backward matrix size n

Computed by hilbert_solve with the page's lu_factor and lu_solve; κ from its singular values. The gap between forward and backward error is the condition number at work.

Implementation

import math

def logsumexp(xs):
    m = max(xs)
    if math.isinf(m):
        return m                       # all -inf, or an inf present
    return m + math.log(sum(math.exp(x - m) for x in xs))

def softmax(xs):
    m = max(xs)
    exps = [math.exp(x - m) for x in xs]
    total = sum(exps)
    return [v / total for v in exps]

def softplus(x):                       # log(1 + e^x), cutoffs as in scikit-learn
    if x <= -37: return math.exp(x)
    if x <= -2:  return math.log1p(math.exp(x))
    if x <= 18:  return math.log(1 + math.exp(x))
    if x <= 33.3: return x + math.exp(-x)
    return x

def bce_with_logits(z, y):             # -[y log s(z) + (1 - y) log(1 - s(z))]
    return (1 - y) * z - min(z, 0.0) + math.log1p(math.exp(-abs(z)))

def kahan_sum(xs):
    total = carry = 0.0
    for x in xs:
        y = x - carry
        t = total + y
        carry = (t - total) - y        # the low bits that did not fit in t
        total = t
    return total

def welford(xs):
    n = mean = m2 = 0
    for x in xs:
        n += 1
        delta = x - mean
        mean += delta / n
        m2 += delta * (x - mean)
    return mean, m2 / (n - 1)

welford([1e9 + 4, 1e9 + 7, 1e9 + 13, 1e9 + 16])   # (1000000010.0, 30.0)

The same habits in PyTorch and scikit-learn:

import torch
import torch.nn.functional as F

logits = torch.tensor([1000., 999., 998.])
torch.logsumexp(logits, dim=0)               # 1000.4076, not inf
F.log_softmax(logits, dim=0)                 # not softmax(...).log()
F.binary_cross_entropy_with_logits(z, y)     # not F.binary_cross_entropy(torch.sigmoid(z), y)
F.cross_entropy(logits_batch, labels)        # takes logits, applies log_softmax inside

x = torch.randn(10_000_000, dtype=torch.float16)
x.sum(dtype=torch.float32)                   # accumulate in a wider type
torch.finfo(torch.float16).eps, torch.finfo(torch.bfloat16).eps   # 0.000977, 0.0078

torch.testing.assert_close(actual, expected)                  # per-dtype rtol/atol defaults
torch.testing.assert_close(a, b, rtol=1e-4, atol=1e-6)         # or explicit ones

torch.linalg.cond(A)                         # κ before trusting a solve
torch.linalg.lstsq(X, y)                     # QR/SVD based, not solve(X.T @ X, X.T @ y)

from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
for batch in batches:                        # streaming mean and variance, float64 accumulators
    scaler.partial_fit(batch)

Tradeoffs

  • Precision against speed and memory. float64 makes most of these problems disappear and costs 2× the memory and, on GPUs, often 30× or more the time. Deep learning instead runs low precision where errors average out (matrix products with wide accumulators) and keeps float32 for the parts that accumulate: reductions, normalization statistics, optimizer state and the loss.
  • Compensated sums cost operations. Kahan summation does four floating-point operations per term and a serial dependency; pairwise summation is nearly free and parallel, which is why libraries default to it. Compilers allowed to reassociate (-ffast-math) can optimize the compensation away entirely.
  • Stable rewrites change the graph. A fused log_softmax or a loss on logits is both faster and more accurate than composing the pieces, but only if the model exposes logits; putting a sigmoid or softmax inside the model and a log in the loss throws the stability away.
  • Determinism against throughput. Fixing the reduction order makes results bitwise reproducible, but rules out atomics and some fast kernels. Differences in the last few bits between runs are expected; differences larger than the tolerances below are bugs.
  • Tolerances must match the dtype. Compare with a relative tolerance a few times the dtype's epsilon plus a small absolute floor for values near zero. assert_close's defaults:
dtypeFraction bitseps = 2⁻ᵇⁱᵗˢdefault rtoldefault atol
float16109.8e-040.0011e-05
bfloat1677.8e-030.0161e-05
float32231.2e-071.3e-061e-05
float64522.2e-161e-071e-07

Check yourself

Why does float32 sum 0.1 accurately for the first few thousand terms but not for a hundred thousand?

Each addition rounds relative to the running total. While the total is small its gap is tiny compared with 0.1; once it passes 8,192 the gap is 2⁻¹⁰ ≈ 0.001, and past 65,536 it is 2⁻⁷ ≈ 0.008, so every 0.1 is rounded by up to a few percent of itself, the same way for thousands of terms in a row. Pairwise summation keeps both operands of each addition small.

A model outputs probabilities through a sigmoid and is trained with BCELoss. Some losses are pinned at 100. What happened, and what is the fix?

The sigmoid saturated to exactly 0 or 1 for confidently wrong examples, the log became −∞, and BCELoss clamped it to −100, which also zeroes the gradient. Output logits and use BCEWithLogitsLoss, which computes the loss from the logit with log1p and never rounds σ(z) to 0.

A linear solve returns a residual of 10⁻¹⁵ relative to ‖b‖. Is the answer accurate to 15 digits?

Only if the matrix is well-conditioned. The residual is the backward error; the forward error can be up to κ times larger. With κ = 10¹⁰ expect about 5 or 6 correct digits, as in the n = 8 Hilbert row.

Why subtract the maximum in softmax, rather than, say, the mean?

Subtracting the max makes every exponent ≤ 0, so nothing overflows, and makes one term exactly 1, so the denominator is at least 1 and the log of it can never be log 0. Subtracting the mean still lets a logit far above the mean overflow.

Connections

Read first

Read next

No page builds on this one yet.

References

  • Goldberg. What Every Computer Scientist Should Know About Floating-Point Arithmetic. ACM Computing Surveys 23(1), 5–48, 1991.
  • Higham. Accuracy and Stability of Numerical Algorithms, 2nd ed. SIAM, 2002: chapters 2 (the rounding model), 4 (summation) and 7–9 (conditioning and LU).
  • Trefethen and Bau. Numerical Linear Algebra. SIAM, 1997: lectures 12–15 on conditioning and backward stability.
  • Kahan. Further Remarks on Reducing Truncation Errors. Communications of the ACM 8(1), 40, 1965.
  • Welford. Note on a Method for Calculating Corrected Sums of Squares and Products. Technometrics 4(3), 419–420, 1962.
  • Chan, Golub and LeVeque. Algorithms for Computing the Sample Variance: Analysis and Recommendations. The American Statistician 37(3), 242–247, 1983.
  • Mächler. Accurately Computing log(1 − exp(−|a|)), Assessed by the Rmpfr Package. 2012, the note scikit-learn's log1pexp follows.
  • Blanchard, Higham and Higham. Accurately Computing the Log-Sum-Exp and Softmax Functions. IMA Journal of Numerical Analysis 41(4), 2311–2330, 2021.
  • IEEE. 754-2019, Standard for Floating-Point Arithmetic. 2019.
  • PyTorch v2.15.0: logsumexp, _log_softmax, BCEWithLogitsLoss, cascade_sum, _DTYPE_PRECISIONS.
  • scikit-learn 1.9.1: log1pexp, _incremental_mean_and_var, _safe_accumulator_op.