Выделение строк в пандах MultiIndex DataFrame

Цель и мотивация

API MultiIndex с годами набирает популярность, однако не все в нем полностью понимается с точки зрения структуры, работы и связанных операций.

Одна важная операция - фильтрация. Фильтрация является распространенным требованием, но варианты использования разнообразны. Соответственно, определенные методы и функции будут более применимы к некоторым случаям использования, чем другие.

Таким образом, цель этого поста - затронуть некоторые распространенные проблемы фильтрации и варианты использования, продемонстрировать различные методы для решения этих проблем и обсудить их применимость. Вот некоторые вопросы высокого уровня, на которые этот пост пытается ответить:

  • Нарезка на основе одного значения/метки
  • Нарезка на основе нескольких меток одного или нескольких уровней
  • Фильтрация по логическим условиям и выражениям
  • Какие методы применимы при каких обстоятельствах

Эти проблемы были разбиты на 6 конкретных вопросов, перечисленных ниже. Для простоты пример DataFrames в приведенной ниже настройке имеет только два уровня и не имеет дублирующих индексных ключей. Большинство представленных решений проблем могут обобщаться до N уровней.

В этом посте не будет рассказано, как создавать мультииндексы, как выполнять над ними операции назначения или какие-либо обсуждения, связанные с производительностью (это отдельные темы для другого времени)


Вопросы

Вопрос 1-6 будет задан в контексте нижеприведенной настройки.

mux = pd.MultiIndex.from_arrays([
    list('aaaabbbbbccddddd'),
    list('tuvwtuvwtuvwtuvw')
], names=['one', 'two'])

df = pd.DataFrame({'col': np.arange(len(mux))}, mux)

         col
one two     
a   t      0
    u      1
    v      2
    w      3
b   t      4
    u      5
    v      6
    w      7
    t      8
c   u      9
    v     10
d   w     11
    t     12
    u     13
    v     14
    w     15

Вопрос 1: Выбор одного элемента
Как выбрать строки, имеющие "a" на уровне "one"?

         col
one two     
a   t      0
    u      1
    v      2
    w      3

Кроме того, как я могу снизить уровень "один" в выводе?

     col
two     
t      0
u      1
v      2
w      3

Вопрос 1b
Как мне нарезать все строки со значением "t" на уровне "два"?

         col
one two     
a   t      0
b   t      4
    t      8
d   t     12

Вопрос 2: Выбор нескольких значений на уровне
Как выбрать строки, соответствующие элементам "b" и "d" на уровне "один"?

         col
one two     
b   t      4
    u      5
    v      6
    w      7
    t      8
d   w     11
    t     12
    u     13
    v     14
    w     15

Вопрос 2b
Как получить все значения, соответствующие "t" и "w" на уровне "два"?

         col
one two     
a   t      0
    w      3
b   t      4
    w      7
    t      8
d   w     11
    t     12
    w     15

Вопрос 3: Нарезка одного поперечного сечения (x, y)
Как мне получить поперечное сечение, то есть одну строку, имеющую определенные значения для индекса из df? В частности, как мне получить поперечное сечение ('c', 'u'), заданное

         col
one two     
c   u      9

Вопрос 4: Нарезка нескольких поперечных сечений [(a, b), (c, d), ...]
Как выбрать две строки, соответствующие ('c', 'u') и ('a', 'w')?

         col
one two     
c   u      9
a   w      3

Вопрос 5: Один предмет, нарезанный на уровень
Как я могу получить все строки, соответствующие "a" на уровне "one" или "t" на уровне "two"?

         col
one two     
a   t      0
    u      1
    v      2
    w      3
b   t      4
    t      8
d   t     12

Вопрос 6: Произвольная нарезка
Как я могу нарезать определенные сечения? Для "a" и "b" я хотел бы выбрать все строки с подуровнями "u" и "v", а для "d" я хотел бы выбрать строки с подуровнем "w".

         col
one two     
a   u      1
    v      2
b   u      5
    v      6
d   w     11
    w     15

Вопрос 7 будет использовать уникальную настройку, состоящую из числового уровня:

np.random.seed(0)
mux2 = pd.MultiIndex.from_arrays([
    list('aaaabbbbbccddddd'),
    np.random.choice(10, size=16)
], names=['one', 'two'])

df2 = pd.DataFrame({'col': np.arange(len(mux2))}, mux2)

         col
