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

Время на прочтение
9 мин

Количество просмотров 35K

К старту курса по Fullstack-разработке на Python делимся решениями классической проблемы неточности чисел с плавающей точкой. В материале вы найдёте примеры работы с функциями и классами, предназначенными специально для решения проблем чисел с плавающей точкой.


Числа с плавающей точкой — быстрый и эффективный способ хранения чисел и работы с ними. Но он связан с рядом трудностей для начинающих и опытных программистов! Вот классический пример:

>>> 0.1 + 0.2 == 0.3
False

Впервые увидев такое, можно растеряться. Такое поведение корректно! Поговорим о том, почему ошибки при операциях над числами с плавающей точкой так распространены, почему они возникают и как с ними справиться в Python.

Компьютер обманывает вас

Вы видели, что 0.1 + 0.2 не равно 0.3, но безумие на этом не заканчивается. Вот ещё пара примеров, сбивающих с толку:

>>> 0.2 + 0.2 + 0.2 == 0.6
False

>>> 1.3 + 2.0 == 3.3
False

>>> 1.2 + 2.4 + 3.6 == 7.2
False

Проблема касается и сравнения:

>>> 0.1 + 0.2 <= 0.3
False

>>> 10.4 + 20.8 > 31.2
True

>>> 0.8 - 0.1 > 0.7
True

Что происходит? Когда вы вводите в интерпретатор Python число 0.1, оно сохраняется в памяти как число с плавающей точкой и происходит преобразование. 0.1 — это десятичное число с основанием 10, но числа с плавающей точкой хранятся в двоичной записи. То есть основание 0.1 преобразуется из 10 в 2.

Получающееся двоичное число может недостаточно  точно представлять исходное число с основанием 10. 0.1 — один из примеров. Двоичным представлением будет 0.0(0011). То есть 0.1 — это бесконечно повторяющееся десятичное число, записанное с основанием 2. То же происходит, когда в виде десятичного числа с основанием 10 записывается дробь ⅓. Получается бесконечно повторяющееся десятичное число 0.3(3).

Память компьютера конечна, поэтому бесконечно повторяющееся представление двоичной дроби 0.1 округляется до конечной дроби. Её значение зависит от архитектуры компьютера (32- или 64-разрядная).

Увидеть значение с плавающей точкой, которое сохраняется для 0.1, можно с помощью метода .as_integer_ratio(). Представление с плавающей точкой состоит из числителя и знаменателя:

>>> numerator, denominator = (0.1).as_integer_ratio()
>>> f"0.1 ≈ {numerator} / {denominator}"
'0.1 ≈ 3602879701896397 / 36028797018963968'

Чтобы отобразить дробь с точностью до 55 знаков после запятой, используем format():

>>> format(numerator / denominator, ".55f")
'0.1000000000000000055511151231257827021181583404541015625'

Так 0.1 округляется до числа чуть больше, чем его истинное значение.

Узнайте больше о числовых методах, подобных .as_integer_ratio(), в моей статье 3 Things You Might Not Know About Numbers in Python («3 факта о числах в Python, которые вы могли не знать»).

Эта ошибка представления чисел с плавающей точкой встречается куда чаще, чем может показаться.

Ошибка представления числа очень типична

Существуют три причины, почему число округляется при его представлении в виде числа с плавающей точкой:

  1. В числе больше значащих разрядов, чем позволяет плавающая точка.

  2. Это иррациональное число.

  3. Число рациональное, но без конечного двоичного представления. 

64-разрядные числа с плавающей точкой имеют 16 или 17 значащих разрядов. Любое число, у которого значащих разрядов больше, округляется. Иррациональные числа, такие как π и e, нельзя представить конечной дробью с целочисленным основанием. И, опять же, иррациональные числа в любом случае округляются при сохранении в виде чисел с плавающей точкой.

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

А как на счёт бесконечных рациональных чисел, например 0.1 с основанием 2? Именно здесь вам встретится большинство связанных с плавающей точкой трудностей и благодаря математике — позволяющей определять, конечная дробь или нет, — вы будете сталкиваться с ошибками представления чаще, чем думаете.

С основанием 10 дробь можно представить как конечную, если её знаменатель — произведение степеней простых множителей 10. Два простых множителя 10 — это 2 и 5, поэтому дроби ½, ¼, ⅕, ⅛ и ⅒ — конечные, а ⅓, ⅐ и ⅑ — нет. У основания 2 только один простой множитель — 2.

Конечные дроби здесь только те, знаменатель которых равен степени числа 2. В результате дроби ⅓, ⅕, ⅙, ⅐, ⅑ и ⅒ — бесконечные, когда представлены в двоичной записи.

Теперь наш первый пример должен стать понятнее. 0.1, 0.2 и 0.3 при преобразовании в числа с плавающей точкой округляются:

>>> # -----------vvvv  Display with 17 significant digits
>>> format(0.1, ".17g")
'0.10000000000000001'

