你们有没有遇到过这种情况:拿到一份几百行的销售明细,老板想看一下每个区域每个品类的汇总情况,你第一反应是打开Excel拖透视表,拖了半天发现数据源更新了又得重新拖一遍。其实在 pandas 里,Groupby 和透视方法就是干这个的,而且比 Excel 透视表灵活得多。我做了几年数据分析,可以说这两个方法覆盖了日常工作中至少六成以上的数据汇总需求,从简单的分组求和到复杂的多维度交叉分析,基本都能搞定。这篇就把我实际项目里用下来的经验完整梳理一遍,适合刚接触 pandas 的初学者,也适合已经会用 groupby 但想系统掌握透视方法的同学。
1. Groupby 的底层逻辑:先分组、再聚合、后合并
很多人学 groupby 的时候,只是机械地记住"df.groupby('列名').sum()"这种写法,换一个需求就不知道怎么变了。其实把 groupby 拆开看,它的执行过程可以分成三个阶段:拆分(split)、应用(apply)、组合(combine)。理解这三个阶段,你就掌握了 groupby 的核心。
1.1 拆分阶段:分组键是怎么决定数据走向的
拆分阶段做的事情很简单:按照你指定的列,把 DataFrame 拆成若干个小块。比如你指定 df.groupby('区域'),那么"华东"的数据会被放到一起,"华南"的数据会被放到一起,每个区域都会得到一组独立的数据块。
这里有一个值得注意的点:分组键可以是单列,也可以是多列,甚至可以是 Series、数组或者自定义函数。我实际项目里最常用的是多列分组,比如"区域 + 品类"两个维度同时分组,pandas 会依次按照列的先后顺序进行层级分组,生成一个多层次的分组对象。
python复制import pandas as pd
# 模拟一份销售数据
df = pd.DataFrame({
'区域': ['华东', '华东', '华南', '华南', '华北', '华北', '华东', '华南'],
'品类': ['手机', '电脑', '手机', '电脑', '手机', '电脑', '电脑', '手机'],
'销售额': [1200, 3000, 900, 1500, 2100, 1800, 2600, 1300],
'利润': [80, 200, 60, 90, 140, 110, 170, 85]
})
# 多列分组
grouped = df.groupby(['区域', '品类'])
执行完这一步,grouped 还不是结果,它只是告诉你"分组关系已经确定好了"。真正出结果的是下一步。
1.2 聚合阶段:核心函数的选择决定了汇总口径
聚合阶段是最多样化的,因为你可以在这个阶段调用几乎任何函数。最常见的聚合方式有四种:
第一种是内置聚合函数,包括 sum()、mean()、count()、max()、min()、median()、std()、var() 等。这些函数会应用到所有数值列上。如果你只想对特定列聚合,可以先用 [] 选取列,再调用聚合方法。
第二种是 agg() 方法,这是我最推荐的写法。它允许你对不同的列指定不同的聚合函数,非常灵活:
python复制result = df.groupby(['区域']).agg({
'销售额': 'sum', # 销售额求和
'利润': ['mean', 'max'] # 利润求平均和最大值
})
第三种是用自定义函数。agg() 支持传入任意函数,比如你想计算销售额的极差(最大值减最小值),可以这样写:
python复制def spread(x):
return x.max() - x.min()
result = df.groupby('区域')['销售额'].agg(spread)
第四种是 apply() 方法,它比 agg() 更通用。区别在于 agg() 要求函数作用在每个分组后的列上,返回一个标量值;而 apply() 可以返回一个 DataFrame 或者 Series,是一个非常灵活的低级接口。简单理解:agg() 适合汇总统计,apply() 适合做更复杂的组内运算。
1.3 组合阶段:结果集怎么合并、索引怎么处理
combine 阶段是 pandas 自动完成的,但这里有一个特别容易踩的坑——as_index 参数。默认情况下 as_index=True,分组键会变成结果的索引而不是列。比如:
python复制result = df.groupby('区域')['销售额'].sum()
print(result)
# 输出:
# 区域
# 华东 6800
# 华南 3700
# 华北 3900
# Name: 销售额, dtype: int64
这个结果是 Series,索引是"区域",列名是"销售额"。在很多场景下这种结构非常方便,可以直接参与后续的计算。但如果你想把数据导出成 Excel 或者继续做透视,可能需要把分组键还原成列,这时候要么加 reset_index(),要么在 groupby() 里设置 as_index=False。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 透视表 pivot_table:用 Excel 的思维做 pandas 的数据重整
透视表的核心价值在于:它不是单纯地做汇总,而是把原始数据的长表格式转换成列联表格式,让维度之间的对比关系一目了然。Excel 的透视表大家都用过,pandas 的 pivot_table() 在思路上几乎一模一样,只是换了一套参数语法。
2.1 基本参数拆解:index、columns、values、aggfunc 的一一对应关系
第一次接触 pivot_table() 的人,看到一堆参数可能有点懵。其实只要把它跟 Excel 透视表拖拽字段的概念对应起来,就很好懂了:
index:相当于 Excel 透视表的"行标签",你想按什么维度看数据,就把它放到 index。columns:相当于 Excel 透视表的"列标签",你想横向展开什么维度,就把它放到 columns。values:相当于 Excel 透视表的"值",也就是要汇总的数值字段。aggfunc:相当于 Excel 透视表的"值字段设置",指定求和、求平均、计数等。margins:相当于 Excel 透视表里的"总计"。
给你一个具体例子:
python复制# 透视表:行为区域,列为品类,值取销售额总和
pt = pd.pivot_table(
df,
index='区域',
columns='品类',
values='销售额',
aggfunc='sum',
margins=True
)
print(pt)
输出结果是这样的:
code复制品类 电脑 手机 All
区域
华东 5600 1200 6800
华南 1500 2200 3700
华北 1800 2100 3900
All 8900 5500 14400
是不是跟 Excel 透视表的默认输出很像?All 行和 All 列就是 margins 参数带来的总计结果。如果你的业务报表需要小计和总计,这个参数能省不少事。
2.2 多级行索引与多级列索引:透视表的强大之处
透视表真正开始体现威力,是在多维度交叉分析的时候。比如你不仅要看"区域 × 品类",还想同时看"区域 × 品类 × 渠道",那只要在 index 里传入一个列表,在 columns 里也传入一个列表:
python复制df['渠道'] = ['线上', '线下', '线上', '线下', '线上', '线下', '线下', '线上']
pt_multi = pd.pivot_table(
df,
index=['区域', '渠道'],
columns=['品类'],
values='销售额',
aggfunc='sum'
)
print(pt_multi)
这个结果就是一个多层级索引的 DataFrame,行方向是"区域 + 渠道"的层级组合,列方向是品类。你可以用 .loc['华东', '线上'] 来提取某个交叉格,也可以用 .xs() 方法跨层级索引。
2.3 透视表的其他参数:fill_value、dropna、observed
三个参数单独拿出来说一下,因为它们在实际数据中很常见。
fill_value:当透视表某个格子里没有数据时,默认会显示 NaN。用 fill_value=0 可以把这些空格替换成 0,这在做销售报表时非常实用,因为业务上"无销售"和"缺失数据"含义不同。
dropna:默认是 True,表示如果某一行全部是 NaN 就丢弃。但如果你设置了 fill_value=0,通常不存在全部 NaN 的行,所以这个参数在某些组合下几乎不生效。我建议设置 fill_value=0 的同时把 dropna=False 显式写上,保持代码意图清晰。
observed:这个参数只在分组列存在 Categorical 类型的列时才起作用。如果设置为 False(pandas 1.x 之后默认值有变化),即使某个类别没有数据也会展示出来;设置为 True,则只展示实际出现的类别组合。当你的数据里有"未开始营业的门店"或"未上架的品类"时,这个参数决定了结果中是否保留这些空组合。
3. 交叉表 crosstab:分组汇总的轻量级变体
pandas.crosstab() 是透视表的一个变体,很多教材里一句话带过,但在实际业务中它非常实用。两者的核心区别在于:pivot_table() 操作的是 DataFrame 对象,需要显式指定数值列;而 crosstab() 接收的是 Series 或类似数组对象,专门用来做"频数统计"和"分组交叉计数"。
3.1 crosstab 与 pivot_table 的最直接差异
最直观的差异体现在使用场景上:当你只需要统计两个分类变量的组合频数时,crosstab() 写起来更简洁。比如你想看"每个区域的品类销量分布"(注意是销量,不是销售额),用 crosstab 一行就出来了:
python复制# 为数据增加一列销量
df['销量'] = [10, 30, 8, 15, 22, 20, 28, 12]
cross = pd.crosstab(df['区域'], df['品类'])
print(cross)
# 输出:
# 品类 电脑 手机
# 区域
# 华北 1 1
# 华东 2 1
# 华南 1 1
这里默认的聚合方式是计数,返回的是每个组合出现的次数。这跟 Excel 里"计数项"很类似,特别适合做用户行为分布、频次分布这类基于计数的分析。
那什么时候用 pivot_table() 而不是 crosstab()?当你需要聚合的是"数值字段"而不是频次,比如求和、求平均,pivot_table() 更顺手。当然 crosstab() 也支持 values 和 aggfunc 参数,只是写法上要求你额外指定数值列,不够直观。
3.2 归一化与百分比结构化:crosstab 的独门优势
crosstab() 的 normalize 参数是我非常喜欢的一个功能。它可以按行、按列或者整体归一化,直接在结果里得到占比,省去事后手动计算百分比的步骤。
python复制# 按行归一化,查看每个区域内各品类占比
cross_norm = pd.crosstab(df['区域'], df['品类'], normalize='index')
print(cross_norm)
输出结果:
code复制品类 电脑 手机
区域
华北 0.500000 0.500000
华东 0.666667 0.333333
华南 0.500000 0.500000
这在做结构分析时非常方便。比如你想看每个区域的品类结构是否健康,直接看这个归一化表就够了。normalize='columns' 则是按列归一化,反映每个品类在不同区域的分布;normalize='all' 则按整体归一化,反映每种组合在全部数据中的占比。
3.3 大型项目中的建议:什么时候优先选 crosstab
以一个实际的电商运营场景为例:运营同学想看"不同类目 × 不同价格带"的商品数量分布,这时候你手头有几千条商品数据。如果用 pivot_table(),你得指定 values='商品ID'、aggfunc='count';如果用 crosstab(),直接两列一传就出结果:
python复制# 价格带划分
bins = [0, 100, 300, 500, 1000, float('inf')]
labels = ['0-100', '100-300', '300-500', '500-1000', '1000+']
df['价格带'] = pd.cut(df['销售额'], bins=bins, labels=labels)
# 交叉计数
dist = pd.crosstab(df['品类'], df['价格带'])
这种"看一眼分布"的需求,用 crosstab() 是最舒服的。pivot_table() 更适合那种需要进一步加工、计算、透视的复杂报表任务。
4. 真实业务落地:从原始明细到经营分析报告
讲了这么多理论,来一个完整的实战案例。假设我们现在有一份某个连锁零售品牌的订单明细数据,包含字段:订单编号、下单日期、门店、区域、品类、销售额、利润、渠道。需要用 pandas 输出一份月度区域经营分析报表。
4.1 数据准备与字段清洗
实际项目里的数据很少是干净的,所以第一步永远是清洗。这里可能遇到的情况包括:日期列是字符串、销售额有缺失值、渠道字段大小写不一致。先处理这些问题再进入汇总阶段:
python复制# 日期字符串转 datetime
df['下单日期'] = pd.to_datetime(df['下单日期'])
# 提取月份
df['月份'] = df['下单日期'].dt.to_period('M')
# 销售额缺失值填充为0
df['销售额'] = df['销售额'].fillna(0)
# 渠道字段统一大写
df['渠道'] = df['渠道'].str.upper()
这里要注意 dt.to_period('M') 生成的是 Period 类型,如果你不喜欢它参与后续计算时的表现,也可以改成 df['下单日期'].dt.strftime('%Y-%m'),得到字符串类型的月份,两种方式各有适用场景。
4.2 月度区域汇总:Groupby 多列分组 + 不同聚合口径
核心需求一:每个区域、每个月的销售额、利润、订单数、利润率。其中"订单数"不能简单 sum(),因为订单号是字符串,正确的做法是数不重复订单号的个数,也就是用 nunique();而"利润率"则是计算完销售额和利润之后再相除,不是直接聚合得到的。
所以我的做法是分两步:先算基础聚合指标,再算派生指标。
python复制monthly_region = df.groupby(['月份', '区域'], as_index=True).agg(
销售额=('销售额', 'sum'),
利润=('利润', 'sum'),
订单数=('订单编号', 'nunique')
)
monthly_region['利润率'] = monthly_region['利润'] / monthly_region['销售额']
注意这里的 agg() 用到了命名聚合(named aggregation)语法:新列名=(原列名, 聚合函数)。这是 pandas 0.25 之后的特性,比传字典写起来更整洁,结果列名也直接可控。订单数用 nunique() 是因为同一订单可能有多行明细,直接 count() 会把明细行数当成订单数,导致虚高。
4.3 月度品类结构分析:透视方法的应用
核心需求二:不同区域、不同月份的品类销售结构,需要横向对比。这时候用透视表更合适:
python复制pivot_summary = pd.pivot_table(
df,
index=['月份', '区域'],
columns='品类',
values='销售额',
aggfunc='sum',
margins=True,
margins_name='全品类合计'
)
这个表能直接看出:某个月某个区域哪个品类的销售额最高,以及各类别之间的结构差异。配合 margins_name 参数,可以把总计的名称从默认的 "All" 改成更有业务含义的 "全品类合计"。
4.4 结果导出与可视化前的最后加工
汇总表算完之后,很多业务同事习惯在 Excel 里继续加工。这时候要注意格式问题。默认情况下,透视结果可能包含多层索引、NaN 空值、以及非常规的列名。导出前可以做一个标准化处理:
python复制# 清掉空值
pivot_summary = pivot_summary.fillna(0)
# 多层索引展平为普通列
pivot_summary = pivot_summary.reset_index()
# 重命名列
pivot_summary.columns.name = None
# 导出
pivot_summary.to_excel('月度经营报表.xlsx', index=False)
这一步经常被忽略,但恰恰是"能用"与"好用"之间的差距。业务方拿到表,最怕还要自己调整格式。你提前把数据整理干净,他们就能直接筛选、做透视、再加工。
5. 高频踩坑与性能优化:分组聚合的翻身指南
最后来说说我在实际项目中踩过的坑和一些性能优化经验,这些问题不遇则已,一遇到往往要折腾好久。
5.1 空值分组的坑:为什么 groupby 后 NaN 组不见了
第一个高频坑是空值处理。默认情况下,groupby() 会丢弃分组键为 NaN 的行。这会导致一个隐蔽的问题:你明明有几条数据的分组字段是空的,汇总结果里却根本看不到它们。在"数据质量不高"的真实场景里,这可能会影响最终的报表口径。
如果你需要保留 NaN 组,显式加上 dropna=False:
python复制result = df.groupby('区域', dropna=False)['销售额'].sum()
注意:dropna 参数在旧版本 pandas 中不一定存在,如果你的环境比较老,可能需要升级版本。另外,pivot_table() 也有类似问题,默认行为是对空组合不显示,用 dropna=False 可以强制显示。
5.2 Categorical 类型列的分组行为
第二个坑与分类数据有关。当分组列是 Categorical 类型时,groupby() 默认会把未出现的类别也纳入分组,导致结果里出现一堆全为 NaN 的行。这个行为在某些场景下是好事(比如你想补齐所有门店),但在多数场景下会让人困惑。
解决方案是设置 observed=True,只显示实际存在的分组组合:
python复制result = df.groupby('品类', observed=True)['销售额'].sum()
如果你用 pivot_table(),对应的是 observed 参数,可以设成 True 来避免空类别展开。
5.3 性能优化:sort 参数与大数据量处理思路
再说说性能。groupby() 默认会对分组键进行排序,这个过程在大数据量下会消耗额外内存和时间。如果你的业务逻辑不依赖排序,可以显式设置 sort=False:
python复制result = df.groupby('区域', sort=False)['销售额'].sum()
这只是第一步。当数据量达到几百万行时,真正要关注的其实是"能否避免全表扫描"。我的经验是:
- 尽量先用
[['区域', '销售额']]选取需要的列,再做 groupby,这样 pandas 内部处理的列数少、内存占用小。 - 如果数据规模很大,考虑用
pd.cut()分桶后按桶分组,可以显著减少分组数量,提高聚合速度。 - 如果分组键种类非常多,可以先用
astype('category')把字符串分组键转成 Categorical 类型,某些场景下分组速度提升非常明显。
5.4 agg 与 apply 的性能差异:为什么 agg 通常更快
很多初学者在需要自定义函数时直接选 apply(),但不知道 agg() 和 apply() 在执行路径上有很大差异。简单说,agg() 会对每个分组独立调用指定的聚合函数,它假设这些函数是向量化的(比如 sum、mean),因此能借用 pandas 底层的 C 语言实现;而 apply() 把你提供的 Python 函数逐组调用,相当于 Python 循环,速度慢得多。
如果你的自定义函数可以用 numpy 的向量化操作改写,优先用 agg() 配合 numpy 函数。比如:
python复制# 慢:每个分组逐个跑 Python 循环
df.groupby('区域')['销售额'].apply(lambda x: np.sum(x) / len(x))
# 快:直接用 agg 内置函数
df.groupby('区域')['销售额'].mean()
能用到这个优化点的地方非常多。我之前处理一份百万级大小的销售明细,用 apply() 跑分组复利计算要一分多钟,改成先用 agg() 算总额再在结果集上做向量化运算,几秒钟就完成了。
写在最后:分组聚合的三种姿势可以怎么选
做了几年数据分析,我个人对 Groupby 和透视方法的选择有这么个经验性判断:如果你只需要按某个维度做简单的求和、求平均、计数,groupby() 加内置聚合函数是最快的路径;如果数据要从长表变宽表,做行列维度的直观对比,用 pivot_table();如果只是统计两个分类变量共同出现的频次,crosstab() 是杀鸡用牛刀里的牛刀,但因为它简洁,实际体验反而最舒服。
最后分享一个我在项目里常用的组合思路:先用 groupby() 算完所有的基础汇总指标,再用 pivot_table() 把这些指标转置成宽表,最后从宽表里直接取数列做环比、占比、排名。三步走下来,你会发现从原始明细到分析报表的通路变得很清晰,也不用在代码里到处拼凑数据了。希望这篇能帮你在数据汇总这件事上少走一些弯路。
