Pandas中的for-loop真的很糟糕吗?我什么时候该在乎?

lmvvr0a8  于 2023-10-14  发布在  其他
关注(0)|答案(3)|浏览(109)

for循环真的很“糟糕”吗?如果不是,在什么情况下它们会比使用更传统的“矢量化”方法更好?1

我熟悉“矢量化”的概念,以及pandas如何使用矢量化技术来加速计算。矢量化函数在整个系列或DataFrame上广播操作,以实现比传统的数据迭代更大的加速。
然而,我很惊讶地看到很多代码(包括来自Stack Overflow的答案)提供了解决问题的方法,这些问题涉及使用for循环和列表解析来循环数据。文档和API都说循环是“坏”的,并且应该“永远不要”遍历数组、序列或 Dataframe 。那么,为什么我有时会看到用户建议基于循环的解决方案?
虽然这个问题听起来有点宽泛,但事实是,在一些非常特殊的情况下,for循环通常比传统的数据迭代更好。这篇文章旨在为后代捕捉这一点。

6l7fqoea

6l7fqoea1#

TLDR;不,for循环并不完全“坏”,至少并不总是如此。更准确地说,某些向量化操作比迭代慢**,而不是说迭代比某些向量化操作快。知道何时以及为什么是从代码中获得最大性能的关键。简而言之,在这些情况下,值得考虑矢量化pandas函数的替代方案:
1.当您的数据很小时(...取决于您正在做什么),
1.当处理object/混合数据类型时
1.使用str/regex访问器函数时
让我们分别研究这些情况。

小数据迭代矢量化

Pandas在其API设计中遵循"Convention Over Configuration"方法。这意味着相同的API已被安装以满足广泛的数据和用例。
当pandas函数被调用时,以下事情(以及其他事情)必须由函数内部处理,以确保工作正常
1.索引/轴对齐
1.处理混合数据包
1.处理缺失数据
几乎每个函数都必须在不同程度上处理这些问题,这会带来开销。数值函数的开销较小(例如Series.add),而字符串函数的开销较大(例如Series.str.replace)。
另一方面,for循环比你想象的要快。更好的是列表解析(通过for循环创建列表)甚至更快,因为它们是列表创建的优化迭代机制。
列表解析遵循以下模式

[f(x) for x in seq]

其中seq是pandas系列或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包来运行本文中的所有timeit测试。上述操作的时间安排如下:

对于中等大小的N,列表解析的性能优于query,甚至优于向量化的不等于比较。不幸的是,列表解析是线性扩展的,所以它不能为更大的N提供太多的性能增益。

注意

值得一提的是,列表解析的大部分好处来自于不必担心索引对齐,但这意味着如果你的代码依赖于索引对齐,这将中断。在某些情况下,对底层NumPy数组的向量化操作可以被认为是“两全其美”,允许向量化 * 而不需要 * pandas函数的所有不必要的开销。这意味着您可以将上面的操作重写为

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

它的性能优于pandas和列表解析等价物:

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

价值计数

再举一个例子--这一次,使用了另一个比for循环更快的vanilla python结构--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

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

注意

更多琐事(礼貌@user2357112)。Counter是用C加速器实现的,所以虽然它仍然必须使用python对象而不是底层的C数据库,但它仍然比for循环快。巨蟒之力!
当然,从这里得到的是,性能取决于您的数据和用例。这些例子的目的是说服你不要排除这些解决方案作为合法的选择。如果这些仍然不能给予你所需要的性能,那么还有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 dtypes操作

基于字符串的比较

重新回顾第一节中的过滤示例,如果要比较的列是字符串,会怎么样?考虑上面相同的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

那么,什么改变了?这里需要注意的是,**string操作本质上很难向量化。**Pandas将字符串视为对象,所有对对象的操作都回到了一个缓慢、循环的实现中。
现在,因为这个循环的实现被上面提到的所有开销所包围,所以这些解决方案之间存在恒定的幅度差异,即使它们的规模相同。
当涉及到对可变/复杂对象的操作时,没有比较。列表理解优于所有涉及字典和列表的操作。

按键显示字典值

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

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

位置列表索引

从列列表中提取第0个元素的3个操作的计时(处理异常),mapstr.get accessor method和列表解析:

# 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

itertools.chain.from_iterable和嵌套列表解析都是纯粹的python结构,并且比stack解决方案更好地扩展。
这些时间是一个强有力的迹象,表明pandas不具备与混合数据类型一起工作的能力,并且您可能应该避免使用它来这样做。只要有可能,数据应该以标量值(int/floats/string)的形式出现在单独的列中。
最后,这些解决方案的适用性在很大程度上取决于您的数据。因此,最好的做法是在决定使用什么之前,在数据上测试这些操作。请注意,我没有对这些解决方案中的apply进行计时,因为这会扭曲图形(是的,它就是那么慢)。

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

Pandas可以对字符串列应用正则表达式操作,如str.containsstr.extractstr.extractall,以及其他“向量化”字符串操作(如str.splitstr.findstr.translate等)。这些函数比列表解析慢,并且比其他函数更方便。
预编译正则表达式模式并使用re.compile对数据进行重新编译通常要快得多(另请参阅Is it worth using Python's re.compile?)。与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]]

如果需要处理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功能非常可扩展。它可以根据需要返回每个捕获组的列表。只需提取查询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

更多示例

充分披露-我是作者(部分或全部)这些职位列出如下。

总结

正如上面的例子所示,迭代在处理小行的 Dataframe 、混合 Dataframe 和正则表达式时非常有用。
您获得的加速取决于您的数据和问题,因此您的里程可能会有所不同。最好的办法是仔细地运行测试,看看付出的努力是否值得。
“矢量化”函数的优点在于其简单性和可读性,所以如果性能不是关键的,您肯定会更喜欢它们。
另一方面,某些字符串操作处理有利于使用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
)
20jt8wwn

20jt8wwn2#

总之

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

基准x1c 0d1x

ycl3bljg

ycl3bljg3#

pandas中的for循环真的很糟糕吗?
是的,是的,它们是(至少对于大量行的大型计算)。看看这个:

我测试的13种技术中最慢的 4 都使用for循环。它们比矢量化方法慢300 x ~ 1400 x
太疯狂了在这里看到我的完整答案:How to iterate over Pandas DataFrame s without iterating

我什么时候该在乎?

1.如果你迭代了很多行。
1.如果你厌倦了等待你的计算完成。
1.如果你想让Python使用更少的电力。
同样,当计算超过200万行时,看看上面图中的极端差异。

那么,为什么我有时会看到用户建议使用基于循环的解决方案?

我怀疑他们可能只是不知道。制作上面的情节花了我几百个小时的学习。如果你看到了for循环,请引导人们使用my answer,他们可以参考我提供的替代技术。其中一些是相当复杂的,如布尔索引的纯向量化方法。而且,即使是列表理解也不是天生的直觉。他们只是需要有人来证明:
1.糟糕的技术有多慢。
1.如何做好技术,有真实的代码和真实的例子。

我应该关心for循环是坏的吗?

也许不会记住 functional,现有的代码总是比 perfect,不存在的代码好。对于小型数据集,运行时节省可能并不重要(例如:0.01秒对0.2秒)。
我发现如果我等待代码运行的时间超过1秒,我想知道为什么。
而对于200万行,30~135+秒是绝对无法承受的,特别是考虑到简单的列表理解解决方案是~1秒,而纯向量化方法是0.1秒。

参见

1.我的完整回答:How to iterate over Pandas DataFrame s without iterating

相关问题