Арифметика с плавающей точкой: проблемы и ограничения
Числа с плавающей точкой представляются в компьютерном оборудовании в виде двоичных дробей (основание 2). Например, десятичная дробь
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, могут быть представлены точно).
Если вы активно используете операции с плавающей точкой, вам следует взглянуть на пакет 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).
Ещё один полезный инструмент — функция 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 float с «двойной точностью» 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–2023 Python Software Foundation
Licensed under the PSF License.
https://docs.python.org/3.10/tutorial/floatingpoint.html