Spec-Zone.ru › pandas 0.25

Улучшение производительности

В этой части учебника мы рассмотрим, как ускорить определенные функции, работающие с pandas DataFrames с помощью трех различных техник: Cython, Numba и pandas.eval(). Мы увидим повышение скорости примерно в 200 раз, когда используем Cython и Numba для тестовой функции, работающей с DataFrame по строкам. Используя pandas.eval(), мы ускорим суммирование примерно в 2 раза.

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]: 
            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
..        ...       ...  ... ..
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

Но, очевидно, это недостаточно быстро. Давайте посмотрим, где тратится время во время этой операции (ограничено четырьмя наиболее ресурсоемкими вызовами) с помощью магической функции prun в ipython:

In [5]: %prun -l 4 df.apply(lambda x: integrate_f(x['a'], x['b'], x['N']), axis=1)  # noqa E999
         672332 function calls (667306 primitive calls) in 0.285 seconds

   Ordered by: internal time
   List reduced from 221 to 4 due to restriction <4>

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
     1000    0.144    0.000    0.217    0.000 <ipython-input-4-c2a74e076cf0>:1(integrate_f)
   552423    0.074    0.000    0.074    0.000 <ipython-input-3-c138bdd570e3>:1(f)
     3000    0.008    0.000    0.045    0.000 base.py:4695(get_value)
     6001    0.005    0.000    0.012    0.000 {pandas._libs.lib.values_from_object}

Большая часть времени тратится внутри integrate_f или f, поэтому мы сосредоточим усилия на цитонизации этих двух функций.

Примечание

В 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)
         119905 function calls (114879 primitive calls) in 0.096 seconds

   Ordered by: internal time
   List reduced from 216 to 4 due to restriction <4>

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
     3000    0.012    0.000    0.064    0.000 base.py:4695(get_value)
     6001    0.007    0.000    0.017    0.000 {pandas._libs.lib.values_from_object}
     3000    0.007    0.000    0.073    0.000 series.py:1061(__getitem__)
     3000    0.006    0.000    0.006    0.000 {method 'get_value' of 'pandas._libs.index.IndexEngine' objects}

Использование ndarray

Это вызов series… много! Он создает Series из каждой строки и получает данные как из индекса, так и из series (три раза для каждой строки). Функциональные вызовы дороги в Python, поэтому, возможно, мы можем минимизировать их, цитонизируя часть 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'].to_numpy(),
                                  df['b'].to_numpy(),
                                  df['N'].to_numpy())
1000 loops, best of 3: 1.25 ms per loop

Мы получили ещё значительное улучшение. Давайте снова проверим, где тратится время:

In [11]: %prun -l 4 apply_integrate_f(df['a'].to_numpy(),
   ....:                              df['b'].to_numpy(),
   ....:                              df['N'].to_numpy())
   ....: 
  File "<ipython-input-11-613f5c6ec02d>", line 2
    df['b'].to_numpy(),
    ^
IndentationError: unexpected indent

Как можно ожидать, большая часть времени теперь тратится в 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'].to_numpy(),
                                       df['b'].to_numpy(),
                                       df['N'].to_numpy())
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 для компиляции кода JIT. Мы просто берём чистый код 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'].to_numpy(),
                                     df['b'].to_numpy(),
                                     df['N'].to_numpy())
    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.to_numpy())
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
21 ms +- 787 us per loop (mean +- std. dev. of 7 runs, 10 loops each)
In [16]: %timeit pd.eval('df1 + df2 + df3 + df4')
8.12 ms +- 249 us per loop (mean +- std. dev. of 7 runs, 100 loops each)

Теперь давайте сделаем то же самое, но со сравнениями:

In [17]: %timeit (df1 > 0) & (df2 > 0) & (df3 > 0) & (df4 > 0)
272 ms +- 6.92 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)')
19.2 ms +- 1.87 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
103 ms +- 12.7 ms per loop (mean +- std. dev. of 7 runs, 10 loops each)
In [21]: %timeit pd.eval('df1 + df2 + df3 + df4 + s')
10.2 ms +- 215 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

В дополнение к функции верхнего уровня 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.25.0/user_guide/enhancingperf.html

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API