📎 配套代码:
第17讲_透视表与交叉表.py
📊 配套数据:data/quote/data/industry/—— 交易数据、行业数据(沪深300 × 2024 年以来,仓库自带,开箱即跑)
🎬 开场:只看一个维度会骗你
把 20 日动量因子按每日截面分成五档,统计各档的平均次日收益(单位:bp,万分之一):
df.pivot_table(index="动量档", values="fwd", aggfunc="mean", observed=True) * 1e4
动量档 平均收益 最低 7.91 低 6.80 中 7.20 高 6.53 最高 2.52
看起来结论很清楚:动量越高,次日收益越低——短期反转效应,而且大致单调。
现在把市值加进来,做一张二维表:
df.pivot_table(index="动量档", columns="市值档", values="fwd", aggfunc="mean", observed=True) * 1e4
市值档 小 中 大 动量档 最低 20.28 4.39 -0.32 低 11.84 7.95 0.32 中 9.51 8.82 2.72 高 10.26 4.05 4.97 最高 8.03 -5.66 5.69
小市值组:从 20.28 降到 8.03——强烈的反转。
大市值组:从 −0.32 升到 5.69——方向反过来了,是动量。
同一个因子,在小市值股票上是反转,在大市值股票上是动量。而一维的那张表把两者平均掉了,只剩下一个被小市值主导的、方向不明的”反转”结论。
单一分组会掩盖子样本之间的异质性。 透视表的价值就在这里——它让你同时看两个维度,把被平均掉的结构显出来。
这一讲讲怎么用 pivot_table 和 crosstab 做出因子分析里那几张标准表。
🎯 这一讲结束时,你能
- 用
pivot_table做双重分组的汇总表,并解释为什么它比单一分组更能看清结构 - 同时输出均值、样本数、标准差,而不是只看均值就下结论
- 用
crosstab先检查样本分布,再看统计结果 - 用”分档 × 时间”的表判断一个因子稳不稳定
- 判断什么时候该用
pivot_table、什么时候直接groupby更合适
一、🧰 pivot_table 的四个角色
df.pivot_table( index="动量档", # 谁当行 columns="市值档", # 谁当列 values="fwd", # 格子里放什么 aggfunc="mean", # 怎么汇总 observed=True, # 分类列不生成空组(第 11 讲) )
和第 14 讲的 pivot 比,多的就是 aggfunc——pivot 要求一格一值(重复就报错),pivot_table 允许一格多值,用聚合函数压成一个。
index 和 columns 都可以传列表,得到多层的行或列:
df.pivot_table(index=["industry", "动量档"], columns="市值档", values="fwd")
结果的行索引就是第 12 讲的 MultiIndex。
二、🧰 只看均值会下错结论
平均收益是最容易看的数,也是最容易误导人的数。至少要同时看样本数和离散度:
t = df.pivot_table(index="动量档", values="fwd", aggfunc=["mean", "count", "std"], observed=True)
动量档 平均收益 样本数 标准差 最低 7.91 835016 297.56 低 6.80 834531 269.45 中 7.20 834360 272.84 高 6.53 834386 298.07 最高 2.52 834725 432.12
样本数很均匀(qcut 五分位的结果,本来就该均匀)。但标准差不均匀:最高档 329,比最低的 212 高出一半还多。
这意味着最高动量组那个均值,是在一个波动大得多的样本上算出来的——它的不确定性远高于其他档。只看均值排个序,会把这个差异完全忽略掉。
IMPORTANT: 🔑
aggfunc传列表,一次把该看的都看了aggfunc=["mean", "count", "std"]或者用字典给不同列不同聚合:
aggfunc={"fwd": ["mean", "std"], "total_mv": "median"}结果的列会变成多层,用
t.columns = [...]重命名一下更好读。
三、🧰 margins:加上总计
df.pivot_table(index="动量档", columns="市值档", values="fwd", aggfunc="mean", observed=True, margins=True, margins_name="全体") * 1e4
市值档 小 中 大 全体 动量档 最低 20.28 4.39 -0.32 7.91 低 11.84 7.95 0.32 6.80 中 9.51 8.82 2.72 7.20 高 10.26 4.05 4.97 6.53 最高 8.03 -5.66 5.69 2.52 全体 11.98 3.88 2.72 6.19
多出来的”全体”行列正是开场那张一维表。把它和二维格子放在一起,一眼就能看出边际汇总掩盖了什么。
WARNING: ⚠️
margins的总计是重新聚合,不是行/列的平均
“全体”那一格是把该行所有原始观测放在一起算的均值,不是(20.28 + 4.39 - 0.32) / 3。
各组样本量不同时两者会差很多。想验证就对比一下t.iloc[:, :-1].mean(axis=1)和t["全体"]。
fill_value 用来填聚合后出现的空格:
pivot_table(..., fill_value=0)
但要谨慎——空格代表”这个组合没有样本”,填成 0 会让它看起来像”收益为零”。多数时候留着 NaN 更诚实。
四、🧰 crosstab:先看样本分布
统计结果好不好看,先要确认样本站得住。crosstab 专做计数:
pd.crosstab(df["动量档"], df["市值档"])
市值档 小 中 大 动量档 最低 270596 275359 289061 低 283378 281089 270064 中 300113 278540 255707 高 294248 272341 267797 最高 242762 283351 308612
normalize 把频数变成占比:
pd.crosstab(df["动量档"], df["市值档"], normalize="index") * 100
市值档 小 中 大 动量档 最低 32.4 33.0 34.6 低 34.0 33.7 32.4 中 36.0 33.4 30.6 高 35.3 32.6 32.1 最高 29.1 33.9 37.0
normalize 三个取值:"index"(每行占比)、"columns"(每列占比)、"all"(占总数)。
这张表告诉你一件事:最高动量档里大市值股占 36.0%,最低档里只占 28.7%,而小市值正好相反(28.9% vs 39.5%)。两个分档不是完全独立的——高动量股票里大市值偏多。
这解释了为什么必须做双重分组:如果只看动量档,你看到的差异里混着市值的影响。
TIP: 🚀 因子分析的固定动作
每次做分档统计,先跑一次crosstab看样本分布:
- 各档样本数是否均衡(
qcut应该均衡,cut常常不均衡——第 10 讲)- 分档和其他特征是否有系统性关联(有的话要做中性化,第 16 讲)
这一步花不了一分钟,能挡掉很多站不住的结论。
crosstab 也能做计数之外的事,传 values 和 aggfunc 就变成了 pivot_table 的简写:
pd.crosstab(df["动量档"], df["市值档"], values=df["fwd"], aggfunc="mean")
两者能力重叠,选哪个看语义:数个数用 crosstab,算别的用 pivot_table。
五、🧰 分档 × 时间:这个因子稳不稳定
上面所有表都是把整个样本期混在一起算的。一个因子在全样本上有效,不代表它一直有效。
把时间放到行上:
df.pivot_table(index="年月", columns="动量档", values="fwd", aggfunc="mean", observed=True) * 1e4
动量档 最低 低 中 高 最高 年月 202302 13.8 14.5 16.7 11.9 -6.6 202303 -9.9 -7.6 -6.9 -3.5 4.6 202304 -23.3 -21.6 -19.9 -14.8 -5.3 202305 10.6 1.6 -1.5 -4.7 -25.8 202306 22.8 13.1 6.4 1.6 -20.6 202307 -12.8 -2.6 10.3 8.4 -9.1 202308 -3.9 -8.4 -10.1 -18.5 -32.5 202309 1.9 3.2 -0.1 -5.9 -17.1 ...共 40 个月
算一下多空价差(最高档减最低档):
均值 9.6 bp 标准差 38.0 bp 方向为正 17/29 个月 (59%)
多空价差的月度标准差是 38.0bp,而均值只有 9.6bp——波动是均值的四倍。
29 个月里只有 17 个月方向是对的,勉强过半。全样本那个”单调递减”的结论,在月度层面并不稳定。
IMPORTANT: 🔑 时间维度是因子分析的照妖镜
全样本均值把好月份和坏月份平均在了一起。一个真正可用的因子,应该在大多数时期方向一致,而不是靠几个极端月份撑起整个统计量。
这张表最该看的两个数:方向为正的月份占比,和多空价差的时间序列标准差。
六、🧰 行业 × 动量档
同样的方法换个维度:
df[df["industry"].isin(top6)].pivot_table( index="industry", columns="动量档", values="fwd", aggfunc="mean") * 1e4
动量档 最低 低 中 高 最高 industry 专用机械 7.4 8.4 10.7 12.7 4.3 元器件 14.2 11.4 13.5 15.6 8.9 化工原料 6.0 8.3 6.1 4.9 -2.8 汽车配件 8.6 6.3 9.4 9.2 6.5 电气设备 8.3 7.5 8.5 6.3 2.5 软件服务 -0.1 4.4 12.5 12.4 12.1
软件服务从 −0.1 单调升到 12.1,多空价差 +12.2bp,是清楚的动量效应。化工原料从 6.0 降到 −2.8,多空价差 −8.8bp,是反转。
同一个因子在不同行业方向相反——这又是一次”平均掉就看不见了”。
NOTE: 💡 这些数字不能直接当策略结论
本讲所有表都是未扣交易成本、未剔除涨跌停和 ST、未做行业中性、未做统计显著性检验的原始统计。
它们在这里的作用是演示透视表怎么用,以及为什么要多切几个维度看。真要下结论,还差很多步。
七、🤔 pivot_table 还是 groupby
两者能做的事高度重叠:
df.pivot_table(index="动量档", columns="市值档", values="fwd", aggfunc="mean") df.groupby(["动量档", "市值档"], observed=True)["fwd"].mean().unstack()
结果完全一样——pivot_table 内部做的就是 groupby 加 unstack(第 14 讲)。
选择依据:
| 情况 | 用什么 |
|---|---|
| 要一张二维交叉表看结构 |
pivot_table,一步到位 |
| 要多个聚合函数 + 总计 |
pivot_table,aggfunc 和 margins 现成 |
| 结果要继续参与计算 |
groupby,长表形态更好接后续操作 |
分组后要做的不只是聚合(transform/filter) |
groupby(第 15、16 讲) |
| 只数个数 | crosstab |
简单说:要看的用 pivot_table,要算的用 groupby。
🏋️ 训练营
QUESTION: 🟢 训练 1:做一张”因子分档 × 行业”的平均收益表,同时带上每格的样本数,并加上总计行列。
TIP: 👉 参考df.pivot_table(index="动量档", columns="industry", values="fwd", aggfunc=["mean", "count"], observed=True, margins=True, margins_name="全体")
aggfunc传列表会让列变成两层(外层是mean/count,内层是行业)。行业多的时候这张表会很宽,实际用的时候通常先筛出关注的几个行业。
observed=True不能省——industry如果是分类列,不写会生成全组合的空格(第 11 讲)。
QUESTION: 🟡 训练 2:下面这张表的”全体”列,能不能通过对前三列求平均得到?为什么?
市值档 小 中 大 全体 最低 20.28 4.39 -0.32 7.91TIP: 👉 参考
不能。(20.28 + 4.39 - 0.32) / 3 = 8.12,而表里是 7.91。
margins的总计是把该行所有原始观测重新聚合一次,不是对已聚合结果求平均。两者只有在各组样本量完全相等时才相同。
这里三个市值档的样本数是 270596 / 275359 / 289061,不相等,所以加权平均的结果和简单平均不同。
实务里这个区别很要紧——等权平均和市值加权平均是两个不同的组合,别把它们混为一谈。
QUESTION: 🔴 训练 3:你在全样本上发现某因子”高档比低档多赚 15bp”,想确认这个结论站不站得住。设计三张表来检验,说明每张表要看什么。
TIP: 👉 参考
表一:样本分布pd.crosstab(df["因子档"], df["市值档"], normalize="index")看分档是否和市值等特征系统性相关。相关的话,你测到的可能是市值效应而不是因子效应。
表二:均值 + 样本数 + 标准差
df.pivot_table(index="因子档", values="fwd", aggfunc=["mean","count","std"])看 15bp 的差异相对标准差有多大。如果各档标准差是 300bp,那 15bp 淹没在噪声里。
表三:分档 × 时间
t = df.pivot_table(index="年月", columns="因子档", values="fwd", aggfunc="mean") hl = t["最高"] - t["最低"] (hl > 0).mean() # 方向为正的月份占比看这 15bp 是长期稳定的,还是靠少数几个月撑起来的。本讲实测那个因子只有 17/40 个月方向为正——全样本的单调性掩盖了时间上的不稳定。
三张表分别检验混淆变量、噪声水平、时间稳定性。三关都过了才值得往下做,而且这还只是最基础的检验。
🐛 常见坑
- ⚠️ 只看一个维度:单一分组会把子样本之间的异质性平均掉,本讲开场那个因子在小市值上是反转、大市值上是动量。
- ⚠️ 只看均值不看样本数和标准差:均值排序好看,但可能建立在噪声上。
aggfunc传列表一次看全。 - ⚠️ 把
margins的总计当成行列平均:它是重新聚合,各组样本量不等时和简单平均不同。 - ⚠️ 不看时间维度:全样本有效不代表一直有效。分档 × 时间的表是必查项。
- ⚠️
fill_value=0掩盖空组:空格代表”没有样本”,填 0 会看起来像”收益为零”。 - ⚠️ 分类列忘了
observed=True:生成全组合空格(第 11 讲)。 - ⚠️ 用
pivot_table做重排:默认aggfunc="mean"会静默把重复记录平均掉。重排用pivot(第 14 讲)。
✍️ 作业
- 用本地数据造一个因子(动量、换手率、或估值),做每日截面五分位分档,然后做”分档 × 市值档”的平均收益表。检查一维表和二维表的结论是否一致。
- 对同一个分档,用
aggfunc=["mean","count","std"]输出三个统计量,找出标准差明显偏大的那一档,思考它的均值可信度。 - 用
crosstab(..., normalize="index")检查你的因子分档和市值档是否独立。如果不独立,用第 16 讲的方法做一次市值中性化,再重新检查。 - 做”分档 × 年月”的表,算出多空价差的时间序列,统计方向为正的月份占比和标准差。判断这个因子稳不稳定。
- 验证
margins的总计不等于行平均:取一行,手工算简单平均,和margins给的数对比,用各组样本量解释差异。 - 思考题:本讲说”要看的用
pivot_table,要算的用groupby“。那么当你要把透视表的结果继续参与计算(比如算多空价差、再画图),应该怎么组织代码?(提示:pivot_table的结果是宽表——第 14 讲讲过宽表擅长什么。)
🔮 下讲预告:第 18 讲——时间序列(上)。这一讲的”年月”是从字符串切出来的,可以用但很受限——没法做”最近 20 个交易日”、没法按周重采样、也没法处理时区。下一讲讲 pandas 的时间类型:怎么把字符串日期转成真正的时间戳、时间索引能做哪些字符串做不到的事,以及为什么量化里几乎所有时间序列操作都要求先把索引变成
DatetimeIndex。