Метка: python

О точности float в Python

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

Кто-то, вероятно, слышал о проблеме 0.1 + 0.1 + 0.1 == 0.3. Вкратце, вбейте в интерпретаторе Python:

>>> 0.1 + 0.1 + 0.1 == 0.3
False

Здравый смысл подсказывает нам, что здесь что-то не так, должно же равняться! Новичков это вообще может вбить в ступор. Программисты поопытнее могут объяснить это ошибками округления float-чисел. Давайте же разберемся, что на самом деле там происходит.

Экспоненциальное представление чисел

Стандарт IEEE-754 регулирует, как должны представляться вещественные числа в железе (процессорах, сопроцессорах и так далее) и программном обеспечении. Так много вариантов представлений, но на практике почти везде сейчас используются числа с плавающей точкой одинарной или двойной точности, причем оба варианта с основанием 2, это важно.

Плавающая точка

Почему точка плавающая? Потому что числе представлены внутри компьютера экспоненциальном формате:

Число = ±мантисса * основаниеэкпонента

Меняя экспоненту можно двигать положение точки в любую сторону. Например, если основание было бы 10, то числа 1,2345678; 1 234 567,8; 0,000012345678; 12 345 678 000 000 000 отличались бы только экспонентой.

float в Python

float – встроенные тип в Python (CPython) и представляет собой число с плавающей точкой двойной точности, независимо от системы и версии.

float в Python – это double из C, C++, C# или Java и имеет 64 бита (8 байт) для хранения данных о числе.

Примечание: сторонними библиотеками можно получить и другие типы, а еще есть Decimal.

В эти 64 бита упакованы как 11 бит на экспоненту и 52 бита на мантиссу (+ 1 бит на знак, итого 53). Вот так:

Расположение бит мантиссы и экспоненты в 64 битах числа с плавающей точкой

Думаете, любое реальное число можно представить, используя эти 64 бита? Конечно, нет. Простая комбинаторика скажет, что у нас может быть не более 264 разных чисел (64 позиции по 2 варианта), а на деле их и того меньше. Диапазон чисел, представимых таким форматом составляет: ±1.7*10-308 до 1.7*10+308. То есть от очень малых по модулю чисел, до очень больших. Допустимые числа на числовой прямой распределены неравномерно: гуще в районе нуля и реже в районе огромных чисел.

Распределение чисел в представлении не равномерно. График показывает, как найти 0.6.
Здесь про 0.6, но смысл тот же.

Источник ошибок

Откуда же берутся ошибки?

Дело в том, что числа 0.1 – нет! Действительно, нет способа представить это немудреное число в формате с плавающей точкой с основанием 2!

0.1 – это просто текст, для которого Python должен подобрать максимально близкое представление в памяти компьютера. Можно найти число поменьше или побольше, но точно 0.1 – не получится. Все дело в основании 2 – именно двойка фигурирует под степенью. Надо подобрать такие J и N, чтобы получить число максимально близкое к 0.1:

0.1 = 1 / 10 ≈ J / (2**N)

или

J ≈ 2**N / 10

При этом в J должно быть ровно 53 бита. Наиболее подходящие N для такого случая равняется 56.

>>> 2**52 <= 2**56 // 10 < 2**53
True

>>> divmod(2**56, 10)
(7205759403792793, 6)

Остаток от деления – 6 чуть больше половины делителя (10), поэтому наилучшее приближение будет, если мы округлим частное вверх, то есть добавим к 7205759403792793 + 1 = 7205759403792794. Таким образом, это будет ближайшее к 0.1 число, возможное в представлении float. Доказательство проверкой:

>>> 7205759403792794 / 2 ** 56 == 0.1
True

Оно чуть больше, чем реальное 0.1. Если бы мы не прибавили единицу, то получилось бы число чуть меньшее, чем 0.1, но никакое сочетание J и N не даст нам ровно 0.1 ни в едином случае!

>>> format(7205759403792793 / 2 ** 56, '.56f')
'0.09999999999999999167332731531132594682276248931884765625'

