Python Pandas 中的通用 Groupby:快速方法

Dus*_*tin 4 python performance dataframe pandas

终极问题

有没有办法做一个不依赖于 pd.groupby 的通用的、高性能的 groupby 操作?

输入

pd.DataFrame([[1, '2020-02-01', 'a'], [1, '2020-02-10', 'b'], [1, '2020-02-17', 'c'], [2, '2020-02-02', 'd'], [2, '2020-03-06', 'b'], [2, '2020-04-17', 'c']], columns=['id', 'begin_date', 'status'])`
Run Code Online (Sandbox Code Playgroud)
   id  begin_date status
0   1  2020-02-01      a
1   1  2020-02-10      b
2   1  2020-02-17      c
3   2  2020-02-02      d
4   2  2020-03-06      b
Run Code Online (Sandbox Code Playgroud)

期望输出

   id status  count  uniquecount
0   1      a      1            1
1   1      b      1            1
2   1      c      1            1
3   2      b      1            1
4   2      c      1            1
Run Code Online (Sandbox Code Playgroud)

问题

现在,使用 Pandas 在 Python 中有一种简单的方法可以做到这一点。

df = df.groupby(["id", "status"]).agg(count=("begin_date", "count"), uniquecount=("begin_date", lambda x: x.nunique())).reset_index()
# As commented, omitting the lambda and replacing it with "begin_date", "nunique" will be faster. Thanks!
Run Code Online (Sandbox Code Playgroud)

对于较大的数据集,此操作很慢,我会猜测并说 O(n²)。

缺乏普遍适用性的现有解决方案

现在,经过一些谷歌搜索,StackOverflow 上有一些替代解决方案,可以使用 numpy、iterrows 或其他不同的方式。

执行熊猫分组操作的更快替代方法

熊猫从 groupby 中快速加权随机选择

还有一个很棒的:

python pandas中的Groupby:快速方法

这些解决方案通常旨在在我的示例中创建“计数”或“唯一计数”,基本上是聚合值。但是,不幸的是,总是只有一个聚合,而不是多个 groupby 列。此外,不幸的是,他们从未解释如何将它们合并到分组数据帧中。

有没有办法使用 itertools(就像这个答案:更快的替代执行熊猫 groupby 操作,或者甚至更好这个答案:Python pandas 中的 Groupby:Fast Way),它不仅返回系列“计数”,而且返回整个数据帧分组形式?

终极问题

有没有办法做一个不依赖于 pd.groupby 的通用的、高性能的 groupby 操作?

这看起来像这样:

from typing import List
def fastGroupby(df, groupbyColumns: List[str], aggregateColumns):
    # numpy / iterrow magic
    return df_grouped

df = fastGroupby(df, ["id", "status"], {'status': 'count',
                             'status': 'count'}
Run Code Online (Sandbox Code Playgroud)

并返回所需的输出。

ALo*_*llz 5

在放弃之前,groupby我建议首先评估您是否真正利用了所groupby提供的东西。

废除lambda支持内置pd.DataFrameGroupBy方法。

许多SeriesDataFrame方法被实现为pd.DataFrameGroupBy方法。您应该直接使用它们,而不是用groupby+调用它们apply(lambda x: ...)

此外,对于许多计算,您可以将问题重新构建为对整个 DataFrame 的一些矢量化操作,然后使用在 cython 中实现的 groupby 方法。这会很快。

一个常见的例子是找到'Y'一组内答案的比例。一个直接的方法是检查每个组内的条件,然后得到比例:

N = 10**6
df = pd.DataFrame({'grp': np.random.choice(range(10000), N),
                   'answer': np.random.choice(['Y', 'N'], N)})

df.groupby('grp')['answer'].apply(lambda x: x.eq('Y').mean())
Run Code Online (Sandbox Code Playgroud)

这样想问题需要lambda,因为我们在groupby里面做了两个操作;检查条件然后平均。这个完全相同的计算可以被认为是首先检查整个 DataFrame 的条件,然后计算组内的平均值:

df['answer'].eq('Y').groupby(df['grp']).mean()
Run Code Online (Sandbox Code Playgroud)

这是一个很小的变化,但后果却是巨大的,随着群体数量的增加,收益会越来越大。

%timeit df.groupby('grp')['answer'].apply(lambda x: x.eq('Y').mean())
#2.32 s ± 99.9 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

%timeit df['answer'].eq('Y').groupby(df['grp']).mean()
#82.8 ms ± 995 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
Run Code Online (Sandbox Code Playgroud)

添加sort=False为参数

默认情况下groupby,按键对输出进行排序。如果没有理由有一个排序的输出,你可以得到一点点指定sort=False


添加observed=True为参数

如果分组键是分类的,它将重新索引到所有可能的组合,即使对于从未出现在您的 DataFrame 中的组也是如此。如果这些都不重要,将它们从输出中移除将大大提高速度。


对于您的示例,我们可以检查差异。切换到pd.DataFrameGroupBy.nunique排序有很大的收益,删除排序会增加一点额外的速度。两者的结合提供了一个“相同”的解决方案(直到排序),并且对于许多组来说快了近 100 倍。

import perfplot
import pandas as pd
import numpy

def agg_lambda(df):
    return df.groupby(['id', 'status']).agg(uniquecount=('Col4', lambda x: x.nunique()))
    
def agg_nunique(df):
    return df.groupby(['id', 'status']).agg(uniquecount=('Col4', 'nunique'))

def agg_nunique_nosort(df):
    return df.groupby(['id', 'status'], sort=False).agg(uniquecount=('Col4', 'nunique'))

perfplot.show(
    setup=lambda N: pd.DataFrame({'Col1': range(N),
                       'status': np.random.choice(np.arange(N), N),
                       'id': np.random.choice(np.arange(N), N),
                       'Col4': np.random.choice(np.arange(N), N)}),
    kernels=[
        lambda df: agg_lambda(df),
        lambda df: agg_nunique(df),
        lambda df: agg_nunique_nosort(df),
    ],
    labels=['Agg Lambda', 'Agg Nunique', 'Agg Nunique, No sort'],
    n_range=[2 ** k for k in range(20)],
    # Equality check same data, just allow for different sorting
    equality_check=lambda x,y: x.sort_index().compare(y.sort_index()).empty,
    xlabel="~ Number of Groups"
)
Run Code Online (Sandbox Code Playgroud)

在此处输入图片说明