☰
Pandas groupby分组聚合、变换与性能优化实战
2026/11/2 17:31:05 网站建设 项目流程

1. 数据分组这件事,为什么总让人手忙脚乱

数据分组这四个字,听起来像是表格软件里点两下筛选的事,可真到了写代码的阶段,十个里有八个会在groupby上卡一下。我带的几个新人里,几乎每个人第一次做“按城市统计销售额”这种需求时,都会先写一段 for 循环加字典累加,跑完发现结果对不上,再花半小时 debug。问题不在于他们不会写循环,而在于没有建立起“分组—计算—合并”这个思维模型。groupby这个 Python 里最常用的数据处理动作,本质上就是把一件事拆成三步做:先按某个规则把数据切成若干堆,再对每一堆独立算一遍,最后把结果拼回来。理解了这个模型,剩下的全是配方问题。

这篇内容面向的是已经有 Python 基础、会用一点 pandas,但每次遇到“分组统计”“分组内排名”“分组后填充”这类需求就要去翻文档的人。我会把groupby从设计思路到实操细节拆开讲,包括分组键的五种写法、agg/transform/apply/filter四个动作到底该选哪个、多级分组怎么做透视、几百兆数据下怎么把它跑快,以及我自己踩过的那些坑。所有代码都可以直接复制到本地跑,数据用模拟的订单流水,不涉及任何真实业务。

先说结论:groupby真正难的从来不是语法,而是你到底想要什么形状的结果。想要一行一组的汇总表,用agg;想要和原表等长的新列,用transform;想要分组内的前 N 条明细,用apply或nlargest;想要按组的整体特征筛掉一批组,用filter。选错动作,代码能跑通但结果一定是错的,而且错得很隐蔽。下面我们一层层拆。

1.1 手工分组的三种典型写法,以及它们各自的毛病

在正式讲groupby之前,先看看没有它的时候大家是怎么干的,这样对比下来你才知道它省掉的到底是什么。第一种写法是用字典加循环,遍历每一行,把键相同的数据塞进同一个列表。这种写法逻辑最直白,新手最容易想到,但它的问题是必须把全部数据先读进内存再逐行处理,十万行数据还行,一百万行就开始明显卡顿,而且代码里全是if key not in d这种样板,写完自己都不想看第二遍。

第二种写法是先把数据按键排序,再用itertools.groupby顺序扫一遍。这个思路其实很正统,itertools.groupby就是干这个的,性能也好,但它有个致命的隐含前提:数据必须已经按分组键排好序。如果没排序,同一个键的数据分散在文件的不同位置,groupby会把它当成好几个不同的组,结果就是分组数莫名其妙变多。我在早期处理日志文件时被这个坑过整整一个下午,最后发现是上游导出的数据顺序变了。

第三种写法是先set出所有唯一键,再对每个键做一次全表过滤。这种写法代码短,但复杂度是 O(键数 × 行数),键一多就直接爆炸,属于能跑但绝对不该上生产的类型。三种写法各有各的适用场景,但共同的问题是:它们都只解决了“分组”,没解决“分组之后干什么”。而实际需求里,分组只是开头,后面跟着的求和、求均值、求每组最大值所在的那一行、按组标准化,才是真正的工作量。groupby的价值就在于把这些后续动作标准化了,让你不用每次都重新造轮子。

1.2 groupby 的抽象模型:拆分、计算、合并

groupby的设计遵循一个非常经典的范式,叫拆分—计算—合并。拆分阶段,pandas 会根据你给的键,在内部建立一张“键 → 行索引列表”的映射表,这个过程只遍历一次数据,复杂度是 O(n)。计算阶段,你把一个函数交给它,它对每一组的数据分别执行,彼此完全独立,天然适合并行。合并阶段,pandas 负责把每组的返回值拼成一个新的结构,拼接方式取决于你用的是哪个动作。

