Python: pandas の永続化フォーマットについて調べた - CUBE SUGAR CONTAINER

CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: pandas の永続化フォーマットについて調べた

以前、このブログでは pandas の DataFrame を Pickle として保存することで読み込み速度を上げる、というテクニックを紹介した。

blog.amedama.jp

実は pandas がサポートしている永続化方式は Pickle 以外にもある。 今回は、その中でも代表的な以下の永続化フォーマットについて特性を調べると共に簡単なベンチマークを取ってみることにした。

  • Pickle
  • Feather
  • Parquet

使った環境とパッケージのバージョンは次の通り。

$ sw_vers 
ProductName:    Mac OS X
ProductVersion: 10.13.5
BuildVersion:   17F77
$ python -V
Python 3.6.5
$ pip list --format=columns | egrep "(pandas|feather-format|pyarrow)"
feather-format   0.4.0      
pandas           0.23.3     
pyarrow          0.9.0.post1

pandas の永続化フォーマットについて

本格的な説明に入る前に、まずはこの記事で扱う各フォーマットの概要について解説する。

Pickle Format

Python が標準でサポートしている直列化方式。 直列化というのは、プログラミングにおいてオブジェクトをバイト列に変換・逆変換する機能のことをいう。 バイト列に変換した内容は、ディスクにファイルなどの形で保存 (永続化) できる。

基本的に、直列化した内容は Python でしか扱うことができない。 つまり、言い換えると他のプログラミング言語や分析ツール (例えば R など) へのポータビリティはない。

その他、詳しくは以下のブログ記事で解説している。

blog.amedama.jp

Feather Format

主に Python と R の間でデータのポータビリティがあることを目指して策定されたファイルフォーマット。 行単位ではなく列単位でデータを格納する形式 (列指向・カラムナ) になっている。 今のところ pandas では試験的な導入という位置づけらしい。

Parquet Format

Apache Hadoop エコシステムでよく使われているファイルフォーマット。 こちらも Feather フォーマットと同様に、異なるプログラミング言語や分析ツールの間でポータビリティがある。

以前、このブログでも Python で Parquet フォーマットを実装した二つのパッケージについて調べたことがある。

blog.amedama.jp

下準備

それぞれの永続化フォーマットについての解説が済んだところで、次は実際に試してみる準備に入る。

まずは、今回使うパッケージ一式をインストールしておく。

$ pip install pandas ipython feather-format memory_profiler

動作確認のためのデータセットは、ベンチマークを取りたいのである程度サイズがほしい。 そこで前述した Pickle の記事と同様に Kaggle の大気汚染データセットを使うことにした。

Hazardous Air Pollutants | Kaggle

上記のページか、あるいは kaggle コマンドを使ってデータセットをダウンロードしてくる。

$ kaggle datasets download -d epa/hazardous-air-pollutants

ダウンロードしたらデータセットを展開する。

$ mv ~/.kaggle/datasets/epa/hazardous-air-pollutants/hazardous-air-pollutants.zip .
$ unzip hazardous-air-pollutants.zip 

すると、次の通り 2GB 強の CSV ファイルが手に入る。

$ du -m epa_hap_daily_summary.csv 
2349   epa_hap_daily_summary.csv

続いて IPython の REPL を起動する。

$ ipython

Python 純正の REPL を使わないのは理由は、実行時間やメモリ使用量の計測が IPython を使った方が楽ちんなので。

REPL を起動したら pandas をインポートした上で上記の CSV を読み込んでおく。

>>> import pandas as pd
>>> df = pd.read_csv('epa_hap_daily_summary.csv')

次の通りデータセットが読み込まれれば準備完了。

>>> df.head()
   state_code         ...           date_of_last_change
0          42         ...                    2015-07-22
1          48         ...                    2014-03-25
2          22         ...                    2015-07-22
3          18         ...                    2017-02-20
4           6         ...                    2015-07-22

[5 rows x 29 columns]

各永続化方式のベンチマーク

今回は、各永続化方式について以下の観点でベンチマークを取ってみる。

  • 保存にかかる時間
  • 保存したときのファイルサイズ
  • 読み込みにかかる時間
  • 読み込みに必要なメモリサイズ

