📎 配套代码:
第12讲_MultiIndex.py
📊 配套数据:data/quote/—— 交易数据(沪深300 × 2024 年以来,仓库自带,开箱即跑)
🎬 开场:量化研究反复在问的两个问题
拿到一份行情数据,你要问的问题基本逃不出两类:
- 时序视角:茅台这些年走得怎么样?
- 截面视角:2024 年 1 月 2 日这天,全市场什么情况?
在平表上,两个问题都写成布尔筛选:
px[px["ts_code"] == "600519.SH"] # 时序 px[px["trade_date"] == "20240102"] # 截面
17 万行的样本行情表上实测:
取茅台全历史 3.55 ms 574 行 取某天全市场 3.39 ms 298 行
两个问题各要 3.5 毫秒。原因一样:每次都把 17 万行从头扫到尾,比对每一行是不是你要的,哪怕结果只有几百行。
把这两列变成层次索引之后:
mi = px.set_index(["ts_code", "trade_date"]).sort_index() mi.loc["600519.SH"] # 时序 mi.xs("20240102", level="trade_date") # 截面
取茅台全历史 0.05 ms 快 73 倍 取某天全市场 0.11 ms 快 30 倍
取单个格子的差距更大:
px[(px["ts_code"]<mark>"600519.SH") & (px["trade_date"]</mark>"20240102")]["close"] # 6.83 ms mi.loc[("600519.SH", "20240102"), "close"] # 0.02 ms
快了 368 倍。 而且内存从 21.1 MB 降到 6.4 MB。
(样本小,绝对时间都是毫秒级。数据量涨到几百万行时,平表扫描是线性变慢,而 loc 是二分查找——倍数会继续拉大。)
面板数据(每只股票每天一行)天然是二维的——(股票, 日期) 唯一确定一行。层次索引就是把这个结构告诉 pandas,让它按结构去取,而不是每次都扫全表。
🎯 这一讲结束时,你能
- 用
set_index把面板数据组织成(股票, 日期)的层次索引,并说清为什么必须紧跟sort_index() - 按三种视角取数:外层一整块、内层跨组、单个格子,以及用
IndexSlice做范围切片 - 用
swaplevel/groupby(level=)/reset_index在层级之间来回操作 - 说出 MultiIndex 的内部结构
levels + codes,并解释它和第 11 讲 Categorical 的关系 - 避开两个坑:忘了排序导致切片变慢或报错、
levels过滤后不缩水
一、🧰 建立层次索引
最常用的是把现成的几列抬成索引:
mi = px.set_index(["ts_code", "trade_date"]).sort_index()
外层放取数时更常用作整块的那一维。量化里通常是股票代码——因为”取一只股票的全历史”比”取某天的全市场”更频繁。
其它几种造法:
pd.MultiIndex.from_arrays([codes, dates], names=["ts_code", "trade_date"]) # 每级一个等长数组 pd.MultiIndex.from_tuples([("600519.SH", "20240102"), ...]) # 每行一个元组 pd.MultiIndex.from_product([codes, dates], names=["ts_code", "trade_date"]) # 笛卡尔积
from_product 在构造完整面板时很有用——比如你要一个”所有股票 × 所有交易日”的空壳,再把实际数据填进去,缺的自然是 NaN。
sort_index() 不是可选的
px.set_index(["ts_code", "trade_date"]) # 没排序 px.set_index(["ts_code", "trade_date"]).sort_index() # 排了
两者取数速度实测:
未排序 loc["600519.SH"] 0.12 ms 已排序 loc["600519.SH"] 0.04 ms 快 3 倍
原因在第五节。这里先记住:建完层次索引紧跟一个 sort_index(),写成一句连着的链式调用,别分开。
二、🧰 三种取数视角
| 你要什么 | 写法 | 例子 |
|---|---|---|
| 外层某个值的一整块 | .loc[外层值] |
mi.loc["600519.SH"] |
| 内层某个值,跨所有外层 | .xs(值, level=) |
mi.xs("20240102", level="trade_date") |
| 单个格子 | .loc[(外, 内), 列] |
mi.loc[("600519.SH","20240102"), "close"] |
| 范围/多值组合 | pd.IndexSlice |
见下 |
外层:取一只股票的全历史
mi.loc["600519.SH"]
结果里 ts_code 这一级会被消掉,剩下 trade_date 作为普通索引——正好是一个可以直接画图、算收益率的时间序列。
内层:取某天的全市场
内层不能直接用 loc 的第一个位置(那是留给外层的),要用 xs:
mi.xs("20240102", level="trade_date")
或者用 slice(None) 给外层占位:
mi.loc[(slice(None), "20240102"), :]
两者等价,但 xs 明显更好读。选内层优先用 xs。
范围切片:IndexSlice
想表达”这几只股票、这段时间”,元组里塞切片语法会很难写。pd.IndexSlice 是为此准备的:
idx = pd.IndexSlice mi.loc[idx[["600519.SH", "000001.SZ"], "20240101":"20240131"], :]
两只股票 × 2024 年 1 月 → 44 行
idx[...] 里每个位置对应一级,可以是单值、列表、切片、或 :(全选)。末尾那个 , : 是列选择,不能省。
🎮 随堂快练
QUESTION:
mi.loc["600519.SH"]和mi.loc"600519.SH"有什么区别?
TIP: 👉 答案
传单值会把外层消掉,结果索引只剩trade_date;传列表会保留两级索引,结果仍是 MultiIndex。
这和第 06 讲df["close"]返回 Series、df"close"返回 DataFrame 是同一个规律:传单值降维,传列表保持结构。
实际用的时候:要拿来当时间序列处理就传单值,要保持面板结构继续操作就传列表。
三、🧰 层级操作
换级顺序
mi.swaplevel().sort_index() # 两级对调 mi.swaplevel("ts_code", "trade_date").sort_index() # 指名道姓
什么时候需要?当你的分析主要按日期做截面时。把 trade_date 提到外层,mi.loc["20240102"] 就成了最快的那条路。
swaplevel 后必须重新 sort_index()——换了外层,原来的排序就不作数了。养成连写的习惯。
按级别分组
mi.groupby(level="ts_code")["close"].mean() # 每只股票的均价 mi.groupby(level="trade_date")["vol"].sum() # 每天的总成交量
level= 让你直接按索引的某一级分组,不用先 reset_index 把它变回列。
WARNING: ⚠️ 旧写法
df.sum(level="ts_code")在 pandas 2.x 已删除
会直接报TypeError。统一用df.groupby(level=...).sum()。
索引和列之间来回切
mi.reset_index() # 索引全部拍回普通列 mi.reset_index(level="trade_date") # 只把一级拍回列 mi.droplevel("trade_date") # 直接扔掉一级(不变成列)
reset_index 和 set_index 是互逆的。很多操作(merge、写文件、画图)在平表上更方便,用完再 set_index 回去。
四、🔬 levels + codes:第 11 讲结构的推广
MultiIndex 内部不是逐行存元组,而是每一级各存一份”唯一值表 + 整数码”:
逻辑上的四行: levels(每级的唯一值表,去重且排序)
('600519.SH', '20240102') levels[0] = ['000001.SZ', ..., '600519.SH'] 300 项
('600519.SH', '20240103') levels[1] = ['20240102', ..., '20260521'] 574 项
('000001.SZ', '20240102')
('000001.SZ', '20240103') codes(每行在各级上的整数码)
codes[0] = [3821, 3821, 0, 0] int16
codes[1] = [ 486, 487, 486, 487] int16
还原第 r 行 = (levels[0][codes[0][r]], levels[1][codes[1][r]])
这和第 11 讲的 categories + codes 是同一个结构——MultiIndex 相当于每一级各做一次 Categorical 编码,再把各级的码摞起来。
真实数据上:
codes dtype [int16, int16] levels 长度 [300, 574]
300 只股票、574 个交易日,两级的码都装得进 int16(2 字节)。17 万行 × 2 级 × 2 字节 ≈ 0.7 MB 的码,加上两张几百项的表。
而如果逐行存元组,每行都要一个 Python 元组对象加两个字符串引用。这就是开场那个内存差异的来源:
平表(含两列 object 键) 21.1 MB MultiIndex 表 6.4 MB 其中索引本身 4.9 MB
为什么快
取数快是因为排好序的 codes 可以二分查找。mi.loc["600519.SH"] 做的是:把 "600519.SH" 翻译成它在 levels[0] 里的码(一次哈希查找),然后在有序的 codes[0] 上二分定位这一段的起止位置,直接切出来。全程 O(log n),不碰字符串。
平表的布尔筛选没有这个结构可用,只能 O(n) 全表比对。
五、🐛 忘了排序的代价
上面那个二分查找有个前提:codes 必须是排好序的。
pandas 用 _lexsort_depth 记录”从第一级开始,连续排好了几级”:
未排序 _lexsort_depth = 0 已排序 _lexsort_depth = 2 (等于层数,拉满)
深度不够时:
| 操作 | 未排序时 |
|---|---|
mi.loc["600519.SH"] |
能跑,但退回全表扫描,慢 3 倍 |
mi.loc[("600519.SH", "20240102")] |
能跑,同样走的是慢路 |
mi.loc["000001.SZ":"600519.SH"] |
UnsortedIndexError,直接报错 |
只有范围切片会明确报错——因为二分定位边界必须要求有序,pandas 没法蒙混过去。前两种都能给出正确结果,只是悄悄慢下来。
WARNING: ⚠️ 慢是静默的
这一讲的坑和前几讲不太一样:前面那些静默错误给的是错误答案,这里给的是正确答案但很慢。
后果是你不会发现它——直到数据量涨上去,某个跑得好好的脚本忽然要跑几个小时。
排查方法只有一个:mi.index._lexsort_depth,看它等不等于mi.index.nlevels。
IMPORTANT: 🔑
sort_index()是给取数铺路,不是强迫症
建立层次索引之后立刻排序,让_lexsort_depth拉满到层数。之后所有取数都走二分。
三个必须排序的时机:set_index之后、swaplevel之后、concat拼接多个分片之后。
最后一个最容易忘——按月读取再拼起来,每个分片内部有序,拼完整体未必有序。
六、🐛 levels 不会随过滤缩水
这是个反直觉的行为:
sub = mi.loc"600519.SH", "000001.SZ" # 只留两只股票 len(sub) # 1,148 行 len(sub.index.levels[0]) # 300 ← 还是全部
过滤只改了 codes(少了很多行),levels 那张唯一值表原封不动。它记录的是”这个索引曾经有哪些可能取值”,不是”现在实际用到了哪些”。
后果是 groupby(level=0) 可能冒出几千个空组——这和第 11 讲 observed=False 造出大量空组是同一个根源:分类结构记住了全部可能的类别,而数据里只用到一小部分。
解法:
sub.index.remove_unused_levels() # levels[0] 从 300 项降到 2 项
TIP: 🚀 什么时候需要显式清理
从大面板里切出一小块、之后还要做groupby(level=)或unstack时。
判断方法:len(idx.levels[0])和idx.get_level_values(0).nunique()对比,差得多就该清理了。
七、🧰 和第 14 讲的接口:unstack
层次索引的一级可以”升”成列,把长表变成宽表:
mi["close"].unstack("ts_code")
ts_code 000001.SZ 600519.SH ... trade_date 20240102 11.80 1680.0 20240103 11.75 1688.0
这正是量化里最常用的行情矩阵形态:行是日期、列是股票,可以直接做矩阵运算、算相关系数、跑回归。
反过来 stack 把列压回索引。这两个操作全建立在 levels + codes 的重排上,第 14 讲展开。
🏋️ 训练营
QUESTION: 🟢 训练 1:把一份平表行情组织成层次索引,然后写出三种取数:① 平安银行的全历史 ② 2024 年 1 月 2 日的全市场 ③ 平安银行在那天的收盘价
TIP: 👉 参考mi = px.set_index(["ts_code", "trade_date"]).sort_index() mi.loc["000001.SZ"] # ① mi.xs("20240102", level="trade_date") # ② mi.loc[("000001.SZ", "20240102"), "close"] # ③② 必须用
xs或slice(None)占位,因为trade_date是内层。sort_index()不能省——不排序的话 ③ 会弹PerformanceWarning。
QUESTION: 🟡 训练 2:
mi = pd.MultiIndex.from_arrays([['x','x','y'], ['b','a','a']])。不运行,写出levels和codes。
TIP: 👉 参考
levels[0] = ['x','y'],levels[1] = ['a','b']——都是去重且排序的。
逐行翻译:('x','b')→(0,1);('x','a')→(0,0);('y','a')→(1,0)。
所以codes[0] = [0,0,1],codes[1] = [1,0,0]。
关键是码指向的是排序后的位置,不是出现顺序。这一点和第 11 讲astype("category")默认按字母序排类别是同一个规律。
QUESTION: 🔴 训练 3:你要做一个”每只股票相对其所属行业当日均值的超额收益”因子。数据是层次索引的行情表
mi(索引ts_code/trade_date,含close)和一张ts_code → industry的映射表。写出实现,并说明为什么中间要reset_index。
TIP: 👉 参考df = mi.reset_index() # ① df["ret"] = df.sort_values(["ts_code","trade_date"]) \ .groupby("ts_code")["close"].pct_change() df = df.merge(ind_map, on="ts_code", how="left") # ② df["ind_ret"] = df.groupby(["trade_date","industry"])["ret"].transform("mean") df["excess"] = df["ret"] - df["ind_ret"]为什么
reset_index:merge是按列连接的,行业映射表要贴到ts_code上,而ts_code现在是索引的一级。虽然merge也支持left_index=True,但把索引拍回列之后整段代码都在平表上做,groupby(["trade_date","industry"])这种跨列分组写起来更自然。
什么时候该切回去:算完因子如果还要按股票或按日期反复取数,再set_index([...]).sort_index()回去。
两个易错点:①pct_change前必须排序(第 09 讲);② 用transform("mean")而不是agg,因为要的是和原表等长、能直接相减的一列(第 07 讲)。
🐛 常见坑
- ⚠️
set_index后忘了sort_index:取数退回全表扫描慢 58 倍,深层定位弹PerformanceWarning,范围切片直接UnsortedIndexError。 - ⚠️
swaplevel后忘了重新排序:换了外层,原排序失效。写成swaplevel(...).sort_index()连着。 - ⚠️
concat拼完忘了排序:各分片内部有序不代表拼完整体有序。 - ⚠️ 用
loc选内层:mi.loc[:, "20240102"]选的是列不是内层行。用xs(..., level=)或slice(None)占位。 - ⚠️
levels过滤后不缩水:切出子集后levels仍是全集,groupby(level=)会冒出空组。用remove_unused_levels()。 - ⚠️
df.sum(level=...)已删除:改用df.groupby(level=...).sum()。 - ⚠️ 传单值降维、传列表保结构:
loc["600519.SH"]消掉一级,loc"600519.SH"保留两级。 - ⚠️ 外层选错了维度:外层应该是你最常整块取用的那一维。做时序研究放股票代码,做截面研究放日期。选反了取数会一直走慢路。
✍️ 作业
- 读一份本地行情数据,分别用平表布尔筛选和层次索引
loc取”某只股票全历史”,用time.perf_counter各计时五次取均值,记录倍数。再对”某天全市场”做同样的对比,解释为什么两个倍数差别很大。 - 同一份数据,
set_index后不排序和排序两个版本,打印index._lexsort_depth,各做一次loc取数并计时。再试范围切片,把报错信息抄下来。 - 打印你那份数据的
index.codes的 dtype 和index.levels各级长度,手算 codes 占多少字节,和index.memory_usage()对照。 - 从大面板里
loc切出两只股票,打印index.levels[0]的长度和index.get_level_values(0).nunique(),确认两者不等;用remove_unused_levels()后再打印一次。 - 用
unstack("ts_code")把长表转成”行是日期、列是股票”的行情矩阵,打印形状,并计算 NaN 比例。分别试两组股票:都是老股票的一组,和包含 2020 年后上市新股的一组,比较两者的 NaN 比例,解释差异。 - 思考题:开场说外层放股票代码是因为”取一只股票的全历史比取某天的全市场更频繁”。那么截面取数(
xs)为什么只快了 50 倍,而外层取数快了 2872 倍?(提示:想想内层的码在排好序的codes里是连续的还是分散的。)
🔮 下讲预告:第 13 讲——合并连接 merge。这一讲把两个维度组织进了索引,下一讲是把两张表按键拼到一起——行情表贴上行业分类、因子表贴上后一期的收益率。你会看到
merge的四种连接方式怎么选、连接键重复时行数为什么会爆炸,以及为什么合并之后第一件事永远是检查行数。