这个模型最妙的地方在于,计算阶段和合并阶段是解耦的。同一份分组结果,你可以拿去做求和(返回标量,合并成一行一组),也可以拿去做标准化(返回等长序列,合并成和原表一样长),还可以拿去做筛选(返回布尔值,合并成原表的子集)。这就是为什么groupby后面能接那么多方法,它们其实是同一个模型的不同出口。

提示:groupby对象本身是惰性的,你写下df.groupby("city")的时候什么计算都不会发生,它只是记录了“按 city 分组”这件事。真正的计算发生在你调用sum()、agg()、apply()的那一刻。所以调试时如果觉得某一步特别慢,先确认是不是在这里触发了计算。

理解了这一层,你会发现很多“奇怪”的行为都变得合理了。比如为什么groupby之后head()返回的是每组前几行而不是整个表的前几行,为什么groupby().sum()的分组键会跑到索引上去,都是这个模型直接推出来的结果,不是随意的设计。

1.3 三个工具怎么选:pandas、itertools、手写字典

实际工作里能用来做分组的不止 pandas 一家,选错了工具会多花很多冤枉时间。我整理了一张对照表,按我自己的使用经验标了适用场景:

工具返回结构适用场景主要限制
pandas.groupbyDataFrame / Series表格数据分析、需要接聚合统计单机内存,超大数据吃力
itertools.groupby迭代器流式处理、日志逐行扫描、超大文件必须先按相同键排序
手写字典累加自定义逻辑极简、依赖少的小脚本代码冗长,扩展性差
SQL / 数据库结果集数据本来就在库里、量特别大需要额外环境

我的判断标准很简单:数据已经是 DataFrame,就用 pandas 的groupby,不要犹豫;数据是逐行读进来的文本流、而且内存装不下,就用排序加itertools.groupby;数据在数据库里而且上亿行,直接在库里聚合,把结果拉回来。最忌讳的是明明手上有 DataFrame,却非要df.values.tolist()转成列表再手写循环,白白丢掉向量化带来的几十倍性能。

还有一点值得说:现在有些场景我会考虑换引擎。当单表到千万行、分组键又特别多的时候,pandas 的groupby会明显吃内存,这时候 DuckDB 直接在 DataFrame 上跑 SQL 聚合,或者用 Polars 的group_by,速度会好不少。但这属于优化阶段的事,先把手上的groupby用明白,比换引擎重要得多。

2. 拆开 groupby 的四个核心动作:分组、聚合、变换、过滤

很多人学groupby的方式是背 API,sum会了、mean会了、agg会了,然后就卡住。正确的学习路径是先搞清楚动作的分类,再去记具体方法名。我把所有groupby之后能接的操作归成四类:聚合(把一组压成一个值)、变换(一组变一组、长度不变)、过滤(按组整体特征决定留不留)、以及带任意逻辑的应用(什么都能干,但最慢)。四类的区别用一句话就能说清:结果的行数变了没有,变了是怎么变的。

聚合会让行数变少,一组一行;变换行数完全不变,输出的长度和输入一样;过滤也是变少,但保留的是整组。而apply是个万能口袋,你让它返回什么形状它就能拼成什么形状,代价是慢、而且返回结构不稳定,容易出意外。实际写代码的时候,我给自己定的规矩是:能用agg就不用apply,能用transform就不用apply,只有在“每组返回不定长结果”的时候才动apply。按这个规矩写出来的代码,性能稳定,可读性也好。

2.1 分组键的五种写法,以及多键分组后的索引问题

分组键是groupby的第一个参数,它的灵活程度超出很多人想象。最常用的是传列名字符串,df.groupby("city"),简单直接。第二种是传一个和原表等长的 Series 或数组,比如你按另一张表映射过来的区域划分来分组。第三种是传函数,df.groupby(lambda i: i % 7)这种,一般用在按索引规律分组,实战里不多见但确实有用。

第四种是传字典或 Series 做映射,比如手上有一张城市到省份的对照表,df.groupby(province_map),pandas 会按索引把城市映射成省份再分组。这个用法在“维度需要重新归类”的时候特别好使,不用先去merge一张新表。第五种是传列表,df.groupby(["province", "city"]),多键分组,这个是最常用的进阶写法。

