pandas 1.0 のpd.NAのハマりどころ

2020-03-01 02:17:00 -08:00·
Aki Ariga
Aki Ariga
· 7 min read
post

https://unsplash.com/photos/_7HU079sGNw

https://unsplash.com/photos/_7HU079sGNw

pandas 1.0.0 からexperimental featureとして登場した pandas.NA 。これは今まで0.x系では numpy.nan で表していた missing valueを pandas.NA で表現しようというもの。

この検索しにくい名前のおかげで、情報を探してもなかなかたどり着かないので苦労をする。公式の “What’s new in 1.0.0” の “Experimental new features” の項と hkzmさんのQiitaの記事がよくまとまっているので困ったらここに立ち返るとよい。

What’s new in 1.0.0 (January 29, 2020) - pandas 1.0.1 documentation

pandas 1.0.0 (rc0) での pd.NA の特徴 - Qiita

この記事では、nullableなdtype周りでハマったことをまとめていく。

そもそもなぜ pandas.NA が登場したのか

以下のドキュメントには、 numpy.nan 周りの説明がざらっと書いてある。特にcastされるときの挙動は今でも役に立つだろう。(というか昔の説明がなくなっているように思う)

Frequently Asked Questions (FAQ) - pandas 1.1.0.dev0+609.g52a63ab42 documentation

pandasを利用されている方は、このintからfloatへのcastに遭遇したことも多いだろう。pandas 0.24からNullable Integer type (pandas.Int64Dtype() いわゆる Int64int64 はnumpyのdtypeを指すので注意。 Int32, Int16, Int8 もある) も登場し、Intのままで NaNを保持できるようになった。

In [1]: arr = pd.array([1, 2, np.nan], dtype=pd.Int64Dtype())
# pd.array([1, 2, np.nan], dtype="Int64") でもOK
In [2]: arr
Out[2]: 
<IntegerArray>
[1, 2, NaN]
Length: 3, dtype: Int64

では、なぜpandasはfloatにデフォルトcastしているのだろうか?

実は、 NaN というのはIEEE 754でfloatと決まっているのである。

NaN

だから、 numpy.nan もfloatだし、pandasはもともと numpy.nan があるとfloatのdtypeにcastしていたのである。IEEEで決まっていたら仕方がない。 type() を調べても nanfloat になるわけだ。

In [1]: np.nan
Out[1]: nan

In [2]: type(np.nan)
Out[2]: float

In [3]: float('nan')
Out[3]: nan

In [4]: type(float('nan'))
Out[4]: float

pandas.NA の登場と、新しいdtypeの追加

仕方がないというわけにはいかないので、pandasは通称 Int64 を導入したのである。しかしこれだけでは、 booleannumpy.nanpandas.NA が入ると object にcastされてしまうのは続いている。また、 strobject 型というなんでもごった煮のdtypeで扱いたくない。というので、pandas 1.0.0から pandas.BooleanDtype() (string表記は "boolean")と pandas.StringDtype() (string表記は "string")が登場した。また、合わせて pandas.NA も登場した。

つまり、nullableな型は以下のように遷移してきた。

  • 0.23まで
    • float, object
  • 0.24〜0.25
    • float, object, Int64, Int32, Int16, Int8
  • 1.0以降
    • float, object, Int64, Int32, Int16, Int8, boolean, string,

Nullable Valueの有無でのハマりどころ

以下はpandas 0.23.4, 0.25.3と 1.0.1で比較をしている。最初は1.0前後で比較をしようとしたが、大きな変更は0.24の前後で変わっていたようである。

Int64 を指定したDataFrameで numpy.nan ではなく pandas.NA が返ってくる

0.25以前では dtype="Int64" を指定したときは、 numpy.nan がmissing valueとして返ってきた。

In [13]: df = pd.DataFrame([{"a": 1, "b": 2}, {"a": 3, "b": 4, "c": 5}], dtype="Int64")

In [14]: df
Out[14]:
   a  b    c
0  1  2  NaN
1  3  4    5

In [15]: df.c.iloc[0]
Out[15]: nan

In [16]: type(df.c.iloc[0])
Out[16]: float

In [18]: pd.np.isnan(df.c.iloc[0])
Out[18]: True

これが、以下のようになる。

In [2]: df = pd.DataFrame([{"a": 1, "b": 2}, {"a": 3, "b": 4, "c": 5}], dtype="Int64")

In [3]: df
Out[3]:
   a  b     c
0  1  2  <NA>
1  3  4     5

In [4]: df.c.iloc[0]
Out[4]: <NA>

In [5]: type(df.c.iloc[0])
Out[5]: pandas._libs.missing.NAType

In [6]: np.isnan(df.c.iloc[0])
Out[6]: <NA>

そうだよね、と思うだろうがこれに伴い以下の挙動が変わってきている。

Noneへの置換をしたい場合

