Как StaticFrame может превзойти Pandas, используя представления массива NumPy

Массив NumPy — это объект Python, который хранит данные в непрерывном буфере C-массива. Превосходная производительность этих массивов обусловлена ​​не только их компактным представлением, но и способностью массивов совместно использовать представления этого буфера среди многих массивов. NumPy часто использует операции с массивами без копирования, создавая производные массивы без копирования подчиненных буферов данных. В полной мере используя эффективность NumPy, библиотека DataFrame StaticFrame предлагает на порядки лучшую производительность, чем Pandas, для многих распространенных операций.

Операции без копирования с массивами NumPy

Фраза «без копирования» описывает операцию над контейнером (здесь массив или DataFrame), где создается новый экземпляр, но на базовые данные ссылаются, а не копируют. Хотя для экземпляра выделяется некоторая новая память, размер обычно незначителен по сравнению с потенциально очень большим объемом базовых данных.

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

Например, разница между нарезкой массива из 100 000 целых чисел (~0,1 мкс) и нарезкой и последующим копированием того же массива (~10 мкс) составляет два порядка.

>>> import numpy as np
>>> data = np.arange(100_000)
>>> %timeit data[:50_000]
123 ns ± 0.565 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)
>>> %timeit data[:50_000].copy()
13.1 µs ± 48.4 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)

Мы можем проиллюстрировать, как это работает, исследуя два атрибута массивов NumPy. Атрибут flags отображает сведения о том, как осуществляется обращение к памяти массива. Атрибут base, если он установлен, предоставляет дескриптор массива, который фактически содержит буфер, на который ссылается этот массив.

В приведенном ниже примере мы создаем массив, берем срез и смотрим на flags среза. Мы видим, что для среза OWNDATA равно False, а base среза является исходным массивом (у них одинаковый идентификатор объекта).

>>> a1 = np.arange(12)
>>> a1
array([ 0,  1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11])

>>> a2 = a1[:6]
>>> a2.flags
  C_CONTIGUOUS : True
  F_CONTIGUOUS : True
  OWNDATA : False
  WRITEABLE : True
  ALIGNED : True
  WRITEBACKIFCOPY : False
  UPDATEIFCOPY : False

>>> id(a1), id(a2.base)
(140506320732848, 140506320732848)

Эти производные массивы являются «представлениями» исходного массива. Виды можно снимать только при определенных условиях: изменение формы, транспонирование или нарезка.

Например, после преобразования исходного одномерного массива в двумерный массив OWNDATA становится False, показывая, что он по-прежнему ссылается на данные исходного массива.

>>> a3 = a1.reshape(3,4)
>>> a3
array([[ 0,  1,  2,  3],
       [ 4,  5,  6,  7],
       [ 8,  9, 10, 11]])
>>> a3.flags
  C_CONTIGUOUS : True
  F_CONTIGUOUS : False
  OWNDATA : False
  WRITEABLE : True
  ALIGNED : True
  WRITEBACKIFCOPY : False
  UPDATEIFCOPY : False

>>> id(a3.base), id(a1)
(140506320732848, 140506320732848)

Как горизонтальные, так и вертикальные срезы этого двумерного массива одинаково приводят к массивам, которые просто ссылаются на данные исходного массива. Опять же, OWNDATA — это False, а base среза — это исходный массив.

>>> a4 = a3[:, 2]
>>> a4
array([ 2,  6, 10])

>>> a4.flags
  C_CONTIGUOUS : False
  F_CONTIGUOUS : False
  OWNDATA : False
  WRITEABLE : True
  ALIGNED : True
  WRITEBACKIFCOPY : False
  UPDATEIFCOPY : False

>>> id(a1), id(a4.base)
(140506320732848, 140506320732848)

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

>>> a4[0] = -1
>>> a4
array([-1,  6, 10])
>>> a3
array([[ 0,  1, -1,  3],
       [ 4,  5,  6,  7],
       [ 8,  9, 10, 11]])
>>> a2
array([ 0,  1, -1,  3,  4,  5])
>>> a1
array([ 0,  1, -1,  3,  4,  5,  6,  7,  8,  9, 10, 11])

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

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

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