多键分组有个必须知道的行为:结果会变成 MultiIndex(多级索引)。如果你直接reset_index()或者一开始就加as_index=False,多级索引会展开成普通列;如果不处理,后面取值会很难受,得用xs或者元组索引去访问。我个人的习惯是一律写as_index=False,除非我明确要拿多级索引去做unstack转透视表:

import pandas as pd import numpy as np rng = np.random.default_rng(42) n = 20000 df = pd.DataFrame({ "order_id": np.arange(1, n + 1), "province": rng.choice(["华东", "华南", "华北", "西南"], n), "city": rng.choice([f"C{i}" for i in range(12)], n), "channel": rng.choice(["线上", "门店", "代理"], n), "amount": rng.gamma(2.0, 120.0, n).round(2), }) # 扁平结构,方便直接导出 res = df.groupby(["province", "city"], as_index=False)["amount"].sum() print(res.head())

顺带说一个细节:分组键里如果有NaN,pandas 默认会把这一整行丢掉,而且不报警告。你的总金额和原始表对不上,很多时候就是这里丢的。要保留空值组,显式加dropna=False,这是 pandas 1.1 之后才有的参数,老版本只能先fillna。

2.2 agg 聚合:一組一行,以及命名聚合这个真香写法

聚合是最常用的动作,sum、mean、count、nunique、max、min、std这些都是内置的,直接点出来就行。多个指标一起算,用列表传进去,结果是多级列名。但这种写法的问题是列名不好看,amount下面挂着sum、mean、count,导出去给业务方看还得手动改名。

真正好用的是命名聚合(pandas 0.25 之后支持),用关键字参数的形式写新列名=("原列名", "函数"),一次搞定计算和改名,结果还是扁平的列名,直接就能导出:

summary = df.groupby("province", as_index=False).agg( 订单数=("order_id", "nunique"), 总金额=("amount", "sum"), 客单价=("amount", "mean"), 最大单笔=("amount", "max"), )

这个写法有个坑要注意:命名聚合里不能对同一个源列的两个不同函数使用同一个输出列名,也不能和普通的位置参数混用,比如agg("sum", 新列=("a","mean"))会直接报错。要么全用命名形式,要么全用列表形式。

另外两个高频参数值得单独提。observed=True在分组键是 category 类型时非常关键,不加的话 pandas 会把所有类别组合都算一遍,哪怕某些组合在数据里根本不存在,结果里就冒出一堆 0 行,还特别慢。sort=False能省掉分组后的排序开销,如果你不关心输出顺序,加上它在大数据量下能省个百分之十几的时间。

2.3 transform 变换:结果和原表等长,这是最容易被忽略的利器

我见过太多人用groupby().agg()算完每组均值,然后再merge回原表去算差异,其实一行transform就够了。transform的特点是返回的 Series 和原表长度一样、索引一致,可以直接赋值成新列,这是做组内对比、组内占比、组内标准化的标准姿势。

# 每组均值,直接广播回每一行 df["province_avg"] = df.groupby("province")["amount"].transform("mean") # 组内占比 df["share_in_province"] = df["amount"] / df.groupby("province")["amount"].transform("sum") # 组内排名 df["rank_in_province"] = df.groupby("province")["amount"].rank(ascending=False, method="dense") # 组内标准化 g = df.groupby("province")["amount"] df["zscore"] = (df["amount"] - g.transform("mean")) / g.transform("std")

transform支持的函数范围比agg窄一些,内置的统计函数都能用,自定义函数也行,但自定义函数必须返回标量或者和组等长的序列,返回值长度不对会直接抛错。这一点比apply严格,但正是这种严格让它的结果可预测。我在实际项目里的经验是:凡是“算出来要拼回原表”的需求,先想transform,想不出来再考虑apply。

注意:transform里的自定义函数拿到的是一列(Series),不是整个子 DataFrame。如果你想在函数里同时用两列,得用df.groupby(...)[["a","b"]].transform(...),或者在函数外面先把需要的列算出来。这个限制经常让新人困惑,其实是为了保证返回长度可控。

