Python groupby深入解析:itertools与pandas分组聚合实践 同事把一张两万多行的销售明细表丢过来说“帮忙按大区和品类各汇总一下顺便算个占比”然后就去开会了。我见过不少人这时候会打开表格软件开始筛选、复制、粘贴、求和一个下午就耗在这上面中间但凡漏了一行或者排序错位后面全得重来。同样的活儿在我这儿是几行代码的事核心就是一个东西Python groupby。数据分组本身不是什么高深技术但它几乎是所有统计分析的起点——分区域看业绩、分渠道看转化、分时间段看趋势、分用户看行为全都是“先分组、再聚合”这一个套路的变体。这篇东西想讲清楚三件事groupby 到底在底层做了什么、Python 生态里那两个同名但完全不同的 groupby 该怎么选、以及我在实际项目里踩过的那些分组坑。适合已经会写几句 pandas 但总觉得分组结果“不太对劲”的人也适合刚入门、想一次性把这块地基打牢的人。1. 两个 groupby 撞在一起先把“分组”这件事说透1.1 分组本质是一次“键值映射 归并”很多人学 groupby 是直接从 API 文档开始的上来就是df.groupby(列名).sum()跑通了就以为学会了。但一到结果不符合预期就完全不知道从哪儿查。问题出在跳过了最基础的一层认知分组这个动作在计算机里到底发生了什么。你可以把一张表想象成一摞卡片每张卡片上有若干字段。分组就是准备一堆盒子每个盒子上写一个标签然后你一张一张翻卡片看这张卡片上“大区”这个字段写的是“华东”就丢进“华东”那个盒子写的是“华南”就丢进“华南”那个盒子。翻完一遍盒子的数量就是分组的组数每个盒子里的卡片就是这一组的数据。这个过程专业点叫“按关键字做哈希映射”本质就是查表——用一个键去换一个容器把散落的数据按同一个键聚到同一个容器里。聚合则是第二步对每个盒子里的卡片做统计数一数有几张计数、把某个字段加起来求和、取个平均均值。分组和聚合经常被连在一起说但它们是两个独立动作。搞清这一点非常关键因为后面所有的坑几乎都能归到“分组阶段出了问题”或者“聚合阶段用错了函数”这两类里。再往下想一层你会意识到分组是有成本的。哈希映射要计算键的哈希值、要维护容器、要处理哈希冲突这些都要时间和内存。所以当你对一张千万行的表做分组时真正的耗时往往不在“求和”那一步而在“把行分到各个桶里”这一步。理解了成本在哪儿后面讲提速时你就知道该往哪个方向使劲了。1.2 itertools.groupby 与 pandas groupby 的分工边界Python 里有两个东西都叫 groupby这是新手最容易混淆的地方。一个是标准库itertools里的groupby另一个是pandas的DataFrame.groupby。名字一模一样行为逻辑却差了十万八千里用错了就是灾难。itertools.groupby是一个纯粹的迭代工具它不存数据、不做统计只负责把“相邻且键相同”的元素打包成一组交给你。它非常轻几乎没有内存开销适合处理流式数据、日志文件、超大文件逐行读取这类场景。但它有个要命的限制只合并相邻元素。如果你的数据是乱序的同一个键的值分散在列表各处它就会给你返回好几组同名结果。pandas的groupby则是为表格数据设计的它会真的把所有同键的行聚到一起不管它们在原表里隔得多远。它内部会做哈希、会排序、会构建分组索引最后返回一个 GroupBy 对象你可以在上面接各种聚合方法。功能强、写起来舒服代价是要把数据读进内存。我把这两者的区别整理成一张表选型时对着看就行维度itertools.groupbypandas groupby是否要求预排序必须否则同键被拆成多组不需要内部自己归并内存占用极低流式处理需要整表载入内存返回类型(key, 迭代器) 的生成器GroupBy 对象典型用途日志分流、大文件分块、逐行处理表格统计、透视、特征工程聚合能力需自己写内置 sum/mean/size/agg 等处理乱序数据结果错误但不报错自动归并结果正确最后一行特别值得强调itertools.groupby在数据未排序时不会报错它只会安安静静地给你错误的结果。这是我认为它最危险的地方——错误是静默的你不主动校验根本发现不了。而 pandas 那边虽然也有静默丢数据的情况后面会讲 NaN 的坑但至少不会因为顺序问题给你错误答案。所以选型逻辑很简单手上有结构化的表格数据、要做统计一律 pandas在做流式处理、文件太大读不进内存、或者只需要把数据按块分出来交给下游处理才用 itertools。混着用的场景极少真混用时一定要在脑子里把这两套心智模型分开。2. itertools.groupby 的暴脾气不排序就等于白分组2.1 相邻合并机制拆解itertools.groupby的工作方式用一句话概括它从头到尾扫一遍可迭代对象只要当前元素的键和上一个相同就归到同一组一旦键变了就关掉当前组、开一个新组。它不回头看也不做全局记忆。这一点如果用生活场景类比就像老师按学号顺序念名单念到“三班”就分到三班那一堆念到“一班”就换一堆——如果名单是“三班、一班、三班”这样乱序的那三班就会被分成两堆。看一段能直接跑的代码感受一下这个行为差异from itertools import groupby records [ (华东, A产品, 120), (华南, A产品, 90), (华东, B产品, 200), (华南, B产品, 150), (华东, C产品, 80), ] for region, group in groupby(records, keylambda r: r[0]): items list(group) total sum(r[2] for r in items) print(region, len(items), total)跑出来你会看到“华东”出现两次、“华南”出现两次每次统计的只是其中一段连续的数据。这不是 bug这是设计如此。修正办法只有一个——先排序records.sort(keylambda r: r[0])排序之后所有“华东”挨在一起groupby才能一次性把它们收进同一组。这个“先排序再分组”的组合在算法里有个正式名字叫 sort-group是处理外部数据时最经典的模式之一。2.2 一个日志分流的实跑例子我在处理日志类数据时特别偏爱这个组合因为日志文件常常几百兆到几个 G用 pandas 读进来内存会炸但按行流式处理完全没压力。假设有一个按时间顺序写入的访问日志每行是“时间 模块名 级别 消息”现在要按模块把日志拆成不同文件。from itertools import groupby import re def parse(line): parts line.strip().split( , 3) return {time: parts[0], module: parts[1], level: parts[2], msg: parts[3]} def module_key(line): return parse(line)[module] with open(app.log, encodingutf-8) as fin: lines (l for l in fin if l.strip()) # 关键一步按模块名排序让同模块的行物理相邻 sorted_lines sorted(lines, keymodule_key) for module, group in groupby(sorted_lines, keymodule_key): with open(f{module}.log, w, encodingutf-8) as fout: for line in group: fout.write(line)注意这里sorted()会把所有行拉进内存如果你的文件大到连行都存不下就得换外部排序的思路或者干脆用数据库。这是流式方案的一个现实约束别指望它什么都能扛。另外提醒一句module_key里每次都调parse等于每行被解析了两遍排序时一遍、分组时一遍。日志量大时这个开销很明显。优化办法是先解析成元组再排序或者把 key 缓存起来。这种细节在文档里不会写但真正跑一次大文件你就知道了。2.3 迭代器只能用一次的坑groupby返回的第二个元素是一个迭代器不是列表。这意味着两件事第一你不能对它求长度、不能下标访问第二它只能被消费一次第二次再用就是空的。for region, group in groupby(records, keylambda r: r[0]): print(sum(r[2] for r in group)) # 正常输出 print(sum(r[2] for r in group)) # 输出 0因为迭代器已耗尽更隐蔽的情况是你在循环里把group存进一个列表想等循环结束再统一处理。这是绝对不行的因为groupby内部共享同一个底层迭代器一旦你推进到下一组上一组的迭代器就失效了你存下来的那些引用全变成空壳。正确做法是在循环体内立刻把list(group)落地或者当场算完。这个特性第一次遇到确实会懵但只要记住一句话就再也不会错groupby给的组是“临时窗口”窗口一过就关。要么当场处理要么立刻复制出来。3. pandas groupby 的四件套agg、transform、filter、apply 各管什么3.1 split-apply-combine 到底在哪一步发生pandas 的 groupby 背后是经典的 split-apply-combine 三步走先把原表按键拆成若干子表split对每个子表施加一个函数apply再把结果拼回一个整体combine。这三个词你在任何教程里都能看到但真正决定输出长什么样的是中间那一步“apply 的函数返回什么形状”。如果函数对每个子表返回一个标量比如求和得到一个数那 combine 之后的结果就是“每组一行”行数等于组数这叫聚合。如果函数返回的是和子表等长的序列比如给每行算个组内排名那 combine 之后结果和原表一样长这叫转换。如果函数返回的是布尔值比如判断这组是否满足某条件那 combine 之后就是“每组一个 True/False”可以用来筛掉整组数据。如果函数返回的是任意形状的表格那就是最自由的 apply。对应到 API 上就是四个方法agg、transform、filter、apply。很多人写 groupby 写得别扭根本原因就是没想清楚自己到底要哪种形状的输出随手挑了个方法结果要么行数对不上要么索引对不齐要么性能差十倍。我的经验是先问自己一句我要的结果行数是等于组数还是等于原表行数等于组数就用agg等于原表行数就用transform只保留部分组就用filter实在说不清形状才用apply。这一句话能省掉八成的试错。3.2 agg 与 transform 的输出形状差异看一个对比用同一份销售数据import pandas as pd df pd.DataFrame({ region: [华东, 华东, 华南, 华南, 华北], category: [A, B, A, B, A], amount: [120, 200, 90, 150, 80], }) # 形状一每组一行 by_region df.groupby(region)[amount].agg([sum, mean, count]) print(by_region) # 形状二和原表同长 df[region_avg] df.groupby(region)[amount].transform(mean) print(df)agg出来的是 3 行三个大区索引是 regiontransform出来的是 5 行索引和原表一模一样值是该行所属大区的平均值。这个差别直接决定了你下一步能不能把它们拼起来。transform之所以能“贴回原表”是因为它保留了原索引pandas 在做 combine 的时候会按索引对齐。这就带来一个隐含要求你传给transform的函数返回结果的索引必须和子表一致。如果你自己写了个 lambda 返回了一个没有正确索引的 Seriespandas 会按位置硬塞结果可能看起来对、其实错位了。这种错误极难发现因为不报错、也不警告。一个稳妥的习惯需要贴回原表时优先用transform配合字符串方法名sum、mean、max别急着写自定义 lambda。字符串方法名走的是 pandas 内部优化路径又快又安全。3.3 filter 与 apply 的使用边界filter的语义是“整组保留或整组丢弃”它接收一个函数函数作用于每个子表并返回布尔值。典型场景只保留订单数大于 100 的用户的所有记录。big_users df.groupby(region).filter(lambda g: g[amount].sum() 250)注意它的返回值是原表的一个子集行还是那些行只是少了一些组。它不能用来“筛选组内的某些行”——那是query或者布尔索引的活儿。这个区分很多人搞混结果写了一堆奇怪的filter表达式。apply是万能钥匙也是性能杀手。它能做任何事代价是 pandas 会为每个组构造一个 DataFrame 或 Series 传给你的函数然后收集返回值再拼接。这个“构造 拼接”的开销在小数据上无所谓在几十万组、每组几行的情况下会慢到让人怀疑人生。我做过一个粗糙的对比同样一个组内 Top-N 的需求用apply加自定义函数要跑十几秒换成sort_valuesgroupby().head(n)之后不到一秒。差距就在那儿。所以务实的原则是能用内置方法就用内置方法apply只留到最后没办法的时候用。pandas 的内置聚合函数都是 C 实现或者经过高度优化的apply是纯 Python 循环性能不在一个量级。另外从 pandas 2.2 开始apply多了个include_groups参数用来控制分组列要不要传进函数里默认行为在版本之间有过调整如果你从老版本升上来跑出奇怪结果先去查这个参数。4. 真实业务口径下的分组统计实操4.1 多列分组与结果还原成普通表单列分组太简单了实际业务里九成是“按 A 和 B 两个维度交叉看”。多列分组就是在groupby里传一个列表result df.groupby([region, category], as_indexFalse)[amount].sum()这里有个容易被忽略的参数as_index。默认是True分组列会变成结果的索引得到一个带多级索引的表。多级索引在后续做切片时很好用但如果你要把结果导出成 CSV、或者再和别的表 merge多级索引就会碍事。我的习惯是统计类结果一律as_indexFalse直接拿到一张扁平的普通表后面怎么处理都方便。等价的写法是reset_index()但as_indexFalse少一次数据拷贝。多列分组之后如果你还想在结果里同时看到“总和”和“均值”用agg传列表result df.groupby([region, category], as_indexFalse).agg( total(amount, sum), avg(amount, mean), cnt(amount, size), )这是命名聚合的写法好处是列名你自己定不用后面再改名。这种写法比传字典{amount: [sum, mean]}清晰得多我个人强烈推荐养成习惯。字典写法出来的列是 MultiIndex 表头导出 Excel 的时候特别丑还得手动压平。4.2 size 和 count 差在哪儿这俩函数是 groupby 里最容易出错的组合没有之一。size()数的是行数不关心内容count()数的是非空值的个数遇到 NaN 就跳过。如果你的数据里有缺失值两个结果会不一样而且差距可能很大。df2 pd.DataFrame({ region: [华东, 华东, 华南, 华北], amount: [120, None, 90, 80], }) print(df2.groupby(region)[amount].size()) # 华东 2, 华南 1, 华北 1 print(df2.groupby(region)[amount].count()) # 华东 1, 华南 1, 华北 1“华东”有两行但只有一行有金额所以size是 2、count是 1。做订单量统计时用size每行就是一单做有效金额统计时用count用混了就是口径错误而且这种错误在结果表上完全看不出来只有对着原始数据核对才会发现。我在项目里定过一条规矩凡是统计行数的字段列名必须带_rows后缀统计非空值的必须带_valid后缀强制在命名上把口径区分开。4.3 占比、累计、组内排名一次算完业务报表里最常要的三个衍生指标——占比、累计、排名——全都可以在分组框架下一行搞定不用写循环。df[total_by_region] df.groupby(region)[amount].transform(sum) df[pct] df[amount] / df[total_by_region] df[cum_amount] df.groupby(region)[amount].cumsum() df[rank_in_region] df.groupby(region)[amount].rank(ascendingFalse, methoddense)三个模式的共同点它们返回的都是和原表同长的结果所以用transform或者 GroupBy 对象上的累积方法可以直接赋成新列。占比这一条要特别注意先把分母算出来存成列而不是在表达式里嵌套两次 groupby——嵌套写法的可读性很差而且 pandas 会重复计算分组白白多花一次开销。rank的method参数值得留意dense是并列同名次、后面不跳号1、1、2min是并列取最小名次、后面跳号1、1、3average是并列取平均1.5。报表里通常用dense因为业务方看到“第 1 名、第 1 名、第 3 名”会问为什么没有第 2 名。5. 那些年踩过的分组坑排查链路完整复盘5.1 NaN 被静默丢弃的排查过程有次做用户分群统计各组人数加起来比总人数少了一截差了大概百分之三。第一反应是数据源有问题查了原始表行数没错又查了去重逻辑也没错。折腾了小半天最后发现问题出在分组键上有缺失值。pandas 的groupby默认dropnaTrue也就是说如果分组列里有 NaN这些行会被直接丢掉不计入任何一组也不会报错、不会警告。这在高斯分布的数据里不明显但在真实业务数据里缺失值往往集中在某些特定来源丢掉它们等于系统性地偏掉了某一类样本。排查的完整链路是这样的先算len(df)再算各组size()之和两者一减就知道丢了多少行然后用df[分组列].isna().sum()看缺失了多少最后确认是不是要保留。修复很简单df.groupby(region, dropnaFalse)[amount].sum()加上dropnaFalse之后NaN 会自己成为一组。要不要保留是业务问题但至少你得知道有这么回事。我现在的习惯是在任何分组统计之前先跑一句df[分组列].isna().sum()看看有没有缺失有就先决定怎么处理绝不默认让它悄悄消失。同样的道理还适用于分类变量。如果分组列是category类型而某些类别在数据里没出现pandas 默认不会为它们生成行。要强制补全用observedFalse不过这个参数在较新版本里默认值有变化跨版本迁移时要留意。5.2 SettingWithCopy 警告的来源另一个高频坑是这个警告A value is trying to be set on a copy of a slice from a DataFrame。它通常出现在这种写法之后sub df[df[region] 华东] sub[pct] sub[amount] / sub[amount].sum() # 警告原因是df[...]返回的可能是一个视图也可能是一份拷贝pandas 不确定所以给你个警告。绝大多数情况下代码能跑对但在某些链式操作下赋值会作用在临时对象上你的修改直接丢失而且不报错。解决办法很直接明确要一份独立数据时就.copy()sub df[df[region] 华东].copy()我个人的原则是只要这个子集后面要被修改一律加.copy()。多拷贝一次的内存开销远小于排查“为什么我的赋值没生效”所花的时间。另外提醒一句别在链式索引上赋值也就是别写df[df.a 1][b] 0这种它百分之百不可靠正确写法是df.loc[df.a 1, b] 0。5.3 transform 索引对不齐导致的空值有一回我用自定义函数做transform结果贴回原表后出现了一大片 NaN但单独跑函数明明有值。查了半天问题在于函数返回的 Series 索引和子表索引不一致——我在函数里做了reset_index()把索引重置成了 0、1、2pandas 拿这个新索引去和原表对齐对不上就填 NaN。这个坑的教训是transform里的函数不要动索引。需要用到原索引就直接用不需要就返回一个和输入等长的列表或数组让 pandas 按位置对齐。如果非要返回 Series务必保证索引是子表原本的索引。顺带说一个相关现象当分组键本身也是索引的一部分时transform的对齐行为会更绕。稳妥做法是分组前先reset_index(dropTrue)让索引干净能避开一大类对齐问题。6. 百万行数据下的分组提速思路6.1 把字符串列换成 category分组慢八成慢在键的计算上。如果分组列是字符串pandas 每次分组都要对字符串做哈希几百万行下来开销很可观。而如果这一列的取值种类很少比如大区只有七八个、渠道只有十几个转成category类型能显著提速因为它内部把字符串映射成了整数编码哈希的是小整数而不是长字符串。df[region] df[region].astype(category)这个转换的收益在取值种类少、行数多的时候最明显。我实测过一个千万行、分组列只有 12 个取值的场景转 category 之后分组时间降了一半左右。代价是 category 类型在增删类别时有些额外约束如果你后续要往这列里写新值记得用cat.add_categories不能直接赋值一个不存在的类别。判断要不要转的经验值去重后的取值数除以总行数小于 0.05基本都值得转。6.2 用 agg 替代 apply 的改写方式前面提过apply慢但很多人不知道该往哪儿改。这里给一个具体的改写例子。需求是“每个大区里金额最高的那条记录”。直观写法是apply加排序取第一行top1 df.groupby(region, group_keysFalse).apply( lambda g: g.nlargest(1, amount) )改写思路是先全局排序再按组取头部top1 df.sort_values(amount, ascendingFalse).groupby(region).head(1)后者快得多因为sort_values和head都是向量化实现没有逐组构造 DataFrame 的开销。类似的改写还有很多组内求和用agg(sum)而不是apply(lambda g: g.sum())组内计数用size()而不是apply(len)组内去重计数用nunique()而不是apply(lambda g: g.nunique())。这些内置方法走的是优化过的路径写法还更短。什么时候apply真的没法替代当你要在每组内部做多步骤的、依赖中间状态的计算并且输出形状不固定时。这种场景我一般会考虑退一步先把数据按组拆成字典用纯 Python 处理完再拼回去反而比apply可控。6.3 分块与预排序的取舍数据量再往上走单机内存扛不住的时候常见的两条路一是分块读、分块聚合、最后合并中间结果二是先按分组键排序落盘再顺序扫描。第一条路在 pandas 里用chunksize参数实现partials [] for chunk in pd.read_csv(big.csv, chunksize500_000): partials.append(chunk.groupby(region, as_indexFalse)[amount].sum()) final pd.concat(partials).groupby(region, as_indexFalse)[amount].sum()关键点在于分块聚合的结果必须再做一次聚合因为同一个大区可能出现在多个块里。如果统计的是均值就不能简单再求均值得同时保留分子和分母总和与计数最后再除这个细节很多人第一次写都会错。第二条路适合数据已经按时间或 ID 有序、且分组键和排序键一致的情况可以省掉一次全量排序。但如果分组键和排序键不一致预排序就白做了还不如直接用分块。7. 我个人的几条使用习惯写了几年分组统计慢慢沉淀出几个固定动作基本都是踩坑换来的。第一个动作拿到数据先看分组键的缺失和基数。df[col].isna().sum()和df[col].nunique()两条命令五秒钟能提前暴露掉一大半的口径问题。基数特别高比如用户 ID几十万个不同值的时候分组结果会非常稀疏这时候要考虑是不是该先做分桶再分组。第二个动作统计结果一律as_indexFalse。理由前面说过扁平表在导出、拼接、可视化时都更省事。多花的那点思考成本比后面反复reset_index划算。第三个动作给每个聚合列起明确的业务名。sum、mean这种默认名过一遍手就分不清谁是谁了尤其是列多的时候。命名聚合的写法虽然多敲几个字但半个月后回来看代码你会感谢当时的自己。第四个动作涉及占比、累计这类衍生指标时先把中间结果落成列再算不要写超长的链式表达式。链式写法看着酷调试的时候没法中间打印出问题只能二分法排查非常低效。最后分享一个我常用的校验套路任何分组统计做完把各组的结果加总和全表直接统计的结果对一遍。对不上就说明有行被丢了或者被重复计了回去查dropna、查索引对齐、查是不是有重复的分组键。这个动作花不了十秒但能挡住绝大多数“看起来很正常”的错误结果。数据分组这件事写对不难难的是确认自己写对了。