Улучшение производительности
Cython (написание расширений C для pandas)
Для многих случаев использования записи pandas на чистом Python и numpy достаточно. Однако в некоторых вычислительно сложных приложениях можно добиться значительного ускорения, переложив работу на cython.
Этот учебник предполагает, что вы максимально переработали код на Python, например, попытались удалить циклы for и использовать векторизацию numpy. Всегда стоит оптимизировать код на Python в первую очередь.
В этом руководстве показан «типичный» процесс цитонизации медленного вычисления. Мы используем пример из документации cython, но в контексте pandas. Наш окончательный цитонизированный вариант примерно в 100 раз быстрее, чем чистый Python.
Чистый Python
У нас есть DataFrame, к которому мы хотим применить функцию по строкам.
In [1]: df = pd.DataFrame({'a': np.random.randn(1000),
...: 'b': np.random.randn(1000),
...: 'N': np.random.randint(100, 1000, (1000)),
...: 'x': 'x'})
...:
In [2]: df
Out[2]:
N a b x
0 585 0.469112 -0.218470 x
1 841 -0.282863 -0.061645 x
2 251 -1.509059 -0.723780 x
3 972 -1.135632 0.551225 x
4 181 1.212112 -0.497767 x
5 458 -0.173215 0.837519 x
6 159 0.119209 1.103245 x
.. ... ... ... ..
993 190 0.131892 0.290162 x
994 931 0.342097 0.215341 x
995 374 -1.512743 0.874737 x
996 246 0.933753 1.120790 x
997 157 -0.308013 0.198768 x
998 977 -0.079915 1.757555 x
999 770 -1.010589 -1.115680 x
[1000 rows x 4 columns]
Вот функция на чистом Python:
In [3]: def f(x): ...: return x * (x - 1) ...: In [4]: def integrate_f(a, b, N): ...: s = 0 ...: dx = (b - a) / N ...: for i in range(N): ...: s += f(a + i * dx) ...: return s * dx ...:
Мы достигаем результата, используя apply (по строкам):
In [7]: %timeit df.apply(lambda x: integrate_f(x['a'], x['b'], x['N']), axis=1) 10 loops, best of 3: 174 ms per loop
Но, очевидно, этого недостаточно для нас. Давайте посмотрим, где тратится время во время этой операции (ограничившись четырьмя наиболее затратными вызовами) с использованием магической функции ipython prun:
In [5]: %prun -l 4 df.apply(lambda x: integrate_f(x['a'], x['b'], x['N']), axis=1)
670990 function calls (665981 primitive calls) in 0.232 seconds
Ordered by: internal time
List reduced from 143 to 4 due to restriction <4>
ncalls tottime percall cumtime percall filename:lineno(function)
1000 0.123 0.000 0.181 0.000 <ipython-input-4-91e33489f136>:1(integrate_f)
552423 0.058 0.000 0.058 0.000 <ipython-input-3-bc41a25943f6>:1(f)
3000 0.006 0.000 0.035 0.000 base.py:2454(get_value)
3000 0.004 0.000 0.040 0.000 series.py:598(__getitem__)
Подавляющее большинство времени тратится внутри integrate_f или f, поэтому мы сосредоточим свои усилия на цитонизции этих двух функций.
Примечание
В Python 2 замена range её аналогом-генератором (xrange) означала бы, что строка range исчезнет. В Python 3 range уже является генератором.
Простой cython
Сначала нам нужно импортировать магическую функцию cython в ipython (для версий cython < 0.21 можно использовать %load_ext cythonmagic):
In [6]: %load_ext Cython
Теперь давайте просто скопируем наши функции в cython, как есть (суффикс здесь для различения версий функций):
In [7]: %%cython ...: def f_plain(x): ...: return x * (x - 1) ...: def integrate_f_plain(a, b, N): ...: s = 0 ...: dx = (b - a) / N ...: for i in range(N): ...: s += f_plain(a + i * dx) ...: return s * dx ...:
Примечание
Если у вас возникают проблемы с вставкой вышеприведенного кода в ipython, возможно, вам нужно использовать последнюю версию ipython, чтобы вставка работала корректно с магическими функциями ячеек.
In [4]: %timeit df.apply(lambda x: integrate_f_plain(x['a'], x['b'], x['N']), axis=1) 10 loops, best of 3: 85.5 ms per loop
Уже это сократило время в третью часть, не так уж и плохо для простого копирования и вставки.
Добавление типа
Мы получаем еще одно значительное улучшение, просто указав информацию о типе:
In [8]: %%cython ...: cdef double f_typed(double x) except? -2: ...: return x * (x - 1) ...: cpdef double integrate_f_typed(double a, double b, int N): ...: cdef int i ...: cdef double s, dx ...: s = 0 ...: dx = (b - a) / N ...: for i in range(N): ...: s += f_typed(a + i * dx) ...: return s * dx ...:
In [4]: %timeit df.apply(lambda x: integrate_f_typed(x['a'], x['b'], x['N']), axis=1) 10 loops, best of 3: 20.3 ms per loop
Теперь, мы говорим о значительном улучшении! Теперь он в десять раз быстрее исходной реализации на Python, и мы не сильно изменили код. Давайте еще раз посмотрим, что занимает много времени:
In [9]: %prun -l 4 df.apply(lambda x: integrate_f_typed(x['a'], x['b'], x['N']), axis=1)
118565 function calls (113556 primitive calls) in 0.053 seconds
Ordered by: internal time
List reduced from 140 to 4 due to restriction <4>
ncalls tottime percall cumtime percall filename:lineno(function)
3000 0.006 0.000 0.036 0.000 base.py:2454(get_value)
3000 0.004 0.000 0.041 0.000 series.py:598(__getitem__)
9024 0.003 0.000 0.007 0.000 {built-in method builtins.getattr}
1 0.003 0.003 0.052 0.052 {pandas._libs.lib.reduce}
Использование ndarray
Это вызывает series... очень много! Это создает Series из каждой строки и получает данные из индекса и series (три раза для каждой строки). Вызовы функций в Python медленные, поэтому, возможно, мы можем минимизировать их, цитонизируя часть apply.
Примечание
Сейчас мы передаем ndarray в цитоновскую функцию, к счастью, cython прекрасно работает с numpy.
In [10]: %%cython ....: cimport numpy as np ....: import numpy as np ....: cdef double f_typed(double x) except? -2: ....: return x * (x - 1) ....: cpdef double integrate_f_typed(double a, double b, int N): ....: cdef int i ....: cdef double s, dx ....: s = 0 ....: dx = (b - a) / N ....: for i in range(N): ....: s += f_typed(a + i * dx) ....: return s * dx ....: cpdef np.ndarray[double] apply_integrate_f(np.ndarray col_a, np.ndarray col_b, np.ndarray col_N): ....: assert (col_a.dtype == np.float and col_b.dtype == np.float and col_N.dtype == np.int) ....: cdef Py_ssize_t i, n = len(col_N) ....: assert (len(col_a) == len(col_b) == n) ....: cdef np.ndarray[double] res = np.empty(n) ....: for i in range(len(col_a)): ....: res[i] = integrate_f_typed(col_a[i], col_b[i], col_N[i]) ....: return res ....:
Реализация простая: создается массив нулей, и цикл проходит по строкам, применяя нашу integrate_f_typed, и помещая это в массив нулей.
Предупреждение
В версии 0.13.0, так как Series внутренне был переработан и больше не наследует ndarray, а вместо этого наследует NDFrame, вы не можете передавать Series напрямую как параметр типа ndarray в цитоновскую функцию. Вместо этого передайте фактический ndarray используя атрибут .values объекта Series.
До версии 0.13.0
apply_integrate_f(df['a'], df['b'], df['N'])
Используйте .values для получения базового ndarray
apply_integrate_f(df['a'].values, df['b'].values, df['N'].values)
Примечание
Циклы такого типа будут очень медленными в Python, но в Cython циклы по массивам numpy быстрые.
In [4]: %timeit apply_integrate_f(df['a'].values, df['b'].values, df['N'].values) 1000 loops, best of 3: 1.25 ms per loop
Мы получили еще одно существенное улучшение. Давайте снова проверим, где тратится время:
In [11]: %prun -l 4 apply_integrate_f(df['a'].values, df['b'].values, df['N'].values)
203 function calls in 0.001 seconds
Ordered by: internal time
List reduced from 53 to 4 due to restriction <4>
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.001 0.001 0.001 0.001 {built-in method _cython_magic_0d2376971d79b33c0cce44850be4076c.apply_integrate_f}
1 0.000 0.000 0.001 0.001 {built-in method builtins.exec}
3 0.000 0.000 0.000 0.000 internals.py:3612(iget)
3 0.000 0.000 0.000 0.000 frame.py:1940(__getitem__)
Как можно было ожидать, большая часть времени сейчас тратится в apply_integrate_f, поэтому, если мы хотим добиться большей эффективности, мы должны продолжать сосредотачиваться на этом.
Более продвинутые методы
Есть еще надежда на улучшение. Вот пример использования более продвинутых техник cython:
In [12]: %%cython ....: cimport cython ....: cimport numpy as np ....: import numpy as np ....: cdef double f_typed(double x) except? -2: ....: return x * (x - 1) ....: cpdef double integrate_f_typed(double a, double b, int N): ....: cdef int i ....: cdef double s, dx ....: s = 0 ....: dx = (b - a) / N ....: for i in range(N): ....: s += f_typed(a + i * dx) ....: return s * dx ....: @cython.boundscheck(False) ....: @cython.wraparound(False) ....: cpdef np.ndarray[double] apply_integrate_f_wrap(np.ndarray[double] col_a, np.ndarray[double] col_b, np.ndarray[int] col_N): ....: cdef int i, n = len(col_N) ....: assert len(col_a) == len(col_b) == n ....: cdef np.ndarray[double] res = np.empty(n) ....: for i in range(n): ....: res[i] = integrate_f_typed(col_a[i], col_b[i], col_N[i]) ....: return res ....:
In [4]: %timeit apply_integrate_f_wrap(df['a'].values, df['b'].values, df['N'].values) 1000 loops, best of 3: 987 us per loop
Еще быстрее, с оговоркой, что ошибка в нашем коде cython (ошибка «плюс-минус один», например) может привести к segfault, потому что доступ к памяти не проверяется.
Использование numba
Недавняя альтернатива статической компиляции кода cython – использование динамического JIT-компилятора, numba.
Numba предоставляет возможность ускорить приложения с помощью высокопроизводительных функций, написанных непосредственно на Python. С помощью нескольких аннотаций, ориентированные на массивы и математические Python-коды могут быть скомпилированы в машинные инструкции в режиме реального времени с производительностью, сопоставимой с C, C++ и Fortran, без необходимости менять язык или интерпретаторы Python.
Numba работает, генерируя оптимизированный машинный код с использованием инфраструктуры компилятора LLVM во время импорта, выполнения или статически (используя встроенный инструмент pycc). Numba поддерживает компиляцию Python для выполнения на аппаратном обеспечении CPU или GPU и разработана для интеграции с Python-научной стековой системой.
Примечание
Вам нужно установить numba. Это легко сделать с помощью conda, используя: conda install numba, см. установка с помощью miniconda.
Примечание
Начиная с версии numba 0.20, объекты pandas не могут быть переданы напрямую в функции, скомпилированные numba. Вместо этого нужно передать массив numpy лежащий в основе объекта pandas в скомпилированную функцию numba, как показано ниже.
Jit
Использование numba для компиляции кода в режиме реального времени. Мы просто берем чистый Python-код из предыдущего примера и добавляем аннотацию с помощью декоратора @jit.
import numba
@numba.jit
def f_plain(x):
return x * (x - 1)
@numba.jit
def integrate_f_numba(a, b, N):
s = 0
dx = (b - a) / N
for i in range(N):
s += f_plain(a + i * dx)
return s * dx
@numba.jit
def apply_integrate_f_numba(col_a, col_b, col_N):
n = len(col_N)
result = np.empty(n, dtype='float64')
assert len(col_a) == len(col_b) == n
for i in range(n):
result[i] = integrate_f_numba(col_a[i], col_b[i], col_N[i])
return result
def compute_numba(df):
result = apply_integrate_f_numba(df['a'].values, df['b'].values, df['N'].values)
return pd.Series(result, index=df.index, name='result')
Обратите внимание, что мы напрямую передаем массивы numpy в функцию numba. compute_numba – это просто обертка, которая обеспечивает более удобный интерфейс, передавая/возвращая объекты pandas.
In [4]: %timeit compute_numba(df) 1000 loops, best of 3: 798 us per loop
Векторизация
numba также может быть использован для написания векторизованных функций, которые не требуют явного цикла по наблюдениям вектора; векторизованная функция будет автоматически применяться к каждой строке. Рассмотрим следующий пример-игрушку удвоения каждого наблюдения:
import numba
def double_every_value_nonumba(x):
return x*2
@numba.vectorize
def double_every_value_withnumba(x):
return x*2
# Custom function without numba
In [5]: %timeit df['col1_doubled'] = df.a.apply(double_every_value_nonumba)
1000 loops, best of 3: 797 us per loop
# Standard implementation (faster than a custom function)
In [6]: %timeit df['col1_doubled'] = df.a*2
1000 loops, best of 3: 233 us per loop
# Custom function with numba
In [7]: %timeit df['col1_doubled'] = double_every_value_withnumba(df.a.values)
1000 loops, best of 3: 145 us per loop
Ограничения
Примечание
numba будет выполняться для любой функции, но может ускорить только определенные типы функций.
numba лучше всего ускоряет функции, применяющие числовые функции к массивам numpy. Когда он получает функцию, использующую только те операции, которые он умеет ускорять, он будет выполняться в режиме nopython.
Если numba получит функцию, содержащую то, с чем он не умеет работать (в настоящее время это множества, списки, словари или строковые функции), он вернется к object mode. В режиме object mode, numba будет выполняться, но ваш код не ускорится значительно. Если вы предпочитаете, чтобы numba выбрасывал ошибку, если он не может скомпилировать функцию таким образом, чтобы ускорить ваш код, передайте numba аргумент nopython=True (например, @numba.jit(nopython=True)). Более подробную информацию об устранении неполадок в режимах numba см. на странице устранения неполадок numba.
Подробнее см. в документации numba.
Оценивание выражений с помощью eval() (экспериментальная функция)
Новое в версии 0.13.
Функция верхнего уровня pandas.eval() реализует оценку выражений для объектов Series и DataFrame.
Примечание
Чтобы воспользоваться использованием eval(), необходимо установить numexpr. Дополнительные сведения см. в разделе раздел рекомендуемых зависимостей.
Цель использования eval() для оценки выражений вместо простого Python заключается в двух аспектах: 1) большие объекты DataFrame обрабатываются более эффективно и 2) большие арифметические и логические выражения оцениваются единовременно с помощью базового движка (по умолчанию используется numexpr для оценки).
Примечание
Не следует использовать eval() для простых выражений или выражений, включающих небольшие DataFrame. На самом деле, eval() значительно медленнее для меньших выражений/объектов по сравнению с обычным Python. Хорошим правилом является использование eval() только тогда, когда у вас есть DataFrame с более чем 10 000 строками.
eval() поддерживает все арифметические выражения, поддерживаемые движком, в дополнение к некоторым расширениям, доступным только в pandas.
Примечание
Чем больше кадр и выражение, тем больше ускорения вы увидите при использовании eval().
Поддерживаемый синтаксис
Эти операции поддерживаются pandas.eval():
- Арифметические операции, за исключением операций сдвигов влево (
<<) и вправо (>>) , например,df + 2 * pi / s ** 4 % 42 - the_golden_ratio - Операции сравнения, включая цепочечные сравнения, например,
2 < df < df2 - Булевы операции, например,
df < df2 and df3 < df4 or not df_bool -
listиtupleлитералы, например,[1, 2]или(1, 2) - Доступ к атрибутам, например,
df.a - Выражения индексирования, например,
df[0] - Простая оценка переменных, например,
pd.eval('df')(это не очень полезно) - Математические функции,
sin,cos,exp,log,expm1,log1p,sqrt,sinh,cosh,tanh,arcsin,arccos,arctan,arccosh,arcsinh,arctanh,absиarctan2.
Этот синтаксис Python не разрешен:
- Выражения
- Вызовы функций, кроме математических функций.
-
is/is notоперации -
ifвыражения -
lambdaвыражения -
list/set/dictгенераторы - Литералы
dictиsetвыражения -
yieldвыражения - Генераторы
- Булевы выражения, состоящие только из скалярных значений
- Операторы
- Не разрешены ни простые, ни составные операторы. Сюда относятся, например,
for,while, иif.
- Не разрешены ни простые, ни составные операторы. Сюда относятся, например,
Примеры
pandas.eval() хорошо работает с выражениями, содержащими большие массивы.
Сначала давайте создадим несколько массивов приличного размера для работы:
In [13]: nrows, ncols = 20000, 100 In [14]: df1, df2, df3, df4 = [pd.DataFrame(np.random.randn(nrows, ncols)) for _ in range(4)]
Теперь давайте сравним их сложение с помощью обычного Python и eval():
In [15]: %timeit df1 + df2 + df3 + df4 9.3 ms +- 307 us per loop (mean +- std. dev. of 7 runs, 100 loops each)
In [16]: %timeit pd.eval('df1 + df2 + df3 + df4')
6.74 ms +- 158 us per loop (mean +- std. dev. of 7 runs, 100 loops each)
Теперь сделаем то же самое, но с сравнениями:
In [17]: %timeit (df1 > 0) & (df2 > 0) & (df3 > 0) & (df4 > 0) 22.5 ms +- 369 us per loop (mean +- std. dev. of 7 runs, 10 loops each)
In [18]: %timeit pd.eval('(df1 > 0) & (df2 > 0) & (df3 > 0) & (df4 > 0)')
8.42 ms +- 234 us per loop (mean +- std. dev. of 7 runs, 100 loops each)
eval() также работает с несовпадающими объектами pandas:
In [19]: s = pd.Series(np.random.randn(50)) In [20]: %timeit df1 + df2 + df3 + df4 + s 19.8 ms +- 1.16 ms per loop (mean +- std. dev. of 7 runs, 100 loops each)
In [21]: %timeit pd.eval('df1 + df2 + df3 + df4 + s')
8.2 ms +- 454 us per loop (mean +- std. dev. of 7 runs, 100 loops each)
Примечание
Операции, такие как
1 and 2 # would parse to 1 & 2, but should evaluate to 2 3 or 4 # would parse to 3 | 4, but should evaluate to 3 ~1 # this is okay, but slower when using eval
должны выполняться в Python. Будет возбуждено исключение, если вы попытаетесь выполнить какие-либо булевы/битовые операции со скалярными операндами, которые не являются типа bool или np.bool_. Снова выполните эти операции в обычном Python.
Метод DataFrame.eval (Экспериментальный)
Добавлен в версии 0.13.
В дополнение к функции верхнего уровня pandas.eval() вы также можете оценить выражение в «контексте» DataFrame.
In [22]: df = pd.DataFrame(np.random.randn(5, 2), columns=['a', 'b'])
In [23]: df.eval('a + b')
Out[23]:
0 -0.246747
1 0.867786
2 -1.626063
3 -1.134978
4 -1.027798
dtype: float64
Любое выражение, которое является допустимым выражением pandas.eval(), также является допустимым выражением DataFrame.eval(), с дополнительной выгодой, что вам не нужно добавлять имя DataFrame к столбцу(ам), который(е) вы хотите оценить.
Кроме того, вы можете выполнять присваивание столбцов внутри выражения. Это позволяет выполнять формульную оценку. Цель присваивания может быть новым именем столбца или существующим именем столбца, и она должна быть допустимым идентификатором Python.
Добавлен в версии 0.18.0.
Ключевое слово inplace определяет, будет ли это присваивание выполнено в исходном DataFrame или возвращена копия с новым столбцом.
Предупреждение
Для обратной совместимости, inplace по умолчанию True если не указано иное. Это изменится в будущей версии pandas - если ваш код зависит от присваивания на месте, вам следует обновить, чтобы явно указать inplace=True
In [24]: df = pd.DataFrame(dict(a=range(5), b=range(5, 10)))
In [25]: df.eval('c = a + b', inplace=True)
In [26]: df.eval('d = a + b + c', inplace=True)
In [27]: df.eval('a = 1', inplace=True)
In [28]: df
Out[28]:
a b c d
0 1 5 5 10
1 1 6 7 14
2 1 7 9 18
3 1 8 11 22
4 1 9 13 26
Когда inplace установлено в False, возвращается копия DataFrame с новыми или изменёнными столбцами, а исходная таблица остаётся неизменной.
In [29]: df
Out[29]:
a b c d
0 1 5 5 10
1 1 6 7 14
2 1 7 9 18
3 1 8 11 22
4 1 9 13 26
In [30]: df.eval('e = a - c', inplace=False)
© 2008–2012, AQR Capital Management, LLC, Lambda Foundry, Inc. and PyData Development Team
Licensed under the 3-clause BSD License.
https://pandas.pydata.org/pandas-docs/version/0.20.3/enhancingperf.html