Spec-Zone.ru › Python 3.14

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

Числа с плавающей точкой представляются в аппаратном обеспечении компьютера в виде дробей с основанием 2 (двоичных дробей). Например, десятичная дробь 0.625 имеет значение 6/10 + 2/100 + 5/1000, а двоичная дробь 0.101 имеет значение 1/2 + 0/4 + 1/8. Эти две дроби имеют одинаковые значения; единственное существенное различие состоит в том, что первая записана в дробной системе счисления с основанием 10, а вторая — с основанием 2.

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

Поначалу эту проблему проще понять на примере системы счисления с основанием 10. Рассмотрим дробь 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')  # 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 с числами типа float унаследованы от аппаратного обеспечения с плавающей точкой и на большинстве компьютеров для одной операции не превышают 1 часть из 2**53. Для большинства задач этого более чем достаточно, но нужно помнить, что это не десятичная арифметика и что каждая операция с float может сопровождаться новой ошибкой округления.

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

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

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

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

В Python есть инструменты, которые помогут в редких случаях, когда вам действительно нужно узнать точное значение числа float. Метод 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'

Это точное шестнадцатеричное представление можно использовать для точного восстановления значения float:

>>> 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 типа float соответствуют значениям двойной точности 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

Иными словами, только при значении N, равном 56, число 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 Python Software Foundation
Licensed under the PSF License.
https://docs.python.org/3.14/tutorial/floatingpoint.html

Spec-Zone.ru

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