>>> format(7205759403792794 / 2 ** 56, '.56f')
'0.10000000000000000555111512312578270211815834045410156250'

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

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

>>> format(0.1 + 0.1 + 0.1 - 0.3, '.56f')
'0.00000000000000005551115123125782702118158340454101562500'

Она мала, но она есть! Именно поэтому никогда не советуют точно сравнивать числа типа float, даже если для вас они равны, их представления могут отличаться, если числа получены разным путем. Могут отличаться, а могут и совпадать! Так что это может сыграть злую шутку.

>>> 0.15 + 0.15 == 0.3
True
>>> 0.1 + 0.15 + 0.05 == 0.1 + 0.1 + 0.1
False
>>> 0.1 + 0.15 + 0.05
0.3
>>> 0.1 + 0.1 + 0.1
0.30000000000000004

Там есть функция, которая делает более аккуратное сложение IEEE-754 чисел, но она тоже работает не идеально. На примере из документации – отлично и проваливается на нашем пресловутом триплете из 0.1:

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

# не тут то было!
>>> math.fsum([0.1] * 3) == 0.3
False

Поверьте, это не единственная особенность такого представления чисел. Я обязательно расскажу больше. Будьте на связи!

Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈 

Генераторные выражения

Edison dynamo - КДПВ

Мы говорили про map и itertools.starmap, но я тут подумал… Зачем они, если есть замечательные генераторные выражения:

  • Они умеют делать: генераторы, списки list, словари dict и множества set.
  • Поддерживают вложенные циклы для обработки многомерных данных
  • Умеют фильтровать данные, как filter
  • Обладают лаконичным и понятным синтаксисом

По-английски они называются в зависимости от типа данных на выходе: generator expressions и list/dictionary/set comprehensions.

Если нам нужен генератор, то ставим круглые скобки. Если нужен сразу список – квадратные. Если нужен словарь или множество – фигурные. А внутри цикл for/in. Наш «прибавлятор» единицы стал короче и без лямбд:

>>> list(map(lambda x: x + 1, [1, 2, 3, 4]))
[2, 3, 4, 5]

>>> [x + 1 for x in [1, 2, 3, 4]]
[2, 3, 4, 5]

Пример на замену starmap не то чтобы сильно короче, но значительно понятнее, потому что виден фактический вызов pow и разумные имена переменных:

>>> from itertools import starmap
>>> list(starmap(pow, [(2, 4), (3, 2), (5, 2)]))
[16, 9, 25]

>>> [pow(base, exp) for base, exp in [(2, 4), (3, 2), (5, 2)]]
[16, 9, 25]

Если нужно множество (коллекция без повторов), то все то же самое, но скобки фигурные. Пример: все уникальные буквы слова:

>>> {r for r in 'BANANA'}
{'N', 'B', 'A'}

Если нужен словарь, то скобки также фигурные, но генерируем парами «ключ: значение». Пример: ключ – строка, значение – строка задом наперед:

>>> {key: key[::-1] for key in ["Mama", "Papa"]}
{'Mama': 'amaM', 'Papa': 'apaP'}

Наконец, если нужен генератор, то скобки круглые. Генератор вычисляет и выдает значения лениво (по одному, когда они требуются):

>>> g = (x ** 2 for x in [1, 2, 3, 4])
>>> next(g)
1
>>> print(*g)
4 9 16

Если функция принимает ровно 1 аргумент, то передавая в нее генератор можно опустить лишние круглые скобки:

>>> sum(x ** 2 for x in [1, 2, 3, 4])
30

Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈 

starmap – это не звездная карта!

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

>> list(map(lambda x: x + 1, [1, 2, 3, 4]))
[2, 3, 4, 5]

Что делать, если нужно применить функцию, которая принимает большее количество аргументов? Например, возведение в степень pow принимает основание и показатель:

>>> pow(2, 4)
16

Как и требуют, мы даем в map функцию с одним аргументом, но каждый элемент t – кортеж из двух элементов, мы распаковываем его в аргументы pow звездочкой:

>>> list(map(lambda t: pow(*t), [(2, 4), (3, 2), (5, 2)]))
[16, 9, 25]