2.4 apply 与 filter:什么时候必须用它们

apply是万能钥匙,也是性能杀手。它把每一组的子 DataFrame 完整交给你,你想返回什么形状都行:返回一个标量,结果是一行一组;返回一个 Series,结果是一行一组多列;返回一个 DataFrame,结果会拼成一个更大的表。最典型的必须用apply的场景是“每组取金额最大的前三条明细”,因为结果是不定长的,agg和transform都表达不了。

top3 = df.groupby("province", group_keys=False).apply( lambda g: g.nlargest(3, "amount"), include_groups=False, )

这里有两个参数必须说清楚,因为它们经常导致报错或者警告。group_keys=False控制分组键要不要加进结果的索引,不加的话结果就是原表的行结构,加的话会多一层索引,取数的时候很别扭。include_groups是 pandas 2.2 引入的参数,用来控制传进函数的子 DataFrame 里要不要包含分组键列,默认行为在版本间变过,早期版本会给你一个FutureWarning提示。如果你的 pandas 版本低于 2.2,直接写lambda g: g.nlargest(3, "amount")就行,同时可以g = g.drop(columns=["province"])手动剔掉,避免分组键莫名其妙出现在结果里。

filter的用法很直白:给一个返回布尔值的函数,返回 True 的组整体保留,返回 False 的组整体丢弃。它和query的区别在于,filter的判断依据是组的整体特征,比如“订单数大于 500 的城市”“退货率低于 5% 的渠道”。这类需求如果先聚合再筛再 join 回去,要写三行;用filter一行就完了:

active = df.groupby("city").filter(lambda g: len(g) >= 800)

filter的代价是它需要把每组都完整过一遍,数据量大的时候不便宜。如果筛选条件能表达成聚合结果上的条件,我更倾向先agg拿到符合条件的键集合,再用isin过滤,这样通常更快,也更容易读懂。

3. 完整实操:从原始流水到多维报表的一条龙流程

光讲语法没意思,这一章我把一个完整的需求走一遍:手上有一份两万行的订单流水,需要产出三个东西——按省份的汇总、按省份加渠道的交叉表、每个省份金额前五的明细,最后导成一个多 sheet 的文件。这个流程基本覆盖了日常八成的分组需求,你可以把数据换成自己的,代码结构直接复用。

先说明一下数据的形态,这是我自己造的一份模拟数据,字段包括订单号、省份、城市、渠道、下单时间和金额,金额用 gamma 分布生成,这样有长尾,更接近真实业务里的订单金额分布。时间字段我特意做成了带时分秒的完整时间戳,因为后面要做周维度的分组,很多人这一步会把时间存成字符串,导致后面Grouper直接报错,这个坑后面会专门讲。

3.1 数据准备与清洗:分组之前必须先想清楚的三件事

分组统计出错,八成不是groupby写错了,而是分组键本身脏。我在动手分组之前一定会检查三件事:分组键有没有空值、有没有前后空格或者大小写不一致、类型对不对。这三件事任何一件出问题,结果都会莫名其妙地多出几组或者少几组,而且不会报错。

df["province"] = df["province"].str.strip() df["channel"] = df["channel"].str.strip().str.lower() # 检查空值 print(df[["province", "channel"]].isna().sum()) # 检查类型 print(df.dtypes) # 分组键的唯一值数量,数量对不上说明有脏数据 print(df["province"].nunique(), df["province"].unique())

字符串分组键最常见的两个问题,一个是“华东”和“华东 ”(多了个空格)被当成两组,另一个是大小写混用导致“线上”和“线上”看着一样实际不同。这类问题在 Excel 里肉眼看不出来,代码里必须显式清洗。类型问题更隐蔽,如果省份列里混进了数字或者None,分组结果的组数就会多出几个奇怪的键。

清洗完之后,我习惯先看一眼每个键的行数分布,这一步能提前发现很多问题:

print(df["province"].value_counts(dropna=False)) print(df["channel"].value_counts(dropna=False))