>>> format(0.2, ".17g")
'0.20000000000000001'

>>> format(0.3, ".17g")
'0.29999999999999999'

При сложении 0.1 и 0.2 получается число чуть больше 0.3:

>>> 0.1 + 0.2
0.30000000000000004

А поскольку 0.1 + 0.2 чуть больше, чем 0.3, и 0.3 представлено числом, которое чуть меньше 0.3, выражение 0.1 + 0.2 == 0.3 оказывается False.

Об ошибке представления чисел с плавающей точкой должен знать каждый программист на любом языке — и уметь с ней справляться. Она характерна не только для Python. Результат вывода 0.1 + 0.2 на разных языках можно увидеть на сайте с подходящим названием 0.30000000000000004.com.

Как сравнивать числа с плавающей точкой в Python

Как же справляться с ошибками представления чисел с плавающей точкой при сравнении таких чисел в Python? Хитрость заключается в том, чтобы избегать проверки на равенство. Вместо ==, >= или <= всегда используйте с числами с плавающей точкой функцию math.isclose():

>>> import math
>>> math.isclose(0.1 + 0.2, 0.3)
True

В math.isclose() проверяется, достаточно ли близок первый аргумент ко второму. То есть проверяется расстояние между двумя аргументами. Оно равно абсолютной величине разницы обоих значений:

>>> a = 0.1 + 0.2
>>> b = 0.3
>>> abs(a - b)
5.551115123125783e-17

Если abs(a — b) меньше некоего процента от большего значения a или b, то a считается достаточно близким к b, чтобы считаться «равным» b. Этот процент называется относительной погрешностью и указывается именованным аргументом rel_tol функции math.isclose(), который по умолчанию равен 1e-9.

То есть если abs(a — b) меньше 0.00000001 * max(abs(a), abs(b)), то a и b считаются «близкими» друг к другу. Это гарантирует, что a и b будут приблизительно с девятью знаками после запятой.

Если нужно, можно изменить относительную погрешность:

>>> math.isclose(0.1 + 0.2, 0.3, rel_tol=1e-20)
False

Конечно, относительная погрешность зависит от ограничений задачи, но для большинства повседневных приложений стандартной относительной погрешности должно быть достаточно. Проблема возникает, если a или b равно нулю, а rel_tol меньше единицы. Тогда, как бы ни было близко ненулевое значение к нулю, относительной погрешностью гарантируется, что проверка на близость будет неудачной. В качестве запасного варианта здесь применяется абсолютная погрешность:

>>> # Relative check fails!
>>> # ---------------vvvv  Relative tolerance
>>> # ----------------------vvvvv  max(0, 1e-10)
>>> abs(0 - 1e-10) < 1e-9 * 1e-10
False

>>> # Absolute check works!
>>> # ---------------vvvv  Absolute tolerance
>>> abs(0 - 1e-10) < 1e-9
True

В math.isclose() эта проверка выполняется автоматически. Абсолютная погрешность определяется с помощью именованного аргумента abs_tol. Но abs_tol по умолчанию равен 0.0, поэтому придётся задать его вручную, если нужно проверить близость значения к нулю.

В итоге в функции math.isclose() возвращается результат следующего сравнения — с относительными и абсолютными проверками в одном выражении:

abs(a - b) <= max(rel_tol * max(abs(a), abs(b)), abs_tol)

math.isclose() появилась в PEP 485 и доступна с Python 3.5.

Когда стоит использовать math.isclose()?

В целом math.isclose() следует применять, сравнивая значения с плавающей точкой. Заменим == на math.isclose():

>>> # Don't do this:
>>> 0.1 + 0.2 == 0.3
False

>>> # Do this instead:
>>> math.isclose(0.1 + 0.2, 0.3)
True

Со сравнениями >= и <= нужно быть осторожным. Обработаем равенство отдельно, используя math.isclose(), а затем проверим строгое сравнение:

>>> a, b, c = 0.1, 0.2, 0.3

>>> # Don't do this:
>>> a + b <= c
False

>>> # Do this instead:
>>> math.isclose(a + b, c) or (a + b < c)
True

Есть альтернативы math.isclose(). Если вы работаете с NumPy, можете использовать numpy.allclose() и numpy.isclose():

>>> import numpy as np

>>> # Use numpy.allclose() to check if two arrays are equal
>>> # to each other within a tolerance.
>>> np.allclose([1e10, 1e-7], [1.00001e10, 1e-8])
False

>>> np.allclose([1e10, 1e-8], [1.00001e10, 1e-9])
True

>>> # Use numpy.isclose() to check if the elements of two arrays
>>> # are equal to each other within a tolerance
>>> np.isclose([1e10, 1e-7], [1.00001e10, 1e-8])
array([ True, False])

>>> np.isclose([1e10, 1e-8], [1.00001e10, 1e-9])
array([ True, True])

Имейте в виду: стандартные относительные и абсолютные погрешности — не то же самое, что math.isclose(). Стандартная относительная погрешность для numpy.allclose() и numpy.isclose() равна 1e-05, а стандартная абсолютная погрешность — 1e-08.

