float и Decimal



Вас никогда не удивляло, что 0.1 + 0.2 != 0.3? Почему float считает с погрешностями, и всем норм?



Дело в том, что 0.1 выглядит как



0 0111111101 11001100110011001100110011001100110011001100110011010.



Где:

0 обозначает знак +1 обозначает -)

0111111101 обозначает exponent, равную 0^10 + 2^9 + 2^8 + 2^7 + 2^6 + итд = 1019. Вычтем 1023 (размерность double) и получим итоговое значение: 1019 - 1023 = 4

11001100110011001100110011001100110011001100110011010 обозначет "significand" или "мантису", которая равна: 2^-exp + 2^-exp-1 + 2^-exp-2 + итд ~= 0.1



Вот так мы можем примерно представить 0.1 в виде float. Примерно – потому что все вычисления идут с погрешностью. Мы можем проверить данное утверждение, добавив погрешность вручную:



>>> assert 0.1 + 2.220446049250313e-18 == 0.1



Значение внешне не изменилось при добавлении погрешности. Посмотрим на sys.float_info.epsilon, который устанавливает необходимый порог для минимальных отличий 1.0 от следующего float числа.



>>> import sys

>>> sys.float_info.epsilon

2.220446049250313e-16

>>> assert 1.0 + sys.float_info.epsilon > 1.0

>>> assert 1.0 + 2.220446049250313e-17 == 1.0 # число меньше epsilon



Как конкретно будет выглядеть 0.1? А вот тут нам уже поможет Decimal для отображения полного числа в десятичной системе:



>>> decimal.Decimal(0.1)

Decimal('0.1000000000000000055511151231257827021181583404541015625')



И вот ответ про 0.1 + 0.2, полное демо с битиками:



>>> decimal.Decimal(0.1)

Decimal('0.1000000000000000055511151231257827021181583404541015625')

>>> decimal.Decimal(0.2)

Decimal('0.200000000000000011102230246251565404236316680908203125')



>>> decimal.Decimal(0.1 + 0.2)

Decimal('0.3000000000000000444089209850062616169452667236328125')



>>> decimal.Decimal(0.3)

Decimal('0.299999999999999988897769753748434595763683319091796875')



Числа не равны друг другу, потому что их разница больше предельной точности float. А сам Decimal может использовать любую точность под задачу.



>>> from decimal import Decimal, getcontext

>>> getcontext().prec = 6

>>> Decimal(1) / Decimal(7)

Decimal('0.142857')



>>> getcontext().prec = 28

>>> Decimal(1) / Decimal(7)

Decimal('0.1428571428571428571428571429')



Но и Decimal не может в абсолютную точность, потому что есть в целом невыразимые в десятичной системе числа, такие как math.pi, , тд. С чем-то из них может помочь fractions.Fraction для большей точности, но от существования иррациональных чисел никуда не деться.



Почему всем норм, что у нас с float такие погрешности в вычислениях? Потому что во многих задачах абсолютная точность недостижима и не имеет смысла. Благодаря плавающей точке мы можем хранить как очень большие, так и очень маленькие числа без существенных затрат памяти. А ещё float - очень быстрый. В том числе за счет аппаратного ускорения.



» pyperf timeit -s 'a = 0.1; b = 0.2' 'a + b'

.....................

Mean +- std dev: 8.75 ns +- 0.2 ns



» pyperf timeit -s 'import decimal; a = decimal.Decimal("0.1"); b = decimal.Decimal("0.2")' 'a + b'

.....................

Mean +- std dev: 27.7 ns +- 0.1 ns



Разница в 3 раза.



Про то, как устроен float внутри – рассказывать не буду. У Никиты Соболева недавно было большое и подробное видео на тему внутреннего устройства float. У него действительно хороший технический контент, советую подписаться: @opensource_findings



Итого

Если у вас нет требований по работе именно с десятичной записью числа (как, например, в бухгалтерии), то используйте float. Он даст достаточную точность и хорошую скорость. Если вы хотите, чтобы расчеты велись в десятичных цифрах и ваши расчеты построены так, что абсолютная точность достижима, то используйте Decimal.



Дополнительные материалы:

https://www.youtube.com/@sobolevn/

https://0.30000000000000004.com

https://en.wikipedia.org/wiki/X87

http://aco.ifmo.ru/el_books/numerical_methods/lectures/app_1.html