7  数据聚合与分组运算

对数据集进行分类,并对每个组应用一个函数(无论是聚合、转换还是过滤),是数据分析工作流程中的一个核心环节。在加载、合并和准备好数据集之后,我们经常需要计算分组统计量,或者为后续的报告和可视化创建数据透视表。pandas 提供了一个功能强大且灵活的 groupby 接口,使我们能够以一种自然的方式对数据集进行切片、切块和汇总。

关系型数据库和 SQL(结构化查询语言)之所以广受欢迎,原因之一在于它们能够轻松地连接、过滤、转换和聚合数据。然而,像 SQL 这样的查询语言对于可以执行的分组运算类型有一定的限制。正如您将看到的,借助 Python 和 pandas 的表现力,我们可以通过将复杂的分组运算表达为处理每个组相关数据的自定义 Python 函数来执行它们。在本章中,正如 sec-data-aggregation 概述的那样,您将学习如何:

时间序列数据的分组聚合

本章主要讨论横截面数据的分组操作。对于时间序列数据,groupby 的一个特殊应用场景是重采样 (resampling),例如将日度的股价数据聚合为月度数据,以分析其长期趋势。这是一个非常重要且专门的领域,我们将在后续关于时间序列分析的章节中进行深入探讨。

与前面的章节一样,我们首先导入 NumPy 和 pandas:

列表 7.1
import numpy as np
import pandas as pd

7.1 理解分组运算的思维模式

哈德利·威克姆(Hadley Wickham)是 R 语言 ggplot2dplyr 等知名数据科学包的作者,他创造了 “拆分-应用-合并”(split-apply-combine) 这个术语来描述分组运算。这是一个理解该过程的强大心智模型。

  1. 拆分 (Split):pandas 对象(如 DataFrameSeries)中包含的数据,根据您提供的一个或多个键分割成若干组。拆分是在对象的特定轴上执行的。例如,DataFrame 可以在其行上(axis='index')或列上(axis='columns')进行分组。
  2. 应用 (Apply): 将一个函数独立地应用于每个组,从而产生一个新值(或一个新的数据结构)。这可以是一个聚合函数,如 sum()mean();也可以是一个转换函数,如对数据进行标准化;或者是一个更复杂的自定义函数。
  3. 合并 (Combine): 将所有函数应用的结果合并成一个单一的结果对象。这个最终对象的结构取决于所执行的操作。

图 fig-group-aggregation 为这个过程提供了一个概念性的图示。

每个分组键可以有多种形式,而且键的类型不必完全相同: * 一个与分组轴长度相同的列表或数组。 * 一个指示 DataFrame 中列名的值。 * 一个给出分组轴上的值与组名之间对应关系的字典或 Series。 * 一个作用于轴索引或索引中各个标签的函数。

如果这些听起来有些抽象,不必担心。在本章中,我们将为所有这些方法提供许多示例。首先,我们创建一个小型的表格数据集作为 DataFrame

表 7.1: 用于分组运算的小型示例DataFrame
df = pd.DataFrame({'key1' : ['a', 'a', None, 'b', 'b', 'a', None],
                   'key2': pd.Series([1, 2, 1, 2, 1, None, 1], dtype='Int64'),
                   'data1': np.random.standard_normal(7),
                   'data2': np.random.standard_normal(7)})
df

假设您想根据 key1 中的标签来计算 data1 列的均值。有多种方法可以实现。一种是访问 data1 并使用 key1 列(一个 Series)调用 groupby

grouped = df['data1'].groupby(df['key1'])
grouped
理解GroupBy对象

请注意,上一步操作的返回结果并不是一个带有计算好的均值的数据框,而是一个 <pandas.core.groupby.generic.SeriesGroupBy object>。这非常关键。GroupBy 对象是“惰性的 (lazy)”。它本身并未进行任何实际的计算,而是包含了一个执行分组计算所需的所有信息的“蓝图”或“配方”。它知道分组的键 (df['key1']) 和要操作的数据 (df['data1'])。只有当你调用一个聚合方法(如 .mean().sum() 等)时,pandas 才会真正执行“拆分-应用-合并”的过程,并返回最终结果。这种设计在计算上是高效的,尤其是在处理大型数据集时,因为它避免了不必要的中间数据存储。

这个 grouped 变量现在是一个特殊的 GroupBy 对象。除了关于分组键 df['key1'] 的一些中间数据外,它实际上还没有计算任何东西。其设计思想是,这个对象包含了对每个组应用某种操作所需的所有信息。例如,要计算各组的均值,我们可以调用 GroupBy 对象的 mean 方法:

表 7.2: 按 key1 分组的 data1 均值
grouped.mean()

数据(一个 Series)已经通过分组键进行了聚合,产生了一个新的 Series,该 Series 现在由 key1 列中的唯一值进行索引。结果索引的名称是 ‘key1’,因为 DataFrame 的列 df['key1'] 的名称就是它。

如果我们传递一个包含多个数组的列表作为分组键,会得到一个层级索引的结果:

means = df['data1'].groupby([df['key1'], df['key2']]).mean()
means

这里我们使用了两个键对数据进行分组,得到的 Series 现在有一个由观察到的唯一键对组成的层级索引(MultiIndex)。我们可以使用 unstack 方法将这个结果重塑为一个更熟悉的 DataFrame

表 7.3: 带有层级索引的未堆叠分组均值
means.unstack()

在前面的例子中,分组键都是 Series。然而,它们可以是任何长度正确的数组。这里,我们用中国的省份和年份作为分组键:

表 7.4: 按外部 province 和 year 数组分组的 data1 均值
provinces = np.array(['京', '沪', '沪', '京', '京', '沪', '京'])
years = np.array()
df['data1'].groupby([provinces, years]).mean()

通常,分组信息与您要处理的数据位于同一个 DataFrame 中。在这种情况下,您可以将列名(无论是字符串、数字还是其他 Python 对象)作为分组键传递:

表 7.5: 按单个列名分组
df.groupby('key1').mean(numeric_only=True)
表 7.6: 按另一列名分组,注意“讨厌的列”
df.groupby('key2').mean(numeric_only=True)
“讨厌的列” (Nuisance Columns)

在上面按 key2 分组的例子 (表 tbl-df-grouped-key2) 中,您可能注意到结果中没有 key1 列。这是因为 df['key1'] 不是数值类型的数据,无法计算均值。pandas 将这类列称为“讨厌的列 (nuisance column)”,并在聚合操作时自动排除它们。默认情况下,groupby 会对所有可聚合的数值列进行计算。这是一个方便的功能,但也可能隐藏问题。如果某一列因为数据类型错误(比如,一个本应是数字的列被错误地存成了字符串)而被排除,您可能不会立即察觉。因此,养成检查聚合结果列名的习惯是一种良好的实践。

要按多个列进行分组,请传递一个列名列表:

表 7.7: 按多个列名分组
df.groupby(['key1', 'key2']).mean(numeric_only=True)

无论使用 groupby 的目标是什么,一个通常很有用的 GroupBy 方法是 size,它返回一个包含各组大小的 Series

表 7.8: 使用多个键的分组大小
df.groupby(['key1', 'key2']).size()

请注意,默认情况下,分组键中的任何缺失值都会从结果中排除。可以通过向 groupby 传递 dropna=False 来禁用此行为:

表 7.9: 包含NA值的 key1 分组大小
df.groupby('key1', dropna=False).size()
表 7.10: 包含NA值的 key1 和 key2 分组大小
df.groupby(['key1', 'key2'], dropna=False).size()

size 精神相似的分组函数是 count,它计算每个组中每列的非空值数量:

表 7.11: 每组非空值的计数
df.groupby('key1').count()

7.1.1 遍历各组

groupby 返回的对象支持迭代,生成一个由包含组名和数据块的二元元组组成的序列。思考以下代码:

for name, group in df.groupby('key1'):
    print(f'组名: {name}')
    print(group)
    print('-' * 20)

如果使用多个键,元组的第一个元素将是一个包含键值的元组:

for (k1, k2), group in df.groupby(['key1', 'key2']):
    print(f'组名: {(k1, k2)}')
    print(group)
    print('-' * 20)

当然,您可以对这些数据块做任何您想做的事情。一个您可能会觉得有用的技巧是,用一行代码计算出一个包含数据块的字典:

列表 7.2
pieces = {name: group for name, group in df.groupby('key1')}
pieces['b']

默认情况下,groupbyaxis='index'(即行)上进行分组,但您可以在任何其他轴上进行分组。例如,我们可以按数据类型对示例 df 的列进行分组:

列表 7.3
grouped = df.groupby(df.dtypes, axis='columns')

for dtype, group in grouped:
    print(f'数据类型: {dtype}')
    print(group)
    print('-' * 20)

7.1.2 选择一列或列的子集

使用列名或列名数组对从 DataFrame 创建的 GroupBy 对象进行索引,其效果是为聚合操作选择列的子集。这意味着:

df.groupby('key1')['data1']
df.groupby('key1')[['data2']]

是以下写法的语法糖 (syntactic sugar):

df['data1'].groupby(df['key1'])
df[['data2']].groupby(df['key1'])

特别是对于大型数据集,可能只需要对少数几列进行聚合。例如,在前面的数据集中,要仅计算 data2 列的均值并将结果作为 DataFrame 获取,我们可以这样写:

表 7.12: 对列的子集进行聚合
df.groupby(['key1', 'key2'])[['data2']].mean()

如果传递的是列表或数组,此索引操作返回的对象是一个分组的 DataFrame;如果只传递单个列名作为标量,则返回一个分组的 Series

s_grouped = df.groupby(['key1', 'key2'])['data2']
s_grouped
表 7.13: 对分组Series进行聚合的结果
s_grouped.mean()

7.1.3 使用字典和Series进行分组

分组信息可能以非数组的形式存在。为了演示这一点,我们来看一个更具中国情境的例子。我们将使用 akshare 包获取中国部分省份的人均GDP数据。

