groupby 是拆开、各算各的、再拼回去
groupby 是 pandas 里最值钱的一个方法,也是最容易只学会一半的一个。它的模型只有三步——拆开(split)、各算各的(apply)、再拼回去(combine)——而绝大多数困惑都出在最后一步拼成什么形状上。这一章把三步拆开看,顺便算清两笔账:它比手写循环快多少,以及它会安静地丢掉什么。
df = pd.DataFrame({"k": ["a", "b", None, "a", None],
"v": [1, 2, 3, 4, 5]})
df["v"].sum() # 15
df.groupby("k")["v"].sum() # ?
问:分组求和之后,所有组的和加起来是多少?
三步模型
这个模型有个正式名字叫 split–apply–combine,它比 pandas 老得多——SQL 的 GROUP BY、MapReduce、Kotlin 的 groupBy { }.mapValues { },全是同一件事:
关键在第三步。同样是「求每组的均值」,拼回去的方式有两种,得到的形状完全不同:
df = k: a a b b b v: 1 3 10 20 30 5 行
df.groupby("k")["v"].mean() ← agg:一组一行
a 2.0
b 20.0 2 行
df.groupby("k")["v"].transform("mean") ← transform:行数不变
0 2.0
1 2.0
2 20.0
3 20.0
4 20.0 5 行
判据非常干脆:
要一张报表 → agg。要贴回原表 → transform。
agg 归约(一组变一个数),transform 广播(一组的结果铺回该组的每一行)。
「每个人的消费减去他所在城市的平均消费」这种特征,一行就能写:
df["偏离"] = df["v"] - df.groupby("k")["v"].transform("mean")
用 agg 写这件事要先算再 merge 回去,三行,还得担心 index 对齐。
它省下的时间
手写版本长这样,逻辑一目了然:
acc = {}
for k, v in zip(df["k"], df["v"]):
acc[k] = acc.get(k, 0.0) + v
20 万行、1000 个组,本机实测:
| 写法 | 耗时 | 倍数 | 循环在哪一侧 |
|---|---|---|---|
| Python 字典循环 | 15.1 ms | 11.0× | Python |
groupby(...).apply(lambda s: s.sum()) | 12.0 ms | 8.8× | Python(每组一次) |
groupby(...).sum() | 1.4 ms | 1× | C |
中间那一行值得盯着看。.apply(lambda s: s.sum()) 看起来已经是「pandas 的写法」了,实际上它比内置的 .sum() 慢 8.8 倍,只比手写字典循环快一点点。
原因就是第 1 章那条判据:Python 解释器要执行多少次?.sum() 是一次(整个分组在 C 里完成);.apply(f) 是一千次(每个组调用一次你的 Python 函数)。apply 不是向量化,它只是把 for 循环藏进了方法名里。
所以有一条实用的优先级:
# 1. 内置聚合最快,能用就用
df.groupby("k")["v"].sum() .mean() .max() .count() .nunique() .std()
# 2. 一次算多个,用 agg —— 仍然走 C
df.groupby("k").agg(总额=("v", "sum"), 笔数=("v", "size"), 峰值=("v", "max"))
# 3. 实在要自定义逻辑,才用 apply;接受它慢
df.groupby("k").apply(my_complicated_function, include_groups=False)
它会安静地丢掉什么
现在回到开头那个问题。groupby 默认丢掉分组键为 NaN 的行:
df 有 5 行,v 的总和 15
df.groupby("k").size().sum() 3 ← 少了 2 行
df.groupby("k")["v"].sum().sum() 7.0 ← 少了 8
df.groupby("k", dropna=False)["v"].sum().sum() 15.0 ✓
没有警告,没有提示。你得到的是一张看起来完全正常的报表,只是它统计的不是全部数据。
这个默认值本身是有道理的(「键都不知道,怎么分组」),但它和上一章那件事撞在一起就很危险:NaN 既表示「真的缺数据」,也表示「上一步对不上」。所以一次 merge 失败留下的 NaN,会在下游的 groupby 里变成「悄悄少了一批行」。
有一个几乎零成本的自查,值得写进每一段分组代码里:
g = df.groupby("k")["v"].sum()
assert g.sum() == df["v"].sum(), f"分组丢了 {df['v'].sum() - g.sum()}"
还有一个相关的坑,用了分类类型(category)之后会遇到:observed 参数。分类列 groupby 时,pandas 会不会把「定义了但一行都没有」的类别也列出来?在 pandas 3 里默认是 observed=True(只列出现过的),但如果你的代码要跨版本跑,最好显式写出来。
split–apply–combine 这个说法出自 Hadley Wickham 2011 年的论文《The Split-Apply-Combine Strategy for Data Analysis》——就是写 R 语言 dplyr 和 ggplot2 的那位。这本书第 11 章的「长表」和第 14 章的「图形语法」也都能追到他身上。Python 数据栈的很多设计,是从 R 那边学来的。
要区分三个容易混的方法:
agg:一组 → 一个数。结果行数 = 组数。transform:一组 → 同样多个数。结果行数 = 原行数。filter:一组 → 留或不留。结果是原表的一个子集。(例:只保留笔数大于 10 的组)
三个都接受函数,形状各不相同。搞不清用哪个时,先问「我要的结果有几行」。
形状的差别,五行:
import pandas as pd, numpy as np
df = pd.DataFrame({"k": ["a", "a", "b", "b", "b"], "v": [1, 3, 10, 20, 30]})
print(df.groupby("k")["v"].mean()) # 2 行
print(df.groupby("k")["v"].transform("mean")) # 5 行
df["偏离组均值"] = df["v"] - df.groupby("k")["v"].transform("mean")
print(df)
那个「悄悄丢行」,四行:
d = pd.DataFrame({"k": ["a", "b", None, "a", None], "v": [1, 2, 3, 4, 5]})
print(d["v"].sum(), # 15
d.groupby("k")["v"].sum().sum(), # 7.0 ← 丢了
d.groupby("k", dropna=False)["v"].sum().sum()) # 15.0 ✓
那个 11.0 倍(20 万行,跑几秒):
import time
rng = np.random.default_rng(9)
g = pd.DataFrame({"k": rng.integers(0, 1000, 200_000),
"v": rng.normal(0, 1, 200_000)})
def by_dict():
acc = {}
for k, v in zip(g["k"].tolist(), g["v"].tolist()):
acc[k] = acc.get(k, 0.0) + v
return acc
for name, fn in [("字典循环", by_dict),
("groupby.sum", lambda: g.groupby("k")["v"].sum()),
("groupby.apply", lambda: g.groupby("k")["v"].apply(lambda s: s.sum()))]:
t = time.perf_counter(); fn(); print(name, "%.1f ms" % ((time.perf_counter() - t) * 1000))
python3 -c "import pandas as pd;d=pd.DataFrame({'k':['a','b',None,'a',None],'v':[1,2,3,4,5]});print(d['v'].sum(), d.groupby('k')['v'].sum().sum())"
最后那个对比里,apply 和 sum 的差距会随组数变大而变大——把 1000 改成 10000 再跑一次。
- SQL 的
GROUP BY与窗口函数,正好对应agg与transform。SELECT city, avg(x) ... GROUP BY city是 agg(一组一行);SELECT x - avg(x) OVER (PARTITION BY city)是 transform(行数不变)。知道了这个对应,SQL 窗口函数一下就不神秘了——它就是「拼回原来的形状」的那个版本。 - MapReduce 就是这三步。map 拆、shuffle 按键归拢、reduce 各算各的。Hadoop、Spark、Flink 的核心抽象都是这个模型,只是把「拆开」这一步分到了几百台机器上。学会 groupby 的形状直觉,读分布式计算的代码会顺很多。
- Excel 的数据透视表。拖一个字段到「行」,拖一个到「值」,就是一次
groupby().agg()。pandas 里对应的方法干脆就叫pivot_table,参数名(index、columns、values、aggfunc)和 Excel 那个面板一一对应。 - 那个「悄悄丢行」在生产里的样子。每日报表的总额比原始流水少了一点,查了半天发现是某个字段有几百行
NULL。凡是「汇总之后总数对不上」的问题,先查分组键的缺失。
「groupby().apply(...) 是 pandas 的写法,所以它是向量化的、是快的。」
apply 里的那个函数,pandas 会每组调用一次——1000 个组就是 1000 次 Python 函数调用。本机实测它比内置的 .sum() 慢 8.8 倍,只比手写字典循环快 25%。
同一个误解在 df.apply(f, axis=1) 上更贵:那是每行调一次,十万行就是十万次。它读起来像是 pandas 在帮你做什么聪明的事,实际上只是把 for 循环写成了方法调用。
判据还是第 1 章那条:数一数 Python 解释器要执行多少次你的代码。顺序是:内置聚合(.sum()、.mean())→ agg 带内置函数名 → transform 带内置函数名 → 最后才是 apply 带自定义函数。往下每走一级,慢一个台阶。
正确答案是 B:7。键是 NaN 的那两行(v 为 3 和 5)被默默丢掉了。
GROUP BY 来说也不对(SQL 会把 NULL 单独归成一组,行为反而更接近选项 C)。各家工具在这件事上的默认值不一致,所以跨工具搬逻辑时特别容易出事。
C 「NaN 自成一组」——这是 dropna=False 的行为,也是 SQL 的行为,但不是 pandas 的默认值。写上这个参数,总和就回到 15。
D 「报错」——pandas 在这本书里几乎从不为数据问题报错。它的一贯风格是给你一个结果,然后用某种记号提示;而这一次,连记号都没有——被丢掉的行不会出现在任何地方。这是全书最安静的一处数据损失。
顺带说一个救命的习惯:任何一次汇总之后,加一句 assert 汇总.sum() == 原始.sum()。这一行的成本是十秒钟,能挡住的是「报表少了一批数据但没人发现」这类事故。
agg → 一组一行的报表;transform → 行数不变、可以直接贴回原表的一列。先决定要哪个形状,再选方法。
没人。分组键为 NaN 的行被静默丢弃(除非写 dropna=False)。自己检查:assert 结果.sum() == 原表.sum()。
这一章的一句话
groupby 是「拆开、各算各的、再拼回去」,全部的选择都在第三步——agg 给你一张报表,transform 给你一列能贴回原表的东西;而它默认丢掉键为 NaN 的行,是全书最安静的一次数据损失。
下一章是卷 II 的最后一章,讲另一个会静默改变数据量的操作。这次不是变少,是变多:五个客户的表和十一条订单的表一 merge,行数变成 11——因为 1 号客户下了 7 单,他的年龄被复制了 7 份。于是客户平均年龄从 40.00 岁变成了 29.09 岁,错了 27.3%,而报表看起来完全正常。