如果某个键的行数只有个位数,而其他键都是几千,那基本可以确定是脏数据或者测试数据混进来了。这一步花不了十秒,但能省掉后面半小时的排查。

3.2 基础分组统计与多级分组的展开

第一步产出按省份的汇总表,用命名聚合一步到位。这里我特意加了nunique去统计订单号,因为同一个订单可能有多条明细行,如果直接count会把明细行数当成订单数,这是业务口径上最常犯的错误。统计“单量”用nunique,统计“行数”用count,这两个千万别混,我在评审别人的代码时第一眼就看这个。

prov_summary = ( df.groupby("province", as_index=False) .agg( 订单数=("order_id", "nunique"), 明细行数=("order_id", "count"), 总金额=("amount", "sum"), 客单价=("amount", "mean"), 金额中位数=("amount", "median"), ) .sort_values("总金额", ascending=False) )

用中位数而不是只用均值,是我从实际数据里学到的习惯。金额这种长尾分布,均值很容易被几笔大单拉高,中位数更能反映“典型订单”的水平。两个指标放一起看,如果均值远高于中位数,说明这个省份有大客户集中,业务上和“散户为主”的省份处理策略完全不同。

第二步做省份加渠道的交叉表。这里用多级分组加unstack,把渠道这个维度转成列,得到一张真正的二维表:

cross = ( df.groupby(["province", "channel"])["amount"] .sum() .unstack(fill_value=0) )

unstack之后如果某些省份没有某个渠道的数据,会出现NaN,用fill_value=0直接填 0 更符合报表习惯。这里要提醒一点:unstack之后如果列的顺序乱了,可以用reindex按你想要的顺序重排,不要指望它自动按字母序或者按金额序排好。

如果不想用unstack,还有一个更直接的写法是pivot_table,它内部其实就是groupby加unstack,语法更贴近做报表的思维,两者选一个用熟就行:

cross2 = df.pivot_table( index="province", columns="channel", values="amount", aggfunc="sum", fill_value=0, margins=True, margins_name="合计", )

margins=True会自动加一行一列的合计,做报表的时候特别省事,不用再手动拼一行。

3.3 分组内 TopN 与组内占比的几个固定套路

第三步是每个省份金额前五的明细。这个需求用nlargest加groupby最简洁,注意group_keys=False,不然结果会带上一层省份索引,导出的时候很难看:

top5 = ( df.sort_values("amount", ascending=False) .groupby("province", group_keys=False) .head(5) .sort_values(["province", "amount"], ascending=[True, False]) )

这里先说排序再head的写法,比apply(nlargest)快很多,因为head是向量化实现的,apply是逐组调用 Python 函数。我实测过一万多行的数据,前者几乎是瞬间,后者要几百毫秒,数据量再大一个量级差距会更明显。能用向量化方法解决的,就别用apply,这条经验在groupby上体现得淋漓尽致。

组内占比也是高频需求,写法就是transform("sum")之后做除法,前面已经演示过。这里补充一个细节:如果你要算的是“组内排名第一的那条占总量的比例”,不要用transform("max"),因为max是值不是位置,遇到并列会出错,正确做法是先rank再筛:

df["rank_in_prov"] = df.groupby("province")["amount"].rank(method="first", ascending=False) first_share = ( df[df["rank_in_prov"] == 1] .assign(share=lambda d: d["amount"] / d.groupby("province")["amount"].transform("sum")) [["province", "order_id", "amount", "share"]] )

rank的method参数要留意,默认是average,遇到并列会给出 1.5 这样的平均排名,如果你想保证每组只有一条被选中,用method="first"按出现顺序打破并列,这个细节在做“取每组第一条”的需求时非常关键。

3.4 时间维度的分组:Grouper、resample 与分箱

按时间分组是另一个高频场景,也是最容易出问题的地方。先说过最常见的报错:TypeError: Only valid with DatetimeIndex。这个错误基本都是因为时间列是字符串,Grouper认不出来。解决方法是先转类型,而且一定要加errors="coerce",把转不了的脏值变成NaT,否则一条脏数据就会让整个转换失败:

