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» подробно объясняется ниже, в разделе «Ошибка представления». См. статью Примеры проблем с числами с плавающей точкой — в ней доступно рассказывается о принципах работы двоичной арифметики с плавающей точкой и о проблемах, с которыми часто сталкиваются на практике. Более подробно о других распространённых неожиданностях читайте в статье Опасности арифметики с плавающей точкой.

Как говорится ближе к концу первой статьи, «простых решений нет». И всё же не стоит чрезмерно опасаться чисел с плавающей точкой! Погрешности операций с типом float в Python унаследованы от аппаратного обеспечения с плавающей точкой и на большинстве компьютеров для каждой операции не превышают примерно 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, а на почти всех платформах числа float в 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