pandasMultiIndexPythonгруппировки

MultiIndex в pandas: иерархический индекс на практике

2026-07-11 10 мин

Если после groupby по двум-трём колонкам вы получили таблицу, к которой непонятно как обращаться, — это MultiIndex, иерархический индекс. Короткий ответ: к нему обращаются кортежами через .loc, отдельный уровень вытаскивают через .xs, режут срезами через pd.IndexSlice, а когда он мешает — сплющивают обратно в колонки через .reset_index(). Ниже разберу каждый приём на одном сквозном примере с заказами и честно скажу, где иерархия реально экономит время, а где её проще сбросить сразу.

Заведём данные, с которыми будем работать до конца статьи:

import pandas as pd

orders = pd.DataFrame({
    'region':   ['Москва', 'Москва', 'Питер', 'Питер', 'Москва', 'Питер'],
    'category': ['electronics', 'books', 'electronics', 'books', 'electronics', 'books'],
    'month':    ['2026-01', '2026-01', '2026-01', '2026-01', '2026-02', '2026-02'],
    'revenue':  [120000, 30000, 80000, 15000, 140000, 18000],
})

Откуда вообще берётся MultiIndex?

Он не появляется на пустом месте — вы создаёте его сами, обычно двумя способами. Первый и самый частый — groupby по нескольким ключам:

rev = orders.groupby(['region', 'category'])['revenue'].sum()
region  category
Москва  books           30000
        electronics    260000
Питер   books           15000
        electronics     80000
Name: revenue, dtype: int64

Слева теперь не один индекс, а два уровня: region и вложенный в него category. Это и есть MultiIndex. Ровно та же логика, что и GROUP BY region, category в SQL, только результат — не плоская таблица с двумя колонками-ключами, а Series с иерархией по строкам. Если аналогия с GROUP BY для вас не до конца очевидна, её полезно прогнать руками в SQL-тренажёре — тогда поведение pandas читается интуитивнее.

Второй источник — pivot_table, где иерархия уезжает в колонки:

pt = orders.pivot_table(
    index='region',
    columns=['month', 'category'],
    values='revenue',
    aggfunc='sum',
)

Теперь MultiIndex сидит уже по горизонтали: верхний уровень колонок — month, вложенный — category. Важно с самого начала держать в голове: индекс бывает и по строкам, и по колонкам, и приёмы для них зеркальные (просто добавляете axis=1).

Как обращаться к строкам через loc и кортежи?

Базовое правило: одна позиция в кортеже — один уровень индекса. Хотите конкретную ячейку — передавайте полный кортеж:

rev.loc[('Москва', 'electronics')]   # 260000

Хотите весь верхний уровень — передавайте только его значение, и pandas вернёт вложенную часть:

rev.loc['Москва']
category
books           30000
electronics    260000
Name: revenue, dtype: int64

С DataFrame то же самое, только не забывайте, что .loc принимает два аргумента — строки и колонки, и кортеж строкового ключа лучше явно оборачивать:

df = rev.reset_index().set_index(['region', 'category'])
df.loc[('Питер', 'books'), 'revenue']   # 15000

Здесь новичков чаще всего подстерегает неоднозначность. Запись df.loc[('Питер', 'books')] pandas может понять двояко: то ли это кортеж-ключ по строкам, то ли вы просите строку 'Питер' и колонку 'books'. Пока вы работаете с одним ключом — всё ок, но как только в дело идут списки, обязательно указывайте оба измерения явно: df.loc[('Питер', 'books'), :]. Это снимает половину загадочных KeyError.

Чем xs удобнее loc для среза по уровню?

Когда нужно вытащить не верхний, а вложенный уровень, .loc становится неуклюжим — приходится либо перечислять всё сверху, либо городить срезы. Здесь выручает .xs (cross-section): он берёт значение с указанного уровня, не заботясь о том, какой это уровень по счёту.

rev.xs('electronics', level='category')
region
Москва    260000
Питер      80000
Name: revenue, dtype: int64

Одной строкой я получил выручку по электронике во всех регионах — без упоминания регионов вообще. Для колоночного MultiIndex добавляем axis=1:

pt.xs('electronics', axis=1, level='category')
.xs хорош читаемостью: xs('electronics', level='category') буквально проговаривает намерение. Минус — по умолчанию он выбрасывает использованный уровень из результата (можно оставить через drop_level=False) и не умеет фильтровать по нескольким значениям сразу. Для «дай мне книги и электронику, но только по Питеру» одного .xs не хватит — нужен инструмент помощнее.

Как резать MultiIndex срезами через IndexSlice?

Вот тот самый инструмент. pd.IndexSlice даёт срезовый синтаксис сразу по нескольким уровням — это как df.loc[a:b, c:d], только каждый «слот» соответствует уровню иерархии.

idx = pd.IndexSlice

