パフォーマンスの向上#

このチュートリアルのセクションでは、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型の宣言#

関数変数と戻り値の型に注釈を付け、cdefcpdefを使用してパフォーマンスを向上させることができます。

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_fnp.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のboundscheckwraparoundチェックを無効にすると、パフォーマンスをさらに向上させることができます。

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は、メモリへのアクセスがチェックされないため、セグメントフォルトを引き起こします。boundscheckwraparoundの詳細については、コンパイラディレクティブに関する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つの方法で使用できます。

  1. 選択されたpandasメソッドでengine="numba"キーワードを指定します。

  2. @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が含まれている場合、parallelTrueに設定して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()は、SeriesDataFrameの高性能な式評価を実装します。式評価により、演算を文字列として表現することができ、大きな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")(これはあまり役に立ちません)

  • 数学関数:sincosexplogexpm1log1psqrtsinhcoshtanharcsinarccosarctanarccosharcsinharctanhabsarctan2log10

次のPython構文は**許可されません**

    • 数学関数以外の関数呼び出し。

    • is/is not演算

    • if

    • lambda

    • list/set/dict内包表記

    • リテラルdictおよびset

    • yield

    • ジェネレータ式

    • スカラー値のみからなるブール式

    • 単純な文と複合文のどちらも許可されません。これにはforwhile、および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()パーサー#

2つの異なる式構文パーサーがあります。

デフォルトの'pandas'パーサーは、クエリのような操作(比較、接続、論理和)を表現するためのより直感的な構文を許可します。特に、&|演算子の優先順位は、対応するブール演算andorの優先順位と同じになります。

たとえば、上記の接続は括弧なしで記述できます。あるいは、'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

ここで使用されているand演算子とor演算子の優先順位は、Pythonの場合と同じです。

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つの異なるエンジンを表しています。

../_images/eval-perf.png

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によって評価されます。