Потеря точности и ошибки округления

Выясняем, почему компьютер уверяет, что 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 (созданный из строки).
Проверьте себя
1. Почему в Python 0.1 + 0.2 == 0.3 даёт False?
AЭто ошибка интерпретатора Python
B0.1 и 0.2 хранятся как неточные двоичные приближения, их ошибки складываются, и сумма не совпадает с приближением 0.3
CPython округляет 0.3 в большую сторону
DСложение float в Python запрещено
2. Какой способ НЕ подходит для точных денежных расчётов?
AХранить сумму в копейках как целое число
BИспользовать тип Decimal, созданный из строки
CХранить сумму в рублях как float (double)
DОкруглять результат функцией round перед сравнением
3. Как корректно сравнить два вещественных числа на «равенство»?
AЧерез оператор == напрямую
BПроверить, что модуль их разности меньше малого порога, например через math.isclose
CСравнить их строковые представления
DСложить их и проверить, что сумма чётная