提升性能#
在本教程的这一部分,我们将使用 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_f 或 f 中,因此我们将重点放在 Cython 化这两个函数上。
纯 Cython#
首先,我们需要将 Cython magic function 导入 IPython:
现在,让我们简单地将函数复制到 Cython 中:
与纯 Python 方法相比,这已经将性能提升了三分之一。
声明 C 类型#
我们可以为函数变量和返回类型添加注解,并使用 cdef 和 cpdef 来提升性能:
为函数添加 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 的 boundscheck 和 wraparound 检查可以带来更多性能提升。
然而,如果一个循环索引 i 访问数组中的无效位置,将会导致段错误,因为内存访问没有被检查。有关 boundscheck 和 wraparound 的更多信息,请参阅 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 一起使用:
在 select pandas 方法中指定
engine="numba"关键字定义一个用
@jit装饰的 Python 函数,并将Series或DataFrame的底层 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``一个包含不支持的 Python 或 NumPy 代码的函数,编译将回退到 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``
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() 相同。例如:
如果您不在局部变量前加上 @,pandas 将会引发一个异常,告知您该变量未定义。
在使用 DataFrame.eval() 和 DataFrame.query() 时,这允许您在表达式中拥有一个与 DataFrame 列同名的局部变量。
pandas.eval() 解析器#
有两种不同的表达式语法解析器。
默认的 'pandas' 解析器允许更直观的语法来表达查询类操作(比较、合取和析取)。特别是,& 和 | 运算符的优先级与相应的布尔运算 and 和 or 的优先级相同。
例如,上面的合取可以不带括号地编写。或者,您可以使用 'python' 解析器来强制执行严格的 Python 语义。
可以使用关键字 and 将相同的表达式“and”在一起:
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 中执行。如果您尝试对非 bool 或 np.bool_ 类型的标量操作数执行任何布尔/位运算,将会引发异常。
下面是一张图,显示了 pandas.eval() 的运行时间与涉及计算的帧大小的关系。两条线代表两个不同的引擎。
只有当您的 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 求值。