Содержание страницы
15. Арифметика с плавающей запятой: проблемы и ограничения¶Floating-Point Arithmetic: Issues and Limitations
Числа с плавающей запятой представляются в компьютерном оборудовании в виде дробей по основанию 2 (двоичных).
Например, десятичная дробь 0.625
имеет значение 6/10 + 2/100 + 5/1000, и точно так же двоичная дробь 0.101
имеет значение 1/2 + 0/4 + 1/8. Эти две дроби имеют одинаковые значения, единственное
реальное различие в том, что первая записана в десятичной дробной записи, а вторая – в двоичной.
К сожалению, большинство десятичных дробей не могут быть точно представлены в виде двоичных дробей. Следствием этого является то, что, вообще говоря, вводимые десятичные числа с плавающей запятой лишь приблизительно соответствуют двоичным числам с плавающей запятой, которые на самом деле хранятся в машине.
Проблему легче сначала понять в десятичной системе. Рассмотрим дробь 1/3. Её можно приблизить десятичной дробью:
0.3
или, лучше,
0.33
или, лучше,
0.333
и так далее. Сколько бы цифр вы ни захотели записать, результат никогда не будет точно равен 1/3, но будет всё лучшим приближением к 1/3.
Точно так же, сколько бы двоичных цифр вы ни захотели использовать, десятичное значение 0.1 не может быть точно представлено в виде двоичной дроби. В двоичной системе 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:
>>> 0.1 + 0.1 + 0.1 == 0.3
False
Кроме того, поскольку 0.1 не может быть ближе к точному значению 1/10, а
0.3 не может быть ближе к точному значению 3/10, то предварительное округление с помощью
функции round() не поможет:
>>> round(0.1, 1) + round(0.1, 1) + round(0.1, 1) == round(0.3, 1)
False
Хотя числа нельзя сделать ближе к их точным значениям,
функция math.isclose() может быть полезна для сравнения неточных значений:
>>> math.isclose(0.1 + 0.1 + 0.1, 0.3)
True
В качестве альтернативы функция round() может использоваться для сравнения грубых
приближений:
>>> round(math.pi, ndigits=2) == round(22 / 7, ndigits=2)
True
Двоичная арифметика с плавающей запятой таит много подобных сюрпризов. Проблема с «0.1» подробно объясняется ниже, в разделе «Ошибка представления». Смотрите Примеры проблем с плавающей запятой для хорошего обзора того, как работает двоичная арифметика с плавающей запятой и какие проблемы обычно встречаются на практике. Также смотрите Опасности плавающей запятой для более полного описания других распространённых сюрпризов.
Как сказано ближе к концу, «лёгких ответов нет». Тем не менее, не стоит чрезмерно опасаться плавающей запятой! Ошибки в операциях 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() представляет число с плавающей запятой в шестнадцатеричном виде (с основанием 16), снова выдавая точное значение, хранящееся в компьютере:
>>> x.hex()
'0x1.921f9f01b866ep+1'
Это точное шестнадцатеричное представление можно использовать для точного восстановления значения числа с плавающей запятой:
>>> x == float.fromhex('0x1.921f9f01b866ep+1')
True
Поскольку представление точное, оно полезно для надёжного переноса значений между разными версиями Python (независимость от платформы) и обмена данными с другими языками, поддерживающими тот же формат (например, Java и C99).
Ещё одним полезным инструментом является функция sum(), которая помогает уменьшить потерю точности при суммировании. Она использует расширенную точность для промежуточных округлений по мере добавления значений к накапливаемой сумме. Это может повлиять на общую точность, так что ошибки не накапливаются до такой степени, чтобы повлиять на конечный результат:
>>> 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 == 1.0
False
>>> sum([0.1] * 10) == 1.0
True
Функция math.fsum() идёт дальше и отслеживает все «потерянные цифры» по мере добавления значений к накапливаемой сумме, так что результат округляется только один раз. Это медленнее, чем sum(), но будет точнее в редких случаях, когда входные значения большой величины в основном взаимно уничтожаются, оставляя итоговую сумму, близкую к нулю:
>>> arr = [-0.10430216751806065, -266310978.67179024, 143401161448607.16,
... -143401161400469.7, 266262841.31058735, -0.003244936839808227]
>>> float(sum(map(Fraction, arr))) # Точное суммирование с однократным округлением
8.042173697819788e-13
>>> math.fsum(arr) # Однократное округление
8.042173697819788e-13
>>> sum(arr) # Многократное округление с расширенной точностью
8.042178034628478e-13
>>> total = 0.0
>>> for x in arr:
... total += x # Многократное округление со стандартной точностью
...
>>> total # Прямое сложение не даёт ни одной правильной цифры!
-0.0051575902860057365
15.1. Ошибка представления¶Representation Error
В этом разделе подробно объясняется пример «0.1» и показывается, как можно самостоятельно выполнить точный анализ подобных случаев. Предполагается базовое знакомство с двоичным представлением чисел с плавающей запятой.
Ошибка представления относится к тому факту, что некоторые (на самом деле большинство) десятичные дроби не могут быть точно представлены в виде двоичных (с основанием 2) дробей. Это главная причина, по которой Python (или Perl, C, C++, Java, Fortran и многие другие) часто не отображает точное десятичное число, как ожидается.
Почему так? 1/10 не может быть точно представлено в виде двоичной дроби. Начиная как минимум с 2000 года, почти все машины используют двоичную арифметику с плавающей запятой IEEE 754, и почти все платформы отображают числа с плавающей запятой Python на двоичные значения двойной точности IEEE 754 binary64. Значения 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.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'