先に断っておくと、今回のベンチマークはあくまで簡易的なものになっている。 本来であれば、扱うデータ型などによる特性の違いや、試行回数についても複数回の平均を取りたい。 とはいえ、そこまでやると大変なので、今回は大気汚染データセットだけを使って、かつ実行回数も 1 回だけに絞っている。 もしかすると環境や使うデータセットによっては異なる結果が出る場合もあるかもしれない。

ちなみに、あとの方に結果をまとめた表を作ってあるので読むのがめんどくさい人はそこまで読み飛ばしてもらえると。

保存にかかる時間

まずは各永続化フォーマットでファイルに保存するのにかかる時間を測ってみる。

Pickle Format

>>> %time df.to_pickle('hazardous-air-pollutants.pickle')
CPU times: user 9.31 s, sys: 9.17 s, total: 18.5 s
Wall time: 20.8 s

Feather Format

>>> %time df.to_feather('hazardous-air-pollutants.feather')
CPU times: user 1min 38s, sys: 6.76 s, total: 1min 45s
Wall time: 1min 46s

Parquet Format

>>> %time df.to_parquet('hazardous-air-pollutants.snappy.parquet')
CPU times: user 2min 2s, sys: 7.52 s, total: 2min 9s
Wall time: 2min 15s

pandas.to_parquet() 関数はデフォルトで snappy による圧縮がかかる。 念のため、圧縮をかけない場合についても確認しておこう。

>>> %time df.to_parquet('hazardous-air-pollutants.none.parquet', compression=None)
CPU times: user 2min 3s, sys: 9.19 s, total: 2min 12s
Wall time: 2min 30s

あまり変わらないか、むしろ遅くなっている。

ちなみに Pickle フォーマットも GZip で圧縮することはできる。 ただし、試してみたところ保存に時間がかかりすぎて実用に耐えないという判断で検証するのを止めた。

上記から、保存に関しては Pickle フォーマットが最も早いことが分かった。

終わったら、一旦 IPython の REPL から抜けておこう。

>>> exit()

保存したときのファイルサイズ

続いては各永続化フォーマットで保存したときのディスク上のサイズを確認する。

まず、元々の CSV は 2.3GB だった。

$ du -m epa_hap_daily_summary.csv 
2349   epa_hap_daily_summary.csv

各永続化フォーマットにおけるサイズは次の通り。 圧縮の有無に関わらず Parquet が抜きん出て小さいことが分かる。

$ du -m hazardous-air-pollutants.* | grep -v zip$
3116   hazardous-air-pollutants.feather
260    hazardous-air-pollutants.none.parquet
1501   hazardous-air-pollutants.pickle
228    hazardous-air-pollutants.snappy.parquet

それ以外だと Pickle はやや小さくなっているのに対し Feather は元の CSV よりもサイズが増えてしまっていることが分かる。

読み込みにかかる時間

もう一度 IPython の REPL を起動して pandas をインポートしておこう。

$ ipython
>>> import pandas as pd

準備ができたら各永続化フォーマットで保存したファイルを読み込むのにかかる時間を測ってみる。

Pickle Format

>>> %time df = pd.read_pickle('hazardous-air-pollutants.pickle')
CPU times: user 4.51 s, sys: 4.07 s, total: 8.58 s
Wall time: 9.03 s

Feather Format

>>> %time df = pd.read_feather('hazardous-air-pollutants.feather')
CPU times: user 18.2 s, sys: 17.3 s, total: 35.4 s
Wall time: 1min 8s

Parquet Format

%time df = pd.read_parquet('hazardous-air-pollutants.snappy.parquet')
CPU times: user 27.4 s, sys: 34.4 s, total: 1min 1s
Wall time: 1min 31s

念のため snappy 圧縮していないものについても。

%time df = pd.read_parquet('hazardous-air-pollutants.none.parquet')
CPU times: user 27.3 s, sys: 34.7 s, total: 1min 2s
Wall time: 1min 18s

わずかに読み込み時間が短くなっている。

終わったら一旦 IPython の REPL を終了しておこう。

>>> exit()

