Pandas groupby实战:分组聚合、transform与性能优化全攻略 搞数据分析的人没有谁没被groupby救过命。我刚入行那会儿为了算一个“按城市分组的平均销售额”手动写循环加条件判断吭哧吭哧搞了十几行代码逻辑绕得自己都看不懂。后来换成groupby三行搞定速度还快了一个量级。可以说groupby是 pandas 里最值得花时间吃透的方法之一。这篇文章不是官方文档的中文翻译而是把我在实际项目中用过的groupby技巧完整梳理一遍从分组、聚合、转换、过滤到性能优化每一段都有可直接运行的代码、适用场景和踩坑提醒。不管你是刚接触 pandas 的初学者还是用了一两年但没系统整理过groupby用法的人这篇都能帮你把这块短板补上。建议直接收藏用的时候翻出来对照。1. 先搞懂 groupby 到底在干什么很多教程上来就丢各种聚合函数却不说底层逻辑。我建议你在敲任何代码之前先花五分钟把groupby的执行模型搞清楚后面所有操作都不会再疑惑。groupby的核心思想是三个步骤业内叫split-apply-combine也就是“拆分-应用-合并”Split 拆分按照指定的键把 DataFrame 拆成若干个分组每个分组是一份子数据。Apply 应用对每个分组独立执行操作比如求和、求均值、自定义函数、标准化等。Combine 合并把每个分组的计算结果汇总成一个新的 DataFrame 或 Series。看一个最简单的例子import pandas as pd df pd.DataFrame({ 城市: [北京, 上海, 北京, 上海, 北京, 上海], 销售额: [100, 200, 150, 180, 120, 210], 成本: [60, 110, 80, 90, 70, 100] }) df.groupby(城市).mean()执行结果销售额 成本 城市 北京 123.333333 70.0 上海 196.666667 100.0程序做的事情是先按“城市”列把 6 行数据拆成“北京组”3 行、“上海组”3 行再对每一组分别调用mean()求均值最后把两端结果拼成一张新表。这里有一个新手最容易困惑的点df.groupby(城市)这一行运行之后你直接打印它看到的不是一个分组后的 DataFrame而是类似pandas.core.groupby.groupby.DataFrameGroupBy object at 0x...这样的内存地址。这是因为groupby是惰性求值的——它只记录了分组配置还没有真正计算。只有当你调用.mean()、.sum()、.agg()、.apply()这类方法时拆分、计算、合并才会真正触发。理解这个特性很重要因为它解释了为什么你可以在groupby之后链式调用各种方法也解释了为什么你在调试时要习惯用.groups属性去看分组情况。groupby对象上有几个高频使用的“检查类”属性# 查看每个分组包含哪些原始索引 df.groupby(城市).groups # 查看每个分组的行数 df.groupby(城市).size() # 查看分组总数 df.groupby(城市).ngroups # 提取某个分组的子 DataFrame df.groupby(城市).get_group(北京)其中size()我几乎天天用它能快速告诉你每个组的样本量这在查数据分布是否均衡时特别管用。get_group(北京)适合你想单独拎出一个组做排查的场景省得再写一次布尔索引。还有一个细节groupby默认按列分组但同样可以按行分组只要把axis1传进去。不过在常规数据分析中按行分组的场景很少知道有这么个参数就行。2. 分组维度怎么选决定了后续操作顺手不顺手groupby的参数by是整个方法的灵魂它可以接收非常多种形式列名、多个列名、Series、自定义函数、索引层级等等。这节我把常用分组方式全部罗列出来方便你对照选用。2.1 按单个列分组最基础的形式直接传列名字符串df.groupby(城市)[销售额].sum()这里有一个高频疑问为什么很多教程在groupby之后还要带一对方括号[销售额]因为代码里的执行顺序是先按“城市”把全表分组然后只从每个组里取出“销售额”这一列最后做求和。这样返回的是Series不带方括号则对全部数值列做聚合返回DataFrame。两者结果形状不一样用哪个取决于你接下来要什么。如果你想对多个列做不同的聚合又不希望返回 Series可以用列表形式选列df.groupby(城市)[[销售额, 成本]].mean()2.2 按多个列分组传一个列名列表就按多列组合成多个分组df2 pd.DataFrame({ 城市: [北京, 北京, 上海, 上海, 北京, 上海], 季度: [Q1, Q2, Q1, Q2, Q1, Q2], 销售额: [100, 150, 200, 180, 120, 210] }) df2.groupby([城市, 季度])[销售额].sum()结果会是一个多重索引的 Series外层索引是“城市”内层是“季度”。这种结构在后续unstack()转置、用pivot_table对比时都非常方便。我个人的经验是多列分组常用于两个维度的交叉统计比如“城市 × 季度”“部门 × 月份”。但要注意分组键越多分组数量越多如果某个组合没有数据默认情况下该组合不会出现在结果里后面讲observed参数时还会再提到这个坑。2.3 按索引层级分组如果你的 DataFrame 本身就是多层索引可以直接按索引层级名分组不需要额外加列df3 df2.set_index([城市, 季度]) df3.groupby(level城市)[销售额].sum()参数level指定按哪一层索引分组。这在处理时间序列或者已经做了set_index的多维数据时很省事不用再把索引转回普通列。2.4 按区间分组cut 和 qcut连续数值列按区间分组是groupby和pd.cut/pd.qcut组合最经典的用法。比如把销售额分成几个档位再看每个档位的分布情况bins [0, 100, 150, 200, 300] labels [低, 中低, 中高, 高] df[销售档位] pd.cut(df[销售额], binsbins, labelslabels) df.groupby(销售档位, observedFalse)[销售额].agg([count, mean])pd.cut按等间距分箱pd.qcut按分位数分箱两者都能把连续变量变成离散变量然后就能作为分组键使用。业务里常见的是把年龄分年龄段、把消费金额分消费等级这类需求几乎都能用这个组合完成。2.5 按自定义函数分组如果想按“城市名长度”或“销售额是否过均值”这类逻辑分组可以传一个函数给groupbydf.groupby(lambda x: 大城市 if df.loc[x, 销售额] 150 else 小城市)[销售额].sum()不过这种写法在实际业务中用得不多因为可读性差调试也麻烦。我更建议先在原表上用apply增加一列分桶结果再用普通列名分组。代码会清楚很多df[城市规模] df[销售额].apply(lambda v: 大城市 if v 150 else 小城市) df.groupby(城市规模)[销售额].sum()按自定义函数分组适合临时性、探索性的数据课题不适合放进正式脚本。2.6 按外部 Series 分组by参数可以传入另一个长度与 DataFrame 相同的 Series 或数组。即使这个 Series 不在 DataFrame 里也能作为分组键groups pd.Series([A组] * 3 [B组] * 3) df.groupby(groups)[销售额].mean()这种用法在数据清洗时偶有奇效比如根据外部规则文件给每一行打标签再按标签分组汇总。不过正常项目里我还是推荐把分组键合并进表里保持数据来源一致逻辑更清晰。3. 聚合操作的核心agg 比表面看起来强大得多分组之后最常用的动作就是聚合。sum()、mean()、count()这种直接调用的聚合函数只是入门真正能拉开效率差距的是agg()方法它支持同时应用多个聚合函数、为不同列指定不同聚合方式。3.1 同时计算多个统计量df.groupby(城市)[销售额].agg([sum, mean, max, min])结果会生成一个新的 DataFrame列名分别是sum、mean、max、min。这在日报统计里几乎就是标配每个城市的总销售额、客单价均值、最高单笔、最低单笔一行代码全部出来。3.2 不同列用不同聚合函数传一个字典给agg键是列名值是聚合方式df.groupby(城市).agg({ 销售额: [sum, mean], 成本: sum })这里要特别提醒字典的键必须是真实存在的列名不能是经过变换的表达式。如果你想对“销售额 × 系数”这种计算结果做聚合得先新增一列再聚合或者用NamedAgg配合lambda实现下一条会讲。3.3 用 NamedAgg 给聚合结果命名默认的聚合列名是函数名比如sum、mean一旦你用了lambda列名就会变成lambda_0非常难看。NamedAgg可以解决命名问题df.groupby(城市).agg( 总销售额(销售额, sum), 平均销售额(销售额, mean), 成本总计(成本, sum) )注意这是新写法内部元组的第一个元素是列名第二个是聚合函数名。生成的列名就是你指定的总销售额、平均销售额、成本总计后续不需要再做rename。3.4 在 row 上直接处理后再聚合agg本质是“逐列”处理如果你想在分组内先算一笔衍生指标比如“销售额 - 成本”的毛利再求毛利总和有两种常见方案。方案一先算列再分组聚合。df[毛利] df[销售额] - df[成本] df.groupby(城市)[毛利].sum()方案二用apply结合自定义函数df.groupby(城市).apply(lambda g: (g[销售额] - g[成本]).sum())这里我要认真说一下agg和apply的区别因为这是很多人搞混的地方。agg接收的函数是“按列”传入的也就是说每个聚合函数一次拿到一列数据apply接收的函数是整个子 DataFrame。这意味着想用两列以上的数据计算一个标量用apply。想对同一列计算多个标量用agg配合列表。想对不同列分别计算用agg配合字典。举例来说如果我想算每个城市“销售额”和“成本”两个向量的相关系数agg就没法受理因为相关系数需要同时传入两列而apply可以df.groupby(城市).apply(lambda g: g[销售额].corr(g[成本]))我在实际项目中很少让agg和apply互相替代它们的产出类型也不同。3.5 聚合结果如何转回普通 DataFrame聚合结果默认会把分组键作为索引形成 MultiIndex 或 Index。想要变成普通列有三种方式reset_index()最通用把索引转回普通列。参数as_indexFalse在groupby时直接指定这样分组键就以列的形式保留。.reset_index()后还可以再rename聚合结果列名。# 方式一 df.groupby(城市, as_indexFalse)[销售额].sum() # 方式二 df.groupby(城市)[销售额].sum().reset_index()两者的最终产出一样我习惯用as_indexFalse因为少一步链式调用代码更干净。4. transform 和 filter聚合之外的两大场景很多人没搞明白很多资料把groupby讲完聚合就结束了实际业务里还有两类高频需求一是想在保留原表行数的基础上填充分组统计量比如把每个城市的平均销售额填回每一行二是想筛掉某些分组。前者用transform后者用filter。4.1 transform不折叠行数广播分组结果transform执行聚合计算后返回的是和原表行数相同的 Series 或 DataFrame分组键不会折叠而是把计算结果广播到每一行。这是它与agg最直观的差异。直接看一个业务场景我想把每条记录的销售额减去其所在城市的平均销售额得到“偏离度”。用transform非常直接df[城市销售额均值] df.groupby(城市)[销售额].transform(mean) df[偏离均值] df[销售额] - df[城市销售额均值]没有transform时你大概率会写出这样的绕路代码city_mean df.groupby(城市)[销售额].mean() df[城市销售额均值] df[城市].map(city_mean)一天两天可能不痛不痒但脚本里这种“先聚合再合并”的写法一旦多起来可读性就会迅速下降。transform把这个过程压缩成一行逻辑也更清晰。transform同样支持多个函数、组合使用但它的核心要求是返回的长度必须与输入一致。如果你对transform传了一个返回标量的函数它会自动广播如果返回的是列表或数组长度也得和当前分组的行数对齐。4.2 分组内填充缺失值数据清洗里的经典场景每个城市的部分记录缺了销售额你想用同城市的均值把空值补上。transform一行搞定df[销售额填充] df.groupby(城市)[销售额].transform(lambda s: s.fillna(s.mean()))先按城市分组组内取均值再fillna最后广播到原行。这样清洗出出来的数据带有明显的业务逻辑用同类别的均值填充而不是用全局均值一股脑硬填。4.3 groupby transform 与 merge 的区别这两者都能把分组统计结果“传回”原数据但机制不同。merge需要你指定关联键本质是外键连接适合两个不同来源的数据表而groupby transform只对同一个表内部做分组不用显式定义关联关系索引会自动对齐。能用transform时我不推荐绕道merge少一步关联就少一个出错点。4.4 filter按组整体筛选filter用于过滤掉“整个分组”而不只是个别行。它其实有三种写法按组的统计特征过滤按组成员的某列值过滤按组的索引或标签过滤。最常见的是第一类保留样本量足够大的组。df.groupby(城市).filter(lambda g: len(g) 2)这段代码会保留所有大于等于 2 行的城市分组其余组的所有行都会被删掉。注意这里返回的是分组之后的一个子 DataFrame行数不定和transform的“保行数”逻辑不同。另一种按组内属性过滤的写法df.groupby(城市).filter(lambda g: g[销售额].max() 200)只保留组内销售额最大值超过 200 的城市。这在业务上对应“剔除表现过差的组”这类需求比如去掉退款率过高的省份、去掉成交量过低的渠道。这里我要强调一点filter不是用来做普通行筛选的。普通行筛选比如“只保留销售额大于 100 的行”直接用布尔索引即可不要先groupby再filter没有意义还浪费性能。4.5 三个方法放到一起看方法返回值形状典型用途映射关系agg每个分组一行分组汇总、生成统计报表分组键 → 统计值transform与原表行数一致组内填充、去均值、标准化每个分组行 → 广播值filter原表子集剔除不合格分组分组 → 保留/删除整个组这三者覆盖了分组后的绝大多数操作需求也是我在面试候选人时最常考察的区分点。能把它们说清楚说明对groupby的理解不是背代码而是真正掌握到了模型层面。5. 三大实战场景组内排名、透视表替换、标准化与缺失值清洗理论讲再多都不如直接上业务场景。这一节我把实战里出现频率最高的三个组合方案完整写出来你可以直接抄作业。5.1 组内排名与 Top N 提取需求每个城市找出销售额最高的两条记录。df[销售额排名] df.groupby(城市)[销售额].rank(methoddense, ascendingFalse) df[df[销售额排名] 2].sort_values([城市, 销售额排名])rank是分组后最容易被忽略的宝藏方法。它的method参数有几个分支dense并列名次连续比如 1、1、2、3min并列名次取最小比如 1、1、3、4max并列名次取最大比如 1、1、2、4first并列时按出现顺序分先后比如 1、2、3、4。业务上我常用dense因为当销售额相同时名次连续方便后续按名次区间切分。这里有一个容易踩的坑groupby默认会在分组键上排序所以分组内rank计算没问题但你sort_values时注意不要按原始索引排序否则每组数据顺序会乱掉。5.2 用 groupby unstack 复刻透视表很多场景下pivot_table能完成的工作groupby组合也能完成。两者底层实现其实有关联但groupby写起来更灵活。例子统计每个城市、每个季度的销售额总和并转成宽表df2.groupby([城市, 季度])[销售额].sum().unstack(fill_value0)结果会生成一张行是城市、列是季度的矩阵。这正是透视表的经典形态。区别在于pivot_table更直观地接收index、columns、values、aggfunc四个参数适合快速搭建groupby unstack可以在分组和聚合环节插入更多自定义逻辑适合需要复杂前处理的场景。如果你要同时统计均值、总和两个指标pivot_table的aggfunc参数支持传列表df2.pivot_table(values销售额, index城市, columns季度, aggfunc[sum, mean], fill_value0)而groupby版本的写法如下df2.groupby([城市, 季度])[销售额].agg([sum, mean]).unstack(fill_value0)谁的输出更符合你的字段要求就选谁二者没有绝对优劣。我个人的体会是如果纯做二维交叉表用pivot_table更快如果后面还要继续分组、过滤或转换那就用groupby链式操作代码从头到尾只是一个对象流。5.3 分组标准化与缺失值填充“组内标准化”在数据分析中很常见比如按城市把销售额做 Z-score 标准化排除城市间整体水平的干扰df[销售额标准化] df.groupby(城市)[销售额].transform( lambda s: (s - s.mean()) / s.std() )这个方法的价值在于它把“每个城市自己的均值、标准差”作为基准而不是全局统一基准。这样处理后不同城市之间的绝对销售额差异被抹平只看城市内部的相对波动适合比较不同量纲的分组数据。缺失值填充方面除了前文说过的分组均值填充还可以做分组前向填充df[销售额前向填充] df.groupby(城市)[销售额].transform(lambda s: s.fillna(methodffill))这种写法在时间序列数据里非常实用——按城市分组组内按时间顺序把缺失值用上一个非缺失值填充。注意新版 pandas 里fillna(methodffill)会提示改用ffill()方法写法可以调整为df[销售额前向填充] df.groupby(城市)[销售额].transform(lambda s: s.ffill())6. 性能调优与避坑清单这些细节决定了你的脚本能不能扛住真实数据很多人在小数据集上跑groupby觉得很流畅一到几百 MB 的真实数据就开始卡顿问题往往出在几个不起眼的参数和用法上。这一节内容是我在实际项目中踩过坑之后总结出来的每条都值得认真看。6.1 分组列用 category 类型性能会有本质区别当你的分组键是字符串但取值有限时比如只有“北京”“上海”“广州”等几十个城市最好转成category类型。这能让分组操作从“字符串比较”降级为“整数编号查找”速度提升非常显著df[城市] df[城市].astype(category) df.groupby(城市)[销售额].sum()我试过用 1000 万行、50 个城市的表做分组求和转类型之前耗时接近 10 秒转之后 1.5 秒左右体感差距极大。在read_csv阶段就能顺手转df pd.read_csv(销售数据.csv, dtype{城市: category})这个技巧对于任何需要反复分组的场景都值得养成习惯。6.2 observed 参数避开“空分组”陷阱上一节我留了一个扣子groupby配合category列时默认行为是保留所有类别组合即使某个组合没有任何数据。这会导致聚合结果里出现大量全为 0 或 NaN 的空分组行。在 pandas 1.x 之后处理category分组键时建议显式指定observedobservedTrue只输出实际有数据的组合行数少速度快observedFalse输出所有可能的组合适合需要固定类别体系的结构化报表。df.groupby(城市, observedTrue)[销售额].sum()如果你发现分组结果突然多出来很多空行优先检查是不是observed没有设置。同理在多列分组时observedFalse会产生笛卡尔积级别的空组合数据量大时甚至会把内存打爆这是必须警惕的。6.3 sort 参数排序不是免费的groupby默认会对分组键排序。也就是说即使你的数据本来已经是按时间排列的groupby之后也会把分组键做一次排序。如果数据量很大且你不需要有序结果加上sortFalse能省去一次全局排序的开销df.groupby(城市, sortFalse)[销售额].sum()但注意sortFalse只影响分组键的输出顺序不影响分组内部的聚合逻辑。如果你想按分组统计值排序比如按总销售额降序排城市那可以这样写df.groupby(城市)[销售额].sum().sort_values(ascendingFalse)6.4 惰性求值带来的排查问题前文说过groupby是惰性的这在调试时会带来一点麻烦你无法直接打印分组的内部内容。排查思路是用.get_group(组名)查看某个分组的数据长什么样用.size()快速确定各分组行数是否正常用.groups检查分组键的分布用.obj属性查看原始对象。如果在聚合后得到奇怪结果不要急着怀疑聚合函数先用.get_group()把某个分组的数据单独拎出来人工核对通常几秒钟就能定位问题来源。6.5 链式赋值的陷阱在groupby结果上做赋值时要注意是否命中副本df.groupby(城市)[销售额].transform(lambda s: s 1) # 正确 df.groupby(城市)[销售额].apply(lambda s: s 1) # 不推荐用于赋值场景apply返回的结果有时会与原始索引不完全对齐直接赋值给新列时可能出现 NaN 错位。相比之下transform的索引对齐机制更可靠。我建议你养成一个习惯给原表新增一列分组计算结果时优先用transform它就是为了这个场景设计的。6.6 警惕 apply 返回 DataFrame 时的列名错乱apply很灵活但灵活带来的副作用是输出结构的“不确定性”。举个例子df.groupby(城市).apply(lambda g: g[[销售额, 成本]].mean())这段代码的结果可能会得到一个 MultiIndex 的 DataFrame而不是你想象中的普通宽表。因为apply会把分组键拼进索引函数返回的 Series/DataFrame 再参与对齐列索引和行索引容易混乱。我有一个压箱底的技巧如果apply返回的是 Series 或 DataFrame建议在函数末尾加一个reset_index(dropTrue)或者返回结构明确的 DataFrame然后统一处理索引。不要指望 pandas 自动帮你对齐它的对齐规则有时候比你预期的更“聪明”而那恰恰是出错的地方。6.7 新版 pandas 的 include_groups 行为变化从 pandas 2.0 开始apply在分组后传入函数的 DataFrame 默认不包含分组键列但早期版本和部分内部行为仍有差异。如果你在升级 pandas 后发现原先正常的apply代码突然多出或缺少一列多半是include_groups相关行为调整导致的。稳妥的解法有两个在apply(lambda g: ...)内用.select_dtypes(exclude[分组键列])主动过滤或者直接用.drop(columns分组键列)明确去掉不需要的列。这个问题在社区里问得非常频繁碰到别慌优先检查版本更新日志。6.8 分组后不要直接用循环遍历当你对groupby对象做for name, group in df.groupby(城市)时每迭代一次相当于做了一次分组操作。如果分组的数量很多、数据量很大这种遍历效率低得惊人。我有一次需要遍历 2000 多个组做独立处理换成按组抽样后用map批量操作后速度提升了将近 50 倍。通用原则是优先把需求转化为向量化操作实在不能向量化再考虑对少数分组遍历。groupby的apply就是帮你把遍历隐藏在内部循环里的手段但若你的函数逻辑异常复杂apply的性能也未必比显式循环好多少。遇到这种极端情况考虑先用group_keysFalse关闭分组键合并减少索引对齐的负担。df.groupby(城市, group_keysFalse).apply(complex_func)7. 常见问题速查表把日常工作中被问到最多的groupby问题整理成一张表方便你定位症状原因解决方法分组后结果多出很多空行分组键是category类型observed未设置设置observedTrue分组键在结果里变成了索引而不是列as_index默认值为 True加as_indexFalse或reset_index()聚合结果的列名是lambda_0agg接收了匿名函数且未命名改用NamedAgg或agg(新列名(列名, 函数))给原表新增分组统计列时出现 NaN索引未对齐或用了apply而非transform改用transformapply返回的 DataFrame 列名错乱apply的返回结构与索引对齐规则过复杂函数内reset_index(dropTrue)或走agg路线分组操作跑得太慢字符串列未转category/ 分组键默认排序转类型 sortFalse分组后行数看起来不对混淆了transform保行数和agg折叠行明确需求后选对应方法新版 pandas 里 apply 结果变化include_groups行为调整在函数内显式drop(columns分组键)这八类问题覆盖了我见过的绝大部分groupby使用故障按表格逐条排查基本能定位。8. 最后分享一个压箱底的小技巧关于groupby还有一个我后来才完全搞清楚的细节apply返回 Series 时如果 Series 的索引和你预期不符pandas 会把索引当作行索引拼进结果导致最终结果“多出一层索引”。这个现象很多教程都不会讲但它几乎每个用过apply的人都会遇到。我现在的习惯是能不用apply就不用apply。能用agg解决的用agg能直接对原列算好的先算好再聚合实在要自定义复杂逻辑就让函数返回DataFrame或者带明确索引的Series并在最终结果上强制reset_index()。groupby本身不复杂复杂的是在分组、聚合、转换、过滤这些操作之间选出最合适的一条路。把这篇文章里的方法逐个在自己的数据上试一遍你会发现原本要写几十行的数据加工流程最后压缩到十几行还更不容易出错。这套经验是实打实从一线项目里踩出来的收藏起来碰到实际问题时回翻一下比临时查文档高效得多。