df["order_time"] = pd.to_datetime(df["order_time"], errors="coerce") print(df["order_time"].isna().sum()) # 转失败的行数,必须看一眼

转好之后按周聚合,用pd.Grouper指定列名和频率,不要用resample,因为resample要求 DatetimeIndex,而我们的表是普通索引:

weekly = ( df.groupby([pd.Grouper(key="order_time", freq="W"), "province"]) .agg(订单数=("order_id", "nunique"), 金额=("amount", "sum")) .reset_index() )

freq="W"是自然周,"M"是自然月,"Q"是季度。这里有个版本差异要注意,新版 pandas 对"M"这种简写会给出弃用提示,要求写成"ME",如果你的代码在旧环境和新环境都要跑,最好先确认一下版本,或者在测试里固定住行为。

除了按自然周期分,还有一种更灵活的用法是自定义分箱,比如把金额切成若干区间再统计各区间的订单数,用pd.cut配合groupby就行:

bins = [0, 100, 300, 800, 2000, np.inf] labels = ["0-100", "100-300", "300-800", "800-2000", "2000+"] df["amount_band"] = pd.cut(df["amount"], bins=bins, labels=labels, right=False) band_stat = df.groupby("amount_band", observed=False)["order_id"].nunique()

pd.cut的right=False表示左闭右开,这个一定要明确写出来,不然边界值落在哪个区间全靠猜,不同人对“100 到 300”的理解可能不一致,导致口径对不上。observed=False在这里是故意的,因为我希望没有数据的区间也出现在结果里(显示 0),这样图表上不会缺档。

3.5 结果导出:多 sheet 一次写完

最后把三张表写进一个文件,用ExcelWriter一次搞定。这里有个小技巧,把索引去掉、列宽设置一下,业务方打开就能直接看:

with pd.ExcelWriter("report.xlsx", engine="openpyxl") as writer: prov_summary.to_excel(writer, sheet_name="省份汇总", index=False) cross.to_excel(writer, sheet_name="省份渠道交叉") top5.to_excel(writer, sheet_name="各组Top5", index=False)

cross那张表保留了索引,因为省份就是它的行标签,去掉索引反而看不出哪行是哪个省。每张表的index参数要根据表的实际结构决定,这个细节看着小,但导出的表好不好用差别很大。另外,数据量大到几十万行的时候,Excel 写入会明显变慢,超过一百万行就直接写不进去了,这时候改用 CSV 或者直接写数据库更靠谱。

4. 性能优化与踩坑实录:那些文档里不会写的细节

groupby的语法半小时就能学完,真正拉开差距的是两件事:知道什么写法会慢,以及知道报错大概是什么原因。这一章我把这些年积累下来的性能经验和问题清单整理出来,都是实际调试出来的,不是从文档里抄的。

先给出一个总的性能排序,从快到慢:内置聚合函数(sum/mean/count等)快于命名聚合快于transform内置函数,transform内置函数快于apply返回标量,apply返回标量快于apply返回 DataFrame。这个顺序不是绝对的,但大方向上不会错。每往右移一格,性能大概掉一个数量级,所以能用左边的就别用右边的。

4.1 让 groupby 跑快的六个具体动作

第一,只选需要的列。df.groupby("k").agg(...)会把整张表都传进去,如果表有五十列而你只需要三列,内存和时间的浪费是实打实的。改成df.groupby("k")[["a","b","c"]].agg(...)或者更省事的df[["k","a","b","c"]].groupby("k"),效果立竿见影。

第二,把字符串分组键转成 category。这是投入产出比最高的一招。字符串比较是逐字符比的,category 在内部变成了整数编码,分组时的哈希和比较都快得多,内存占用也能降一大截。数据量大、分组键重复度高的时候,提速非常明显:

df["province"] = df["province"].astype("category") df["channel"] = df["channel"].astype("category") # 转成 category 之后,务必加 observed=True res = df.groupby(["province", "channel"], observed=True)["amount"].sum()

