做数据分析的人几乎没有一个能绕开groupby。不管你是用pandas处理Excel报表、清洗业务日志还是做特征工程groupby就像一把万能钥匙先拆分、再计算、最后合并。很多初学者把groupby当成“分组求和”用但其实它背后是一整套完整的“拆分-应用-合并”思想能玩出来的花样远比想象中多。这篇文章我会把groupby从基础语法到底层逻辑、从聚合到变换过滤、从常见报错到性能优化完整拆开讲一遍。适合已经能写简单pandas代码、但想系统掌握分组操作的读者也适合那些总是“用的时候查一下”的选手看完这篇你基本可以告别零散搜索了。1. groupby到底在做什么核心概念与底层逻辑1.1 split-apply-combine分组三步骤很多人用groupby只是照着例子写却不理解它为什么叫“分组”而不是“聚合”。groupby的全名其实是三个步骤的组合split拆分、apply应用、combine合并。这三个词出自统计学里经典的分组计算框架pandas完整实现了这套逻辑。第一步split按照你指定的“分组键”把整个DataFrame或Series纵向切成一堆小数据块。举个例子假设数据里有商品类别列“分类”里面有“数码”“家电”“服饰”三种值groupby(分类)就会把数据切成三块每块内的行都对应同一个分类。第二步apply对每一块分别执行你给的操作。这个操作可以是一般的聚合函数比如求平均值、求和也可以是一个自定义函数甚至一个复杂的建模流程。关键点在于每一块都是独立的“小DataFrame”操作是逐块进行的。第三步combine把每一步得到的结果重新组装成一个新的DataFrame或Series。这里要注意组合的逻辑取决于你用的是agg、transform还是apply它们三者的合并方式完全不同。我后面会专门花一节讲这三者的区别因为它们是最容易把人绕晕的地方。注意groupby默认会“丢弃”分组键所在的列吗不会。但如果你用as_indexFalse或.reset_index()分组键会变成普通列而不是索引。这个细节非常影响后续取数我会在2.2节再展开。1.2 分组键的几种形态列名、Series、数组、字典/映射、函数groupby的第一个参数也就是“分组键”远不止一个列名那么简单。理解这几种形态你才算真正会用groupby。最基础的是传单个列名df.groupby(部门)。也可以用多个列名组成的列表df.groupby([月份, 部门])这样的结果是按两列列值组成的“复合键”分组形成多层次索引。第二种常见形态是传一个与DataFrame等长的Series或数组。比如你不想在DataFrame里新增一列却想按某个外部条件分组# 假设有8行数据长度为8的标签数组 groups [A, B, A, B, A, B, A, B] df.groupby(groups).sum()这里groups可以来自一个外部变量、另一列经过某种计算得到的结果甚至是df[价格] 100这样的布尔Series从而实现“按条件分组”。第三种是字典或映射把某个列的取值映射到另一个标准化的值。比如旧字段里写的“京”“沪”“穗”你想按区域合并统计可以传一个字典表示“京”属于“华北”、“沪”属于“华东”等。这个常用于对脏数据进行分组治理。第四种是函数比如按索引的某个属性分组。最经典的场景是按时间索引的年份、月份、季度进行聚合df.groupby(lambda x: x.year).sum() # 当索引是DatetimeIndex时pd.Grouper也是一种特殊的分组键专门用于时间序列分组df.groupby(pd.Grouper(freqM))可以把数据按月重采样汇总。这么多形态本质都指向同一个逻辑groupby内部会把“分组键”统一转换成一个与行数等长的数组然后按照数组中的唯一值拆分数据。所以只要你能得到长度一致的标签序列就能作为分组键。这是理解所有分组的底层钥匙。2. 基础用法的几个进阶细节从分组到聚合2.1 聚合函数与aggregate同名函数到底作用于哪些列最直观的用法是df.groupby(部门)[薪资].mean()意思是按部门分组后只对“薪资”列求平均。但如果你直接写df.groupby(部门).mean()pandas会对所有“能做均值计算的数值列”都求一遍均值——这看起来很方便实际却容易埋雷假如数据里有一列“员工编号”它本身就是数值但求均值毫无意义假如某列是“性别”这种编码为0/1的字段求均值也可能带来误导。所以我的建议是聚合时尽量显式指定要计算的列。你可以这样写df.groupby(部门)[薪资].mean()也可以把多列一起算df.groupby(部门)[[薪资, 绩效]].mean()如果你想让不同列用不同的聚合函数那就得用agg方法。agg是aggregate的简写它接受一个字典键是列名值是聚合函数名或函数对象df.groupby(部门).agg({薪资: mean, 绩效: sum, 人数: count})这里“人数”列如果用count它会忽略NaN只统计非空行。如果你想统计包括NaN在内的总行数不能用count而要用size后者返回每个分组的行数不受NaN影响。实操心得size和count的区别是高频考点。df.groupby(部门).size()永远返回每组的行数df.groupby(部门).count()返回的是每列的非空值数量。假如某一列正好没有缺失值它俩看起来一样但只要数据里有空值结果就不同。2.2 多种聚合函数同时使用列表、命名聚合与column布局在agg里还可以传一个函数列表同时对同一列计算多种统计量df.groupby(部门)[薪资].agg([mean, sum, max, min])返回结果是一个DataFrame列名是“mean”“sum”这些函数名。如果嫌这些名字太生硬可以用命名聚合pandas 0.25的NamedAggdf.groupby(部门)[薪资].agg(平均薪资mean, 总薪资sum)也可以对整个DataFrame做命名聚合需要注意的是语法稍微不同df.groupby(部门).agg( 平均薪资(薪资, mean), 最大绩效(绩效, max) )这种写法的好处是结果列名清晰不需要.rename去改名字尤其适合一次算多个维度的业务报表。关于agg返回的索引结构默认分组键会成为索引。如果你希望分组键作为普通列方便后续用筛选或转成Excel可以加一句.reset_index()或者在分组时用df.groupby(部门, as_indexFalse).agg(...)。as_indexFalse是pandas里一个非常实用但容易被忽略的参数建议你在做报表输出时养成加上它的习惯。2.3 “小计”与分类汇总groupby里的描述性统计describe本身可以给出分位数、标准差、最大值最小值等一套结果。如果你结合groupby可以一次性输出每个分组的统计报告df.groupby(部门)[薪资].describe()这个操作在实际工作中特别适合写周报不用一行一行算平均值、最大值、中位数直接一行代码得到一张汇总表再接.to_excel()输出。如果只想看几个指定的分位数可以自己组装df.groupby(部门)[薪资].quantile([0.25, 0.5, 0.75])3. 遍历分组、变换与过滤groupby不止聚合3.1 查看分组与遍历分组groups、get_group和for循环groupby之后得到的DataFrameGroupBy对象不只是用来算聚合值。你可以先“看一眼”数据被分成了哪些组grouped df.groupby(部门) print(grouped.groups) # 返回一个字典键是组名值是行索引组成的数组 print(grouped.size()) # 每个组的行数get_group是取出某一个组内容的神器grouped.get_group(数码)这在调试的时候特别好用你可以先确认“数码”这个组的数据长什么样再决定用什么聚合方式。遍历分组也很常见尤其在你想对每个组分别做某些操作、或者想保存成多个文件时for name, group in df.groupby(部门): print(f当前组: {name}, 行数: {len(group)}) # group 就是一个完整的DataFrame可以在这里做任意处理 # 比如保存成单独的文件 group.to_excel(foutput_{name}.xlsx, indexFalse)注意group是原始数据的“视图/复制”这里并不包含分组键的列因为分组键已经被当成了索引。如果你不希望索引成为分组键可以设置group_keysFalse后面4.1节再细说。3.2 transform把聚合结果广播回每一行transform是groupby里最容易产生“恍然大悟”感觉的功能。它的作用是对每个组做计算但返回的不是压缩后的分组汇总表而是一个和原始DataFrame有完全相同索引和行数的结果。换句话讲transform会把聚合结果“广播”回原来的每一行。最常见的场景是“算每行占组内汇总值的比例”df[薪资占比] df[薪资] / df.groupby(部门)[薪资].transform(sum)这一行代码做了三件事先按部门分组求总薪资然后把每个总薪资广播回对应行最后用每行的薪资除以它所在组的总额。如果没有transform你需要先算聚合结果再用merge合并代码量明显更多而且容易出错。transform还可以配合fillna做组内缺失值填充。下面的写法可以把每组的缺失值替换为该组的平均值df[薪资] df.groupby(部门)[薪资].transform(lambda x: x.fillna(x.mean()))或者更常用的“组内标准化”df[薪资标准化] df.groupby(部门)[薪资].transform(lambda x: (x - x.mean()) / x.std())有一点必须注意transform会对每一组单独执行函数然后确保返回结果的索引与原DataFrame对齐。如果函数输出结果是标量它会被广播到整组如果函数输出是一个Series/数组那它必须与组长度一致。如果长度不对pandas会抛错。3.3 filter按组条件过滤而不按行条件过滤很多初学者想“过滤掉某些行”第一反应是df[df[部门] 数码]这是按行条件过滤。但如果你的需求是“保留那些行数大于100的部门”那就完全不一样了因为你得先按部门算行数再根据行数决定是否保留整个组。这时候要用filter。df.groupby(部门).filter(lambda g: len(g) 100)filter接收的函数会作用于每一个分组函数返回True时保留整个组返回False时丢弃整个组。它非常灵活不只是len还可以是“该组薪资均值是否大于某个值”等等。df.groupby(部门).filter(lambda g: g[薪资].mean() 5000)需要注意的是filter并不修改原数据它返回的是筛选后的DataFrame索引保持原来的索引。如果想重置索引可以再加.reset_index(dropTrue)。实操心得filter和query经常被混淆。df.query(薪资 5000)是按行过滤保留所有薪资大于5000的行df.groupby(部门).filter(...)是按组过滤保留整组。判断该用哪个就看“条件作用的粒度”是行还是组。4. 实操过程中的高频坑与高级技巧4.1 apply和agg的区别隐式合并的“坑”apply在groupby中是一个功能非常强大但也特别“玄学”的方法。很多人以为agg和apply可以互相替代其实它们完全不同。agg只适合做“逐列/逐组的聚合”一般输入是一列数据或一个分组输出是一个标量或若干个标量组成的一行。它的结果一定是“每组一行”。apply则更加灵活它可以返回标量、Series、DataFrame甚至长度不一的对象。因此它其实是一个“通用apply”内部会自动尝试把多个组的输出结果拼接组合。但也正因为灵活它的行为有非常多坑。一个典型的坑是这样的当apply返回一个Series时pandas会尝试把Series的索引作为结果的列名最终可能把分组键“填”到结果里造成“多出一层索引”或“索引重复”。网络上有很多人称它为“groupby.apply的隐式合并机制”pandas会根据每个组的输出结构自动做concat。这个行为在早期版本里尤其不好控制。举例def z_score(group): return (group[薪资] - group[薪资].mean()) / group[薪资].std() df.groupby(部门).apply(z_score)这个结果会得到一个带MultiIndex的Series第一层部门第二层是原始行索引。很多新手以为它会自动按索引对齐结果发现索引里出现了重复变得很困惑。如果你想避免这些不确定的行为建议优先使用transform做“逐行广播”的操作而不是apply。如果你确实需要用apply最好明确你的返回类型并且用group_keysFalse控制分组键是否出现在结果里df.groupby(部门, group_keysFalse).apply(z_score)group_keysFalse的作用是告诉pandas分组键不要作为结果索引的一部分直接返回每组的计算结果并按顺序拼接。这个参数在apply和某些transform场景里是常见的救火队员。4.2 处理NaN与分组结果顺序之谜当分组键列本身含有NaN时groupby默认会把这些行放到一个名为NaN的组里。这往往不符合业务预期。处理办法是在分组前清洗df df.dropna(subset[部门])或者在分组时用dropnaFalse或dropnaTrue显式控制。pandas 1.1 支持groupby(..., dropnaFalse)来保留NaN组dropnaTrue则丢弃。默认是True所以含NaN的行不会出现在结果中。如果你发现“某组不见了”先检查分组键列是否有NaN。关于顺序groupby默认会把分组键按照排序后的顺序输出具体排序规则对字符串是字典序对数字是数值大小。有些时候你希望按数据出现的顺序分组可以加sortFalsedf.groupby(部门, sortFalse).mean()这个参数在业务场景中挺常见比如原始数据里“部门一、部门二、部门三”的顺序是有意义的默认排序会打乱它。sortFalse可以保持首次出现顺序输出报表时看起来更自然。4.3 分组后保留非分组列的几种策略很多人在groupby之后发现某些需要的列不见了。比如df.groupby(部门).mean()只计算数值列非数值列如“备注”“员工姓名”会直接被忽略。这不一定是你的操作有误而是mean只对数值列有意义。如果你想在分组后保留某列的信息有几种思路第一种把这个列加进分组键里。比如df.groupby([部门, 组长]).mean()得到的结果里“组长”也作为索引的一部分。如果不想让它变成索引加as_indexFalse。第二种用agg对需要的非数值列做first或last取组内第一条或最后一条记录。df.groupby(部门).agg(负责人(组长, first), 人数(员工姓名, count))第三种如果你想保留整行数据包括分组列以外的所有字段可以考虑用transform给每一行打上组内聚合标签而不是直接压缩行数。这更像“打标签”而不是“汇总”。4.4 性能优化避免慢速apply提升groupby效率数据量超过几十万行以后groupby的性能开始变得重要。很多人的第一反应是写循环这在大数据量下非常慢。其实pandas的groupby本身是用Cython优化的聚合函数速度极快但apply和自定义lambda会明显拖慢速度因为每一组都要调用一次Python函数。我用一个真实情景说明某业务表有200万行按用户维度计算平均消费金额。直接df.groupby(用户ID)[金额].mean()大约一二百毫秒如果改成df.groupby(用户ID)[金额].apply(lambda x: x.mean())时间会变成几秒因为lambda加Python循环是慢的。所以性能优化的第一原则是能使用内置agg函数时不要用apply能用向量化计算比如transform时不要逐行循环。第二原则是如果需要按多个维度分组不要写两次groupby再合并最好一次groupby后用agg一次算多个统计量利用pandas内部的多列并行优化。第三原则是如果确实需要对每个组做复杂逻辑先用ngroup()给组编号再按组号分组循环效率会比直接按字符串分组好一些尤其在分组键是重复字符串时。df[组编号] df.groupby(部门).ngroup()如果你在做时间序列分组务必使用pd.Grouper而不是把时间格式化后再分组。比如df.groupby(pd.Grouper(freqM))比df.groupby(df[日期].dt.to_period(M))在内存占用和速度上都更优。5. 常见问题与排查实录一张表解决高频故障5.1 高频报错与应对办法以下是实际使用中非常容易遇到的报错我统一整理成一张速查表现象原因解决办法KeyError: 部门分组键列名写错或该列不在DataFrame中先用df.columns确认列名注意空格和大小写TypeError: agg() missing ...agg传了函数字典但不是所有列都有映射确保字典里的键都存在于DataFrame列中ValueError: Length of values does not match length of indextransform返回的长度与组长度不一致检查自定义函数是否返回了长度不匹配的结果AttributeError: DataFrameGroupBy object has no attribute foo写错了方法名或调用了一个不存在的方法检查拼写或考虑换成apply完成自定义逻辑FutureWarning: The default of observedFalse ...pandas提示分类类型分组时未观察到的类别也会被保留在groupby(..., observedTrue)中设置observedTrue输出结果多了一个莫名的列可能apply返回了带列名的Seriespandas自动合并列名考虑给返回Series指定name或改用transform5.2 分组后索引乱了、行对不上怎么办一个非常常见的情节是分组聚合完之后你想把结果和原表做连接结果发现因为分组键变成了索引merge时匹配不上。此时最稳妥的方案是在分组时直接as_indexFalse或者分组结束后reset_index()。summary df.groupby(部门, as_indexFalse).agg(平均薪资(薪资, mean)) # 这样summary就是普通列结构可以直接和原表merge merged df.merge(summary, on部门, howleft)这个操作在业务报表开发中几乎每天都会用到因为它能方便地给原始明细数据“加上一列汇总值”。很多初学者在merge之前忘记复位索引结果匹配失败或产生重复行。我自己的习惯是只要结果要用于下一次连接就立刻reset_index(dropFalse)其中dropFalse是保留原索引列。5.3 分组键是多重条件时的经验复合分组与透视表选择当你要按多个维度分组时groupby([月份, 部门])得到的是一个MultiIndex。如果你觉得多层索引操作麻烦或者之后要输出到Excel让人看懂可以考虑用pivot_table替代df.pivot_table(index月份, columns部门, values薪资, aggfuncmean)pivot_table其实底层也调用了groupby但输出的是“宽表”更容易阅读。反过来如果你要做复杂聚合、变换、过滤groupby更灵活。所以我的建议是只看结果用pivot_table要加工处理用groupby。如果分组键本身是时间还有一个关键技巧先用pd.to_datetime转成时间类型再用dt.to_period(M)或dt.year做分组而不是用字符串切片。6. 让groupby更好用的三个独门习惯6.1 写一个自己的聚合函数命名空间如果你发现自己经常写df.groupby(X)[Y].agg([mean, sum])可以考虑把这些常用统计量封装成一个函数字典常用统计 { 均值: mean, 总和: sum, 样本数: count, 最大值: max, 最小值: min, } df.groupby(部门)[[薪资, 绩效]].agg(常用统计)这样做的好处是当报表口径变化你只需要改一处字典所有复用该字典的代码都会同步。这在大规模报表项目里特别有用。6.2 用memoize和哈希思路优化自定义逻辑再讲一个我自己的小技巧。当你确实需要对每个分组调用一个较慢的自定义函数比如跑一个简单线性回归或者计算某种特征指标可以用“先分组编号再并行”的思路。Python标准库里有concurrent.futures.ProcessPoolExecutor但pandas的groupby本身不是为并行设计的最简单的优化是把每组的输入先准备好再用多进程处理。如果你不想引入多进程一个更简单的小优化是在自定义函数内部避免反复读取同一个DataFrame子集尽量把需要的列用.loc一次性取出来再塞进函数。这不仅让代码更清晰也顺手提升了速度。6.3 把groupby结果可视化快速洞察分组差异groupby不只是用来造报表它还能快速帮你看数据分布。比如按部门聚合后直接用plotdf.groupby(部门)[薪资].mean().plot(kindbar)或者查看各部门的趋势变化df.groupby([日期, 部门])[销售额].sum().unstack().plot()这里的unstack()是把其中一层索引变成列适合把分组后的结果变成标准透视表结构。配合plot几行代码就能出图对数据探索阶段特别有帮助。我个人在实际项目里的习惯是在跑复杂模型之前一定先用groupby加上describe和可视化做一轮“数据体检”。这能帮你快速发现异常值、数据倾斜和分组不均匀的问题避免后面模型被少数极端分组带偏。7. 最后再分享几个可以继续扩展的方向groupby的价值远不止这一篇文章能讲完。如果你吃透了上面这些内容接下来有几个方向可以继续深入一是df.groupby(...).rolling(...)分组内的滚动窗口计算比如按用户ID分组后计算“过去7天累计消费”。这是特征工程和时序分析的高频操作。二是pd.Grouper结合groupby做复杂的时间分组比如按“每个用户每个月”聚合这时分组键是[用户ID, pd.Grouper(freqM)]。这个组合在行为分析里非常强大。三是groupby与apply结合做更复杂的“组内建模”。例如对每个部门拟合一条趋势线和预测值然后把预测结果返回到原表。记住用group_keysFalse控制索引即可。四是可以尝试df.groupby(..., observedTrue)处理分类变量如果你用Categorical类型存储分组键observedTrue可以让结果只保留实际出现的类别避免输出无意义空组。这些扩展方向每一个都可以单独写一篇长文。但只要你把groupby的拆分-应用-合并三种逻辑真正理解透遇到这些高级玩法也不会觉得陌生。说到底pandas里八成的数据加工能力都是围绕“结构分组”和“数据对齐”这两件事展开的groupby正好把这两件事串在了一起。希望这篇详细拆解能帮你少走一些弯路也欢迎你按自己业务里的真实数据把这些例子挨个跑一遍踩过坑之后的理解远比我在这里说得更深刻。