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» подробно описана ниже, в разделе «Ошибка представления». Более полное описание других распространенных сюрпризов см. в разделе The Perils of Floating Point.

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

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

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

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

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

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

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

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

>>> x == 3537115888337719 / 1125899906842624
True

Метод float.hex() выражает число float в шестнадцатеричном формате (основание 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. Двойные числа 754 содержат 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 в 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'
Вернуться на верх