Как быстро и удобно ворочать большими числовыми массивами в Python (часть 1)

Введение

В статье речь пойдет о базовых технических аспектах обработки больших числовых массивов в Python. Статья ориентирована на начинающих data scientists, которые используют Python. Я покажу основные подходы и инструменты, но не буду сильно останавливаться на деталях (они есть в документации), иначе статья получится слишком большой. =)

На каких данных начать тренироваться новичку? Я считаю хорошим источником данные рынка акций. Источников этих данных предостаточно (где и как их найти я расскажу в конце статьи) и это просто рай для начинающих ученых в области данных. А самое главное, они “живые”, можно разработать стратегию управления портфелем и проверить ее, подождав несколько дней. Мы будем использовать финансовые данные рынка американских акций как источник.

Будем разрабатывать простейшую инвестиционную стратегию на основе скользящих средних (ema), но на довольно больших объемах данных.

Мы сосредоточимся на технических аспектах реализации одной из инвестиционных стратегий.

ИИтак, задача

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

Дано: финансовые данные (open, high, low, close, volume, split, divs) ~2000 акций за последние ~20 лет.

Нужно рассчитать распределение весов акций в портфеле для каждого дня, если разрешено покупать акции только с положительным ema(20) от close, а соотношение весов должно соответствовать соотношению ema(30) от волатильности (close*volume).

