MultiIndex
MultiIndex
Core Principle: A
MultiIndexprovides hierarchical, multi-level labeling across rows or columns, enabling the representation and manipulation of higher-dimensional datasets within 2D DataFrames.
Creating one
df.set_index(["chrom", "pos"])
df.groupby(["gene", "sample"]).mean() # grouping by 2+ keys produces one
df.pivot_table(index="gene", columns=["tissue", "condition"])
pd.MultiIndex.from_tuples([("chr1", 100), ("chr1", 200)], names=["chrom", "pos"])
pd.MultiIndex.from_product([["chr1", "chr2"], [100, 200]], names=["chrom", "pos"])
pd.MultiIndex.from_arrays([chroms, positions], names=["chrom", "pos"])
from_product is how you build a complete grid: every chromosome × every bin: which is useful for reindexing sparse results into a full matrix.
Selecting
df.loc["chr1"] # whole first level → drops that level
df.loc[("chr1", 7676154)] # a full key
df.loc[("chr1", 7676154), "ref"] # key + column
df.loc["chr1":"chr3"] # slice the outer level (needs sorting)
# partial selection on inner levels needs slice(None) or IndexSlice
idx = pd.IndexSlice
df.loc[idx[:, 7676154], :] # all chromosomes, that position
df.loc[idx["chr1", 100:200], :]
df.xs("chr1", level="chrom") # cross-section, drops the level
df.xs(7676154, level="pos") # select on an inner level directly
.xs is the readable option for "give me one value of one level". IndexSlice is more general but harder to read.
Sort before you slice
df = df.sort_index()
df.index.is_monotonic_increasing # must be True for range slicing
Slicing an unsorted MultiIndex raises UnsortedIndexError, or is quietly slow. Sort immediately after creating one. This is the single most common MultiIndex frustration.
Level manipulation
df.index.names # ['chrom', 'pos']
df.index.get_level_values("chrom") # flat array of that level
df.index.levels # the unique values per level
df.reset_index(level="pos") # one level → column
df.reset_index() # all levels → columns
df.swaplevel("chrom", "pos").sort_index()
df.droplevel("chrom")
df.reorder_levels(["pos", "chrom"])
Grouping and stacking
df.groupby(level="chrom").size()
df.groupby(level=[0, 1]).mean()
df.sum(level=...) # removed: use groupby(level=...)
df.stack() # columns → index level (wider → longer)
df.unstack() # index level → columns (longer → wider)
df.unstack(level="condition")
df.unstack(fill_value=0)
stack/unstack are the MultiIndex-aware cousins of melt/pivot. See Reshaping with pivot and melt.
MultiIndex on columns
Just as common, and often the more useful direction:
expr.columns = pd.MultiIndex.from_arrays(
[tissues, conditions, replicates], names=["tissue", "condition", "rep"]
)
expr["liver"] # all liver samples
expr.loc[:, idx[:, "treated", :]] # all treated, any tissue
expr.T.groupby(level=["tissue", "condition"]).mean().T # collapse replicates
That last line: averaging technical replicates within condition: is a genuinely elegant use of a column MultiIndex, and awkward without one.
When it earns its complexity
Worth it:
- Genuinely hierarchical keys: (chromosome, position), (gene, transcript, exon), (patient, timepoint).
- The output of a multi-key
groupbyyou will immediatelyunstack. - Repeated selection on the outer level of a large frame: the sorted index makes it fast.
- Column hierarchies over a wide experimental design.
Not worth it:
- As a habit. Flat columns plus
groupbyhandles most work and is far easier to read, merge and debug. - When you will immediately merge with something else.
mergeon MultiIndexes is fiddly;reset_index()first. - When collaborators will read your code. MultiIndex selection syntax has a real learning cost.
The honest advice: reach for it when the data is hierarchical and you will exploit that; otherwise reset_index() and move on.
Bioinformatics examples
# a long-format expression table indexed by gene and sample
long = counts.melt(id_vars="gene", var_name="sample", value_name="count")
mi = long.set_index(["gene", "sample"]).sort_index()
mi.loc["TP53"] # all samples for one gene
mi.loc[("TP53", "sample_3"), "count"] # one value
mi.groupby(level="gene")["count"].mean()
# variants keyed by locus
v = variants.set_index(["chrom", "pos"]).sort_index()
v.loc["chr17"] # all chr17 variants
v.loc[("chr17", slice(7_670_000, 7_680_000))] # a genomic window
# a summary table that reads well
summary = de.groupby(["tissue", "direction"])["gene"].count().unstack(fill_value=0)
Common mistakes
- Slicing without sorting →
UnsortedIndexError. - Forgetting the tuple.
df.loc["chr1", 7676154]is interpreted as (row, column), not as a two-level key. You needdf.loc[("chr1", 7676154)]. - Levels retaining unused categories after filtering:
df.index.levelsstill lists them. Usedf.index.remove_unused_levels(). - Merging on a MultiIndex without resetting first.
to_csvround-trip: a MultiIndex writes fine but needsindex_col=[0, 1]on read.- Using it when flat columns would do. Complexity you pay for on every subsequent line.
See also
Index Objects · groupby · Reshaping with pivot and melt · loc vs iloc · DataFrame
Exercise
Write window_report(text). The first line of stdin is a query: a chromosome
name, a start position and an end position separated by spaces. Every following
line is a variant: chromosome, position and quality, also space separated.
Return a single string.
Index the variants by (chrom, pos), sort the index, and select the requested
closed window start <= pos <= end on that chromosome by slicing the
MultiIndex: not by boolean masking the columns. Return
<chrom> n=<count> mean=<mean qual to 3 dp> top=<position of the highest quality>. When the window contains no variants: including when the chromosome
is absent from the data entirely: return <chrom> n=0 and nothing more. If
two variants tie on quality, report the lower position.
Test cases · 4
| # | via | input | expected stdout |
|---|---|---|---|
| 1 | stdin | chr17 7670000 7680000 chr17 7676154 99.0 chr17 7676521 45.0 chr17 7687377 12.0 chr1 7676200 88.0 chr17 7669000 30.0 | chr17 n=2 mean=72.0 top=7676154 |
| 2 | stdin | chr7 55191000 55192000 chr7 55191822 88.5 chr7 55191900 88.5 chr12 25245350 12.4 | chr7 n=2 mean=88.5 top=55191822 |
| 3 | stdin | chrX 1 1000 chr1 500 10.0 chr2 600 20.0 | chrX n=0 |
| 4 | stdin | chr1 100 200 chr1 50 10.0 | chr1 n=0 |
Hints · 3
01Hint
df.set_index(['chrom', 'pos']).sort_index() — slicing an unsorted MultiIndex raises UnsortedIndexError.
02Hint
A partial key with a slice looks like mi.loc[(chrom, slice(start, end)), :].
03Hint
Asking for a chromosome that is not in the index raises KeyError, so check index.get_level_values first; idxmax on a sorted index returns the (chrom, pos) tuple of the first maximum.