这里必须强调observed=True。category 类型默认会保留所有理论上的类别组合,observed=False时 pandas 会把笛卡尔积全枚举一遍,如果你的类别是从全量历史数据里定义的,组合数可能上万,那性能不但没提升反而暴跌。这个坑我踩过一次,一个本该两秒的查询跑了四十多秒,最后就是差这一个参数。

第三,能不用 apply 就不用 apply。前面说过,apply是逐组调用 Python 层函数,每次调用都有开销。如果一个需求能用agg、transform、rank、cumsum、nlargest组合出来,一定要组合,别图省事写apply。

第四,sort=False关掉排序。groupby默认会把结果按键排序,如果你的分组键是几十万种组合,这个排序本身就占了相当一部分时间。不关心顺序的时候加上它。

第五,分块处理。数据量大到接近内存上限时,与其硬扛,不如按某个维度切块,逐块分组、把结果追加到一个列表里,最后concat。这样内存峰值可控,代码也不复杂。

第六,换引擎。当单表超过千万行、又需要频繁做多键分组时,pandas 的瓶颈会很明显。这时候用 DuckDB 直接对 DataFrame 执行 SQL 聚合,或者用 Polars 的惰性求值,通常能快几倍到十几倍。不过这是最后一步,前面的优化做完再看需不需要。

4.2 常见报错与异常结果的排查速查表

下面这张表是我这些年在groupby上遇到过的典型问题,按出现频率排序,遇到问题可以先扫一遍:

现象大概率原因处理方式
分组数比预期多键有空格、大小写不一致、混入脏值先strip+lower+value_counts检查
总数比原表少键里有NaN,默认被丢弃加dropna=False保留空值组
结果多出一堆 0 行category 键枚举了未出现的组合加observed=True
报错输出长度不匹配自定义函数返回长度不等于组长度检查函数返回值,或改用apply
transform结果没对齐索引有重复或乱序先reset_index(drop=True)再操作
时间分组报类型错误时间列是字符串pd.to_datetime(..., errors="coerce")
结果索引取不到列多键分组产生了 MultiIndex加as_index=False或reset_index()
apply出 FutureWarning新版 pandas 对分组键列入参行为变化显式传include_groups=False并按需剔列
内存瞬间爆掉整表传入 + 高基数键 + 未选列只选需要的列,转 category,分块处理
结果顺序每次不一样未指定sort,分组键是无序类型显式sort_values或sort=True

除了表里的这些,还有一个特别隐蔽的问题:结果里出现了重复的行。这种情况通常是分组键本身有重复值导致的正常行为,但也有可能是你在apply里返回了带原始索引的 DataFrame,pandas 在拼接时把索引也带上了,看起来就像多了几行。判断方法是看结果的index是不是有重复,有的话reset_index(drop=True)一下就能确认。

4.3 三条我认为最值钱的实操心得

第一条,先想清楚输出形状,再动手写代码。我现在的习惯是,写groupby之前先在纸上画一下:结果应该有几行、每行代表什么、有哪些列。这一步想清楚了,动作选哪个基本就定了,代码一遍就过。反过来,边写边试的结果往往是改来改去,最后写出一堆嵌套的apply,自己都看不懂。

第二条,永远拿一个小样本验证口径。分组统计最怕的不是报错,是静默算错。我的做法是每次写完,随机抽一个组,手工把它的原始数据筛出来,用最笨的方式算一遍,和groupby的结果对一下。这个习惯帮我抓到过好几次“用count当单量”和“把NaN组漏掉”的问题。样本量不用大,一百行足够。

第三条,别怕把中间结果落下来看。groupby的链式写法很好看,但调试的时候很痛苦。我的做法是调试阶段把每一步都赋给一个变量,print一下形状和前几行,确认没问题再合并成一条链。看着啰嗦,但比在一行长表达式里找问题快得多。等逻辑稳定了再收拾成链式写法,那时候你已经知道每一步是什么了。

5. 几个可以直接抄的分组套路

