Потеря точности и ошибки округления
Выясняем, почему компьютер уверяет, что 0.1 + 0.2 ≠ 0.3, как ошибки округления накапливаются и какими приёмами их обуздать.
Ошибка округления — крошечная разница между настоящим числом и его ближайшим представимым в плавающей точке значением; при вычислениях такие разницы складываются и накапливаются.
Мы уже знаем два факта из прошлых уроков: $0{,}1$ — бесконечная двоичная дробь, а double хранит лишь конечные 52 бита мантиссы. Сложим эти факты — и получим самый знаменитый «баг, который не баг» во всём программировании. Понимать его обязан каждый, кто пишет код с деньгами, измерениями или сравнениями вещественных чисел: цена ошибки тут — от неверного ценника до зависшего цикла.
Почему 0.1 + 0.2 ≠ 0.3
Запустите этот код — результат удивляет на первый взгляд:
a = 0.1
b = 0.2
print(a + b)
print(a + b == 0.3)
print(format(a + b, ".17f")) # 17 знаков после запятой
Вывод:
0.30000000000000004 False 0.30000000000000004
Что произошло? Ни $0{,}1$, ни $0{,}2$ не представимы точно — каждая хранится как ближайшее двоичное приближение с микроскопической ошибкой. При сложении эти ошибки складываются, и сумма оказывается чуть-чуть больше $0{,}3$ — на $4 \cdot 10^{-17}$. А число $0{,}3$, записанное напрямую, имеет свою ошибку приближения, и она другая. Поэтому два почти равных числа на уровне бит различаются, и сравнение == честно возвращает False. Формально, если обозначить операцию округления к ближайшему представимому числу как $\text{fl}(\cdot)$, то компьютер вычисляет не $0{,}1 + 0{,}2$, а:
$$\text{fl}\big(\text{fl}(0{,}1) + \text{fl}(0{,}2)\big) \neq \text{fl}(0{,}3)$$
Относительная погрешность одного округления не превышает машинного эпсилон: для double это $\varepsilon \approx 2{,}22 \cdot 10^{-16}$. Звучит ничтожно — но дальше эти крохи начинают копиться.
Накопление ошибки
Одно округление незаметно. Но если складывать неточное число тысячи раз, погрешности суммируются. Классический пример — прибавить $0{,}1$ десять раз и ожидать ровно $1{,}0$:
total = 0.0
for _ in range(10):
total += 0.1
print(total)
print(total == 1.0)
Вывод:
0.9999999999999999 False
Сумма не дотянула до единицы на $10^{-16}$. Здесь циклов всего десять; в численных методах их бывают миллионы, и тогда накопленная ошибка способна исказить уже значащие цифры результата. Чем больше операций и чем сильнее различаются по величине слагаемые, тем заметнее расходится ответ с математически точным.
Когда это опасно
Деньги
Самая болезненная область. Если хранить рубли как float, цена $0{,}1 + 0{,}2$ рублей превратится в $0{,}30000000000000004$, а после округления до копеек и тысяч операций касса перестанет сходиться. Финансовые системы никогда не считают деньги в плавающей точке.
Сравнения в циклах
Опасно сравнивать вещественные на точное равенство, особенно как условие выхода из цикла:
x = 0.0
пока x != 1.0: # ОПАСНО: может никогда не стать ровно 1.0
x = x + 0.1
... тело цикла ...
# из-за накопления ошибки x перескочит 1.0 и цикл зациклится
Поскольку $x$ из-за округлений не попадёт в $1{,}0$ ровно, условие x != 1.0 рискует остаться истинным навсегда — бесконечный цикл. Это псевдокод (поэтому он не исполняется), но ситуация абсолютно реальная.
Как это работает: почему так спроектировано
Может показаться, что это недоработка. На деле — осознанный компромисс. Плавающая точка жертвует абсолютной точностью ради огромного диапазона и высокой скорости аппаратных вычислений. Хранить $0{,}1$ точно означало бы работать с дробями или строками — это в десятки раз медленнее. Для физики, графики, статистики погрешность в 16-м знаке несущественна, а скорость критична. Поэтому процессоры считают в IEEE 754, а программист обязан знать, где этой точности не хватает, и сознательно выбирать другой инструмент.
Как обходить
1. Сравнивать с допуском, а не на равенство
Вместо a == b проверяйте, что числа близки: $|a - b| \lt \epsilon$ для маленького порога $\epsilon$. В Python для этого есть готовая функция:
import math
print(0.1 + 0.2 == 0.3) # наивно
print(math.isclose(0.1 + 0.2, 0.3)) # с допуском
print(round(0.1 + 0.2, 10) == round(0.3, 10)) # округлить оба
Вывод:
False True True
2. Считать в целых (наименьших единицах)
Для денег храните не рубли, а копейки — целым числом. Целые в компьютере точны абсолютно, ошибки округления исчезают:
cents = 10 + 20 # 0.10 руб + 0.20 руб в копейках
print(cents, "копеек")
print("итого:", cents / 100, "руб")
Вывод:
30 копеек итого: 0.3 руб
3. Использовать тип Decimal
Для финансов и точных десятичных вычислений в Python есть decimal.Decimal — он считает в десятичной системе ровно так, как человек на бумаге, без двоичных приближений:
from decimal import Decimal
x = Decimal("0.1") + Decimal("0.2")
print(x)
print(x == Decimal("0.3"))
Вывод:
0.3 True
Важно: Decimal создают из строки "0.1", а не из float 0.1 — иначе двоичная ошибка просочится внутрь ещё до того, как Decimal возьмётся за дело.
Частые ошибки
- Сравнивают float на ==. Почти всегда баг. Используйте
math.iscloseили сравнение с порогом $\epsilon$. - Хранят деньги во float. Считайте в копейках (целыми) или в
Decimal. Плавающая точка и деньги несовместимы. - Создают Decimal из float.
Decimal(0.1)унаследует двоичную погрешность; правильно —Decimal("0.1")из строки. - Думают, что округление display решает проблему. Показать два знака — не то же самое, что считать точно: внутренняя ошибка остаётся и накапливается.
- Ставят точное равенство условием выхода из цикла. Считайте итерации целым счётчиком, а не сравнивайте накопленный float с целью.
Итоги
- $0{,}1 + 0{,}2 = 0{,}30000000000000004$, потому что слагаемые хранятся как неточные двоичные приближения, и их ошибки складываются.
- Погрешность одного округления ничтожна ($\varepsilon \approx 2 \cdot 10^{-16}$ для double), но в длинных вычислениях она накапливается.
- Опаснее всего это в деньгах и в сравнениях на точное равенство — вплоть до несходящейся кассы и бесконечных циклов.
- Обходят тремя приёмами: сравнение с допуском (
math.isclose), счёт в целых (копейки) и типDecimal(созданный из строки).