Повышение производительности
В этой части учебника мы рассмотрим, как ускорить определенные функции, работающие с pandas DataFrame с помощью Cython, Numba и pandas.eval(). Как правило, использование Cython и Numba может обеспечить более значительное ускорение, чем использование pandas.eval(), но потребует гораздо больше кода.
Примечание
Помимо выполнения шагов в этом учебнике, пользователям, заинтересованным в повышении производительности, настоятельно рекомендуется установить рекомендуемые зависимости для pandas. Эти зависимости часто не устанавливаются по умолчанию, но обеспечат повышение скорости, если они присутствуют.
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
...:
Мы достигаем результата, используя DataFrame.apply() (по строкам):
In [5]: %timeit df.apply(lambda x: integrate_f(x["a"], x["b"], x["N"]), axis=1)
74.9 ms +- 728 us per loop (mean +- std. dev. of 7 runs, 10 loops each)
Давайте посмотрим, где тратится время во время этой операции, используя магическую функцию ipython prun:
# most time consuming 4 calls
In [6]: %prun -l 4 df.apply(lambda x: integrate_f(x["a"], x["b"], x["N"]), axis=1) # noqa E999
605956 function calls (605938 primitive calls) in 0.167 seconds
Ordered by: internal time
List reduced from 163 to 4 due to restriction <4>
ncalls tottime percall cumtime percall filename:lineno(function)
1000 0.097 0.000 0.148 0.000 <ipython-input-4-c2a74e076cf0>:1(integrate_f)
552423 0.051 0.000 0.051 0.000 <ipython-input-3-c138bdd570e3>:1(f)
3000 0.003 0.000 0.012 0.000 series.py:1095(__getitem__)
3000 0.002 0.000 0.005 0.000 series.py:1220(_get_value)
Подавляющая часть времени тратится внутри integrate_f или f, поэтому мы сосредоточимся на цитонизации этих двух функций.
Простой Cython
Сначала нам нужно импортировать магическую функцию Cython в IPython:
In [7]: %load_ext Cython
Теперь просто скопируем наши функции в Cython:
In [8]: %%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
...:
In [9]: %timeit df.apply(lambda x: integrate_f_plain(x["a"], x["b"], x["N"]), axis=1)
46.6 ms +- 466 us per loop (mean +- std. dev. of 7 runs, 10 loops each)
Это улучшило производительность по сравнению с чистым Python на треть.
Объявление типов C
Мы можем также аннотировать переменные функции и типы возвращаемых значений и использовать cdef и cpdef для повышения производительности:
In [10]: %%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 [11]: %timeit df.apply(lambda x: integrate_f_typed(x["a"], x["b"], x["N"]), axis=1)
7.76 ms +- 83.8 us per loop (mean +- std. dev. of 7 runs, 100 loops each)
Аннотирование функций типами C приводит к улучшению производительности более чем в десять раз по сравнению с исходной реализацией на Python.
Использование ndarray
При повторном профилировании время тратится на создание Series из каждой строки и вызов __getitem__ как из индекса, так и из серии (три раза для каждой строки). Эти вызовы функций Python являются дорогостоящими и могут быть улучшены путем передачи np.ndarray.
In [12]: %prun -l 4 df.apply(lambda x: integrate_f_typed(x["a"], x["b"], x["N"]), axis=1)
52533 function calls (52515 primitive calls) in 0.019 seconds
Ordered by: internal time
List reduced from 161 to 4 due to restriction <4>
ncalls tottime percall cumtime percall filename:lineno(function)
3000 0.003 0.000 0.012 0.000 series.py:1095(__getitem__)
3000 0.002 0.000 0.005 0.000 series.py:1220(_get_value)
3000 0.002 0.000 0.002 0.000 base.py:3777(get_loc)
3000 0.002 0.000 0.002 0.000 indexing.py:2765(check_dict_or_set_indexers)
In [13]: %%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.float64
....: and col_b.dtype == np.float64 and col_N.dtype == np.dtype(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
....:
Content of stderr:
In file included from /home/runner/micromamba/envs/test/lib/python3.10/site-packages/numpy/core/include/numpy/ndarraytypes.h:1929,
from /home/runner/micromamba/envs/test/lib/python3.10/site-packages/numpy/core/include/numpy/ndarrayobject.h:12,
from /home/runner/micromamba/envs/test/lib/python3.10/site-packages/numpy/core/include/numpy/arrayobject.h:5,
from /home/runner/.cache/ipython/cython/_cython_magic_96d1519457caba8fa4f96b759be00659f51c6b18.c:1215:
/home/runner/micromamba/envs/test/lib/python3.10/site-packages/numpy/core/include/numpy/npy_1_7_deprecated_api.h:17:2: warning: #warning "Using deprecated NumPy API, disable it with " "#define NPY_NO_DEPRECATED_API NPY_1_7_API_VERSION" [-Wcpp]
17 | #warning "Using deprecated NumPy API, disable it with " \
| ^~~~~~~
Эта реализация создает массив нулей и вставляет результат integrate_f_typed , применяемый к каждой строке. Итерация по ndarray в Cython быстрее, чем по объекту Series.
Поскольку apply_integrate_f типизирована для принятия np.ndarray, необходимо использовать вызовы Series.to_numpy() для использования этой функции.
In [14]: %timeit apply_integrate_f(df["a"].to_numpy(), df["b"].to_numpy(), df["N"].to_numpy())
834 us +- 4.04 us per loop (mean +- std. dev. of 7 runs, 1,000 loops each)
Производительность улучшилась по сравнению с предыдущей реализацией почти в десять раз.
Отключение директив компилятора
Сейчас большая часть времени тратится в apply_integrate_f. Отключение проверок Cython boundscheck и wraparound может повысить производительность.
In [15]: %prun -l 4 apply_integrate_f(df["a"].to_numpy(), df["b"].to_numpy(), df["N"].to_numpy())
78 function calls in 0.001 seconds
Ordered by: internal time
List reduced from 21 to 4 due to restriction <4>
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.001 0.001 0.001 0.001 <string>:1(<module>)
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:4062(__getitem__)
3 0.000 0.000 0.000 0.000 base.py:541(to_numpy)
In [16]: %%cython
....: cimport cython
....: cimport numpy as np
....: import numpy as np
....: cdef np.float64_t f_typed(np.float64_t x) except? -2:
....: return x * (x - 1)
....: cpdef np.float64_t integrate_f_typed(np.float64_t a, np.float64_t b, np.int64_t N):
....: cdef np.int64_t i
....: cdef np.float64_t s = 0.0, dx
....: 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[np.float64_t] apply_integrate_f_wrap(
....: np.ndarray[np.float64_t] col_a,
....: np.ndarray[np.float64_t] col_b,
....: np.ndarray[np.int64_t] col_N
....: ):
....: cdef np.int64_t i, n = len(col_N)
....: assert len(col_a) == len(col_b) == n
....: cdef np.ndarray[np.float64_t] res = np.empty(n, dtype=np.float64)
....: for i in range(n):
....: res[i] = integrate_f_typed(col_a[i], col_b[i], col_N[i])
....: return res
....:
Content of stderr:
In file included from /home/runner/micromamba/envs/test/lib/python3.10/site-packages/numpy/core/include/numpy/ndarraytypes.h:1929,
from /home/runner/micromamba/envs/test/lib/python3.10/site-packages/numpy/core/include/numpy/ndarrayobject.h:12,
from /home/runner/micromamba/envs/test/lib/python3.10/site-packages/numpy/core/include/numpy/arrayobject.h:5,
from /home/runner/.cache/ipython/cython/_cython_magic_3bb7bde31cdaf5ab952bfe5a612c6edef03550d0.c:1216:
/home/runner/micromamba/envs/test/lib/python3.10/site-packages/numpy/core/include/numpy/npy_1_7_deprecated_api.h:17:2: warning: #warning "Using deprecated NumPy API, disable it with " "#define NPY_NO_DEPRECATED_API NPY_1_7_API_VERSION" [-Wcpp]
17 | #warning "Using deprecated NumPy API, disable it with " \
| ^~~~~~~
In [17]: %timeit apply_integrate_f_wrap(df["a"].to_numpy(), df["b"].to_numpy(), df["N"].to_numpy())
620 us +- 2.65 us per loop (mean +- std. dev. of 7 runs, 1,000 loops each)
Однако, индексатор цикла i, обращающийся к недопустимому местоположению в массиве, вызовет ошибку сегментации, поскольку проверка доступа к памяти не выполняется. Более подробную информацию о boundscheck и wraparound, см. в документации Cython по директивам компилятора.
Numba (JIT-компиляция)
Альтернативой статической компиляции кода Cython является использование динамического компилятора just-in-time (JIT) с Numba.
Numba позволяет вам написать чисто Python-функцию, которая может быть JIT-скомпилирована в машинные инструкции, аналогичные по производительности C, C++ и Fortran, путем декорирования вашей функции с помощью @jit.
Numba генерирует оптимизированный машинный код, используя инфраструктуру компилятора LLVM во время импорта, выполнения или статически (используя включенный инструмент pycc). Numba поддерживает компиляцию Python для выполнения как на процессоре, так и на графическом процессоре, и предназначена для интеграции с Python-научным стеком программного обеспечения.
Примечание
Компиляция @jit добавит накладные расходы к времени выполнения функции, поэтому преимущества производительности могут не проявиться, особенно при использовании небольших наборов данных. Рассмотрите возможность кеширования вашей функции, чтобы избежать накладных расходов на компиляцию каждый раз при её запуске.
Numba может использоваться двумя способами с pandas:
Укажите ключевое слово
engine="numba"в выбранных методах pandasОпределите собственную Python-функцию, декорированную с помощью
@jit, и передайте лежащий в основе NumPy массивSeriesилиDataFrame(используяSeries.to_numpy()) в функцию
Двигатель pandas Numba
Если Numba установлена, можно указать engine="numba" в выбранных методах pandas, чтобы выполнить метод с помощью Numba. Методы, которые поддерживают engine="numba" , также будут иметь ключевое слово engine_kwargs , которое принимает словарь, позволяющий указать ключи "nogil", "nopython" и "parallel" со значениями булевых типов, чтобы передать их в декоратор @jit. Если engine_kwargs не указано, оно по умолчанию равно {"nogil": False, "nopython": True, "parallel": False}, если не указано иное.
Примечание
С точки зрения производительности, в первый раз функция, выполняемая с помощью движка Numba, будет медленной, так как Numba потребует некоторой накладных расходов на компиляцию функции. Однако скомпилированные функции JIT кэшируются, и последующие вызовы будут быстрыми. В целом, движок Numba эффективен с большим количеством точек данных (например, 1+ миллион).
In [1]: data = pd.Series(range(1_000_000)) # noqa: E225
In [2]: roll = data.rolling(10)
In [3]: def f(x):
...: return np.sum(x) + 5
# Run the first time, compilation time will affect performance
In [4]: %timeit -r 1 -n 1 roll.apply(f, engine='numba', raw=True)
1.23 s ± 0 ns per loop (mean ± std. dev. of 1 run, 1 loop each)
# Function is cached and performance will improve
In [5]: %timeit roll.apply(f, engine='numba', raw=True)
188 ms ± 1.93 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
In [6]: %timeit roll.apply(f, engine='cython', raw=True)
3.92 s ± 59 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
Если ваше вычислительное оборудование содержит несколько процессоров, наибольшее увеличение производительности можно получить, установив parallel на True для использования более чем 1 процессора. Внутри pandas использует numba для распараллеливания вычислений по столбцам DataFrame; следовательно, эта выгода от производительности полезна только для DataFrame с большим количеством столбцов.
In [1]: import numba
In [2]: numba.set_num_threads(1)
In [3]: df = pd.DataFrame(np.random.randn(10_000, 100))
In [4]: roll = df.rolling(100)
In [5]: %timeit roll.mean(engine="numba", engine_kwargs={"parallel": True})
347 ms ± 26 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
In [6]: numba.set_num_threads(2)
In [7]: %timeit roll.mean(engine="numba", engine_kwargs={"parallel": True})
201 ms ± 2.97 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
Примеры пользовательских функций
Пользовательская Python-функция, декорированная @jit, может использоваться с объектами pandas, передавая их представления NumPy массивов с помощью Series.to_numpy().
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")
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 лучше всего ускоряет функции, которые применяют числовые функции к массивам NumPy. Если вы попытаетесь @jit функцию, содержащую неподдерживаемый код Python или NumPy, компиляция вернется к режиму object mode, что, скорее всего, не ускорит вашу функцию. Если вы предпочитаете, чтобы Numba выдавала ошибку, если она не может скомпилировать функцию таким образом, чтобы ускорить ваш код, передайте Numba аргумент nopython=True (например, @jit(nopython=True)). Более подробную информацию об устранении неполадок режимов Numba см. на странице устранения неполадок Numba.
Использование parallel=True (например, @jit(parallel=True)) может привести к SIGABRT , если слой потоков приводит к небезопасному поведению. Вы можете предварительно указать безопасный слой потоков перед запуском функции JIT с parallel=True.
В общем случае, если у вас возникает ошибка segfault (SIGSEGV) при использовании Numba, пожалуйста, сообщите об этой проблеме в трекере проблем Numba.
Вычисление выражений с помощью eval()
Функция верхнего уровня pandas.eval() реализует производительное вычисление выражений для Series и DataFrame. Вычисление выражений позволяет выражать операции в виде строк и потенциально обеспечивает повышение производительности, вычисляя арифметические и логические выражения сразу для больших DataFrame.
Примечание
Не следует использовать eval() для простых выражений или выражений, включающих небольшие DataFrame. Фактически, eval() на много порядков медленнее для более мелких выражений или объектов, чем обычный Python. Хорошим правилом является использование eval() только тогда, когда у вас есть DataFrame с более чем 10 000 строками.
Поддерживаемый синтаксис
Эти операции поддерживаются pandas.eval():
Арифметические операции, за исключением операторов левого сдвига (
<<) и правого сдвига (>>) , например,df + 2 * pi / s ** 4 % 42 - the_golden_ratioОперации сравнения, включая цепочечные сравнения, например,
2 < df < df2Логические операции, например,
df < df2 and df3 < df4 or not df_boollistи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.
Локальные переменные
Вы должны явным образом ссылаться на любую локальную переменную, которую хотите использовать в выражении, поместив символ @ перед именем. Этот механизм одинаков как для DataFrame.query(), так и для DataFrame.eval(). Например,
In [18]: df = pd.DataFrame(np.random.randn(5, 2), columns=list("ab"))
In [19]: newcol = np.random.randn(len(df))
In [20]: df.eval("b + @newcol")
Out[20]:
0 -0.206122
1 -1.029587
2 0.519726
3 -2.052589
4 1.453210
dtype: float64
In [21]: df.query("b < @newcol")
Out[21]:
a b
1 0.160268 -0.848896
3 0.333758 -1.180355
4 0.572182 0.439895
Если вы не добавите префикс @, pandas выдаст исключение, сообщая, что переменная не определена.
Использование DataFrame.eval() и DataFrame.query() позволяет иметь локальную переменную и столбец DataFrame с одинаковым именем в выражении.
In [22]: a = np.random.randn()
In [23]: df.query("@a < a")
Out[23]:
a b
0 0.473349 0.891236
1 0.160268 -0.848896
2 0.803311 1.662031
3 0.333758 -1.180355
4 0.572182 0.439895
In [24]: df.loc[a < df["a"]] # same as the previous expression
Out[24]:
a b
0 0.473349 0.891236
1 0.160268 -0.848896
2 0.803311 1.662031
3 0.333758 -1.180355
4 0.572182 0.439895
Предупреждение
pandas.eval() выдаст исключение, если вы не сможете использовать префикс @, потому что он не определён в этом контексте.
In [25]: a, b = 1, 2
In [26]: pd.eval("@a + b")
Traceback (most recent call last):
File ~/micromamba/envs/test/lib/python3.10/site-packages/IPython/core/interactiveshell.py:3577 in run_code
exec(code_obj, self.user_global_ns, self.user_ns)
Cell In[26], line 1
pd.eval("@a + b")
File ~/work/pandas/pandas/pandas/core/computation/eval.py:325 in eval
_check_for_locals(expr, level, parser)
File ~/work/pandas/pandas/pandas/core/computation/eval.py:167 in _check_for_locals
raise SyntaxError(msg)
File <string>
SyntaxError: The '@' prefix is not allowed in top-level eval calls.
please refer to your variables by name without the '@' prefix.
В этом случае просто ссылайтесь на переменные так, как это делается в стандартном Python.
In [27]: pd.eval("a + b")
Out[27]: 3
pandas.eval() парсеры
Существует два разных парсера синтаксиса выражений.
Парсер по умолчанию 'pandas' позволяет использовать более интуитивный синтаксис для выражения операций типа запросов (сравнения, союзы и дизъюнкции). В частности, приоритет операторов & и | приравнивается к приоритету соответствующих логических операций and и or.
Например, приведенное выше объединение можно записать без скобок. В качестве альтернативы, можно использовать парсер 'python' для обеспечения строгой семантики Python.
In [28]: nrows, ncols = 20000, 100
In [29]: df1, df2, df3, df4 = [pd.DataFrame(np.random.randn(nrows, ncols)) for _ in range(4)]
In [30]: expr = "(df1 > 0) & (df2 > 0) & (df3 > 0) & (df4 > 0)"
In [31]: x = pd.eval(expr, parser="python")
In [32]: expr_no_parens = "df1 > 0 & df2 > 0 & df3 > 0 & df4 > 0"
In [33]: y = pd.eval(expr_no_parens, parser="pandas")
In [34]: np.all(x == y)
Out[34]: True
То же выражение может быть “и” объединено с помощью слова and:
In [35]: expr = "(df1 > 0) & (df2 > 0) & (df3 > 0) & (df4 > 0)"
In [36]: x = pd.eval(expr, parser="python")
In [37]: expr_with_ands = "df1 > 0 and df2 > 0 and df3 > 0 and df4 > 0"
In [38]: y = pd.eval(expr_with_ands, parser="pandas")
In [39]: np.all(x == y)
Out[39]: True
Операторы and и or здесь имеют тот же приоритет, что и в Python.
pandas.eval() движки
Существует два разных движка выражений.
Движок 'numexpr' — это более производительный движок, который может обеспечить повышение производительности по сравнению со стандартным синтаксисом Python для больших DataFrame. Для этого движка необходимо установить необязательную зависимость numexpr.
Движок 'python' обычно не полезен, за исключением тестирования других движков вычислений. Использование eval() с engine='python' не даст никаких преимуществ в производительности, а может даже снизить её.
In [40]: %timeit df1 + df2 + df3 + df4
7.42 ms +- 81.8 us per loop (mean +- std. dev. of 7 runs, 100 loops each)
In [41]: %timeit pd.eval("df1 + df2 + df3 + df4", engine="python")
8.11 ms +- 161 us per loop (mean +- std. dev. of 7 runs, 100 loops each)
Метод DataFrame.eval()
Помимо функции верхнего уровня pandas.eval(), вы также можете оценить выражение в «контексте» DataFrame.
In [42]: df = pd.DataFrame(np.random.randn(5, 2), columns=["a", "b"])
In [43]: df.eval("a + b")
Out[43]:
0 -0.161099
1 0.805452
2 0.747447
3 1.189042
4 -2.057490
dtype: float64
Любое выражение, которое является допустимым выражением pandas.eval(), также является допустимым выражением DataFrame.eval(), с добавлением преимущества, что вам не нужно добавлять префикс имени DataFrame к столбцу (столбцам), который (ые) вы хотите оценить.
Кроме того, вы можете выполнять присваивание столбцов в рамках выражения. Это позволяет выполнять формульное вычисление. Цель присваивания может быть новым именем столбца или существующим именем столбца, и она должна быть допустимым идентификатором Python.
In [44]: df = pd.DataFrame(dict(a=range(5), b=range(5, 10)))
In [45]: df = df.eval("c = a + b")
In [46]: df = df.eval("d = a + b + c")
In [47]: df = df.eval("a = 1")
In [48]: df
Out[48]:
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
Возвращается копия DataFrame с новыми или измененными столбцами, а исходная таблица остается неизменной.
In [49]: df
Out[49]:
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 [50]: df.eval("e = a - c")
Out[50]:
a b c d e
0 1 5 5 10 -4
1 1 6 7 14 -6
2 1 7 9 18 -8
3 1 8 11 22 -10
4 1 9 13 26 -12
In [51]: df
Out[51]:
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 [52]: df.eval(
....: """
....: c = a + b
....: d = a + b + c
....: a = 1""",
....: )
....:
Out[52]:
a b c d
0 1 5 6 12
1 1 6 7 14
2 1 7 8 16
3 1 8 9 18
4 1 9 10 20
Аналогичный код на стандартном Python:
In [53]: df = pd.DataFrame(dict(a=range(5), b=range(5, 10)))
In [54]: df["c"] = df["a"] + df["b"]
In [55]: df["d"] = df["a"] + df["b"] + df["c"]
In [56]: df["a"] = 1
In [57]: df
Out[57]:
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
Сравнение производительности eval()
Функция pandas.eval() хорошо работает с выражениями, содержащими большие массивы.
In [58]: nrows, ncols = 20000, 100
In [59]: df1, df2, df3, df4 = [pd.DataFrame(np.random.randn(nrows, ncols)) for _ in range(4)]
Арифметика с DataFrame:
In [60]: %timeit df1 + df2 + df3 + df4
7.34 ms +- 117 us per loop (mean +- std. dev. of 7 runs, 100 loops each)
In [61]: %timeit pd.eval("df1 + df2 + df3 + df4")
2.85 ms +- 58.8 us per loop (mean +- std. dev. of 7 runs, 100 loops each)
Сравнение с DataFrame:
In [62]: %timeit (df1 > 0) & (df2 > 0) & (df3 > 0) & (df4 > 0)
5.98 ms +- 37 us per loop (mean +- std. dev. of 7 runs, 100 loops each)
In [63]: %timeit pd.eval("(df1 > 0) & (df2 > 0) & (df3 > 0) & (df4 > 0)")
9.38 ms +- 36.7 us per loop (mean +- std. dev. of 7 runs, 100 loops each)
Арифметика с DataFrame с несовпадающими осями.
In [64]: s = pd.Series(np.random.randn(50))
In [65]: %timeit df1 + df2 + df3 + df4 + s
12.6 ms +- 105 us per loop (mean +- std. dev. of 7 runs, 100 loops each)
In [66]: %timeit pd.eval("df1 + df2 + df3 + df4 + s")
3.69 ms +- 62 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_.
На этом графике показано время выполнения pandas.eval() в зависимости от размера таблицы, участвующей в вычислении. Две линии соответствуют двум разным движкам.
Преимущества использования движка numexpr с pandas.eval() проявятся только в том случае, если у DataFrame более примерно 100 000 строк.
Этот график был создан с использованием DataFrame с тремя столбцами, каждый из которых содержит значения с плавающей точкой, сгенерированные с помощью numpy.random.randn().
Ограничения оценки выражений с numexpr
Выражения, которые приведут к типу данных object или включают операции с датой и временем из-за NaT , должны оцениваться в пространстве Python, но часть выражения все еще может быть оценена с помощью numexpr. Например:
In [67]: df = pd.DataFrame(
....: {"strings": np.repeat(list("cba"), 3), "nums": np.repeat(range(3), 3)}
....: )
....:
In [68]: df
Out[68]:
strings nums
0 c 0
1 c 0
2 c 0
3 b 1
4 b 1
5 b 1
6 a 2
7 a 2
8 a 2
In [69]: df.query("strings == 'a' and nums == 1")
Out[69]:
Empty DataFrame
Columns: [strings, nums]
Index: []
Числовая часть сравнения (nums == 1) будет оценена numexpr, а объектная часть сравнения ("strings == 'a') — Python.
© 2008–2022, 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/2.2.2/user_guide/enhancingperf.html