卷 II · 贴上名字CH 09深度 9/23

groupby 是拆开、各算各的、再拼回去

groupby 是 pandas 里最值钱的一个方法,也是最容易只学会一半的一个。它的模型只有三步——拆开(split)、各算各的(apply)、再拼回去(combine)——而绝大多数困惑都出在最后一步拼成什么形状上。这一章把三步拆开看,顺便算清两笔账:它比手写循环快多少,以及它会安静地丢掉什么。

快 11.0 倍agg 与 transform两行悄悄没了

▷ 先猜一下
df = pd.DataFrame({"k": ["a", "b", None, "a", None],
                   "v": [1, 2, 3, 4, 5]})
df["v"].sum()                  # 15
df.groupby("k")["v"].sum()     # ?

问:分组求和之后,所有组的和加起来是多少?

A 15。分组只是重新排列,总量不会变 B 7。键是 NaN 的两行被丢掉了 C 15,其中 NaN 自成一组,和是 8 D 报错。分组键不能有缺失值

三步模型

这个模型有个正式名字叫 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 ms11.0×Python
groupby(...).apply(lambda s: s.sum())12.0 ms8.8×Python(每组一次)
groupby(...).sum()1.4 msC

中间那一行值得盯着看。.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 语言 dplyrggplot2 的那位。这本书第 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())"

最后那个对比里,applysum 的差距会随组数变大而变大——把 1000 改成 10000 再跑一次。

▸ 在现实里
  • SQL 的 GROUP BY 与窗口函数,正好对应 aggtransformSELECT 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,参数名(indexcolumnsvaluesaggfunc)和 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 带自定义函数。往下每走一级,慢一个台阶。

◇ 揭晓

正确答案是 B7。键是 NaN 的那两行(v 为 3 和 5)被默默丢掉了。

A 「总量不会变」——对 SQL 的 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%,而报表看起来完全正常。