表 7.14: 中国部分省市人均GDP(元)
import akshare as ak

# 获取宏观经济数据 - 省份数据 - 年度国民经济核算 - 人均国内生产总值
macro_china_gdp_df = ak.macro_china_province_gdp_yearly()

# 筛选我们感兴趣的省份和年份范围
provinces = ['北京', '上海', '江苏', '浙江', '广东', '四川', '河南', '陕西']
years =

df_gdp = macro_china_gdp_df[
    macro_china_gdp_df['province'].isin(provinces) & 
    macro_china_gdp_df['year'].isin(years)
]

# 选择需要的列并重命名
df_gdp = df_gdp[['province', 'year', 'gdp_per_capita']].copy()
df_gdp.rename(columns={'province': 'province_cn', 'year': 'year', 'gdp_per_capita': 'gdp_per_capita_cny'}, inplace=True)
df_gdp.head()

现在,假设我们有一个按地理区域对这些省份进行分组的对应关系,并希望按此分组对人均GDP值进行汇总。

列表 7.4
mapping = {'北京': '华北', '上海': '华东', '江苏': '华东', '浙江': '华东', 
           '广东': '华南', '四川': '西南', '河南': '华中', '陕西': '西北'}

# 将省份名称映射到区域以创建分组键
by_region = df_gdp.groupby(df_gdp['province_cn'].map(mapping))

现在我们可以应用聚合操作,例如,计算样本年份内每个区域的平均人均GDP。

表 7.15: 按地理区域划分的平均人均GDP (2018-2022)
by_region['gdp_per_capita_cny'].mean().to_frame()

Series 也具有相同的功能,可以将其视为一个固定大小的映射:

map_series = pd.Series(mapping, name='region')
map_series

我们也可以将这个 Series 传递给 groupby

表 7.16: 使用Series进行分组的各区域平均人均GDP
df_gdp.groupby(df_gdp['province_cn'].map(map_series)).mean(numeric_only=True)

7.1.4 使用函数进行分组

与字典或 Series 相比,使用 Python 函数是定义分组映射的一种更通用的方法。任何作为分组键传递的函数都将对每个索引值调用一次(如果使用 axis='columns',则对每个列值调用一次),其返回值将用作组名。

让我们使用 表 tbl-china-gdp-data 中的经济数据 DataFrame,但首先,我们将设置一个更有意义的索引。

表 7.17: 以省份和年份为索引的经济数据
df_gdp_indexed = df_gdp.set_index(['province_cn', 'year'])
df_gdp_indexed.head()

假设您想按省份名称的长度进行分组。虽然您可以计算一个字符串长度的数组,但直接传递 len 函数更简单。pandas 会将其应用于索引的第一层(‘province_cn’)。

表 7.18: 按省份名称长度分组的平均人均GDP
# 获取索引的第一层 ('province_cn')
province_index = df_gdp_indexed.index.get_level_values(0)
df_gdp_indexed.groupby(province_index.map(len)).mean(numeric_only=True)

将函数与数组、字典或 Series 混合使用没有问题,因为所有东西在内部都会被转换为数组。

7.1.5 按索引级别分组

对于具有层级索引的数据集,最后一个便利之处是能够使用轴索引的某个级别进行聚合。使用 表 tbl-gdp-indexed 中的 df_gdp_indexed DataFrame,我们可以按索引的 ‘year’ 级别进行分组。

要按级别分组,请使用 level 关键字传递级别编号或名称:

表 7.19: 按 “year” 索引级别进行聚合
df_gdp_indexed.groupby(level='year').mean()

7.2 数据聚合

聚合是指任何从数组生成标量值的数据转换。前面的例子已经使用了其中的几种,包括 meancountsum。您可能想知道在 GroupBy 对象上调用 .mean() 时发生了什么。许多常见的聚合,如 表 tbl-optimized-groupby-methods 中所列的,都有优化的实现。然而,您不仅限于使用这些方法。

表 7.20: 经过优化的 groupby 方法
经过优化的 groupby 方法
函数名 描述
any, all 如果任何(一个或多个值)或所有非NA值为“真值”,则返回 True
count 非NA值的数量
cummin, cummax 非NA值的累积最小值和最大值
cumsum 非NA值的累积和
cumprod 非NA值的累积积
first, last 第一个和最后一个非NA值
mean 非NA值的均值
median 非NA值的算术中位数
min, max 非NA值的最小值和最大值
nth 检索数据排序后在位置n处出现的值
ohlc 为类时间序列数据计算四个“开-高-低-收”统计量
prod 非NA值的乘积
quantile 计算样本分位数
rank 非NA值的序数排名,类似于调用 Series.rank
size 计算分组大小,以Series形式返回结果
sum 非NA值的和
std, var 样本标准差和方差

