Spec-Zone.ru › Python 3.12

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

Числа с плавающей запятой в компьютерном оборудовании представляются в виде дробей с основанием 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')  # 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:

>>> 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)))   # Exact summation with single rounding
8.042173697819788e-13
>>> math.fsum(arr)                   # Single rounding
8.042173697819788e-13
>>> sum(arr)                         # Multiple roundings in extended precision
8.042178034628478e-13
>>> total = 0.0
>>> for x in arr:
...     total += x                   # Multiple roundings in standard precision
...
>>> total                            # Straight addition has no correct digits!
-0.0051575902860057365

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

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

Ошибка представления относится к тому факту, что некоторые (на самом деле большинство) десятичные дроби не могут быть точно представлены в виде двоичных (с основанием 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.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'

© 2001–2024 Python Software Foundation
Licensed under the PSF License.
https://docs.python.org/3.12/tutorial/floatingpoint.html

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API