pandas.MultiIndex.levelsの挙動の注意点



MultiIndexな`Series`や`DataFrame`のオブジェクトは、slicingやgroupbyなどの処理を行い`pandas.MultiIndex.levels`にアクセスすると、slicingなどを行う前のオブジェクトが保持している全indexを返すので注意が必要です。

ref: [MultiIndex#defined-levels](https://pandas.pydata.org/pandas-docs/stable/user_guide/advanced.html#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](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.MultiIndex.html#pandas.MultiIndex)は階層的インデックスと呼ばれるもので、1つの行または列に対して複数のラベルが階層的に付けられているようなIndex objectです。

[MultiIndex / advanced indexing — pandas 0.25.1 documentation](https://pandas.pydata.org/pandas-docs/stable/user_guide/advanced.html)

※ `ipython`で実行

```Python
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を組み合わせて指定することで行(または列)をピックアップすることができます。

```Python
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`でアクセスできます。

```Python
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を見ると、

```Python
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`にアクセスすると

```Python
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()`を使えばよいです。

```Python
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でも同様のことが起こります。

```Python
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と同じようなことをやってるので仕様を理解していれば予想できると思いますが、自分はしばらくハマってしまったので残しておきます。