第14讲 重塑与透视

📎 配套代码第14讲_重塑与透视.py
📊 配套数据data/quote/ —— 交易数据(沪深300 × 2024 年以来,仓库自带,开箱即跑)

🎬 开场:同一份数据,两种存法

一份样本收盘价数据,17 万行。长表的样子是每只股票每天一行:

ts_code     trade_date    close
000001.SZ   20230103      11.80
000001.SZ   20230104      11.75
600519.SH   20230103    1680.00

宽表是行=日期、列=股票:

ts_code     000001.SZ   600519.SH   ...
trade_date
20230103        11.80      1680.00
20230104        11.75      1688.00

同样的数据,两个数字差得离谱:

长表   171,347 行 × 3 列      20 MB
宽表   574 行 × 300 列         1 MB      ← 少了 20 倍

算一个每日截面的 z-score(因子标准化的标准动作):

w.sub(w.mean(axis=1), axis=0).div(w.std(axis=1), axis=0)                    # 宽表  0.8 ms
px.groupby("trade_date")["close"].transform(lambda x: (x-x.mean())/x.std())  # 长表 46.3 ms

宽表快 55 倍。

但这不意味着宽表更好。长表能干宽表干不了的事——多个因子并排存、每天增量追加、和别的表 merge

两种形态各有各的场合,量化研究里你会不停地在它们之间来回转。这一讲讲清怎么转、以及什么时候该用哪种。


🎯 这一讲结束时,你能

  • 说清长表和宽表各自擅长什么,并按用途选择存储形态
  • pivot / unstack 把长表转成宽表,用 stack / melt 转回来
  • 知道 pivot 遇到重复键会报错、pivot_table 会静默聚合,并选对那个
  • 解释互转过程中 NaN 是怎么冒出来的,以及它们代表什么
  • 避开 stack() 默认丢弃 NaN 这个即将变更的行为

一、🧰 两种形态各擅长什么

长表(long) 宽表(wide)
形状 每个观测一行 行=时间,列=标的
截面运算 groupby(日期) 直接 axis=1快 55 倍
矩阵运算 做不了,要先转 相关矩阵、协方差、滚动窗口一次算完
多个因子 加一列就行 每个因子要一张表
增量追加 直接 concat 新行 要对齐列,麻烦
和别的表合并 merge 天然合适 要先转长表
内存 键列重复存储 省,键只存一次
缺失 不存在的观测就没有行 补成 NaN 显式占位

宽表为什么省这么多内存

长表 20 MB,宽表 1 MB。省掉的不是数据,是

长表里 ts_codetrade_date 这两列 object,各自把同一批字符串重复存了几百万遍。宽表把它们提成了索引和列名——每个只存一次

剩下的 1 MB 正好是 574 × 300 × 8 字节的浮点数据本身。

这和第 11 讲 category 省内存是同一个道理:重复的东西只存一次。宽表相当于把这件事做到了结构上。


二、🧰 长表 → 宽表

pivot:直接指定三个角色

w = px.pivot(index="trade_date", columns="ts_code", values="close")
17 ms → 形状 (574, 300)

三个参数分别回答:谁当行、谁当列、格子里放什么

unstack:从层次索引出发

如果数据已经是层次索引(第 12 讲),把某一级”升”成列:

mi["close"].unstack("ts_code")

两者是同一件事的两种入口——pivot 内部就是先 set_indexunstack。已经建好层次索引就用 unstack,平表就用 pivot

🐛 pivot 遇到重复键会报错

px.pivot(index="trade_date", columns="ts_code", values="close")

如果同一个 (日期, 股票) 出现了两行:

ValueError: Index contains duplicate entries, cannot reshape

这个报错是好事。 一个格子只能放一个值,出现重复说明你的数据有问题——可能是第 13 讲那种财报重复,也可能是多次追加导致的重复行。pivot 逼你先去面对它。

对照 pivot_table

px.pivot_table(index="trade_date", columns="ts_code", values="close", aggfunc="mean")
形状 (574, 4)      静默取了均值

它不报错,直接把重复的值聚合掉了。

