提升效能#

在本教學課程的這一部分,我們將探討如何使用 Cython、Numba 和 pandas.eval() 加快在 pandas DataFrame 上運作的特定函數。一般而言,使用 Cython 和 Numba 可以提供比使用 pandas.eval() 更快的速度,但需要更多的程式碼。

注意

除了遵循本教學課程中的步驟,強烈建議有興趣提升效能的使用者安裝 pandas 的 建議相依性。這些相依性通常不會預設安裝,但如果存在,將提供速度提升。

Cython(為 pandas 編寫 C 延伸模組)#

對於許多使用案例,使用純 Python 和 NumPy 編寫 pandas 就已足夠。然而,在某些計算密集的應用程式中,透過將工作卸載到 cython 可以達到顯著的加速。

本教學課程假設您已盡可能在 Python 中重構,例如嘗試移除 for 迴圈並利用 NumPy 向量化。最好先在 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_ff 中,因此我們將集中精力將這兩個函數 Cython 化。

純 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)
47.3 ms +- 70.2 us per loop (mean +- std. dev. of 7 runs, 10 loops each)

這已將與純 Python 方法相比的效能提升了三分之一。

宣告 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 實作快十倍以上的效能提升。

使用 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)
         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 結果。在 Cython 中,迴圈處理 ndarray 比迴圈處理 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)

效能已比先前的實作改善了將近十倍。

停用編譯器指令#

大部分時間現在花在 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 的動態即時 (JIT) 編譯器。

Numba 讓您可以撰寫純 Python 函式,透過使用 @jit 裝飾函式,將其 JIT 編譯成原生機器指令,效能類似於 C、C++ 和 Fortran。

Numba 會在匯入時間、執行時間或靜態(使用隨附的 pycc 工具)產生最佳化的機器碼,使用 LLVM 編譯器基礎架構。Numba 支援將 Python 編譯成在 CPU 或 GPU 硬體上執行,並設計為與 Python 科學軟體堆疊整合。

注意

@jit 編譯會增加函式的執行時間負擔,因此特別是在使用小型資料集時,可能無法實現效能優勢。考慮 快取 函式,以避免每次執行函式時產生編譯負擔。

Numba 可以透過 2 種方式與 pandas 搭配使用

  1. 在選定的 pandas 方法中指定 engine="numba" 關鍵字

  2. 定義您自己的 Python 函式,並加上 @jit 裝飾器,並將 SeriesDataFrame (使用 Series.to_numpy()) 的基礎 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 函式可透過傳遞其 NumPy 陣列表示法與 Series.to_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 也可用於撰寫向量化函式,而無需使用者明確迴圈處理向量的觀測值;向量化函式會自動套用至每一列。請考慮以下將每個觀測值加倍的範例

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 編譯包含不支援的 PythonNumPy 程式碼的函數,編譯將會復原為 物件模式,這很可能不會加速您的函數。如果您希望 Numba 在無法以加速程式碼的方式編譯函數時擲回錯誤,請傳遞引數 nopython=True 給 Numba(例如 @jit(nopython=True))。如需有關 Numba 模式疑難排解的更多資訊,請參閱 Numba 疑難排解頁面

使用 parallel=True(例如 @jit(parallel=True))可能會導致 SIGABRT,如果執行緒層導致不安全的行為。您可以在使用 parallel=True 執行 JIT 函數之前,先 指定安全的執行緒層

一般來說,如果您在使用 Numba 時遇到段落錯誤 (SIGSEGV),請將此問題回報給 Numba 問題追蹤器

透過 eval() 進行運算式評估#

頂層函數 pandas.eval() 實作 SeriesDataFrame 的效能表達式評估。表達式評估允許將運算表示為字串,並有可能透過一次評估大型 DataFrame 的算術和布林表達式,進而提升效能。

注意

不應將 eval() 用於簡單表達式或涉及小型資料框的表達式。事實上,對於較小的表達式或物件而言,eval() 的速度比純 Python 慢了許多個數量級。一個不錯的經驗法則就是,只有在 DataFrame 有超過 10,000 列時才使用 eval()

支援的語法#

下列運算受 pandas.eval() 支援

  • 算術運算,左移 (<<) 和右移 (>>) 運算子除外,例如:df + 2 * pi / s ** 4 % 42 - the_golden_ratio

  • 比較運算,包括鏈式比較,例如:2 < df < df2

  • 布林運算,例如:df < df2 and df3 < df4 or not df_bool

  • listtuple 文字,例如:[1, 2](1, 2)

  • 屬性存取,例如:df.a

  • 下標運算式,例如:df[0]

  • 簡單變數評估,例如:pd.eval("df")(這不太有用)

  • 數學函數:sincosexplogexpm1log1psqrtsinhcoshtanharcsinarccosarctanarccosharcsinharctanhabsarctan2log10

不允許使用以下 Python 語法

  • 表達式

    • 數學函數以外的函數呼叫。

    • is/is not 運算

    • if 表達式

    • lambda 表達式

    • list/set/dict 推導式

    • 文字 dictset 表達式

    • yield 表達式

    • 產生器表達式

    • 僅由純量值組成的布林表達式

  • 陳述式

    • 不允許 簡單複合 陳述式。這包括 forwhileif

局部變數#

您必須明確參照您想在表達式中使用的任何局部變數,方法是在名稱前面加上 @ 字元。此機制對於 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' 解析器允許更直觀的語法來表達類查詢的運算(比較、連接和分離)。特別是,&| 算子的優先順序被設為對應布林運算 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 來「與」在一起

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

此處的 andor 算子具有與 Python 中相同的優先順序。

pandas.eval() 引擎#

有兩種不同的表達式引擎。

'numexpr' 引擎是效能較佳的引擎,與標準 Python 語法相比,它可以提升大型 DataFrame 的效能。此引擎需要安裝選用的相依性 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 中執行。如果您嘗試對非 boolnp.bool_ 類型的標量運算元執行任何布林/位元運算,會引發例外狀況。

以下是顯示執行時間的圖表 pandas.eval(),作為運算中所涉及的框架大小的函數。兩條線是兩個不同的引擎。

../_images/eval-perf.png

只有當 DataFrame 有超過大約 100,000 列時,您才會看到使用 numexpr 引擎的效能優點 pandas.eval()

此圖表是使用 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 評估。