pandas 1.2.0+ での pd.NA の特徴

## これは何？

この qiita の URL は 2020-01-29 以前は `pandas 1.0.0 (rc0) での pd.NA の特徴` として公開したが、 **pandas 1.2.0** でのアップデートを反映した記事に 2020-01-30 に更新した。（記事内でのpandasは1.2.1にて検証している）

[検証環境](#検証環境) は最後に。

## Contents major update

- 2021-01-30: pandas 1.2.1 にて内容を更新。この記事
- 2020-01-13: pandas 1.0.0 (rc4) にて 初回公開。 [gist](https://gist.github.com/koizumihiroo/9a9fd610ae7ad9d772cc4e72dc629798) に内容を退避



## 要約

- `pd.NA` は missing value の意味として登場。
- `pd.NA` が 使えるのは FloatingArray, IntegerArray, BooleanArray, StringArray
- `pd.NA` 導入により int class にも missing value が表現可能となった（不用意な float への変換がなくなった）。
  - float は Float64 (Float32) として pd.NA を扱うことができるようになったが。
- `pd.NA` は singleton object で 全ての data types と一貫性をもつ。
- `pd.NA` に対する比較演算子の返り値は全て `pd.NA` (Julia の `missing` object, R の `NA`) と同じ挙動）
- 論理演算子での演算は、いわゆる three-valued logic に従う
- `pd.read_csv()` では `Float64`, `Int64`, `string`, `boolean` の指定により NA が認識される。個別の指定が面倒なため、 `convert_dtypes()` というメソッドが便利である。


## data type

pandas 内に新たに `NAType` という class が導入される。目的は missing value としての値を示す。

```python
>>> import pandas as pd
>>> pd.NA
<NA>

>>> type(pd.NA)
<class 'pandas._libs.missing.NAType'>
```

pd.Series, pd.DataFrame では型を指定しないと object 型、明示するとその型として扱われる。なお `Int64Dtype` という class は pandas 0.24 から導入された Nullable interger (An ExtensionDtype for int64 integer data. Array of integer (optional missing) values) である。 `int64` ではなく `Int64` と大文字で dtype の指定が必要なことに注意が必要。 技術的には `Pandas Extension Arrays` の導入により ExtensionDType の利用が可能となった。
- https://pandas.pydata.org/pandas-docs/stable/reference/arrays.html 
- https://dev.pandas.io/pandas-blog/pandas-extension-arrays.html

```python
>>> pd.Series([pd.NA]).dtype
dtype('O') # O means Object

# dtype の指定は文字列エイリアスでも type そのものでもどちらでもいける。以下は文字列での指定。
>>> pd.Series([pd.NA], dtype="Float64").dtype
Float64Dtype()

>>> pd.Series([pd.NA], dtype="Int64").dtype
Int64Dtype()

>>> pd.Series([pd.NA], dtype="boolean").dtype
BooleanDtype

>>> pd.Series([pd.NA], dtype="string").dtype
StringDtype
```


---

`NAType` の実装はこちら。

https://github.com/pandas-dev/pandas/blob/493363ef60dd9045888336b5c801b2a3d00e976d/pandas/_libs/missing.pyx#L335-L485


また、 `Int32`, `Int16`, `Int8`, `Float32` も pd.NA の扱いは可能である。取り扱うデータ範囲が確定している場合はこれらの指定によりメモリを節約できる（はず）。

```python
>>> pd.Series([pd.NA], dtype="Int32")
0    <NA>
dtype: Int32

>>> pd.Series([pd.NA], dtype="Int16")
0    <NA>
dtype: Int16

>>> pd.Series([pd.NA], dtype="Int8")
0    <NA>
dtype: Int8

>>> pd.Series([pd.NA], dtype="Float32")
0    <NA>
dtype: Float32
```

---

# pd.Series, pd.DataFrame での型

型の確定について、 `int64` ではなく、大文字の `Int64` で指定が必要。

```python
>>> pd.Series([1, 2]) + pd.Series([pd.NA, pd.NA])
0    <NA>
1    <NA>
dtype: object

>>> pd.Series([1, 2]) + pd.Series([pd.NA, pd.NA], dtype="Int64")
0    <NA>
1    <NA>
dtype: Int64
```


`int64` の指定は error となる。

```python
>>> pd.Series([pd.NA], dtype="int64").dtype
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/local/lib/python3.9/site-packages/pandas/core/series.py", line 335, in __init__
    data = sanitize_array(data, index, dtype, copy, raise_cast_failure=True)
  File "/usr/local/lib/python3.9/site-packages/pandas/core/construction.py", line 480, in sanitize_array
    subarr = _try_cast(data, dtype, copy, raise_cast_failure)
  File "/usr/local/lib/python3.9/site-packages/pandas/core/construction.py", line 587, in _try_cast
    maybe_cast_to_integer_array(arr, dtype)
  File "/usr/local/lib/python3.9/site-packages/pandas/core/dtypes/cast.py", line 1723, in maybe_cast_to_integer_array
    casted = np.array(arr, dtype=dtype, copy=copy)
TypeError: int() argument must be a string, a bytes-like object or a number, not 'NAType'
```

各dtypeでの指定方法

```python
>>> pd.Series([1, 2, pd.NA], dtype="Float64")
0     1.0
1     2.0
2    <NA>
dtype: Float64

>>> pd.Series([1, 2, pd.NA], dtype="Int64")
0       1
1       2
2    <NA>
dtype: Int64

>>> pd.Series([True, False, pd.NA], dtype="boolean")
0     True
1    False
2     <NA>
dtype: boolean

>>> pd.Series(["a", "b", pd.NA], dtype="string")
0       a
1       b
2    <NA>
dtype: string

```

なお、 Float64 では np.nan (float('nan')) は pd.NA に変換されて共存できない

```python
>>> pd.Series([1, np.nan, float('nan'), None, pd.NA], dtype="Float64")
0     1.0
1    <NA>
2    <NA>
3    <NA>
4    <NA>
dtype: Float64
```

これらの `pd.NA` として見なされる値を代入しても、 `pd.NA` へ強制変換される。

```python
x = pd.Series(range(5), dtype="Float64")
x[1] = np.nan
x[2] = float('nan')
x[3] = None
x[4] = pd.NA
>>> x
0     0.0
1    <NA>
2    <NA>
3    <NA>
4    <NA>
dtype: Float64
```

と書いたが、実は共存することはできる。ただしこの NaN を代入しても NA に変化されてしまう。 ~~~この辺りの経緯は [issue#32256](https://github.com/pandas-dev/pandas/issues/32265) で議論されているようが、深くは追えていないので謎である。~~~ このようにNaNが基本扱えないことは、 1.2.0 から意図した挙動であると大御所からコメントが確認できる。 [issue#39039](https://github.com/pandas-dev/pandas/issues/39039#issuecomment-763617942)

```python
>>> pd.Series([-1, 0, 1, pd.NA],dtype="Float64") / pd.Series([0, 0, 0, 0],dtype="Float64")       
0    -inf
1     NaN
2     inf
3    <NA>
dtype: Float64
>>> x[0] = x[1]
>>> x
0    <NA>
1     NaN
2     inf
3    <NA>
dtype: Float64
```



---

## boolean の挙動

boolean との演算の結果は Julia の `missing` や R の `NA` と同じ挙動。

```python
>>> pd.Series([True, False, pd.NA], dtype="boolean") & True
0     True
1    False
2     <NA>
dtype: boolean

# dtypeをしない場合の挙動は、バグか意図か不明
>>> pd.Series([True, False, pd.NA]) & True
0     True
1    False
2    False
dtype: bool

>>> pd.Series([True, False, pd.NA], dtype="boolean") | True
0    True
1    True
2    True
dtype: boolean

>>> pd.NA & True
<NA>

>>> pd.NA & False
False

>>> pd.NA | True
True

>>> pd.NA | False
<NA>
```

## `NA` 伝播 (propagate)

---

sum 演算の結果は `NA` 伝播 (propagate) するが、引数を指定しない `pd.Series.sum()` は `0` として取り扱い propagate しない。 propagate 扱いするためには `sum(skipna=False)` を指定する必要がある。 Int64型だけでなくobject型でもskipna=Falseの指定は動作する。


```python
>>> sum([1, pd.NA])
<NA>
```

```python
# pd.Series object
>>> pd.Series([1, pd.NA])
0       1
1    <NA>
dtype: object

>>> pd.Series([1, pd.NA]).sum()
1
>>> pd.Series([1, pd.NA]).sum(skipna=False)
<NA>
```

```python
# pd.Series Int64
>>> pd.Series([1, pd.NA], dtype='Int64')
0       1
1    <NA>
dtype: Int64

>>> pd.Series([1, pd.NA], dtype='Int64').sum()
1
>>> pd.Series([1, pd.NA], dtype='Int64').sum(skipna=False)
<NA>
```

cumsum, cumprod の挙動は次の通り。なぜか Int64 時での cumsum() は skipna=True のデフォルト指定が反映されない。まった Float64 も dtype が object へ変更されてしまう。後日 issue を報告しておく。(即日 issue 立てました [#39479](https://github.com/pandas-dev/pandas/issues/39479) )

```python
>>> pd.Series([1, 2, pd.NA, 4, pd.NA], dtype='Int64').cumsum()
0       1
1       3
2    <NA>
3    <NA>
4    <NA>
dtype: object

>>> pd.Series([1, 2, None, 4, float('nan')], dtype='Int64').cumsum(skipna=False)
0       1
1       3
2    <NA>
3    <NA>
4    <NA>
dtype: object

>>> pd.Series([1, 2, pd.NA, 4, pd.NA], dtype='Float64').cumsum()
0    1.0
1    3.0
2    3.0
3    7.0
4    7.0
dtype: object

>>> pd.Series([1, 2, pd.NA, 4, pd.NA], dtype='Int64').cumsum(skipna=False)
0       1
1       3
2    <NA>
3    <NA>
4    <NA>
dtype: object
```

## pow 関数

累乗の扱いは R の `NA_integer_` と整合的。 julia の挙動は謎い。

```python
>>> pd.NA ** 0
1
>>> 1 ** pd.NA
1
>>> -1 ** pd.NA
-1
```

```R
> R.version.string
[1] "R version 3.6.1 (2019-07-05)"

> NA_integer_ ^ 0L
[1] 1
> 1L ^ NA_integer_
[1] 1
> -1L ^ NA_integer_
[1] -1
```

```julia
julia> VERSION
v"1.3.1"

julia> missing ^ 0
missing

julia> 1 ^ missing
missing

julia> -1 ^ missing
missing
```
---

## read_csv での指定

次の csv ファイルで実験。 (`test.csv`)

```text
X_float,X_int,X_bool,X_string
1.0,1,True,"a"
2.0,2,False,"b"
NA,NA,NA,"NA"
```

dtypeの指定無しでは、 pandas 0.25.3 の挙動と同じ。

```python
>>> df1 = pd.read_csv("test.csv")
>>> df1
   X_float  X_int X_bool X_string
0      1.0    1.0   True        a
1      2.0    2.0  False        b
2      NaN    NaN    NaN      NaN
>>> df1.dtypes
X_float     float64
X_int       float64
X_bool       object
X_string     object
dtype: object
```

NAとして認識するには、dtypeを指定する。
dtype に `Int64`, `string` は指定可能.

```python
# dtype は 文字リテラルでなく次の type class でも可能。
# df2 = pd.read_csv("test.csv", dtype={'X_int': pd.Int64Dtype(), 'X_string': pd.StringDtype()})
>>> df2 = pd.read_csv("test.csv", dtype={'X_float': 'Float64', 'X_int': 'Int64', 'X_bool': 'boolean', 'X_string': 'string'})
>>> df2
   X_float  X_int  X_bool X_string
0      1.0      1    True        a
1      2.0      2   False        b
2     <NA>   <NA>    <NA>     <NA>
>>> df2.dtypes
X_float     Float64
X_int         Int64
X_bool      boolean
X_string     string
dtype: object
```

と書いたが、dtypeの指定をいちいち書くのは面倒な場合は `pd.DataFrame.convert_dtypes()` というメソッドが非常に便利である。今後の定番になるであろう。

```python
>>> df3 = pd.read_csv("test.csv").convert_dtypes()
>>> df3
   X_float  X_int  X_bool X_string
0        1      1    True        a
1        2      2   False        b
2     <NA>   <NA>    <NA>     <NA>
>>> df3.dtypes
X_float       Int64
X_int         Int64
X_bool      boolean
X_string     string
dtype: object
```

## 要約

- `pd.NA` は missing value の意味として登場。
- `pd.NA` が 使えるのは FloatingArray, IntegerArray, BooleanArray, StringArray
- `pd.NA` 導入により int class にも missing value が表現可能となった（不用意な float への変換がなくなった）。
  - float は Float64 (Float32) として pd.NA を扱うことができるようになった。
- `pd.NA` は singleton object で 全ての data types と一貫性をもつ。
- `pd.NA` に対する比較演算子の返り値は全て `pd.NA` (Julia の `missing` object, R の `NA`) と同じ挙動）
- 論理演算子での演算は、いわゆる three-valued logic に従う
- `pd.read_csv()` では `Float64`, `Int64`, `string`, `boolean` の指定により NA が認識される。個別の指定が面倒なため、 `convert_dtypes()` というメソッドが便利である。

## 最後に
こういうマニアックな話が大好きな方、ぜひ justInCase へ遊びに来て下さい。https://www.wantedly.com/companies/justincase

---

## 参考URL

- https://pandas.pydata.org/docs/whatsnew/v1.0.0.html
- https://pandas.pydata.org/docs/whatsnew/v1.2.0.html
- https://dev.pandas.io/docs/user_guide/missing_data.html
- https://dev.pandas.io/docs/user_guide/gotchas.html#nan-integer-na-values-and-na-type-promotions
- https://dev.pandas.io/docs/user_guide/gotchas.html#why-not-make-numpy-like-r
- https://hackmd.io/@jorisvandenbossche/Sk0wMeAmB
- https://github.com/pandas-dev/pandas/blob/493363ef60dd9045888336b5c801b2a3d00e976d/pandas/_libs/missing.pyx#L335-L485

## 検証環境

docker上で確認した。

```dockerfile
FROM python:3.9.1-slim-buster
WORKDIR /home
RUN pip install pandas==1.2.1
CMD ["/bin/bash"]
```

```sh
$ docker build -t pdna .
$ docker run -it --rm -v $(pwd):/home/ pdna
```

Inside Docker

```sh

root@98cfef126cbd:/home# cat /etc/issue
Debian GNU/Linux 10 \n \l

root@98cfef126cbd:/home# uname -a
Linux 98cfef126cbd 4.19.121-linuxkit #1 SMP Tue Dec 1 17:50:32 UTC 2020 x86_64 GNU/Linux
```

```sh
root@98cfef126cbd:/home# python -c "import pandas as pd; pd.show_versions()"

INSTALLED VERSIONS
------------------
commit           : 9d598a5e1eee26df95b3910e3f2934890d062caa
python           : 3.9.1.final.0
python-bits      : 64
OS               : Linux
OS-release       : 4.19.121-linuxkit
Version          : #1 SMP Tue Dec 1 17:50:32 UTC 2020
machine          : x86_64
processor        : 
byteorder        : little
LC_ALL           : None
LANG             : C.UTF-8
LOCALE           : en_US.UTF-8

pandas           : 1.2.1
numpy            : 1.19.5
pytz             : 2020.5
dateutil         : 2.8.1
pip              : 21.0
setuptools       : 52.0.0
Cython           : None
pytest           : None
hypothesis       : None
sphinx           : None
blosc            : None
feather          : None
xlsxwriter       : None
lxml.etree       : None
html5lib         : None
pymysql          : None
psycopg2         : None
jinja2           : None
IPython          : None
pandas_datareader: None
bs4              : None
bottleneck       : None
fsspec           : None
fastparquet      : None
gcsfs            : None
matplotlib       : None
numexpr          : None
odfpy            : None
openpyxl         : None
pandas_gbq       : None
pyarrow          : None
pyxlsb           : None
s3fs             : None
scipy            : None
sqlalchemy       : None
tables           : None
tabulate         : None
xarray           : None
xlrd             : None
xlwt             : None
numba            : None
```