読み込みに必要なメモリサイズ

続いては永続化されたファイルを読み込む際に必要なメモリのサイズを確認しておく。 これが大きいと、読み込むときにスラッシングを起こしたり最悪の場合はメモリに乗り切らずにエラーになる恐れがある。

まずは IPython を起動して pandas をインポートする。

$ ipython
>>> import pandas as pd

それができたら、続いては %load_ext で memory_profiler を読み込む。 これで %memit マジックコマンドが使えるようになる。

>>> %load_ext memory_profiler

あとは各永続化フォーマットで保存されたファイルを読み込む際のメモリ使用量を %memit マジックコマンドで確認していく。

Pickle Format

>>> %memit df = pd.read_pickle('hazardous-air-pollutants.pickle')
peak memory: 2679.70 MiB, increment: 2617.24 MiB

読み込みが終わったら GC を呼んで読み込んだ DataFrame を開放しておく。

>>> del df; import gc; gc.collect()

仕様的には手動で呼び出しても本当にオブジェクトが回収される保証はないはず。 とはいえ、まあこの状況ならまず間違いなく回収されるでしょう。

Feather Format

>>> %memit df = pd.read_feather('hazardous-air-pollutants.feather')
peak memory: 3614.03 MiB, increment: 3549.74 MiB

終わったら GC を呼ぶ。

>>> del df; import gc; gc.collect()

Parquet Format

>>> %memit df = pd.read_parquet('hazardous-air-pollutants.snappy.parquet')
peak memory: 3741.76 MiB, increment: 3677.94 MiB

終わったら GC を呼ぶ。

>>> del df; import gc; gc.collect()

念のため snappy 圧縮なしのパターンについても。

%memit df = pd.read_parquet('hazardous-air-pollutants.none.parquet')
peak memory: 3771.86 MiB, increment: 3707.23 MiB

ベンチマーク結果

ベンチマークの結果を表にまとめてみる。

項目 Pickle Feather Parquet (snappy)
保存にかかる時間 20s 1m46s 2m15s
保存したときのサイズ 1501MB 3116MB 228MB
読み込みにかかる時間 9s 1m8s 1m18s
読み込みに必要なメモリサイズ 2679MB 3614MB 3741MB

保存したときのサイズは Parquet が最も小さかったけど、それ以外は全て Pickle の性能が優れていた。

各永続化フォーマットを使う際の注意点

Pickle Format

Pickle に関しては、保存する DataFrame に対する制約は特にない。 その以外で特に気をつける点としては Pickle フォーマットにはバージョンがあるところくらいかな。 これは、異なるバージョンの Python 間で同じ Pickle ファイルを扱う場合には、フォーマットのバージョンに気をつける必要がある。 あとは前述した通り Python 以外から扱えないので基本的に他の環境へのポータビリティがない。

その他、細かい点については以下のブログ記事にまとめてある。

blog.amedama.jp

Feather Format

pandas の DataFrame を Feather フォーマットで保存する際の注意点は以下のページに記載されている。

IO Tools (Text, CSV, HDF5, …) — pandas 0.23.3 documentation

以下に、要点をコードで解説する。

インデックスがあると保存できない

Feather フォーマットを使う場合、DataFrame にインデックスが設定されていると永続化できない。

物は試しということで、まずはインデックスを指定した DataFrame を用意しよう。

>>> import pandas as pd
>>> data = [(10, 'Alice'), (20, 'Bob'), (30, 'Carol')]
>>> df = pd.DataFrame(data, columns=['id', 'name'])
>>> df = df.set_index('id')

上記を Feather フォーマットで保存してみる、と以下のような例外になる。

>>> df.to_feather('example.feather')
Traceback (most recent call last):
...(省略)...
ValueError: feather does not support serializing a non-default index for the index; you can .reset_index() to make the index into column(s)

もし、インデックスの指定がある DataFrame を Feather で扱いたいとしたら次のようにする。 一旦インデックスをリセットして、カラムの形で持っておけば大丈夫。

>>> df = df.reset_index()
>>> df.to_feather('example.feather')

とはいえ、ちょっとめんどくさいね。

重複するカラム名があると保存できない