これは0.24で Int64 導入時からの挙動だが、 Int64 のdtypeのcolumnは、手でNoneを代入すると numpy.nanpandas.NA が保持される。これは df.wheredf.applymap などで numpy.nanpandas.NA を置換しようとしてもdtypeを変えないと None に置換できないということを意味する。 df.replaceobject dtypeになる。

0.25.3の挙動

In [25]: df
Out[25]:
   a  b    c
0  1  2  NaN
1  3  4    5

# cは何も変わらない
In [26]: df.where(pd.notnull(df), None)
Out[26]:
   a  b    c
0  1  2  NaN
1  3  4    5

# cがfloatにcastされてしまう
In [27]: df.applymap(lambda x: None if np.isnan(x) else x)
Out[27]:
   a  b    c
0  1  2  NaN
1  3  4  5.0

# cはobject dtypeにcastされている
In [38]: df.replace({np.nan: None})
Out[38]:
   a  b     c
0  1  2  None
1  3  4     5

In [39]: df.replace({np.nan: None}).dtypes
Out[39]:
a     Int64
b     Int64
c    object
dtype: object

1.0.1での挙動

In [2]: df = pd.DataFrame([{"a": 1, "b": 2}, {"a": 3, "b": 4, "c": 5}], dtype="Int64")

In [7]: df
Out[7]:
   a  b     c
0  1  2  <NA>
1  3  4     5

In [3]: df.where(pd.notnull(df), None)
Out[3]:
   a  b     c
0  1  2  <NA>
1  3  4     5

# np.isnanはTypeErrorを返す(当たり前)
In [5]: df.applymap(lambda x: None if np.isnan(x) else x)
TypeError: boolean value of NA is ambiguous

# pd.isnullならOK。floatには変わる
In [6]: df.applymap(lambda x: None if pd.isnull(x) else x)
Out[6]:
   a  b    c
0  1  2  NaN
1  3  4  5.0

# cはobjectに置換される
In [8]: df.replace({np.nan: None})
Out[8]:
   a  b     c
0  1  2  None
1  3  4     5

In [9]: df.replace({np.nan: None}).dtypes
Out[9]:
a     Int64
b     Int64
c    object
dtype: object

1.0.1でのNullableなtypeを使わない挙動

In [27]: df2 = pd.DataFrame([{"a": 1, "b": 2}, {"a": 3, "b": 4, "c": 5}])

In [28]: df2
Out[28]:
   a  b    c
0  1  2  NaN
1  3  4  5.0

# cはobjectになる
In [29]: df2.where(pd.notnull(df2), None)
Out[29]:
   a  b     c
0  1  2  None
1  3  4     5

In [30]: df2.where(pd.notnull(df2), None).dtypes
Out[30]:
a     int64
b     int64
c    object
dtype: object

# cはfloatになる
In [31]: df2.applymap(lambda x: None if np.isnan(x) else x)
Out[31]:
   a  b    c
0  1  2  NaN
1  3  4  5.0

# cはobjectになる
In [33]: df2.replace({np.nan: None})
Out[33]:
   a  b     c
0  1  2  None
1  3  4     5

In [34]: df2.replace({np.nan: None}).dtypes
Out[34]:
a     int64
b     int64
c    object
dtype: object

まとめると以下の表のようになる。

Namenon-nullable dtypenullable dtype
df.whereobject にcast変化なし
df.applymapfloat にcastfloat にcast
df.replaceobject にcastobject にcast

df.to_dict(orient="record") の挙動の罠

行ごとにデータを処理したいのであれば、 df.to_dict(orient="record") を使うことができる。 が、これは後述するように挙動がバージョン感で差異があったり、古いバージョンではちょっと想定と違う挙動をするので df.to_records を使うほうが良い。具体的には、0.23以前は、カラムごとに nan の有無でdtypeを変えることが期待されるが、行ごとにdtypeを揃えに行こうとするため、 nan がある行が含まれていると全行 float に変わってしまった。なお、1.0.1ではこの挙動は期待通りに修正されている。

0.23.4

In [37]: df = pd.DataFrame([{"a": 1, "b": 2}, {"a": 3, "b": 4, "c": 5}])

In [38]: df
Out[38]:
   a  b    c
0  1  2  NaN
1  3  4  5.0

# 全部の値が numpy.float64 にキャストされている
In [39]: df.to_dict(orient="record")
Out[39]: [{'a': 1.0, 'b': 2.0, 'c': nan}, {'a': 3.0, 'b': 4.0, 'c': 5.0}]

In [40]: type(df.to_dict(orient="record")[0]['a'])
Out[40]: numpy.float64

In [41]: type(df.to_dict(orient="record")[0]['c'])
Out[41]: numpy.float64

1.0.1

In [2]: df = pd.DataFrame([{"a": 1, "b": 2}, {"a": 3, "b": 4, "c": 5}])

In [3]: df2 = pd.DataFrame([{"a": 1, "b": 2}, {"a": 3, "b": 4, "c": 5}], dtype="Int64")