WARNING: ⚠️ 默认用 pivot,需要聚合时才用 pivot_table
pivot_table 的默认 aggfunc"mean"。如果你的数据本不该有重复,它会悄悄把两条记录平均掉,你完全不会知道。
判断标准:你是在”重排”数据还是在”汇总”数据。重排用 pivot(要求一格一值),汇总用 pivot_table(明确指定 aggfunc)。


三、🧰 宽表 → 长表

stack:把列压回索引

w.stack()
171,347 行        ← 默认丢掉了 NaN

🐛 stack() 默认丢 NaN,而且这个行为正在变

w.stack()                       # 171,347 行   丢掉 NaN
w.stack(dropna=False)           # 172,200 行   保留,但弹 FutureWarning
w.stack(future_stack=True)      # 172,200 行   保留,不弹警告

三种写法三个结果。当前版本 stack() 默认丢弃 NaN,但 pandas 已经把 dropna 参数标记为废弃——新实现的默认行为是保留 NaN

FutureWarning: The previous implementation of stack is deprecated...
Specify future_stack=True to adopt the new implementation and silence this warning.

这意味着同一行 w.stack() 代码,在 pandas 升级前后会给出行数不同的结果

IMPORTANT: 🔑 显式表达你要不要 NaN
想要只保留有数据的观测(多数情况):w.stack(),但升级后要改成 w.stack(future_stack=True).dropna()
想要完整面板含空格:现在就写 w.stack(future_stack=True)
无论哪种,len() 检查一下都是值得的——这是本讲最容易在版本升级时静默出错的地方。

melt:更通用的宽转长

w.reset_index().melt(id_vars="trade_date", var_name="ts_code", value_name="close")
172,200 行,含 NaN 853

melt 保留全部格子(包括 NaN),而且可以指定生成列的名字。id_vars 是”保持不动的列”,其余列全部融化成两列:变量名和值。

处理”一行里横着摆了 12 个月”这类表格时,melt 是标准工具。


四、🐛 NaN 是从哪冒出来的

长表 17 万行,转成宽表后有 574 × 300 = 17.2 万个格子

宽表格子数   172,200
长表行数     171,347
差额             853      ← 空格子,NaN 比例 0.5%

这 853 个 NaN 不是数据丢失,是长表里本来就没有这些观测

  • 新股上市前——那只股票在那些日期还不存在
  • 退市后——不再有交易
  • 长期停牌——当天没有行情

长表用”没有这一行”来表达”没有这个观测”,宽表必须用一个显式的 NaN 占位,因为矩形结构容不下空缺。

TIP: 🚀 转宽表之后先看一眼 NaN 比例

w.isna().mean().mean()          # 整体
w.isna().mean(axis=1)           # 每天有多少股票没数据
w.isna().mean()                 # 每只股票有多少天没数据

按列看能识别出”上市晚的次新股”,按行看能识别出”数据源某段时间断了”。
0.5% 是很低的稀疏度——沪深 300 成分股基本每天都在交易。换成全市场会高得多(含大量次新股和停牌)。如果某天缺失突然跳到 50%,那是数据问题不是业务现象。

往返一致性

原长表 171,347 → pivot → stack → 171,347 行     一致

pivotstack() 能回到原样,正是因为 stack() 默认把那 853 个空格子又丢掉了。这也解释了为什么 stack 的默认行为变更值得留意。


五、🧰 宽表才能做的事

有些运算天生是矩阵语言,长表上做不了:

r = w.pct_change()

r.corr()                    # 相关矩阵     (184, 184),14.2 ms
r.rolling(20).std()         # 滚动波动率   整个矩阵一次算完,3.2 ms
r.cov()                     # 协方差矩阵
r @ weights                 # 组合收益

相关矩阵、协方差矩阵、组合权重相乘——风险模型和组合优化的输入全是矩阵。这些在长表上要么做不了,要么得先转成宽表。

反过来,截面统计两边都能做,只是宽表快:

每日截面 z-score    宽表  0.8 ms    长表 transform   46.3 ms
每日截面百分位      宽表  4.5 ms    长表 groupby.rank 22.5 ms

🎮 随堂快练

QUESTION: 你要算 500 只股票两两之间的收益率相关系数。数据是长表。写出步骤。
TIP: 👉 答案