math.isclose() особенно удобна для модульных тестов, хотя и здесь имеются альтернативы. Во встроенном модуле unittest в Python есть метод unittest.TestCase.assertAlmostEqual().

Но в нём применяется только проверка абсолютной разницы. А это ещё и утверждение, то есть при сбоях возникает ошибка AssertionError, из-за которой оно непригодно для сравнений в бизнес-логике.

Отличная альтернатива math.isclose() для модульного тестирования — это функция pytest.approx() из pytest pytest. Как и в math.isclose(), здесь принимаются два аргумента и возвращается, равны они или нет в пределах некой погрешности:

>>> import pytest
>>> 0.1 + 0.2 == pytest.approx(0.3)
True

Как и в math.isclose(), в pytest.approx() для задания относительной и абсолютной погрешностей есть именованные аргументы rel_tol и abs_tol. Но стандартные значения различаются. У rel_tol оно 1e-6, а у abs_tol — 1e-12.

Если первые переданные в pytest.approx() два аргумента подобны массиву (то есть это итерируемый объект Python, такой же, как список или кортеж или даже массив NumPy), тогда в pytest.approx() поведение подобно numpy.allclose() и возвращается то, равны эти два массива или нет в пределах погрешностей:

>>> import numpy as np                                                          
>>> np.array([0.1, 0.2]) + np.array([0.2, 0.4]) == pytest.approx(np.array([0.3, 0.6])) 
True

Для pytest.approx() сгодятся даже значения словаря:

>>> {'a': 0.1 + 0.2, 'b': 0.2 + 0.4} == pytest.approx({'a': 0.3, 'b': 0.6})
True

Числа с плавающей точкой отлично подходят для работы с числами, когда абсолютная точность не требуется. Они быстры и эффективны с точки зрения потребления памяти. Но, если точность нужна, стоит рассмотреть ряд альтернатив числам с плавающей точкой.

Точные альтернативы числам с плавающей точкой

В Python есть два встроенных числовых типа, которые обеспечивают полную точность в ситуациях, когда числа с плавающей точкой не подходят: Decimal и Fraction.

Тип Decimal

В типе Decimal могут храниться десятичные значения именно с той точностью, какая нужна. По умолчанию в нём сохраняются 28 значащих цифр (это число можно изменить согласно конкретной решаемой задаче):

>>> # Import the Decimal type from the decimal module
>>> from decimal import Decimal

>>> # Values are represented exactly so no rounding error occurs
>>> Decimal("0.1") + Decimal("0.2") == Decimal("0.3")
True

>>> # By default 28 significant figures are preserved
>>> Decimal(1) / Decimal(7)
Decimal('0.1428571428571428571428571429')

>>> # You can change the significant figures if needed
>>> from decimal import getcontext
>>> getcontext().prec = 6  # Use 6 significant figures
>>> Decimal(1) / Decimal(7)
Decimal('0.142857')

Больше узнать о типе Decimal можно в документации Python.

Тип Fraction

Альтернатива числам с плавающей точкой — тип Fraction. В нём могут точно сохраняться рациональные числа. При этом устраняются проблемы с ошибками представления, возникающие в числах с плавающей точкой:

>>> # import the Fraction type from the fractions module
>>> from fractions import Fraction

>>> # Instantiate a Fraction with a numerator and denominator
>>> Fraction(1, 10)
Fraction(1, 10)

>>> # Values are represented exactly so no rounding error occurs
>>> Fraction(1, 10) + Fraction(2, 10) == Fraction(3, 10)
True

У типов Fraction и Decimal много преимуществ по сравнению со стандартными значениями с плавающей точкой. Но есть и недостатки: меньшая скорость и повышенное потребление памяти.

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

Заключение

Значения с плавающей точкой — это и благо, и проклятие одновременно. Они обеспечивают быстрые арифметические операции и эффективное потребление памяти за счёт неточного представления. Из этой статьи вы узнали:

  • Почему числа с плавающей точкой неточны.

  • Почему ошибка представления с плавающей точкой типична.

  • Как корректно сравнивать значения с плавающей точкой.

  • Как точно представлять числа, используя типы Fraction и Decimal. 

Узнайте о числах в Python ещё больше. Например, знаете ли вы, что int — не единственный целочисленный тип в Python? Узнайте, какой есть ещё, а также о других малоизвестных фактах о числах в моей статье.

А мы поможем вам прокачать скиллы или с самого начала освоить профессию, востребованную в любое время:

  • Профессия Fullstack-разработчик на Python

  • Профессия Data Analyst

Выбрать другую востребованную профессию.

Краткий каталог курсов и профессий

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

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

Сначала проблему легче понять из базы 10. Рассмотрим дробь 1/3. Вы можете приблизить это как основную 10 фракцию: 0.3 или лучше, 0.33или лучше, 0.333 и так далее. Независимо от того, сколько цифр вы хотите записать, результат никогда не будет ровно 1/3, но будет все более приближенным к 1/3.

