📎 配套代码:
第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 讲提过 groupby 有 agg/transform/apply 三个出口,这一讲把 agg 这条路走完整,下一讲讲 transform。
🎯 这一讲结束时,你能
- 用五种不同形式的分组键:列名、多列、派生列、外部 Series、索引级别
- 用命名聚合一次产出多个指标,并直接得到想要的列名
- 判断什么时候该把 lambda 拆成内置聚合,以及这件事值不值得
- 避开分组键缺失导致数据静默丢失的坑
- 分清
size和count,用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这一步悄悄消失。
加起来对不上的时候,检查这里。
五、🧰 size 和 count 不是一回事
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的副作用。
②count和size的差:结果里同时输出股票数(nunique)和样本数(count)。如果样本数明显小于”股票数 × 天数”,说明pct_chg有大量缺失,这时候日收益的均值是在一个比你以为的更小的样本上算的。
加分项:nunique是个容易被忽略的聚合——统计”每组有多少个不同的值”,做行业股票数、每日活跃标的数这类统计时常用。
🐛 常见坑
- ⚠️ 分组键缺失的行被静默丢弃:
groupby默认dropna=True。分组键来自merge时尤其要检查。 - ⚠️
size和count混用:前者数行数,后者数某列非缺失值。两者不等说明有缺失。 - ⚠️ 多个指标分多次
agg:分组成本付了好几遍,一次写完快 2.9 倍。 - ⚠️ 多列分组忘了
observed=True:分类列会生成全组合空组(第 11 讲)。 - ⚠️ 用
agg(["mean","std"])得到两层列名:后续取用麻烦,改用命名聚合直接产出想要的列名。 - ⚠️ 能拆成内置的聚合硬写
apply:加权平均拆成两个sum再除,快 2.7 倍。 - ⚠️ 在宽表上
assign造中间列:会复制整张表,把省下的时间吃掉。先取窄再造列。 - ⚠️ 把
apply(axis=1)的性能结论套到groupby上:后者的 lambda 代价只有一到三倍,组数不大时不必纠结。 - ⚠️
filter用在简单条件上:transform加布尔筛选更快,结果一样。
✍️ 作业
- 对本地行情表用五种分组键各分一次组(单列、多列、按年、按月、索引级别),打印各自的组数,确认它们符合你的预期。
- 用命名聚合一次产出至少四个指标,再用循环分四次各算一个,比较总耗时。解释为什么一次算完更快。
- 同一个聚合分别用
.mean()、.agg("mean")、.agg(lambda x: x.mean())三种写法计时,记录倍数。再换一个组数差十倍的分组键重做一遍,观察倍数怎么变。 - 找出你数据里分组键有缺失的列,分别用
dropna=True和dropna=False分组,比较覆盖的总行数,确认差额等于该列的缺失行数。 - 对同一列同时算
size()和count(),找出两者不等的组,检查这些组里是哪些行的值缺失了。 - 思考题:本讲说加权平均可以拆成两个
sum再除。那么组内的标准差能不能也拆成内置聚合?(提示:方差等于平方的均值减去均值的平方。这个拆法在数值上有什么隐患?)
🔮 下讲预告:第 16 讲——transform 与组内变换。这一讲的
agg把每组压成一行,下一讲的transform返回和原表一样长的结果——每一行配上它所属组的统计量。行业中性化、截面标准化、组内排名、组内填充,量化里这些操作全靠它。第 07 讲给过一行对照表,下一讲把这条路走完整。