Машинный эпсилон и почему 0.1 + 0.2 ≠ 0.3

Урок про машинный эпсилон — фундаментальную «зернистость» вещественной арифметики — и про знаменитое 0.1 + 0.2 ≠ 0.3.

Машинный эпсилон (eps) — наименьшее положительное число такое, что 1.0 + eps ≠ 1.0 в машинной арифметике. Для double он равен 2^-52 ≈ 2.22 × 10^-16.

Что такое машинный эпсилон

Из-за конечной мантиссы существует порог: если к единице прибавить слишком маленькое число, оно просто «не дотянется» до следующего представимого значения и пропадёт. Этот порог и есть машинный эпсилон. Он характеризует относительную точность арифметики: любую величину компьютер хранит с относительной ошибкой не больше eps/2.

Найти его легко: будем делить пробное значение пополам, пока 1 + проба ещё отличается от 1, и остановимся, когда перестанет.

# Ищем машинный эпсилон «вручную»
eps = 1.0
while 1.0 + eps / 2.0 != 1.0:
    eps /= 2.0
print("машинный эпсилон:", eps)
print("это 2^-52?       ", eps == 2.0 ** -52)

# То же значение лежит в стандартной библиотеке
import sys
print("sys.float_info.epsilon:", sys.float_info.epsilon)

Вывод:

машинный эпсилон: 2.220446049250313e-16
это 2^-52?        True
sys.float_info.epsilon: 2.220446049250313e-16

Знаменитое 0.1 + 0.2

Теперь разгадка классики. 0.1 хранится как чуть-чуть больше 0.1, 0.2 — как чуть-чуть больше 0.2, их сумма округляется к представимому числу, которое оказывается чуть больше истинного 0.3. А 0.3, записанное напрямую, округляется к другому, чуть меньшему представимому числу. Эти два «чуть» не совпадают — отсюда неравенство.

a = 0.1 + 0.2
print("0.1 + 0.2 =", a)
print("равно 0.3? ", a == 0.3)
print("разница:   ", a - 0.3)

# Правильное сравнение — через допуск
import math
print("isclose?   ", math.isclose(a, 0.3))
print("вручную:   ", abs(a - 0.3) < 1e-9)

Вывод:

0.1 + 0.2 = 0.30000000000000004
равно 0.3?  False
разница:    5.551115123125783e-17
isclose?    True
вручную:    True

Разница 5.5e-17 — порядка машинного эпсилона. Это не баг Python и не баг процессора, это фундаментальное свойство двоичной плавающей точки; так же ведут себя C, Java, JavaScript и любой другой язык на IEEE 754.

Как работает под капотом

Машинный эпсилон задаёт «зернистость» вокруг 1.0. Вокруг другого числа x зернистость масштабируется: соседние представимые отстоят примерно на |x| · eps. Поэтому относительная ошибка хранения и одной операции ограничена eps/2. Когда вы видите в результате «хвост» вроде ...00004 — это последние биты мантиссы, и доверять им нельзя. Практическое следствие: сравнивайте вещественные числа не на точное равенство, а с допуском, согласованным с масштабом величин.

  1.0          1.0 + eps      1.0 + 2*eps
   |---------------|---------------|---------->
   <----- eps ----->
  числа между соседними отметками НЕ представимы: они округляются
  к ближайшей отметке. eps = ширина одного «зерна» возле единицы.

Частые ошибки

  • Накапливать сумму многих мелких чисел наивно. Складывая 0.1 миллион раз, получите заметное отклонение от 100000. Помогает сложение Кэхэна или суммирование от меньших к большим.
  • Брать слишком жёсткий допуск. Сравнение abs(a−b) < 1e-18 бессмысленно: это меньше eps, такой допуск никогда не сработает для чисел порядка 1.
  • Брать абсолютный допуск для величин разного масштаба. Для чисел порядка 10^9 допуск 1e-9 абсурдно строг — нужен относительный (как в math.isclose).

Итоги

  • Машинный эпсилон = 2^-52 ≈ 2.22e-16 — наименьшая добавка, ещё меняющая 1.0.
  • Он задаёт относительную точность: ошибка хранения/операции ≤ eps/2.
  • 0.1 + 0.2 ≠ 0.3 — не баг, а следствие двоичного представления десятичных дробей.
  • Сравнивать float нужно с допуском (math.isclose), согласованным с масштабом чисел.
Проверьте себя
1. Чему равен машинный эпсилон для IEEE 754 double?
A2^-23 ≈ 1.2e-7
B2^-52 ≈ 2.22e-16
C2^-64 ≈ 5.4e-20
D10^-16 ровно
2. Что показывает условие 1.0 + eps/2 == 1.0 при поиске машинного эпсилона?
AЧто eps/2 уже меньше зернистости вокруг 1.0 и теряется при сложении
BЧто компьютер сломался
CЧто eps равно нулю
DЧто мантисса переполнилась
3. Как корректно сравнивать два float на «равенство»?
AЧерез ==
BЧерез допуск: abs(a−b) меньше выбранного eps или math.isclose
CОкруглив оба до int
DПереведя оба в строки