PandasのMultiIndexの速さ

~~結論 MultiIndex は遅い。列名はtupleで保持しろ~~
→大規模なデータセットだとMultiIndexが速い時がある？

# 後の検証でMultiIndexが速い時が見つかったので、いずれ訂正版だします
Qiita 非公開にする方法がないのですが、消すのもあれなので残しときます



# 背景
とあるPythonのソフトウェアにおいて、扱うデータを下のように実装した。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/662712/340e653f-5ff3-e52d-b976-17d0221200ab.png)

列インデックスに「カテゴリ」と「アイテム」の情報を含ませ、カテゴリで抽出したりアイテムで抽出したりする（実際は2レイヤではないし、行もレイヤのあるインデックスを持たせたりしている）。

ここでデータの例として各都道府県×12ヶ月のデータを用意した。地方と都道府県の情報をもたせてあり、「地方名で抽出」「都道府県で抽出」などの取り扱いをしたい。
データの中身はランダムである。

最初は列名を「地方_都道府県」として実装した（特にこのようにした根拠はない）

しかし、この実装にはいくつか問題がある。
+ 列名に"_"の含まれるアイテム名を使用できない
+ レイヤ名が数字型であっても文字に変換される
+ 階層構造が理解しにくい

そこでPandasのMultiIndexに注目した。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/662712/1f283589-9cd6-b942-fc47-ab72b07c1ee2.png)

MultiIndexはPandasの列名に複数列もたせる実装である。
上記の"_"の問題が解決する。

# MultiIndex の問題点
MultiIndexは一見便利そうだが、実行時間で問題が発生した。

# 実験
### データ準備
```Python
import pandas as pd
import numpy as np

all_list = [("北海道", "北海道"), ("東北", "青森県"), ("東北", "岩手県"), ("東北", "宮城県"), ("東北", "秋田県"), ("東北", "山形県"), ("東北", "福島県"), ("関東", "茨城県"), ("関東", "栃木県"), ("関東", "群馬県"), ("関東", "埼玉県"), ("関東", "千葉県"), ("関東", "東京都"), ("関東", "神奈川県"), ("中部", "新潟県"), ("中部", "富山県"), ("中部", "石川県"), ("中部", "福井県"), ("中部", "山梨県"), ("中部", "長野県"), ("中部", "岐阜県"), ("中部", "静岡県"), ("中部", "愛知県"), ("近畿", "三重県"), ("近畿", "滋賀県"), ("近畿", "京都府"), ("近畿", "大阪府"), ("近畿", "兵庫県"), ("近畿", "奈良県"), ("近畿", "和歌山県"), ("中四国", "鳥取県"), ("中四国", "島根県"), ("中四国", "岡山県"), ("中四国", "広島県"), ("中四国", "山口県"), ("中四国", "徳島県"), ("中四国", "香川県"), ("中四国", "愛媛県"), ("中四国", "高知県"), ("九州", "福岡県"), ("九州", "佐賀県"), ("九州", "長崎県"), ("九州", "熊本県"), ("九州", "大分県"), ("九州", "宮崎県"), ("九州", "鹿児島県"), ("九州", "沖縄県")]
split_list = [f"{t[0]}_{t[1]}" for t in all_list]

np.random.seed(0)
value = np.random.rand(12, 47)

sample_df_M = pd.DataFrame(value, columns=pd.MultiIndex.from_tuples(all_list), index=np.arange(1,13))
sample_df_S = pd.DataFrame(value, columns=split_list, index=np.arange(1,13))
```

それぞれMultiIndexのデータフレームをsample_df_M, "_"区切りのデータフレームをsample_df_Sとする。

### 地域で抽出
地域名を入力するとその地域のデータフレームが返される
```Python
def get_area_from_M(area):
    return sample_df_M[[area]]

def get_area_from_S(area):
    pref_list = (c for c in sample_df_S.columns if area == c.split("_")[0])
    return sample_df_S[pref_list]
```


それぞれ北海道(1×12のDataFrame)と中部(9×12のDataFrame)で実行時間を計測した。

```Python
%%timeit
get_area_from_M("中部")

# 291 µs ± 5.73 µs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)
```

|地域|データセット|実行時間|
|-|-|-|
|北海道|MultiIndex|288 µs ± 5.69 µs|
|北海道|"_"区切り|250 µs ± 4.89 µs|
|中部|MultiIndex|291 µs ± 5.73 µs|
|中部|"_"区切り|250 µs ± 8.76 µs|

明らかMultiIndexより"_"区切りのほうが計算が早い
当初はMultiIndexのほうが高速だと考えていたため、この結果は正直驚いた

この結果は地域下にある都道府県数によらずこの順番であった
（ものすごく極端なデータセットの場合は要検証）

### (地域, 都道府県)で抽出
地域と都道府県両方の情報を与え、ひとつの都道府県を抽出する

```Python
def get_area_pref_from_M(area, pref):
    # return sample_df_M[[(area, pref)]]
    return sample_df_M.loc[:, [pd.IndexSlice[area, pref]]]

def get_area_pref_from_S(area, pref):
    return sample_df_S[[f"{area}_{pref}"]]
```
※get_area_pref_from_M にコメントアウトの行があるが、どちらの場合でも実行時間に差はなかった

```Python
%%timeit
get_area_pref_from_M("九州", "沖縄県")

# 1.23 ms ± 13 µs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)
```

|データセット|実行時間|
|-|-|
|MultiIndex|1.23 ms ± 13 µs|
|"_"区切り|233 µs ± 4.5 µs|

驚くことに実行時間に5倍以上の差があった
MultiIndexはめちゃくちゃ遅い

