Арифметика с плавающей точкой: проблемы и ограничения
Числа с плавающей точкой представляются в компьютерном оборудовании в виде двоичных дробей. Например, десятичная дробь
0.125
имеет значение 1/10 + 2/100 + 5/1000, и аналогично двоичная дробь
0.001
имеет значение 0/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:
>>> .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() в Синтаксис строк форматирования.
Для случаев использования, которые требуют точного десятичного представления, попробуйте использовать модуль decimal, который реализует десятичную арифметику, подходящую для бухгалтерских приложений и приложений высокой точности.
Ещё один вид точной арифметики поддерживается модулем fractions, который реализует арифметику на основе рациональных чисел (поэтому числа, такие как 1/3, могут быть представлены точно).
Если вы активно используете операции с плавающей точкой, вы должны взглянуть на пакет Numerical Python и многие другие пакеты для математических и статистических операций, предоставляемые проектом 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).
Ещё один полезный инструмент — функция 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 и 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–2022 Python Software Foundation
Licensed under the PSF License.
https://docs.python.org/3.8/tutorial/floatingpoint.html