Как эффективно загрузить данные и вернуться к анализу!

В реальном мире данные не всегда упакованы в аккуратные и удобные для загрузки файлы. Иногда ваши данные будут жить в малоизвестных двоичных или нерегулярно структурированных текстовых форматах и ​​попадут к вам на порог без каких-либо эффективных загрузчиков на основе Python. Для небольших объемов данных обычно легко собрать собственный загрузчик, используя простой собственный Python. Но для больших данных решения на чистом Python могут стать неприемлемо медленными, и в этот момент пора вкладывать средства в создание чего-то более быстрого.

В этой статье я покажу вам, как использовать комбинацию встроенных функций, C-API и Cython, чтобы быстро и легко собрать собственный сверхбыстрый пользовательский загрузчик данных для NumPy / Pandas. Сначала мы рассмотрим общую структуру, которая часто используется для хранения двоичных данных, а затем напишем код для загрузки некоторых образцов данных. Попутно мы кратко рассмотрим C-API и протокол буфера Python, чтобы вы поняли, как работают все части. Здесь много всего, но не волнуйтесь - все очень просто, и мы позаботимся о том, чтобы наиболее важные части кода были общими и могли использоваться повторно. Вы также можете следить за рабочим блокнотом здесь. Когда мы закончим, вы сможете легко адаптировать код к вашему конкретному формату данных и вернуться к анализу!

Форматы двоичных данных

Для наших целей файл двоичных данных - это не что иное, как большой массив байтов, который кодирует ряд элементов данных, таких как целые числа, числа с плавающей запятой или символьные массивы. Хотя существует множество форматов двоичного кодирования, один общий формат состоит из серии отдельных «записей», хранящихся друг за другом. Внутри каждой записи первые байты обычно кодируют заголовок, который определяет длину (в байтах) записи, а также другую идентифицирующую информацию, которая позволяет пользователю декодировать данные.

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

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

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

Загрузить двоичные данные с одним типом записи

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

  • первые 4 байта кодируют 32-битное целое число
  • следующие 5 байтов кодируют массив символов

Сначала мы загрузим наши данные в массив NumPy, и после этого это всего лишь один лайнер для создания Pandas DataFrame.

Единственная сложность здесь заключается в том, что массивы NumPy могут содержать данные только одного типа, в то время как наши данные содержат как целые числа, так и символьные массивы. К счастью, numpy позволяет нам определять структурированные типы с несколькими подкомпонентами. Итак, что мы делаем, так это создаем dtype NumPy, который имеет ту же структуру, что и наши двоичные записи. Если вы хотите прочитать документацию по dtype NumPy, вы можете сделать это здесь, но указать dtype действительно довольно просто. Вот dtype, который соответствует формату нашего примера двоичных данных:

Определив наш dtype, мы можем продолжить и загрузить данные с помощью всего нескольких строк:

И это все! Что может быть проще, правда? Одна маленькая вещь, о которой следует позаботиться, заключается в том, что столбец name в наших данных содержит объекты типа bytes. Мы, вероятно, предпочли бы иметь строки, поэтому давайте воспользуемся методом Series.str.decode () для преобразования объектов bytes в str:

В приведенных выше фрагментах мы сначала загрузили наш двоичный файл в массив байтов, а затем создали массив NumPy с функцией np.frombuffer. В качестве альтернативы вы можете объединить эти два шага с помощью функции np.fromfile, но иногда бывает полезно вручную копаться в двоичных данных и копаться в них. Если вам нужно быстрое введение или освежение знаний о том, как управлять и просматривать байтовые данные в Python, взгляните на эту записную книжку, которую я создал в качестве краткого руководства для этой статьи.

А как насчет двоичных данных с несколькими типами записей?

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

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

В общем, чтобы загрузить двоичные данные в NumPy, нам нужно разделить их на один или несколько однородных массивов, как показано ниже:

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

Поэтому вместо того, чтобы записывать отдельные файлы, мы покажем, как настроить массивы памяти в Cython, по одному для каждого типа записей, который нас интересует, и эффективно заполнить их нашими двоичными записями. Затем мы предоставим эти массивы NumPy, используя протокол буфера из Python C-API. Мы могли бы сделать все это на собственном Python, но мы будем использовать Cython, потому что хотим, чтобы наше решение было быстрым (двоичные файлы иногда бывают довольно большими).

Python C-API и буферный протокол

Python C-API - это вход в более низкоуровневую реализацию Python. Он позволяет программистам расширять Python кодом, написанным на C / C ++, а также позволяет встраивать Python в другие языки программирования. Однако нам не нужно много знать о C-API. Все, что нам нужно, это понимание протокола буферизации на высоком уровне.

Протокол буфера работает на уровне C-API и определяет способ, которым объекты Python могут получать доступ и совместно использовать память друг друга. Когда мы вызываем np.frombuffer для объекта, который реализует протокол буфера, NumPy переходит в C-API и запрашивает у объекта представление его внутренней памяти. В случае успеха NumPy переходит к настройке массива с использованием общих данных. Обратите внимание, что здесь не происходит копирование! После вызова np.frombuffer и исходный буферный объект, и массив NumPy используют одну и ту же базовую память. Упрощенная версия процесса выглядит примерно так:

