Spec-Zone.ru › pandas 0.22

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

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)
         671001 function calls (665992 primitive calls) in 0.242 seconds

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

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
     1000    0.130    0.000    0.189    0.000 <ipython-input-4-eac78f5c232c>:1(integrate_f)
   552423    0.059    0.000    0.059    0.000 <ipython-input-3-270d8b4c8fce>:1(f)
     3000    0.006    0.000    0.036    0.000 base.py:2537(get_value)
     3000    0.004    0.000    0.041    0.000 series.py:620(__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)
         118576 function calls (113567 primitive calls) in 0.056 seconds

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

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
     3000    0.006    0.000    0.038    0.000 base.py:2537(get_value)
     3000    0.004    0.000    0.043    0.000 series.py:620(__getitem__)
     9027    0.004    0.000    0.007    0.000 {built-in method builtins.getattr}
        1    0.003    0.003    0.055    0.055 {pandas._libs.lib.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)
         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_9df59845e7ff190f442cda6f3d60e56c.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:3865(iget)
        3    0.000    0.000    0.000    0.000 generic.py:1837(_get_item_cache)

Как можно предположить, большая часть времени теперь тратится в 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 scientific software stack.

Примечание

Вам нужно установить 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

Vectorize

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

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

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

In [17]: %timeit (df1 > 0) & (df2 > 0) & (df3 > 0) & (df4 > 0)
22.7 ms +- 435 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.7 ms +- 270 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
22.8 ms +- 1.9 ms per loop (mean +- std. dev. of 7 runs, 100 loops each)
In [21]: %timeit pd.eval('df1 + df2 + df3 + df4 + s')
8.35 ms +- 205 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, вы должны обновить его, чтобы явно установить 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.22.0/enhancingperf.html

Spec-Zone.ru

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