MultiIndexなSeriesやDataFrameのオブジェクトは、slicingやgroupbyなどの処理を行いpandas.MultiIndex.levelsにアクセスすると、slicingなどを行う前のオブジェクトが保持している全indexを返すので注意が必要です。
ref: MultiIndex#defined-levels
The MultiIndex keeps all the defined levels of an index, even if they are not actually used. When slicing an index, you may notice this.
This is done to avoid a recomputation of the levels in order to make slicing highly performant. If you want to see only the used levels, you can use the get_level_values() method.
以下では中身についてもう少し掘り下げていきます。
version
Python 3.6.8
pandas: 0.25.1
pandas.MultiIndexの概要
MulltiIndexは階層的インデックスと呼ばれるもので、1つの行または列に対して複数のラベルが階層的に付けられているようなIndex objectです。
MultiIndex / advanced indexing — pandas 0.25.1 documentation
※ ipythonで実行
In [1]: import pandas as pd
...: import numpy as np
...:
...: arrays = [['bar', 'bar', 'baz', 'baz', 'foo', 'foo', 'qux', 'qux'],
...: ['one', 'two', 'one', 'two', 'one', 'two', 'one', 'two']]
...: tuples = list(zip(*arrays))
...: index = pd.MultiIndex.from_tuples(tuples, names=['first', 'second'])
...: s = pd.Series(np.random.randn(8), index=index)
...: s
...:
Out[1]:
first second
bar one -1.907102
two -0.743662
baz one 1.116687
two 0.113606
foo one 0.100706
two -0.896215
qux one 0.385674
two 0.876058
dtype: float64
階層名がfirstである0番目の階層のindexの要素は(foo, bar, baz, qux)であり、階層名がsecondである1番目の階層のindexの要素は(one, two)であることが見た感じ分かるかと思います。階層的になっているindexを組み合わせて指定することで行(または列)をピックアップすることができます。
In [2]: s.loc[('bar', 'one')]
Out[2]: -1.907101843107428
In [3]: s.loc[('bar')]
Out[3]:
second
one -1.907102
two -0.743662
dtype: float64
indexの特定の階層で定義されているユニークなlabelには、pandas.MultiIndex.levelsでアクセスできます。
In [16]: s.index.levels
Out[16]: FrozenList([['bar', 'baz', 'foo', 'qux'], ['one', 'two']])
。。。が、このlevelsへのアクセスは扱いに注意が必要なので後述するget_level_values()かremove_unused_levels()を使ったほうが良さそうです。
pandas.MultiIndex.levelsの注意点
記事の冒頭で引用を貼りましたが、MultiIndexは実際に使われていないindexでも、一度定義されたindexのlevelsは保持されています。
Seriesをslicingして一部分だけを取り出したオブジェクトのindexを見ると、
In [13]: s[0:1]
Out[13]:
first second
bar one -1.907102
dtype: float64
In [7]: s[0:1].index
Out[7]:
MultiIndex([('bar', 'one')],
names=['first', 'second'])
のように切り出した部分にのみ使われているindexが返りますが、s[0:1].index.levelsにアクセスすると
In [9]: s[0:1].index.levels # FrozenList([['bar'], ['one']] が帰ってきそうだが違う
Out[9]: FrozenList([['bar', 'baz', 'foo', 'qux'], ['one', 'two']])
のように元のsが保持しているindexの一覧が返ってしまいます。パフォーマンスのためにこのような仕様になっているようですが、あまり直感的な挙動では無いと思うので気をつけてください。
documentにも書いてありますが、slicingしたオブジェクトの特定の階層のindexを取得したい場合はget_level_values()を、MultiIndexを再構成したい場合は remove_unused_levels()を使えばよいです。
In [53]: s[0:1].index.get_level_values(level='first')
Out[53]: Index(['bar'], dtype='object', name='first')
In [18]: s[0:1].index.remove_unused_levels().levels
Out[18]: FrozenList([['bar'], ['one']])
蛇足?
document内で言及している箇所は確認出来ませんでしたが、groupbyでも同様のことが起こります。
In [40]: group = s.groupby(level='first')
In [41]: group.get_group('baz')
Out[41]:
first second
baz one 1.116687
two 0.113606
dtype: float64
In [42]: group.get_group('baz').index.levels
Out[42]: FrozenList([['bar', 'baz', 'foo', 'qux'], ['one', 'two']])
sliceと同じようなことをやってるので仕様を理解していれば予想できると思いますが、自分はしばらくハマってしまったので残しておきます。