### 都道府県で抽出(地域の指定なし)
都道府県のみの情報を与え、その都道府県を抽出する

```Python
def get_pref_from_M(pref):
    return sample_df_M.loc[:, pd.IndexSlice[:, pref]]

def get_pref_from_S(pref):
    pref_list = (c for c in sample_df_S.columns if pref == c.split("_")[1])
    return sample_df_S[pref_list]
```

```Python
%%timeit
get_pref_from_M("京都府")

# 470 µs ± 3.55 µs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)
```

|データセット|実行時間|
|-|-|
|MultiIndex|470 µs ± 3.55 µs|
|"_"区切り|239 µs ± 5.34 µs|

こちらもMultiIndexが遅く、(地域, 都道府県)の抽出ほどではないが大きな開きがある

### 地域リストで抽出
次のようなリストをもとに、そのリストに含まれる地域の都道府県データを抽出する
（データの順はリストの順にならう）
```Python
area_list1 = ["東北", "九州"]
area_list2 = ["九州", "東北"]
area_list3 = ["北海道", "東北", "中部", "中四国", "九州"]
```

```Python
def get_area_list_from_M(area_list):
    return sample_df_M[area_list]

def get_area_list_from_S(area_list):
    area_list = (c for area in area_list for c in sample_df_S.columns if area in c)
    return sample_df_S[area_list]
```

|地域リスト|データセット|実行時間|
|-|-|-|
|area_list1|MultiIndex|295 µs ± 6.01 µs|
|area_list1|"_"区切り|248 µs ± 3.21 µs|
|area_list2|MultiIndex|292 µs ± 6.17 µs|
|area_list2|"_"区切り|250 µs ± 6.94 µs|
|area_list3|MultiIndex|284 µs ± 6.47 µs|
|area_list3|"_"区切り|256 µs ± 2.02 µs|

どの地域リストを使用しても"_"区切りが勝る結果となった。

# まとめ……？
MultiIndexの利点はあれあど速さにおいてデメリットが大きく、MultiIndexは使わないほうがよいといっても過言ではないくらいの結果になった。

だったら"_"区切りのデメリットを受け入れるのか……？

# tupleの列名が最強
実はMultiIndexにする直前のtupleを列名に持った状態のデータは"_"区切りと同等の速さを達成できた

```Python
sample_df_T = pd.DataFrame(value, columns=all_list, index=np.arange(1,13))
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/662712/001d34c9-29e9-9450-cbfc-1d5e49ca7be8.png)

```Python
def get_area_from_T(area):
    pref_list = (c for c in sample_df_T.columns if area == c[0])
    return sample_df_T[pref_list]

def get_area_pref_from_T(area, pref):
    return sample_df_T[[(area, pref)]]

def get_pref_from_T(pref):
    pref_list = (c for c in sample_df_T.columns if pref == c[1])
    return sample_df_T[pref_list]

def get_area_list_from_T(area_list):
    area_list = (c for area in area_list for c in sample_df_T.columns if area == c[0])
    return sample_df_T[area_list]
```

**地域名で抽出**
|地域|データセット|実行時間|
|-|-|-|
|北海道|MultiIndex|288 µs ± 5.69 µs|
|北海道|"_"区切り|250 µs ± 4.89 µs|
|北海道|tuple|237 µs ± 4.04 µs|
|中部|MultiIndex|291 µs ± 5.73 µs|
|中部|"_"区切り|250 µs ± 8.76 µs|
|中部|tuple|245 µs ± 5.25 µs|

tupleは"_"区切りより若干速い可能性まである？

**(地域, 都道府県)で抽出**
|データセット|実行時間|
|-|-|
|MultiIndex|1.23 ms ± 13 µs|
|"_"区切り|233 µs ± 4.5 µs|
|tuple|235 µs ± 5.9 µs|

**都道府県で抽出**
|データセット|実行時間|
|-|-|
|MultiIndex|470 µs ± 3.55 µs|
|"_"区切り|239 µs ± 5.34 µs|
|tuple|242 µs ± 7.23 µs|

**都道府県リストで抽出**
|地域リスト|データセット|実行時間|
|-|-|-|
|area_list1|MultiIndex|295 µs ± 6.01 µs|
|area_list1|"_"区切り|248 µs ± 3.21 µs|
|area_list1|tuple|266 µs ± 8.56 µs|
|area_list2|MultiIndex|292 µs ± 6.17 µs|
|area_list2|"_"区切り|250 µs ± 6.94 µs|
|area_list2|tuple|267 µs ± 9.25 µs|
|area_list3|MultiIndex|284 µs ± 6.47 µs|
|area_list3|"_"区切り|256 µs ± 2.02 µs|
|area_list3|tuple|283 µs ± 6.88 µs|

# 結論
tuple型の列名を使うと、"_"区切りのように文字列を工夫することなく
デメリットなくマルチレイヤの列名を実現できる。

~~tupleがMultiIndexに劣る点をしいて上げるなら見た目だが、それも`pd.MultiIndex.from_tuple`で簡単に変換できるので問題がない。~~

~~PandasでMultiIndexを使うくらいならtupleを使え~~

ということでした

# おまけ
メモリ使用量も比較してみた
```Python
sample_df_M.columns.memory_usage(deep=True)
```
上記コードで書く列名に使用しているメモリを調査した

|データセット|メモリ使用量 (B)|
|-|-|
|MultiIndex|7852|
|"_"区切り|5516|
|tuple|3328|

これでもMultiIndexが不利に……

MultiIndex 5レイヤぐらい積み重なったとき、リスト内包表記に比べ可読性があるくらいの利点しかない(そもそも5レイヤも積み上げるな)

# 今後
大規模なデータセットで再現性を確認