您可以使用自己设计的聚合函数,并额外调用任何也在被分组对象上定义的方法。例如,nsmallest Series 方法从数据中选择所请求数量的最小值。虽然 nsmallest 没有为 GroupBy 显式实现,但我们仍然可以通过一个未优化的实现来使用它。在内部,GroupBySeries 分片,对每个分片调用 piece.nsmallest(n),然后将这些结果组装成结果对象。让我们回到 表 tbl-initial-df-random 中的初始示例 DataFrame

df
表 7.21: 为 key1 中的每个组选择 data1 的两个最小值
grouped = df.groupby('key1')
grouped['data1'].nsmallest(2)

要使用您自己的聚合函数,请将任何聚合数组的函数传递给 aggregate 方法或其简写别名 agg

列表 7.5
def peak_to_peak(arr):
    return arr.max() - arr.min()
表 7.22: 应用自定义的 peak_to_peak 聚合
grouped.agg(peak_to_peak)
性能考量

自定义的聚合函数,比如我们刚才定义的 peak_to_peak,通常比 pandas 内置的优化函数(如 表 tbl-optimized-groupby-methods 中的函数)要慢得多。这是因为在后台,pandas 的优化函数通常是在 Cython 中实现的,直接在内存中对数据进行操作,避免了 Python 函数调用的开销和数据在不同层级之间重新排列的成本。而对于自定义函数,pandas 需要为每个组调用一次 Python 函数,这个过程涉及到额外的数据结构构建和函数调用开销。因此,当性能至关重要时,应优先使用内置的优化函数。

您可能会注意到,像 describe 这样的方法也能工作,尽管严格来说它们不是聚合。它们为每个组生成了数据的多方面摘要。

表 7.23: 每个组的描述性统计
# 输出可能很宽,所以我们进行转置以便于阅读
grouped.describe().T

7.2.1 逐列及多函数应用

让我们使用一个更贴近中国商业实践的数据集来探索更高级的聚合。我们将虚构一个来自某连锁餐厅的消费数据集,其中包含了消费金额、支付方式、是否为会员等信息。在中国,小费文化不普遍,因此我们用“折扣”代替。

列表 7.6
# 创建一个模拟数据集
data = {
    'total_bill': np.random.uniform(50, 500, 250).round(2),
    'discount': np.random.uniform(0, 50, 250).round(2),
    'member': np.random.choice(['Yes', 'No'], 250, p=[0.6, 0.4]),
    'day': np.random.choice(['Fri', 'Sat', 'Sun', 'Thur'], 250),
    'time': np.random.choice(['Lunch', 'Dinner'], 250),
    'size': np.random.randint(1, 7, 250)
}
restaurant = pd.DataFrame(data)

# 折扣不能超过总消费
restaurant['discount'] = restaurant.apply(
    lambda row: min(row['discount'], row['total_bill'] * 0.5), axis='columns'
)
restaurant['discount_pct'] = (restaurant['discount'] / restaurant['total_bill']).round(4)
restaurant.head()

如您所见,对 SeriesDataFrame 的所有列进行聚合,只需使用 aggregate(或 agg)并附上期望的函数,或调用像 meanstd 这样的方法。然而,您可能希望根据不同的列使用不同的聚合函数,或者一次性使用多个函数。幸运的是,这是可以做到的。首先,我将按 daymember 对餐厅数据进行分组:

grouped = restaurant.groupby(['day', 'member'])

请注意,对于像 表 tbl-optimized-groupby-methods 中的描述性统计,您可以将函数名作为字符串传递:

grouped_pct = grouped['discount_pct']
grouped_pct.agg('mean')

如果您传递一个函数或函数名的列表,您将得到一个 DataFrame,其列名取自这些函数:

表 7.24: 对分组Series应用多个聚合函数
grouped_pct.agg(['mean', 'std', peak_to_peak])

您不必接受 GroupBy 为列指定的名称;特别是 lambda 函数的名称为 '<lambda>',这使得它们难以识别。因此,如果您传递一个 (name, function) 元组的列表,每个元组的第一个元素将用作 DataFrame 的列名:

表 7.25: 使用自定义名称应用多个聚合
grouped_pct.agg([('average', 'mean'), ('stdev', np.std)])

对于 DataFrame,您有更多的选择,因为您可以为所有列指定一个函数列表,或者为每列指定不同的函数。首先,假设我们想为 discount_pcttotal_bill 列计算相同的三个统计量:

表 7.26: 对多列应用多个函数
functions = ['count', 'mean', 'max']
result = grouped[['discount_pct', 'total_bill']].agg(functions)
result

如您所见,结果 DataFrame 具有层级列。您可以像通常那样访问列的子集:

result['discount_pct']

和之前一样,可以传递一个带有自定义名称的元组列表:

表 7.27: 对多列应用命名聚合
ftuples = [('Average', 'mean'), ('Variance', np.var)]
grouped[['discount_pct', 'total_bill']].agg(ftuples)

现在,假设您想对一个或多个列应用可能不同的函数。为此,向 agg 传递一个字典,其中包含列名到目前为止列出的任何函数规范的映射:

表 7.28: 对不同列应用不同函数
grouped.agg({'discount': np.max, 'size': 'sum'})
表 7.29: 对某些列应用多个函数,对其他列应用单个函数
grouped.agg({'discount_pct': ['min', 'max', 'mean', 'std'],
             'size': 'sum'})

