パフォーマンスの向上#
このチュートリアルのセクションでは、Cython、Numba、およびpandas.DataFrame
上で動作する特定の関数の速度を向上させる方法について調べます。pandas.eval()
を使用します。一般的に、CythonとNumbaを使用すると、pandas.eval()
を使用するよりも大幅な速度向上を実現できますが、はるかに多くのコードが必要になります。
注記
このチュートリアルに記載されている手順に従うことに加えて、パフォーマンスを向上させたいユーザーは、pandasの推奨される依存関係をインストールすることを強くお勧めします。これらの依存関係はデフォルトではインストールされないことがよくありますが、存在すれば速度が向上します。
Cython (pandas用のC拡張機能の記述)#
多くのユースケースでは、純粋なPythonとNumPyでpandasを記述するだけで十分です。ただし、計算負荷の高いアプリケーションでは、作業をcythonにオフロードすることで、かなりの速度向上を実現できる場合があります。
このチュートリアルでは、forループの削除やNumPyのベクトル化の活用など、Pythonで可能な限り多くのリファクタリングを行ったことを前提としています。まずPythonで最適化を試みる価値は常にあります。
このチュートリアルでは、遅い計算をCython化する「典型的な」プロセスを説明します。 Cythonのドキュメントの例をpandasのコンテキストで使用します。最終的なCython化されたソリューションは、純粋なPythonソリューションよりも約100倍高速です。
純粋な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)
91 ms +- 432 us 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
605968 function calls (605950 primitive calls) in 0.170 seconds
Ordered by: internal time
List reduced from 166 to 4 due to restriction <4>
ncalls tottime percall cumtime percall filename:lineno(function)
1000 0.100 0.000 0.151 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.013 0.000 series.py:1086(__getitem__)
3000 0.002 0.000 0.006 0.000 series.py:1211(_get_value)
圧倒的に多くの時間がintegrate_f
またはf
内で費やされているため、これらの2つの関数の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)
47.3 ms +- 70.2 us per loop (mean +- std. dev. of 7 runs, 10 loops each)
これにより、純粋なPythonアプローチと比較して、パフォーマンスが3分の1向上しました。
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.95 ms +- 8.93 us per loop (mean +- std. dev. of 7 runs, 100 loops each)
関数にC型の注釈を付けることで、元のPython実装と比較して10倍以上の性能向上を実現します。
ndarrayの使用#
プロファイリングし直すと、各行からSeries
を作成し、インデックスとシリーズの両方から__getitem__
を呼び出す(各行で3回)のに時間がかかっています。(各行で3回)。これらのPython関数の呼び出しはコストが高いため、np.ndarray
を渡すことで改善できます。
In [12]: %prun -l 4 df.apply(lambda x: integrate_f_typed(x["a"], x["b"], x["N"]), axis=1)
52545 function calls (52527 primitive calls) in 0.019 seconds
Ordered by: internal time
List reduced from 164 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:1086(__getitem__)
3000 0.002 0.000 0.006 0.000 series.py:1211(_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_30a836062691f1794ff3b6c6d990f6ad5dccd13e.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())
831 us +- 4.54 us per loop (mean +- std. dev. of 7 runs, 1,000 loops each)
パフォーマンスは、以前の実装と比べて約10倍向上しました。
コンパイラディレクティブの無効化#
現在、ほとんどの時間が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:4050(__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_1acd0c4ec62f802e66ab641a1e7f5f3138567e90.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 +- 3.17 us per loop (mean +- std. dev. of 7 runs, 1,000 loops each)
ただし、配列内の無効な場所にアクセスするループインデクサi
は、メモリへのアクセスがチェックされないため、セグメントフォルトを引き起こします。boundscheck
とwraparound
の詳細については、コンパイラディレクティブに関するCythonのドキュメントを参照してください。
Numba (JITコンパイル)#
Cythonコードを静的にコンパイルする代わりに、Numbaを使用して動的なJust-In-Time (JIT)コンパイラを使用できます。
Numbaを使用すると、C、C++、Fortranと同等の性能を持つネイティブマシン命令にJITコンパイルできる純粋なPython関数を記述できます。関数を@jit
でデコレートすることで実現します。
Numbaは、インポート時、実行時、または静的に(付属のpyccツールを使用)LLVMコンパイラインフラストラクチャを使用して最適化されたマシンコードを生成することによって機能します。Numbaは、CPUまたはGPUハードウェアで実行されるPythonのコンパイルをサポートしており、Python科学ソフトウェアスタックとの統合を目的として設計されています。
注記
@jit
コンパイルにより、関数のランタイムにオーバーヘッドが追加されるため、特に小さなデータセットを使用する場合は、パフォーマンスのメリットが得られない可能性があります。関数のキャッシングを検討して、関数が実行されるたびにコンパイルオーバーヘッドを回避してください。
Numbaはpandasで2つの方法で使用できます。
選択されたpandasメソッドで
engine="numba"
キーワードを指定します。@jit
でデコレートされた独自のPython関数を定義し、Series
またはDataFrame
の基盤となるNumPy配列(Series.to_numpy()
を使用)を関数に渡します。
pandas Numbaエンジン#
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エンジンは大量のデータポイント(例:100万以上)で高性能です。
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
に設定して1つ以上の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)
カスタム関数例#
@jit
でデコレートされたカスタムPython関数は、Series.to_numpy()
を使用してNumPy配列表現を渡すことで、pandasオブジェクトで使用できます。
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を使用して、ベクトルの観測値を明示的にループ処理する必要のないベクトル化関数を記述することもできます。ベクトル化関数は各行に自動的に適用されます。各観測値を2倍にする次の例を考えてみましょう。
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配列に数値関数を適用する関数の高速化に最も優れています。サポートされていないPythonまたはNumPyコードを含む関数を@jit
しようとすると、コンパイルはオブジェクトモードに戻り、ほとんどの場合、関数の速度は向上しません。Numbaがコードの速度を向上させる方法で関数をコンパイルできない場合にエラーをスローするようにしたい場合は、Numbaにnopython=True
(例:@jit(nopython=True)
)引数を渡します。Numbaモードのトラブルシューティングの詳細については、Numbaトラブルシューティングページを参照してください。
parallel=True
(例:@jit(parallel=True)
)を使用すると、スレッド層が安全でない動作につながる場合、SIGABRT
が発生する可能性があります。parallel=True
を使用してJIT関数を実行する前に、まず安全なスレッド層を指定することができます。
一般的に、Numbaを使用中にセグメント違反(SIGSEGV
)が発生した場合は、Numbaのissue trackerに問題を報告してください。
eval()
による式評価#
最上位関数pandas.eval()
は、Series
とDataFrame
の高性能な式評価を実装します。式評価により、演算を文字列として表現することができ、大きなDataFrame
に対して算術演算とブール演算を一度に評価することで、性能が向上する可能性があります。
注記
単純な式や、小さなDataFrameを含む式にはeval()
を使用しないでください。実際、eval()
は、小さな式やオブジェクトに対しては、通常のPythonよりも桁違いに遅くなります。良い経験則としては、1万行以上のDataFrame
がある場合にのみeval()
を使用することです。
サポートされる構文#
これらの演算はpandas.eval()
でサポートされています。
左シフト(
<<
)演算子と右シフト(>>
)演算子を除く算術演算。例:df + 2 * pi / s ** 4 % 42 - the_golden_ratio
連鎖比較を含む比較演算。例:
2 < df < df2
ブール演算。例:
df < df2 and df3 < df4 or not df_bool
list
およびtuple
リテラル。例:[1, 2]
または(1, 2)
属性アクセス。例:
df.a
添字式。例:
df[0]
単純な変数評価。例:
pd.eval("df")
(これはあまり役に立ちません)数学関数:
sin
、cos
、exp
、log
、expm1
、log1p
、sqrt
、sinh
、cosh
、tanh
、arcsin
、arccos
、arctan
、arccosh
、arcsinh
、arctanh
、abs
、arctan2
、log10
。
次のPython構文は**許可されません**
式
数学関数以外の関数呼び出し。
is
/is not
演算if
式lambda
式list
/set
/dict
内包表記リテラル
dict
およびset
式yield
式ジェネレータ式
スカラー値のみからなるブール式
文
ローカル変数#
式で使用したいローカル変数は、名前の前に@
文字を付けることで、*明示的に参照*する必要があります。このメカニズムは、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()
パーサー#
2つの異なる式構文パーサーがあります。
デフォルトの'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
を使って「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
pandas.eval()
エンジン#
2つの異なる式エンジンがあります。
'numexpr'
エンジンは、より高性能なエンジンであり、大規模なDataFrame
に対して、標準的なPython構文と比較してパフォーマンスの向上をもたらす可能性があります。このエンジンを使用するには、オプションの依存関係であるnumexpr
をインストールする必要があります。
'python'
エンジンは、一般的に、他の評価エンジンとのテストを除いては役に立ちません。eval()
をengine='python'
で使用しても、パフォーマンス上のメリットは**全く**なく、パフォーマンスの低下を招く可能性があります。
In [40]: %timeit df1 + df2 + df3 + df4
6.88 ms +- 49.8 us per loop (mean +- std. dev. of 7 runs, 100 loops each)
In [41]: %timeit pd.eval("df1 + df2 + df3 + df4", engine="python")
7.52 ms +- 23.8 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.11 ms +- 195 us per loop (mean +- std. dev. of 7 runs, 100 loops each)
In [61]: %timeit pd.eval("df1 + df2 + df3 + df4")
2.79 ms +- 16.6 us per loop (mean +- std. dev. of 7 runs, 100 loops each)
DataFrame
比較
In [62]: %timeit (df1 > 0) & (df2 > 0) & (df3 > 0) & (df4 > 0)
6.01 ms +- 56 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.31 ms +- 53.2 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.5 ms +- 198 us per loop (mean +- std. dev. of 7 runs, 100 loops each)
In [66]: %timeit pd.eval("df1 + df2 + df3 + df4 + s")
3.59 ms +- 38.7 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()
の実行時間を示すプロットを次に示します。2つの線は2つの異なるエンジンを表しています。

pandas.eval()
でnumexpr
エンジンを使用することによるパフォーマンス上のメリットは、DataFrame
の行数が約10万行を超える場合にのみ見られます。
このプロットは、numpy.random.randn()
を使用して生成された浮動小数点値をそれぞれ含む3つの列を持つDataFrame
を使用して作成されました。
numexpr
での式評価の制限#
NaT
のためにオブジェクトdtypeになるか、日付時刻の演算が含まれる式は、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によって評価されます。