资讯动态

pandas分组聚合实战:从groupby底层逻辑到高级技巧

发布时间:2026/10/16 13:13:30 来源:尧图企业网站定制
1. 为什么分组与聚合是数据处理的分水岭刚接触数据分析那会儿我总觉得把数据读进 DataFrame、清洗完缺失值就算大功告成了。直到有一次接手一个销售明细表领导要“按区域看每个品类的月度走势”我盯着几千行数据愣了半天最后用 Excel 透视表硬拖出来的。那次之后我才明白分组与聚合才是从“会读数据”到“会分析数据”的真正分水岭。pandas 的groupby机制本质上就是一套“拆分—应用—合并”的流水线。你告诉它按哪一列或多列拆分它就把数据切成若干小块你给它一个聚合函数它就在每个小块上独立运算最后再把结果拼回一张表。听起来简单但真正用起来坑全藏在细节里分组键怎么选、聚合函数怎么传、多级索引怎么处理、自定义函数怎么写得又快又稳每一步都有讲究。这篇文章适合两类人一类是已经会用df.groupby(col).sum()这种基础操作但遇到复杂需求就卡壳的另一类是数据分析刚入门想系统搞明白分组聚合到底能玩出什么花样的。我会从底层逻辑讲到实战技巧把那些文档里不会写的经验都摊开来说。2. 分组聚合的底层逻辑与核心思路拆解2.1 拆分-应用-合并一个类比就能讲透很多人学groupby的时候脑子里是一团浆糊因为 pandas 的链式调用看起来像魔法。我用一个生活化的类比来解释假设你是一个快递站站长面前堆了一千个包裹每个包裹上写着目的地城市。现在你要统计每个城市有多少个包裹、总重量是多少。你的做法一定是先把包裹按城市分成一堆一堆拆分然后对每一堆分别数个数、称总重应用最后把每个城市的结果写在一张汇总表上合并。pandas 的groupby干的就是这件事只不过它操作的是行和列速度比你手动分拣快几万倍。这个类比的关键在于拆分和合并是 pandas 自动完成的你只需要定义“按什么拆”和“怎么算”。理解了这一点后面所有的参数和技巧都是在这个框架上做微调。2.2 为什么不用循环向量化背后的性能账新手最容易犯的错误是写for循环遍历每个分组。我早期也这么干过一个几万行的数据集循环跑了十几秒换成groupby之后不到 0.1 秒。差距为什么这么大groupby底层用的是哈希表来记录每个分组对应的行索引然后通过 C 语言实现的聚合函数直接操作底层数组。而 Python 的for循环每次迭代都要做类型检查、函数调用、对象创建开销是数量级的差距。更关键的是groupby的聚合过程是向量化的它一次性对所有分组并行计算而不是一个一个排队。所以我的第一条经验就是只要你的需求是“按某列分组后做统计”永远优先用groupby不要写循环。哪怕逻辑再复杂也先想想能不能用agg或apply表达出来。2.3 分组键的选择列名、Series、字典还是函数groupby的第一个参数非常灵活这也是很多人困惑的地方。我把它归纳为四种常见形态列名字符串最常用df.groupby(city)就是按 city 列分组。Series 或数组当你需要按一个不在 DataFrame 里的外部序列分组时用比如按自定义的区间标签分组。字典或 Series 映射把某一列的值映射到新的分组标签比如把多个城市归为一个大区。函数对索引进行操作比如按索引的奇偶性分组。我个人的习惯是能用列名就用列名因为可读性最好。只有在做数据转换或临时分析时才会用字典或函数。这里有个容易踩的坑如果你传入一个函数它默认作用在索引上而不是列值上。想按列值分组得写成df.groupby(df[col].apply(func))这种形式。2.4 聚合函数的三种传法字符串、列表和字典聚合函数怎么传是新手最容易迷糊的地方。我把它总结成三种模式对应不同的需求场景第一种是单函数单列直接传字符串df.groupby(city)[sales].sum()。这种写法最简洁适合快速探索。第二种是多函数单列传列表df.groupby(city)[sales].agg([sum, mean, count])。返回的是一张多级列索引的表每个函数一列。第三种是多函数多列传字典df.groupby(city).agg({sales: [sum, mean], profit: max})。这种写法最灵活可以针对不同列指定不同的聚合方式。我实测下来字典传法在复杂分析中最实用因为它把“哪列用什么算”这件事表达得非常清晰。但要注意字典的键必须是列名值可以是字符串、列表或函数。3. 核心细节解析与实操要点3.1 分组后的列选择方括号和点号的区别df.groupby(city)[sales]和df.groupby(city).sales看起来效果一样但有个关键区别方括号返回的是 SeriesGroupBy 对象点号返回的也是但当列名包含空格或特殊字符时点号会报错。更重要的是方括号支持传入列表选择多列点号不行。我的建议是统一用方括号哪怕只选一列。这样代码风格一致以后要改成多列也不用重构。另外如果你不选列直接聚合pandas 会尝试对所有数值列做聚合非数值列会被自动忽略或报错具体行为取决于 pandas 版本。所以显式选列是个好习惯。3.2 多级索引的处理reset_index 和 as_indexgroupby默认会把分组键变成结果的索引这就是所谓的多级索引。对于后续要做合并或绘图的人来说这个索引往往很碍事。两个解决办法一是聚合时加as_indexFalse这样分组键会保留为普通列。二是聚合后用reset_index()把索引还原成列。我通常用后者因为链式调用更自然而且可以在最后一步统一处理。注意as_indexFalse在某些聚合函数下行为不一致比如agg传字典时可能不生效。所以我现在一律用reset_index()省心。3.3 自定义聚合函数agg 和 apply 的取舍pandas 内置的聚合函数sum、mean、max、min、count、std、var、median 等能覆盖八成需求但剩下两成往往需要自定义逻辑。比如你要算每个分组的极差max 减 min或者算加权平均。这时候有两个选择agg和apply。agg传自定义函数时函数接收的是每个分组的 Series返回一个标量。apply更灵活可以返回 Series 或 DataFrame但速度慢一些。我的经验法则是如果每个分组返回一个标量用agg如果返回多个值或复杂结构用apply。另外agg支持用 lambda 或命名函数但命名函数在调试时更友好因为报错信息里能看到函数名。3.4 分组键的缺失值处理dropna 参数默认情况下groupby会把分组键中的 NaN 直接丢弃。这意味着如果你的数据里有缺失的分组键这些行会凭空消失导致总数对不上。我第一次遇到这个问题时排查了半天才发现是 NaN 惹的祸。解决办法是加dropnaFalse这样 NaN 会作为一个独立的分组保留下来。但要注意不同 pandas 版本对dropna的支持程度不同老版本可能没有这个参数。所以升级 pandas 是个好习惯。3.5 分组后的迭代get_group 和遍历有时候你需要对每个分组做完全不同的处理比如分别导出成 Excel 文件。这时候可以用for name, group in df.groupby(city)来遍历或者用get_group(北京)直接取某个分组。遍历的时候有个性能陷阱每次迭代拿到的 group 是一个新的 DataFrame如果分组数量很多内存开销会很大。我一般只在分组数少于一百时才用遍历否则优先考虑向量化方案。4. 实操过程与核心环节实现4.1 环境准备与示例数据构造先确保你的 pandas 版本在 1.0 以上因为很多新特性比如dropna参数、named aggregation在老版本里没有。用pd.__version__查一下低于 1.0 的建议升级。import pandas as pd import numpy as np # 构造一份模拟销售数据 np.random.seed(42) data { region: np.random.choice([华东, 华北, 华南, 西南], 200), category: np.random.choice([电子, 服饰, 食品, 家居], 200), month: np.random.choice([1月, 2月, 3月], 200), sales: np.random.randint(100, 1000, 200), profit: np.random.randint(10, 200, 200), quantity: np.random.randint(1, 50, 200) } df pd.DataFrame(data) print(df.head())这份数据有四个分组维度region、category、month和三个数值列sales、profit、quantity足够演示大部分场景。4.2 基础聚合从单列到多列最简单的需求按区域算总销售额。result df.groupby(region)[sales].sum().reset_index() print(result)如果想同时看总额、均值、最大值result df.groupby(region)[sales].agg([sum, mean, max]).reset_index() print(result)这里返回的列名是 sum、mean、max如果想让列名更有意义可以用命名聚合result df.groupby(region).agg( 总销售额(sales, sum), 平均销售额(sales, mean), 最大单笔(sales, max) ).reset_index() print(result)命名聚合是我最推荐的写法因为列名直接表达了业务含义后续做报表或给别人看的时候一目了然。但要注意命名聚合要求 pandas 版本在 0.25 以上。4.3 多列多函数字典聚合的实战假设你要按区域和品类分组同时算销售额的总和与均值、利润的最大值result df.groupby([region, category]).agg({ sales: [sum, mean], profit: max, quantity: sum }).reset_index() print(result)这样返回的是一张多级列索引的表sales 下面有 sum 和 mean 两列。如果觉得列名太啰嗦可以用pd.MultiIndex手动扁平化或者直接用命名聚合重写result df.groupby([region, category]).agg( 销售额总和(sales, sum), 销售额均值(sales, mean), 利润最大值(profit, max), 数量总和(quantity, sum) ).reset_index()两种写法各有优劣字典写法适合快速探索命名聚合适合最终输出。4.4 自定义聚合极差和加权平均内置函数不够用时就得自己写。比如算每个区域销售额的极差def range_func(x): return x.max() - x.min() result df.groupby(region)[sales].agg(range_func).reset_index() result.columns [region, 销售额极差] print(result)再比如算加权平均权重是 quantitydef weighted_avg(group): return np.average(group[sales], weightsgroup[quantity]) result df.groupby(region).apply(weighted_avg).reset_index() result.columns [region, 加权平均销售额] print(result)这里用apply而不是agg因为函数需要访问两列。apply会把每个分组的整个 DataFrame 传进来灵活性更高但速度慢一些。我实测下来几万行数据用apply也就几百毫秒完全可接受。4.5 分组后的变换transform 的妙用transform是一个容易被忽略但极其好用的方法。它和agg的区别在于agg返回每个分组一个值transform返回和原数据一样长度的序列。举个例子你想给每一行数据加上“该行销售额占本区域总销售额的比例”。用agg得先算总和再合并回去用transform一行搞定df[region_total] df.groupby(region)[sales].transform(sum) df[sales_ratio] df[sales] / df[region_total] print(df.head())transform还支持自定义函数比如用分组均值填充缺失值df[sales_filled] df.groupby(region)[sales].transform( lambda x: x.fillna(x.mean()) )这个技巧在数据清洗阶段特别实用比先算均值再merge回去简洁得多。4.6 多级分组的索引处理与透视表转换多级分组后结果往往是一张带多级索引的表。如果要做可视化或导出通常需要把它变成“宽表”。这时候可以用unstackpivot df.groupby([region, category])[sales].sum().unstack() print(pivot)unstack会把最内层的索引变成列得到一个区域为行、品类为列的矩阵。如果反过来想把列变回行用stack。这两个方法在数据重塑时非常有用但要注意如果分组组合有缺失unstack会产生 NaN需要根据业务决定是填零还是保留。5. 常见问题与排查技巧实录5.1 聚合结果列名混乱怎么办用字典聚合时返回的列名是多级索引导出 CSV 后表头会变成两行很难看。解决办法有两个一是用命名聚合列名直接指定二是聚合后手动重命名result.columns [_.join(col).strip() for col in result.columns.values]这行代码会把(sales, sum)变成sales_sum把(profit, max)变成profit_max。我一般用第二种因为可以批量处理不用一列一列写。5.2 分组后数据量对不上这是最经典的问题分组前的总行数和分组后的行数不一致。原因通常有三个分组键里有 NaN被默认丢弃了。加dropnaFalse解决。聚合函数本身会过滤数据比如mean会跳过 NaN导致某些分组结果为空。分组键的类型不一致比如数字 1 和字符串 1 被当成两个分组。排查方法很简单先看df[分组列].nunique()和分组后的行数是否一致再看有没有 NaN。我踩过最坑的一次是分组列里混了空格肉眼看不出来用df[col].str.strip()清洗后才正常。5.3 自定义函数报错怎么定位自定义函数在agg或apply里报错时pandas 的报错信息往往很模糊只告诉你“某个函数出错了”不告诉你是哪个分组。我的排查技巧是先用一个小分组手动调用函数看能不能跑通。sample_group df[df[region] 华东][sales] print(range_func(sample_group))如果小样本能跑通说明是数据问题可能是某个分组有空值或类型异常。如果小样本也报错那就是函数本身的问题。另外在函数里加print或logging也能帮助定位但注意apply会调用多次输出会很多。5.4 性能优化什么时候该用 agg什么时候该用 apply我做过一个粗略的基准测试同样一个分组求和操作agg(sum)比apply(lambda x: x.sum())快大约 5 到 10 倍。所以能用内置函数就用内置函数能用agg就不用apply。但如果逻辑确实复杂apply也不是不能用。我的经验是数据量在十万行以内apply的耗时通常在可接受范围内超过百万行就得考虑用transform或分块处理了。另外groupby之前先过滤掉不需要的行能显著减少计算量。5.5 常见问题速查表问题现象可能原因解决办法分组后行数变少分组键有 NaN加dropnaFalse列名是多级索引字典聚合的默认行为用命名聚合或手动扁平化自定义函数报错分组内有异常值先在小样本上测试函数聚合速度慢用了 apply 或循环改用内置函数或 agg分组键类型不一致混用了数字和字符串统一类型后再分组transform 结果长度不对函数返回了非标量确保函数返回单个值5.6 几个我踩过的坑和独家技巧第一个坑groupby之后直接赋值会报 SettingWithCopyWarning。比如df.groupby(region)[sales].mean() 100这种写法是错的。正确做法是用transform或map把结果映射回原表。第二个坑多列分组时分组键的顺序会影响结果的结构。groupby([A, B])和groupby([B, A])返回的索引层级是反的unstack后的行列也会互换。所以分组前要想清楚哪个维度做行、哪个做列。第三个技巧用pd.Grouper按时间频率分组。如果你的数据有日期列可以直接df.groupby(pd.Grouper(keydate, freqM))按月分组不用先提取月份。这个在处理时间序列时特别方便。第四个技巧groupby支持传入多个聚合函数的同时保留原始索引。用df.groupby(region).agg([sum, mean])不加reset_index结果的多级索引可以直接用于后续的loc筛选比如result.loc[华东]就能取出华东的所有聚合结果。6. 从分组聚合延伸到实际分析场景6.1 用分组聚合做数据探索的快速模板每次拿到新数据我都会跑一套固定的分组聚合模板来快速了解数据分布。这套模板包括按每个类别列分组算数值列的 count、mean、std、min、max。代码大概长这样def quick_profile(df, cat_cols, num_cols): for cat in cat_cols: print(f--- 按 {cat} 分组 ---) result df.groupby(cat)[num_cols].agg([count, mean, std, min, max]) print(result) print()这套模板能在几分钟内告诉你哪些类别的数据量不均衡、哪些类别的数值波动大、有没有异常值。比一上来就画图更高效因为数字比图形更精确。6.2 分组聚合与透视表的等价转换很多人分不清groupby和pivot_table的区别。其实pivot_table底层就是groupby加unstack只是封装得更友好。比如# groupby 写法 df.groupby([region, category])[sales].sum().unstack() # pivot_table 写法 df.pivot_table(indexregion, columnscategory, valuessales, aggfuncsum)两种写法结果一样但pivot_table支持marginsTrue加总计行还支持fill_value填充缺失值。我的选择是快速探索用groupby做报表用pivot_table。6.3 分组聚合在特征工程中的应用做机器学习特征工程时分组聚合是构造统计特征的利器。比如你要预测用户流失可以按用户 ID 分组算每个用户的平均消费金额、最近一次消费距今天数、消费频率等。这些特征往往比原始数据更有预测力。user_features df.groupby(user_id).agg( 平均消费(amount, mean), 消费次数(amount, count), 最大单笔(amount, max), 消费标准差(amount, std) ).reset_index()构造完特征后再用merge和原始标签表关联起来就能喂给模型了。这个流程我在多个项目里用过效果稳定。6.4 分组聚合的边界什么时候不该用虽然groupby很强大但也不是万能的。有两种情况我会避免用它一是分组数量极多且每组数据极少。比如按用户 ID 分组每个用户只有一两条记录这时候聚合的意义不大反而浪费计算资源。二是需要跨组计算的场景。比如算每个分组占全局的比例groupby本身做不到得先用transform算全局值再除。这种时候直接算全局统计量更简单。说到底工具是死的需求是活的。理解groupby的拆分-应用-合并逻辑比记住所有参数更重要。遇到新需求时先想清楚“按什么拆、怎么算、怎么合”再去找对应的 API往往比翻文档更快。我个人在实际操作中的体会是分组聚合的难点从来不在语法而在于想清楚业务逻辑对应的分组维度和聚合方式。语法查一查就会了但“按区域还是按品类分组”“算总和还是算均值”这些决策才是真正体现分析能力的地方。多练、多踩坑、多回头看自己写的代码慢慢就有感觉了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