第15讲 分组与聚合

📎 配套代码第15讲_分组与聚合.py
📊 配套数据data/quote/ data/industry/ —— 交易数据、行业数据(沪深300 × 2024 年以来,仓库自带,开箱即跑)

🎬 开场:把 17 万行压成一张股票画像表

手上是样本行情,17 万行、300 只股票。你想要的是每只股票一行的汇总:均价多少、波动多大、总共成交了多少钱、有多少个交易日。

df.groupby("ts_code").agg(
    均价     = ("close",   "mean"),
    波动     = ("pct_chg", "std"),
    总成交额 = ("amount",  "sum"),
    天数     = ("close",   "count"),
)
                 均价      波动       总成交额   天数
ts_code
000001.SZ  11.354498  1.341626  1.044170e+09   816
000002.SZ   9.149277  2.255900  9.741446e+08   816

17 万行变成 300 行,四个指标一次算完,列名就是你要的中文名。

这就是 groupby 的第一种出口:聚合——把每一组压缩成一行。第 07 讲提过 groupbyagg/transform/apply 三个出口,这一讲把 agg 这条路走完整,下一讲讲 transform


🎯 这一讲结束时,你能

  • 用五种不同形式的分组键:列名、多列、派生列、外部 Series、索引级别
  • 用命名聚合一次产出多个指标,并直接得到想要的列名
  • 判断什么时候该把 lambda 拆成内置聚合,以及这件事值不值得
  • 避开分组键缺失导致数据静默丢失的坑
  • 分清 sizecount,用 filter 按组的整体性质筛选

一、🧰 分组键可以是什么

groupby 接受的不只是列名。同一份数据,五种分法:

df.groupby("ts_code")                              # 单列
df.groupby(["industry", "market"], observed=True)  # 多列
df.groupby(df["trade_date"].str[:4])               # 外部 Series(按年)
df.groupby(df["trade_date"].str[:6])               # 外部 Series(按月)
mi.groupby(level="ts_code")                        # 索引级别(第 12 讲)

真实组数:

单列 ts_code             300 组
多列 industry+market      86 组
按年                       3 组
按月                      29 组
索引级别                 300 组

用外部 Series 分组这一条很实用——你不需要先造一列出来。想按月分组,直接把日期切一段传进去就行,不用先 df["月"] = ... 污染原表。

多列分组记得 observed=True(第 11 讲)——分类列不写它会生成全组合的空组。


二、🧰 agg:一次算多个指标

四种写法,从简到繁:

df.groupby("ts_code")["close"].mean()                        # ① 单个聚合
df.groupby("ts_code")["close"].agg(["mean", "std", "count"])  # ② 一列多个指标
df.groupby("ts_code").agg({"close": "mean", "vol": "sum"})    # ③ 不同列不同聚合
df.groupby("ts_code").agg(                                    # ④ 命名聚合
    均价 = ("close", "mean"),
    波动 = ("pct_chg", "std"),
)

命名聚合是最值得用的一种

写法是 新列名 = (源列, 聚合方式)。它的好处是结果直接就是你要的形状和列名,不用事后 rename 或者处理多层列名。

对照写法②的结果,列名会是 ("close", "mean") 这样的两层结构,后续取用很别扭。

一次算完 vs 分次算

agg 列表一次算 5 个指标      6 ms
分 5 次各算一个             19 ms      → 一次算完快 3.0 倍

分组这件事本身是有成本的——把 17 万行按 300 个键归位。一次 agg 里算五个指标,这个成本只付一次;分五次调用,就付了五次。

TIP: 🚀 需要多个指标时,写在一个 agg
这不只是省时间,也让代码意图更清楚:这几个指标是同一次汇总的产物。


三、🤔 内置聚合 vs lambda:代价没有想象中大

第 07 讲讲过 apply(axis=1) 比向量化慢 1585 倍。groupby 里的 lambda 是不是也这么惨?实测:

分组键 组数 内置 .mean() agg(lambda x: x.mean()) 倍数
industry 66 3.6 ms 4.7 ms 1.3×
ts_code 300 4.0 ms 7.6 ms 1.9×
trade_date 574 2.9 ms 9.4 ms 3.2×

只慢一到三倍,和 1585 倍完全不是一个量级。

原因在于往返 Python 的次数

  • apply(axis=1)每行调用一次 Python 函数——100 万行就是 100 万次。
  • groupby().agg(lambda)每组调用一次——300 个组就是 300 次,而每次处理的是几百行数据。

分组数越多,倍数越大(1.3 → 3.2),正是因为往返次数在增加。如果你的分组数接近行数,代价就会向 apply(axis=1) 靠拢。