最后这一章我整理了几个高频场景的现成写法,都是我在不同项目里反复用到的。这些不是 API 教程,而是“需求来了直接改改就用”的配方,你可以根据自己的字段名套进去。

5.1 分组填充缺失值与组内累计

组内填充是数据清洗里很常见的一步,比如同一个门店的客单价缺失了,用该门店的历史均值补。用transform一步到位,注意fillna要传 Series,不能传单个标量,否则就是全表同一个值了:

df["amount_filled"] = df["amount"].fillna( df.groupby("city")["amount"].transform("mean") )

如果要的是“用组内前一个非空值往后填”,那就用ffill,但ffill本身不认识分组,得先分组再调:

df["amount_ffill"] = df.groupby("city")["amount"].ffill()

组内累计也是transform系的常用动作,cumsum、cumcount、cummax都支持分组,写法和普通版本一样,只是前面多了groupby:

df = df.sort_values(["city", "order_time"]) df["city_cum_amount"] = df.groupby("city")["amount"].cumsum() df["city_seq"] = df.groupby("city").cumcount() + 1

这里有个前提容易被忽略:累计类操作依赖顺序。如果原表没按时间排好,cumsum算出来的顺序就是错的,而且不会报错。所以我在做累计之前一定会显式sort_values,哪怕我觉得数据本来就是有序的,也要写上,这行代码是给自己看的保险。

5.2 分组去重取最新一条与多条件分组

“每个用户取最近一次下单记录”这个需求,几乎每个项目都会遇到。最直接的写法是先排序再groupby().head(1),或者用drop_duplicates配合keep="last":

latest = ( df.sort_values("order_time") .groupby("city", group_keys=False) .tail(1) )

tail(1)和head(1)的区别就是取最后一条还是第一条,配合排序用哪个都行。还有一种写法是drop_duplicates(subset=["city"], keep="last"),这个更快,但它的“最后”指的是原表顺序里的最后,不是时间上的最后,所以必须先把表按时间排好,否则结果就是错的。这两个方法的取舍我一般是:要保留组内其他计算的中间结果就用groupby,纯粹去重就用drop_duplicates。

多条件分组在实际业务里更常见,比如按“省份 + 渠道 + 金额区间”三个维度分组,写法就是把三个键放进列表,pd.cut生成的区间列也可以直接当键用。唯一的提醒是,维度一多,结果的组合数会指数增长,做报表之前先nunique看一下组合规模,别生成出一张几十万行的表把自己吓一跳。

5.3 分组结果与原表合并时的索引对齐

最后一个高频问题:分组聚合的结果怎么合并回原表。正确姿势是用transform直接生成新列,而不是agg完再merge。必须merge的时候,注意分组键的类型要一致,category 和 object 之间合并会报类型不匹配,两边都转成同一种再合。

还有一种情况是聚合结果的分组键在索引上,直接merge会失败,得先reset_index()。我一般的写法是聚合时就用as_index=False,从源头避免这个问题,这样结果天然是扁平的,后面怎么用都方便。如果你接手了别人的代码,看到merge报KeyError,第一反应就去看左边那张表的索引是不是 MultiIndex,八成就是这个问题。

# 推荐:从一开始就是扁平结构 stat = df.groupby("city", as_index=False)["amount"].mean() merged = df.merge(stat, on="city", how="left", suffixes=("", "_city_mean"))

suffixes这个参数也别忘了,不写的话同名列会自动加_x/_y后缀,后面引用列名的时候很容易写错,显式指定能省掉不少麻烦。

这套东西用熟之后,我个人的体会是:groupby的语法其实一页纸就能写完,真正需要练的是看到需求就能判断出该用哪个动作、结果长什么形状。这个判断力没有捷径,就是拿真实数据多练几遍,每次写完抽一组手工核对。我刚开始写的时候也经常用apply一把梭,后来被数据量教育了几次,才慢慢养成先想形状、再选动作、最后才考虑优化的顺序。这个顺序反过来,往往写出来的东西又快又难维护。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询