Что еще есть кроме int32 и float64?

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

У него есть собственный набор «родных» типов, которые он способен обрабатывать на полной скорости, но он также может работать практически с всем, известным Python.

Статья написана как дополнение к моему руководству NumPy Illustrated и состоит из семи частей:

  1. Целые числа
  2. Поплавки (включая дроби и десятичные дроби)
  3. Булс
  4. Струны
  5. Свидания
  6. Комбинации
  7. Проверка типа

1. Целые числа

Таблица целочисленных типов в NumPy тривиальна для любого, у кого есть минимальный опыт работы с C/C++:

Как и в C/C++, «u» означает «без знака», а цифры представляют количество битов, используемых для хранения переменной в памяти (например, np.int64 — целое число со знаком шириной 8 байт).

Когда вы загружаете Python int в NumPy, он преобразуется в собственный тип NumPy с именем np.int32 (или np.int64 в зависимости от ОС, версии Python и величины инициализаторов):

Если вас не устраивает вариант целочисленного типа, который NumPy выбрал для вас, вы можете явно указать его с помощью аргумента «dtype» (= тип данных), который принимает либо объект dtype np.array([1,2,3], np.uint8), либо строку np.array([1,2,3], ‘uint8’).

Зачем вам нужен нестандартный dtype? Рассмотрим следующий пример (в Windows):

NumPy лучше всего работает, когда ширина элементов массива фиксирована. Это быстрее и занимает меньше памяти, но в отличие от обычного Python int (который работает в арифметике произвольной точности), значения массива будут переноситься, когда они пересекают максимальное (или минимальное) значение для соответствующего типа данных:

— здесь даже не предупреждение!

Со скалярами другая история: сначала NumPy изо всех сил старается преобразовать значение в более широкий тип, затем, если его нет, выдает предупреждение о переполнении (чтобы не заливать вывод предупреждениями — только один раз):

Причина такой дискриминации такова:

В отличие от настоящих ошибок с плавающей запятой (где аппаратный FPU устанавливает флаг всякий раз, когда он выполняет атомарную операцию, которая приводит к переполнению), нам нужно самим реализовать обнаружение целочисленного переполнения. Мы делаем это со скалярами, но не с массивами, потому что это было бы слишком медленно для каждой атомарной операции над массивами. (Роберт Керн, один из основных разработчиков NumPy)

Вы можете превратить это в ошибку:

или временно подавить его:

Или полностью: np.warnings.filterwarnings('ignore', 'overflow')

Но вы не можете ожидать, что он будет обнаружен при работе с любыми массивами.

Вернемся к нашему примеру, правильный способ сделать это — указать правильный dtype:

NumPy также имеет набор псевдонимов в стиле C (например, np.byte — это np.int8, np.short — это np.int16, np.intc — это int с любой шириной, которую тип int имеет в C, и т. д.), но они постепенно выводятся из употребления (например, устаревание np .long в NumPy v1.20.0), поскольку явное лучше, чем неявное (но см. современное использование np.longdouble ниже).

И еще несколько экзотических псевдонимов:

np.int_ равно np.int32 в 64-разрядной версии Windows, но np.int64 в 64-разрядной версии Linux/MacOS, используется для обозначения целого числа по умолчанию. Указание np.int_ (или просто int) в качестве dtype означает «делать то, что вы бы сделали, если бы я вообще не указывал dtype»: np.array([1,2]), np.array([1,2], np.int_) и np.array([1,2], int) — это одно и то же.

np.intp равно np.int32 в 32-битном Python, но np.int64 в 64-битном Python, ≈ssize_t в C, используемом в Cython в качестве типа для указателей.

Иногда случается так, что некоторые значения в массиве ведут себя аномально или отсутствуют, и вы хотите обработать массив, не удаляя их (например, в других столбцах есть допустимые данные).

Вы не можете поместить туда None, потому что оно не подходит для последовательных значений np.int64, а также потому, что 1+None — неподдерживаемая операция.