只有当至少有一列应用了多个函数时,DataFrame 才会有层级列。

7.2.2 返回不带行索引的聚合数据

到目前为止,在所有示例中,聚合后的数据都带有一个由唯一分组键组合构成的索引(可能是层级索引)。因为这并不总是理想的,您可以在大多数情况下通过向 groupby 传递 as_index=False 来禁用此行为。

表 7.30: 带有扁平索引的分组聚合
restaurant.groupby(['day', 'member'], as_index=False).mean(numeric_only=True)

当然,总是可以通过在结果上调用 reset_index() 来获得这种格式的结果。使用 as_index=False 参数可以避免一些不必要的计算。

7.3 Apply: 通用的拆分-应用-合并

最通用的 GroupBy 方法是 applyapply 将被操作的对象拆分成块,对每个块调用传递的函数,然后尝试将这些块连接起来。

回到之前的餐厅消费数据集,假设您想按组选择前五个 discount_pct 值。首先,编写一个函数,用于选择特定列中值最大的行:

列表 7.7
def top(df, n=5, column='discount_pct'):
    return df.sort_values(column, ascending=False)[:n]
    
top(restaurant, n=6)

现在,如果我们按 member 分组,并用这个函数调用 apply,我们会得到以下结果:

表 7.31: 会员与非会员的前5名折扣百分比
restaurant.groupby('member').apply(top)

这里发生了什么?top 函数在 groupby 的每个组上被调用。restaurant DataFrame 根据 member 的值被分割成组。然后 top 函数在每个组上被调用,每个函数调用的结果使用 pandas.concat 粘合在一起,并用组名标记各个部分。因此,结果有一个层级索引,其内层包含了原始 DataFrame 的索引值。

如果您向 apply 传递一个接受其他参数或关键字的函数,您可以在函数之后传递它们:

表 7.32: 每个会员和日期组合的最高总账单
restaurant.groupby(['member', 'day']).apply(top, n=1, column='total_bill')

除了这些基本的使用机制,要充分利用 apply 可能需要一些创造力。传递的函数内部发生什么完全取决于您;它必须返回一个 pandas 对象或一个标量值。

例如,您可能还记得我之前在一个 GroupBy 对象上调用了 describe

result = restaurant.groupby('member')['discount_pct'].describe()
result

为了得到不同的视图,可以将其 unstack:

result.unstack('member')

GroupBy 内部,当您调用像 describe 这样的方法时,它实际上只是以下操作的快捷方式:

def f(group):
    return group.describe()
grouped.apply(f)

7.3.1 禁止分组键

在前面的例子中,您看到结果对象有一个由分组键形成的层级索引,以及原始对象每个部分的索引。您可以通过向 groupby 传递 group_keys=False 来禁用此行为:

表 7.33: 会员与非会员的前5名折扣百分比,并禁止了分组键
restaurant.groupby('member', group_keys=False).apply(top)

7.3.2 分位数和分箱分析

您可能还记得,pandas 有一些工具,特别是 pandas.cutpandas.qcut,用于将数据按您选择的箱或样本分位数切分成桶。将这些函数与 groupby 结合起来,可以方便地对数据集进行分箱或分位数分析。考虑一个简单的随机数据集,并使用 pandas.cut 进行等长分箱:

列表 7.8
frame = pd.DataFrame({'data1': np.random.standard_normal(1000),
                      'data2': np.random.standard_normal(1000)})
frame.head()
quartiles = pd.cut(frame['data1'], 4)
quartiles.head(10)

cut 返回的 Categorical 对象可以直接传递给 groupby。因此,我们可以为这些四分位数计算一组分组统计量,如下所示:

列表 7.9
def get_stats(group):
    return pd.DataFrame(
        {'min': group.min(), 'max': group.max(),
         'count': group.count(), 'mean': group.mean()}
    )

grouped = frame.groupby(quartiles)
grouped.apply(get_stats)

请记住,使用 agg 可以更简单地计算出相同的结果:

表 7.34: 使用 agg 方法进行分位数分析
grouped.agg(['min', 'max', 'count', 'mean'])

这些是等长分箱。要根据样本分位数计算等大小的分箱,请使用 pandas.qcut。我们可以传递 labels=False 来只获取四分位数索引而不是区间:

quartiles_samp = pd.qcut(frame['data1'], 4, labels=False)
quartiles_samp.head()

现在我们可以按这些样本分位数进行分组:

表 7.35: 按样本分位数进行聚合
frame.groupby(quartiles_samp).apply(get_stats)

7.3.3 示例:使用特定于组的值填充缺失值

在清理缺失数据时,有时您会使用 dropna 删除数据观测值,但在其他情况下,您可能希望使用固定值或从数据中派生的某个值来填充空值(NA)。fillna 是实现此目的的正确工具。

