
如果你的工作流里已经离不开 pandas那你迟早会撞上一个东西MultiIndex。上个月我在整理一份区域销售底表时Excel 里的大区、城市、门店本来是三列导入 DataFrame 后同事让我把它变成一行一个维度组合的索引还要能按“华东”直接下钻到所有城市按(华东, 上海)精确取数——我第一个想到的就是用 MultiIndex 把它构造出来。pandas 里构造 MultiIndex 有四条常用路径from_tuples()、from_arrays()、from_product()、from_frame()每条的输入形态不同适用场景也差很远。这篇文章我把我实际用的过程和数据都摆出来从最基础的用法到参数边界再到底层表现适合刚接触多层索引、或者一直在用字符串拼接列名凑合的读者。如果你只是想快速抄一个能跑的构造代码直接跳到对应小节的代码块就行如果你想弄明白为什么有时候明明构造出来了一用df.loc就报 KeyError那建议从头到尾读完。1. 先搞清楚MultiIndex 到底解决了什么问题要理解这四种构造方法先要理解 MultiIndex 这个数据结构本身。它不是什么玄乎的东西你可以把 MultiIndex 看成“一行数据的多维身份标签”。普通索引是一个一维标签数组比如Index([华东, 华北])而 MultiIndex 是多个这样的标签层叠在一起每一行用一个元组来唯一标识例如(华东, 上海)。MultiIndex 内部实际由三部分组成levels每一层所有去重后的取值、codes每一行在每个层级上对应哪个位置的整数编码、names每一层索引的名字。这跟最初 pandas 老版本的labels命名容易混淆后面我会专门讲这个坑。看到这三个属性你就大概知道为什么 pandas 要专门提供from_tuples、from_arrays等构造方法了——因为它们本质上都是把不同的输入形态转换成同一套 levels 和 codes。在实际业务里MultiIndex 最大的价值是你不需要再把“大区-城市”拼成一列字符串然后到处切分。有了多层索引你可以直接df.loc[华东] # 取整个华东的下级数据 df.loc[(华东, 上海)] # 精确到城市 df.xs(上海, level城市) # 跨层级搜索 df.groupby(level0).sum() # 按第一层聚合这些都是普通一维索引做不到或者做起来非常别扭的。可以说只要你的数据天然带多个维度而你要频繁地按其中任意一个维度筛选或聚合MultiIndex 就是 pandas 里最正规的解法。接下来我按四种构造方法一个个拆最后再给一张对照表。2. from_tuples从元组列表直接拉起来最简单但藏着两个小坑2.1 基本用法pd.MultiIndex.from_tuples()接收的是一个元组列表每个元组代表一行索引的多层级值。名称里的 tuples 已经说得很清楚它面对的就是一组“已经成对的标签”。import pandas as pd tuples [ (华东, 上海), (华东, 杭州), (华北, 北京), (华北, 天津), ] idx pd.MultiIndex.from_tuples(tuples, names[大区, 城市]) print(idx)输出长这样MultiIndex([(华东, 上海), (华东, 杭州), (华北, 北京), (华北, 天津)], names[大区, 城市])用的时候也很简单直接把构造好的索引赋给 DataFramedf pd.DataFrame( {销售额: [120, 80, 150, 90]}, indexidx ) df.loc[(华东, 上海)] # 返回这一行的 Series这是最直观、最接近“人脑手写”的构造方式。2.2 容易被忽略的地方普通 Index 和 MultiIndex 是两回事很多新手会想既然 MultiIndex 每一行本质是个元组那我直接pd.Index([(华东,上海), (华北,北京)])不就行了还真不行。pd.Index()面对这种输入时只会把它当成一个“元素是元组”的一维索引也就是 object 类型的普通索引。打印出来你会看到Index([(华东, 上海), (华北, 北京)], dtypeobject)。虽然它看起来也是两行元组但它没有层级概念你不能对它用swaplevel()、不能用groupby(level0)、也不能用df.loc[华东]做部分选择。所以一旦你有跨维度筛选、下钻的需求别绕路直接走from_tuples。2.3 第一个坑构造顺序不会自动整理from_tuples不会自动排序你传什么顺序最终levels和索引行的顺序就是什么顺序。这在数据量少的时候无所谓但如果你构造完以后直接对 DataFrame 做切片可能会碰到一种“明明索引里有这个值却报 KeyError”的情况。比如我把顺序打乱tuples [ (华北, 天津), (华东, 上海), (华北, 北京), ] idx pd.MultiIndex.from_tuples(tuples)然后用df.loc[华东]去取某些版本下会直接报错提示 MultiIndex 不是 lexsorted。这不是数据没有而是索引没有按字典序排序。解决办法也简单idx_sorted idx.sort_values()所以我的习惯是如果后面要做切片或部分匹配构造完立刻sort_index()别赌它恰好有序。2.4 第二个坑老版本 labels 属性已经改名 codes如果你看的是几年前的老教程可能会看到idx.labels这种访问方式。pandas 在 0.24 版本里把labels改名为codes老写法现在访问会报错或者警告。正确写法是idx.codes # FrozenList([array([1, 0, 0], dtypeint8), array([0, 1, 0], dtypeint8)])我自己在接手老项目代码时就踩过这个同事 2018 年写的代码里idx.labels还在用跑起来直接 AttributeError。升级 pandas 后这些老 API 迟早要清理。2.5 典型应用场景from_tuples适合那些“数据源本身就是元组/配对结果”的情况。比如从数据库查出来两列直接list(zip(col1, col2))就能得到元组列表或者你已经解析好了一批时间段——(2024-01, 2024-03)、(2024-03, 2024-06)——这些天然的二元组就是from_tuples的输入。3. from_arrays手上有多个同长数组时的首选顺便讲透 codes 和 sortorder3.1 基本用法pd.MultiIndex.from_arrays()接收的是数组的列表。第一个数组会成为第一层索引第二个数组会成为第二层以此类推。它和from_tuples的区别在于输入形态不同而且它是“按位置对齐”的不做笛卡尔积。arrays [ [华东, 华东, 华北, 华北], # 第一层大区 [上海, 杭州, 北京, 天津], # 第二层城市 ] idx pd.MultiIndex.from_arrays(arrays, names[大区, 城市]) print(idx)输出结果和from_tuples基本一样MultiIndex([(华东, 上海), (华东, 杭州), (华北, 北京), (华北, 天津)], names[大区, 城市])两个数组的长度必须一致。长度不一致会直接抛 ValueErrorValueError: Length of levels and labels must be the same3.2 数组里已经有数据别再手动拼接实战中我最常用的from_arrays场景是数据本身就躺在 DataFrame 的两列里我想把它变成索引对象。写法可以这样source pd.DataFrame({ 大区: [华东, 华东, 华北, 华北], 城市: [上海, 杭州, 北京, 天津], 销售额: [120, 80, 150, 90], }) idx pd.MultiIndex.from_arrays( [source[大区], source[城市]], names[大区, 城市], )如果你只是想把这个 DataFrame 的列变成索引完全可以直接用set_indexsource.set_index([大区, 城市])这两者目标相同结果上也几乎一样。区别在于set_index是 DataFrame 层面的操作会带着其他数据列一起调整索引结构而from_arrays只是纯粹构造一个MultiIndex对象。如果后面还要把这个索引应用到另一张表上用from_arrays更干净不会牵扯进额外数据列。3.3 深入看两个参数sortorder 和 verify_integrityfrom_arrays的完整签名里有两个不太起眼、但实际很要命的参数。第一个是sortorder。它表示索引从第几层开始已经是字典序排序好的。传None表示我们不保证排序传0表明确认所有层级都已排序。如果你乱传把没排序的数据标记成了已排序后续切片时 pandas 会信任这个标记并跳过排序检查结果很可能就是错误数据或者奇怪的 KeyError。如果拿不准就保持默认None之后用sort_index()搞定。第二个是verify_integrity默认False。打开后 pandas 会检查层级之间是否合法、是否出现重复组合、长度是否一致。这个参数在数据量小的时候感觉不出来几百万行时能明显拖慢构造速度。我的建议是调试阶段可以开一次看看数据规格有没有问题正式跑批就关掉。3.4 适用场景与和 from_tuples 的互换什么时候用from_arrays比from_tuples舒服答案是你已经有两个或多个等长数组、列、或者 Python 列表时不用再zip打包直接平铺传进去就行。它特别适合接到groupby或value_counts的结果后面grouped df.groupby([大区, 城市], as_indexFalse).agg(销售额(销售额, sum)) g grouped[大区].astype(str).values c grouped[城市].astype(str).values idx pd.MultiIndex.from_arrays([g, c])另外from_arrays和from_tuples是可以互相转换的。元组列表拆开后很容易变成多个数组反过来多个数组用zip(*arrays)也能合成元组列表。实际项目中我经常根据当下数据最顺手的形式来选两个方法谁都不比谁更高明。4. from_product自动展开所有组合参数扫描和数据补全都靠它4.1 基本用法pd.MultiIndex.from_product()接收若干个可迭代对象然后把这些对象里面的元素全部做笛卡尔积。也就是说它不是按行对齐的而是把每一层的所有取值挨个组合一遍。district [华东, 华北] city [上海, 杭州] idx pd.MultiIndex.from_product( [district, city], names[大区, 城市], ) print(idx)输出MultiIndex([(华东, 上海), (华东, 杭州), (华北, 上海), (华北, 杭州)], names[大区, 城市])你可以看到“华东”和“华北”各自都跟“上海”“杭州”配了一遍共 2×24 行。如果再加一层quarter [Q1, Q2]那就会变成 2×2×28 行全部组合自动展开。4.2 输出顺序别默认它是按传入顺序来的这是from_product最容易坑人的地方。很多教程默认它输出的是“按照传入数组的顺序展开”但我实测下来pandas 不同小版本里 from_product 对 levels 的处理存在差异它为了内部索引的字典序一致性会把 levels 重新规范化。什么意思你传入[华东, 华北]它实际存储的 levels 可能是按排序后的顺序排列的codes指向排序后的位置最终呈现出来的组合顺序就可能跟你手写双层循环时的顺序不一样。这种情况在数据量小时不容易察觉等你要对两个配对的列做顺序敏感的拼接时就发现了。我的对策是如果组合顺序有业务语义比如年份永远从 2020 排到 2024不能变成 2024 开头构造完立刻检查idx.tolist()该sort_index()就sort_index()该reindex就reindex总之别赌它一定保持你传入的顺序。另外from_product的签名里也有sortorder参数。默认情况不传表示排序状态未知。如果你想明确告诉 pandas “我传入的序列本来就是排好序的”可以传sortorder0但前提是你真的确定每个可迭代对象都按字典序递增否则后面切片照样翻车。4.3 典型应用参数组合扫描和缺失组合补全from_product最强的地方是“我没有现成组合但我知道所有维度的可能取值它帮我全部铺开”。比如你做超参数网格搜索模型组合是(学习率, 批大小)学习率有三个值、批大小有两个值你想生成一个全组合的 DataFrame 作为扫描表index pd.MultiIndex.from_product( [[0.001, 0.01, 0.1], [32, 64]], names[learning_rate, batch_size], ) param_grid pd.DataFrame(indexindex).reset_index()这样一张参数扫描表就出来了非常自然。另一个常用场景是全组合补缺。假设实际业务数据里“华北-杭州”这个组合没有销售额记录但你希望统计结果里仍然保留它并且填 0 或 NaNfull_index pd.MultiIndex.from_product( [data[大区].unique(), data[城市].unique()], names[大区, 城市], ) data_full data.reindex(full_index)data_full里缺失的组合就会自动补上 NaN后面fillna(0)再groupby统计结果就完整了。4.4 和 from_tuples / from_arrays 的分工一句话总结分工from_tuples和from_arrays处理的是“已经存在”的组合from_product处理的是“可能但未必存在”的组合。前者是数据映射后者是全量展开。这也是为什么from_product在生成模拟数据、组成因子实验设计、构造完整索引框架时特别顺手。5. from_frame把维度表变成索引模板和 set_index 的边界别搞混5.1 基本用法pd.MultiIndex.from_frame()是四种方法里最“表格式”的它接收一个 DataFrame每一列会成为 MultiIndex 的一个层级。列的顺序就是层级的顺序。dim pd.DataFrame({ 大区: [华东, 华东, 华北, 华北], 城市: [上海, 杭州, 北京, 天津], }) idx pd.MultiIndex.from_frame(dim, names[区域, 地市]) print(idx)输出MultiIndex([(华东, 上海), (华东, 杭州), (华北, 北京), (华北, 天津)], names[区域, 地市])注意这里我传了names[区域, 地市]结果里索引名变成了新名字。如果不传names那就用原 DataFrame 的列名「大区」「城市」。from_frame对输入有一个硬性要求列名必须唯一。如果维度表里有两列都叫“城市”直接报ValueError: Columns must be unique。这个在实际清洗数据时很容易遇到特别是从 Excel 读进来第一行往往带着合并单元格的抬头列名可能重复。处理办法是先df.columns [城市_1, 城市_2]之类的去重再构造。5.2 从维度表变成索引模板我最常用的from_frame场景是我手里已经有一张干净的维度表比如所有大区和城市的主数据表。这张表本身不包含任何业务数值只有维度字段。我想把它变成另一个业务表的索引模板保证业务表里的每一个组合都合法那直接valid_idx pd.MultiIndex.from_frame(dim) result business_df.reindex(valid_idx)这样result的行就严格限定在合法维度组合里维度表里不存在的组合自动变 NaN相当于做了一次维度级的外连接。逻辑非常清晰。5.3 和 set_index 的边界set_index和from_frame经常被放在一起比较。两者的关系是set_index是在 DataFrame 对象上操作把指定列提升为索引并保留其他列作为数据列。from_frame只负责构造一个索引对象输入是一张只含维度列的表输出一个 MultiIndex不关心任何业务数值。如果你只是想让当前 DataFrame 用两列当索引直接用set_index([大区,城市])更省事如果你需要的是一个可以挂到其他 DataFrame 上的“共用索引对象”那就用from_frame。我见过不少人为了让另一张表用同一套索引先把原表reset_index()再set_index()来回折腾其实不如一开始就构造一个索引对象赋值过去。5.4 从 MultiIndex 还原回 DataFrame既然from_frame是把 DataFrame 变成 MultiIndex那反向操作也很常用。把 MultiIndex 还原成 DataFrame你只需要df.reset_index()或者调用idx.to_frame(indexFalse)同样能拿到每列是一个层级的 DataFrame。需要注意的是to_frame默认indexTrue会把 MultiIndex 本身也留在结果里通常写idx.to_frame(indexFalse)更符合预期。6. 构造完之后怎么用排序判断、恢复展开、命名与选型对照6.1 四种方法的核心差别对照把四个方法放一起对比输入结构、构造语义、输出顺序、典型来源这四列最值得关注方法输入形态构造语义顺序表现典型来源from_tuples元组列表每个元组对应一行逐行映射不自动排序基本保留传入顺序数据库查询结果、已配对的标签from_arrays相同长度的数组列表按位置逐行对齐不展开不自动排序保持原位置DataFrame 多列、groupby 结果from_product多个可迭代对象自动做笛卡尔积内部 levels 会字典序化输出顺序可能改变参数扫描、全组合模拟from_frame一张维度 DataFrame每列成一个层级按 DataFrame 行顺序维度主数据表、清洗好的维度表选型时可以按这个逻辑走已有成对元组用from_tuples已有等长的多列/数组用from_arrays要自动生成所有组合用from_product手里就是一张维度表用from_frame。6.2 构造完第一件事检查索引状态无论你用哪种方法构造完索引我都建议在正式使用前做三件事print(idx.names) # 名字是否设置合理 print(idx.nlevels) # 层级数量是否符合预期 print(idx.is_monotonic_increasing) # 是否已经有排序状态如果is_monotonic_increasing是 False而你后面要做切片、部分选择就先补一句idx idx.sort_values()赋值给 DataFrame 也一样df df.sort_index()这一步可以省掉后面一大半 KeyError 和UnsortedIndexError。6.3 命名一定要在构造时想好很多人在构造 MultiIndex 时不传names数据量小的时候无所谓一旦做reset_index()层级列名只会变成level_0、level_1。在业务报告里这种列名没有任何意义后面还要再 rename非常被动。所以无论用哪种方法我强烈建议在构造时就把names写清楚。如果构造完之后想改名字也可以用idx idx.set_names([大区, 城市])但既然是顺手的事不如一开始就写好。6.4 重复索引的问题MultiIndex 允许出现重复组合比如两行都是(华东, 上海)。这在某些业务场景下是合法的但会产生两个后果df.loc[(华东, 上海)]返回的不再是 Series而是一个 DataFrame因为匹配到多行。某些聚合、性能优化操作在重复索引下会失去优势明显变慢。所以如果你拿到的数据本身有重复维度组合先想清楚这个组合在业务上是应该唯一的还是本来就允许多条记录如果本应唯一多半是数据质量问题值得在上游排查。6.5 我的一点实操体会老实说四种构造方法本身并没有绝对的高下之分更多是“手里有什么就用什么”。早期我几乎只用from_tuples觉得它直观后来数据来源越来越杂才开始体会from_arrays和from_frame在处理“多列原始字段”时的顺手程度。from_product则是做参数组合和缺失补全时才捡起来的神器。如果你也经常在 pandas 里被多层索引折腾我建议花一天时间把这四种方法都跑一遍重点不是背 API而是感受每种输入形态对应什么数据习惯。构造 MultiIndex 本身不难难的是理解每种的排序语义和数据来源这直接决定了后续切片、聚合、透视时会不会翻车。