Машинный эпсилон и почему 0.1 + 0.2 ≠ 0.3
Урок про машинный эпсилон — фундаментальную «зернистость» вещественной арифметики — и про знаменитое $0.1 + 0.2 \ne 0.3$.
Машинный эпсилон ($\varepsilon$) — наименьшее положительное число такое, что $1.0 + \varepsilon \ne 1.0$ в машинной арифметике. Для double он равен $2^{-52} \approx 2.22 \times 10^{-16}$.
Что такое машинный эпсилон
Из-за конечной мантиссы существует порог: если к единице прибавить слишком маленькое число, оно просто «не дотянется» до следующего представимого значения и пропадёт. Этот порог и есть машинный эпсилон. Он характеризует относительную точность арифметики: любую величину компьютер хранит с относительной ошибкой не больше $\varepsilon/2$.
Найти его легко: будем делить пробное значение пополам, пока $1 + \text{проба}$ ещё отличается от 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.5 \times 10^{-17}$ — порядка машинного эпсилона. Это не баг Python и не баг процессора, это фундаментальное свойство двоичной плавающей точки; так же ведут себя C, Java, JavaScript и любой другой язык на IEEE 754.
Как работает под капотом
Машинный эпсилон задаёт «зернистость» вокруг 1.0. Вокруг другого числа $x$ зернистость масштабируется: соседние представимые отстоят примерно на $|x| \cdot \varepsilon$. Поэтому относительная ошибка хранения и одной операции ограничена $\varepsilon/2$. Когда вы видите в результате «хвост» вроде ...00004 — это последние биты мантиссы, и доверять им нельзя. Практическое следствие: сравнивайте вещественные числа не на точное равенство, а с допуском, согласованным с масштабом величин.
1.0 1.0 + eps 1.0 + 2*eps
|---------------|---------------|---------->
<----- eps ----->
числа между соседними отметками НЕ представимы: они округляются
к ближайшей отметке. eps = ширина одного «зерна» возле единицы.
Частые ошибки
- Накапливать сумму многих мелких чисел наивно. Складывая $0.1$ миллион раз, получите заметное отклонение от $100000$. Помогает сложение Кэхэна или суммирование от меньших к большим.
- Брать слишком жёсткий допуск. Сравнение $|a - b| \lt 10^{-18}$ бессмысленно: это меньше $\varepsilon$, такой допуск никогда не сработает для чисел порядка 1.
- Брать абсолютный допуск для величин разного масштаба. Для чисел порядка $10^9$ допуск $10^{-9}$ абсурдно строг — нужен относительный (как в
math.isclose).
Итоги
- Машинный эпсилон = $2^{-52} \approx 2.22 \times 10^{-16}$ — наименьшая добавка, ещё меняющая 1.0.
- Он задаёт относительную точность: ошибка хранения/операции $\le \varepsilon/2$.
- $0.1 + 0.2 \ne 0.3$ — не баг, а следствие двоичного представления десятичных дробей.
- Сравнивать float нужно с допуском (
math.isclose), согласованным с масштабом чисел.