w = px.pivot(index="trade_date", columns="ts_code", values="close")
r = w.pct_change()
c = r.corr()

关键是先转宽表corr() 计算的是”列与列之间”的相关,长表里所有股票的收益都挤在同一列,没有列间关系可言。
注意 pct_change 在宽表上是按列做的(每列一只股票的时间序列),正好是你要的。这也说明为什么宽表的行必须是时间——顺序有意义的那一维要放在行上。


六、🧰 pivot_table:带聚合的透视

当你确实要汇总时:

df.pivot_table(index="industry", columns="mv_q", values="ret",
               aggfunc="mean", fill_value=0, margins=True)
参数 作用
aggfunc 怎么聚合,可传列表 ["mean", "count"] 同时算多个
fill_value 空格填什么
margins=True 加一行一列”总计”
observed=True 分类列时避免全组合空组(第 11 讲)

因子分析里典型的用法是双重分组的收益表:行是行业、列是市值档、格子里是平均收益。这正是 pivot_table 的主场——它本来就是给汇总用的。

crosstab 是它的简化版,专做计数:

pd.crosstab(df["industry"], df["mv_q"])       # 各行业各市值档有多少只股票

🏋️ 训练营

QUESTION: 🟢 训练 1:把长表行情转成”行=日期、列=股票”的收盘价矩阵,打印形状和 NaN 比例,再转回长表并验证行数一致。
TIP: 👉 参考

w = px.pivot(index="trade_date", columns="ts_code", values="close")
print(w.shape, w.isna().mean().mean())
back = w.stack().rename("close").reset_index()
assert len(back) == len(px.dropna(subset=["close"]))

能对上是因为 stack() 默认丢掉了转宽表时补出来的 NaN。如果写 stack(future_stack=True),行数会变成 w.size,比原长表多出那些空格子。

QUESTION: 🟡 训练 2:下面两行代码看起来在做同一件事,结果却不同。解释差异,并说明各自适合什么场景。

a = df.pivot(index="trade_date", columns="ts_code", values="close")
b = df.pivot_table(index="trade_date", columns="ts_code", values="close")

TIP: 👉 参考
数据没有重复键时,两者结果相同
有重复键时:pivotValueError: Index contains duplicate entriespivot_table 用默认的 aggfunc="mean" 静默取均值
场景:重排数据用 pivot——你期望一格一值,报错正好告诉你数据有问题。汇总数据用 pivot_table——并且显式写出 aggfunc,别依赖默认值。
危险在于用 pivot_table 做重排:本该是错误的重复数据被悄悄平均掉了,你拿到一张看起来正常的表。

QUESTION: 🔴 训练 3:你要做一个因子分析:计算每只股票的 20 日动量因子,做每日截面标准化,然后按因子值分五档统计各档的平均次日收益。数据是长表。设计流程,说明每一步在长表还是宽表上做、为什么。
TIP: 👉 参考

# 1. 转宽表算动量和收益 —— 时间序列运算按列做,宽表天然合适
w   = px.pivot(index="trade_date", columns="ts_code", values="close")
mom = w.pct_change(20, fill_method=None)
fwd = w.pct_change(fill_method=None).shift(-1)     # 次日收益

# 2. 截面标准化 —— 宽表 axis=1,比长表 groupby 快 22 倍
z = mom.sub(mom.mean(axis=1), axis=0).div(mom.std(axis=1), axis=0)

# 3. 转回长表做分档统计 —— 分组聚合是长表的语言
long = (pd.concat({"z": z.stack(), "fwd": fwd.stack()}, axis=1)
          .reset_index())
long["q"] = long.groupby("trade_date")["z"].transform(
                lambda s: pd.qcut(s, 5, labels=False, duplicates="drop"))
long.groupby("q")["fwd"].mean()