one two     
a   5      0
    0      1
    3      2
    3      3
b   7      4
    9      5
    3      6
    5      7
    2      8
c   4      9
    7     10
d   6     11
    8     12
    8     13
    1     14
    6     15

Вопрос 7: Фильтрация на основе числовых уровней на основе неравенства
Как получить все строки, в которых значения на уровне "два" больше 5?

         col
one two     
b   7      4
    9      5
c   7     10
d   6     11
    8     12
    8     13
    6     15

Ответ 1

MultiIndex/Advanced Indexing

Примечание
Этот пост будет структурирован следующим образом:

  1. Вопросы, изложенные в ФП, будут рассмотрены один за другим
  2. Для каждого вопроса будет продемонстрирован один или несколько методов, применимых для решения этой проблемы и получения ожидаемого результата.

Примечание(как и это) будет включено для читателей, заинтересованных в изучении дополнительных функций, деталей реализации, и другая информация, краткая к теме под рукой. Эти заметки были составлено путем обыскивания документов и раскрытия различных темных особенности, и из моего собственного (по общему признанию ограниченного) опыта.

Все примеры кода созданы и протестированы на pandas v0.23.4, python3.7. Если что-то не понятно, или фактически неверно, или если вы этого не сделали найти решение, применимое к вашему варианту использования, пожалуйста, не стесняйтесь предложить редактирование, запросить разъяснения в комментариях или открыть новый вопрос.... если применимо.