假设您需要填充值因组而异。一种方法是对数据进行分组,并使用 apply 和一个在每个数据块上调用 fillna 的函数。这里有一些关于中国各省的数据,分为南方和北方地区(以秦岭-淮河为界):

列表 7.10
provinces = ['黑龙江', '北京', '山东', '河南',
          '上海', '湖北', '四川', '广东']
group_key = ['北方'] * 4 + ['南方'] * 4
data = pd.Series(np.random.standard_normal(8), index=provinces)

# 将某些值设为缺失
data[['山东', '湖北', '广东']] = np.nan
data

我们现在可以使用各组的均值来填充 NA 值:

列表 7.11
def fill_mean(group):
    return group.fillna(group.mean())

#| label: tbl-fill-mean-result
#| tbl-cap: '用区域均值填充缺失值后的数据'
data.groupby(group_key).apply(fill_mean)

在另一种情况下,您可能在代码中预定义了因组而异的填充值。由于分组内部有一个 name 属性,我们可以利用它:

列表 7.12
fill_values = {'北方': 0.5, '南方': -1}
def fill_func(group):
    return group.fillna(fill_values[group.name])

data.groupby(group_key).apply(fill_func)

7.3.4 示例:随机抽样和排列

假设您想为蒙特卡洛模拟目的从一个大型数据集中抽取随机样本(有放回或无放回)。我们可以使用 Seriessample 方法。为了演示,这里有一种构建一副扑克牌的方法:

列表 7.13
# H=红桃, S=黑桃, C=梅花, D=方块
suits = ['H', 'S', 'C', 'D']
card_val = (list(range(1, 11)) + * 3) * 4
base_names = ['A'] + list(range(2, 11)) + ['J', 'K', 'Q']
cards = []
for suit in suits:
    cards.extend(str(num) + suit for num in base_names)
    
deck = pd.Series(card_val, index=cards)
deck.head(13)

从牌堆中抽取五张牌可以写成:

def draw(deck, n=5):
    return deck.sample(n)
draw(deck)

假设您想从每种花色中随机抽取两张牌。因为花色是每张牌名称的最后一个字符,我们可以基于此进行分组并使用 apply

列表 7.14
def get_suit(card):
    # 最后一个字母是花色
    return card[-1]

#| label: tbl-draw-by-suit
#| tbl-cap: '从每种花色中随机抽取两张牌'
deck.groupby(get_suit).apply(draw, n=2)

或者,我们可以传递 group_keys=False 来去掉外层的花色索引:

表 7.36: 从每种花色中抽牌,并禁止分组键
deck.groupby(get_suit, group_keys=False).apply(draw, n=2)

7.3.5 示例:分组加权平均和相关性

groupby 的拆分-应用-合并范式下,DataFrame 中的列之间或两个 Series 之间的操作是可能的。举个例子,这个数据集包含分组键、值和一些权重:

表 7.37: 用于分组加权平均计算的示例数据
df = pd.DataFrame({'category': ['a', 'a', 'a', 'a', 'b', 'b', 'b', 'b'],
                   'data': np.random.standard_normal(8),
                   'weights': np.random.uniform(size=8)})
df

按类别计算的加权平均值将是:

列表 7.15
def get_wavg(group):
    return np.average(group['data'], weights=group['weights'])

#| label: tbl-wavg-result
#| tbl-cap: '按类别的分组加权平均值'
df.groupby('category').apply(get_wavg)

再举一个更贴近金融实践的例子。我们将使用 akshare 获取几只A股龙头股票(贵州茅台、宁德时代、中国平安)和沪深300指数(000300.SH)近期的收盘价数据。

列表 7.16
import akshare as ak
import pandas as pd

tickers = {'sh600519': '贵州茅台', 'sz300750': '宁德时代', 'sh601318': '中国平安'}
index_ticker = 'sh000300' # 沪深300指数

# 获取股票数据
all_data = {}
for code, name in tickers.items():
    stock_df = ak.stock_zh_a_hist(symbol=code, period='daily', start_date='20180101', end_date='20231231', adjust='qfq')
    all_data[name] = stock_df.set_index('日期')['收盘']

# 获取指数数据
index_df = ak.stock_zh_index_daily(symbol=index_ticker)
index_df = index_df[index_df['date'] >= '2018-01-01'].set_index('date')
all_data['沪深300'] = index_df['close']

# 合并数据
close_px = pd.DataFrame(all_data).sort_index()
close_px.index = pd.to_datetime(close_px.index)
close_px.dropna(inplace=True)
close_px.tail()

DataFrameinfo() 方法在这里是一种方便的方式,可以概览 DataFrame 的内容。

close_px.info()

一个在投资组合分析中常见的任务是计算个股的每日收益率与市场指数(此处为沪深300)的年度相关性。作为一种实现方式,我们首先创建一个函数,计算每列与 ‘沪深300’ 列的成对相关性:

列表 7.17
def csi300_corr(group):
    return group.corrwith(group['沪深300'])

接下来,我们使用 pct_change 计算 close_px 的百分比变化(即日收益率):

rets = close_px.pct_change().dropna()