Таким же образом, независимо от того, сколько цифр из 2-х оснований вы хотите использовать, десятичное значение 0,1 не может быть представлено в точности как дробь из 2-х оснований. В базе 2 1/10 — бесконечно повторяющаяся дробь 0.0001100110011001100110011...

Остановитесь на любом конечном количестве битов, и вы получите приближение. На большинстве современных машин числа с плавающей запятой аппроксимируются с использованием двоичной дроби, а числитель использует первые 53 бита, начиная с самого старшего бита, а знаменатель — как степень двух. В случае 1/10 двоичная дробь равна 3602879701896397/2 ** 55, что близко, но не точно равно истинному значению 1/10.

Многие пользователи не знают о приближении из-за способа отображения значений. Python печатает только десятичное приближение к истинному десятичному значению двоичного приближения, хранящегося на машине. На большинстве машин, если бы Python должен был печатать истинное десятичное значение двоичного приближения, хранящегося для 0.1, он должен был бы отображать

>>> 0.1
# 0.1000000000000000055511151231257827021181583404541015625

Это больше цифр, чем большинство людей считают полезным, поэтому Python сохраняет количество цифр управляемым, отображая округленное значение вместо

Просто помните, что хотя напечатанный результат выглядит как точное значение 1/10, фактическое сохраненное значение является ближайшей представимой двоичной дробью.

Интересно, что существует много разных десятичных чисел, которые имеют одну и ту же ближайшую приблизительную двоичную дробь. Например, числа 0.1 и 0.10000000000000001 и 0.1000000000000000055511151231... все приблизительно равны 3602879701896397/2 ** 55. Поскольку все эти десятичные значения имеют одинаковую аппроксимацию, любое из них может отображаться при сохранении инварианта eval(repr(x)) == x.

Исторически Python и встроенная функция repr() выбирали функцию с 17 значащими цифрами, 0.10000000000000001. Начиная с Python 3.1 в большинстве систем теперь может выбирать самый короткий из них и просто отображать 0.1.

Обратите внимание, что это по своей природе двоичное число с плавающей точкой: это не ошибка в Python и не ошибка в вашем коде. Вы увидите то же самое на всех языках, которые поддерживают арифметику с плавающей запятой.

Для более приятного вывода вы можете использовать форматирование строки для получения ограниченного числа значащих цифр:

>>> import math
>>> format(math.pi, '.12g')
# '3.14159265359'
>>> format(math.pi, '.2f')
# '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

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

Как говорится, «простых ответов нет». Тем не менее, не следует чрезмерно опасаться чисел с плавающей запятой! Ошибки в операциях с плавающей запятой Python наследуются от аппаратного обеспечения чисел с плавающей запятой, и на большинстве машин они имеют порядок не более одной части в 2 ** 53 на операцию. Это более чем достаточно для большинства задач, но вам нужно помнить, что это не десятичная арифметика и что каждая операция с плавающей запятой может подвергаться новой ошибке округления.

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

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

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

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

Python предоставляет инструменты, которые могут помочь в тех редких случаях, когда вы действительно хотите узнать точное значение числа с плавающей точкой. Метод float.as_integer_ratio() выражает значение типа float в виде дроби:

>>> 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.

Другим полезным инструментом является функция math.fsum(), которая помогает уменьшить потерю точности во время суммирования. Она отслеживает «потерянные цифры», когда значения добавляются в промежуточный итог. Это может повлиять на общую точность, так что ошибки не накапливаются до такой степени, что бы влиять на итоговую сумму:

>>> import math
>>> sum([0.1] * 10) == 1.0
False
>>> math.fsum([0.1] * 10) == 1.0
True

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

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

Ошибка представления относится к тому факту, что фактически большинство десятичных дробей не могут быть представлены точно как двоичные дроби (основание 2) . Это главная причина, почему Python или Perl, C, C++, Java, Fortran и многие другие языки часто не будут отображать точное десятичное число, которое ожидаете.

Это почему? 1/10 не совсем представимо в виде двоичной дроби. Почти все машины сегодня используют арифметику IEEE-754 с плавающей точкой, и почти все платформы отображают плавающие значения Python в IEEE-754 с «двойной точностью». При вводе, компьютер стремится преобразовать 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

Это означает, что точное число, хранящееся в компьютере, равно десятичному значению полученному выше. Вместо отображения полного десятичного значения многие языки, включая более старые версии Python округляют результат до 17 значащих цифр:

>>> format(0.1, '.17f')
'0.10000000000000001'

Модули fractions and 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'
Правильный способ сравнения чисел с плавающей точкой в Python
Правильный способ сравнения чисел с плавающей точкой в Python

Числа с плавающей точкой — это быстрый и эффективный способ хранения и работы с числами, но они имеют ряд подводных камней, которые наверняка ставят в тупик многих начинающих программистов, а возможно, и опытных! Классический пример, демонстрирующий подводные камни плавающих чисел, выглядит следующим образом:

>>> 0.1 + 0.2 == 0.3
False

Увидев это в первый раз, вы можете быть дезориентированы. Но не выбрасывайте свой компьютер в мусорную корзину. Это правильное поведение!

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

Ваш компьютер — лжец (вроде того)

Вы видели, что 0,1 + 0,2 не равно 0,3, но на этом безумие не заканчивается. Вот еще несколько сбивающих с толку примеров:

>>> 0.2 + 0.2 + 0.2 == 0.6
False

>>> 1.3 + 2.0 == 3.3
False

>>> 1.2 + 2.4 + 3.6 == 7.2
False

Проблема также не ограничивается сравнением равенства:

>>> 0.1 + 0.2 <= 0.3
False

>>> 10.4 + 20.8 > 31.2
True

>>> 0.8 - 0.1 > 0.7
True

Так что же происходит? Ваш компьютер обманывает вас? Похоже на то, но под поверхностью происходит нечто большее.

Когда вы вводите число 0.1 в интерпретатор Python, оно сохраняется в памяти как число с плавающей точкой. При этом происходит преобразование. 0,1 — это десятичная дробь по основанию 10, но числа с плавающей точкой хранятся в двоичном формате. Другими словами, 0,1 преобразуется из основания 10 в основание 2.

Полученное двоичное число может не точно представлять исходное число по основанию 10. 0,1 — один из примеров. Двоичное представление 0.000111. То есть, 0,1 является бесконечно повторяющейся десятичной дробью при записи по основанию 2. То же самое происходит, когда вы записываете дробь ⅓ в виде десятичной дроби по основанию 10. В итоге вы получаете бесконечно повторяющуюся десятичную дробь 0.33.

Память компьютера ограничена, поэтому бесконечно повторяющееся двоичное дробное представление 0,1 округляется до конечной дроби. Значение этого числа зависит от архитектуры вашего компьютера (32-битная или 64-битная). Один из способов увидеть значение с плавающей точкой, которое хранится для 0,1, — использовать метод .as_integer_ratio() для плавающих чисел, чтобы получить числитель и знаменатель представления с плавающей точкой:

>>> numerator, denominator = (0.1).as_integer_ratio()
>>> f"0.1 ≈ {numerator} / {denominator}"
'0.1 ≈ 3602879701896397 / 36028797018963968'

Теперь используйте format(), чтобы показать дробь с точностью до 55 знаков после запятой:

>>> format(numerator / denominator, ".55f")
'0.1000000000000000055511151231257827021181583404541015625'

Поэтому 0,1 округляется до числа, которое немного больше его истинного значения.

Эта ошибка, известная как ошибка представления с плавающей точкой (floating-point representation error), случается гораздо чаще, чем вы можете себе представить.

Ошибка репрезентативности действительно распространена

Существует три причины, по которым число округляется при представлении в виде числа с плавающей точкой:

  1. Число имеет больше значащих цифр, чем позволяет плавающая точка.
  2. Число иррационально.
  3. Число рационально, но имеет нетерминированное двоичное представление.

64-битные числа с плавающей точкой имеют 16 или 17 значащих цифр. Любое число с большим количеством значащих цифр округляется. Иррациональные числа, такие как π и e, не могут быть представлены ни одной конечной дробью ни в одной целочисленной базе. Поэтому, опять же, несмотря ни на что, иррациональные числа будут округляться при хранении в виде плавающих чисел.

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

А как насчет рациональных чисел без конца, например, 0,1 в основании 2? Именно здесь вы столкнетесь с большинством проблем с плавающей запятой, а благодаря математике, определяющей, заканчивается ли дробь, вы столкнетесь с ошибкой представления чаще, чем вы думаете.

В основании 10 дробь конечна, если ее знаменатель является произведением степеней простых множителей 10.

Два простых множителя 10 это 2 и 5, поэтому дроби ½, ¼, ⅕, ⅛ и ⅒ все конечны, а ⅓, ⅐ и ⅑ — нет. Однако в основании 2 есть только один простой множитель — 2. Поэтому только дроби, знаменатель которых равен степени 2, имеют конечный результат. В результате дроби типа ⅓, ⅕, ⅙, ⅐, ⅑ и ⅒, выраженные в двоичной форме, являются непересекающимися.

Теперь вы можете понять исходный пример в этой статье. 0.1, 0.2 и 0.3 округляются при преобразовании в числа с плавающей точкой:

>>> # -----------vvvv  Display with 17 significant digits
>>> format(0.1, ".17g")
'0.10000000000000001'

>>> format(0.2, ".17g")
'0.20000000000000001'

>>> format(0.3, ".17g")
'0.29999999999999999'

При сложении 0,1 и 0,2 получается число, немного большее, чем 0,3:

>>> 0.1 + 0.2
0.30000000000000004