Вот введение в некоторые распространенные идиомы (далее именуемые "Четыре идиомы"), которые мы будем часто посещать повторно

  1. DataFrame.loc - общее решение для выбора по метке (+ pd.IndexSlice для более сложных приложений, включающих срезы)

  2. DataFrame.xs - Извлечение определенного поперечного сечения из Series/DataFrame.

  3. DataFrame.query - Укажите операции нарезки и/или фильтрации динамически (т.е. как выражение, которое оценивается динамически. Более применимо к некоторым сценариям, чем к другим. Также см. этот раздел документации для запроса на мультииндексах.

  4. Булево индексирование с использованием маски, созданной с помощью MultiIndex.get_level_values (часто в сочетании с Index.isin, особенно при фильтрации по нескольким значениям). Это также весьма полезно в некоторых обстоятельствах.

Будет полезно взглянуть на различные проблемы нарезки и фильтрации в терминах четырех идиом, чтобы лучше понять, что можно применить к данной ситуации. Очень важно понимать, что не все идиомы будут работать одинаково хорошо (если вообще) при любых обстоятельствах. Если идиома не была указана в качестве потенциального решения проблемы ниже, это означает, что идиома не может быть эффективно применена к этой проблеме.


Вопрос 1

Как выбрать строки, имеющие "a" на уровне "one"?

         col
one two     
a   t      0
    u      1
    v      2
    w      3

Вы можете использовать loc в качестве решения общего назначения, применимого к большинству ситуаций:

df.loc[['a']]

На этом этапе, если вы получаете

TypeError: Expected tuple, got str

Это означает, что вы используете старую версию панд. Подумайте об обновлении! В противном случае используйте df.loc[('a', slice(None)), :].

В качестве альтернативы вы можете использовать xs здесь, так как мы извлекаем одно поперечное сечение. Обратите внимание на аргументы levels и axis (здесь можно принять разумные значения по умолчанию).

df.xs('a', level=0, axis=0, drop_level=False)
# df.xs('a', drop_level=False)

Здесь необходим аргумент drop_level=False, чтобы xs не сбрасывал уровень "один" в результате (уровень, на который мы нарезали).

Еще один вариант здесь использует query:

df.query("one == 'a'")

Если у индекса нет имени, вам нужно изменить строку запроса на "ilevel_0 == 'a'".

Наконец, используя get_level_values:

df[df.index.get_level_values('one') == 'a']
# If your levels are unnamed, or if you need to select by position (not label),
# df[df.index.get_level_values(0) == 'a']

Кроме того, как я могу снизить уровень "один" в выводе?

     col
two     
t      0
u      1
v      2
w      3

Это легко сделать с помощью

df.loc['a'] # Notice the single string argument instead the list.

Или

df.xs('a', level=0, axis=0, drop_level=True)
# df.xs('a')

Обратите внимание, что мы можем опустить аргумент drop_level (по умолчанию предполагается, что он True).

Примечание
Вы можете заметить, что отфильтрованный DataFrame может иметь все уровни, даже если они не отображаются при распечатке DataFrame. Например,

v = df.loc[['a']]
print(v)
         col
one two     
a   t      0
    u      1
    v      2
    w      3

print(v.index)
MultiIndex(levels=[['a', 'b', 'c', 'd'], ['t', 'u', 'v', 'w']],
           labels=[[0, 0, 0, 0], [0, 1, 2, 3]],
           names=['one', 'two'])

Вы можете избавиться от этих уровней, используя MultiIndex.remove_unused_levels:

v.index = v.index.remove_unused_levels()

print(v.index)
MultiIndex(levels=[['a'], ['t', 'u', 'v', 'w']],
           labels=[[0, 0, 0, 0], [0, 1, 2, 3]],
           names=['one', 'two'])

Вопрос 1b

Как мне нарезать все строки со значением "t" на уровне "два"?

         col
one two     
a   t      0
b   t      4
    t      8
d   t     12

Интуитивно понятно, что вам нужно что-то, связанное с slice():

df.loc[(slice(None), 't'), :]

Это просто работает! ™ Но это неуклюже. Здесь мы можем упростить более естественный синтаксис секционирования, используя API pd.IndexSlice.

idx = pd.IndexSlice
df.loc[idx[:, 't'], :]

Это намного, намного чище.

Примечание
Почему требуется конечный срез : по столбцам? Это связано с тем, что loc можно использовать для выбора и нарезки вдоль обеих осей (axis=0 или axis=1). Без явного указания, на какую ось нарезать должно быть сделано, операция становится неоднозначной. Смотрите большое красное поле в документации по нарезке.

Если вы хотите удалить любой оттенок двусмысленности, loc принимает axis Параметр:

df.loc(axis=0)[pd.IndexSlice[:, 't']]

Без параметра axis (то есть, просто выполняя df.loc[pd.IndexSlice[:, 't']]), предполагается, что срезание выполняется по столбцам, и KeyError будет поднят в этом случае.

Это задокументировано в слайсерах. Однако для целей этого поста мы явно укажем все оси.

С xs это

df.xs('t', axis=0, level=1, drop_level=False)

С query это

df.query("two == 't'")
# Or, if the first level has no name, 
# df.query("ilevel_1 == 't'") 

И, наконец, с get_level_values вы можете сделать

df[df.index.get_level_values('two') == 't']
# Or, to perform selection by position/integer,
# df[df.index.get_level_values(1) == 't']

Все с одинаковым эффектом.


Вопрос 2

Как выбрать строки, соответствующие элементам "b" и "d" на уровне "один"?

         col
one two     
b   t      4
    u      5
    v      6
    w      7
    t      8
d   w     11
    t     12
    u     13
    v     14
    w     15

Используя loc, это делается аналогичным образом, указав список.

df.loc[['b', 'd']]

Чтобы решить вышеуказанную проблему выбора "b" и "d", вы также можете использовать query:

items = ['b', 'd']
df.query("one in @items")
# df.query("one == @items", parser='pandas')
# df.query("one in ['b', 'd']")
# df.query("one == ['b', 'd']", parser='pandas')

Примечание
Да, синтаксическим анализатором по умолчанию является 'pandas', но важно подчеркнуть, что этот синтаксис обычно не является python. Парсер Pandas генерирует немного другое дерево разбора из выражение. Это сделано для того, чтобы сделать некоторые операции более понятными для указывать. Для получения дополнительной информации, пожалуйста, прочитайте мой пост на Оценка динамических выражений в пандах с использованием pd.eval().

И с get_level_values + Index.isin:

df[df.index.get_level_values("one").isin(['b', 'd'])]

Вопрос 2b

Как получить все значения, соответствующие "t" и "w" на уровне "два"?

         col
one two     
a   t      0
    w      3
b   t      4
    w      7
    t      8
d   w     11
    t     12
    w     15

С loc это возможно только в сочетании с pd.IndexSlice.

df.loc[pd.IndexSlice[:, ['t', 'w']], :] 

Первое двоеточие : в pd.IndexSlice[:, ['t', 'w']] означает разрез по первому уровню. По мере увеличения глубины запрашиваемого уровня вам нужно будет указывать больше срезов, по одному на каждый уровень. Однако вам не нужно указывать больше уровней, чем нарезанный.

С query это

items = ['t', 'w']
df.query("two in @items")
# df.query("two == @items", parser='pandas') 
# df.query("two in ['t', 'w']")
# df.query("two == ['t', 'w']", parser='pandas')

С get_level_values и Index.isin (аналогично описанному выше):

df[df.index.get_level_values('two').isin(['t', 'w'])]

Вопрос 3

Как мне получить поперечное сечение, то есть одну строку, имеющую определенные значения для индекса из df? В частности, как мне получить крест раздел ('c', 'u'), данный

         col
one two     
c   u      9

Используйте loc, указав набор ключей:

df.loc[('c', 'u'), :]

Или

df.loc[pd.IndexSlice[('c', 'u')]]

Примечание
На этом этапе вы можете столкнуться с PerformanceWarning, который выглядит следующим образом:

PerformanceWarning: indexing past lexsort depth may impact performance.

Это просто означает, что ваш индекс не отсортирован. pandas зависит от сортируемого индекса (в данном случае лексикографически, поскольку мы имеем дело со строковыми значениями) для оптимального поиска и извлечения. Быстрое решение будет сортировать ваши Предварительно используйте DataFrame, используя DataFrame.sort_index. Это особенно желательно с точки зрения производительности, если вы планируете делать несколько таких запросов в тандеме:

df_sort = df.sort_index()
df_sort.loc[('c', 'u')]

Вы также можете использовать MultiIndex.is_lexsorted(), чтобы проверить, является ли индекс сортируется или нет. Эта функция возвращает True или False соответственно. Вы можете вызвать эту функцию, чтобы определить, нужна ли дополнительная сортировка шаг требуется или нет.

С xs, это снова просто передача одного кортежа в качестве первого аргумента, со всеми остальными аргументами, для которых установлены соответствующие значения по умолчанию:

df.xs(('c', 'u'))

С query все становится немного неуклюжим:

df.query("one == 'c' and two == 'u'")

Теперь вы можете видеть, что это будет относительно сложно обобщить. Но все еще в порядке для этой конкретной проблемы.

С доступом, охватывающим несколько уровней, get_level_values все еще можно использовать, но это не рекомендуется:

m1 = (df.index.get_level_values('one') == 'c')
m2 = (df.index.get_level_values('two') == 'u')
df[m1 & m2]

Вопрос 4

Как выбрать две строки, соответствующие ('c', 'u') и ('a', 'w')?

         col
one two     
c   u      9
a   w      3

С loc это так же просто, как:

df.loc[[('c', 'u'), ('a', 'w')]]
# df.loc[pd.IndexSlice[[('c', 'u'), ('a', 'w')]]]

В query вам нужно будет динамически генерировать строку запроса, перебирая сечения и уровни:

cses = [('c', 'u'), ('a', 'w')]
levels = ['one', 'two']
# This is a useful check to make in advance.
assert all(len(levels) == len(cs) for cs in cses) 

query = '(' + ') or ('.join([
    ' and '.join([f"({l} == {repr(c)})" for l, c in zip(levels, cs)]) 
    for cs in cses
]) + ')'

print(query)
# ((one == 'c') and (two == 'u')) or ((one == 'a') and (two == 'w'))

df.query(query)

100% НЕ РЕКОМЕНДУЕМ! Но это возможно.


Вопрос 5

Как я могу получить все строки, соответствующие "а" на уровне "один" или "т" на уровне "два"?

         col
one two     
a   t      0
    u      1
    v      2
    w      3
b   t      4
    t      8
d   t     12

На самом деле это очень трудно сделать с loc, обеспечивая при этом правильность и сохраняя ясность кода. df.loc[pd.IndexSlice['a', 't']] неверен, он интерпретируется как df.loc[pd.IndexSlice[('a', 't')]] (то есть, выбор поперечного сечения). Можно подумать о решении с pd.concat для обработки каждой метки отдельно:

pd.concat([
    df.loc[['a'],:], df.loc[pd.IndexSlice[:, 't'],:]
])

         col
one two     
a   t      0
    u      1
    v      2
    w      3
    t      0   # Does this look right to you? No, it isn't!
b   t      4
    t      8
d   t     12

Но вы заметите, что одна из строк дублирована. Это потому, что этот ряд удовлетворял обоим условиям нарезки, и поэтому появился дважды. Вместо этого вам нужно будет сделать

v = pd.concat([
        df.loc[['a'],:], df.loc[pd.IndexSlice[:, 't'],:]
])
v[~v.index.duplicated()]

Но если ваш DataFrame по своей сути содержит дублирующиеся индексы (которые вы хотите), то это не сохранит их. Используйте с особой осторожностью.

С query это глупо просто:

df.query("one == 'a' or two == 't'")

С get_level_values это все еще просто, но не так элегантно:

m1 = (df.index.get_level_values('one') == 'a')
m2 = (df.index.get_level_values('two') == 't')
df[m1 | m2] 

Вопрос 6

Как я могу нарезать определенные сечения? Для "a" и "b" я бы хотел выбрать все строки с подуровнями "u" и "v", и для "d" я бы хотел выбрать строки с подуровнем "w".

         col
one two     
a   u      1
    v      2
b   u      5
    v      6
d   w     11
    w     15

Это особый случай, который я добавил, чтобы помочь понять применимость Четырех идиом - это один из случаев, когда ни один из них не будет работать эффективно, так как срезы очень специфичны и не следуют никакой реальной схеме.

Как правило, для решения таких задач требуется явная передача списка ключей в loc. Один из способов сделать это с помощью:

keys = [('a', 'u'), ('a', 'v'), ('b', 'u'), ('b', 'v'), ('d', 'w')]
df.loc[keys, :]

Если вы хотите сохранить некоторую типизацию, вы поймете, что есть шаблон для нарезки "a", "b" и его подуровней, поэтому мы можем разделить задачу нарезки на две части и получить concat результат:

pd.concat([
     df.loc[(('a', 'b'), ('u', 'v')), :], 
     df.loc[('d', 'w'), :]
   ], axis=0)

Спецификация нарезки для "a" и "b" немного чище (('a', 'b'), ('u', 'v')), потому что одни и те же индексируемые подуровни одинаковы для каждого уровня.


Вопрос 7

Как получить все строки, в которых значения на уровне "два" больше 5?

         col
one two     
b   7      4
    9      5
c   7     10
d   6     11
    8     12
    8     13
    6     15

Это можно сделать с помощью query,

df2.query("two > 5")

И get_level_values.

df2[df2.index.get_level_values('two') > 5]

Примечание
Подобно этому примеру, мы можем фильтровать на основе любого произвольного условия, используя эти конструкции. В общем, полезно помнить, что loc и xs специально предназначены для индексации на основе меток, тогда как query и get_level_values полезны для построения общих условных масок для фильтрации.


Бонусный вопрос

Что если мне нужно нарезать столбец MultiIndex?

На самом деле, большинство решений здесь применимо и к столбцам с небольшими изменениями. Рассмотрим:

np.random.seed(0)
mux3 = pd.MultiIndex.from_product([
        list('ABCD'), list('efgh')
], names=['one','two'])

df3 = pd.DataFrame(np.random.choice(10, (3, len(mux))), columns=mux3)
print(df3)

one  A           B           C           D         
two  e  f  g  h  e  f  g  h  e  f  g  h  e  f  g  h
0    5  0  3  3  7  9  3  5  2  4  7  6  8  8  1  6
1    7  7  8  1  5  9  8  9  4  3  0  3  5  0  2  3
2    8  1  3  3  3  7  0  1  9  9  0  4  7  3  2  7

Это следующие изменения, которые вам необходимо внести в Four Idioms, чтобы они работали со столбцами.

  1. Для нарезки с помощью loc используйте

    df3.loc[:, ....] # Notice how we slice across the index with ':'. 
    

    или

    df3.loc[:, pd.IndexSlice[...]]
    
  2. Чтобы использовать xs соответствующим образом, просто передайте аргумент axis=1.

  3. Вы можете получить доступ к значениям уровня столбца напрямую, используя df.columns.get_level_values. Затем вам нужно будет сделать что-то вроде

    df.loc[:, {condition}] 
    

    Где {condition} представляет некоторое условие, построенное с использованием columns.get_level_values.

  4. Чтобы использовать query, единственный вариант - это транспонировать, запрашивать индекс и снова транспонировать:

    df3.T.query(...).T
    

    Не рекомендуется, используйте один из трех других вариантов.