Pandas groupby
核心用法解析:数据分析的瑞士军刀
数据是现代世界的基石,而对数据进行分组、聚合和分析是数据科学和商业智能中最常见的任务之一。Pandas 库作为 Python 中强大的数据处理工具,其核心功能之一就是 groupby
方法。groupby
的出现极大地简化了分组分析的过程,它允许我们根据一个或多个键将数据分成不同的组,然后对每个组独立地执行各种操作。
可以毫不夸张地说,熟练掌握 groupby
是成为 Pandas 高手的必经之路,也是进行高效数据分析的关键技能。本文将深入探讨 Pandas groupby
的核心概念、工作原理以及各种常见和高级用法,帮助你充分释放其强大的分析能力。
1. 理解 groupby
的核心概念:Split-Apply-Combine
Pandas 的 groupby
操作遵循着一个被称为 “Split-Apply-Combine” (拆分-应用-组合) 的范式。理解这个范式是理解 groupby
如何工作的关键:
- Split (拆分): 根据用户指定的键(一个或多个列或索引),将原始 DataFrame 或 Series 拆分成多个独立的数据块。每个数据块对应于分组键的一个唯一组合。想象一下,你有一堆各种颜色的积木,Split 步骤就是把相同颜色的积木放到一起,形成不同的堆。
- Apply (应用): 对每个拆分出来的数据块(组)独立地应用一个函数或操作。这个操作可以是聚合(如求和、平均)、转换(如标准化、填充缺失值)或过滤(如选择满足条件的组)。回到积木的例子,Apply 步骤就是在每个颜色堆的积木上执行某个操作,比如数一下每堆有多少块,或者计算每堆积木的总重量。
- Combine (组合): 将每个组独立执行操作后得到的结果组合成一个统一的 Series、DataFrame 或其他结构。这是将分散的结果汇聚起来,形成最终分析结果的步骤。将每堆积木的操作结果(如数量或总重量)整理成一张表格,这就是 Combine 步骤。
groupby
方法本身执行的是 Split 步骤,它返回一个 GroupBy
对象。这个 GroupBy
对象是惰性的,它知道如何根据分组键拆分数据,但实际的操作(Apply 和 Combine)是在你对 GroupBy
对象调用聚合、转换或过滤方法时才会执行。
2. groupby()
方法的基本用法
groupby()
方法是所有分组操作的起点。它的基本语法是 df.groupby(by=None, axis=0, level=None, as_index=True, sort=True, group_keys=True, squeeze=NoDefault.no_default, observed=True, dropna=True)
。最常用的参数是 by
。
2.1 按单个列分组
这是最常见的分组方式。你可以传递一个列名(字符串)或一个包含列名的列表给 by
参数。
假设我们有一个销售数据 DataFrame df
:
“`python
import pandas as pd
import numpy as np
data = {
‘Region’: [‘North’, ‘South’, ‘North’, ‘South’, ‘East’, ‘North’, ‘East’, ‘South’],
‘Category’: [‘A’, ‘B’, ‘A’, ‘C’, ‘B’, ‘C’, ‘A’, ‘B’],
‘Sales’: [100, 150, 120, 80, 200, 90, 180, 130],
‘Quantity’: [10, 15, 12, 8, 20, 9, 18, 13],
‘Date’: pd.to_datetime([‘2023-01-15’, ‘2023-01-16’, ‘2023-01-17’, ‘2023-01-18’, ‘2023-01-19’, ‘2023-01-20’, ‘2023-01-21’, ‘2023-01-22’])
}
df = pd.DataFrame(data)
print(“原始 DataFrame:”)
print(df)
按 ‘Region’ 列分组
grouped_by_region = df.groupby(‘Region’)
print(“\n按 ‘Region’ 分组后的 GroupBy 对象:”)
print(grouped_by_region)
“`
输出会显示一个 GroupBy
对象,例如 <pandas.core.groupby.generic.DataFrameGroupBy object at ...>
. 这表明数据已经被拆分好了,等待你进一步的操作。
2.2 按多个列分组
你可以传递一个包含多个列名的列表给 by
参数,以实现更细粒度的分组。数据将根据这些列的所有唯一组合进行拆分。
“`python
按 ‘Region’ 和 ‘Category’ 列分组
grouped_by_region_category = df.groupby([‘Region’, ‘Category’])
print(“\n按 ‘Region’ 和 ‘Category’ 分组后的 GroupBy 对象:”)
print(grouped_by_region_category)
“`
这将把数据拆分成更小的组,例如 (‘North’, ‘A’), (‘North’, ‘C’), (‘South’, ‘B’), (‘South’, ‘C’), (‘East’, ‘A’), (‘East’, ‘B’) 等。
2.3 按索引或索引级别分组
如果你的 DataFrame 使用了 MultiIndex(多层索引),你可以通过指定 level
参数来按索引的特定级别进行分组。如果 DataFrame 没有命名索引,你可以直接按索引本身进行分组(虽然不常见)。
“`python
创建一个带有 MultiIndex 的 DataFrame
df_multiindex = df.set_index([‘Region’, ‘Category’])
print(“\n带有 MultiIndex 的 DataFrame:”)
print(df_multiindex)
按第一个索引级别 (Region) 分组
grouped_by_index_level_0 = df_multiindex.groupby(level=0)
print(“\n按第一个索引级别 (Region) 分组后的 GroupBy 对象:”)
print(grouped_by_index_level_0)
按第二个索引级别 (Category) 分组
grouped_by_index_level_1 = df_multiindex.groupby(level=1)
print(“\n按第二个索引级别 (Category) 分组后的 GroupBy 对象:”)
print(grouped_by_index_level_1)
同时按多个索引级别分组
grouped_by_index_levels = df_multiindex.groupby(level=[0, 1]) # 等同于 df.groupby([‘Region’, ‘Category’]) 前提是 Region 和 Category 是索引
print(“\n同时按多个索引级别分组后的 GroupBy 对象:”)
print(grouped_by_index_levels)
“`
2.4 其他 groupby()
参数
axis
: 默认为 0,表示按行分组(列的值)。设置为 1 则表示按列分组(行的值)。as_index
: 默认为True
。如果为True
,分组键将成为结果 DataFrame 的索引(对于单列分组是 Index,对于多列分组是 MultiIndex)。如果为False
,分组键将作为普通列出现在结果中。这对于后续的数据处理非常有用。sort
: 默认为True
。是否对分组键进行排序。dropna
: 默认为True
。如果分组键包含NaN
值,是否丢弃这些行。如果设置为False
,NaN
将作为一个独立的分组。
3. 访问 GroupBy 对象中的数据
虽然 GroupBy
对象本身不直接显示结果,但我们可以查看它的结构或访问特定的组。
3.1 迭代 GroupBy 对象
你可以迭代 GroupBy
对象,每次迭代会返回一个元组 (name, group_df)
,其中 name
是分组键的值(对于多列分组是元组),group_df
是该组对应的数据子集(一个 DataFrame)。
“`python
print(“\n迭代按 ‘Region’ 分组的结果:”)
for region_name, region_df in grouped_by_region:
print(f”\nRegion: {region_name}”)
print(region_df)
print(“\n迭代按 ‘Region’ 和 ‘Category’ 分组的结果:”)
for (region_name, category_name), group_df in grouped_by_region_category:
print(f”\nRegion: {region_name}, Category: {category_name}”)
print(group_df)
“`
3.2 获取特定组
你可以使用 get_group()
方法根据分组键的值获取特定的一个组的数据。
“`python
获取 Region 为 ‘North’ 的组
north_group = grouped_by_region.get_group(‘North’)
print(“\n获取 Region 为 ‘North’ 的组:”)
print(north_group)
获取 Region 为 ‘South’ 且 Category 为 ‘B’ 的组
south_b_group = grouped_by_region_category.get_group((‘South’, ‘B’))
print(“\n获取 Region 为 ‘South’ 且 Category 为 ‘B’ 的组:”)
print(south_b_group)
“`
4. GroupBy 操作:Apply 阶段的核心方法
GroupBy
对象的真正威力体现在 Apply 阶段。Pandas 提供了多种方法来对每个组执行操作,主要分为三类:聚合 (Aggregation)、转换 (Transformation) 和过滤 (Filtration)。此外,还有一个通用的 apply()
方法可以处理更复杂的情况。
4.1 聚合 (Aggregation)
聚合操作对每个组的数据进行汇总计算,返回一个单一值。常见的聚合函数包括 sum()
, mean()
, count()
, size()
, min()
, max()
, std()
, var()
, first()
, last()
, nunique()
等。结果通常是一个 Series 或 DataFrame,其索引是分组键。
“`python
计算每个区域的总销售额
region_sales = grouped_by_region[‘Sales’].sum()
print(“\n每个区域的总销售额:”)
print(region_sales)
计算每个区域的平均销量
region_quantity_mean = grouped_by_region[‘Quantity’].mean()
print(“\n每个区域的平均销量:”)
print(region_quantity_mean)
计算每个区域的销售记录数量 (非NaN值)
region_count = grouped_by_region[‘Sales’].count()
print(“\n每个区域的销售记录数量 (count – 非NaN):”)
print(region_count)
计算每个区域的总行数 (包括NaN)
region_size = grouped_by_region.size()
print(“\n每个区域的总行数 (size – 包括NaN):”)
print(region_size)
计算每个区域和类别的总销售额和总销量
region_category_sales_quantity_sum = grouped_by_region_category[[‘Sales’, ‘Quantity’]].sum()
print(“\n每个区域和类别的总销售额和总销量:”)
print(region_category_sales_quantity_sum)
获取每个区域的第一条销售记录
region_first_record = grouped_by_region.first()
print(“\n每个区域的第一条销售记录:”)
print(region_first_record)
获取每个区域的最后一条销售记录
region_last_record = grouped_by_region.last()
print(“\n每个区域的最后一条销售记录:”)
print(region_last_record)
计算每个区域有多少独特的分类
region_unique_categories = grouped_by_region[‘Category’].nunique()
print(“\n每个区域独特的分类数量:”)
print(region_unique_categories)
“`
使用 agg()
进行多重聚合或指定列聚合
agg()
方法提供了更大的灵活性,可以同时对不同的列应用不同的聚合函数,甚至对同一列应用多个聚合函数,并可以为结果列指定名称。
“`python
对 ‘Sales’ 列计算总和、平均值和计数
region_sales_agg = grouped_by_region[‘Sales’].agg([‘sum’, ‘mean’, ‘count’])
print(“\n按区域对 Sales 列进行多重聚合:”)
print(region_sales_agg)
对不同的列应用不同的聚合函数
region_sales_quantity_agg = grouped_by_region.agg({
‘Sales’: ‘sum’,
‘Quantity’: ‘mean’,
‘Date’: ‘max’ # 找出每个区域的最新销售日期
})
print(“\n按区域对不同列应用不同聚合函数:”)
print(region_sales_quantity_agg)
使用元组为聚合结果命名
region_sales_quantity_agg_named = grouped_by_region.agg(
TotalSales=(‘Sales’, ‘sum’),
AvgQuantity=(‘Quantity’, ‘mean’),
LatestDate=(‘Date’, ‘max’)
)
print(“\n按区域对不同列应用不同聚合函数并重命名:”)
print(region_sales_quantity_agg_named)
对同一列应用多个函数并命名
region_sales_multi_named = grouped_by_region.agg(
TotalSales=(‘Sales’, ‘sum’),
AvgSales=(‘Sales’, ‘mean’),
SalesCount=(‘Sales’, ‘count’)
)
print(“\n按区域对同一列应用多个函数并命名:”)
print(region_sales_multi_named)
“`
agg()
方法非常强大,它是进行复杂聚合分析的首选。你可以传递函数名(字符串)、函数本身、函数列表或字典。
4.2 转换 (Transformation)
转换操作对每个组的数据应用一个函数,并返回一个与原始组具有相同索引的 Series 或 DataFrame。转换的结果会与原始 DataFrame 进行对齐,通常用于在原始数据中添加组级别的计算结果。
常见的转换操作包括:
* 填充缺失值 (e.g., 使用组的平均值/中位数填充)
* 标准化或归一化 (e.g., 计算组内的 Z-score)
* 计算排名
* 计算组内的百分比变化等
转换方法通常使用 transform()
。传递给 transform()
的函数应该接收一个 Series 或 DataFrame,并返回一个具有相同索引的 Series 或 DataFrame。
“`python
计算每个区域销售额占该区域总销售额的比例
方法一: 计算总销售额,然后除以
region_total_sales = grouped_by_region[‘Sales’].transform(‘sum’) # transform(‘sum’) 等价于 grouped_by_region[‘Sales’].sum() 但结果是 Series,并且索引是对齐原始 df 的
df[‘RegionTotalSales’] = grouped_by_region[‘Sales’].transform(‘sum’)
df[‘SalesProportion’] = df[‘Sales’] / df[‘RegionTotalSales’]
print(“\n添加区域总销售额和销售占比列 (方法一):”)
print(df)
方法二: 在 transform 中直接计算比例 (需要自定义函数)
def calculate_proportion(x):
# x 是一个 Series (某个组的 Sales 列)
return x / x.sum()
df[‘SalesProportion_v2’] = grouped_by_region[‘Sales’].transform(calculate_proportion)
print(“\n添加销售占比列 (方法二 – transform with custom function):”)
print(df)
计算每个区域内销售额的 Z-score
def z_score(x):
# x 是一个 Series (某个组的 Sales 列)
return (x – x.mean()) / x.std()
df[‘SalesZScore’] = grouped_by_region[‘Sales’].transform(z_score)
print(“\n添加区域内销售额的 Z-score 列:”)
print(df)
假设 Sales 列有一些缺失值,使用组内平均值填充
df_with_nan = df.copy()
df_with_nan.loc[[1, 5], ‘Sales’] = np.nan # 制造一些缺失值
print(“\n带有缺失值的 DataFrame:”)
print(df_with_nan)
df_filled = df_with_nan.copy()
df_filled[‘SalesFilled’] = df_with_nan.groupby(‘Region’)[‘Sales’].transform(lambda x: x.fillna(x.mean()))
print(“\n使用区域内平均值填充 Sales 缺失值:”)
print(df_filled)
计算每个区域内销售额的排名
df[‘SalesRankWithinRegion’] = grouped_by_region[‘Sales’].rank(method=’min’, ascending=False)
print(“\n添加区域内销售额排名列:”)
print(df)
“`
transform()
方法特别适用于创建新的列,这些列包含基于组的计算结果,但需要与原始 DataFrame 的行一一对应。
4.3 过滤 (Filtering)
过滤操作根据每个组的属性(如组的大小、组内某个值的总和等)来决定是否保留整个组。filter()
方法用于执行此操作。传递给 filter()
的函数应该接收一个 DataFrame(代表一个组),并返回一个布尔值(True
表示保留该组,False
表示丢弃该组)。filter()
方法返回一个 DataFrame,它包含所有被保留的组的原始行。
“`python
过滤出销售总额大于 250 的区域
region_sales_sum = grouped_by_region[‘Sales’].sum() # 先计算出每个区域的总销售额
定义过滤函数:接收一个组的 DataFrame,判断该组的总销售额是否大于250
在 filter 的 lambda 中,x 是该组的 DataFrame
filtered_df_by_sum = grouped_by_region.filter(lambda x: x[‘Sales’].sum() > 250)
print(“\n过滤出销售总额大于 250 的区域对应的行:”)
print(filtered_df_by_sum)
过滤出记录数少于 3 条的区域
在 filter 的 lambda 中,len(x) 是该组的行数
filtered_df_by_size = grouped_by_region.filter(lambda x: len(x) < 3)
print(“\n过滤出记录数少于 3 条的区域对应的行:”)
print(filtered_df_by_size)
过滤出包含 Category ‘C’ 的区域
filtered_df_by_category = grouped_by_region.filter(lambda x: ‘C’ in x[‘Category’].values)
print(“\n过滤出包含 Category ‘C’ 的区域对应的行:”)
print(filtered_df_by_category)
“`
filter()
是保留原始行的一种方式,但它是基于整个组的属性来做决策的。
4.4 通用 apply()
方法
apply()
方法是 groupby
操作中最灵活但有时性能最低的方法。它可以用于执行聚合、转换或过滤等操作,或者任何其他对每个组进行计算的自定义逻辑。传递给 apply()
的函数会接收每个组作为一个完整的 DataFrame 或 Series,然后你可以对这个组执行任何 Pandas 操作。
apply()
方法根据返回结果的类型和形状,会尝试智能地判断是进行聚合、转换还是返回一个 Series/DataFrame。
- 如果函数返回一个 Series,并且所有 Series 的索引都是唯一的,且跨组的索引是所有组索引的子集,结果可能是一个 Series,其索引是分组键。
- 如果函数返回一个 Series,并且所有 Series 的索引都是相同的,结果可能是一个 DataFrame,其索引是分组键。
- 如果函数返回一个 DataFrame,结果将是一个 MultiIndex DataFrame,其中第一个索引是分组键。
- 如果函数返回一个标量,结果将是一个 Series,索引是分组键(这等同于聚合)。
- 如果函数返回一个与输入组具有相同索引和列的 DataFrame/Series,结果可能是一个与原始 DataFrame 结构相似的 DataFrame(这等同于转换)。
因为 apply()
接收的是完整的组 DataFrame/Series,你可以利用所有 Pandas API。
“`python
使用 apply 计算每个区域销售额和销量的相关系数
def corr_sales_quantity(group):
if len(group) < 2: # 相关系数需要至少两组数据
return np.nan
return group[‘Sales’].corr(group[‘Quantity’])
region_corr = grouped_by_region.apply(corr_sales_quantity)
print(“\n使用 apply 计算每个区域销售额和销量的相关系数:”)
print(region_corr)
使用 apply 返回每个区域销售额最高的行
def get_top_sales_row(group):
# group 是一个 DataFrame
return group.loc[group[‘Sales’].idxmax()] # idxmax() 找到最大值的索引标签
region_top_sales_row = grouped_by_region.apply(get_top_sales_row)
print(“\n使用 apply 返回每个区域销售额最高的行:”)
print(region_top_sales_row)
使用 apply 执行一个转换操作(虽然 transform 更推荐)
比如计算每个区域 Sales 的累计总和
def cumulative_sales(group):
# group 是一个 DataFrame
# 这里我们只需要 Sales 列,然后计算累计总和
# 重要的是返回的 Series/DataFrame 要保持原始索引
return group[‘Sales’].cumsum()
apply 在这里会识别到返回的是一个 Series 且索引与原始组一致,会尝试进行转换
df[‘RegionCumulativeSales_apply’] = grouped_by_region.apply(cumulative_sales)
print(“\n使用 apply 计算区域内累计销售额:”)
print(df)
注意:这里的 apply 行为类似 transform,但通常 transform 更快
使用 apply 执行一个自定义聚合,比如加权平均
def weighted_avg_sales(group):
# 计算加权平均:总销售额 / 总数量
return group[‘Sales’].sum() / group[‘Quantity’].sum()
region_weighted_avg = grouped_by_region.apply(weighted_avg_sales)
print(“\n使用 apply 计算每个区域的加权平均销售额:”)
print(region_weighted_avg)
“`
虽然 apply()
功能强大,但对于简单的聚合和转换任务,通常更推荐使用内置的聚合函数、agg()
或 transform()
,因为它们通常经过优化,性能更好,尤其是在处理大数据时。apply()
更适合那些用内置方法难以表达的复杂、自定义操作。
5. groupby
的一些高级用法和注意事项
5.1 as_index=False
的用途
正如之前提到的,as_index=False
会将分组键作为普通列保留在结果 DataFrame 中,而不是将其设置为索引。这在很多情况下非常方便,例如当你需要将分组结果与其他 DataFrame 进行合并,或者仅仅希望结果是一个扁平的 DataFrame 时。
“`python
按区域计算总销售额,并将 Region 作为普通列
region_sales_flat = df.groupby(‘Region’, as_index=False)[‘Sales’].sum()
print(“\n按区域计算总销售额 (as_index=False):”)
print(region_sales_flat)
按区域和类别计算总销售额和总销量,将分组键作为普通列
region_category_sales_quantity_flat = df.groupby([‘Region’, ‘Category’], as_index=False).agg({
‘Sales’: ‘sum’,
‘Quantity’: ‘sum’
})
print(“\n按区域和类别计算总销售额和总销量 (as_index=False):”)
print(region_category_sales_quantity_flat)
“`
比较 as_index=True
(默认)和 as_index=False
的结果,可以看出后者生成的结果 DataFrame 更容易直接使用。
5.2 处理分组键中的缺失值 (dropna
参数)
默认情况下,dropna=True
会忽略分组键中包含 NaN
的行。如果你想将 NaN
也视为一个独立的分组,可以设置 dropna=False
。
“`python
df_with_nan_region = df.copy()
df_with_nan_region.loc[0, ‘Region’] = np.nan # 在 Region 列引入 NaN
print(“\n带有 Region 缺失值的 DataFrame:”)
print(df_with_nan_region)
默认行为 (dropna=True) 会忽略 NaN 分组
grouped_with_nan_default = df_with_nan_region.groupby(‘Region’)[‘Sales’].sum()
print(“\n按 Region 分组求和 (dropna=True – 忽略 NaN):”)
print(grouped_with_nan_default)
dropna=False 会包含 NaN 分组
grouped_with_nan_include = df_with_nan_region.groupby(‘Region’, dropna=False)[‘Sales’].sum()
print(“\n按 Region 分组求和 (dropna=False – 包含 NaN):”)
print(grouped_with_nan_include)
“`
5.3 性能考虑:矢量化优先于 apply
在使用 groupby
进行计算时,如果能使用内置的聚合函数 (sum
, mean
, etc.)、transform()
或结合 agg()
来完成任务,通常这些方法会比使用 apply()
并传入一个自定义函数要快得多。这是因为内置方法和 transform/agg
可以利用 Pandas 和 NumPy 底层的 C 优化,而 apply
是在 Python 循环中迭代每个组,效率相对较低。
只有当你的分组逻辑无法通过内置方法或 agg
/transform
高效实现时,才应该考虑使用 apply()
。在实践中,始终优先尝试矢量化的解决方案。
6. 总结
Pandas groupby
是进行数据分组分析的强大工具。它基于 Split-Apply-Combine 范式,能够根据一个或多个键将数据拆分、对每个组应用操作,并将结果组合起来。
groupby()
方法是起点,返回一个GroupBy
对象。- 聚合 (Aggregation):使用
sum()
,mean()
,count()
,agg()
等方法对每个组进行汇总计算,结果通常是一行代表一个组。 - 转换 (Transformation):使用
transform()
方法对每个组应用函数,返回与原始组具有相同索引的结果,常用于在原始数据中添加组级别信息。 - 过滤 (Filtering):使用
filter()
方法根据组的属性决定是否保留整个组,返回原始 DataFrame 的子集。 - 通用
apply()
:最灵活的方法,适用于任何自定义的分组操作,但可能性能较低。
理解 Split-Apply-Combine 范式,熟练掌握聚合、转换和过滤的常用方法,并在需要时灵活使用 agg()
和 apply()
,你就能利用 Pandas groupby
高效地解决各种复杂的数据分析问题。记住在可能的情况下优先选择矢量化操作以获得最佳性能。
希望本文能帮助你更深入地理解和应用 Pandas 的 groupby
功能,使其成为你在数据分析旅途中的一把趁手“瑞士军刀”。多加练习,你会发现 groupby
在处理分组数据时带来的便利和效率提升。