руководствоPython
August 13, 2020

Арифметика с плавающей точкой: проблемы и ограничения

Числа с плавающей запятой представлены в компьютерном оборудовании как дроби с основанием 2 (двоичные). Например, десятичная дробь

0.125

имеет значение 1/10 + 2/100 + 5/1000, и точно так же двоичная дробь

0.001

имеет значение 0/2 + 0/4 + 1/8. Эти две дроби имеют одинаковые значения, единственная реальная разница в том, что первая записана в дробной системе с основанием 10, а вторая - с основанием 2.

К сожалению, большинство десятичных дробей не могут быть представлены точно как двоичные дроби. Следствием этого является то, что, как правило, вводимые вами десятичные числа с плавающей запятой только аппроксимируются двоичными числами с плавающей запятой, фактически сохраненными в машине.

Задачу сначала легче понять по основанию 10. Рассмотрим дробь 1/3. Вы можете аппроксимировать это как дробь по основанию 10:

0.3

или лучше,

0.33

или лучше,

0.333

и так далее. Независимо от того, сколько цифр вы хотите записать, результат никогда не будет точно 1/3, а будет все более точным приближением к 1/3.

Точно так же, независимо от того, сколько цифр с основанием 2 вы хотите использовать, десятичное значение 0,1 не может быть представлено точно как дробь с основанием 2. В основании 2 1/10 - это бесконечно повторяющаяся дробь

0.0001100110011001100110011001100110011001100110011...

Остановитесь на любом конечном числе бит, и вы получите приблизительное значение. Сегодня на большинстве машин числа с плавающей запятой аппроксимируются с использованием двоичной дроби с числителем, использующим первые 53 бита, начиная со старшего разряда, а знаменатель - это степень двойки. В случае 1/10 двоичная дробь близка к истинному значению 1/10, но не в точности равна ему.3602879701896397 / 2 ** 55

Многие пользователи не знают приближения из-за способа отображения значений. Python печатает только десятичное приближение к истинному десятичному значению двоичного приближения, хранящегося в машине. На большинстве машин, если бы Python печатал истинное десятичное значение двоичного приближения, сохраненного для 0,1, он должен был бы отображать

>>> 0.1
0.1000000000000000055511151231257827021181583404541015625

Это больше цифр, чем большинство людей считает полезными, поэтому Python поддерживает управление количеством цифр, отображая вместо этого округленное значение.

>>> 1 / 10
0.1

Просто помните, хотя напечатанный результат выглядит как точное значение 1/10, фактическое сохраненное значение является ближайшей представимой двоичной дробью.

Интересно, что существует множество различных десятичных чисел, которые имеют одну и ту же ближайшую приблизительную двоичную дробь. Например, все числа 0.1и 0.10000000000000001и 0.1000000000000000055511151231257827021181583404541015625приблизительно равны . Поскольку все эти десятичные значения имеют одно и то же приближение, любое из них может отображаться с сохранением инварианта .3602879701896397 / 2 ** 55eval(repr(x)) == x

Исторически сложилось так , Python быстрое и встроенные repr()функции будет выбрать один с 17 значащими цифрами, 0.1000000000000000 с Python 3.1, Python (в большинстве систем) теперь может выбирать самые короткие из них и просто отображать 0.1.

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

Для более приятного вывода вы можете использовать форматирование строки для получения ограниченного количества значащих цифр:

>>> format(math.pi, '.12g')
'3.14159265359'

>>> format(math.pi, '.2f')
'3.14'

>>> repr(math.pi)
'3.141592653589793'

Важно понимать, что это, по сути, иллюзия: вы просто округляете отображение истинного машинного значения.

Одна иллюзия может порождать другую. Например, поскольку 0,1 - это не точно 1/10, суммирование трех значений 0,1 также может не дать точно 0,3:

>>> .1 + .1 + .1 == .3
False

Кроме того, поскольку 0,1 не может приблизиться к точному значению 1/10, а 0,3 не может приблизиться к точному значению 3/10, предварительное округление с помощью round()функции не может помочь:

>>> round(.1, 1) + round(.1, 1) + round(.1, 1) == round(.3, 1)
False

Хотя числа не могут быть приближены к их предполагаемым точным значениям, round() функция может быть полезна для последующего округления, чтобы результаты с неточными значениями стали сопоставимы друг с другом:

>>> round(.1 + .1 + .1, 10) == round(.3, 10)
True

Двоичная арифметика с плавающей запятой преподносит много подобных сюрпризов.

Как говорится в конце, «нет простых ответов». Тем не менее, не опасайтесь чисел с плавающей запятой! Ошибки в операциях Python с плавающей запятой наследуются от оборудования с плавающей запятой, и на большинстве машин они составляют не более 1 части из 2 ** 53 на операцию. Этого более чем достаточно для большинства задач, но нужно помнить, что это не десятичная арифметика и что каждая операция с плавающей запятой может иметь новую ошибку округления.

Хотя патологические случаи действительно существуют, при наиболее случайном использовании арифметики с плавающей запятой вы увидите результат, которого ожидаете, в конце, если вы просто округлите отображение ваших окончательных результатов до ожидаемого количества десятичных цифр.

Для случаев использования, требующих точного десятичного представления, попробуйте использовать decimal модуль, реализующий десятичную арифметику, подходящий для бухгалтерских приложений и приложений с высокой точностью.

