Pandasでのデータ保存・読込速度を高速化したい（Pickle、Numpy、Dask比較）


Python上で表データを参照・操作する際、Pandasを利用するのが便利です。  
ただしPandasが用意しているCSVデータへのファイル保存メソッド「`.to_csv()`」とファイル読み込みメソッド「`.read_csv()`」は、**速度が遅くストレスを感じる**ことが多々あります。ストレスだけならまだしも、レスポンスを求められるアプリケーションにて支障が出る場合もあります。

この悩みを解決するために、以下の2種類の方法が有名です。
<dl>
  <dt>Pickleファイルとして保存・読み込み</dt>
  <dd>[pandas.DataFrame, Seriesをpickleで保存、読み込み（to_pickle, read_pickle）](https://note.nkmk.me/python-pandas-to-pickle-read-pickle/)</dd>
  <dd>[[Python] 時系列CSVの読み込みを爆速化する](https://qiita.com/Yuhsak/items/7c8e2b131cc536ef9ba1)</dd>
  <dd>[csvをpickleで保存して読み取りを速くする](https://qiita.com/ctn15/items/501d8f838234f2308490)</dd>
  <dt>ライブラリ「dask」のデータフレームで保存・読み込み</dt>
  <dd>[データ分析のための並列処理ライブラリDask
](https://qiita.com/kodai_sudo/items/c2ff1e85da18eaf13b65)</dd>
  <dd>[PythonのDaskをしっかり調べてみた（大きなデータセットを快適に扱う）
](https://qiita.com/simonritchie/items/e174f243bc03fb25462e)</dd>
</dl>

上記以外で私がふと思い付いたのは、**Pandasデータフレーム形式をNumpy配列形式（ndarray）に変換した上で保存・読込**、という方法です。Numpy配列もバイナリファイルでの保存・読み込みに対応しており、これも相当な高速さです。

そのため今回、PandasのDataFrameを上記2つの方法に加えて、Numpy配列として保存・読込する速度を、圧縮無し・有りで比較しました[^1]。

[^1]: Qiitaでは過去に以下記事でPickleとNumpy配列の保存・読込速度を比較していますが、今回はその厳密版です。 [pickle vs npy](https://qiita.com/daenqiita/items/d86dee829ded639f929b)

結論を言うと今回のケースでは、**圧縮無し・有りの両方の場合でPickleとNumpyはほぼ同じ速度で保存・読込ができました**。行列演算のためにCSVを保存している、などのシーンではNumpy配列で保存・読込するのもアリだと考えました。

#実験環境
以下のマシン、Python、ライブラリを利用しています。

| HW | spec |
|:-:|:--|
|CPU|Intel Core-i7 8700K 3.7GHz（OC無し）|
|RAM|64 GB|
|OS|Windows 10 Pro|
|Storage|SSD|

| SW | Version |
|:-:|:--|
|Python|3.7.6|
|Pandas|1.1.3|
|Numpy|1.18.5|
|Dask|2.11.0|

#実験用データ内容
実験用データとして、26列、n行のデータフレームを作ります。「n行」を「100,000行」「500,000行」「1,000,000行」「5,000,000行」としてそれぞれデータフレームを作ります。

 - 各列のヘッダーは大文字アルファベット```A,B, C, … X, Y, Z```とします。このヘッダー情報もファイルに保存します。
 - A～Z列（26列）には、0～10までのランダムな数値（float64）が入ります。文字列はヘッダー以外入りません。
 -  一番左のインデックス番号```0, 1, 2…```はデータフレーム上に表示されますが、ファイルには保存しません。

例えば10万行のデータフレームは以下のようなものです。

| |A|B|C|...|X|Y|Z|
|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
|0|3.944174|7.77929|5.353887|...|3.780075|4.20695|5.699642|
|1|2.599836|1.344337|4.334787|...|2.681332|2.518155|2.365451|
|2|3.958478|1.571491|5.058014|...|4.280774|8.359501|7.578656|
|3|1.900132|3.497633|6.768347|...|5.575207|2.407428|4.613036|
|...|...|...|...|...|...|...|...|
|99997|3.383083|2.935686|8.675706|...|6.491713|5.677511|3.364014|
|99998|1.281705|1.754496|7.459649|...|1.006637|1.704848|0.303642|
|99999|4.947884|1.561846|8.458604|...|9.590147|5.12362|9.163138|

上記の形の「100,000行」「500,000行」「1,000,000行」「5,000,000行」のデータフレームを、各方式別に保存・読込し速度を比較していきます。

上記のデータフレームを生成するコードは以下です。

```python:保存対象のDataFrameを生成する
import pandas as pd
import numpy as np

def init_df_exp(num):
    col = [chr(i) for i in range(65, 65+26)] # アルファベット26文字のリスト
    arr = np.random.rand(num, len(col)) * 10
    df_res = pd.DataFrame(arr, columns=col)
    return df_res

df = init_df_exp(100000)
```


#保存速度比較
上記で説明した保存対象のデータフレームを、以下の3つの方式で保存し速度を比較します。
##3つの方式のコード（簡易）
```python:Pandas_CSV方式（PandasでのCSV保存）
df.to_csv('test.csv', index=None) # 圧縮無し
df.to_csv('test.csv.gz', index=None, compression='gzip') # 圧縮有り
```
```python:Pickle方式（PandasでのPickle保存）
df.to_pickle('test.pkl') # 圧縮無し
df.to_pickle('test.pkl.gz', compression='gzip') # 圧縮有り
```
```python:Numpy方式（Numpy配列に変換して保存）
np_data = np.array(df)
# ndarrayの場合はヘッダー情報が失われるので、別配列にして保存しておく
np_header = np.array(df_w.columns.tolist())
np.savez('test.npz', np_header, np_data) # 圧縮無し
np.savez_compressed('test_comp.npz', np_header, np_data) # 圧縮有り
```
実験では上記コードを関数化し、データ量（100,000行、500,000行、1,000,000行、5,000,000行）ごとに速度を表に出力します。

##保存速度の比較結果
###圧縮無の場合
※単位は秒

| |10万行|50万行|100万行|500万行|
|:--|:-:|:-:|:-:|:-:|
|Pandas CSV方式|3.085|15.519|30.983|154.018|
|Pickle方式|**0.036**|0.341|0.649|3.405|
|Numpy方式|**0.036**|**0.181**|**0.348**|**1.782**|

###圧縮有の場合
※単位は秒

| |10万行|50万行|100万行|500万行|
|:--|:-:|:-:|:-:|:-:|
|Pandas CSV方式 |6.365|32.342|63.529|314.245|
|Pickle方式 |0.679|3.538|6.955|34.644|
|Numpy方式 |**0.665**|**3.298**|**6.511**|**32.539**|

Pandasの通常のCSV保存だと相当な時間がかかります。500万行 \* 26列の保存に圧縮無しでも2～3分かかり、結構なストレスです。しかしPickle方式とNumpy方式だとほんの数秒で処理が完了します。通常のPandas CSV方式での保存速度と比べると、**Pickle方式とNumpy方式は45倍～86倍ほど高速**でした。圧縮がある場合でも、9倍以上高速でした。

便宜上、最も速い数値を強調していますが、PickleとNumpyの差は実験スクリプトを回す度に前後するので誤差の範囲かと考えます（生成するデータフレームは毎回ランダムなため、数値の並びによって処理のし易さ・難しさががあるのでしょう）。つまり**PickleとNumpyはほぼ同じ速度**と言えます。

#読込速度比較
保存し各方式のファイルを、それぞれの方式で読み込み、速度を比較します。読み込みの方式には、多くのPandas処理を並列化可能なライブラリ「Dask」を追加しました。
##4つの方式のコード（簡易）
```python:Pandas_CSV方式（Pandasでの通常CSV読み込み）
pd.read_csv('test.csv') # 圧縮無し
pd.read_csv('test.csv.gz', compression='gzip') # 圧縮有り
```
```python:Pickle方式（PandasでのPickle読み込み）
pd.read_pickle('test.pkl') # 圧縮無し
pd.read_pickle('test.pkl.gz', compression='gzip') # 圧縮有り
```
```python:Numpy方式（Numpy配列バイナリを読み込み、データフレームへ変換）
npz = np.load('test.npz', allow_pickle=True) # 圧縮無し
# npz形式に複数のNumpy配列を保存した場合、'arr_0', 'arr_1'…の順に取り出せる
pd.DataFrame(npz['arr_1'], columns=npz['arr_0'])
npz_comp = np.load('test_comp.npz', allow_pickle=True) # 圧縮有り
pd.DataFrame(npz_comp ['arr_1'], columns=npz_comp ['arr_0'])
```
```python:Dask_CSV方式（Daskで読み込み、Pandasのデータフレームへ変換）
import dask as dd

dd.read_csv('test.csv').compute() # 圧縮無し
dd.read_csv('test.csv.gz', compression='gzip').compute() # 圧縮有り
```
実験では上記コードを関数化し、データ量ごと（100,000行、500,000行、1,000,000行、5,000,000行）に速度を表に出力します。
##読込速度の比較結果
###圧縮無の場合
※単位は秒

| |10万行|50万行|100万行|500万行|
|:--|:-:|:-:|:-:|:-:|
|Pandas CSV方式|0.41|1.968|3.887|19.431|
|Pickle方式|**0.022**|**0.088**|**0.164**|**0.801**|
|Numpy方式|0.032|0.135|0.268|1.283|
|Dask CSV方式|0.463|0.928|1.431|6.624|

###圧縮有の場合
※単位は秒

| |10万行|50万行|100万行|500万行|
|:--|:-:|:-:|:-:|:-:|
|Pandas CSV方式|0.653|3.131|6.224|31.038|
|Pickle方式|0.139|0.646|1.27|6.359|
|Numpy方式|**0.105**|**0.51**|**1.012**|**4.958**|
|Dask CSV方式|0.734|3.552|6.903|34.763|

やはり**Pickle方式とNumpy方式が圧倒的に速く、この二つの方式はほぼ同等の速度**でした。この二つの方式は、通常のPandas CSV方式での読み込み速度より**15～25倍ほど高速**だという結果になりました。

Dask方式は圧縮がないファイルの場合はPandas CSV方式よりも3倍以上の速さでした。しかし**Dask方式は圧縮があるファイルの読み込みをすると、Pandas CSV方式と同じ速度**になりました。

#まとめ
**PickleとNumpyのバイナリ形式での保存・読込は高速**です。事前に保存できるのであればPickleかNumpyで保存するとレスポンスが良く、ストレスを軽減できます。データフレームの保存の場合はPickleの方がシンプルにできます。ただしCSVとして平文で保存しないといけない場合は、Daskでの読込を考えて良いでしょう。

PickleとNumpyはいずれもシリアル化を別個に実装していてアルゴリズムは異なっているようですが、詳しく見ていないのでわかりません[^2]。同じアルゴリズムだったら特に比較検討は意味ないですね。

[^2]: PythonモジュールのPickleでのシリアル化のアルゴリズムは以下のサイトで説明されていますが、詳しくは読んでいません→[How pickle works in Python](https://rushter.com/blog/pickle-serialization-internals/) 。Numpyのシリアル化のアルゴリズムは以下で説明されています。こちらも詳しく読んでません。[GitHub: numpy/lib/format.py](https://github.com/numpy/numpy/blob/v1.19.0/numpy/lib/format.py)

保存においては、数値テーブルデータだけの場合、総データ数が300万個くらい（26列 \* 10万行）になったところを境に、PickleとNumpyでの保存の速度に優位が出てくるような結果になりました。圧縮がある場合はなおさらです。

読込においては、数値テーブルデータだけの場合、総データ数が3000万個くらい（26列 * 100万行）になったところを境に、PickleとNumpyでの保存の速度に優位が出てくるような結果になりました。圧縮がある場合はなおさらです。DaskはCSVの形式の読込ができ、Pandasよりも高速です。しかし圧縮されたCSVの場合はDaskとPandasは同じ速度になります。

私はあるWeb APIの開発で、数値テーブル同士の行列計算をする処理が必要でした。その際、DBからデータを引っ張るとトラフィックが毎回の処理でNWトラフィックが発生してしまうためローカルファイルで済ます必要がありました。そのため、どの形式で保存すればレスポンスが最も速くできるかを確認する必要がありました。PickleはPython標準ライブラリで良いのですが、Numpy配列のままで保存すれば行列計算にそのまま使えるので、そのようなケースではNumpy配列のバイナリとして保存することも良いと考えています。