代码之家  ›  专栏  ›  技术社区  ›  cs95 abhishek58g

对于有熊猫的圈套-我什么时候应该关心?

  •  52
  • cs95 abhishek58g  · 技术社区  · 7 年前

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

    但是,我很惊讶地看到许多代码(包括堆栈溢出的答案)为涉及循环使用数据的问题提供了解决方案 for 循环和列表理解。在阅读了文档并对API有了相当的了解之后,我相信循环是“坏的”,并且应该“永远”在数组、序列或数据帧上迭代。那么,我怎么会看到用户时不时地建议不完善的解决方案呢?

    所以,总结一下…我的问题是:
    对于 循环真的“坏”?如果不是,在什么情况下,它们会比使用更传统的“矢量化”方法更好?

    1-虽然这个问题听起来有点宽泛,但事实是,当 对于 循环通常比传统的数据迭代要好。这篇文章的目的是为子孙后代捕捉这一点。

    1 回复  |  直到 7 年前
        1
  •  63
  •   cs95 abhishek58g    7 年前

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

    1. 当你的数据很小时(取决于你在做什么),
    2. 处理时 object 混合型
    3. 当使用 str /regex访问函数

    让我们单独检查一下这些情况。


    小数据的迭代V/S矢量化

    熊猫跟随 "Convention Over Configuration" API设计的方法。这意味着已经安装了相同的API来满足广泛的数据和用例。

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

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

    几乎每个函数都必须在不同的程度上处理这些问题,这就提出了 开销 . 对于数值函数(例如, Series.add ,而字符串函数(例如, Series.str.replace )

    for 另一方面,循环比你想象的要快。更好的是 list comprehensions (通过 对于 循环)甚至更快,因为它们是用于创建列表的优化迭代机制。

    列表理解遵循模式

    [f(x) for x in seq]
    

    在哪里? seq 是熊猫系列或数据帧列。或者,在多列上操作时,

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

    在哪里? seq1 seq2 是列。

    数值比较
    考虑一个简单的布尔索引操作。列表理解方法已针对 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测试。上述操作的时间安排如下:

    enter image description here

    列表理解优于 查询 对于中等大小的N,甚至优于向量化的不等于极小N的比较。不幸的是,列表理解是线性的,因此对于较大的N,它没有提供太多的性能增益。

    注释
    值得一提的是,列表理解的许多好处来自于不必担心索引对齐, 但这意味着如果代码依赖于索引对齐, 这会打破的。在某些情况下,在 底层numpy数组可以被视为带来了 两个世界”,允许矢量化 没有 熊猫所有不必要的开销。这意味着您可以将上面的操作重写为

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

    其表现优于大熊猫和单子理解能力:
    enter image description here
    numpy矢量化已经超出了本文的范围,但是如果性能很重要的话,它绝对值得考虑。

    价值计数
    举另一个例子——这次,用另一个普通的python构造 更快 比for循环- 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
    

    enter image description here

    结果更加明显, 计数器 在更大范围的小N(~3500)中赢得了两种矢量化方法。

    注释
    更多琐事(由@user2357112提供)。这个 计数器 是用 C accelerator , 因此,尽管它仍然必须使用Python对象,而不是 在C数据类型的基础上,它仍然比 对于 循环。蟒蛇 力量!

    当然,这里要考虑的是性能取决于您的数据和用例。这些例子的要点是说服您不要将这些解决方案排除在合法选项之外。如果这些仍然不能提供你所需要的性能,那么 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
    

    enter image description here

    numba为非常强大的矢量化代码提供了循环的python代码的JIT编译。理解如何使numba工作涉及到一个学习曲线。


    混合操作/ 对象 D型

    基于字符串的比较
    重新访问第一节中的过滤示例,如果要比较的列是字符串呢?考虑上面相同的3个函数,但是输入数据帧转换为字符串。

    # 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

    那么,发生了什么变化?这里需要注意的是 字符串操作本身很难向量化。 pandas将字符串视为对象,对象上的所有操作都会返回到缓慢、循环的实现中。

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

    当涉及到可变/复杂对象的操作时,没有比较。列表理解优于所有涉及听写和列表的操作。

    按键访问字典值
    以下是从字典列中提取值的两个操作的计时: 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

    位置列表索引
    从列列表中提取第0个元素的3个操作的计时(处理异常), map , str.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)
    

    重建序列时。

    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 解决方案。

    这些时间是一个强有力的迹象,表明熊猫不具备与混合数据类型一起工作的能力,并且您可能应该避免使用它来完成这项工作。在可能的情况下,数据应该作为标量值(ints/floats/strings)出现在单独的列中。

    最后,这些解决方案的适用性很大程度上取决于您的数据。所以,最好的做法是在决定使用什么之前对数据测试这些操作。注意我怎么没时间 apply 在这些解决方案中,因为它会扭曲图表(是的,速度很慢)。


    regex操作,以及 .str 存取方法

    熊猫可以应用regex操作,例如 str.contains , str.extract str.extractall 以及其他“矢量化”字符串操作(例如 str.split 找到 , str.translate`,等等)。这些函数比列表理解慢,并且比其他任何函数都更方便。

    预编译regex模式并使用 re.compile (也见 Is it worth using Python's re.compile? )相当于 包含在内 看起来像这样:

    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]]
    

    如果你需要照顾奶奶,你可以做些类似的事情

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

    相当于 斯特萃取物 (没有组)看起来像:

    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 groups Matcher对象的属性。

    为了 抽提物 改变 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

    更多例子
    完全披露-我是下面列出的这些文章的作者(部分或全部)。


    结论

    如上面的例子所示,迭代在处理小的数据帧行、混合数据类型和正则表达式时会发光。

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

    “矢量化”函数以其简单性和可读性著称,因此,如果性能不是关键的,您肯定会喜欢这些函数。

    另一个注意事项是,某些字符串操作处理有利于使用numpy的约束。下面是两个例子,其中谨慎的numpy矢量化优于python:

    此外,有时只是通过 .values 与系列或数据帧相反,它可以为大多数常见的场景提供足够的健康加速(请参见 注释 数值比较 以上部分)。因此,例如 df[df.A.values != df.B.values] 会显示出瞬间的性能提升 df[df.A != df.B] . 使用 价值观 可能不是在所有情况下都适用,但知道这是一个有用的黑客。

    如上所述,由您决定这些解决方案是否值得麻烦地实现。


    附录:代码段

    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
    )