Поскольку 0,1 + 0,2 немного больше, чем 0,3, а 0,3 представляется числом, которое немного меньше его самого, выражение 0,1 + 0,2 == 0,3 имеет значение False.

Как сравнивать плавающие числа в Python

Как же бороться с ошибками представления с плавающей точкой при сравнении плавающих чисел в Python? Хитрость заключается в том, чтобы избежать проверки равенства. Никогда не используйте ==, >= или <= с плавающей точкой. Вместо этого используйте функцию math.isclose():

>>> import math
>>> math.isclose(0.1 + 0.2, 0.3)
True

math.isclose() проверяет, близок ли первый аргумент ко второму. Но что именно это значит? Основная идея заключается в том, чтобы проверить расстояние между первым и вторым аргументом, которое эквивалентно абсолютному значению разности этих величин:

>>> a = 0.1 + 0.2
>>> b = 0.3
>>> abs(a - b)
5.551115123125783e-17

Если abs(a — b) меньше некоторого процента от большего из a или b, то a считается достаточно близким к b, чтобы быть «равным» b. Этот процент называется относительным допуском.

Вы можете указать относительный допуск с помощью аргумента rel_tol ключевого слова math.isclose(), который по умолчанию равен 1e-9. Другими словами, если abs(a — b) меньше 1e-9 * max(abs(a), abs(b)), то a и b считаются «близкими» друг к другу. Это гарантирует, что a и b равны примерно до девяти знаков после запятой.

При необходимости вы можете изменить относительный допуск:

>>> math.isclose(0.1 + 0.2, 0.3, rel_tol=1e-20)
False

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

Однако существует проблема, если одно из a или b равно нулю, а rel_tol меньше единицы. В этом случае, как бы близко ненулевое значение ни было к нулю, относительный допуск гарантирует, что проверка на близость всегда будет неудачной. В этом случае использование абсолютного допуска работает как запасной вариант:

>>> # Relative check fails!
>>> # ---------------vvvv  Relative tolerance
>>> # ----------------------vvvvv  max(0, 1e-10)
>>> abs(0 - 1e-10) < 1e-9 * 1e-10
False

>>> # Absolute check works!
>>> # ---------------vvvv  Absolute tolerance
>>> abs(0 - 1e-10) < 1e-9
True

math.isclose() выполнит эту проверку автоматически. Аргумент ключевого слова abs_tol определяет абсолютный допуск. Однако по умолчанию abs_tol равен 0.0, поэтому вам придется задать его вручную, если вам нужно проверить, насколько близко значение к нулю.

В целом, math.isclose() возвращает результат следующего сравнения, которое объединяет относительный и абсолютный тесты в одно выражение:

abs(a - b) <= max(rel_tol * max(abs(a), abs(b)), abs_tol)

math.isclose() была введена в PEP 485 и доступна с Python 3.5.

Когда следует использовать math.isclose()?

В целом, следует использовать math.isclose() всякий раз, когда вам нужно сравнить значения с плавающей точкой. Замените == на math.isclose():

>>> # Don't do this:
>>> 0.1 + 0.2 == 0.3
False

>>> # Do this instead:
>>> math.isclose(0.1 + 0.2, 0.3)
True

Также нужно быть осторожным со сравнениями >= и <=. Обработайте равенство отдельно с помощью math.isclose(), а затем проверьте строгое сравнение:

>>> a, b, c = 0.1, 0.2, 0.3

>>> # Don't do this:
>>> a + b <= c
False

>>> # Do this instead:
>>> math.isclose(a + b, c) or (a + b < c)
True

Существуют различные альтернативы math.isclose(). Если вы используете NumPy, вы можете использовать numpy.allclose() и numpy.isclose():

>>> import numpy as np

>>> # Use numpy.allclose() to check if two arrays are equal
>>> # to each other within a tolerance.
>>> np.allclose([1e10, 1e-7], [1.00001e10, 1e-8])
False

>>> np.allclose([1e10, 1e-8], [1.00001e10, 1e-9])
True

>>> # Use numpy.isclose() to check if the elements of two arrays
>>> # are equal to each other within a tolerance
>>> np.isclose([1e10, 1e-7], [1.00001e10, 1e-8])
array([ True, False])

>>> np.isclose([1e10, 1e-8], [1.00001e10, 1e-9])
array([ True, True])

Имейте в виду, что относительные и абсолютные допуски по умолчанию не совпадают с math.isclose(). Относительный допуск по умолчанию для numpy.allclose() и numpy.isclose() равен 1e-05, а абсолютный допуск по умолчанию для обоих вариантов равен 1e-08.

Метод math.isclose() особенно полезен для модульных тестов, хотя есть и альтернативы. Встроенный в Python модуль unittest имеет метод unittest.TestCase.assertAlmostEqual(). Однако этот метод использует только тест на абсолютное различие. Кроме того, это утверждение, а значит, при сбоях возникает ошибка AssertionError, что делает его непригодным для сравнений в вашей бизнес-логике.