IMPORTANT: 🔑 结论要分场合,别一概而论
“别用 lambda”这句话在 apply(axis=1) 上是必须遵守的,在 groupby 上只是能改就改
判断依据是组数:几百个组,lambda 完全可以接受;几百万个组(比如按 (股票, 日期) 分组),那就要认真对待了。

但”拆成内置”仍然经常值得

有些聚合看起来内置做不到,比如成交额加权均价

slim = df"ts_code", "close", "vol"      # 先取窄,理由见下

# 直觉写法:逐组算
slim.groupby("ts_code").apply(
    lambda g: (g["close"] * g["vol"]).sum() / g["vol"].sum(),
    include_groups=False)                                        # 17.4 ms

# 拆开:先造中间列,再用两个内置 sum
(slim.assign(v=slim["close"] * slim["vol"])
     .groupby("ts_code")"v", "vol".sum()
     .eval("v / vol"))                                           # 6.4 ms  → 快 2.7 倍

关键在于加权平均可以拆成”两个求和再相除”,而求和是内置的。

WARNING: ⚠️ slim 那一步不能省
assign复制整张表再加一列。直接在原来那张 8 列的宽表上做,省下的时间会被复制吃掉一部分。
造中间列之前先把表取窄,这是一个通用的习惯。

TIP: 🚀 遇到”内置做不到”时,先想能不能拆
很多组内计算都能拆成几个内置聚合的组合:

  • 加权平均 → sum(x*w) / sum(w)
  • 方差 → mean(x²) - mean(x)²
  • 相关系数 → 几个 sum 的组合
    拆不开的(组内回归、组内排序取特定行)才用 apply,那是第 07 讲说的”确实要看整个子表”的情况。

四、🐛 分组键缺失,那些行会被静默丢掉

df.groupby("industry")["close"].count().sum()                 # 171,347
df.groupby("industry", dropna=False)["close"].count().sum()   # 171,347

这份样本里 industry 一个不缺,两者相等。换成全市场,退市股之类查不到行业,差额就会出现。这 800 行的 industry 是缺失的(第 13 讲那批没匹配上基础信息的退市股),默认被整个排除在分组之外

这个行为和第 09 讲的 NaN 比较、第 13 讲的 merge 是同一类问题:缺失导致数据静默减少,而总数看起来仍然合理

df.groupby("industry", dropna=False)      # 缺失单独成一组

WARNING: ⚠️ 分组前先看一眼分组键的缺失

df["industry"].isna().sum()

特别是分组键来自 merge 的时候——没匹配上的行会带着 NaN 进来,然后在 groupby 这一步悄悄消失。
加起来对不上的时候,检查这里。


五、🧰 sizecount 不是一回事

df.groupby("ts_code").size()              # 171,347      算行数
df.groupby("ts_code")["pct_chg"].count()  # 171,347      算非缺失值个数

这份样本的 pct_chg 没有缺失,两者相等。一旦有缺失,差额就等于缺失行数。

  • size() 数的是行数,不管值是不是缺失,也不针对某一列
  • count() 数的是某列的非缺失值个数

算”每只股票有多少个交易日”用 size();算”有多少天有有效收益率”用 count()。两者不等就说明有缺失,这本身是个有用的信号。


六、🧰 filter:按组的整体性质筛选

前面的操作都在”算”,filter 是在”筛”——按整组的性质决定这一组要不要

df.groupby("ts_code").filter(lambda g: len(g) >= 500)
15 ms → 170,809 行,298 只股票

筛掉了交易天数不足 500 天的股票(上市晚的次新股、中途退市的)。这在构造股票池时很常用——样本期不足的标的会污染统计结果

但通常有更快的写法

df[df.groupby("ts_code")["close"].transform("size") >= 500]
7 ms → 170,809 行      → 快 2.2 倍

结果完全一样。transform("size") 把每组的大小广播回每一行,再做一次普通的布尔筛选——全程走内置,不需要每组调一次 Python 函数。

TIP: 🚀 filter 好读,transform 好用
条件简单时(组大小、组均值超过阈值)用 transform 更快。
条件复杂到写成 transform 反而绕的时候,filter 的可读性更值钱——几百个组的表上,那点时间差不重要。
transform 是下一讲的主角。


🏋️ 训练营

QUESTION: 🟢 训练 1:给一份行情表,按股票分组,一次算出:交易天数、均价、收益率标准差、总成交额,列名分别为”天数/均价/波动/成交额”。
TIP: 👉 参考

df.groupby("ts_code").agg(
    天数   = ("close",   "size"),
    均价   = ("close",   "mean"),
    波动   = ("pct_chg", "std"),
    成交额 = ("amount",  "sum"),
)

用命名聚合直接拿到中文列名,不用事后 rename
注意”交易天数”用 size 而不是 count——前者数行数,后者数非缺失值。如果这只股票某天停牌导致 pct_chg 缺失,两者会不一样。