У Pandas для этого есть отдельный тип данных, но NumPy работает с пропущенными значениями через так называемый замаскированный массив: вы помечаете недопустимые значения логической маской, а затем все операции выполняются так, как если бы значения не были там.

Наконец, если по какой-то причине вам нужны целые числа произвольной точности (Python ints) в ndarrays, numpy тоже может это сделать:

— но без обычного ускорения, так как он будет хранить ссылки вместо самих чисел, продолжать упаковывать/распаковывать объекты Python при обработке и т. д.

2. Поплавки

Поскольку чистый Python float не отличается от стандартизированного IEEE 754 типа C double (обратите внимание на разницу в именах), переход чисел с плавающей запятой от Python к NumPy практически беспроблемный: Python float напрямую совместим с np.float64 и Python complex — с np.complex128.

* Как сообщает np.finfo(np.float<nn>).precision., два альтернативных определения дают цифры 15 и 17 для np.float64, 6 и 9 для np.float32 и т. д.

** На сегодняшний день np.float128 предназначено только для Unix (недоступно в Windows).

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

Предположим, вы вычисляете сигмовидную функцию активации массива, и один из его элементов оказывается равным

Это предупреждение пытается сказать вам, что NumPy знает, что с математической точки зрения 1/(1+exp(-x)) никогда не может быть нулем, но в данном конкретном случае это происходит из-за переполнения.

Такие предупреждения можно «повысить» до исключений или отключить с помощью errstate или filterwarnings, как описано в разделе «целые числа» выше — и, возможно, в данном конкретном случае этого будет достаточно, — но если вы действительно хотите чтобы получить точное значение, вы можете выбрать более широкий dtype:

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

Также точно представимы такие дроби, как 0,5, 0,125, 0,875, где знаменатель представляет собой степень числа 2 (0,5 = 1/2, 0,125 = 1/8, 0,875 = 7/8 и т. д.). Любой другой знаменатель приведет к ошибке округления, так что 0,1+0,2!=0,3.

Стандартный подход решения этой проблемы (как и источника №2 неточности: округления результатов вычислений) заключается в сравнении их с относительным допуском (для сравнения двух ненулевых аргументов) и абсолютным допуском (если один аргументов равно нулю). Для скаляров этим занимается math.isclose(a, b, *, rel_tol=1e-09, abs_tol=0.0), для массивов NumPy есть векторная версия np.isclose(a, b, rtol=1e-05, atol=1e-08). Обратите внимание, что допуски имеют разные имена и значения по умолчанию.

Для финансовых данных удобен тип decimal.Decimal, так как он вообще не предполагает дополнительных допусков:

Но это не серебряная пуля: у него также есть ошибки округления (см. Источник № 2 выше). Единственная проблема, которую он решает, — это точное представление десятичных чисел, к которым привыкли люди.

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

Для чисто математических вычислений можно использовать fractions.Fraction:

Он может точно представлять любые рациональные числа и не подвержен ошибкам округления при вычислениях (источник №2), но π и e не повезло! Если они вам тоже нужны, то SymPy — ваш друг.

И Decimal, и Fraction не являются нативными типами для NumPy, но он способен работать с ними со всеми тонкостями, такими как многомерность и причудливая индексация, хотя и за счет более медленной скорости обработки, чем у нативных ints или floats.

Комплексные числа обрабатываются так же, как и числа с плавающей запятой. Существуют дополнительные удобные функции с интуитивно понятными именами, такие как np.real(z), np.imag(z), np.abs(z), np.angle(z), которые работают как со скалярами, так и со скалярами. массивы в целом. Единственное отличие от чистого Python complex, np.complex_ не работает с целыми числами:

Как и в случае с целыми числами, в массивах с плавающей запятой (и сложных) также иногда полезно рассматривать определенные значения как «отсутствующие». Плавающие лучше подходят для хранения аномальных данных: у них есть значение math.nan (или np.nan, или float(‘nan’)), которое можно хранить вместе с «действительными» числовыми значениями.

