熊猫的 for 循环真的很糟糕吗? 我什么时候应该关心?

for循环真的“很糟糕”吗?如果没有,在什么情况下,他们会比使用更传统的“向量化”方法更好

我熟悉“向量化”的概念,以及大熊猫如何使用向量化技术来加快计算速度。向量化函数在整个系列或 DataFrame 上广播操作,以实现比传统的在数据上迭代大得多的加速。

然而,我非常惊讶地看到许多代码(包括来自 Stack Overflow 的答案)为使用 for循环和列表理解在数据中循环的问题提供了解决方案。文档和 API 指出,循环是“坏的”,人们应该“永远”不要在数组、序列或 DataFrames 上迭代。那么,为什么我有时会看到用户建议使用基于循环的解决方案呢?


1-虽然这个问题听起来有点宽泛,但事实是,在非常特定的情况下,for循环通常比传统的对数据进行迭代要好。这篇文章的目的是为后人捕捉这一点。

31096 次浏览

不,for循环并不总是“坏”的,至少不总是。它可能是 更准确地说,一些向量化的操作比迭代更慢,而不是说迭代比某些向量化操作更快。知道何时以及为什么是从代码中获得最佳性能的关键。简而言之,在这些情况下,值得考虑一种替代向量化大熊猫功能的方法:

  1. 当您的数据很小时(... 取决于您正在做的事情) ,
  2. 当处理 object/混合 dtype 时
  3. 当使用 str/regex 访问器函数时

让我们分别研究一下这些情况。


小数据的迭代 v/s 矢量化

大熊猫在其 API 设计中采用了 “约定优于配置”方法。这意味着同样的空气污染指数已经适应了广泛的数据和用例。

当调用熊猫函数时,下列事项(以及其他事项)必须由该函数内部处理,以确保正常工作

  1. 索引/轴对齐
  2. 处理混合数据类型
  3. 处理丢失的数据

几乎每个函数都必须在不同程度上处理这些问题,这就提供了一个 在头顶上。数值函数(例如 Series.add)的开销较小,而字符串函数(例如 Series.str.replace)的开销更大。

另一方面,for 循环比你想象的要快。更好的是 列表理解法(通过 for循环创建列表)更快,因为它们是优化的创建列表的迭代机制。

列表理解遵循这种模式

[f(x) for x in seq]

其中 seq是熊猫系列或 DataFrame 列。或者,当操作多个列时,

[f(x, y) for x, y in zip(seq1, seq2)]

其中 seq1seq2是列。

数值比较
考虑一个简单的布尔索引操作。这种列表内涵方法是根据 Series.ne(!=)和 query计时的。功能如下:

# Boolean indexing with Numeric value comparison.
df[df.A != df.B]                            # vectorized !=
df.query('A != B')                          # query (numexpr)
df[[x != y for x, y in zip(df.A, df.B)]]    # list comp

为了简单起见,我使用了 perfplot包来运行本文中的所有时间测试。上述行动的时间安排如下:

enter image description here

对于中等大小的 n,列表内涵优于 query,甚至优于向量化的“不等于比较小的 n”。不幸的是,列表内涵是线性伸缩的,所以对于较大的 n,它不能提供太多的性能增益。

注意
值得一提的是,列表内涵的许多好处来自于不必担心指数走势, 但这意味着如果代码依赖于索引对齐方式, 在某些情况下,向量操作在 底层 NumPy 数组可以被认为是带来了“最好的 两个世界”,允许向量化 没有所有不必要的大熊猫功能开销。这意味着您可以将上面的操作重写为

df[df.A.values != df.B.values]

它的表现优于熊猫和列表内涵:

NumPy 向量化超出了本文的讨论范围,但是如果性能很重要的话,它绝对值得考虑。

价值计算
再举一个例子——这次是另一个普通的 python 构造,它是 再快点而不是 for 循环—— collections.Counter。一个常见的需求是计算值计数并将结果作为字典返回。这是通过 value_countsnp.uniqueCounter完成的:

