pandas 1.0とdaskのちょっと細かい調査

# 背景

2020/01/29に[pandas 1.0.0](https://pandas.pydata.org/docs/whatsnew/v1.0.0.html)がリリースされました！パチパチ
2020/02/14現在は、[1.0.1](https://pandas.pydata.org/docs/whatsnew/v1.0.1.html)です。

個人的には、下記の変更点が重要ポイントかなと思ってます。
- pandas独自の`NA`
- String型の対応強化(Experimental)

んで。

僕は分析時には、下記のライブラリとpandasを一緒に使うことが多いです。

- [dask](https://dask.org)
- [intake](https://intake.readthedocs.io/en/latest/)

特にdaskのpandas1.0対応状況や、その他の細かな振る舞いについて整理しようかなと思っています。
daskのバージョンは2020/02/14現在2.10.1です。

intakeに関しては、daskが対応してくれれば問題ないっしょ、って思っています。
（daskの処理待ち時間が暇というのもある。）

![スクリーンショット 2020-02-14 午前11.20.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/373730/4138c9d7-b7d0-ed95-a96e-9b2c17e73ebb.png)

## 気になっていること

- daskは`pandas.NA`をちゃんと使えんの？(ver 1.0関連)
- daskは`dtype: string`をちゃんと使えんの？(ver 1.0関連)
- I/O、特に[fastparquet](https://github.com/dask/fastparquet)は`pandas.NA`とか`dtype: string`とかちゃんと入出力出来んの？(ver 1.0関連)
- そういや、daskは`dtype: categorical`ちゃんと使えんの？(その他)

[Tom Augspurger](https://github.com/tomaugspurger)さんという方が、鬼のようにpandas1.0対応してくれてるっぽいし、今のところ期待感大。
![スクリーンショット 2020-02-14 午後2.26.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/373730/429bae0a-b5a7-5978-fe4f-69967db37829.png)

## 結果
結果だけ知りたい人用。

- daskは、`pandas.NA`を含んでいても、四則演算・文字列演算はOK
- daskは、`Int64`, `string`型などのカスタム型で`set_index`できない
- pandas/dask両方とも、`pandas.NA`を含む`boolean`列でインデックスフィルタできない
- daskは、`apply(meta='string')`としても`object`型になってしまうが、`astype('string')`で復活させられる。
- dask内でpandas.Categoricalを使う場合、フィルタ・集計はいけそう
- dask DataFrameに新しいCategorical列を追加する時は`astype`を使う必要がありそう
- daskは、`Int64`、`string`型をto_parquetできない（engine=fastparquetの場合）。


### 調査環境構築

とりあえずクリーンな検証環境を用意します。
OSは`macOS Catalina 10.15.2`です。

python versionに関してpandasはminimumしか設定していないのと、daskも[python3.8対応](https://docs.dask.org/en/latest/changelog.html#id6)とかしてるっぽいので、`3.7.4`なら問題ないでしょう。

依存関係に関しては、[minimum versions for dependencies](https://pandas.pydata.org/docs/whatsnew/v1.0.0.html#increased-minimum-versions-for-dependencies)を一通り入れておく。ただし、fastparquetとpyArrowはmac上で共存できない[問題](https://github.com/dask/dask/issues/5215)が昔あったので、念のためpyArrowは入れないでおく。使わんし。

検証作業はjupyterlab上でやります。

```bash
pyenv virtualenv 3.7.4 pandas100
pyenv shell pandas100
pip install -r requirements.txt
```

```requirements.txt
pandas==1.0.1
dask[complete]==2.10.1
fastparquet==0.3.3

jupyterlab==1.2.6

numpy==1.18.1
pytz==2019.3
python-dateutil==2.8.1
numexpr==2.7.1
beautifulsoup4==4.8.2
gcsfs==0.6.0
lxml==4.5.0
matplotlib==3.1.3
numba==0.48.0
openpyxl==3.0.3
pymysql==0.9.3
tables==3.6.1
s3fs==0.4.0
scipy==1.4.1
sqlalchemy==1.3.13
xarray==0.15.0
xlrd==1.2.0
xlsxwriter==1.2.7
xlwt==1.3.0
```

### dask vs pandas1.0

まずは、pd.NAの確認。

- dtype: int
- dtype: Int64(...Categorical)
- dtype: float
- dtype: string
- dtype: boolean
- dtype: datetime64[ns]
- dtype: timedelta64[ns]
- dtype: object


それぞれでのpd.NAの振る舞いを確認

| `s=...` | type(s.loc[3]) |
| --- | --- |
| pandas.Series([1,2,3,None], dtype='int') | TypeError |
| pandas.Series([1,2,3,pandas.NA], dtype='int') | TypeError |
| pandas.Series([1,2,3,None], dtype='Int64') | pandas._libs.missing.NAType | 
| pandas.Series([1,2,3,None], dtype='float') | numpy.float64 |
| pandas.Series([1,2,3,pandas.NA], dtype='float') | TypeError |
| pandas.Series([1,2,3,None], dtype='Int64').astype('float') | numpy.float64 |
| pandas.Series(['a', 'b', 'c' ,None], dtype='string') | pandas._libs.missing.NAType |
| pandas.Series(['a', 'b', 'c' ,None], dtype='object').astype('string') | pandas._libs.missing.NAType |
| pandas.Series([True, False, True ,None], dtype='boolean') | pandas._libs.missing.NAType |
| pandas.Series([1, 0, 1 ,None], dtype='float').astype('boolean') | pandas._libs.missing.NAType |
| pandas.Series(pandas.to_datetime(['2020-01-01', '2020-01-02', '2020-01-03', None])) | pandas._libs.tslibs.nattype.NaTType |
| pandas.Series(pandas.to_timedelta(['00:00:01', '00:00:02', '00:00:03', None])) | pandas._libs.tslibs.nattype.NaTType |
| pandas.Series([object(), object(), object(), None], dtype='object') | NoneType |
| pandas.Series([object(), object(), object(), pandas.NA], dtype='object') | pandas.Series([object(), object(), object(), pandas.NA], dtype='object')

まとめると、

- dtype intは、`pandas.NA`にならない（そのままTypeError）
- dtype Int64,string,booleanは`pandas.NA`になる。
- dtype floatは、`numpy.NaN`になる
- dtype datetime64, timedelta64は、`NAT`になる
- dtype objectは、`None`を自動で`pandas.NA`に変換しない

これを`dask.dataframe.from_pandas`するとどうなるか調査する。

```python
>>> import pandas
>>> import dask.dataframe

>>> df = pandas.DataFrame({'i': [1,2,3,4], 
...                        'i64': pandas.Series([1,2,3,None], dtype='Int64'), 
...                        's': pandas.Series(['a', 'b', 'c' ,None], dtype='string'), 
...                        'f': pandas.Series([1,2,3,None], dtype='Int64').astype('float')})

>>> ddf = dask.dataframe.from_pandas(df, npartitions=1)
```

```python
>>> df
	i	i64	s	f
0	1	1	a	1.0
1	2	2	b	2.0
2	3	3	c	3.0
3	4	<NA>	<NA>	NaN

>>> ddf
Dask DataFrame Structure:
	i	i64	s	f
npartitions=1				
0	int64	Int64	string	float64
3	...	...	...	...
```
なるほど、`Int64`は、dask上でも`Int64`となっている。`string`も同様。

```python
>>> # Int64に対する（整数）演算
>>> df.i64 * 2
0       2
1       4
2       6
3    <NA>
Name: i64, dtype: Int64

>>> (ddf.i64 * 2).compute()
0       2
1       4
2       6
3    <NA>
Name: i64, dtype: Int64
```
Int64->Int64の処理はちゃんと動く。

```python
>>> # Int64に対する（浮動少数点数）演算
>>> df.i64 - df.f
0    0.0
1    0.0
2    0.0
3    NaN
dtype: float64

>>> (ddf.i64 - ddf.f).compute()
0    0.0
1    0.0
2    0.0
3    NaN
dtype: float64
```
Int64->float64の処理もちゃんと動く。

```python
>>> # pandas.NAを含むInt64列でset_index
>>> df.set_index('i64')
	i	s	f	i64_result	i64-f
i64					
1	1	a	1.0	2	0.0
2	2	b	2.0	4	0.0
3	3	c	3.0	6	0.0
NaN	4	<NA>	NaN	<NA>	NaN

>>> ddf.set_index('i64').compute()
TypeError: data type not understood

>>> # pandas.NAがなかったらどうなるか
>>> ddf['i64_nonnull'] = ddf.i64.fillna(1)
... ddf.set_index('i64_nonnull').compute()
TypeError: data type not understood
```
えー！daskは`Int64`列で`set_index`できないんかい！
もちろんpandasはできる。

```python
>>> # pandas.NAを含むstring列でset_index
>>> df.set_index('s')
	i	i64	f
s			
a	1	1	1.0
b	2	2	2.0
c	3	3	3.0
NaN	4	<NA>	NaN

>>> ddf.set_index('s').compute()
TypeError: Cannot perform reduction 'max' with string dtype

>>> # pandas.NAがなかったらどうなるか
>>> ddf['s_nonnull'] = ddf.s.fillna('a')
... ddf.set_index('s_nonnull')
TypeError: Cannot perform reduction 'max' with string dtype
```
`string`もできんな。これは（僕の使い方では）まだ使えないなぁ。

```python
# .str関数を試してみる
>>> df.s.str.startswith('a')
0     True
1    False
2    False
3     <NA>
Name: s, dtype: boolean

>>> ddf.s.str.startswith('a').compute()
0     True
1    False
2    False
3     <NA>
Name: s, dtype: boolean
```
ふむ、これは動くと。

```python
>>> # pandas.NAを含むboolean列でフィルタ
>>> df[df.s.str.startswith('a')]
ValueError: cannot mask with array containing NA / NaN values

>>> # pandas.NAがダメなのかな？
>>> df['s_nonnull'] = df.s.fillna('a')
... df[df.s_nonnull.str.startswith('a')]
	i	i64	s	f	i64_nonnull	s_nonnull
0	1	1	a	1.0	1	a
3	4	<NA>	<NA>	NaN	1	a

>>> ddf[ddf.s.str.startswith('a')].compute()
ValueError: cannot mask with array containing NA / NaN values

>>> ddf['s_nonnull'] = ddf.s.fillna('a')
... ddf[ddf.s_nonnull.str.startswith('a')].compute()
	i	i64	s	f	i64_nonnull	s_nonnull
0	1	1	a	1.0	1	a
3	4	<NA>	<NA>	NaN	1	a
>>> ddf[ddf.s.str.startswith('a')].compute()
```
え！！！pandas.NA含むとフィルタできないの？
これはダメでしょ！

```python
>>> # applyでmeta='Int64'指定してみる
>>> ddf['i10'] = ddf.i.apply(lambda v: v * 10, meta='Int64')
>>> ddf
Dask DataFrame Structure:
	i	i64	s	f	i64_nonnull	s_nonnull	i10
npartitions=1							
0	int64	Int64	string	float64	Int64	string	int64
3	...	...	...	...	...	...	...

>>> # applyでmeta='string'指定してみる
>>> ddf['s_double'] = ddf.s.apply(lambda v: v+v, meta='string')
Dask DataFrame Structure:
	i	i64	s	f	i64_nonnull	s_nonnull	i10	s_double
npartitions=1								
0	int64	Int64	string	float64	Int64	string	int64	object
3	...	...	...	...	...	...	...	...

>>> # astype('string')してみる
>>> ddf['s_double'] = ddf['s_double'].astype('string')
>>> ddf
Dask DataFrame Structure:
	i	i64	s	f	i64_nonnull	s_nonnull	i10	s_double
npartitions=1								
0	int64	Int64	string	float64	Int64	string	int64	string
3	...	...	...	...	...	...	...	...
```

meta=で指定する場合、反映されないのか。。。
astypeで復活させられるけど、面倒だな。。。

#### 結果

- 演算はOK
- daskでは、Indexとしては使えない（pandas.NA対応している型がそもそも使えないから）
- pandas/dask両方で、filterできない！
- .apply(meta='string')などしても無視される。astypeしないとだめ。

### dask vs pandas.Categorical

pandasでCategoricalを調査するために、今回はCategoricalDtypeを使用した方法を使用します。
基本的なCategoricalDtypeの使い方は、

1. categoriesとorderedを指定してインスタンス化
2. dtype=CategoricalDtypeインスタンスとして、pandas.Seriesをインスタンス化

だと思っています。以下サンプルコード

```python
>>> # まずは、CategoricalDtypeを作る
>>> int_category = pandas.CategoricalDtype(categories=[1,2,3,4,5], 
...                                        ordered=True)
>>> int_category
CategoricalDtype(categories=[1, 2, 3, 4, 5], ordered=True)

>>> int_category.categories
Int64Index([1, 2, 3, 4, 5], dtype='int64')

>>> # こんな感じでpandas.Seriesを作る
>>> int_series = pandas.Series([1,2,3], dtype=int_category)
>>> int_series
0    1
1    2
2    3
dtype: category
Categories (5, int64): [1 < 2 < 3 < 4 < 5]

>>> # 生成時なら、カテゴリに無い値をNaNに変換してくれる
>>> int_series = pandas.Series([1,2,3,6], dtype=int_category)
>>> int_series
0      1
1      2
2      3
3    NaN
dtype: category
Categories (5, int64): [1 < 2 < 3 < 4 < 5]

>>> # 生成後は怒られる
>>> int_series.loc[3] = 10
ValueError: Cannot setitem on a Categorical with a new category, set the categories first
```

次に、dask上でCategoricalを使ってみる。

```python
>>> import pandas
>>> import dask.dataframe
>>> # pandas.DataFrameを生成
>>> df = pandas.DataFrame({'a': pandas.Series([1, 2, 3, 1, 2, 3], dtype=int_category), 
...                        'b': pandas.Series([1, 2, 3, 1, 2, 3], dtype='int64')})
>>> df
	a	b
0	1	1
1	2	2
2	3	3
3	1	1
4	2	2
5	3	3

>>> # dask.dataframe.DataFrameに変換
>>> ddf = dask.dataframe.from_pandas(df, npartitions=1)
>>> ddf
Dask DataFrame Structure:
	a	b
npartitions=1		
0	category[known]	int64
5	...	...
```
とりあえず、categoricalのままdask化することができた。

```python
# pandasでは新しいカテゴリ値の追加は御法度
>>> df.loc[2, 'a'] = 30
ValueError: Cannot setitem on a Categorical with a new category, set the categories first

# daskでは、Categoricalとか関係なく、そもそもアサインできない
>>> ddf.loc['a', 3] = 10
TypeError: '_LocIndexer' object does not support item assignment

# pandasでは、カテゴリ値の演算も御法度
>>> df.a * 2
TypeError: unsupported operand type(s) for *: 'Categorical' and 'int'

# daskでも、カテゴリ値の演算は御法度
>>> ddf.a * 2
TypeError: unsupported operand type(s) for *: 'Categorical' and 'int'

# daskのapplyで、metaとして指定してみる
>>> ddf['c'] = ddf.a.apply(lambda v: v, meta=int_category)
Dont know how to create metadata from category

# daskのapplyで、meta='category'にしたら頑張ってくれるか？
>>> ddf['c'] = ddf.a.apply(lambda v: v, meta='category')
>>> ddf.dtypes
a    category
b       int64
c      object
dtype: object

>>> # 中身との整合性が取れているかチェックしてみる
>>> ddf.compute().dtypes
a    category
b       int64
c    category
dtype: object

>>> # astypeしてみる
>>> ddf['c'] = ddf.c.astype(int_category)
>>> ddf
Dask DataFrame Structure:
	a	b	c
npartitions=1			
0	category[known]	int64	category[known]
5	...	...	...
```

なるほど。カテゴリの制約部分は維持されるが、`.apply(meta=)`とかすると、daskのdtype管理がバグる。
astypeで復活させることは可能だが、面倒だな。。。
フィルタだけなら使えそうってところですかね。

```python
# 集計処理してみる
>>> ddf.groupby('a').b.mean().compute()
a
1    1.0
2    2.0
3    3.0
4    NaN
5    NaN
Name: b, dtype: float64

# Index扱いされることで、型は壊れてしまわないか？
Dask DataFrame Structure:
a	b
npartitions=1		
category[known]	float64
...	...
Dask Name: reset_index, 34 tasks
```

ふむ、集計には対応している感じか。

#### 結果

- pandas.Categoricalをdaskで使うとき、フィルタと集計は問題なさそう
- daskのDataFrameに新しくCategoricalな列を追加したい時は`astype`を使うべし

### to_parquet vs pandas1.0

```python
>>> # まずはpandas.DataFrameを生成
>>> df = pandas.DataFrame(
    {
        'i64': pandas.Series([1, 2, 3,None], dtype='Int64'),
        'i64_nonnull': pandas.Series([1, 2, 3, 4], dtype='Int64'),
        's': pandas.Series(['a', 'b', 'c',None], dtype='string'),
        's_nonnull': pandas.Series(['a', 'b', 'c', 'd'], dtype='string'),
    }
)
>>> df
	i64	i64_nonnull	s	s_nonnull
0	1	1	a	a
1	2	2	b	b
2	3	3	c	c
3	<NA>	4	<NA>	d

>>> # dask.dataframe.DataFrameに変換
>>> ddf = dask.dataframe.from_pandas(df, npartitions=1)
>>> ddf
Dask DataFrame Structure:
	i64	i64_nonnull	s	s_nonnull
npartitions=1				
0	Int64	Int64	string	string
3	...	...	...	...
```

とりあえず、to_parquetしてみる。

```python
>>> ddf.to_parquet('test1', engine='fastparquet')
ValueError: Dont know how to convert data type: Int64
```

まじか。。。予想はしてたが。。。
Int64はダメでもstringはいけるかも。。。

```python
>>> ddf.to_parquet('test2', engine='fastparquet')
ValueError: Dont know how to convert data type: string
```

ダメでした。

#### 結果

- Int64, stringはto_parquet不可。

## おわりに

いかがだったでしょうか？
多分、最後まで読みきってこのコメントを読んでいる人は誰もいないと思います。
投稿を分けるべきでしたかね。

pandas 1.0どやねんと思っている人の役に立てたら嬉しいです。

ではまたそのうち。