Отличной альтернативой math.isclose() для модульного тестирования является функция pytest.approx() из пакета pytest. В отличие от math.isclose(), pytest.approx() принимает только один аргумент — ожидаемое значение:

>>> import pytest
>>> 0.1 + 0.2 == pytest.approx(0.3)
True

pytest.approx() имеет ключевые аргументы rel_tol и abs_tol для установки относительных и абсолютных допусков. Однако значения по умолчанию отличаются от math.isclose(). rel_tol по умолчанию имеет значение 1e-6, а abs_tol по умолчанию имеет значение 1e-12.

Если аргумент, переданный в pytest.approx(), является массивоподобным, то есть это итерабельность Python, например, список или кортеж, или даже массив NumPy, то pytest.approx() ведет себя аналогично numpy.allclose() и возвращает, равны ли два массива в пределах допусков:

>>> import numpy as np                                                          
>>> np.array([0.1, 0.2]) + np.array([0.2, 0.4]) == pytest.approx(np.array([0.3, 0.6])) 
True

pytest.approx() будет работать даже со словарными значениями:

>>> {'a': 0.1 + 0.2, 'b': 0.2 + 0.4} == pytest.approx({'a': 0.3, 'b': 0.6})
True

Числа с плавающей точкой отлично подходят для работы с числами, когда не требуется абсолютная точность. Они быстрые и занимают мало памяти. Но если вам нужна точность, то следует рассмотреть некоторые альтернативы плавающим числам.

Точные альтернативы с плавающей точкой

В Python есть два встроенных числовых типа, которые обеспечивают полную точность в ситуациях, когда плавающие числа не подходят: Decimal и Fraction.

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

>>> # Import the Decimal type from the decimal module
>>> from decimal import Decimal

>>> # Values are represented exactly so no rounding error occurs
>>> Decimal("0.1") + Decimal("0.2") == Decimal("0.3")
True

>>> # By default 28 significant figures are preserved
>>> Decimal(1) / Decimal(7)
Decimal('0.1428571428571428571428571429')

>>> # You can change the significant figures if needed
>>> from decimal import getcontext
>>> getcontext().prec = 6  # Use 6 significant figures
>>> Decimal(1) / Decimal(7)
Decimal('0.142857')

Подробнее о типе Decimal можно прочитать в документации Python.

Тип дроби

Другой альтернативой числам с плавающей точкой является тип Fraction. Дробь может точно хранить рациональные числа и преодолевает проблемы ошибок представления, с которыми сталкиваются числа с плавающей точкой:

>>> # import the Fraction type from the fractions module
>>> from fractions import Fraction

>>> # Instantiate a Fraction with a numerator and denominator
>>> Fraction(1, 10)
Fraction(1, 10)

>>> # Values are represented exactly so no rounding error occurs
>>> Fraction(1, 10) + Fraction(2, 10) == Fraction(3, 10)
True

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

Заключение

Значения с плавающей точкой — это одновременно и преимущество, и недостаток. Они обеспечивают быстрое выполнение арифметических операций и эффективное использование памяти ценой неточного представления. В этой статье вы узнали:

  • Почему числа с плавающей точкой являются неточными
  • Почему часто встречаются ошибки представления чисел с плавающей точкой
  • Как правильно сравнивать значения с плавающей точкой в Python
  • Как точно представлять числа с помощью типов Python Fraction и Decimal

Возможно вам будет интересно:

Алгоритмы сортировки 26 видов
Алгоритмы и структуры данных на Python
100+ сложных упражнений по программированию на Python

Пытаюсь решать задачку на python, 9 тестов на ОК но на 10м тесте выдает PE (Presentation Error). Какая может быть ошибка здесь?
У Саши и Даши есть карточки с цифрами. На каждой карточке написана ровно одна цифра. При этом у каждого из ребят может быть несколько карточек с одной и той же цифрой. Также может быть, что у кого-то нет ни одной карточки с какой-то цифрой.

Ребята хотят показать одно и то же число с помощью своих карточек (без ведущих нулей). Из всех чисел, которые они могут показать вместе, они хотят показать максимальное. Какое число покажут Саша и Даша?

Формат входных данных
В первой строке вводится набор цифр на карточках Саши без пробелов.

Во второй строке вводится набор цифр на карточках Даши без пробелов.

Гарантируется, что количество карточек у Саши и Даши по отдельности не превосходит 100000.

Формат выходных данных
Выведите максимальное число, которые могут показать Саша и Даша вместе с помощью своих карточек, без ведущих нулей. Если они не могут одновременно показать одно и то же число, то выведите −1.

входные данные
выходные данные
1)

280138
798081

8810

2)

123
456

-1

a = int(input())
b = int(input())
maxx = list(str(max(a, b)))
minn = list(str(min(a, b)))
lst = []
lst1 = []
for i in maxx:
    if i in minn:
        lst.append(i)
        minn.remove(i)

lst1= sorted(lst, key=int, reverse=True)

