Spec-Zone.ru › pandas 0.23

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

В этой части учебника мы рассмотрим, как ускорить некоторые функции, работающие с 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
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)
         671713 function calls (666693 primitive calls) in 0.246 seconds

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

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
     1000    0.121    0.000    0.177    0.000 <ipython-input-4-c2a74e076cf0>:1(integrate_f)
   552423    0.056    0.000    0.056    0.000 <ipython-input-3-c138bdd570e3>:1(f)
     3000    0.008    0.000    0.045    0.000 base.py:3090(get_value)
        1    0.006    0.006    0.245    0.245 {pandas._libs.reduction.reduce}

Большая часть времени тратится внутри 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)
         119288 function calls (114268 primitive calls) in 0.055 seconds

   Ordered by: internal time
   List reduced from 211 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:3090(get_value)
     9117    0.004    0.000    0.008    0.000 {built-in method builtins.getattr}
     3000    0.004    0.000    0.041    0.000 series.py:764(__getitem__)
        1    0.003    0.003    0.054    0.054 {pandas._libs.reduction.reduce}

Использование 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 с помощью атрибута .values объекта Series. Причина в том, что определение Cython специфично для ndarray, а не для переданного Series.

Поэтому не делайте так:

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)
         215 function calls in 0.001 seconds

   Ordered by: internal time
   List reduced from 55 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_20a45bf86d2a48d5dc2e7ff1cb491989.apply_integrate_f}
        3    0.000    0.000    0.000    0.000 internals.py:4137(iget)
        1    0.000    0.000    0.001    0.001 {built-in method builtins.exec}
        3    0.000    0.000    0.000    0.000 frame.py:2664(__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 (например, ошибка «плюс-минус один») может вызвать ошибку сегментации, потому что проверка доступа к памяти не выполняется. Подробнее о 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):
    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()

Функция верхнего уровня pandas.eval() реализует вычисление выражений для объектов Series и DataFrame.

Примечание

Чтобы получить выгоду от использования eval(), необходимо установить numexpr. Дополнительные сведения см. в разделе рекомендуемые зависимости.

Использование eval() для вычисления выражений вместо обычного Python имеет два преимущества: 1) крупные объекты DataFrame обрабатываются более эффективно и 2) большие арифметические и логические выражения вычисляются единовременно основным движком (по умолчанию используется numexpr для вычислений).

Примечание

Не следует использовать eval() для простых выражений или выражений, включающих небольшие DataFrames. На самом деле, 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.

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
20.8 ms +- 4.36 ms per loop (mean +- std. dev. of 7 runs, 10 loops each)
In [16]: %timeit pd.eval('df1 + df2 + df3 + df4')
7.85 ms +- 473 us per loop (mean +- std. dev. of 7 runs, 100 loops each)

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

In [17]: %timeit (df1 > 0) & (df2 > 0) & (df3 > 0) & (df4 > 0)
31.6 ms +- 830 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)')
14.2 ms +- 775 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
30.3 ms +- 3.13 ms per loop (mean +- std. dev. of 7 runs, 10 loops each)
In [21]: %timeit pd.eval('df1 + df2 + df3 + df4 + s')
10 ms +- 1.12 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.23.4/enhancingperf.html

Spec-Zone.ru

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