Повышение производительности
В этой части руководства мы рассмотрим, как ускорить некоторые функции, работающие с pandas DataFrame, используя Cython, Numba и pandas.eval(). Как правило, использование Cython и Numba может обеспечить более значительное ускорение, чем использование pandas.eval(), но потребует гораздо больше кода.
Примечание
Помимо выполнения шагов из этого руководства, пользователям, заинтересованным в повышении производительности, настоятельно рекомендуется установить рекомендуемые зависимости для pandas. Эти зависимости часто не устанавливаются по умолчанию, но при наличии могут повысить производительность.
Cython (написание расширений C для pandas)
Во многих случаях для работы с pandas достаточно использовать чистый Python и NumPy. Однако в некоторых приложениях с интенсивными вычислениями можно добиться существенного ускорения, перенеся выполнение задач в Cython.
В этом руководстве предполагается, что вы уже максимально переработали код на Python, например, попытались убрать циклы for и использовать векторизацию NumPy. Всегда стоит сначала оптимизировать код на Python.
В этом руководстве описан типичный процесс преобразования медленных вычислений с помощью Cython. Мы используем пример из документации Cython, адаптировав его для pandas. Наше итоговое решение на Cython работает примерно в 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)
80.3 ms +- 1.18 ms per loop (mean +- std. dev. of 7 runs, 10 loops each)
Посмотрим, на что уходит время при выполнении этой операции, воспользовавшись магической командой prun в IPython:
# 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.173 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.101 0.000 0.154 0.000 <ipython-input-4-c2a74e076cf0>:1(integrate_f)
552423 0.053 0.000 0.053 0.000 <ipython-input-3-c138bdd570e3>:1(f)
3000 0.003 0.000 0.013 0.000 series.py:1104(__getitem__)
3000 0.002 0.000 0.006 0.000 series.py:1229(_get_value)
Основная часть времени уходит на выполнение integrate_f или f, поэтому мы сосредоточимся на преобразовании этих двух функций с помощью Cython.
Обычный Cython
Сначала нужно импортировать в IPython магическую функцию Cython:
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)
48.7 ms +- 490 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.5 ms +- 29.6 us per loop (mean +- std. dev. of 7 runs, 100 loops each)
Аннотирование функций типами C повышает производительность более чем в десять раз по сравнению с исходной реализацией на Python.
Использование ndarray
Повторный анализ производительности показывает, что время уходит на создание Series для каждой строки и вызов __getitem__ как для индекса, так и для Series (по три раза для каждой строки). Эти вызовы функций 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:1104(__getitem__)
3000 0.002 0.000 0.005 0.000 series.py:1229(_get_value)
3000 0.002 0.000 0.003 0.000 indexing.py:2765(check_dict_or_set_indexers)
3000 0.002 0.000 0.002 0.000 base.py:3784(get_loc)
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_1f8c1b875aeb076a8ef75ac5199664d0fea77dfb626f30a4e36b3263c3db7ec2.c:1138:
/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())
830 us +- 945 ns 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:4067(__getitem__)
3 0.000 0.000 0.000 0.000 base.py:545(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_344a9e4468707236d239faf5bdfacf0d14a35efa7e89d2a7b09ae36b339492db.c:1139:
/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())
624 us +- 2.43 us per loop (mean +- std. dev. of 7 runs, 1,000 loops each)
Однако если индексатор цикла i обратится к недопустимой позиции в массиве, это приведёт к ошибке сегментации, поскольку доступ к памяти не проверяется. Подробнее о boundscheck и wraparound см. в документации Cython в разделе директивы компилятора.
Numba (JIT-компиляция)
Вместо статической компиляции кода Cython можно использовать динамический JIT-компилятор Numba (Numba).
Numba позволяет написать функцию на чистом Python, которая затем JIT-компилируется в машинные инструкции и работает с производительностью, сопоставимой с C, C++ и Fortran. Для этого нужно декорировать функцию с помощью @jit.
Numba генерирует оптимизированный машинный код с помощью инфраструктуры компилятора LLVM во время импорта, выполнения или статически (с помощью входящего в комплект инструмента pycc). Numba поддерживает компиляцию Python для выполнения на CPU и GPU и предназначена для интеграции с научным стеком программного обеспечения Python.
Примечание
Компиляция @jit увеличивает время выполнения функции, поэтому преимущества в производительности могут быть незаметны, особенно при работе с небольшими наборами данных. Чтобы избежать затрат времени на компиляцию при каждом запуске функции, рассмотрите возможность кэширования функции.
Numba можно использовать с pandas двумя способами:
Указать ключевое слово
engine="numba"в некоторых методах pandasОпределить собственную функцию Python, декорированную с помощью
@jit, и передать в неё базовый массив NumPy объектаSeriesилиDataFrame(используяSeries.to_numpy())
Движок Numba в pandas
Если Numba установлена, в некоторых методах pandas можно указать engine="numba", чтобы выполнить метод с помощью 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)
Если вычислительное оборудование оснащено несколькими CPU, наибольшего прироста производительности можно добиться, установив parallel в True, чтобы задействовать более одного CPU. Внутри 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, компиляция перейдёт в режим объектов, который, скорее всего, не ускорит выполнение функции. Если вы хотите, чтобы Numba выдавала ошибку, когда не может скомпилировать функцию способом, ускоряющим ваш код, передайте ей аргумент nopython=True (например, @jit(nopython=True)). Подробнее об устранении неполадок в режимах Numba см. на странице устранения неполадок Numba.
Использование parallel=True (например, @jit(parallel=True)) может привести к SIGABRT, если уровень потоков вызывает небезопасное поведение. Прежде чем запускать JIT-функцию с parallel=True, можно указать безопасный уровень потоков.
Если при использовании Numba возникает ошибка сегментации (SIGSEGV), сообщите о проблеме в систему отслеживания ошибок 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_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Генераторные выражения
Логические выражения, состоящие только из скалярных значений
-
Инструкции
Локальные переменные
Необходимо явно указывать любую локальную переменную, которую нужно использовать в выражении: поставьте символ @ перед ее именем. Этот механизм одинаков для 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:3579 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:328 in eval
_check_for_locals(expr, level, parser)
File ~/work/pandas/pandas/pandas/core/computation/eval.py:170 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.75 ms +- 186 us per loop (mean +- std. dev. of 7 runs, 100 loops each)
In [41]: %timeit pd.eval("df1 + df2 + df3 + df4", engine="python")
8.54 ms +- 73.2 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.83 ms +- 224 us per loop (mean +- std. dev. of 7 runs, 100 loops each)
In [61]: %timeit pd.eval("df1 + df2 + df3 + df4")
3.16 ms +- 106 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.53 ms +- 34 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)")
8.68 ms +- 53.9 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.7 ms +- 83.8 us per loop (mean +- std. dev. of 7 runs, 100 loops each)
In [66]: %timeit pd.eval("df1 + df2 + df3 + df4 + s")
3.65 ms +- 45.4 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 с 3 столбцами, каждый из которых содержит числа с плавающей точкой, сгенерированные с помощью numpy.random.randn().
Ограничения вычисления выражений с numexpr
Выражения, результатом которых будет объектный тип данных или которые включают операции с датами и временем из-за 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–2011, AQR Capital Management, LLC, Lambda Foundry, Inc. and PyData Development Team
© 2011–2025, Open source contributors
Licensed under the 3-clause BSD License.
https://pandas.pydata.org/pandas-docs/version/2.3.0/user_guide/enhancingperf.html