EMA - экспоненциальное скользящее среднее (https://en.wikipedia.org/wiki/Moving_average#Exponential_moving_average).

Ограничения: расчеты должны занимать менее 10 секунд и потреблять менее 1.5 GB памяти.

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

ГГрузим данные

ВВ лоб

Допустим, вы каким-то образом получили эти данные в виде ~2000 csv файлов по каждой акции (где именно и как получить данные я расскажу в конце статьи), в следующем формате:

Первый шаг - это загрузить данные в память для дальнейшей обработки. Тот, кто только что изучил Python, сразу ломанется писать что-то наподобие:

Попробуем это запустить (заодно замеряем потребление ОЗУ и время):

Только загрузка сожрала 3.4GB памяти и потратила 1 минуту 41 секунды. Это у меня-то, с Intel SSD 760p и процессором Intel i5 8300H!!!1 А ведь суммарный размер этих файлов на жестком диске всего около 0.6GB!

ЧЧто так долго и куда столько памяти?

Ответ на первую часть - парсинг чисел и дат на Python (да и вообще обработка больших массивов данных) далеко не самая быстрая операция. Парсить такие объемы очень долго, нужно что-то пошустрее.

Ответ на вторую часть: каждый объект python сам по себе жрет довольно много памяти, а ведь каждое число или дата - это объект. Даже если все затолкать в один линейный список, по памяти все равно не уложиться.

Ах, вот если бы можно было использовать числовые массивы напрямую размещенные в памяти как в С!… C?!.. Oh, shi… У нас есть возможность использовать такие массивы, для этого есть библиотека numpy и ее ndarray (https://docs.scipy.org/doc/numpy/reference/generated/numpy.ndarray.html).

numpy.ndarray позволяет размещать числовые (и не только числовые) массивы с элементами одного типа напрямую в памяти. Эти массивы, размещенные напрямую в памяти тем и хороши, что для их работы требуется мало накладных расходов (числа укладываются последовательно друг за другом в памяти без дополнительных расходов). По моим прикидкам это должно быть примерно 0.5GB (если использовать double)

ppandas.DataFrame

Но не спеши хватать голый ndarray. Как же быть с датами? Для удобной работы с датами нужен индекс по датам. Если погуглить какое-нибудь решение для ndarray и time series, то можно очень быстро наткнуться на pandas.DataFrame. pandas.DataFrame базируется на numpy.ndarrays, штука довольно удобная и говорят, хорошо работает с датами в качестве основного индекса. До кучи, у этой библиотеки есть какая-то встроенная поддержка загрузки CSV и все рекомендуют. Ништяк, берем эту либу и переписываем код загрузки данных:

Попробуем это запустить, заодно замеряем память и время:

Что-то около 16 секунд и 1.3GB памяти (???). Памяти и времени сожрало хоть и меньше, но все равно довольно много. А ведь даже вычислений еще и не делали, так не пойдет. Давайте разберемся.

Если взглянуть на документацию pandas внимательнее и узнать больше об устройстве DataFrame, то можно узнать, что он базируется не на двумерном массиве ndarray. Это скорее надстройка на dict(), где ключи - названия столбцов, а сами столбцы - это np.ndarray. По сути, для набора данных создано примерно 2000*8 = 16000 массивов ndarray, а это тоже немалые накладные расходы. Так не пойдет.

xxarray.DataArray

Ах, вот если бы была какая-нибудь надстройка над многомерным ndarray с поддержкой индекса с датами и такая же удобная как pd.DataFrame!... Подожжи… И такая надстройка есть, называется она xarray.DataArray (http://xarray.pydata.org/en/stable/generated/xarray.DataArray.html). Почему-то, незаслуженно, люди используют ее гораздо реже (может потому, что она появилась на 3-4 года позже?) Если сказать честно, эта надстройка даже удобнее чем pd.DataFrame для вычислений (что я покажу позже).

Почитав внимательно доку по xarray.DataArray, мы узнаем, что в него можно положить больше двух измерений, а значит мы и данные по всем тикерам можем собрать в один большой массив. Почитав еще немного доки, узнаем что для загрузки и сохранения данных в xarray можно использовать бинарный формат NetCDF (scipy). Ништяк, пишем конвертер данных из кучи файлов CSV в один многомерный NetCDF ( а я и не говорил, что данные обязательно в CSV оставлять 8-) ).

Запускаем его, смотрим что получилось. На выходе получился один файл data.nc объемом примерно те же 0.6GB. Ок, грузим его в xarray.

Вот так уже норм. Загрузка в пике схавала 1.2GB, но после загрузки в памяти массив данных занял 0.6GB (почти в 2 раза меньше). Это чуть больше, чем я рассчитывал, но приемлемо. Времени это заняло около секунды. Идем дальше с xarray.

Небольшой комментарий. У нас данные все одного типа - double (кроме дат и названий ассетов, но они выступают индексами), потому их и удобно уложить в xarray.DataArray. Но если данные были бы разных типов (разные типы для каждого столбца field), то, возможно, удобнее было бы использовать xarray.DataSet (это логическое продолжение DataFrame для более чем 2 измерений) или несколько DataArray. Например, volume можно уложить в целочисленный массив (но для дальнейшего анализа и вычислений это не имеет особого смысла). pandas.DataFrame, хорошо подходит для данных, где столбцы имеют разный тип и вам нужно только 2 измерения. Однако у нас их три (название тикера, дата, столбец данных). Для более обдуманного применения надо знать как устроены все эти структуры (numpy.ndarray, pandas.Series, pandas.DataFrame, xarray.DataArray, xarray.DataSet и т.д.).

ППромежуточный итог

На этом я вынужден прерваться, чтобы эта часть сильно не распухла. Подведем промежуточный итог. Мы уменьшили время загрузки данных с 100 секунд до 1 и потребляемую память ОЗУ с 3.4 GB до 1.2 в пике или 0.6 GB после загрузки данных.

Почему так важно правильно грузить данные? Да потому, что, разрабатывая свои алгоритмы, вы будете сотни (или тысячи) раз запускать свои программы, а если вы будете тратить при этом каждый раз 100 секунд, это, мягко говоря, накладно. Опять же, если вы меньше тратите памяти впустую, то можете больше полезных данных натолкать для расчетов (в 3-6 раз). Следующий момент не так очевиден, но он означает, что используя ndarray (pandas и xarray надстройки над ним), мы также получаете возможность проводить вычисления гораздо быстрее чем на “чистом python”. Я покажу это во второй части статьи. Это мелкие убогие фокусы “пар и трубы”, как говорят сантехники, на этом работает data science в python.

ВВыводы

  • Используйте специализированные бинарные форматы, чтобы ускорить загрузку данных (их не надо парсить, а значит загрузка будет быстрее).
  • Собирайте кучу маленьких файлов в большие файлы. Один файл размером в половину гигабайта будет грузиться сильно быстрее тысяч мелких файлов.
  • Используйте специальные библиотеки для загрузки данных и хранения их в ОЗУ. Это опять же экономит время и память.

PPS

ЭЭ, как же стриминг?

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

  • Это намного медленнее. Считать все сразу конечно же быстрее.
  • Это неудобнее. Алгоритм придется писать, подстраиваясь под ограничение стриминга.

АА вдруг не влезет?

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

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

ГГде продолжение?!

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

Ну все, теперь ждите и страдайте от предвкушения.