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')  # запросить 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” объяснена в деталях ниже, в разделе “Ошибка представления”. См. Опасности плавающей точки более полный отчет о других распространенных сюрпризах.

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

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

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

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

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

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

>>> 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» в деталях, и показывает, как вы можете выполнить точный анализ подобных случаев сами. Начальное знакомство с бинарным представлением чисел с плавающей точкой предполагается.

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

Почему так? 1/10 нельзя точно представить в виде двоичной дроби. Большинство машин сегодня (ноябрь 2000) используют арифметику с плавающей точкой IEEE-754, и большинство платформ отображают Python-float-ы на числа IEEE-754 «с двойной точностью». Double-ы 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

Поделив и числитель, и знаменатель на 2 редуцирует дробь до:

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'