为什么这样分工

  • 步骤 1、2 是沿时间和沿截面的整齐运算,宽表一次算完整个矩阵。
  • 步骤 3 是分组聚合,长表配 groupby 最自然,而且 concat 两个 stack 结果能自动按 (日期, 股票) 对齐。
    四个易错点
    fill_method=None 不能省——pct_change 的默认值是 'pad',会先把停牌和上市前的 NaN 填上再算收益率,等于在数据空缺处凭空造出收益。转宽表刚制造出来的那 19 万个 NaN 正好会被它填掉。这个默认值已被标记为废弃,但当前版本行为仍然如此,必须显式关掉。
    shift(-1) 取的是未来收益,这是因子分析的正常做法,但只能出现在评估环节,绝不能进因子计算。
    stack() 会丢掉 NaN,两个因子各自 stack 后行数可能不同,靠 concat(axis=1)(日期, 股票) 索引对齐才安全。
    qcutduplicates="drop"(第 10 讲),否则某天因子值重复过多会报错,中断整条流程。

这段代码在本地数据上跑出来:

      mean   count
q
0  0.000815  838262      ← 动量最低的一档
1  0.000684  837951
2  0.000713  837757
3  0.000633  837762
4  0.000225  837793      ← 动量最高的一档

五档次日收益大致单调递减——20 日动量越高,次日收益反而越低。
但这个数字不能当结论用:没扣交易成本、没剔除涨跌停和 ST、没做行业中性、也没做统计显著性检验。它在这里的作用只是证明整条流程跑通了。


🐛 常见坑

  • ⚠️ pivot_table 做重排:默认 aggfunc="mean" 会把重复记录静默平均掉。重排用 pivot,让它报错。
  • ⚠️ stack() 默认丢 NaN:行数和你以为的不一样。而且这个默认行为正在变更,显式写 future_stack=True.dropna()
  • ⚠️ 转宽表后不看 NaN 比例:4% 是正常的面板稀疏度,突然跳到 50% 是数据问题。
  • ⚠️ 宽表的行放错了维度:行必须是时间——pct_changerollingshift 都是沿行方向做的。放反了结果毫无意义且不报错。
  • ⚠️ 在长表上做矩阵运算:相关矩阵、协方差需要”列与列”的关系,长表没有。先 pivot
  • ⚠️ 该在宽表上做的截面运算写成了长表 groupby:慢 20 倍以上,数据量大时差别明显。
  • ⚠️ pivot_table 忘了 observed=True:分类列会生成全组合空组(第 11 讲)。
  • ⚠️ melt 忘了 id_vars:不指定的话所有列都会被融化,包括你想保留的标识列。
  • ⚠️ pct_change 忘了 fill_method=None:默认会先填充缺失再算,在停牌和上市前的空缺处造出假收益。宽表上尤其要注意——转宽表刚制造出来的那些 NaN 正好会被它填掉。

✍️ 作业

  1. 把本地长表行情 pivot 成收盘价矩阵,记录耗时、形状、NaN 比例,以及长表和宽表各自的 memory_usage(deep=True).sum()。解释内存差异从何而来。
  2. w.isna().mean(axis=1) 画出”每天有多少比例的股票没有数据”,找出比例最高的几天,判断是业务原因还是数据问题。再用 w.isna().mean() 找出缺失最多的几只股票,查它们的上市日期。
  3. 同一份数据,分别用宽表 axis=1 和长表 groupby().transform() 计算每日截面 z-score,各计时三次取均值,记录倍数。确认两者结果一致(np.allclose)。
  4. 人为制造一组重复的 (日期, 股票) 记录,分别用 pivotpivot_table 转宽表,把 pivot 的报错信息抄下来,并说明 pivot_table 的结果里那个格子的值是怎么来的。
  5. 对同一个宽表跑 stack()stack(dropna=False)stack(future_stack=True),记录三者的行数和是否有警告。说明 pandas 升级后哪一行代码的行为会变。
  6. 思考题:本讲说”宽表的行必须是时间”。那么如果你要算的是每只股票和某个指数的相关系数(而不是股票两两之间),宽表该怎么组织?(提示:corr() 算的是列与列,想让谁参与相关,谁就得是列。)

🔮 下讲预告:第 15 讲——groupby 的分组机制。这一讲的截面运算你用了两次 groupby,下一讲把它拆开:分组这件事内部是怎么完成的、为什么 transformagg 返回的形状不同、以及为什么 groupby 之后接不同的方法性能能差出一个数量级。


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