if lst1:
    print(*lst1, sep='',end="n")
else:
    print(-1)

Автор оригинала: Chris.

Ошибка представления Python: В этой статье объясняется, почему арифметика с плавающей точкой может привести к вам с пути в Python. Арифметика с плавающей точкой не может точно представлять некоторые числа. Хотя многие люди думают, что это ошибка, это на самом деле нет.

Посмотрите на следующий фрагмент кода:

print((0.1 + 0.2) == 0.3)
# False

В фрагменте кода вы предположили, что число 0.1 фактически представляет собой десятичное значение 0,1 или 1/10. (Дайте мне знать, если вы этого не …;)) Это естественно, но неверное предположение. Значение 0.1 не существует на вашем компьютере. Вместо этого ваш компьютер хранит каждый номер как двоичный, состоящий только из нулей и них.

Используйте онлайн-преобразователь для преобразования десятичного значения 0.1 в двоичное значение, и вы поймете, что вы получите следующий номер: 0,0001100110011001100110011001100110011001100110011001101 …

Представление с плавающей точкой 0,1 в двоичном количестве имеет бесконечное количество цифр. Таким образом, ваш компьютер делает единственное, что он может сделать в данный момент: ограничение количества цифр.

Это имеет следующий эффект. Десятичное число 0,1 представлено ближайшим плавающим – Номер точки 0,100000000000000000055511151231257827023125782702118158340454101583404541015625, который можно представить в ограниченном пространстве.

Теперь легко понять, почему 0,1 + 0,2,3. Плавающее – Точка арифметика недостаточно мощной, чтобы правильно представлять все номера.

Обратите внимание, что это распространенная проблема для всех основных языков программирования. Это происходит из-за вариантов дизайна оборудования, а не из-за дизайнеров языка программирования.

Q & A.

Гухан, один из читателей моей серии электронной почты «Coffee Break Python» (Подписаться!), Указал следующий вопрос:

Но почему это происходит только с 0,1 + 0,2? Если вы добавите любое другое десятичное число, например 0,3 или 0,4, вы все еще получаете то, что ожидаете:

>>> print(0.1 + 0.2)
0.30000000000000004
>>> print(0.1 + 0.3)
0.4
>>> print(0.1 + 0.4)
0.5
>>> print(0.1 + 0.5)
0.6
>>> print(0.1 + 0.6)
0.7

Причина следующая: Python не может представлять некоторые Числа точно используют только арифметику с плавающей точкой (из-за причин выше). Однако это не означает, что Python не может представлять Все Числа именно.

x = 0.1
for i in range(10):
    print(x)
    x += 0.1

"""
OUTPUT (Python 3.7.2):
0.1
0.2
0.30000000000000004
0.4
0.5
0.6
0.7
0.7999999999999999
0.8999999999999999
0.9999999999999999
"""

Фрагмент кода показывает, что некоторые числа представляются правильно, другие нет. Обратите внимание, что значение 0,4 основано на неправильно представленном значении 0,3, добавив 0,1 к нему. Причина в том, что математический результат 0,3000000000004 + 0,1 округляется до поплавка 0,4, что является ближайшим числом с плавающей точкой к результату.

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

  • Некоторые из них могут быть правильно представлены поплавкам,
  • другие не могут.

Это не ошибка, ни большая проблема. Это просто жизнь в дискретизированном мире компьютеров.

Куда пойти отсюда?

Если вы хотите глубоко погружаться в эту тему, вы можете прочитать больше о Плавающая точка Представительство здесь Отказ

Также проверьте мою новую книгу программирования: “Кофе-брейк Python Workbook” Отказ Это легко, весело читать и полно 127 головоломков Python для высокоэффективной подготовки.

Узнайте больше на странице книги в моем блоге: «Кофе-брейк Python Workbook: 127 головоломки Python, чтобы оттолкнуть вас от нуля до героя в вашим перерывам на кофе»

Работая в качестве исследователя в распределенных системах, доктор Кристиан Майер нашел свою любовь к учению студентов компьютерных наук.

Чтобы помочь студентам достичь более высоких уровней успеха Python, он основал сайт программирования образования Finxter.com Отказ Он автор популярной книги программирования Python One-listers (Nostarch 2020), Coauthor of Кофе-брейк Python Серия самооставленных книг, энтузиаста компьютерных наук, Фрилансера и владелец одного из лучших 10 крупнейших Питон блоги по всему миру.

Его страсти пишут, чтение и кодирование. Но его величайшая страсть состоит в том, чтобы служить стремлению кодер через Finxter и помогать им повысить свои навыки. Вы можете присоединиться к его бесплатной академии электронной почты здесь.

Понравилась статья? Поделить с друзьями:
  • Ошибка при запуске приложения 0xc000007b itunes
  • Ошибка представительства также называется
  • Ошибка при запуске игры program will be terminated
  • Ошибка при запуске приложения 0xc000007b hoi 4
  • Ошибка предполагается наличие окончания инструкции код 800а0401