Concept units

Floating-point numbers: rounding to the nearest stored value

Intermediate

Read these first if they are new to you

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.

FormatBits in allExponent bitsFraction bitsLargest valueSignificant decimal digits
float64 (Python's float)6411521.8 × 10308about 16
float32 (PyTorch's default)328233.4 × 1038about 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 toy float with 2 fraction bits: four values per range, and the gap doubles
0 1 2 3 4 5 6 7 8 gap 1/8 1/4 1/2 1 5.4
What to notice. The blue dots are the only values this format can store from 0.5 to 8. Each range between powers of two holds four of them, so the gap doubles each time the numbers double. The orange tick, 5.4, is not a dot, so it is stored as the nearest dot, 5. Values below 0.5 continue the pattern, ever closer together, and are not drawn.

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.

Worked example: a float32 counter that stops at 16,777,216

A program counts events in a float32 and adds 1 for each one. What happens at 16,777,216 = 224?

  1. 16,777,216 = 224, so it lies in the range from 224 to 225. There k = 24.
  2. float32 has f = 23 fraction bits, so the gap is 224 − 23 = 21 = 2.
  3. The neighbouring stored values are therefore 16,777,216 and 16,777,218.
  4. 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.
  5. 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.
  6. 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.1000000000000000055511151231257827021181583404541015625. The numbers 0.2 and 0.3 are rounded too. Each addition is then computed exactly and rounded once more. For 0.1 + 0.2, the errors do not cancel: the sum lands on 0.30000000000000004, the stored value just above the one Python uses for 0.3.

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
This page leaves out the ends of the range

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

  1. Why does 0.1 + 0.2 == 0.3 give False in 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. Write math.isclose(0.1 + 0.2, 0.3), which allows a small relative difference.

  2. 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.

  3. A test checks abs(a - b) < 1e-9 for float64 numbers near 1010. Why does it fail even when a and b differ by a single rounding? (abs gives 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.

Where this shows up

No unit or explanation builds on this one yet.