QUESTION: 🟡 训练 2:下面两段代码结果相同。指出哪个更快、为什么,以及什么情况下这个差别会变得重要。

# A
df.groupby("ts_code")["close"].agg(lambda x: x.mean())
# B
df.groupby("ts_code")["close"].mean()

TIP: 👉 参考
B 更快,实测 300 个组上快 1.9 倍(4.0 ms vs 7.6 ms)。
原因:A 每组往返一次 Python,B 全程在编译好的代码里跑完。
什么时候重要:差别随组数增长。66 个组时只差 1.3 倍,574 个组时 3.2 倍。如果你按 (股票, 日期) 分组——那是几百万个组——差别会大得多。
但也要说清楚:这和第 07 讲 apply(axis=1) 慢 1585 倍完全不是一回事。那个是每行一次 Python 调用,这个是每组一次。“别用 lambda”在 apply(axis=1) 上是必须,在 groupby 上是能改就改。

QUESTION: 🔴 训练 3:你要构造一个可用的股票池:剔除样本期不足 500 天的股票、剔除行业信息缺失的股票,然后按行业统计每个行业的股票数和平均日收益。写出实现,并指出两处容易静默丢数据的地方。
TIP: 👉 参考

print(df["industry"].isna().sum())                          # ① 先看清楚

pool = df[df.groupby("ts_code")["close"].transform("size") >= 500]
pool = pool[pool["industry"].notna()]                        # ② 显式剔除

res = pool.groupby("industry", observed=True).agg(
    股票数 = ("ts_code",  "nunique"),
    日收益 = ("pct_chg",  "mean"),
    样本数 = ("pct_chg",  "count"),
)

两处静默丢数据的地方
① 分组键缺失groupby("industry") 默认丢掉 industry 为 NaN 的行。这里的做法是先打印缺失数量、再显式过滤——让剔除成为一个有意识的动作,而不是 groupby 的副作用。
countsize 的差:结果里同时输出 股票数nunique)和 样本数count)。如果 样本数 明显小于”股票数 × 天数”,说明 pct_chg 有大量缺失,这时候 日收益 的均值是在一个比你以为的更小的样本上算的。
加分项:nunique 是个容易被忽略的聚合——统计”每组有多少个不同的值”,做行业股票数、每日活跃标的数这类统计时常用。


🐛 常见坑

  • ⚠️ 分组键缺失的行被静默丢弃groupby 默认 dropna=True。分组键来自 merge 时尤其要检查。
  • ⚠️ sizecount 混用:前者数行数,后者数某列非缺失值。两者不等说明有缺失。
  • ⚠️ 多个指标分多次 agg:分组成本付了好几遍,一次写完快 2.9 倍。
  • ⚠️ 多列分组忘了 observed=True:分类列会生成全组合空组(第 11 讲)。
  • ⚠️ agg(["mean","std"]) 得到两层列名:后续取用麻烦,改用命名聚合直接产出想要的列名。
  • ⚠️ 能拆成内置的聚合硬写 apply:加权平均拆成两个 sum 再除,快 2.7 倍。
  • ⚠️ 在宽表上 assign 造中间列:会复制整张表,把省下的时间吃掉。先取窄再造列。
  • ⚠️ apply(axis=1) 的性能结论套到 groupby:后者的 lambda 代价只有一到三倍,组数不大时不必纠结。
  • ⚠️ filter 用在简单条件上transform 加布尔筛选更快,结果一样。

✍️ 作业

  1. 对本地行情表用五种分组键各分一次组(单列、多列、按年、按月、索引级别),打印各自的组数,确认它们符合你的预期。
  2. 用命名聚合一次产出至少四个指标,再用循环分四次各算一个,比较总耗时。解释为什么一次算完更快。
  3. 同一个聚合分别用 .mean().agg("mean").agg(lambda x: x.mean()) 三种写法计时,记录倍数。再换一个组数差十倍的分组键重做一遍,观察倍数怎么变。
  4. 找出你数据里分组键有缺失的列,分别用 dropna=Truedropna=False 分组,比较覆盖的总行数,确认差额等于该列的缺失行数。
  5. 对同一列同时算 size()count(),找出两者不等的组,检查这些组里是哪些行的值缺失了。
  6. 思考题:本讲说加权平均可以拆成两个 sum 再除。那么组内的标准差能不能也拆成内置聚合?(提示:方差等于平方的均值减去均值的平方。这个拆法在数值上有什么隐患?)

🔮 下讲预告:第 16 讲——transform 与组内变换。这一讲的 agg 把每组压成一行,下一讲的 transform 返回和原表一样长的结果——每一行配上它所属组的统计量。行业中性化、截面标准化、组内排名、组内填充,量化里这些操作全靠它。第 07 讲给过一行对照表,下一讲把这条路走完整。


← 上一讲  ·  返回课程  ·  下一讲 →