Улучшение производительности
В этой части учебника мы рассмотрим, как ускорить определенные функции, работающие с pandas DataFrames с помощью трех различных техник: Cython, Numba и pandas.eval(). Мы увидим повышение скорости примерно в 200 раз, когда используем Cython и Numba для тестовой функции, работающей по строкам с DataFrame. С помощью pandas.eval() мы ускорим суммирование примерно в 2 раза.
Cython (Написание расширений на C для pandas)
Для многих случаев использования достаточно написания pandas на чистом Python и NumPy. Однако в некоторых вычислительно сложных приложениях можно добиться значительного повышения скорости, переложив работу на cython.
Этот учебник предполагает, что вы максимально переписали код на Python, например, попытавшись удалить циклы for и использовать векторизацию NumPy. Всегда стоит оптимизировать код на Python в первую очередь.
Этот учебник рассматривает «типичный» процесс cythonization медленного вычисления. Мы используем пример из документации Cython, но в контексте pandas. Наш окончательный cythonized-решение примерно в 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]:
a b N x
0 0.469112 -0.218470 585 x
1 -0.282863 -0.061645 841 x
2 -1.509059 -0.723780 251 x
3 -1.135632 0.551225 972 x
4 1.212112 -0.497767 181 x
5 -0.173215 0.837519 458 x
6 0.119209 1.103245 159 x
.. ... ... ... ..
993 0.131892 0.290162 190 x
994 0.342097 0.215341 931 x
995 -1.512743 0.874737 374 x
996 0.933753 1.120790 246 x
997 -0.308013 0.198768 157 x
998 -0.079915 1.757555 977 x
999 -1.010589 -1.115680 770 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) # noqa E999
672223 function calls (667203 primitive calls) in 0.283 seconds
Ordered by: internal time
List reduced from 220 to 4 due to restriction <4>
ncalls tottime percall cumtime percall filename:lineno(function)
1000 0.149 0.000 0.208 0.000 <ipython-input-4-c2a74e076cf0>:1(integrate_f)
552423 0.059 0.000 0.059 0.000 <ipython-input-3-c138bdd570e3>:1(f)
3000 0.009 0.000 0.050 0.000 base.py:4342(get_value)
3000 0.005 0.000 0.057 0.000 series.py:865(__getitem__)
Подавляющее большинство времени тратится внутри либо integrate_f , либо f, поэтому мы сосредоточим наши усилия на cythonization этих двух функций.
Примечание
В Python 2 замена range её генераторным аналогом (xrange) означала бы исчезновение строки range. В Python 3 range уже является генератором.
Обычный Cython
Сначала нам нужно импортировать магическую функцию Cython в ipython:
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)
119798 function calls (114778 primitive calls) in 0.069 seconds
Ordered by: internal time
List reduced from 217 to 4 due to restriction <4>
ncalls tottime percall cumtime percall filename:lineno(function)
3000 0.008 0.000 0.045 0.000 base.py:4342(get_value)
3000 0.004 0.000 0.051 0.000 series.py:865(__getitem__)
9264 0.004 0.000 0.008 0.000 {built-in method builtins.getattr}
1 0.004 0.004 0.066 0.066 {pandas._libs.reduction.reduce}
Использование ndarray
Это вызовы series… много! Создаётся Series для каждой строки, и извлекаются данные как из индекса, так и из series (по три раза для каждой строки). Функциональные вызовы дороги в Python, поэтому, возможно, мы могли бы минимизировать их, cythonizing часть apply.
Примечание
Теперь мы передаём ndarray в функцию Cython, и 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, и помещая результат в массив нулей.
Предупреждение
Вы не можете передавать Series напрямую как параметр типа ndarray в функцию Cython. Вместо этого передавайте фактический ndarray с помощью Series.to_numpy(). Причина в том, что определение Cython относится конкретно к ndarray, а не к передаваемому Series.
Так, не делайте так:
apply_integrate_f(df['a'], df['b'], df['N'])
А вместо этого используйте Series.to_numpy() для получения основного ndarray:
apply_integrate_f(df['a'].to_numpy(),
df['b'].to_numpy(),
df['N'].to_numpy())
Примечание
Циклы такого рода будут крайне медленными в 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)
197 function calls in 0.002 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_58ae20c6c56390bf98221fb186b8c2bf.apply_integrate_f}
3 0.000 0.000 0.000 0.000 frame.py:2893(__getitem__)
3 0.000 0.000 0.000 0.000 managers.py:963(iget)
1 0.000 0.000 0.002 0.002 {built-in method builtins.exec}
Как можно было ожидать, большая часть времени теперь тратится в 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 (ошибка «плюс-минус один», например) может привести к сегментному нарушению, так как доступ к памяти не проверяется. Подробнее о boundscheck и wraparound, см. документацию Cython по направлению компиляции.
Использование 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 было быстрее, чем Cython.
Векторизация
Numba также может использоваться для написания векторизованных функций, которые не требуют явного цикла по наблюдениям вектора; векторизованная функция будет применяться к каждой строке автоматически. Рассмотрим следующий пример удвоения каждого наблюдения:
import numba
def double_every_value_nonumba(x):
return x * 2
@numba.vectorize
def double_every_value_withnumba(x): # noqa E501
return x * 2
# Custom function without numba In [5]: %timeit df['col1_doubled'] = df.a.apply(double_every_value_nonumba) # noqa E501 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()
Функция верхнего уровня 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иlog10.
Следующий синтаксис Python не разрешен:
-
Выражения
- Вызовы функций, кроме математических функций.
-
is/is notоперации -
ifвыражения -
lambdaвыражения -
list/set/dictгенераторы - Литералы
dictиsetвыражения -
yieldвыражения - Генераторы выражений
- Логические выражения, состоящие только из скалярных значений
-
Операторы
- Запрещены ни простые, ни составные операторы. Это включает такие вещи, как
for,while, иif.
- Запрещены ни простые, ни составные операторы. Это включает такие вещи, как
eval() Примеры
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 27.8 ms +- 4.12 ms per loop (mean +- std. dev. of 7 runs, 100 loops each)
In [16]: %timeit pd.eval('df1 + df2 + df3 + df4')
10.9 ms +- 888 us per loop (mean +- std. dev. of 7 runs, 100 loops each)
Теперь сделаем то же самое, но с сравнениями:
In [17]: %timeit (df1 > 0) & (df2 > 0) & (df3 > 0) & (df4 > 0) 283 ms +- 46.3 ms per loop (mean +- std. dev. of 7 runs, 1 loop each)
In [18]: %timeit pd.eval('(df1 > 0) & (df2 > 0) & (df3 > 0) & (df4 > 0)')
24.7 ms +- 2.85 ms per loop (mean +- std. dev. of 7 runs, 10 loops each)
eval() также работает с несовпадающими объектами pandas:
In [19]: s = pd.Series(np.random.randn(50)) In [20]: %timeit df1 + df2 + df3 + df4 + s 130 ms +- 15.4 ms per loop (mean +- std. dev. of 7 runs, 10 loops each)
In [21]: %timeit pd.eval('df1 + df2 + df3 + df4 + s')
18.5 ms +- 5.57 ms 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
В дополнение к функции верхнего уровня 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.24.2/user_guide/enhancingperf.html