Floating-point numbers: rounding to the nearest stored value
Read these first if they are new to you
- Exponentials, undone by logarithms Basic
- Arrays in NumPy Intermediate
The idea in one paragraph
A computer stores a decimal number such as 0.1 as a floating-point number: a sign, a power of two, and a
fixed number of binary digits. Only some numbers fit that pattern exactly, so every other number is rounded to
the nearest one that does. The stored values crowd together near zero and spread apart as they grow, so the
gap between neighbours grows with the size of the number. For numbers that are neither huge nor tiny, the
rounding error is therefore a tiny share of the number itself. That is why 0.1 + 0.2 == 0.3 is
False in Python, and why code compares floats with a tolerance.
A float is scientific notation in base 2
In scientific notation, a decimal number is written as a few digits times a power of ten: 6,500 = 6.5 × 103. A computer does the same in base 2. In binary, each digit (a bit) is 0 or 1. The place values left of the point are 1, 2, 4, 8 and so on, the powers of two from the exponentials unit. Right of the point they are 1/2, 1/4, 1/8, and so on. We write 2−3 for 1/23 = 1/8. So binary 110.1 is 4 + 2 + 1/2 = 6.5, and 6.5 = 1.101 (binary) × 22.
A floating-point number, or float, stores three pieces of that form. The sign says plus or minus. The exponent is the power of two, here 2. The fraction holds the bits after the point, here 101. The bit before the point is always 1, except for zero and the tiny numbers in the warning box below, so the format does not store it. The number of fraction bits sets how many significant digits a float keeps. The number of exponent bits sets how large and how small it can be.
| Format | Bits in all | Exponent bits | Fraction bits | Largest value | Significant decimal digits |
|---|---|---|---|---|---|
float64 (Python's float) | 64 | 11 | 52 | 1.8 × 10308 | about 16 |
| float32 (PyTorch's default) | 32 | 8 | 23 | 3.4 × 1038 | about 7 |
Each format also has 1 sign bit. These are the float64 and float32 dtypes of
NumPy arrays. Both follow the IEEE 754 standard, which almost every
computer uses today. On almost every platform, Python's float is float64.
The gap between stored values doubles at each power of two
Take a toy format with only 2 fraction bits. Between 1 and 2 it can store 1.00, 1.01, 1.10 and 1.11 in binary, which are 1, 1.25, 1.5 and 1.75. Between 2 and 4 the exponent goes up by one, so the same four patterns are doubled: 2, 2.5, 3 and 3.5. Each range from one power of two to the next holds the same count of values, so the gap doubles from one range to the next.
A real format works the same way with more bits. With f fraction bits, the values between 2k and 2k+1 are spaced 2k × 2−f apart:
gap = 2k − f
For float32, f = 23, so the gap between 1 and 2 is 2−23, about 1.2 × 10−7. For float64, f = 52, and that gap is 2−52, about 2.2 × 10−16. The gap just above 1 is called the format's machine epsilon.
A number that falls between two stored values is rounded to the nearer one. A number exactly halfway goes to the neighbour whose last bit is 0. So the error is at most half a gap. The gap grows with the number, so the error is at most the same small share of the number. That share is 2−24, about 6 × 10−8, for float32, and 2−53, about 1.1 × 10−16, for float64. That share is where the "about 7" and "about 16" significant digits in the table come from.
A program counts events in a float32 and adds 1 for each one. What happens at 16,777,216 = 224?
- 16,777,216 = 224, so it lies in the range from 224 to 225. There k = 24.
- float32 has f = 23 fraction bits, so the gap is 224 − 23 = 21 = 2.
- The neighbouring stored values are therefore 16,777,216 and 16,777,218.
- The exact sum, 16,777,217, lies exactly halfway between them. The tie goes to the value whose last bit is 0. That is 16,777,216, because it is 1.000…0 × 224, with all fraction bits 0.
- So 16,777,216 + 1 is stored as 16,777,216, and every later "+ 1" is lost in the same way. The counter never moves again.
- In float64, f = 52, so the gap there is 224 − 52 = 2−28, about 0.0000000037. Adding 1 is exact. float64 counts whole numbers exactly up to 253 = 9,007,199,254,740,992.
Your turn. What is the gap between float32 values from 1024 to 2048? Then what does 1024 + 0.0001 give in float32?
Answer
1024 = 210, so the gap is 210 − 23 = 2−13 = 0.0001220703125. Half a gap is about 0.000061. The added 0.0001 is more than half a gap, so the sum rounds up to the next stored value, 1024.0001220703125. The sum moves, but by 0.000122, not by 0.0001.
Most decimals, such as 0.1, have no exact binary form
A binary number with finitely many bits after the point is a whole number divided by a power of two, such as 0.101 (binary) = 5/8. The number 0.1 is 1/10. If 1/10 equalled J/2N for whole numbers J and N, then 10 × J would equal 2N. But 10 × J is divisible by 5, and no power of two is. So 0.1 has no exact binary form. Its bits repeat forever, as 1/3 = 0.333… does in decimal, and the computer must round them.
Python stores 0.1 as the nearest float64, which is
0.1000000000
The snippet shows this, and one more effect: errors can add up over many operations. In the code,
f"{x:.20f}" writes x with 20 digits after the point. Python writes
2.2 × 10−16 as 2.2e-16, in code and in its output. np.float32(x) stores x as a float32, and int turns a
float into a whole number for printing. np.spacing(x) gives the gap from x to the
next stored value of its type, and np.finfo(...).eps gives machine epsilon.
floats.py
import math
import numpy as np
print(0.1 + 0.2, 0.1 + 0.2 == 0.3)
print(f"{0.1:.20f}") # the stored 0.1, to 20 decimals
print(math.isclose(0.1 + 0.2, 0.3)) # equal to within a tolerance?
total = 0.0
for i in range(10):
total = total + 0.1
print(total) # ten roundings add up
count = np.float32(16777216) # 2**24, as a float32
print(int(count + np.float32(1))) # the added 1 is lost
print(np.spacing(count), np.spacing(16777216.0))
print(np.finfo(np.float32).eps, np.finfo(np.float64).eps)
0.30000000000000004 False
0.10000000000000000555
True
0.9999999999999999
16777216
2.0 3.725290298461914e-09
1.1920929e-07 2.220446049250313e-16
The gap formula and the error bound hold only for sizes from about 2.2 × 10−308 up to the
largest value in float64, and from about 1.2 × 10−38 in float32. Below those sizes, the stored
values are evenly spaced and keep fewer significant bits. These are called subnormal numbers. Among them,
the rounding error can be a large share of the number. Results beyond the largest value become
inf (infinity). In a NumPy array, 0 divided by 0 gives nan ("not a
number").
The page also skips two details. The stored exponent bits hold the power of two plus a fixed offset, so that negative powers need no sign of their own. And the page uses only the default rounding rule, round to nearest with ties to a last bit of 0; the standard defines other rules too.
Compare floats with a tolerance, not with ==
Two calculations that are equal on paper often differ in their last bits. So a test should ask whether
two floats are close, not whether they are equal. math.isclose(a, b) returns
True when a and b differ by at most a share of the larger one. By
default that share, rel_tol, is 10−9, about 9 matching digits. A tolerance of this
kind is called relative, because it grows with the numbers, as the gaps do.
A relative tolerance fails near zero. With the defaults, math.isclose(1e-20, 0) is
False. The allowed difference is 10−9 of the larger value, here 10−29, and the actual difference, 10−20, is far larger. When one value may be zero, also pass an
absolute tolerance, a fixed amount, such as abs_tol=1e-12, chosen for the size of
your data. For whole arrays, np.isclose does a similar job, with a slightly different rule and other defaults:
rtol=1e-05 and atol=1e-08.
Check yourself
-
Why does
0.1 + 0.2 == 0.3giveFalsein Python? What should the test write instead?Answer
None of 0.1, 0.2 and 0.3 has an exact binary form, so each is stored rounded, and the sum is rounded once more. The result, 0.30000000000000004, is the stored value just above the stored 0.3, so
==fails. Writemath.isclose(0.1 + 0.2, 0.3), which allows a small relative difference. -
In float32, what is the gap between stored values from 220 = 1,048,576 to 221? What does 1,048,576 + 0.05 give?
Answer
The gap is 220 − 23 = 2−3 = 0.125. Half a gap is 0.0625, and 0.05 is less than that, so the sum rounds down to 1,048,576. The added 0.05 is lost completely.
-
A test checks
abs(a - b) < 1e-9for float64 numbers near 1010. Why does it fail even whenaandbdiffer by a single rounding? (absgives a number's size without its sign.)Answer
1010 lies between 233 (about 8.6 × 109) and 234, so the gap there is 233 − 52 = 2−19, about 1.9 × 10−6. Two different stored values that close to 1010 differ by at least that much, which is far more than 10−9. A relative tolerance, as in
math.isclose, grows with the numbers and works at any size.
Go further
Optional background, for readers who want more than this page.
-
Floating-Point Arithmetic: Issues and Limitations, 15.1 Representation Error Python tutorial Article Intermediate
Works out by hand why Python stores 0.1 as 3602879701896397 / 255.
-
A picture of how the gap between floats changes at each power of two. The earlier sections build a float from its sign, exponent and fraction bits, which the article calls the significand.
-
What Every Computer Scientist Should Know About Floating-Point Arithmetic David Goldberg, 1991 Article Advanced
The classic survey of rounding error and the IEEE standard, with theorems and proofs.
Where this shows up
No unit or explanation builds on this one yet.