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



pandas iteration (1)

TLDR; 不, for 循环来说不是“坏”,至少,并非总是如此。 一些矢量化操作比迭代慢, 可能 更准确 ,而不是说迭代比一些矢量化操作更快。 了解何时以及为何能够从代码中获得最佳性能。 简而言之,这些是值得考虑替代矢量化熊猫函数的情况:

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

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

迭代v / s小数据矢量化

Pandas在其API设计中遵循 “约定配置” 方法。 这意味着已经安装了相同的API以满足广泛的数据和用例。

当调用pandas函数时,函数必须在内部处理以下内容(以及其他内容),以确保工作

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

几乎每个函数都必须以不同的程度处理这些函数,这会产生 开销 。 数字函数(例如, 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 方法已经针对 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的比较。不幸的是,列表理解线性地扩展,因此它对于较大的N没有提供太多的性能增益。

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

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

其优于大熊猫和列表理解等价物:

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

价值计数
再举一个例子 - 这次,使用另一个比for循环 更快的 vanilla python构造 - collections.Counter 。 常见的要求是计算值计数并将结果作为字典返回。 这是通过 value_counts np.unique 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 循环更快。 蟒蛇力量!

当然,从这里拿走的是性能取决于您的数据和用例。 这些例子的目的是说服你不要排除这些解决方案作为合法的选择。 如果这些仍然没有给你所需的性能,那么总会有 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

那么,改变了什么? 这里需要注意的是 字符串操作本身很难进行矢量化。 Pandas将字符串视为对象,对象上的所有操作都回退到缓慢的循环实现。

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

对于可变/复杂对象的操作,没有比较。 列表理解优于涉及词典和列表的所有操作。

按键访问字典值
以下是从一列词典中提取值的两个操作的计时: map 和列表理解。 该设置位于附录中的“代码片段”标题下。

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

位置列表索引
从列列表(处理异常), map str.get 访问器方法 和列表 str.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)

重建系列时。

列表展平
最后一个例子是展平列表。 这是另一个常见问题,并展示了纯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 和嵌套列表 itertools.chain.from_iterable 都是纯python构造,并且比 stack 解决方案更好地扩展。

这些时间表明大熊猫不具备使用混合dtypes的能力,并且您应该避免使用它来这样做。 只要有可能,数据应在单独的列中作为标量值(整数/浮点数/字符串)出现。

最后,这些解决方案的适用性广泛依赖于您的数据。 因此,最好的办法是在决定使用之前对数据进行测试。 请注意我没有及时 apply 这些解决方案,因为它会扭曲图形(是的,它很慢)。

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

Pandas可以应用正则表达式操作,如 str.contains str.extract str.extractall ,以及其他“矢量化”字符串操作(如 str.split ,str.find , str.translate`等)字符串列。 这些函数比列表推导慢,并且意味着比其他任何函数都更方便。

通过 re.compile 预编译正则表达式模式和迭代数据通常要快得多(另请参阅 使用Python的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 (没有组)的列表comp将类似于:

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 功能非常易于扩展。 它可以适合根据需要返回每个捕获组的列表。 只需提取查询匹配器对象的 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,混合数据类型和正则表达式时,迭代会发光。

您获得的加速取决于您的数据和您的问题,因此您的里程可能会有所不同。 最好的办法是仔细运行测试,看看支付是否值得付出努力。

“矢量化”功能体现在它们的简单性和可读性上,因此如果性能不是很关键,那么你应该更喜欢那些。

另一方面,某些字符串操作处理有利于使用NumPy的约束。 这里有两个例子,其中仔细的NumPy向量化优于python:

  • 使用增量值以更快更有效的方式创建新列 - 通过Divakar回答

  • 用熊猫快速删除标点符号 - 由Paul Panzer回答

此外,有时仅通过 .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 循环真的“糟糕”吗? 如果不是,在什么情况下它们会比使用更传统的“矢量化”方法更好? 1

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

但是,我很惊讶地看到很多代码(包括来自Stack Overflow的答案)提供了解决问题的解决方案,这些问题涉及使用 for 循环和列表推导来循环数据。 文档和API说循环是“坏的”,并且应该“永远”迭代数组,系列或DataFrame。 那么,为什么我有时会看到用户建议基于循环的解决方案呢?

1 - 虽然问题听起来有点宽泛,但事实是,在循环通常优于传统迭代数据时,存在非常具体的情况。 这篇文章旨在为后人捕捉这一点。





list-comprehension