Вместо того, чтобы использовать C-API напрямую, мы собираемся взаимодействовать с C-API через Cython, потому что это намного проще, чем писать код непосредственно на C / C ++. Как вы увидите, также очень легко реализовать буферный протокол из Cython.

Cython: реализовать протокол буфера

Cython - это расширение Python, которое представляет собой комбинацию Python и C / C ++. Код, скомпилированный из Cython, часто работает намного быстрее, чем собственный Python, и дает вам возможность использовать функции и классы из библиотек C / C ++. Мы не будем вводить Cython в этой статье, но есть несколько вводных руководств - например, здесь и здесь.

К счастью, реализовать буферный протокол из Cython очень просто. Все, что нам нужно сделать, это реализовать два метода __getbuffer__ и __releasebuffer__. За кулисами Cython обрабатывает их по-особенному, чтобы они были правильно привязаны к нашему объекту в C-API, но нам не нужно об этом беспокоиться. Все, что нам нужно сделать, это реализовать два метода, и в нашем случае они оба довольно просты. Вот что они делают:

__getbuffer __ (self, Py_buffer *, int) Этот метод будет вызываться любым объектом-потребителем, который хочет получить представление о нашей памяти. Он имеет два аргумента: целое число битовых флагов и указатель на объект типа Py_buffer, который представляет собой простую структуру C, содержащую поля, которые нам необходимо заполнить. Флаги указывают подробности о формате данных, который ожидает потребитель. В нашем случае мы будем поддерживать только простейший тип, который представляет собой одномерные данные, хранящиеся в непрерывном блоке памяти. Итак, все, что нам нужно сделать в __getbuffer__, - это проверить, что флаги указывают на простой буфер, а затем заполнить несколько понятных полей в структуре Py_buffer (см. Код ниже).

__releasebuffer __ (self, Py_buffer *) Цель __releasebuffer__ - разрешить подсчет ссылок, чтобы наш код знал, когда он может освободить и / или перераспределить память в структуре Py_buffer. Однако NumPy не уважает это и ожидает, что буферы сохранят свои данные даже после вызовов __releasebuffer__. Так что в нашем случае нам фактически не нужно здесь ничего делать.

Самый простой способ использовать Cython из ноутбука Jupyter - сначала загрузить Cython, как показано ниже. Вам может потребоваться сначала установить Cython. Как всегда, рассмотрите возможность использования виртуальной среды.

Затем вы вводите код Cython в отдельную ячейку, начиная с IPython magic %%cython -cplus. Здесь мы определяем класс SimplestBuffer, который реализует протокол буфера и может также использоваться из Python. Этот класс является универсальным многоразовым контейнером, который просто хранит двоичные данные и разрешает доступ через протокол буфера, чтобы NumPy мог совместно использовать данные.

С нашим новым блестящим классом SimplestBuffer мы можем повторить наш предыдущий пример следующим образом:

Если вы зашли так далеко, поздравляем! Вся тяжелая работа сделана. Мы узнали, как загружать структурированные двоичные данные в NumPy, а также использовали Cython для создания контейнера для данных, к которым можно эффективно получить доступ через np.frombuffer.

Загрузка двоичных данных с несколькими типами записей

В качестве последней задачи мы будем использовать Cython для создания функции быстрого синтаксического анализа данных fan_bytes, которая специализируется на нашем двоичном формате данных. Функция принимает наши входные двоичные данные как массив байтов и два дополнительных объекта SimplestBuffer. Он использует некоторую простую арифметику указателя C для пошагового выполнения нашего двоичного файла и разветвляет записи на один или другой из SimplestBuffer объектов в зависимости от значения msg_type. Если в файле есть записи с msg_type, отличным от 1 или 2, они будут пропущены. Обратите внимание, что мы также повторили определение SimplestBuffer в этой ячейке, чтобы Cython мог его найти.

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

Так что прогресс !!! На этом этапе мы успешно загрузили двоичный файл, содержащий смешанные типы записей, в два DataFrames, по одному для каждого типа записи.

Несколько небольших расширений

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

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

Во-вторых, мы должны разрешить предварительное выделение памяти в буфере и возможность читать байты непосредственно из файла. Однако обратите внимание, что вектор C ++ уже достаточно эффективен для перераспределения памяти, а что касается чтения из файлов, часто быстрее прочитать все двоичные данные в промежуточный буфер перед обработкой, чем выполнять множество небольших операций чтения в файловой системе. Тем не менее, обе эти функции легко реализовать и могут привести к ускорению.

И, наконец, часто бывает полезно создавать загружаемые модули из Cython, а не помещать весь Cython в записные книжки Jupyter.

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

Заключительные замечания

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

Скорость оценки. Мы не проводили здесь сравнительного тестирования, но в своих тестах я обнаружил, что загрузка двоичных данных с использованием вышеуказанных методов примерно такая же быстрая, как загрузка эквивалентных DataFrames из консервированных двоичных файлов, а иногда и это даже быстрее! Однако одна область, которая не является быстрой, - это преобразование байтовых массивов в строки с использованием pd.Series.str.decode(‘utf-8’). По моему опыту, это преобразование часто является самой медленной частью загрузки двоичных данных. Поэтому вы можете захотеть просто оставить некоторые или все ваши символьные данные в виде байтовых массивов, а не преобразовывать их в собственные строковые объекты.

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

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

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