In [4]: df
Out[4]:
   a  b    c
0  1  2  NaN
1  3  4  5.0

In [5]: df2
Out[5]:
   a  b     c
0  1  2  <NA>
1  3  4     5

# Cのみfloatに、他はintのまま変換されている
In [6]: df.to_dict(orient="record")
Out[6]: [{'a': 1, 'b': 2, 'c': nan}, {'a': 3, 'b': 4, 'c': 5.0}]

# 基本、numpy.int64で保持されている
In [7]: df2.to_dict(orient="record")
Out[7]: [{'a': 1, 'b': 2, 'c': <NA>}, {'a': 3, 'b': 4, 'c': 5}]

In [9]: type(df.to_dict(orient="record")[0]['a'])
Out[9]: int

In [10]: type(df2.to_dict(orient="record")[0]['a'])
Out[10]: numpy.int64

In [11]: type(df.to_dict(orient="record")[0]['c'])
Out[11]: float

In [13]: type(df2.to_dict(orient="record")[0]['c'])
Out[13]: pandas._libs.missing.NAType

df.to_records を使った処理例は以下の通り。

1.0.1

In [2]: df = pd.DataFrame([{"a": 1, "b": 2}, {"a": 3, "b": 4, "c": 5}])

In [3]: df2 = pd.DataFrame([{"a": 1, "b": 2}, {"a": 3, "b": 4, "c": 5}], dtype="Int64")

In [4]: df
Out[4]:
   a  b    c
0  1  2  NaN
1  3  4  5.0

In [5]: df2
Out[5]:
   a  b     c
0  1  2  <NA>
1  3  4     5

In [25]: df2.to_records(index=False)
Out[25]:
rec.array([(1, 2, <NA>), (3, 4, 5)],
          dtype=[('a', 'O'), ('b', 'O'), ('c', 'O')])

# listで受け取りたい場合
In [26]: list(df2.to_records(index=False))
Out[26]: [(1, 2, <NA>), (3, 4, 5)]

# コラム名を取得したい場合
In [27]: df2.to_records(index=False).dtype.names
Out[27]: ('a', 'b', 'c')

# nullableなdtypeを使わない場合の type
In [19]: list(map(lambda x: type(x), df.to_records(index=False)[0]))
Out[19]: [numpy.int64, numpy.int64, numpy.float64]

In [22]: list(map(lambda x: type(x), df.to_records(index=False)[1]))
Out[22]: [numpy.int64, numpy.int64, numpy.float64]

# nullableなdtypeを使った場合の type
In [23]: list(map(lambda x: type(x), df2.to_records(index=False)[0]))
Out[23]: [int, int, pandas._libs.missing.NAType]

In [24]: list(map(lambda x: type(x), df2.to_records(index=False)[1]))
Out[24]: [int, int, int]

0.23.4

In [45]: df = pd.DataFrame([{"a": 1, "b": 2}, {"a": 3, "b": 4, "c": 5}])

In [46]: df
Out[46]:
   a  b    c
0  1  2  NaN
1  3  4  5.0

In [47]: df.to_records(index=False)
Out[47]:
rec.array([(1, 2, nan), (3, 4,  5.)],
          dtype=[('a', '<i8'), ('b', '<i8'), ('c', '<f8')])

# 各値のtype
In [48]: list(map(lambda x: type(x), df.to_records(index=False)[0]))
Out[48]: [numpy.int64, numpy.int64, numpy.float64]

In [49]: list(map(lambda x: type(x), df.to_records(index=False)[1]))
Out[49]: [numpy.int64, numpy.int64, numpy.float64]

dtypeの比較ができない場合がある

Int64 などは numpy.dtype と比較できるが、 boolean, string とは比較できない。うっかり、DataFrameのdtypeごとに比較しようとすると死ぬ。

In [60]: pd.Int64Dtype() == "boolean"
Out[60]: False

In [61]: pd.Int64Dtype() == "Int8"
Out[61]: False

In [62]: np.dtype('float64') == "Int8"
.venv-10/bin/ipython:1: DeprecationWarning: Numeric-style type codes are deprecated and will result in an error in the future.
  #!/Users/aki/src/pytd/.venv-10/bin/python3.7
Out[62]: False

In [59]: np.dtype('float64') == "boolean"
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-59-bc057c799968> in <module>
----> 1 np.dtype('float64') == "boolean"

これは回避の仕方が見つかっていない。

まとめ

当初は pandas.NA の挙動を調べるつもりで1.0前後の差異を調べたが、気がつけばすでに0.24のときに変わっていたことがわかった。1.0内での挙動も違ったりするし、 StringDtype などは experimental featureなので挙動は今後も変わりうる。msgpackへの変換などでPythonのpremitive型に変換するのは気をつける必要がありそう。

Aki Ariga
Authors
Staff Software Engineer
AI Product Engineer. Interested in Machine Learning, MLOps, and Data driven business. If you like my blog post, I’m glad if you can buy me a tea 😉

Related