最后,我们按年份对这些收益率进行分组,年份可以从每个行标签(日期)中提取:

列表 7.18
def get_year(x):
    return x.year

#| label: tbl-yearly-corr
#| tbl-cap: '股票收益与沪深300指数的年度相关性'
by_year = rets.groupby(get_year)
by_year.apply(csi300_corr)

您还可以计算任意两列之间的相关性。这里我们计算贵州茅台和宁德时代之间的年度相关性:

列表 7.19
def corr_moutai_catl(group):
    return group['贵州茅台'].corr(group['宁德时代'])

#| label: tbl-moutai-catl-corr-result
#| tbl-cap: '贵州茅台和宁德时代收益的年度相关性'
by_year.apply(corr_moutai_catl)

7.3.6 示例:分组线性回归

与前一个示例的主题相同,您可以使用 groupby 执行更复杂的分组统计分析,只要函数返回一个 pandas 对象或标量值即可。例如,我们可以定义以下 regress 函数(使用 statsmodels 计量经济学库),它对每个数据块执行普通最小二乘法(OLS)回归。

模型解释:资本资产定价模型(CAPM)

这个例子本质上是在逐年估计资本资产定价模型 (Capital Asset Pricing Model, CAPM) 的Beta系数。CAPM是现代金融学的基石之一,其核心公式是 \(E(R_i) = R_f + \beta_i (E(R_m) - R_f)\)。其中,\(R_i\) 是资产收益率,\(R_m\) 是市场收益率。\(\beta_i\) (Beta) 衡量了资产 \(i\) 相对于整个市场的系统性风险。通过将个别股票(如“贵州茅台”)的收益率对市场指数(如“沪深300”)的收益率进行回归,我们得到的斜率系数就是该股票在该时期的Beta估计值。一个大于1的Beta意味着该股票的波动性大于市场,反之亦然。我们这里的 group-wise 回归,可以让我们观察到公司相对于市场的风险暴露是如何随时间演变的。

列表 7.20
import statsmodels.api as sm
def regress(data, yvar, xvars):
    Y = data[yvar]
    X = data[xvars]
    X = sm.add_constant(X) # 添加截距项
    result = sm.OLS(Y, X).fit()
    return result.params

现在,要对“宁德时代”相对“沪深300”的收益率进行年度线性回归,我们执行:

表 7.38: 宁德时代收益对沪深300收益的年度OLS回归
by_year.apply(regress, yvar='宁德时代', xvars=['沪深300'])

7.4 分组转换和“展开的”GroupBy

sec-apply-general 中,我们研究了用于执行转换的 apply 方法。还有一个名为 transform 的内置方法,它与 apply 类似,但对您可以使用的函数类型施加了更多限制:

  • 它可以产生一个标量值,该值将被广播到组的形状。
  • 它可以产生一个与输入组形状相同的对象。
  • 它不能改变其输入。

让我们看一个简单的例子来说明:

表 7.39: 用于演示 transform 的示例DataFrame
df = pd.DataFrame({'key': ['a', 'b', 'c'] * 4,
                   'value': np.arange(12.)})
df

以下是按键分组的组均值:

g = df.groupby('key')['value']
g.mean()

假设我们想要生成一个与 df['value'] 形状相同但值被替换为按 ‘key’ 分组的平均值的 Series。我们可以将一个计算单个组均值的函数传递给 transform

列表 7.21
def get_mean(group):
    return group.mean()
    
g.transform(get_mean)

对于内置的聚合函数,我们可以像 GroupBy agg 方法一样传递一个字符串别名:

g.transform('mean')
transform vs. apply

transform 的核心约束是它返回的对象必须与输入的分组块(group chunk)具有相同的形状(即相同的索引)。这使得它可以用来进行广播操作,比如用组内均值替换每个元素。而 apply 则灵活得多,它可以返回任意形状的对象,pandas 会尝试将所有返回结果智能地拼接起来。

一个会使 transform 失败但 apply 能成功的例子是返回一个标量值的函数,比如 group.describe()

# 这会失败,因为 describe() 返回一个 Series,其形状与输入组不同
# g.transform(lambda x: x.describe()) 

# 这可以工作,因为 apply 可以处理任意形状的输出
g.apply(lambda x: x.describe())

选择 transform 还是 apply 取决于您的目标:如果您想基于分组计算来“改变”或“转换”原始数据框中的值(例如中心化、标准化),请使用 transform。如果您想对每个组进行汇总,得到一个比原分组更小的结果,请使用 applyagg

apply 类似,transform 适用于返回 Series 的函数,但结果的大小必须与输入相同。例如,我们可以使用一个辅助函数将每个组乘以2:

def times_two(group):
    return group * 2
g.transform(times_two)

作为一个更复杂的例子,我们可以计算每个组内降序的排名:

列表 7.22
def get_ranks(group):
    return group.rank(ascending=False)
g.transform(get_ranks)

考虑一个由简单聚合组成的分组转换函数,例如,将数据进行标准化(计算z-score):

列表 7.23
def normalize(x):
    return (x - x.mean()) / x.std()

