📎 配套代码:
第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_code 和 trade_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_index 再 unstack。已经建好层次索引就用 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 行 一致
pivot 再 stack() 能回到原样,正是因为 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: 👉 参考
数据没有重复键时,两者结果相同。
有重复键时:pivot抛ValueError: Index contains duplicate entries,pivot_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)按(日期, 股票)索引对齐才安全。
④qcut要duplicates="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_change、rolling、shift都是沿行方向做的。放反了结果毫无意义且不报错。 - ⚠️ 在长表上做矩阵运算:相关矩阵、协方差需要”列与列”的关系,长表没有。先
pivot。 - ⚠️ 该在宽表上做的截面运算写成了长表
groupby:慢 20 倍以上,数据量大时差别明显。 - ⚠️
pivot_table忘了observed=True:分类列会生成全组合空组(第 11 讲)。 - ⚠️
melt忘了id_vars:不指定的话所有列都会被融化,包括你想保留的标识列。 - ⚠️
pct_change忘了fill_method=None:默认会先填充缺失再算,在停牌和上市前的空缺处造出假收益。宽表上尤其要注意——转宽表刚制造出来的那些 NaN 正好会被它填掉。
✍️ 作业
- 把本地长表行情
pivot成收盘价矩阵,记录耗时、形状、NaN 比例,以及长表和宽表各自的memory_usage(deep=True).sum()。解释内存差异从何而来。 - 用
w.isna().mean(axis=1)画出”每天有多少比例的股票没有数据”,找出比例最高的几天,判断是业务原因还是数据问题。再用w.isna().mean()找出缺失最多的几只股票,查它们的上市日期。 - 同一份数据,分别用宽表
axis=1和长表groupby().transform()计算每日截面 z-score,各计时三次取均值,记录倍数。确认两者结果一致(np.allclose)。 - 人为制造一组重复的
(日期, 股票)记录,分别用pivot和pivot_table转宽表,把pivot的报错信息抄下来,并说明pivot_table的结果里那个格子的值是怎么来的。 - 对同一个宽表跑
stack()、stack(dropna=False)、stack(future_stack=True),记录三者的行数和是否有警告。说明 pandas 升级后哪一行代码的行为会变。 - 思考题:本讲说”宽表的行必须是时间”。那么如果你要算的是每只股票和某个指数的相关系数(而不是股票两两之间),宽表该怎么组织?(提示:
corr()算的是列与列,想让谁参与相关,谁就得是列。)
🔮 下讲预告:第 15 讲——groupby 的分组机制。这一讲的截面运算你用了两次
groupby,下一讲把它拆开:分组这件事内部是怎么完成的、为什么transform和agg返回的形状不同、以及为什么groupby之后接不同的方法性能能差出一个数量级。