# Value Counts comparison.
ser.value_counts(sort=False).to_dict()           # value_counts
dict(zip(*np.unique(ser, return_counts=True)))   # np.unique
Counter(ser)                                     # Counter

enter image description here

结果更加明显,在较大范围的小 N (~ 3500)中,Counter胜过了两种矢量化方法。

注意
更多的琐事(承蒙@user2357112) Counter的实现使用了一个 < a href = “ https://github.com/python/cpython/blob/v3.7.0/Module/_ Collectionsmdule.c # L2249-L2354”rel = “ noReferrer”> C 加速器, 因此,虽然它仍然必须使用 python 对象,而不是使用 底层的 C 数据类型,它仍然比 for循环快 力量!

当然,从这里得到的结论是性能取决于您的数据和用例。这些例子的要点是说服您不要排除这些解决方案作为合法选择的可能性。如果这些仍然不能给你所需要的性能,总是有 Cython笨蛋。让我们把这个测试加入进来。

from numba import njit, prange


@njit(parallel=True)
def get_mask(x, y):
result = [False] * len(x)
for i in prange(len(x)):
result[i] = x[i] != y[i]
    

return np.array(result)


df[get_mask(df.A.values, df.B.values)] # numba

enter image description here

Numba 为非常强大的向量化代码提供了循环 Python 代码的 JIT 编译。理解如何让数字舞起作用需要一个学习曲线。


混合/object dtype 操作

基于字符串的比较
重新查看第一部分中的过滤示例,如果要比较的列是字符串,会怎么样?考虑上面相同的3个函数,但是输入 DataFrame 强制转换为字符串。

# Boolean indexing with string value comparison.
df[df.A != df.B]                            # vectorized !=
df.query('A != B')                          # query (numexpr)
df[[x != y for x, y in zip(df.A, df.B)]]    # list comp

enter image description here

那么,是什么改变了呢?这里需要注意的是,字符串运算本身就很难向量化。熊猫将字符串视为对象,对象上的所有操作都回落到一个缓慢的、循环的实现。

现在,因为这个循环实现被上面提到的所有开销所包围,所以这些解决方案之间存在一个恒定的大小差异,即使它们的比例是相同的。

当涉及到对可变/复杂对象的操作时,没有比较。列表内涵的表现优于所有涉及字典和列表的操作。

按键访问字典值
下面是从一列字典中提取值的两个操作的计时: map和列表内涵。设置在附录的“代码段”标题下。

# Dictionary value extraction.
ser.map(operator.itemgetter('value'))     # map
pd.Series([x.get('value') for x in ser])  # list comprehension

enter image description here

定位列表索引
从列列表(处理异常)、 mapstr.get访问器方法和列表内涵中提取第0个元素的3个操作的计时:

# List positional indexing.
def get_0th(lst):
try:
return lst[0]
# Handle empty lists and NaNs gracefully.
except (IndexError, TypeError):
return np.nan
ser.map(get_0th)                                          # map
ser.str[0]                                                # str accessor
pd.Series([x[0] if len(x) > 0 else np.nan for x in ser])  # list comp
pd.Series([get_0th(x) for x in ser])                      # list comp safe

注意
如果指数很重要,你可能会这样做:

pd.Series([...], index=ser.index)

在重建序列的时候。

enter image description here

列表扁平化
最后一个例子是扁平化列表,这是另一个常见的问题,它演示了纯 Python 的强大之处。

# Nested list flattening.
pd.DataFrame(ser.tolist()).stack().reset_index(drop=True)  # stack
pd.Series(list(chain.from_iterable(ser.tolist())))         # itertools.chain
pd.Series([y for x in ser for y in x])                     # nested list comp

enter image description here

无论是 itertools.chain.from_iterable还是嵌套的列表内涵都是纯 Python 构造,并且比 stack解决方案的伸缩性要好得多。

