上一章把groupby的分组机制、遍历方式、基础聚合过了一遍,重点在"怎么把数据按某个键切开"。真正让人头疼的活儿其实在切开之后:一列数据要同时出求和、均值、最大值,出来的列名还得让人一眼看懂;算完的指标要保持原表行数,方便拼回去做后续计算;有些指标必须先在组内加权再汇总,随手丢一个mean上去数就错了。这一章聊的就是Pandas 数据聚合与分组里更贴近实战的那一层:agg的各种写法、命名聚合怎么用、transform和apply的分工边界、pivot_table与crosstab该在什么场景出场,以及多级索引怎么收尾、性能怎么保住。适合已经会用groupby().sum()、但一遇到"多函数聚合列名乱成一团"或"算完不知道怎么接回原表"就卡住的人,也适合想把分析脚本从能跑改成跑得快的人。
1. 聚合结果的三种形态,先想清楚要哪一种
分组聚合这件事,很多人卡住不是因为不会写 API,而是因为没想清楚"我要的输出长什么样"。Pandas 在这一层的设计其实很清晰,几乎所有聚合方法最终都会落到三种形态之一,认清形态再选方法,比背函数签名有用得多。
1.1 压缩型聚合:一行代表一组
最典型的就是sum、mean、count、max这类,一组数据压成一个数,行数从几万变成几十。这类结果的行索引就是分组键,列索引是你要的指标。它的价值在于"看全局",比如各区域的销售总额、各渠道的平均客单价。代价是丢掉了明细,你没法再回到原始行级别去做逐行判断。
这里有个容易忽略的点:压缩型聚合的输出形状是由分组键的数量决定的。单列分组出来是单层索引,两列分组出来就是 MultiIndex,后面接透视、接 merge、接 Excel 导出都得为这个多出来的层级做处理。所以动手前先问自己一句:这个结果最后是给人看,还是要继续参与计算?给人看的可以保留多级索引,继续算的尽早摊平。
1.2 同形型输出:行数和原表一致
transform系列属于这一类,输入一万行,输出还是一万行,每行填的是它所在组的某个统计值。这个形态看着不起眼,但它是"组内比较"的唯一正解。想做组内占比、组内标准化、用组内中位数填补缺失值、给每行打上组内排名,都必须在同形输出上做。用压缩型结果去 merge 回原表当然也能实现,但多了一步连接,代码长、出错点多、还慢。
我见过不少脚本是先agg再merge再fillna,三步做完其实transform一行就够。区别不在结果对不对,而在维护成本。
1.3 变形型输出:行列互换的二维汇总
pivot_table和crosstab属于这一类,把一维的分组结果摊成二维表,行是维度 A,列是维度 B,格子里是指标值。这类输出最适合直接贴进报表或者喂给绘图库,也是最容易被业务方要求"再加一列合计""把空的地方填 0"的形态。它的底层依然是分组聚合,只是多做了一步unstack,理解了这层关系,遇到透视表的奇怪行为就能倒推回去查。
1.4 三种形态的选型对照
| 输出形态 | 代表方法 | 行数变化 | 典型用途 | 常见坑 |
|---|---|---|---|---|
| 压缩型 | agg/sum/mean | 变少 | 汇总报表、指标计算 | 多函数聚合列名成多级 |
| 同形型 | transform | 不变 | 组内占比、标准化、填充 | 自定义函数返回值长度必须一致 |
| 变形型 | pivot_table/crosstab | 行列重排 | 二维报表、绘图输入 | 小计行不是简单相加 |
| 筛选型 | filter | 变少,但保留原列 | 剔除样本量过小的组 | 条件函数返回布尔值而非表 |
把这四种形态记牢,后面所有的参数选择都有了判断依据。我自己的习惯是:写聚合代码前先在纸上画一下目标表的行列结构,画完基本就知道该用哪个方法了。
2. agg 的写法与命名聚合:把列名一起管好
agg是这一层最核心的方法,也是最容易写出"结果对但列名没法看"的地方。它的参数形态有好几种,每种返回的结构都不一样,混用会直接报错。
2.1 单列多函数与多列多函数
先选列再聚合,和多列一起聚合,返回结构完全不同。df.groupby("k")["v"].agg(["sum", "mean"])返回的是 DataFrame,索引是分组键,列名就是函数名字符串;而df.groupby("k").agg(["sum", "mean"])返回的列是两层 MultiIndex,第一层是原列名,第二层是函数名。后者信息更全,但列名处理起来更麻烦,导出 CSV 时经常变成amount_sum这种带下划线的拼接形态。
多列不同函数就要传字典:{"amount": ["sum", "mean"], "qty": "sum"}。字典的键是列名,值可以是单个函数名,也可以是函数名列表。这里有个硬性限制需要注意:字典写法只能用在 DataFrameGroupBy 上,也就是必须先df.groupby("k")而不能先取列df.groupby("k")["amount"]。对 SeriesGroupBy 传字典,新版 Pandas 会直接抛SpecificationError: nested renamer is not supported。这个报错我见过太多人卡住,因为旧教程里这种写法是能跑的。
2.2 命名聚合:一次把列名定死
命名聚合是解决列名问题最干净的办法,写法是把"新列名 = (源列名, 函数)"作为关键字参数传给agg:
summary = orders.groupby("region").agg( order_cnt=("order_id", "count"), total_qty=("qty", "sum"), total_amount=("amount", "sum"), max_price=("price", "max"), )出来的表列名就是order_cnt、total_qty这几个,索引是region,干干净净一层,不需要任何后处理。它还有两个隐性好处:一是列的顺序完全由你写的顺序决定,不用再reindex;二是同一个源列可以被多次使用,比如对amount同时取sum和max,各起各的名字,互不干扰。
有几点必须注意。命名聚合的关键字参数名不能是 Python 关键字,也不能和分组键重名,否则会覆盖索引;同一个agg调用里不能混用命名聚合和普通字典写法,混了必报错;如果确实需要动态生成(比如列名来自配置表),可以用pd.NamedAgg(column="amount", aggfunc="sum")显式构造,效果等价但更啰嗦,一般只在循环里用。
提示:命名聚合的关键字参数允许中文标识符,Python 语法层面是合法的,但导出到某些下游工具可能有编码麻烦,团队协作时建议统一用英文字段名,展示层再改名。
2.3 自定义函数的两个入口与代价
agg接受的不只是函数名字符串,也可以是 lambda、普通函数、甚至 numpy 的 ufunc。传自定义函数时,函数收到的是每个分组的 Series(如果是 SeriesGroupBy)或子 DataFrame(如果是 DataFrameGroupBy),返回值可以是标量,也可以是 Series。
代价在这里:字符串函数名走的是 Pandas 内部的 C 实现或者高度优化过的路径,lambda 走的是 Python 逐组调用,速度差一个量级很常见。数据量上了百万行、分组数上万,这个差距会从"多等两秒"变成"多等两分钟"。所以我的原则是能用字符串就用字符串,实在要自定义就先看能不能拆成几个内置函数再组合。
还有一个细节:lambda 聚合出来的列名就是<lambda>,多个 lambda 会得到一堆同名同姓的列,后续根本没法引用。这时候要么改用命名聚合,要么在agg之后手动赋columns。分位数计算是典型场景,直接写成命名聚合更省事:
stats = orders.groupby("region").agg( p50=("amount", lambda s: s.quantile(0.5)), p90=("amount", lambda s: s.quantile(0.9)), )2.4 一份订单表跑通全部写法
拿一份虚构的订单明细做演示,字段是区域、渠道、品类、数量、单价,金额由数量和单价相乘得到:
import pandas as pd orders = pd.DataFrame({ "order_id": range(1, 13), "region": ["东区","东区","南区","南区","西区","西区"] * 2, "channel": ["线上","线下"] * 6, "category": ["标准","礼盒"] * 6, "qty": [3, 2, 5, 1, 4, 6, 2, 3, 7, 1, 4, 5], "price": [99.0, 120.0, 88.0, 150.0, 66.0, 45.0, 99.0, 88.0, 72.0, 150.0, 60.0, 45.0], }) orders["amount"] = orders["qty"] * orders["price"]第一层汇总用命名聚合一次到位,然后补一个加权均价。这里就是最容易出错的地方:均价必须用总金额除以总数量,不能对单价列直接取mean。因为每单数量不同,直接平均单价等于给每个订单相同的权重,数量大的订单被稀释了。这个错误在报表里非常常见,而且不容易被发现,因为两种算法出来的数都在合理区间内。
summary = orders.groupby("region").agg( order_cnt=("order_id", "count"), total_qty=("qty", "sum"), total_amount=("amount", "sum"), ) summary["avg_price"] = summary["total_amount"] / summary["total_qty"]同理,如果要对两列做不同函数、并且还想保留品类维度,就换成多列分组加命名聚合,索引变成两层,再用reset_index摊平即可。
3. transform 与 apply:形状一致和自由发挥的分界线
这两个方法经常被混用,因为它们看起来都能"对每个组做点事"。区别其实很硬:transform对输出形状有强约束,apply没有。
3.1 transform 的硬约束与它的价值
transform要求每组返回的结果能对齐回原始行,要么是一个标量(会被广播到该组所有行),要么是与该组等长的序列。返回长度对不上就直接报错。这个约束看着麻烦,实际上正是它的价值所在:结果一定和原表同长同序,可以直接赋值成新列,不需要 merge、不需要考虑索引对不对齐。
transform传字符串函数名时,走的是和agg同样的优化路径,速度很快:orders.groupby("region")["amount"].transform("sum")。传 lambda 时同样是逐组 Python 调用,慢,但灵活。
有个细节值得记住:transform里的std默认是样本标准差(ddof=1),如果某个组只有一个元素,结果是NaN而不是 0。做标准化时这一点会让结果凭空多出空值,很多人在这一步困惑。
3.2 三个高频场景:组内占比、组内填充、组内排名
组内占比是最常写的:把每行金额除以它所在区域的总金额。用transform("sum")一行搞定,分母和原表逐行对齐,除法直接成立。
orders["region_total"] = orders.groupby("region")["amount"].transform("sum") orders["share_in_region"] = orders["amount"] / orders["region_total"]组内填充是另一个高频需求:用各区域的中位数填补该区域的缺失单价。必须先transform("median")得到同形结果再fillna,不能直接groupby().median()然后fillna,因为后者是压缩型结果,长度不对。
组内排名用rank更直接,它本身就是同形方法:
orders["rank_in_region"] = orders.groupby("region")["amount"].rank( ascending=False, method="dense" )method参数值得说一句:average是默认值,并列时给平均名次,会出现 1.5 这种小数;dense是密集排名,并列后名次不跳号,做"区域前十"这类榜单时更符合直觉;min是并列都取最小名次,后面跳号。选哪个取决于业务口径,不是技术问题,但选错会导致榜单条数和预期不符。
3.3 apply 的弹性与它的代价
apply几乎什么都能做:可以返回标量、Series、DataFrame,Pandas 会根据返回类型自动拼装结果。这种弹性在处理"一次算出多个相关指标"时特别顺手,比如算完总金额、订单数、还要顺手算一个复购率。
代价有两个。第一是慢,apply是纯 Python 循环,没有任何向量化优化,分组数上万时性能会明显掉队。第二是返回结构不可预测,返回 Series 会变成多级索引的 DataFrame,返回 DataFrame 会变成三层索引,很多时候你得先打印一下shape和columns才知道自己拿到了什么。
我的经验是:能用agg表达的就别用apply,能用transform表达的就别用apply,apply留给"逻辑确实复杂、必须看整个组才能决定"的情况。真要用,也尽量在函数里返回 Series 而不是 DataFrame,返回值结构至少可控一点。
3.4 filter:按组筛选而不是按行筛选
filter常被忽略,但它解决的是一个很实际的问题:剔除样本量太小的分组。注意它和布尔索引的区别——布尔索引筛的是行,filter筛的是整组,返回的是原始表里符合条件的那些组的全部行,列结构完全不变。
big_regions = orders.groupby("region").filter(lambda g: g["qty"].sum() >= 10)传进去的函数收到的是子 DataFrame,必须返回布尔值。返回True的组整组保留。这个用法在做分组对比分析前特别有用,样本太小的组留在里面会拉低整体结论的可信度。缺点是它同样走 Python 循环,分组数特别多时不如先算组统计量再isin筛选来得快。
4. 透视表与交叉表:二维汇总怎么不写错
到了要出二维报表的时候,pivot_table和crosstab就该上场了。这两个方法底层都是分组聚合加unstack,理解这层关系,参数就好记了。
4.1 pivot_table 参数的逐个拆解
核心参数就六个:values是要汇总的指标列,index是行维度,columns是列维度,aggfunc是聚合函数,fill_value是空格填充值,margins控制是否加合计。默认aggfunc是mean,这一点坑过很多人——不写聚合函数,出来的是平均值而不是你想要的合计。
pt = pd.pivot_table( orders, values="amount", index="region", columns="channel", aggfunc="sum", fill_value=0, margins=True, margins_name="合计", )fill_value=0建议默认加上。因为某个区域可能压根没有线下渠道的数据,透视出来是NaN,导出到表格里就是刺眼的空白,业务方第一反应是"数据丢了"。填 0 之后语义变成"该组合没有发生",更准确。
aggfunc也支持传列表,出来是多级列索引,可以做"金额求和 + 订单计数"双指标透视,但列名处理会麻烦一些,摊平的时候要小心层级顺序。
4.2 margins 小计行的三个坑
第一个坑:margins的合计行不是把上面几行加起来,而是对原始数据重新聚一次。对sum和count而言,两种算法结果一样;但对mean、median、分位数这类非可加指标,合计行是按原始明细重算的,会和你手动把各行相加的结果不一样。这不是 bug,是设计如此,但必须知道,否则对不上数时会怀疑人生。
第二个坑:margins_name默认是"All",如果中文报表里不接受这个值,一定要显式改,否则下游脚本按"合计"取行取不到。
第三个坑:加了margins=True之后,最后一行一列的索引值不在原来的类别里,后续如果要做loc切片或者按行画图,得先把合计行切出去单独处理,不要混在明细里一起算增长率。
4.3 crosstab 做频次与结构占比
crosstab更偏"计数"场景,最常见的是做交叉频次表和结构占比。只传两个 Series 就是频次统计,加values和aggfunc就能做数值汇总,加normalize直接出占比:
pd.crosstab(orders["region"], orders["channel"], margins=True) pd.crosstab(orders["region"], orders["channel"], normalize="index")normalize的取值决定分母:"index"是每行合计为 1,看的是"每个区域内部渠道结构";"columns"是每列合计为 1,看的是"每个渠道的区域分布";True或"all"是全体合计为 1。选错分母,结论方向完全相反,这是写交叉表最容易翻车的地方。我一般会在列名或者图标题里明确写上"行占比",防止读者误读。
4.4 多级索引列的摊平
透视之后列是 MultiIndex,直接存 CSV 会变成两层表头,很多工具读不了。摊平有两种写法,一种是把列名元组用下划线连起来,另一种是直接取第二层(当第一层只有values一个值时)。
flat = pt.copy() flat.columns = [ "_".join([str(x) for x in c]) if isinstance(c, tuple) else str(c) for c in flat.columns ] flat = flat.reset_index()用列表推导处理的时候记得过滤空字符串,因为index=False的透视表列名元组里可能有一层是空串,拼出来会有多余的下划线。这个小细节不影响功能,但影响下游按列名匹配的稳定性,值得顺手处理掉。
5. 分组键与索引的处理细节
聚合结果"数值对但用不了"的情况,十有八九是索引没处理好。这部分没有技术难度,但特别费时间。
5.1 多列分组后的 MultiIndex 结构
groupby(["region", "channel"])出来的索引是两层,顺序就是你传列表的顺序。这个顺序会一直带到后面的unstack、reset_index、merge里,所以传参顺序不是随便写的,得按你最终想要的层级来排。
多级索引取值有两种方式:loc用元组,xs用level参数。前者直观一些,后者在层级较深时更好用。我个人的习惯是尽早reset_index摊成普通列,因为多级索引在merge、concat、条件筛选中都容易出意外,尤其是两个都是多级索引的表做连接,对齐规则会变得很难预期。
5.2 as_index=False 与 reset_index 的取舍
groupby(..., as_index=False)能让聚合结果直接就是普通列,省掉一次reset_index。但要注意它对命名聚合之外的写法也生效,字典聚合出来的多级列名依然存在,只是索引变了。也就是说as_index=False解决的是行索引问题,不解决列层级问题。
reset_index的优势是可以控制drop=True丢弃旧索引,也可以指定names给新列改名。什么时候用哪个?如果这条链路上后续不做按组索引取值,直接as_index=False更简洁;如果中途需要loc定位某个组,就先留着索引,最后再reset_index。
5.3 三个默认值:dropna、observed、sort
dropna控制分组键里的空值要不要单独成组。较新的 Pandas 版本里默认是True,也就是空值所在的行会被整行丢弃,不参与任何聚合。这在做数据质量核查时非常危险——你以为统计了全部订单,其实悄悄少了一批。做完整性校验时建议显式写dropna=False,让空值变成一个独立的组,一眼就能看到有多少行缺键。
observed只对 Categorical 类型的分组键生效。默认False时,即使某个类别在当前数据里一次都没出现,也会在结果里占一行(值为 0 或 NaN)。新版 Pandas 对这个默认值有调整并给出过警告,稳妥做法是不管什么版本都显式写observed=True,除非你确实需要补齐所有类别。
sort默认True,会对分组键排序。排序在分组数很大时有明显开销,如果你后续不需要按顺序遍历,写sort=False能省下一点时间。这个优化单独看收益不大,但在循环里跑几十次就有感觉了。
5.4 聚合结果回接原表的两种路子
回到明细表有两种方式:transform和merge。判断标准很简单,能一次transform出来的就别merge。需要多列、多函数的复杂结果,走merge更清晰,但要保证连接键的列名和类型一致,尤其是分组键里有整数和字符串混用的情况,merge会静默匹配不上然后给你一堆空值。
merge的时候我会习惯性加上validate="many_to_one",让 Pandas 帮我检查右表是不是真的每个键只有一行。多加这一行代码,能提前拦住"维度表有重复导致结果翻倍"这类最难查的问题。
6. 性能与排查:跑得动、跑得对
代码写对只是及格线,跑得动、跑得稳才算过关。这一层是分组聚合性能问题最集中的地方。
6.1 几条立竿见影的提速路径
第一条,先选列再分组。df.groupby("k")["a"].sum()比df.groupby("k").sum()快不少,因为后者会尝试对所有数值列做聚合,白算了一堆用不上的。列数越多差距越明显。
第二条,能用内置函数就用内置函数。字符串函数名走的是优化路径,lambda 是 Python 逐组调用。一个简单的quantile(0.9)用字符串传不了,但可以用agg里内置的"median"先顶上,实在不行再自定义。
第三条,把分组键转成 Categorical。低基数的字符串列(比如只有几个取值的渠道、状态)转成 category 类型后,分组时的哈希计算开销会明显下降,内存也省。缺点是转换本身有成本,只在反复分组同一列时才划算。
第四条,避免apply返回 DataFrame。返回 DataFrame 时 Pandas 要额外做一次 reshape,开销远大于返回 Series。如果确实要返回多个值,返回 Series 是更优解。
第五条,先过滤再分组。只在部分数据上做聚合时,先用布尔索引裁掉不需要的行,再groupby,比聚合完再筛快得多。这个顺序很容易写反,尤其在链式调用里。
6.2 常见报错与现象速查表
| 报错或现象 | 常见原因 | 处理方式 |
|---|---|---|
SpecificationError: nested renamer is not supported | 对 SeriesGroupBy 传了字典,或字典值里嵌了元组 | 改成命名聚合,或先df.groupby("k")再传字典 |
ValueError: cannot insert X, already exists | reset_index时新列名和已有列重名 | 先drop原列,或用rename改名 |
KeyError: 'xxx' | 聚合后列名是多级索引,按单层名取不到 | 先摊平列名再取 |
FutureWarning: observed=False is deprecated | Categorical 分组键未显式指定 | 显式写observed=True |
| 结果行数比预期少 | 分组键有空值被dropna丢弃 | 显式dropna=False检查缺键比例 |
标准化后出现NaN | 单元素组的std为NaN | 用fillna(0)兜底,或先过滤小组 |
ValueError: Function does not reduce | transform里传了返回标量以外形状的函数 | 改用agg,或调整函数返回等长序列 |
| 结果数值翻倍 | merge时右表键重复 | 加validate="many_to_one"提前拦住 |
6.3 我踩过的几个坑
第一个坑是加权均价。前面提过一次,值得再说一遍,因为它在报表里极难自查。只要指标涉及"比率"或"单价",先想清楚分子分母的聚合顺序,能拆成sum相除的就别用mean。这个错误不会报错,只会给你一个看起来合理的错数。
第二个坑是margins合计行参与后续计算。加了合计行之后表的最后一行不是真实分组,如果直接拿去做同比环比或者排序,会把"合计"当成一个区域排进去。处理办法是透视完立刻把合计行iloc[:-1]切出来单独放,或者干脆先不加margins,在展示层再补。
第三个坑是transform和索引乱序。transform的结果是按位置对齐回原表的,理论上安全,但如果中途做过sort_values又做过reset_index(drop=True),某些旧版本的链式调用里出现过对齐异常的边缘情况。我的保守做法是:transform的结果赋值成新列时,尽量在同一个表达式里完成,不要中间插别的操作。
第四个坑是聚合后的空组。observed=False加 Categorical 分组键,会把没数据的类别也列出来,值为 0;如果你用 0 去做除法算占比,会得到一堆inf。这类结果不会报错,但画图时坐标轴会直接崩掉。看到图不对劲,先查一下分组结果里有没有全是 0 的行。
我个人的体会是,分组聚合这一层真正的门槛不在 API 数量,而在"先想清楚要什么形状、再决定用哪个方法"这个习惯。写之前花三十秒在纸上画出目标表的行列,比事后调半天列名划算得多。真遇到结果对不上的情况,第一件事永远是打印shape和columns,绝大多数问题在这一步就暴露了,剩下的才是逻辑问题。