Но nan заразен в том смысле, что все арифметические действия с nan приводят к nan. Большинство распространенных статистических функций имеют версию, устойчивую к nan (np.nansum, np.nanstd и т. д.), но другие операции с этим столбцом или массивом потребуют предварительной фильтрации. Маскированные массивы автоматизируют этот шаг: маска может быть построена только один раз, затем она «приклеивается» к исходному массиву, так что все последующие операции видят только немаскированные значения и работают с ними.

Также имена float96/float128 несколько вводят в заблуждение. Под капотом это не __float128, а то, что longdouble означает в местном варианте C++. В x86_64 Linux это float80 (дополненное нулями для выравнивания памяти), что, безусловно, шире, чем float64, но это происходит за счет скорости обработки. Также вы рискуете потерять точность, если непреднамеренно конвертируете в тип Python float. Для лучшей переносимости рекомендуется использовать псевдоним np.longdouble вместо np.float96/np.float128, потому что это то, что в любом случае будет использоваться внутри.

Дополнительные сведения о поплавках можно найти в следующих источниках:

3. Булы

Логические значения хранятся в виде отдельных байтов для повышения производительности. np.bool_ — это отдельный тип от bool Python, потому что он не требует подсчета ссылок и ссылки на базовый класс, необходимых для любого чистого типа Python. Итак, если вы считаете, что использование 8 бит для хранения одного бита информации является чрезмерным, посмотрите на это:

np.bool в 28 раз более эффективно использует память, чем Python bool ) — хотя в реальных сценариях скорость ниже: когда вы упаковываете логические значения NumPy в массив, они будут занимать по 1 байту каждый, но если вы упаковываете логические значения Python в список, они каждый раз ссылаться на одни и те же два значения, что фактически занимает 8 байтов на элемент на x86_64:

Подчеркивания в bool_, int_ и т. д. нужны для того, чтобы избежать конфликтов с типами Python. Плохая идея использовать зарезервированные ключевые слова для других целей, но в этом случае у этого есть дополнительное преимущество, заключающееся в разрешении (обычно не рекомендуется, но полезно в редких случаях) from numpy import * без затенения Python bools, ints и т. д. На сегодняшний день np.bool все еще работает, но отображает предупреждение об устаревании.

4. Струны

Инициализация массива NumPy со списком строк Python упаковывает их в собственный тип NumPy фиксированной ширины с именем np.str_. Резервирование пространства, необходимого для размещения самой длинной строки для каждого элемента, может показаться расточительным (особенно в фиксированной кодировке USC-4, в отличие от «динамического» выбора ширины UTF в Python str).

Аббревиатура ‹U4 происходит от так называемого протокола массива, введенного в 2005 году. Оно означает little-endian строка в кодировке USC-4, длиной 5 элементов (USC-4≈UTF-32, фиксированная ширина, 4 байта на кодировку символов). У каждого типа NumPy есть аббревиатура — такая же нечитаемая, как эта — к счастью, они приняли удобочитаемые имена, по крайней мере, для наиболее часто используемых dtypes.

Другой вариант — сохранить ссылки на Python strs в массиве объектов NumPy:

Объем памяти первого массива составляет 164 байта, второй занимает 128 байт для самого массива + 154 байта для трех Python strs:

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

Если вы имеете дело с необработанной последовательностью байтов, NumPy имеет версию фиксированной длины типа Python bytes, называемую np.bytes_:

Здесь |S5 означает неприменимый порядок следования байтов последовательность байтов длиной 5 элементов».

Еще раз, альтернативой является сохранение Python bytes в массиве объектов NumPy:

На этот раз первый массив занимает 124 байта, второй — те же 128 байт на сам массив + 106 байт на тройку Python bytes:

Мы видим, что str_ снова меньше, но для более разной длины str может одержать победу.

Что касается нативных типов np.str_ и np.bytes_, в NumPy есть несколько общих строковых операций. Они отражают методы str Python, живут в модуле np.char и работают со всем массивом:

Со строками объектного режима циклы должны выполняться на уровне Python:

Согласно моим тестам, основные операции выполняются несколько быстрее с str, чем с np.str_.

