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

Числа с плавающей запятой представлены в компьютерном оборудовании в виде двоичных дробей по основанию 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 двоичная дробь равна 3602879701896397 / 2 ** 55, что близко к истинному значению 1/10, но не совсем равно ему.

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

>>> 0.1
0.1000000000000000055511151231257827021181583404541015625

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

>>> 1 / 10
0.1

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

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

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

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

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

>>> format(math.pi, '.12g')  # give 12 significant digits
'3.14159265359'

>>> format(math.pi, '.2f')   # give 2 digits after the point
'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

Двоичная арифметика с плавающей запятой таит в себе много сюрпризов, подобных этому. Проблема с «0.1» подробно описана ниже, в разделе «Ошибка представления». Смотрите Examples of Floating Point Problems для получения краткой информации о том, как работает двоичная система с плавающей запятой, и о типах проблем, с которыми обычно приходится сталкиваться на практике. Также смотрите The Perils of Floating Point для более полного описания других распространенных неожиданностей.

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

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

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

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

Если вы часто используете операции с плавающей запятой, вам следует ознакомиться с пакетом NumPy и многими другими пакетами для математических и статистических операций, предоставляемыми проектом SciPy. Смотрите <https://scipy.org>.

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

>>> 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

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

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

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

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

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

как

J ~= 2**N / 10

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

>>> 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 в соответствии с двойной точностью IEEE 754 составляет:

7205759403792794 / 2 ** 56

При делении числителя и знаменателя на два дробь получается:

3602879701896397 / 2 ** 55

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

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

>>> 0.1 * 2 ** 55
3602879701896397.0

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

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

это означает, что точное число, сохраненное в компьютере, равно десятичному значению 0,1000000000000000000055511151231257827021181583404541015625. Вместо отображения полного десятичного значения во многих языках (включая более старые версии 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'
Вернуться на верх