Если вы не знали: pow(*t) то же самое, что и pow(t[0], t[1]), если в t два элемента.

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

>>> from itertools import starmap
>>> list(starmap(pow, [(2, 4), (3, 2), (5, 2)]))
[16, 9, 25]
Схема работы map и starmap показывает как передаются аргументы

🐉 Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈

PyBuka

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

Для работы нужен pygame (pip install pygame). Запустить плеер можно из терминала (первый аргумент – ритм, второй – число ударов в минуту):

python pybuka.py "D-T---T-D---T-tkD-T---T-D--kS---" 160

D – низкий глубокий удар

T – звонкий громкий удар об обод

t или k – звонкие, но тише, чем T

S – слэп (удар плашмя по центру)

Дефис – пауза.

Особенность воспроизведения звука в pygame: для каждого типа удара о барабан создается отдельный канал channel = mixer.Channel(ch_id), чтобы рядом стоящие по времени ноты не мешали друг другу.

Пример записи звука прилагается.

Ссылка на исходник проекта на GitHub.  

⭐ Если вам понравился проект, поставьте звездочку, пожалуйста. Вам не сложно, а мне очень приятно и огромная мотивация для развития своих проектов с открытым исходным кодом.

🐉 Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈

Перенос строк кода Python

Подписывайтесь на мой канал в Телеграм @pyway , чтобы быть в курсе о новых статьях!

PEP-8 не рекомендует писать строки кода длиннее, чем 79 символов. С этим можно не согласиться, однако, встречаются строки, которые не влезают даже на наши широкоформатные мониторы.

👨‍🎓 Старайтесь не делать очень длинные строки, разбивая сложные условия или формулы на отдельные части, вынося их в переменные или функции с осмысленными названиями.

Если есть острая необходимость иметь длинное выражение, тогда приходится переносить код на следующие строки. Можно делать двумя способами: скобками и слэшем. 

Если, перед выражением открыта скобка (круглая, квадратная или фигурная в зависимости от контекста), но она не закрыта в этой строке, то Python будет сканировать последующие строки, пока не найдет соответствующую закрывающую скобку (англ. implicit line joining). Примеры:

# вычисления
income = (gross_wages
          + taxable_interest
          + (dividends - qualified_dividends)
          - ira_deduction
          - student_loan_interest)

if (student_loan_interest > ira_deduction
        and qualified_dividends == 0):
    ...

# словари
d = {
    "hello": 10,
    "world": 20,
    "abc": "foo"
}

# аргументы функции
some_func(arg1,
    arg2,
    more_arg,
    so_on_and_on)

Обратите внимание, что в первом примере скобки очень важны. Без скобок код не скомпилируется из-за отступов, а если их убрать, то результат будет неверен: income станет gross_wages, а последующие строки не будут иметь эффекта!

# неправильно!
income = gross_wages
+ taxable_interest
+ (dividends - qualified_dividends)
- ira_deduction
- student_loan_interest

Метод переноса обратным слэшем. Ставим обратный слэш конце строки и сразу энтер (перенос строки): тогда следующая строка будет включена в текущую (англ. explicit line joining), не взирая на отступы, как будто бы они написаны в одну строку:

income = gross_wages \
         + taxable_interest \
         + (dividends - qualified_dividends) \
         - ira_deduction \
         - student_loan_interest

Еще примеры со слэшем:

if student_loan_interest > ira_deduction \
        and qualified_dividends == 0:
    ...

# допустимо, согласно PEP-8
with open('/path/to/some/file/you/want/to/read') as file_1, \
     open('/path/to/some/file/being/written', 'w') as file_2:
    file_2.write(file_1.read())

# пробелы в строку попадут, а энтер - нет!
str = "Фу\
      < вот эти пробелы тоже в строке"

Почему скобки лучше для переноса:

  • Лучше восприятие
  • Скобок две, а слэшей надо по одному на каждый перенос
  • Можно забыть слэш и сломать код
  • Можно поставить пробел после слэша и тоже сломать

🐉 Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