Массив NumPy можно легко сделать неизменяемым, установив для флага writeable значение False на интерфейсе flags. После установки этого значения на дисплее flags WRITEABLE отображается как False, и попытка изменить этот массив приводит к исключению.

>>> a1.flags.writeable = False
>>> a1.flags
  C_CONTIGUOUS : True
  F_CONTIGUOUS : True
  OWNDATA : True
  WRITEABLE : False
  ALIGNED : True
  WRITEBACKIFCOPY : False
  UPDATEIFCOPY : False

>>> a1[0] = -1
Traceback (most recent call last):
  File "<console>", line 1, in <module>
ValueError: assignment destination is read-only

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

Преимущества операций без копирования DataFrame

Понимание того, что модель данных на основе неизменяемого массива обеспечивает наилучшую производительность с минимальным риском, легло в основу создания библиотеки StaticFrame DataFrame. Поскольку StaticFrame (как и Pandas) управляет данными, хранящимися в массивах NumPy, использование представлений массива (без необходимости создания защитных копий) дает значительные преимущества в производительности. Без неизменяемой модели данных Pandas не может так использовать представления массивов.

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

Чтобы сравнить производительность, мы будем использовать библиотеку FrameFixtures для создания двух DataFrames по 10 000 строк на 1 000 столбцов разнородных типов. Для обоих мы можем преобразовать StaticFrame Frame в Pandas DataFrame.

>>> import static_frame as sf
>>> import pandas as pd
>>> sf.__version__, pd.__version__
('0.9.21', '1.5.1')

>>> import frame_fixtures as ff
>>> f1 = ff.parse('s(10_000,1000)|v(int,int,str,float)')
>>> df1 = f1.to_pandas()
>>> f2 = ff.parse('s(10_000,1000)|v(int,bool,bool,float)')
>>> df2 = f2.to_pandas()

Простой пример преимущества операции без копирования — переименование оси. В Pandas все базовые данные копируются с защитой. При использовании StaticFrame все базовые данные используются повторно; должны быть созданы только легкие внешние контейнеры. StaticFrame (~0,01 мс) почти на четыре порядка быстрее, чем Pandas (~100 мс).

>>> %timeit f1.rename(index='foo')
35.8 µs ± 496 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)
>>> %timeit df1.rename_axis('foo')
167 ms ± 4.72 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

Учитывая DataFrame, часто необходимо сделать столбец индексом. Когда Pandas делает это, он должен скопировать данные столбца в индекс, а также скопировать все базовые данные. StaticFrame может повторно использовать представление столбца в индексе, а также повторно использовать все базовые данные. StaticFrame (~ 1 мс) на два порядка быстрее, чем Pandas (~ 100 мс).

>>> %timeit f1.set_index(0)
1.25 ms ± 23.7 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
>>> %timeit df1.set_index(0, drop=False)
166 ms ± 3.52 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

Извлечение подмножества столбцов из DataFrame — еще одна распространенная операция. Для StaticFrame это операция без копирования: возвращенный DataFrame просто содержит представления данных столбца в исходном DataFrame. StaticFrame (~10 мкс) может сделать это на порядок быстрее, чем Pandas (~100 мкс).

>>> %timeit f1[[10, 50, 100, 500]]
25.4 µs ± 471 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)
>>> %timeit df1[[10, 50, 100, 500]]
729 µs ± 4.14 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

Обычно объединяют два или более DataFrames. Если у них один и тот же индекс, и мы объединяем их по горизонтали, StaticFrame может повторно использовать все базовые данные входных данных, что делает эту форму объединения операцией без копирования. StaticFrame (~1 мс) может сделать это на два порядка быстрее, чем Pandas (~100 мс).

>>> %timeit sf.Frame.from_concat((f1, f2), axis=1, columns=sf.IndexAutoFactory)
1.16 ms ± 50.1 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
>>> %timeit pd.concat((df1, df2), axis=1)
102 ms ± 14.4 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

Заключение

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