Floating-Point Arithmetic & Numerical Stability

A computer does not store real numbers. It stores floating-point approximations — numbers with a fixed budget of binary digits — and every calculation nudges the result by a tiny rounding error. Most of the time you never notice. But in the calculations mathematical biologists do all day — multiplying probabilities, summing likelihoods, integrating differential equations — those tiny errors can compound until a “correct” model returns a wrong number, an infinity, or nothing at all.

This page is about recognizing those failure modes and the handful of tricks that avoid them. The most important one — working in log space, and its refinement the log-sum-exp trick — is worth its weight in gold for anyone who computes a likelihood.

Multiplying many probabilities underflows to exactly zero once the product drops below about 1e-308, and log(0) = -infinity breaks everything downstream; adding the log-probabilities instead keeps the value finite.

How Computers Store Numbers

A standard double-precision float (float64, the default in R, Python, and Julia) carries about 15–17 significant decimal digits and can represent magnitudes from roughly 1e-308 to 1e308. Two consequences follow immediately:

The classic demonstration:

print(0.1 + 0.2)
print(0.1 + 0.2 == 0.3)
0.30000000000000004
False

The sum is off in the 17th digit, and the equality test fails.

Do not test floating-point numbers for exact equality. Compare within a tolerance instead.

# R: never `if (x == y)` for computed doubles
isTRUE(all.equal(0.1 + 0.2, 0.3))          # TRUE, uses a tolerance
abs((0.1 + 0.2) - 0.3) < 1e-9              # TRUE, explicit tolerance
# Julia: isapprox (the ≈ operator)
(0.1 + 0.2) ≈ 0.3                          # true

Catastrophic Cancellation

Rounding error is usually harmless because it is relative — about 16 digits of accuracy no matter the scale. It becomes dangerous when you subtract two nearly equal numbers: the leading digits cancel, and what remains is mostly rounding noise. This is catastrophic cancellation, and the textbook victim is the one-pass (“computational”) formula for a variance, xi2(xi)2/n\sum x_i^2 - (\sum x_i)^2/n, which subtracts two large, nearly equal sums.

import numpy as np
x = np.array([1e8 + 1.0, 1e8 + 2.0, 1e8 + 3.0])

# one-pass formula: subtracts two huge, nearly-equal numbers
one_pass = (np.sum(x**2) - np.sum(x)**2 / len(x)) / (len(x) - 1)
# two-pass formula: subtract the mean first, then square
two_pass = np.sum((x - x.mean())**2) / (len(x) - 1)

print("one-pass:", one_pass)     # wrong: collapses to 0 (true value is 1.0)
print("two-pass:", two_pass)     # correct: 1.0
one-pass: 0.0
two-pass: 1.0

The two-pass version subtracts the mean first, while the numbers are still close together, so no catastrophic cancellation occurs. The lesson generalizes: rearrange a calculation so you never subtract two big numbers that are almost equal. This is exactly why you should use a library’s var(), sd(), or numpy.var rather than coding the “shortcut” formula yourself.

Work in Log Space

Here is the failure that stops MCMC chains and phylogenetics runs cold. A likelihood is a product of probabilities — one factor per site, read, observation, or individual:

L=i=1npi.L = \prod_{i=1}^{n} p_i .

Each pip_i is below 1, so the product shrinks geometrically. After only a few hundred factors it slips below 1e-308 and underflows to exactly 0 — and then log(0) = -Inf poisons every downstream calculation (see the figure above).

The fix is to never form the product at all. Take the logarithm of both sides and the product becomes a sum:

logL=i=1nlogpi.\log L = \sum_{i=1}^{n} \log p_i .

A sum of nn moderate negative numbers is perfectly well-behaved no matter how large nn gets.

p = np.full(1000, 1e-3)                    # 1000 probabilities, each 0.001
print("product of probabilities:", np.prod(p))       # underflows to 0.0
print("sum of log-probabilities:", np.sum(np.log(p)))  # finite, usable
product of probabilities: 0.0
sum of log-probabilities: -6907.755278982137

Rule of thumb: compute and store log-probabilities, not probabilities. Multiply probabilities → add log-probabilities. Divide → subtract. This single habit is why every serious likelihood, Bayesian, and phylogenetic library works in log space internally.

The Log-Sum-Exp Trick

Working in logs is easy until you have to add probabilities that are stored as logs — and in biology you have to do this constantly, because summing over unobserved possibilities is addition in probability space. You have loga\log a and logb\log b; you want log(a+b)\log(a + b). The naive route — exponentiate, add, take the log — throws away everything you gained, because exp(loga)\exp(\log a) underflows right back to 0.

The log-sum-exp trick keeps you safe. To compute logiexp(xi)\log \sum_i \exp(x_i), factor out the largest term m=maxixim = \max_i x_i:

logiexi=m+logiexim.\log \sum_i e^{x_i} = m + \log \sum_i e^{x_i - m} .

Now the biggest exponent is e0=1e^0 = 1 and everything else is between 0 and 1 — all comfortably representable. You get the exact same answer with no underflow.

The log-sum-exp trick: exponentiating raw log-probabilities underflows every term to zero, so the naive sum is zero and its log is minus infinity; subtracting the maximum first slides the values into the representable range, the sum is well behaved, and adding the maximum back recovers the exact answer.

def logsumexp(a):
    m = np.max(a)
    return m + np.log(np.sum(np.exp(a - m)))

logw = np.array([-1000.0, -1001.5, -1003.0])   # three log-probabilities
naive = np.log(np.sum(np.exp(logw)))           # exp underflows -> log(0)
print("naive:    ", naive)                     # -inf : wrong
print("logsumexp:", logsumexp(logw))           # correct, finite
naive:     -inf
logsumexp: -999.7586887033428

You rarely need to write it yourself — every ecosystem ships a tested, vectorized version:

# R
matrixStats::logSumExp(c(-1000, -1001.5, -1003))
# Julia
using LogExpFunctions
logsumexp([-1000.0, -1001.5, -1003.0])
# Python
from scipy.special import logsumexp
logsumexp([-1000.0, -1001.5, -1003.0])

Where This Shows Up in Biology

Once you recognize “log of a sum of exponentials,” you see it everywhere a model marginalizes over hidden possibilities:

A Short Checklist