pandas 1.2.0 新機能かいつまみ

# pandas 1.2.0 新機能かいつまみ

pandas 1.2.0 がリリースされたので、気になった新機能をメモ

興味のない内容や細かい変更修正はスルーしたので、[すべての変更はこちら](https://pandas.pydata.org/pandas-docs/stable/whatsnew/v1.2.0.html)

## 行名・列名に重複を許さない設定

### 前提

pandasの`Index`は重複したラベルを持つことができる。つまり、データフレームおよびシリーズの行名や列名は一意でなくても良い。

例えば以下のデータフレーム`df_dlabel`は、行名が`'a'`の行が２つ、列名が`'B'`の列が２つある。

```python
df_dlabel = pd.DataFrame(np.arange(9).reshape(3, 3),
                         index=list('aab'), columns=list('ABB'))
df_dlabel
```

|      |    A |    B |    B |
| :--- | ---: | ---: | ---: |
| a    |    0 |    1 |    2 |
| a    |    3 |    4 |    5 |
| b    |    6 |    7 |    8 |

データこねくりまわし民にとって、これが許されていることは時に不自然に思われる。例えばSQLの主キーは基本的に重複を許さない。

### 行名が一意でないと不都合になる例

pandasのデータフレームはスライスや`.loc`等で列名を指定すると、普通はシリーズが返ってくる。しかし重複した列名だと返ってくるのはデータフレームである。このように、戻り結果が曖昧なのはよくないことである。

```python
df_dlabel['A']

df_dlabel['B']
```

> ```python
> # df_dlabel['A'] -> pd.Series
> a    0
> a    3
> b    6
> Name: A, dtype: int32
>
> # df_dlabel['B'] -> pd.DataFrame
>    B  B
> a  1  2
> a  4  5
> b  7  8
> ```

同様に、`.loc`等で行名と列名を同時に指定したときや、`.at`を用いた場合、通常返ってくるのはスカラー値であるが、名前が重複しているとシリーズやデータフレームが返ってくる。

```python
df_dlabel.at['b', 'A']

df_dlabel.at['b', 'B']

df_dlabel.at['a', 'B']
```

> ```python
> # df_dlabel.at['b', 'A'] -> int
> 6
>
> # df_dlabel.at['b', 'B'] -> pd.Series
> B    7
> B    8
> Name: b, dtype: int32
>
> # df_dlabel.at['a', 'B'] -> pd.DataFrame
>    B  B
> a  1  2
> a  4  5
> ```

pandasには行名・列名が一意であることが前提の関数・メソッドが存在しており、それに直面するとエラーが出る（あるいは運が悪ければバグる）。

以下は引数に重複した列名を指定した場合にエラーが出る例。

```python
df_dlabel.merge(df_dlabel, on='A')

df_dlabel.merge(df_dlabel, on='B')
```

> ```python
> # df_dlabel.merge(df_dlabel, on='A') うまくいく
>    A  B_x  B_x  B_y  B_y
> 0  0    1    2    1    2
> 1  3    4    5    4    5
> 2  6    7    8    7    8
>
> # df_dlabel.merge(df_dlabel, on='B') だめ
> ValueError: The column label 'B' is not unique.
> ```

以下は重複した列名を持つデータフレームであれば、引数にそれを指定していなくてもエラーが出る例。

```python
df_dlabel.reindex(list('AC'))
```

> ```python
> ValueError: cannot reindex from a duplicate axis
> ```

以下は重複した列名があると、結果は自動で判断されて、エラーでなく警告が出る例。

```python
df_dlabel.to_dict()
```

> ```python
> UserWarning: DataFrame columns are not unique, some columns will be omitted.
> {'A': {'a': 3, 'b': 6}, 'B': {'a': 5, 'b': 8}}
> ```

### 新たなる挙動

データフレームのフラグ（`.flags`属性により確認可）に`allows_duplicate_labels`フラグが追加された（`.flags`自体も今回追加されたものだが）。

```python
df_dlabel = (pd.DataFrame(np.arange(9).reshape(3, 3),
                          index=list('abc'), columns=list('ABC'))
             .set_flags(allows_duplicate_labels=True))
df_dlabel.flags.allows_duplicate_labels


df_ulabel = (pd.DataFrame(np.arange(9).reshape(3, 3),
                          index=list('abc'), columns=list('ABC'))
             .set_flags(allows_duplicate_labels=False))
df_ulabel.flags.allows_duplicate_labels
```

> ```python
> # df_dlabel
> True
>
> # df_ulabel
> False
> ```

`allows_duplicate_labels`が`True`となっているータフレームは今まで通りの挙動だが、これが`False`のデータフレームは行名・列名が一意であることが保証され、重複を許さない。重複ラベル禁止データフレームに対して行名・列名が重複するような操作を行おうとすると、`DuplicateLabelError`が発生する（ということになっているが、別のエラーが出たり、暗黙的に操作に失敗したりすることもあるようだ）。

```python
df_dlabel.reindex(list('aab'))

df_ulabel.reindex(list('aab'))
```

> ```python
> # df_dlabel できる
>    A  B  C
> a  0  1  2
> a  0  1  2
> b  3  4  5
>
> # df_ulabel できない
> DuplicateLabelError: Index has duplicates.
> ```

なお、データフレームのフラグは上にあるように`.set_flags()`メソッドで変更可能。なお、すでに行名・列名が重複しているデータフレームは`allows_duplicate_labels`を`False`にできず、やはり`DuplicateLabelError`となる。

```python
(pd.DataFrame(np.arange(9).reshape(3, 3),
             index=list('aab'), columns=list('ABB'))
 .set_flags(allows_duplicate_labels=False))
```

> ```python
> DuplicateLabelError: Index has duplicates.
> ```

## 欠損値許容浮動小数データ型

pandasではかつては`NaN`（`np.nan`）が欠損値のようなものとして扱われてきたが、「数ではない値、実数でない値、例外的な数値」`NaN`ではなく、数値以外のデータ型に対しても一貫して使える「データなし、欠損値」`NA`が必要だ、的な主張があり、バージョン1.0.0から欠損値を表す`NA`（`pd.NA`）が導入された。

これにしたがって今回、少し先立って存在していた欠損値許容整数データ型（`Int64Dtype`等）に追従する形で、欠損値許容浮動小数データ型（`Float64Dtype`等）が作られた。

`dtype`引数に`'Float64'`を指定すると欠損値許容浮動小数データ型になる（`'Float32'`等も存在）。`'Int64'`と同様、一文字目が大文字であることで通常の浮動小数データ型と区別される。

欠損値許容浮動小数データ型では`NaN`のかわりに`NA`が用いられる。

```python
s_float = pd.Series([0, 1, np.nan, np.nan], dtype='float64')

s_nfloat = pd.Series([0, 1, np.nan, pd.NA], dtype='Float64')
```

> ```python
> # float64
> 0    0.0
> 1    1.0
> 2    NaN
> 3    NaN
> dtype: float64
>
> # Float64
> 0     0.0
> 1     1.0
> 2    <NA>
> 3    <NA>
> dtype: Float64
> ```

`NaN`と`NA`では、比較演算のときの結果が異なる。

```python
s_float < 0

s_nfloat < 0
```

> ```python
> # float64
> 0    False
> 1    False
> 2    False
> 3    False
> dtype: bool
>
> # Float64
> 0    False
> 1    False
> 2     <NA>
> 3     <NA>
> dtype: boolean
> ```

## データフレーム連結時などにおけるインデックスの名前の維持

`Index`オブジェクトには名前（`name`属性）がある。一般には「インデックスの列名」と認識されているものである（特に`.set_index()`で特定の列をインデックスに設定した場合はよりそう認識するであろう）。

以下の例では`'idx'`という名前を持つインデックスを作成している。

```python
df_n = pd.DataFrame(np.arange(6).reshape(3, 2), columns=list('AB'),
                    index=pd.Index(list('abc'), name='idx'))
df_n
```

| idx  |    A |    B |
| :--- | ---: | ---: |
| a    |    0 |    1 |
| b    |    2 |    3 |
| c    |    4 |    5 |

データフレームを結合する時に、インデックスの名前が同じであっても、インデックスが食い違いのような感じになっているもの（？）が結合されると名前が消えてしまっていたらしい。

```python:以前の挙動
ct = pd.concat([df_n.iloc[:2, :1], df_n.iloc[1:, 1:]], axis=1)
ct
```

|      |    A |    B |
| :--- | ---: | ---: |
| a    |    0 |  nan |
| b    |    2 |    3 |
| c    |  nan |    5 |

これが修正され、名前がなるべく保存されるようになった。（明らかにバグ修正なのになぜか新機能として紹介されている）

## `applymap()`に欠損値を無視するオプション追加

[pd.DataFrame.applymap()](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.applymap.html)はデータフレームの全ての要素に、要素毎に特定の関数を適用するメソッド。

```python
df_str = pd.DataFrame([['where', 'why'], ['when', 'who']], dtype='string')
df_str.applymap(len)
```

|      |    0 |    1 |
| ---: | ---: | ---: |
|    0 |    5 |    3 |
|    1 |    4 |    3 |

欠損値`NA`には関数を適用させずに、そのまま欠損値を伝播させてほしいと思うことがある。

```python
df_str = pd.DataFrame([['where', 'why'], ['when', pd.NA]], dtype='string')
df_str.applymap(len)
```

> ```python
> TypeError: object of type 'NAType' has no len()
> ```

今までは関数自体に「`NA`だったら`NA`を返す」などの条件分岐を設定するか、欠損値を伝播させるオプションを持つ`pd.Series.map()`をシリーズ毎に適用させる必要があった。

```python
df_str.apply(lambda s: s.map(len, na_action='ignore'))
```

|      |    0 |      1 |
| ---: | ---: | -----: |
|    0 |    5 |      3 |
|    1 |    4 | \<NA\> |

`applymap()`にも`na_action`引数が追加され、`'ignore'`を渡すことで欠損値は伝播されるようになった。

```python
df_str.applymap(len, na_action='ignore')
```

|      |    0 |      1 |
| ---: | ---: | -----: |
|    0 |    5 |      3 |
|    1 |    4 | \<NA\> |

## オブジェクトデータ型`Index`の乗算・除算

pandasのインデックスは、シリーズ同様、各要素がすべて数値ならばデータ型が`'object'`であっても問題なく加算・減算ができる。

```python
pd.Series([0, 1, 2], dtype='object') + 2

pd.Index([0, 1, 2], dtype='object') + 2
```

> ```python
> # pd.Series([0, 1, 2], dtype='object') + 2
> 0    2
> 1    3
> 2    4
> dtype: object
>
> # pd.Index([0, 1, 2], dtype='object') + 2
> Int64Index([2, 3, 4], dtype='int64')
> ```

しかし、乗算・除算はエラーが出てしまっていた。これはシリーズとは異なる挙動であった。

```python:以前の挙動
pd.Series([0, 1, 2], dtype='object') * 2

pd.Index([0, 1, 2], dtype='object') * 2
```

> ```python
> # pd.Series([0, 1, 2], dtype='object') * 2
> 0    0
> 1    2
> 2    4
> dtype: object
>
> # pd.Index([0, 1, 2], dtype='object') * 2
> TypeError: cannot perform __mul__ with this index type: Index
> ```

これが修正された。

```python
pd.Index([0, 1, 2], dtype='object') * 2
```

> ```python
> Int64Index([0, 2, 4], dtype='int64')
> ```

## `.explode()`メソッドが集合型に対応

`pd.DataFrame.explode()`および`pd.Series.explode()`は、リストやタプルを要素として持つ場合に、それらを崩すメソッド。

```python
s_pack = pd.Series(['abc', list('abc'), tuple('abc')])
s_pack
```

> ```python
> 0          abc
> 1    [a, b, c]
> 2    (a, b, c)
> dtype: object
> ```

```python
s_pack.explode()
```

> ```python
> 0    abc
> 1      a
> 1      b
> 1      c
> 2      a
> 2      b
> 2      c
> dtype: object
> ```

いままで集合型には対応していなかった（`.explode()`を適用してもそのままだった）が、セットも崩されるようになった。

```python
pd.Series(['abc', list('abc'), set('abc')]).explode()
```

> ```python
> 0    abc
> 1      a
> 1      b
> 1      c
> 2      c
> 2      b
> 2      a
> dtype: object
> ```

## `.lookup()`メソッドは非推奨

`pd.DataFrame.lookup()`とはなにかというと、

```python
np.random.seed(0)
df_map = pd.DataFrame(np.random.randn(10, 3), columns=list('ABC'))

np.random.seed(0)
df_table = (pd.DataFrame({'col': list('ABC')}).iloc[np.random.randint(0, 3, 7)]
            .assign(idx=np.random.randint(0, 10, 7)).reset_index(drop=True))

df_map
df_table
```

↓こういう対応表的なテーブルがあって

|      |         A |         B |         C |
| ---: | --------: | --------: | --------: |
|    0 |   1.76405 |  0.400157 |  0.978738 |
|    1 |   2.24089 |   1.86756 | -0.977278 |
|    2 |  0.950088 | -0.151357 | -0.103219 |
|    3 |  0.410599 |  0.144044 |   1.45427 |
|    4 |  0.761038 |  0.121675 |  0.443863 |
|    5 |  0.333674 |   1.49408 | -0.205158 |
|    6 |  0.313068 | -0.854096 |  -2.55299 |
|    7 |  0.653619 |  0.864436 | -0.742165 |
|    8 |   2.26975 |  -1.45437 | 0.0457585 |
|    9 | -0.187184 |   1.53278 |   1.46936 |

↓こういうデータがあるときに、

|      | col  |  idx |
| ---: | :--- | ---: |
|    0 | A    |    7 |
|    1 | B    |    6 |
|    2 | A    |    8 |
|    3 | B    |    8 |
|    4 | B    |    1 |
|    5 | C    |    6 |
|    6 | A    |    7 |

↓こういうことができるメソッドである。

```python
df_map.lookup(df_table['idx'], df_table['col'])
```

> ```python
> array([ 0.6536186 , -0.85409574,  2.26975462, -1.45436567,  1.86755799,
>        -2.55298982,  0.6536186 ])
> ```

わざわざこんなメソッドいらなくね、ということで廃止される見込みとなった模様。上の例では、`.lookup()`を使わない場合、例えば以下のような方法がある。

```python
df_map.unstack()[df_table.set_index(['col', 'idx']).index]
df_map.unstack()[zip(df_table['col'], df_table['idx'])]
df_map.unstack()[df_table.to_records(index=False).tolist()]
df_map.unstack()[df_table.itertuples(index=False)]
# -> pd.Series

df_map.to_numpy()[df_table['idx'], df_map.columns.get_indexer(df_table['col'])]
# -> np.ndarray
```