Если после 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 дают короткий и точный код, а выравнивание по индексу при арифметике работает само собой.
А вот когда пора сбрасывать:
- вы отдаёте результат наружу — в CSV, в дашборд, в SQL-таблицу: плоские колонки читают все, кортежи-ключи — почти никто;
- дальше идёт
merge/join: сливать по обычным колонкам предсказуемее, чем по уровням индекса; - в команде есть люди, которым кортежи в
.locдаются тяжело, — простота кода важнее элегантности.
Сброс делается одной командой:
flat = rev.reset_index()
region category revenue
0 Москва books 30000
1 Москва electronics 260000
2 Питер books 15000
3 Питер electronics 80000
reset_index возвращает уровни индекса обратно в обычные колонки — и вы снова в плоском, привычном мире. Мой рабочий принцип: считать удобно в иерархии, а отдавать — всегда плоско. Держать MultiIndex «на всякий случай» до самого экспорта смысла нет.
Какие ещё грабли встречаются чаще всего?
Собрал то, на чём спотыкаются регулярно:
- Колоночный MultiIndex после
pivot_tableломает дальнейший код, который ждёт плоские имена. Сплющивайте их явно:pt.columns = ['_'.join(map(str, c)) for c in pt.columns]. reset_index()пытается создать колонку с именем существующего уровня и падает с конфликтом имён — переименуйте уровень заранее черезrename_axisили используйтеreset_index(name='revenue')для Series..loc[('Москва',)](кортеж из одного элемента с запятой) и.loc['Москва']— не одно и то же в краевых случаях; когда не уверены, оборачивайте выбор в явныйIndexSlice.- Забытый
sort_index()— источник иUnsortedIndexError, и тихогоPerformanceWarning, и медленных выборок. Ставьте сортировку привычкой сразу после группировки. groupby(...).agg(...)с несколькими функциями создаёт MultiIndex ещё и по колонкам — итог с иерархией по обеим осям. Если не готовы к этому, агрегируйте по одному показателю или сразу зовитеreset_index.
Как отработать это на реальных задачах?
Теория про MultiIndex укладывается в голове за один вечер, а вот беглость приходит только от повторов на разных данных. Разложите один датасет groupby по двум ключам, вытащите срез через .xs, разверните через unstack, посчитайте доли и сплющите обратно — и через десяток таких прогонов кортежи в .loc перестанут вызывать ступор.
С чего начать тренировку:
- Задачи по pandas — группировки, сводные, срезы по иерархии с проверкой прямо в браузере.
- Тестовые задания аналитика — форматы, которые реально дают на собеседованиях, с разбором.
- Вопросы с собеседований — про разницу
pivot/groupby,stack/unstackи обращение к иерархии. - Разборы кейсов — где иерархический индекс встраивается в целую задачу, а не живёт в вакууме.
- Курс по SQL — чтобы держать в голове мостик между
GROUP BYи pandas-группировкой.
Если хотите закрыть тему разом — в Pro открыты все задачи по Python и SQL, разборы кейсов и AI мок-собесы, где можно проговорить вслух, почему после groupby вы обращаетесь к результату именно кортежем, а не строкой. Это ровно тот навык, который отличает «читал про MultiIndex» от «работаю с ним не задумываясь».