在这种情况下,我们可以使用 transformapply 获得等效的结果:

g.transform(normalize)
g.apply(normalize)

'mean''sum' 这样的内置聚合函数通常比通用的 apply 函数快得多。当与 transform 一起使用时,它们也有一个“快速路径”。这使我们能够执行所谓的 “展开的” (unwrapped) 分组操作:

normalized = (df['value'] - g.transform('mean')) / g.transform('std')
normalized

在这里,我们是在多个 GroupBy 操作的输出之间进行算术运算,而不是编写一个函数并将其传递给 groupby(...).apply。这种向量化的方法就是所谓的“展开的”,它通常要快得多。

7.5 透视表和交叉表

透视表是一种数据汇总工具,常见于Excel等电子表格程序和其他数据分析软件中。它通过一个或多个键聚合数据表,将数据排列在一个矩形中,其中一些分组键沿行排列,一些沿列排列。在 Python 中使用 pandas 创建透视表是通过 groupby 功能以及利用层级索引的重塑操作实现的。DataFrame 还有一个 pivot_table 方法,并且还有一个顶级的 pandas.pivot_table 函数。

回到我们的餐厅消费数据集,假设您想计算一个按 daymember 排列在行上的分组均值表(pivot_table 的默认聚合类型):

表 7.40: 一个简单的均值透视表
restaurant.pivot_table(index=['day', 'member'], numeric_only=True)

这本可以用 groupby 直接生成。现在,假设我们只想对 discount_pctsize 求平均值,并额外按 time 分组。我将把 member 放在表的列中,把 timeday 放在行中:

表 7.41: 带有行和列分组的透视表
restaurant.pivot_table(index=['time', 'day'],
                       columns='member',
                       values=['discount_pct', 'size'])

我们可以通过传递 margins=True 来扩充此表以包含部分总计。这会添加 All 行和列标签,其对应的值是单个层级内所有数据的分组统计信息:

表 7.42: 带有用于小计和总计的边距的透视表
restaurant.pivot_table(index=['time', 'day'],
                       columns='member',
                       values=['discount_pct', 'size'],
                       margins=True)

要使用除 mean 之外的聚合函数,请将其传递给 aggfunc 关键字参数。例如,'count'len 会给您一个分组大小的交叉表:

表 7.43: 使用 len 作为聚合函数的透视表
restaurant.pivot_table(index=['time', 'member'],
                       columns='day',
                       values='discount_pct',
                       aggfunc=len,
                       margins=True)

如果某些组合为空(或为NA),您可能希望传递一个 fill_value

表 7.44: 使用 fill_value 处理缺失组合的透视表
restaurant.pivot_table(index=['time', 'size', 'member'],
                       columns='day',
                       values='discount_pct',
                       fill_value=0)

有关 pivot_table 选项的摘要,请参见 表 tbl-pivot-options

表 7.45: pivot_table 选项
pivot_table 选项
参数 描述
values 要聚合的列名或列名列表;默认情况下,聚合所有数值列
index 用于在结果透视表的行上分组的列名或其他分组键
columns 用于在结果透视表的列上分组的列名或其他分组键
aggfunc 聚合函数或函数列表(默认为 ‘mean’);可以是 groupby 上下文中任何有效的函数
fill_value 替换结果表中的缺失值
dropna 如果为 True,则不包括条目全为 NA 的列
margins 添加行/列小计和总计(默认为 False
margins_name margins=True 时,用于边距行/列标签的名称;默认为 ‘All’
observed 对于分类分组键,如果为 True,则只显示键中观察到的类别值,而不是所有类别

7.5.1 交叉表: Crosstab

交叉表(crosstab)是透视表的一种特殊情况,用于计算分组频率。假设我们想按省份和饮食口味偏好来总结一些调查数据。pandas.crosstab 函数对于此任务可能比 pivot_table 更方便。

列表 7.24
from io import StringIO

data = """Sample,Province,Flavor
1,四川,重辣
2,广东,清淡
3,四川,微辣
4,湖南,重辣
5,广东,清淡
6,江苏,清淡
7,四川,重辣
8,北京,清淡
9,湖南,重辣
10,广东,清淡"""

data = pd.read_csv(StringIO(data))
表 7.46: 省份和口味偏好的交叉表
pd.crosstab(data['Province'], data['Flavor'], margins=True)

crosstab 的前两个参数可以是数组、Series 或数组列表。就像在我们模拟的餐厅数据中一样:

表 7.47: 在餐厅数据集上的交叉表
pd.crosstab([restaurant['time'], restaurant['day']], restaurant['member'], margins=True)

7.6 结论

对于利用python进行数据分析而言,掌握 pandas 的数据分组工具是一项不可或缺的技能。“拆分-应用-合并”策略为将复杂的数据操作问题分解为可管理的步骤提供了一个强大的心智模型。无论是执行简单的聚合、复杂的组内转换,还是创建富有洞察力的透视表,groupby 功能都是驱动大量数据清洗、建模和统计分析工作的核心引擎。