提升性能#

在本教程的这一部分,我们将使用 Cython、Numba 和 DataFrame 来研究如何加速在 pandas pandas.eval() 上运行的某些函数。通常,使用 Cython 和 Numba 可以比使用 pandas.eval() 提供更大的加速,但需要更多的代码。

备注

除了遵循本教程的步骤外,强烈建议对性能有兴趣的用户安装 pandas 的 recommended dependencies 。这些依赖项通常默认不安装,但如果存在,将提供速度提升。

Cython(为 pandas 编写 C 扩展)#

对于许多用例,使用纯 Python 和 NumPy 编写 pandas 已经足够。然而,在一些计算密集型应用中,可以通过将工作卸载到 cython 来实现显著的加速。

本教程假定您已经尽可能地重构了 Python 代码,例如,尝试移除 for 循环并利用 NumPy 向量化。首先在 Python 中进行优化总是值得的。

本教程将介绍一个“典型”的 cythonizing 慢计算过程。我们将使用一个来自 Cython 文档的示例:example from the Cython documentation ,但在 pandas 的上下文中。我们最终的 cythonized 解决方案比纯 Python 解决方案快大约 100 倍。

Pure Python#

我们有一个 DataFrame ,想对其逐行应用一个函数。

下面是用纯 Python 实现的函数:

我们通过使用 DataFrame.apply() (逐行) 来实现结果:

让我们使用 prun ipython magic function 来看一下这个操作中时间花费在哪里:

到目前为止,大部分时间都花费在 integrate_ff 中,因此我们将重点放在 Cython 化这两个函数上。

纯 Cython#

首先,我们需要将 Cython magic function 导入 IPython:

现在,让我们简单地将函数复制到 Cython 中:

与纯 Python 方法相比,这已经将性能提升了三分之一。

声明 C 类型#

我们可以为函数变量和返回类型添加注解,并使用 cdefcpdef 来提升性能:

为函数添加 C 类型注解,与原始 Python 实现相比,性能提升了十倍以上。

使用 ndarray#

重新分析时,时间花在了从每一行创建一个 Series ,以及从索引和 Series 中调用 __getitem__ (每行调用三次)。这些 Python 函数调用开销很大,可以通过传递 np.ndarray 来改进。

这个实现创建了一个零数组,并将应用于每一行的 integrate_f_typed 的结果插入其中。在 Cython 中,遍历 ndarray 比遍历 Series 对象更快。

由于 apply_integrate_f 被类型化为接受 np.ndarray,因此需要调用 Series.to_numpy() 来使用此函数。

与之前的实现相比,性能提升了近十倍。

禁用编译器指令#

现在大部分时间都花在 apply_integrate_f 中。禁用 Cython 的 boundscheckwraparound 检查可以带来更多性能提升。

然而,如果一个循环索引 i 访问数组中的无效位置,将会导致段错误,因为内存访问没有被检查。有关 boundscheckwraparound 的更多信息,请参阅 Cython 文档中的 compiler directives

Numba (JIT 编译)#

除了静态编译 Cython 代码外,还可以使用 Numba 的动态即时 (JIT) 编译器。

Numba 允许您编写一个纯 Python 函数,该函数可以通过用 @jit 装饰您的函数来 JIT 编译为本地机器指令,性能与 C、C++ 和 Fortran 类似。

Numba 通过在导入时、运行时或静态地 (使用包含的 pycc 工具) 使用 LLVM 编译器基础设施生成优化的机器代码。Numba 支持将 Python 编译为在 CPU 或 GPU 硬件上运行,并且设计用于与 Python 科学软件栈集成。

备注

@jit 编译会增加函数运行时间的开销,因此性能优势可能不会显现,尤其是在使用小型数据集时。可以考虑 caching 您的函数,以避免每次运行函数时都产生编译开销。