pandas の DataFrame には重複するカラム名を保存できる。 ただし Feather フォーマットでは重複するカラム名がある DataFrame を永続化できない。

実際に試してみよう。 同じカラム名がある DataFrame を作る。

>>> import pandas as pd
>>> data = [('a', 'b'), ('c', 'd'), ('e', 'f')]
>>> df = pd.DataFrame(data, columns=['feature_name', 'feature_name'])

この通り、ちゃんと作れる。

>>> df
  feature_name feature_name
0            a            b
1            c            d
2            e            f

しかし、永続化しようとすると、この通り例外になる。

>>> df.to_feather('example.feather')
Traceback (most recent call last):
...(省略)...
ValueError: cannot serialize duplicate column names
保存できない型がある

Feather フォーマットでは保存できない型が存在する。 例えば Period 型なんかは典型のようだ。

実際に試してみよう。

>>> import pandas as pd
>>> data = [
...     pd.Period('2018-01-01'),
...     pd.Period('2018-01-02'),
...     pd.Period('2018-01-03'),
... ]
>>> df = pd.DataFrame(data, columns=['periods'])

保存しようとすると、次の通り例外になる。

>>> df.to_feather('example.feather')
Traceback (most recent call last):
...(省略)...
pyarrow.lib.ArrowInvalid: Error inferring Arrow type for Python object array. Got Python object of type Period but can only handle these types: string, bool, float, int, date, time, decimal, list, array

上記のエラーメッセージには取り扱える型の一覧も表示されている。

Parquet Format

続いて Parquet フォーマットを扱う上での注意点について。 公式では以下のページにまとめられている。

IO Tools (Text, CSV, HDF5, …) — pandas 0.23.3 documentation

以下、要点をコードで確認していこう。

重複するカラム名があると保存できない

これは先ほど紹介した Feather フォーマットと同様。 そもそもファイルフォーマットとして、重複したカラム名を想定していないんだろう。

>>> import pandas as pd
>>> data = [('a', 'b'), ('c', 'd'), ('e', 'f')]
>>> df = pd.DataFrame(data, columns=['feature_name', 'feature_name'])

省略すると、先ほどと同じ例外になる。

>>> df.to_parquet('example.parquet')
Traceback (most recent call last):
...(省略)...
ValueError: Duplicate column names found: ['feature_name', 'feature_name']
カテゴリカル変数を保存して読み出すとオブジェクト型になる

pandas にはカテゴリカル変数が型として用意されている。 この型は Parquet フォーマットで保存することはできるんだけど、読み出すときにオブジェクト型になってしまう。

実際に試してみよう。 カテゴリカル変数が含まれるものを保存してもエラーにはならない。

>>> import pandas as pd
>>> data = [('a'), ('b'), ('c')]
>>> df = pd.DataFrame(data, columns=['categorical_name'], dtype='category')
>>> df.dtypes
categorical_name    category
dtype: object
>>> df.to_parquet('example.parquet')

ただし保存されたものを読み出してみると? なんとオブジェクト型になってしまっている。

>>> df = pd.read_parquet('example.parquet')
>>> df.dtypes
categorical_name    object
dtype: object

これは知らないとハマりそう。

保存できない型がある

これは先ほどの Feather フォーマットと同様。

例えば Period 型を保存しようとすると例外になる。

>>> import pandas as pd
>>> data = [
...     pd.Period('2018-01-01'),
...     pd.Period('2018-01-02'),
...     pd.Period('2018-01-03'),
... ]
>>> df = pd.DataFrame(data, columns=['periods'])
>>> df.to_parquet('example.parquet')
Traceback (most recent call last):
...(省略)...
pyarrow.lib.ArrowInvalid: Error inferring Arrow type for Python object array. Got Python object of type Period but can only handle these types: string, bool, float, int, date, time, decimal, list, array
マルチインデックスに使うカラム名は文字列型である必要がある

pandas には複数のカラムを用いたインデックスが作れる。 ただし、そのインデックスに使うカラム名の型は必ず文字列じゃないとだめ。

例えばマルチインデックスの一つとして名前が数値のカラムを指定してみよう。 以下でいう 1970 というカラム。

