第12讲 MultiIndex 层次索引

📎 配套代码第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_indexset_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"]       # ③

② 必须用 xsslice(None) 占位,因为 trade_date 是内层。sort_index() 不能省——不排序的话 ③ 会弹 PerformanceWarning

QUESTION: 🟡 训练 2:mi = pd.MultiIndex.from_arrays([['x','x','y'], ['b','a','a']])。不运行,写出 levelscodes
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_indexmerge 是按连接的,行业映射表要贴到 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" 保留两级。
  • ⚠️ 外层选错了维度:外层应该是你最常整块取用的那一维。做时序研究放股票代码,做截面研究放日期。选反了取数会一直走慢路。

✍️ 作业

  1. 读一份本地行情数据,分别用平表布尔筛选和层次索引 loc 取”某只股票全历史”,用 time.perf_counter 各计时五次取均值,记录倍数。再对”某天全市场”做同样的对比,解释为什么两个倍数差别很大。
  2. 同一份数据,set_index 后不排序和排序两个版本,打印 index._lexsort_depth,各做一次 loc 取数并计时。再试范围切片,把报错信息抄下来。
  3. 打印你那份数据的 index.codes 的 dtype 和 index.levels 各级长度,手算 codes 占多少字节,和 index.memory_usage() 对照。
  4. 从大面板里 loc 切出两只股票,打印 index.levels[0] 的长度和 index.get_level_values(0).nunique(),确认两者不等;用 remove_unused_levels() 后再打印一次。
  5. unstack("ts_code") 把长表转成”行是日期、列是股票”的行情矩阵,打印形状,并计算 NaN 比例。分别试两组股票:都是老股票的一组,和包含 2020 年后上市新股的一组,比较两者的 NaN 比例,解释差异。
  6. 思考题:开场说外层放股票代码是因为”取一只股票的全历史比取某天的全市场更频繁”。那么截面取数(xs)为什么只快了 50 倍,而外层取数快了 2872 倍?(提示:想想内层的码在排好序的 codes 里是连续的还是分散的。)

🔮 下讲预告:第 13 讲——合并连接 merge。这一讲把两个维度组织进了索引,下一讲是把两张表按键拼到一起——行情表贴上行业分类、因子表贴上后一期的收益率。你会看到 merge 的四种连接方式怎么选、连接键重复时行数为什么会爆炸,以及为什么合并之后第一件事永远是检查行数。


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