Numba 可以通过 2 种方式与 pandas 一起使用:

  1. 在 select pandas 方法中指定 engine="numba" 关键字

  2. 定义一个用 @jit 装饰的 Python 函数,并将 SeriesDataFrame 的底层 NumPy 数组 (使用 Series.to_numpy() 传递给函数

pandas Numba 引擎#

如果安装了 Numba,可以在 select 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在:class:DataFrame 的列上并行化计算;因此,这种性能优势仅对具有大量列的:class: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数组表示形式(使用:meth: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 代码的函数,编译将回退到 object mode ,这很可能不会加速你的函数。如果你希望Numba在无法以加速代码的方式编译函数时抛出错误,请向Numba传递参数``nopython=True``(例如``@jit(nopython=True)``)。有关调试Numba模式的更多信息,请参阅 Numba troubleshooting page

使用``parallel=True``(例如``@jit(parallel=True)``)可能会导致``SIGABRT``,如果线程层导致了不安全行为。在运行具有``parallel=True``的JIT函数之前,你可以先 specify a safe threading layer

一般而言,如果你在使用Numba时遇到段错误(SIGSEGV),请向 Numba issue tracker. 报告该问题。

通过:func:~pandas.eval 进行表达式求值#

顶层函数:func:pandas.eval 实现了对:class:~pandas.Series 和:class:~pandas.DataFrame 的高效表达式求值。表达式求值允许将操作表示为字符串,并且通过一次性评估大型:class:~pandas.DataFrame 的算术和布尔表达式,可以带来潜在的性能提升。

备注

你不应该对简单表达式或涉及小型DataFrame的表达式使用:func:~pandas.eval 。事实上,对于较小的表达式或对象,eval() 比纯Python慢几个数量级。一个经验法则是,只有当你有一个拥有超过10,000行的:func:~pandas.eval 时才使用:class:.DataFrame

支持的语法#

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”)``(这个不太有用)

  • 数学函数:sin, cos, exp, log, expm1, log1p, sqrt, sinh, cosh, tanh, arcsin, arccos, arctan, arccosh, arcsinh, arctanh, abs, arctan2log10

以下Python语法是 不允许 的:

  • 表达式

    • 除数学函数外的函数调用。

    • is/is not 操作

    • if 表达式

    • lambda 表达式

    • list/set/dict 推导式

    • 字面量 dictset 表达式

    • yield 表达式

    • 生成器表达式

    • 仅包含标量值的布尔表达式

  • 语句

    • Neither simple or compound statements are allowed. This includes for, while, and if.

局部变量#

您必须*显式引用*您想在表达式中使用的任何局部变量,方法是在变量名前加上 @ 字符。该机制与 DataFrame.query()DataFrame.eval() 相同。例如:

如果您不在局部变量前加上 @,pandas 将会引发一个异常,告知您该变量未定义。

在使用 DataFrame.eval()DataFrame.query() 时,这允许您在表达式中拥有一个与 DataFrame 列同名的局部变量。

警告

pandas.eval() 如果您无法使用 @ 前缀(因为它在该上下文中未定义),将会引发异常。

在这种情况下,您应该像在标准 Python 中一样引用变量。

pandas.eval() 解析器#

有两种不同的表达式语法解析器。

默认的 'pandas' 解析器允许更直观的语法来表达查询类操作(比较、合取和析取)。特别是,&| 运算符的优先级与相应的布尔运算 andor 的优先级相同。

例如,上面的合取可以不带括号地编写。或者,您可以使用 'python' 解析器来强制执行严格的 Python 语义。

可以使用关键字 and 将相同的表达式“and”在一起:

这里的 andor 运算符的优先级与 Python 中的优先级相同。

pandas.eval() 引擎#

有两种不同的表达式引擎。

'numexpr' 引擎性能更优,与标准 Python 语法相比,对于大型 DataFrame 可以带来性能提升。该引擎需要安装可选的依赖 numexpr

'python' 引擎通常*没有*用处,除非用于测试其他评估引擎。使用 eval()``engine=’python’``**不会**获得任何性能提升,并可能导致性能下降。

DataFrame.eval() 方法#

除了顶层的 pandas.eval() 函数外,您还可以在 DataFrame 的“上下文中”评估表达式。

任何有效的 pandas.eval() 表达式也是有效的 DataFrame.eval() 表达式,其额外的好处是您不必在要评估的列名前加上 DataFrame 的名称。

此外,您还可以在表达式中执行列的赋值。这允许“公式化评估”。赋值目标可以是新列名或现有列名,并且它必须是有效的 Python 标识符。

返回一个包含新列或修改后列的 DataFrame 的副本,并且原始框架保持不变。

可以通过使用多行字符串来执行多个列赋值。

标准 Python 中的等价操作是:

eval() 性能比较#

pandas.eval() 结合包含大数组的表达式效果很好。

DataFrame 算术运算:

DataFrame 比较:

DataFrame 带有未对齐轴的算术运算。

备注

诸如

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

只有当您的 pandas.eval() 具有大约 100,000 行以上时,您才能看到使用 numexpr 引擎的 DataFrame 的性能优势。

这张图使用了一个 DataFrame ,其中包含 3 列,每列都包含使用 numpy.random.randn() 生成的浮点值。

numexpr 的表达式求值限制#

如果表达式求值会产生一个 object dtyped 对象,或者由于 NaT 而涉及 datetime 操作,则必须在 Python 空间中进行求值,但表达式的一部分仍然可以使用 numexpr 进行求值。例如:

比较的数值部分 (nums == 1) 将由 numexpr 求值,而比较的 object 部分 ("strings == 'a') 将由 Python 求值。