>>> import pandas as pd
>>> data = [
...     ('Alice', 47, 70),
...     ('Alice', 48, 80),
...     ('Bob', 47, 50),
...     ('Bob', 48, 45),
...     ('Carol', 47, 60),
...     ('Carol', 48, 65),
... ]
>>> df = pd.DataFrame(data, columns=['name', 1970, 'score'])
>>> df = df.set_index(['name', 1970])

この通り、ちゃんと数値の名前がついている。

>>> df.index
MultiIndex(levels=[['Alice', 'Bob', 'Carol'], [47, 48]],
           labels=[[0, 0, 1, 1, 2, 2], [0, 1, 0, 1, 0, 1]],
           names=['name', 1970])

これを Parquet フォーマットで保存しようとするとエラーになる。

>>> df.to_parquet('example.parquet')
Traceback (most recent call last):
...(省略)...
ValueError: Index level names must be strings

まあ文字列以外の名前をつけようとするなんて、そうそうないとは思うけど。

まとめ

今回は pandas の代表的な永続化フォーマットについて、その特性を調べると共に簡易なベンチマークを取ってみた。 簡易的かつ、今回使った環境では、という断りを入れた上でまとめると、次の通り。

  • ディスク上のファイルサイズという観点では Parquet フォーマットが優れていた
  • それ以外の観点 (読み書きの速度やメモリ消費量) では Pickle フォーマットが優れていた
  • pandas と Parquet / Feather フォーマットを組み合わせて使う場合には、いくつかの制限事項がある

以上から、使い分けについては次のことが言えると思う。

  • 基本的には Pickle フォーマットを使っていれば良さそう
  • ディスク上のファイルサイズに制約があれば Parquet フォーマットが良さそう
  • 他の環境とのポータビリティが必要なときは Parquet もしくは Feather フォーマットを使う

いじょう。

2018/07/25 追記

memory_profiler の結果だとメモリ使用量を上手く見積もれない場合がある、という話を耳にした。 そこで GNU time を使った測定もしてみた。 結果を以下に記載する。

下準備

まずは Homebrew を使って GNU time をインストールする。

$ brew install gnu-time

CSVからの 読み込み

とりあえずの基準として CSV を読み込むときのメモリ使用量は次の通り。

$ gtime -f "%M KB" python -c "import pandas as pd; df = pd.read_csv('epa_hap_daily_summary.csv')"
3390012 KB

各永続化フォーマットでの書き込み

各永続化フォーマットで書き込むときのメモリ使用量は次の通り。

$ gtime -f "%M KB" python -c "import pandas as pd; df = pd.read_csv('epa_hap_daily_summary.csv'); df.to_pickle('hazardous-air-pollutants.pickle')"
4381452 KB
$ gtime -f "%M KB" python -c "import pandas as pd; df = pd.read_csv('epa_hap_daily_summary.csv'); df.to_feather('hazardous-air-pollutants.feather')"
5802100 KB
$ gtime -f "%M KB" python -c "import pandas as pd; df = pd.read_csv('epa_hap_daily_summary.csv'); df.to_parquet('hazardous-air-pollutants.snappy.parquet')"
5715660 KB

Pickle フォーマットが最も少ないようだ。

各永続化フォーマットからの読み込み

各永続化フォーマットから読み込むときのメモリ使用量は次の通り。

$ gtime -f "%M KB" python -c "import pandas as pd; df = pd.read_pickle('hazardous-air-pollutants.pickle')"
3028756 KB
$ gtime -f "%M KB" python -c "import pandas as pd; df = pd.read_feather('hazardous-air-pollutants.feather')"
4540188 KB
$ gtime -f "%M KB" python -c "import pandas as pd; df = pd.read_parquet('hazardous-air-pollutants.snappy.parquet')"
5515928 KB

やはり、こちらも Pickle が最も消費が少なかった。

項目 Pickle Feather Parquet (snappy)
書き込みに必要なメモリサイズ (GNU time) 4381452KB 5802100KB 5715660KB
読み込みに必要なメモリサイズ (GNU time) 3028756KB 4540188KB 5515928KB

いじょう。