5. Дата и время

NumPy представляет интересный собственный тип данных для даты и времени, похожий на временную метку POSIX (также известную как время Unix, количество секунд с 1 января 1970 года), но способный подсчитывать время с настраиваемой степенью детализации — от лет до аттосекунд — неизменно представляемой одним int64 число.

  • Детализация по годам означает «просто посчитайте годы» — никаких реальных улучшений по сравнению с хранением лет в виде целого числа.
  • Детализация по дням эквивалентна datetime.date в Python.
  • Микросекунды — datetime.datetime Python.

И все, что ниже, уникально для np.datetime64.

При создании экземпляра np.datetime64 NumPy выбирает самую грубую степень детализации, которая все еще может содержать такие данные:

Обратите внимание, что инициализатор строки не такой мягкий, как в pd.to_datetime: он должен быть именно в этом формате или его минимальных вариациях (см. общие принципы на странице Википедии ISO 8601).

При создании массива вы решаете, согласны ли вы с гранулярностью, которую NumPy выбрал для вас, или вы настаиваете, скажем, на наносекундах или что-то еще, и это даст вам 2⁶³ равноотстоящих момента, измеренных в соответствующих единицах времени в обе стороны. от 1 января 1970 г.

Можно иметь кратность базовой единицы. Например, если вам нужна точность всего в 0,1 секунды, вам не обязательно хранить миллисекунды:

Чтобы получить машиночитаемое представление детализации без разбора строки dtype:

Как и в чистом Python, когда вы вычитаете один np.datetime64 из другого, вы получаете объект np.timedelta64 (также представленный как один int64 с настраиваемой степенью детализации). Например, чтобы получить количество секунд до Нового года,

Или, если вам не нужна дробная часть, просто

После создания вы мало что можете сделать с объектами datetime или timedelta. Ради скорости количество доступных операций сведено к минимуму: только преобразования и базовая арифметика. Например, нет вспомогательных методов «годы» или «дни».

Чтобы получить конкретное поле из скаляра datetime64/timedelta64, вы можете преобразовать его в обычный datetime:

Для таких массивов

вы можете сделать преобразования между np.datetime64 подтипами (быстрее)

или используйте Pandas (в 2-4 раза медленнее):

Вот полезная функция, которая разлагает массив datetime64 в массив из 7 целочисленных столбцов (годы, месяцы, дни, часы, минуты, секунды, микросекунды):

Пара ошибок с datetime:

  1. Несмотря на то, что високосные годы поддерживаются,

високосные секунды (неотъемлемая часть как UTC, так и обычного настенного времени) не являются:

Справедливости ради, ни datetime.datetime, ни даже pytz их тоже не считают (хотя в целом можно извлечь информацию о них с помощью pytz). Модуль time поддерживает их только формально (принимает 60-ю секунду, но выдает неверные интервалы).

Похоже, пока только астропия правильно их обрабатывает,

другие придерживаются пролептического григорианского календаря с его ровно 86400 секундами SI в сутки, который уже имеет примерно полминутную разницу со временем стены с 1970 года из-за неравномерности вращения Земли.

Практические последствия использования этого календаря:
 – ошибка при расчете интервалов, включающих одну или несколько дополнительных секунд.
 – исключение при попытке построить дату/время64 из метки времени, взятой в течение дополнительной секунды.

2. Поскольку и np.datetime64, и np.timedelta64 имеют одинаковую ширину, следует соблюдать осторожность при больших временных дельтах:

Наконец, обратите внимание, что все значения времени в np.datetime64 «наивны»: они не «осведомлены» о переходе на летнее время (поэтому рекомендуется хранить все даты и время в формате UTC) и не могут быть преобразованы из одного часового пояса в другой (используйте pytz для преобразования часового пояса):

6. Их комбинации

«Структурированный массив» в NumPy — это массив с пользовательским dtype, созданным из типов, описанных выше, в качестве основных строительных блоков (аналогично struct в C). Типичным примером является цвет пикселя RGB: тип длиной 3 байта (обычно 4 для выравнивания), в котором цвета доступны по имени:

