Machine Precision¶
Machine precision (or machine epsilon) quantifies the smallest relative difference between consecutive floating-point numbers. This fundamental limit determines the accuracy achievable in numerical computations.
Machine Epsilon¶
The machine epsilon \(\epsilon\) is the smallest value such that \(1 + \epsilon \neq 1\) in floating-point arithmetic.
1. Definition¶
For IEEE 754 double precision with 52 mantissa bits:
import sys
# Machine epsilon from sys
eps = sys.float_info.epsilon
print(f"Machine epsilon: {eps}")
print(f"Scientific: {eps:.2e}")
print(f"As power of 2: 2^{-52} = {2**-52}")
# Verify: 1 + eps != 1
print(f"\n1 + eps == 1: {1 + eps == 1}") # False
print(f"1 + eps/2 == 1: {1 + eps/2 == 1}") # True
2. Precision Test¶
Systematically find where precision is lost.
# Double-Precision Floating-Point Accuracy Test
epsilon = 1.0
while epsilon > 1e-20:
if 1 + epsilon == 1:
print(f"{epsilon:.1e} -> 1 + epsilon = 1 (Precision lost)")
else:
print(f"{epsilon:.1e} -> 1 + epsilon != 1 (Precision maintained)")
epsilon *= 0.1
# Precision is lost around 10^-16
3. NumPy Epsilon Access¶
NumPy provides epsilon for all float types.
import numpy as np
# Different precisions have different epsilons
print(f"float16 eps: {np.finfo(np.float16).eps:.2e}")
print(f"float32 eps: {np.finfo(np.float32).eps:.2e}")
print(f"float64 eps: {np.finfo(np.float64).eps:.2e}")
# Verify for float32
eps32 = np.finfo(np.float32).eps
one = np.float32(1.0)
print(f"\nfloat32: 1 + eps != 1: {one + eps32 != one}")
print(f"float32: 1 + eps/2 == 1: {one + eps32/2 == one}")
Relative vs Absolute Error¶
Machine epsilon bounds relative error, not absolute error.
1. Relative Error Bound¶
For any floating-point operation, the relative error is bounded by \(\epsilon\):
where \(\text{fl}(x)\) is the floating-point representation of \(x\).
import math
def relative_error(exact, approx):
"""Calculate relative error."""
if exact == 0:
return abs(approx)
return abs((exact - approx) / exact)
# Example: representation error of 0.1
exact = 1/10 # Mathematical 0.1
stored = 0.1 # Floating-point 0.1
# The error is tiny but nonzero
from decimal import Decimal
exact_decimal = Decimal('0.1')
stored_decimal = Decimal(stored)
error = float(abs(stored_decimal - exact_decimal))
print(f"Absolute error: {error:.2e}")
print(f"Machine epsilon: {2**-52:.2e}")
print(f"Error < epsilon: {error < 2**-52}") # True
2. Error Scales with Magnitude¶
Absolute error grows with the magnitude of the number.
# Near 1: absolute error ~ epsilon
x1 = 1.0
print(f"Near {x1}: smallest diff = {x1 * 2**-52:.2e}")
# Near 10^10: absolute error ~ epsilon * 10^10
x2 = 1e10
print(f"Near {x2}: smallest diff = {x2 * 2**-52:.2e}")
# Near 10^-10: absolute error ~ epsilon * 10^-10
x3 = 1e-10
print(f"Near {x3}: smallest diff = {x3 * 2**-52:.2e}")
# Demonstration
print(f"\n1.0 + 1e-16 == 1.0: {1.0 + 1e-16 == 1.0}") # True (lost)
print(f"1e-10 + 1e-26 == 1e-10: {1e-10 + 1e-26 == 1e-10}") # True (lost)
3. ULP (Unit in Last Place)¶
The ULP is the spacing between adjacent floats.
import math
def ulp(x):
"""Calculate unit in last place for x."""
if x == 0:
return math.ldexp(1, -1074) # Smallest subnormal
exp = math.floor(math.log2(abs(x)))
return math.ldexp(1, exp - 52)
# ULP varies with magnitude
for val in [1.0, 100.0, 1e10, 1e-10]:
print(f"ulp({val:>10}) = {ulp(val):.2e}")
# math.ulp() available in Python 3.9+
import sys
if sys.version_info >= (3, 9):
print(f"\nmath.ulp(1.0) = {math.ulp(1.0)}")
Single vs Double Precision¶
Single precision loses precision much faster.
1. Precision Comparison¶
import numpy as np
# Single precision (float32)
epsilon32 = np.float32(1.0)
while epsilon32 > np.float32(1e-10):
if np.float32(1) + epsilon32 == np.float32(1):
print(f"float32: {float(epsilon32):.1e} -> Precision lost")
break
epsilon32 *= np.float32(0.1)
else:
print("float32: No precision loss detected")
# Double precision (float64)
epsilon64 = np.float64(1.0)
while epsilon64 > np.float64(1e-20):
if np.float64(1) + epsilon64 == np.float64(1):
print(f"float64: {float(epsilon64):.1e} -> Precision lost")
break
epsilon64 *= np.float64(0.1)
else:
print("float64: No precision loss detected")
2. Significant Digits¶
| Type | Mantissa Bits | Decimal Digits | Epsilon |
|---|---|---|---|
| float16 | 10 | ~3 | ~10⁻³ |
| float32 | 23 | ~7 | ~10⁻⁷ |
| float64 | 52 | ~15 | ~10⁻¹⁶ |
import numpy as np
for dtype in [np.float16, np.float32, np.float64]:
info = np.finfo(dtype)
print(f"{dtype.__name__:>8}: {info.precision:>2} digits, eps = {info.eps:.2e}")
Operation Order Matters¶
Due to finite precision, mathematically equivalent expressions may produce different results.
1. Non-Associativity¶
Floating-point addition is not associative: \((a + b) + c \neq a + (b + c)\).
# Classic example
a = 1e16
b = -1e16
c = 1.0
# Different groupings, different results
result1 = (a + b) + c
result2 = a + (b + c)
print(f"(a + b) + c = {result1}") # 1.0
print(f"a + (b + c) = {result2}") # 0.0 (wrong!)
# Why? b + c loses c due to magnitude difference
print(f"b + c = {b + c}") # -1e16 (c is lost)
2. Subtraction Sensitivity¶
Subtracting nearly equal numbers amplifies relative error.
# Compute 1 + epsilon - 1 two ways
eps = 1e-15
# Method 1: (1 + eps) - 1
result1 = (1 + eps) - 1
print(f"(1 + eps) - 1 = {result1}")
# Method 2: 1 - 1 + eps
result2 = (1 - 1) + eps
print(f"(1 - 1) + eps = {result2}")
# Method 2 is exact; Method 1 has error
print(f"Error in method 1: {abs(result1 - eps):.2e}")
3. Demonstrating Order Effects¶
import numpy as np
# Sum a large array: order matters
np.random.seed(42)
data = np.random.randn(1000000).astype(np.float32)
# Different orderings
sum_forward = np.float32(0)
for x in data:
sum_forward += x
sum_sorted = np.float32(0)
for x in sorted(data):
sum_sorted += x
print(f"Forward sum: {sum_forward}")
print(f"Sorted sum: {sum_sorted}")
print(f"Difference: {abs(sum_forward - sum_sorted)}")
print(f"NumPy sum: {data.sum()}") # Uses pairwise summation
Practical Guidelines¶
Apply these rules for robust numerical code.
1. Tolerance-Based Comparison¶
Never use == for float comparison.
import math
a = 0.1 + 0.2
b = 0.3
# Wrong
print(f"a == b: {a == b}") # False
# Correct: use tolerance
print(f"isclose: {math.isclose(a, b)}") # True
# Custom tolerance for your application
def approx_equal(x, y, rel_tol=1e-9, abs_tol=1e-12):
"""Check approximate equality."""
return abs(x - y) <= max(rel_tol * max(abs(x), abs(y)), abs_tol)
print(f"approx_equal: {approx_equal(a, b)}")
2. Avoid Subtracting Similar Values¶
Restructure calculations to avoid catastrophic cancellation.
import math
# Bad: direct formula for quadratic (when b² >> 4ac)
def quadratic_bad(a, b, c):
disc = math.sqrt(b*b - 4*a*c)
x1 = (-b + disc) / (2*a)
x2 = (-b - disc) / (2*a)
return x1, x2
# Good: avoid cancellation using alternative formula
def quadratic_good(a, b, c):
disc = math.sqrt(b*b - 4*a*c)
if b >= 0:
x1 = (-b - disc) / (2*a)
else:
x1 = (-b + disc) / (2*a)
x2 = c / (a * x1) # Vieta's formula
return x1, x2
# Test with b² >> 4ac
a, b, c = 1, 1e8, 1
print(f"Bad: {quadratic_bad(a, b, c)}")
print(f"Good: {quadratic_good(a, b, c)}")
3. Condition Number Awareness¶
Check if your problem is inherently sensitive.
import numpy as np
# Well-conditioned matrix
A_good = np.array([[1, 0], [0, 1]])
print(f"Identity condition number: {np.linalg.cond(A_good)}")
# Ill-conditioned matrix (Hilbert matrix)
def hilbert(n):
return np.array([[1/(i+j+1) for j in range(n)] for i in range(n)])
for n in [3, 5, 10]:
H = hilbert(n)
cond = np.linalg.cond(H)
print(f"Hilbert({n}) condition number: {cond:.2e}")
# Rule: expect to lose log10(cond) digits of accuracy
4. Use Appropriate Precision¶
Choose precision based on requirements.
import numpy as np
# Memory vs precision tradeoff
data_size = 1_000_000
# float64: 8 bytes, ~15 digits
arr64 = np.random.randn(data_size).astype(np.float64)
print(f"float64: {arr64.nbytes / 1e6:.1f} MB")
# float32: 4 bytes, ~7 digits
arr32 = arr64.astype(np.float32)
print(f"float32: {arr32.nbytes / 1e6:.1f} MB")
# Precision loss when converting
max_diff = np.max(np.abs(arr64 - arr32))
print(f"Max conversion error: {max_diff:.2e}")
Testing Precision Bounds¶
Verify your numerical code respects precision limits.
1. Round-Trip Tests¶
Check that operations preserve expected precision.
import math
def test_round_trip(x, operations):
"""Apply operations and check precision."""
result = x
for op, inv_op in operations:
result = op(result)
result = inv_op(result)
rel_error = abs(result - x) / abs(x) if x != 0 else abs(result)
return rel_error
# Test: multiply then divide
x = 1.234567890123456
ops = [(lambda y: y * 7.3, lambda y: y / 7.3)]
error = test_round_trip(x, ops * 100) # 100 round trips
print(f"Error after 100 mul/div: {error:.2e}")
# Test: exp then log
ops_exp = [(math.exp, math.log)]
error_exp = test_round_trip(x, ops_exp * 100)
print(f"Error after 100 exp/log: {error_exp:.2e}")
2. Stability Under Perturbation¶
Test sensitivity to small input changes.
import numpy as np
def test_stability(func, x, delta=1e-10, n_tests=1000):
"""Test function stability under small perturbations."""
base_result = func(x)
max_relative_change = 0
for _ in range(n_tests):
perturbed_x = x * (1 + np.random.uniform(-delta, delta))
perturbed_result = func(perturbed_x)
if base_result != 0:
rel_change = abs(perturbed_result - base_result) / abs(base_result)
input_rel_change = abs(perturbed_x - x) / abs(x)
amplification = rel_change / input_rel_change if input_rel_change > 0 else 0
max_relative_change = max(max_relative_change, amplification)
return max_relative_change
# Stable function
print(f"sin stability: {test_stability(np.sin, 1.0):.1f}x")
# Less stable near discontinuity
print(f"tan stability (near π/2): {test_stability(np.tan, 1.57):.1f}x")