这些时间安排有力地表明了一个事实,即大熊猫并不具备使用混合 dtype 的能力,你也许应该避免使用这种方法。在可能的情况下,数据应该以标量值(int/float/string)的形式显示在单独的列中。

最后,这些解决方案的适用性很大程度上取决于您的数据。因此,最好的办法是在决定使用什么之前对数据测试这些操作。注意我没有在这些解决方案上计时 apply,因为它会使图形倾斜(是的,就是这么慢)。


正则表达式操作和 .str访问器方法

熊猫可以对字符串列应用正则表达式操作,如 str.containsstr.extractstr.extractall,以及其他“向量化”字符串运算(如 str.splitstr.findstr.translate等)。这些函数比列表理解慢,而且比其他任何函数都更方便。

预编译正则表达式模式并使用 re.compile迭代数据通常要快得多(也参见 是否值得使用 Python 重新编译?)。相当于 str.contains的 list comp 如下所示:

p = re.compile(...)
ser2 = pd.Series([x for x in ser if p.search(x)])

或者,

ser2 = ser[[bool(p.search(x)) for x in ser]]

如果需要处理 NaNs,可以执行以下操作

ser[[bool(p.search(x)) if pd.notnull(x) else False for x in ser]]

相当于 str.extract(没有分组)的 list comp 看起来像这样:

df['col2'] = [p.search(x).group(0) for x in df['col']]

如果您需要处理 no-match 和 NaNs,您可以使用一个自定义函数(更快!) :

def matcher(x):
m = p.search(str(x))
if m:
return m.group(0)
return np.nan


df['col2'] = [matcher(x) for x in df['col']]

matcher函数具有很强的可扩展性。它可以根据需要返回每个捕获组的列表。只需提取查询 matcher 对象的 groupgroups属性。

对于 str.extractall,将 p.search改为 p.findall

字符串提取
考虑一个简单的过滤操作。这个想法是提取4位数字,如果它的前面是一个大写字母。

# Extracting strings.
p = re.compile(r'(?<=[A-Z])(\d{4})')
def matcher(x):
m = p.search(x)
if m:
return m.group(0)
return np.nan


ser.str.extract(r'(?<=[A-Z])(\d{4})', expand=False)   #  str.extract
pd.Series([matcher(x) for x in ser])                  #  list comprehension

enter image description here

更多例子
全面披露-我是作者(部分或全部)这些职位列出如下。


结论

如上面的示例所示,迭代在处理小行的 DataFrames、混合数据类型和正则表达式时非常有用。

你得到的加速取决于你的数据和你的问题,所以你的英里数可能会有所不同。最好的办法就是仔细地运行测试,看看付出的努力是否值得。

“向量化”函数在其简单性和可读性方面大放异彩,因此如果性能不是关键的,那么您肯定更喜欢这些函数。

另一方面,某些字符串运算处理有利于使用 NumPy 的限制。下面是两个小心的 NumPy 向量化优于 python 的例子:

此外,有时仅仅通过 .values操作底层数组,而不是在 Series 或 DataFrames 上操作,就可以为大多数常见场景提供足够健康的加速(参见上面 数值比较小节中的 注意)。因此,举例来说,df[df.A.values != df.B.values]会比 df[df.A != df.B]显示出即时的性能提升。使用 .values可能并不适用于所有情况,但它是一个有用的技巧。

如上所述,这取决于您决定这些解决方案是否值得执行。


附录: 代码段

import perfplot
import operator
import pandas as pd
import numpy as np
import re


from collections import Counter
from itertools import chain

< !->

# Boolean indexing with Numeric value comparison.
perfplot.show(
setup=lambda n: pd.DataFrame(np.random.choice(1000, (n, 2)), columns=['A','B']),
kernels=[
lambda df: df[df.A != df.B],
lambda df: df.query('A != B'),
lambda df: df[[x != y for x, y in zip(df.A, df.B)]],
lambda df: df[get_mask(df.A.values, df.B.values)]
],
labels=['vectorized !=', 'query (numexpr)', 'list comp', 'numba'],
n_range=[2**k for k in range(0, 15)],
xlabel='N'
)