Чтобы получить доступ к полям как к атрибутам, можно использовать np.recarray:

Здесь это работает так же, как reinterpret_cast в C++, но, конечно же, recarray может быть создан сам по себе, не являясь представлением чего-то другого.

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

Со структурированными массивами и повторными переносами можно получить «внешний вид» базового фрейма данных Pandas:

  • вы можете обращаться к столбцам по именам,
  • сделать с ними некоторые арифметические и статистические расчеты,
  • вы можете эффективно обрабатывать пропущенные значения,
  • некоторые операции в NumPy выполняются быстрее, чем в Pandas

Но им не хватает:

  • группировка (кроме того, что предлагает itertools.groupby)
  • могучий индекс панд и MultiIndex (поэтому нет сводных таблиц) и
  • другие тонкости вроде удобной сортировки и т.д.

Суть в том, что, несмотря на то, что этот синтаксис удобен для адресации отдельных столбцов в целом, ни структурированные массивы, ни повторные переносы — это не то, что вы хотели бы использовать в самом внутреннем цикле кода с интенсивными вычислениями:

Примечание. Профилирование кода Python иногда может быть нелогичным: изменение x**2 на x*x может привести к тому, что код будет работать в 1,5 раза быстрее или медленнее в зависимости от характера x.

7. Проверка типа

Один из способов проверить тип массива NumPy — запустить isinstance для его элемента:

Все типы NumPy взаимосвязаны в дереве наследования, отображаемом в верхней части статьи (синий = абстрактные классы, зеленый = числовые типы, желтый = другие), поэтому вместо указания целого списка типов, например isinstance(v, [np.int32, np.int64, etc]), вы можете написать более компактные проверки типов. нравиться

Недостатком этого метода является то, что он работает только с значением массива, а не с самим массивом. Что бесполезно, например, когда массив пуст. Проверить тип массива сложнее.

Для базовых типов оператор == выполняет работу для одной проверки типа:

и оператор in для проверки группы типов:

Но для более сложных типов, таких как np.str_ или np.datetime64, это не так.

Рекомендуемый способ⁴ проверки dtype на соответствие абстрактным типам:

Он работает со всеми нативными типами NumPy, но необходимость этого метода выглядит несколько неочевидной: что не так со старым добрым isinstance? Очевидно, сложность структуры наследования dtypes (они строятся «на лету»!) не позволяли делать это по принципу наименьшего удивления.

Если у вас установлен Pandas, его инструменты проверки типов также работают с dtypes NumPy:

Еще один метод заключается в использовании (недокументированного, но используемого в кодовых базах SciPy/NumPy, например здесь) словаря np.typecodes. Дерево, которое он представляет, менее разветвленное:

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

Обратите внимание, что использование a.dtype.kind вместо a.dtype.char является ошибкой: np.zeros(1, dtype=np.uint8).dtype.kind == ‘u’ отсутствует в np.typecodes, а там указано <…>.char == ‘B’.

Недостатком этого метода является то, что логические значения, строки, байты, объекты и пустоты («?», «U», «S», «O» и «V» соответственно) не имеют выделенных ключей в словаре. .

Этот подход выглядит более хакерским, но менее волшебным, чем issubdtype.

Я хотел бы поблагодарить членов команды NumPy за их помощь в поиске опечаток и за продуктивное обсуждение некоторых передовых концепций.

Рекомендации

  1. Рики Ройссер, Вычисления с плавающей запятой половинной точности, визуализация

2. Руководство по плавающей запятой https://floating-point-gui.de/

3. Дэвид Голдберг, Что каждый программист должен знать об арифметике с плавающей запятой, Приложение D

4. Проблема NumPy # 17325. Добавьте канонический способ определить, является ли dtype целым, с плавающей запятой или сложным.

Лицензия

CC BY-NC 4.0 (=вы делитесь и адаптируете, пока указываете авторство, не зарабатываете на этом деньги и сохраняете ту же лицензию).