Преимущество в производительности операций с DataFrame без копирования
Массив 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)
Эти производные массивы являются «представлениями» исходного массива. Виды можно снимать только при определенных условиях: изменение формы, транспонирование или нарезка.
Например, после преобразования исходного 1D-массива в 2D-массив 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)
Как горизонтальные, так и вертикальные срезы этого 2D-массива аналогичным образом приводят к массивам, которые просто ссылаются на данные исходного массива. Опять же, 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 без копирования
Понимание того, что модель данных на основе неизменяемого массива обеспечивает наилучшую производительность при минимальном риске, легло в основу создания библиотеки DataFrame StaticFrame. Поскольку StaticFrame (например, Pandas) управляет данными, хранящимися в массивах NumPy, использование представлений массива (без необходимости создавать защитные копии) обеспечивает значительные преимущества в производительности. Без неизменяемой модели данных Pandas не может так использовать представления массива.
StaticFrame еще не всегда быстрее, чем Pandas: Pandas выполняет очень производительные операции для соединений и других специализированных преобразований. Но при использовании операций с массивом без копирования StaticFrame может быть намного быстрее.
Чтобы сравнить производительность, мы будем использовать библиотеку FrameFixtures для создания двух DataFrame по 10 000 строк на 1000 столбцов разнородных типов. Для обоих случаев мы можем преобразовать StaticFrame Frame
в DataFrame
Pandas.
>>> 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 просто содержит представления данных столбца в исходном фрейме данных. 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)
Обычно объединяются два или более DataFrame. Если они имеют одинаковый индекс, и мы объединяем их по горизонтали, 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 построен на неизменяемой модели данных, исключается риск мутации побочных эффектов и используются операции без копирования, что обеспечивает значительное преимущество в производительности.