Другая форма точной арифметики поддерживается fractions модулем, который реализует арифметику на основе рациональных чисел (поэтому числа типа 1/3 могут быть представлены точно).

Если вы интенсивно используете операции с плавающей запятой, вам следует взглянуть на пакет Numerical Python и многие другие пакеты для математических и статистических операций, предоставляемые проектом SciPy.

Python предоставляет инструменты, которые могут помочь в тех редких случаях , когда вы действительно действительно хотите знать точное значение с плавающей точкой. float.as_integer_ratio()Метод выражает значение с плавающей точкой в виде дроби:

>>> x = 3.14159
>>> x.as_integer_ratio()
(3537115888337719, 1125899906842624)

Поскольку соотношение точное, его можно использовать для восстановления исходного значения без потерь:

>>> x == 3537115888337719 / 1125899906842624
True

float.hex()Метод выражает поплавок в шестнадцатеричной (основание 16), снова дает точное значение , сохраненное на компьютере:

>>> x.hex()
'0x1.921f9f01b866ep+1'

Это точное шестнадцатеричное представление можно использовать для точного восстановления значения с плавающей запятой:

>>> x == float.fromhex('0x1.921f9f01b866ep+1')
True

Поскольку представление является точным, оно полезно для надежного переноса значений в разные версии Python (независимость от платформы) и обмена данными с другими языками, поддерживающими тот же формат (например, Java и C99).

Еще одним полезным инструментом является math.fsum()функция, которая помогает уменьшить потерю точности во время суммирования. Он отслеживает «потерянные цифры» по мере добавления значений к промежуточной сумме. Это может повлиять на общую точность, так что ошибки не накапливаются до точки, в которой они влияют на окончательную сумму:

>>> sum([0.1] * 10) == 1.0
False
>>> math.fsum([0.1] * 10) == 1.0
True

Ошибка представления

В этом разделе подробно объясняется пример «0.1» и показано, как вы можете самостоятельно выполнить точный анализ подобных случаев. Предполагается базовое знакомство с двоичным представлением с плавающей запятой.

Ошибка представления относится к тому факту, что некоторые (фактически большинство) десятичные дроби не могут быть представлены точно как двоичные (с основанием 2) дроби. Это основная причина, по которой Python (или Perl, C, C ++, Java, Fortran и многие другие) часто не отображает точное десятичное число, которое вы ожидаете.

Это почему? 1/10 нельзя точно представить в виде двоичной дроби. Почти все машины сегодня (ноябрь 2000 г.) используют арифметику с плавающей запятой IEEE-754, и почти все платформы отображают числа с плавающей запятой Python в «двойную точность» IEEE-754. 754 числа с двойной точностью содержат 53 бита точности, поэтому на входе компьютер пытается преобразовать 0,1 в ближайшую дробь, которую он может иметь в форме J / 2 ** N, где J - целое число, содержащее ровно 53 бита. Переписывание

1 / 10 ~= J / (2**N)

так как

J ~= 2**N / 10

и вспоминая, что J имеет ровно 53 бита (это но ), лучшее значение для N - 56:>= 2**52< 2**53

>>> 2**52 <=  2**56 // 10  < 2**53
True

То есть 56 - единственное значение для N, которое оставляет J ровно с 53 битами. Тогда наилучшее возможное значение для J - это округленное частное:

>>> q, r = divmod(2**56, 10)
>>> r
6

Поскольку остаток больше половины от 10, наилучшее приближение получается округлением в большую сторону:

>>> q+1
7205759403792794

Следовательно, наилучшее возможное приближение к 1/10 с двойной точностью 754:

7205759403792794 / 2 ** 56

Разделив числитель и знаменатель на два, дробь уменьшится до:

3602879701896397 / 2 ** 55

Обратите внимание: поскольку мы округлили в большую сторону, это на самом деле немного больше 1/10; если бы мы не округляли в большую сторону, частное было бы немного меньше 1/10. Но ни в коем случае не может быть ровно 1/10!

Таким образом, компьютер никогда не «видит» 1/10: он видит точную дробь, указанную выше, наилучшее двойное приближение 754, которое он может получить:

>>> 0.1 * 2 ** 55
3602879701896397.0

Если мы умножим эту дробь на 10 ** 55, мы сможем увидеть значение до 55 десятичных цифр:

>>> 3602879701896397 * 10 ** 55 // 2 ** 55
1000000000000000055511151231257827021181583404541015625

Это означает, что точное число, хранящееся в компьютере, равно десятичному значению 0,1000000000000000055511151231257827021181583404541015625. Вместо того, чтобы отображать полное десятичное значение, многие языки (включая старые версии Python) округляют результат до 17 значащих цифр:

>>> format(0.1, '.17f')
'0.10000000000000001'

fractions И decimalмодули делают эти расчеты легко:

>>> from decimal import Decimal
>>> from fractions import Fraction

>>> Fraction.from_float(0.1)
Fraction(3602879701896397, 36028797018963968)

>>> (0.1).as_integer_ratio()
(3602879701896397, 36028797018963968)

>>> Decimal.from_float(0.1)
Decimal('0.1000000000000000055511151231257827021181583404541015625')

>>> format(Decimal.from_float(0.1), '.17')
'0.10000000000000001'

Заключение

Пост создан для тг-канала @coolcoders