# все категории, но только регион Москва
rev.loc[idx['Москва', :]]

# конкретная категория во всех регионах
rev.loc[idx[:, 'electronics']]

По колонкам работает так же, через axis=1:

pt.loc[:, idx['2026-01', :]]        # весь январь
pt.loc[:, idx[:, 'electronics']]    # электроника за оба месяца
IndexSlice — самый выразительный из трёх способов, но у него есть жёсткое условие: индекс должен быть отсортирован. Иначе pandas откажется резать и бросит ошибку, о которой ниже. Отработать все три подхода — loc, xs и срезы — удобнее всего не на бумаге, а прямо в браузере: в Python-тренажёре есть задачи именно на выборку из сгруппированных данных, а формы обращения всегда под рукой в Python-справочнике.

Что делать, если loc ругается на несортированный индекс?

Вы почти наверняка увидите это сообщение, как только начнёте резать иерархию срезами:

UnsortedIndexError: MultiIndex slicing requires the index to be lexsorted

Причина простая: чтобы быстро находить диапазоны, pandas нужен лексикографически отсортированный индекс. Пока индекс «в беспорядке», бинарный поиск по нему невозможен, и срез падает. Лечится одной строкой:

rev = rev.sort_index()

Проверить, отсортировано ли, можно через rev.index.is_monotonic_increasing. И дело не только в ошибках — на больших таблицах отсортированный MultiIndex радикально ускоряет выборки: поиск по нему идёт бинарно, а не перебором всех строк. Так что sort_index() сразу после группировки — это не косметика, а привычка, которая экономит и нервы, и время выполнения. Если гоняете тяжёлые агрегаты, где секунды на счету, эта строчка окупается моментально.

Отдельно отмечу: сортировать можно и по конкретному уровню — rev.sort_index(level='category'), — если вам важен порядок именно вложенного ключа для вывода или для последующего среза.

Зачем нужны stack и unstack?

Это пара, которая переключает данные между «широким» и «длинным» форматом, гоняя уровни между строками и колонками. unstack берёт уровень из индекса строк и разворачивает его в колонки:

wide = rev.unstack('category')
category  books  electronics
region
Москва    30000       260000
Питер     15000        80000

Получилась привычная сводная таблица — удобно смотреть глазами, строить графики, считать доли по строке. Обратная операция stack схлопывает колонки обратно в индекс:

long = wide.stack()   # снова Series с MultiIndex (region, category)

На практике я гоняю их так: groupby даёт длинный формат, unstack превращает его в читаемую матрицу для отчёта, а если нужно что-то посчитать построчно и вернуть в «тидди»-вид для дальнейшей склейки — stack возвращает всё назад. Без явного аргумента unstack() разворачивает самый внутренний уровень, но лучше называть уровень по имени — код становится самодокументируемым, и вы не гадаете, что там развернулось. Именно связка groupby → unstack лежит в основе большинства продуктовых сводок — например, разбивки DAU по платформам и дням.

Когда MultiIndex помогает, а когда лучше сбросить?

Иерархия оправдана, когда данные и правда двумерные по своей природе и вы много считаете внутри групп: доли от итога по региону, ранжирование категорий внутри каждого региона, аккуратные срезы «дай мне этот месяц по всем товарам». В таких случаях MultiIndex + unstack дают короткий и точный код, а выравнивание по индексу при арифметике работает само собой.

А вот когда пора сбрасывать:

Сброс делается одной командой:

flat = rev.reset_index()
   region     category  revenue
0  Москва        books    30000
1  Москва  electronics   260000
2   Питер        books    15000
3   Питер  electronics    80000
reset_index возвращает уровни индекса обратно в обычные колонки — и вы снова в плоском, привычном мире. Мой рабочий принцип: считать удобно в иерархии, а отдавать — всегда плоско. Держать MultiIndex «на всякий случай» до самого экспорта смысла нет.

Какие ещё грабли встречаются чаще всего?

Собрал то, на чём спотыкаются регулярно:

Как отработать это на реальных задачах?

Теория про MultiIndex укладывается в голове за один вечер, а вот беглость приходит только от повторов на разных данных. Разложите один датасет groupby по двум ключам, вытащите срез через .xs, разверните через unstack, посчитайте доли и сплющите обратно — и через десяток таких прогонов кортежи в .loc перестанут вызывать ступор.

С чего начать тренировку:

Если хотите закрыть тему разом — в Pro открыты все задачи по Python и SQL, разборы кейсов и AI мок-собесы, где можно проговорить вслух, почему после groupby вы обращаетесь к результату именно кортежем, а не строкой. Это ровно тот навык, который отличает «читал про MultiIndex» от «работаю с ним не задумываясь».

Закрепи Python на задачах
402 Python-задачи через pandas/numpy/scipy — попробуй бесплатно.
Python-тренажёр →