问题:熊猫中的for循环真的不好吗?我什么时候应该在意?
是for
循环真正的“坏”?如果不是,在什么情况下它们会比使用更常规的“矢量化”方法更好?1个
我熟悉“矢量化”的概念,以及熊猫如何利用矢量化技术来加快计算速度。向量化功能在整个系列或DataFrame上广播操作,以实现比传统上迭代数据快得多的加速。
但是,我很惊讶地看到很多代码(包括来自Stack Overflow的答案)提供了解决问题的解决方案,这些问题涉及使用for
循环和列表推导来遍历数据。文档和API指出循环是“不好的”循环,并且“绝不能”循环访问数组,序列或DataFrame。那么,为什么有时我会看到用户建议基于循环的解决方案?
1-虽然问题听起来似乎有些宽泛,但事实是,在某些非常特殊的情况下,for
循环通常比传统上遍历数据更好。这篇文章的目的是为了后代。
回答 0
TLDR;不,for
循环并非总会“坏”,至少并非总是如此。说某些矢量化操作比迭代慢,而不是说迭代快于某些矢量化操作,可能更准确。知道何时以及为什么是使代码获得最大性能的关键。简而言之,在以下情况下,值得考虑使用矢量化熊猫函数的替代方法:
- 当您的数据很小时(…取决于您的工作),
- 处理
object
/ mixed dtypes时 - 使用
str
/ regex访问器功能时
让我们分别检查这些情况。
小数据上的迭代v / s矢量化
熊猫在其API设计中遵循“配置惯例”方法。这意味着已经安装了相同的API,以适应广泛的数据和用例。
调用pandas函数时,该函数必须在内部处理以下各项(除其他事项外),以确保工作正常
- 索引/轴对齐
- 处理混合数据类型
- 处理丢失的数据
几乎每个函数都必须在不同程度上处理这些问题,这带来了开销。数字函数(例如Series.add
)的开销较少,而字符串函数(例如Series.str.replace
)的开销更为明显。
for
循环创建列表)更快,因为它们是优化的列表创建迭代机制。
列表理解遵循模式
[f(x) for x in seq]
seq
熊猫系列或DataFrame列在哪里。或者,当对多列进行操作时,
[f(x, y) for x, y in zip(seq1, seq2)]
凡seq1
和seq2
列。
数值比较
考虑一个简单的布尔索引操作。列表推导方法已针对!=
)和进行计时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
包来运行所有的timeit测试。上述操作的时间安排如下:
query
对于中等大小的N,列表理解要胜过,甚至对于较小的N而言,列表理解要胜过向量化不等于比较。不幸的是,列表理解是线性缩放的,因此对于较大的N而言,它不能提供很多性能提升。
注意
值得一提的是,列表理解的许多好处来自于不必担心索引对齐,但是这意味着,如果您的代码依赖于索引对齐,则此操作会中断。在某些情况下,可以将对基础NumPy数组的矢量化操作视为“两全其美”,从而实现了矢量化,而没有熊猫函数的所有不必要开销。这意味着您可以将上面的操作重写为df[df.A.values != df.B.values]
它的性能优于熊猫和列表理解同等物:
NumPy矢量化不在本文讨论范围之内,但是如果性能很重要,则绝对值得考虑。
值计数
再举一个例子-这次,使用另一个比for循环快的香草python构造- Counter
:
# 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
结果更明显,Counter
在较大的小N范围(〜3500)下胜过两种矢量化方法。
注意
更多琐事(由@ user2357112提供)。的Counter
实现是使用C加速器实现的,因此尽管它仍必须使用python对象而不是底层的C数据类型,但它仍比for
循环要快。Python的力量!
当然,这里的好处是性能取决于您的数据和用例。这些示例的目的是说服您不要将这些解决方案排除为合法选项。如果这些仍然不能满足您的需求,那么总会有cython和numba。让我们将此测试添加到混合中。
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
Numba可以将循环python代码的JIT编译为功能非常强大的矢量化代码。了解如何使numba发挥作用需要学习。
混合/ 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
那么,发生了什么变化?这里要注意的是,字符串操作本质上难以向量化。Pandas将字符串视为对象,并且对对象的所有操作都会回退到缓慢,循环的实现中。
现在,由于此循环实现被上述所有开销所包围,因此,即使这些解决方案按比例缩放,它们之间也存在恒定的幅度差异。
当涉及对可变/复杂对象的操作时,没有比较。列表理解胜过所有涉及字典和列表的操作。
通过键访问字典值
以下是从字典列中提取值的两个操作的时间安排:map
和列表理解。该设置位于附录的“代码段”标题下。
# Dictionary value extraction.
ser.map(operator.itemgetter('value')) # map
pd.Series([x.get('value') for x in ser]) # list comprehension
3个操作的位置列表索引计时,这些操作从列列表中提取第0个元素(处理异常)map
,str.get
访问器方法和列表推导:
# 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)
重建系列时。
列表扁平
化最后一个例子是扁平化列表。这是另一个常见问题,它演示了纯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
无论stack
的解决方案。
这些时间点充分说明了熊猫没有为混合dtypes做好准备的事实,并且您可能应该避免使用它来这样做。数据应尽可能在单独的列中作为标量值(整数/浮点数/字符串)存在。
最后,这些解决方案的适用性在很大程度上取决于您的数据。因此,最好的办法是先决定对数据进行这些操作,然后再决定要做什么。请注意,我尚未apply
对这些解决方案计时,因为它会使图形倾斜(是的,那太慢了)。
正则表达式操作和访问器.str
方法
熊猫可以应用正则表达式的操作,如str.split
,str.find ,
str.translate`,等等)的字符串列。这些功能比列表理解要慢,并且是比其他功能更方便的功能。
预编译正则表达式模式并使用遍历数据通常要快得多str.contains
如下所示:
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]]
如果您需要处理NaN,则可以执行以下操作
ser[[bool(p.search(x)) if pd.notnull(x) else False for x in ser]]
相当于str.extract
(无组)的列表组合看起来像:
df['col2'] = [p.search(x).group(0) for x in df['col']]
如果您需要处理不匹配和NaN,则可以使用自定义函数(速度更快!):
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
功能是非常可扩展的。根据需要,它可以适合返回每个捕获组的列表。只需提取查询匹配对象的group
or groups
属性即可。
对于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
更多示例
完全公开-我是以下列出的这些帖子的作者(部分或全部)。
结论
如上面的示例所示,当处理少量的DataFrame,混合的数据类型和正则表达式时,迭代会发光。
您获得的提速取决于您的数据和问题,因此里程可能会有所不同。最好的办法是仔细运行测试,看看是否值得付出努力。
“向量化”功能的优点在于其简单性和可读性,因此,如果性能不是很关键,则绝对应该首选这些功能。
另一个注意事项是,某些字符串操作处理了一些使用NumPy的约束。以下是两个示例,其中仔细的NumPy向量化性能胜过python:
此外,有时.values
相对于Series或DataFrame ,仅通过底层数组进行操作就可以为大多数常见情况提供足够健康的加速(请参见上面“ 数字比较”部分的“ 注意 ” )。因此,举例来说,即时性能会提高。使用可能并非在每种情况下都适用,但这是一个有用的技巧。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
)
回答 1
简而言之
- for循环+
iterrows
非常慢。大约1k行的开销并不重要,但超过10k行的开销却很明显。 - for loop +
itertuples
比iterrows
or 快得多apply
。 - 向量化通常比
itertuples