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

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

0.125

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

0.001

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

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

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

0.3

или лучше

0.33

еще лучше

0.333

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

Точно также не важно, как много цифр с основанием 2 вы будете использовать, десятичное значение 0.1 не может быть представлено точно в двоичной записи дроби. По основанию 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')  # задает 12 значащих цифр
'3.14159265359'

>>> format(math.pi, '.2f')   # задает 2 цифры после запятой
'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() спецификаторы формата метода в Format String Syntax (docs.python.org/3/library/string.html#formatstrings).

Для случаев, которые требуют точного десятичного представления, попробуйте использовать модуль decimal (docs.python.org/3/library/decimal.html#module-decimal), который реализует десятичную арифметику, подходящую для бухгалтерских приложений и высокоточных приложений.

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

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

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

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

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

>>> x == 3537115888337719 / 1125899906842624
True

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

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

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

Другой полезный инструмент - функция math.fsum() (docs.python.org/3/library/math.html#math.fsum), которая помогает смягчить потерю точности во время суммирования. Она отслеживает "потерянные цифры" как значения, добавляемые к текущему итогу. Это может повлиять на общую точность, так что ошибки не накапливались до такой степени, чтобы влиять на итоговое значение:

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

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

Этот раздел разъясняет "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 бита (является >= 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 (docs.python.org/3/library/fractions.html#module-fractions) и decimal (docs.python.org/3/library/decimal.html#module-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'

Создано