第17讲 透视表与交叉表

📎 配套代码第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_tablecrosstab 做出因子分析里那几张标准表。


🎯 这一讲结束时,你能

  • 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 允许一格多值,用聚合函数压成一个。

indexcolumns 都可以传列表,得到多层的行或列:

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 也能做计数之外的事,传 valuesaggfunc 就变成了 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 内部做的就是 groupbyunstack(第 14 讲)。

选择依据:

情况 用什么
要一张二维交叉表看结构 pivot_table,一步到位
多个聚合函数 + 总计 pivot_tableaggfuncmargins 现成
结果要继续参与计算 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.91

TIP: 👉 参考
不能(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 讲)。

✍️ 作业

  1. 用本地数据造一个因子(动量、换手率、或估值),做每日截面五分位分档,然后做”分档 × 市值档”的平均收益表。检查一维表和二维表的结论是否一致。
  2. 对同一个分档,用 aggfunc=["mean","count","std"] 输出三个统计量,找出标准差明显偏大的那一档,思考它的均值可信度。
  3. crosstab(..., normalize="index") 检查你的因子分档和市值档是否独立。如果不独立,用第 16 讲的方法做一次市值中性化,再重新检查。
  4. 做”分档 × 年月”的表,算出多空价差的时间序列,统计方向为正的月份占比和标准差。判断这个因子稳不稳定。
  5. 验证 margins 的总计不等于行平均:取一行,手工算简单平均,和 margins 给的数对比,用各组样本量解释差异。
  6. 思考题:本讲说”要看的用 pivot_table,要算的用 groupby“。那么当你要把透视表的结果继续参与计算(比如算多空价差、再画图),应该怎么组织代码?(提示:pivot_table 的结果是宽表——第 14 讲讲过宽表擅长什么。)

🔮 下讲预告:第 18 讲——时间序列(上)。这一讲的”年月”是从字符串切出来的,可以用但很受限——没法做”最近 20 个交易日”、没法按周重采样、也没法处理时区。下一讲讲 pandas 的时间类型:怎么把字符串日期转成真正的时间戳、时间索引能做哪些字符串做不到的事,以及为什么量化里几乎所有时间序列操作都要求先把索引变成 DatetimeIndex


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