< !->

# Value Counts comparison.
perfplot.show(
setup=lambda n: pd.Series(np.random.choice(1000, n)),
kernels=[
lambda ser: ser.value_counts(sort=False).to_dict(),
lambda ser: dict(zip(*np.unique(ser, return_counts=True))),
lambda ser: Counter(ser),
],
labels=['value_counts', 'np.unique', 'Counter'],
n_range=[2**k for k in range(0, 15)],
xlabel='N',
equality_check=lambda x, y: dict(x) == dict(y)
)

< !->

# Boolean indexing with string value comparison.
perfplot.show(
setup=lambda n: pd.DataFrame(np.random.choice(1000, (n, 2)), columns=['A','B'], dtype=str),
kernels=[
lambda df: df[df.A != df.B],
lambda df: df.query('A != B'),
lambda df: df[[x != y for x, y in zip(df.A, df.B)]],
],
labels=['vectorized !=', 'query (numexpr)', 'list comp'],
n_range=[2**k for k in range(0, 15)],
xlabel='N',
equality_check=None
)

< !->

# Dictionary value extraction.
ser1 = pd.Series([{'key': 'abc', 'value': 123}, {'key': 'xyz', 'value': 456}])
perfplot.show(
setup=lambda n: pd.concat([ser1] * n, ignore_index=True),
kernels=[
lambda ser: ser.map(operator.itemgetter('value')),
lambda ser: pd.Series([x.get('value') for x in ser]),
],
labels=['map', 'list comprehension'],
n_range=[2**k for k in range(0, 15)],
xlabel='N',
equality_check=None
)

< !->

# List positional indexing.
ser2 = pd.Series([['a', 'b', 'c'], [1, 2], []])
perfplot.show(
setup=lambda n: pd.concat([ser2] * n, ignore_index=True),
kernels=[
lambda ser: ser.map(get_0th),
lambda ser: ser.str[0],
lambda ser: pd.Series([x[0] if len(x) > 0 else np.nan for x in ser]),
lambda ser: pd.Series([get_0th(x) for x in ser]),
],
labels=['map', 'str accessor', 'list comprehension', 'list comp safe'],
n_range=[2**k for k in range(0, 15)],
xlabel='N',
equality_check=None
)

< !->

# Nested list flattening.
ser3 = pd.Series([['a', 'b', 'c'], ['d', 'e'], ['f', 'g']])
perfplot.show(
setup=lambda n: pd.concat([ser2] * n, ignore_index=True),
kernels=[
lambda ser: pd.DataFrame(ser.tolist()).stack().reset_index(drop=True),
lambda ser: pd.Series(list(chain.from_iterable(ser.tolist()))),
lambda ser: pd.Series([y for x in ser for y in x]),
],
labels=['stack', 'itertools.chain', 'nested list comp'],
n_range=[2**k for k in range(0, 15)],
xlabel='N',
equality_check=None
    

)

< !-_ >

# Extracting strings.
ser4 = pd.Series(['foo xyz', 'test A1234', 'D3345 xtz'])
perfplot.show(
setup=lambda n: pd.concat([ser4] * n, ignore_index=True),
kernels=[
lambda ser: ser.str.extract(r'(?<=[A-Z])(\d{4})', expand=False),
lambda ser: pd.Series([matcher(x) for x in ser])
],
labels=['str.extract', 'list comprehension'],
n_range=[2**k for k in range(0, 15)],
xlabel='N',
equality_check=None
)

简而言之

  • For loop + iterrows是非常慢的。开销在 ~ 1k 行上并不显著,但在10k + 行上显著。
  • For loop + itertuplesiterrowsapply快得多。
  • 向量化通常比 itertuples快得多

基准 enter image description here