PyArrowとParquet

さて、ビッグデータ全盛の昨今、数ギガバイト程度のデータのやり取りは珍しくもなんともない時代になりました。交換用データファイルのフォーマットもいろいろな形式が使われていますが、ここではPythonで一般的に使われているファイル形式を簡単に検討してみましょう。

CSV

昔から、単純な表形式のデータにはCSVが使われてきました。Microsoft Excelをはじめとしてさまざまなツールでサポートされており、幅広い環境で利用できます。

データの作成

例として10万行・100カラムのデータを作成し、CSV形式で保存してみましょう。インデックスとして、datetime型の値を指定してます。

In [ ]:
%pip install pandas pyarrow numpy tqdm  dask graphviz

import sys
import numpy as np
import pandas as pd
pd.options.display.max_columns=9
pd.options.display.float_format = '{:,.4f}'.format
In [10]:
N = 100_000
COLS = 100
rng = np.random.default_rng()
vals = rng.random((N, COLS))
index = pd.date_range('2020-01-01', periods=N, freq='S')

df = pd.DataFrame(vals, index=index, 
                  columns=(f'col{n+1}' for n in range(COLS)))
In [9]:
df
Out[9]:
col1 col2 col3 col4 ... col97 col98 col99 col100
2020-01-01 00:00:00 0.1051 0.2304 0.5633 0.2186 ... 0.4938 0.9541 0.1114 0.4569
2020-01-01 00:00:01 0.8783 0.0530 0.5894 0.7277 ... 0.2515 0.1831 0.4785 0.0140
2020-01-01 00:00:02 0.6006 0.3051 0.0255 0.2219 ... 0.9567 0.6323 0.7757 0.4174
2020-01-01 00:00:03 0.7583 0.8868 0.6315 0.9253 ... 0.8089 0.3187 0.8557 0.6480
2020-01-01 00:00:04 0.8916 0.4657 0.4465 0.5070 ... 0.9331 0.5039 0.5945 0.6145
... ... ... ... ... ... ... ... ... ...
2020-01-02 03:46:35 0.9401 0.7828 0.5551 0.8462 ... 0.8787 0.0869 0.1694 0.2971
2020-01-02 03:46:36 0.5255 0.2054 0.9647 0.1585 ... 0.6190 0.3052 0.1427 0.0163
2020-01-02 03:46:37 0.6661 0.5404 0.2672 0.1961 ... 0.9528 0.1088 0.8696 0.8693
2020-01-02 03:46:38 0.8960 0.4027 0.2123 0.5152 ... 0.1540 0.9298 0.9942 0.9286
2020-01-02 03:46:39 0.4044 0.1531 0.2570 0.7808 ... 0.6997 0.0725 0.9053 0.3669

100000 rows × 100 columns

CSV形式で保存

このデータを、CSV形式で保存します。

In [262]:
%%time
df.to_csv("100k_100.csv", index_label="date")
CPU times: user 13.9 s, sys: 161 ms, total: 14 s
Wall time: 14.3 s
In [251]:
!ls -l 100k_100.csv
-rw-r--r--  1 ishimoto  staff  194698470  7 28 22:21 100k_100.csv

ファイルの出力に、14.3秒とかなりの時間がかかります。出力ファイルサイズは約200MBとなり、数値ひとつあたり20バイト近くも使っています。

In [252]:
%%time
df = pd.read_csv('100k_100.csv', index_col=0)
CPU times: user 1.2 s, sys: 88.9 ms, total: 1.29 s
Wall time: 1.29 s
In [10]:
df[:3]
Out[10]:
col1 col2 col3 col4 ... col97 col98 col99 col100
2020-01-01 00:00:00 0.1051 0.2304 0.5633 0.2186 ... 0.4938 0.9541 0.1114 0.4569
2020-01-01 00:00:01 0.8783 0.0530 0.5894 0.7277 ... 0.2515 0.1831 0.4785 0.0140
2020-01-01 00:00:02 0.6006 0.3051 0.0255 0.2219 ... 0.9567 0.6323 0.7757 0.4174

3 rows × 100 columns

読み込みには1.3秒と、出力よりはマシですが、決して高速とは言えません。効率という点では、CSVはあまり優秀なフォーマットとは言えないようです。

Pickle

Pickle はPythonの専用データ形式で、PythonのさまざまなデータはPickle形式で保存できるようになっています。同じデータを、Pickle形式で保存してみましょう。

In [254]:
%%time
df.to_pickle('100k_100.pickle')
CPU times: user 37.1 ms, sys: 40 ms, total: 77.1 ms
Wall time: 82.6 ms
In [255]:
!ls -l 100k_100.pickle
-rw-r--r--  1 ishimoto  staff  82202190  7 28 22:21 100k_100.pickle
In [263]:
%%time
df = pd.read_pickle("100k_100.pickle")
CPU times: user 16.9 ms, sys: 40.7 ms, total: 57.6 ms
Wall time: 56.5 ms

出力に 83ms、入力に57msと、CSVに比べればかなり高速です。ファイルサイズも80MBと、CSVの40%程度に抑えられています。Python以外の環境にデータを受け渡す必要がなければ、データ交換フォーマットとして利用を考慮しても良いでしょう。

ただし、Pickleは読み込み時にプログラムを実行してしまう可能性もあるフォーマットなため、一般的なデータ交換には適していません。信頼できるPickleのみを利用し、外部の組織などからのファイルは決して利用しないようにしましょう。

PyArrow

Apache Arrow は大規模なデータをメモリに読み込んで処理するためのプラットフォームで、高速なデータ転送やファイル入出力機能など、効率的なデータ処理に必要な機能を提供してくれます。PyArrowはAppache ArrowのPythonインターフェースで、NumPypandasと連携して、Apache Arrowを利用できるようになっています。

ここでは、PyArrowが提供するファイルフォーマットである、Parquet(パーケイ) を利用してみます。Parquetは、CSVと同じように、pandasのデータフレームから簡単に保存できます。

In [11]:
%%time
df.to_parquet('100k_100.parquet')
CPU times: user 1.26 s, sys: 299 ms, total: 1.56 s
Wall time: 1.04 s
In [269]:
!ls -l 100k_100.pickle
-rw-r--r--  1 ishimoto  staff  82202190  7 28 22:21 100k_100.pickle
In [270]:
%%time
df = pd.read_parquet('100k_100.parquet') 
CPU times: user 149 ms, sys: 417 ms, total: 567 ms
Wall time: 246 ms

Parquetの出力に約1秒、入力に250ms。CSVよりはだいぶマシですが、Pickleよりは遅い、という結果になりました。

しかし、これだけではParquetの真価は計れません。Parquetは、一般的なデータファイルのように行単位にデータを格納するのではなく、カラム単位にデータを格納しています。こういったデータ形式をカラムナ形式といいます。

カラムナ形式

一般的な行単位のデータ形式に比べて、カラムナ形式は何が優れているのでしょうか?

これまでの例で見てきたような、カラムが100もあるようなデータを処理する場合、同時に100カラムのデータをすべて参照する、ということはどのぐらいあるでしょうか?ほとんどの場合は、同時に集計するのは一部のカラムだけで、すべてのデータを一度に読み込む必要はないでしょう。不要なデータまで読んでしまっては時間がかかりますし、メモリの使用量も増大してデータ処理が難しくなります。

Parquetでは、このような利用形態に合わせて、必要なカラムだけを選択して読み込めます。次の例は、先頭の2カラムだけを読み込んでいます。

In [13]:
%%time 
pd.read_parquet('100k_100.parquet', columns=["col1", "col2"]) 
CPU times: user 13.5 ms, sys: 5.35 ms, total: 18.8 ms
Wall time: 16.1 ms
Out[13]:
col1 col2
2020-01-01 00:00:00 0.6070 0.0047
2020-01-01 00:00:01 0.7917 0.1273
2020-01-01 00:00:02 0.7309 0.9839
2020-01-01 00:00:03 0.0113 0.0183
2020-01-01 00:00:04 0.9194 0.6156
... ... ...
2020-01-02 03:46:35 0.0646 0.7169
2020-01-02 03:46:36 0.4037 0.4304
2020-01-02 03:46:37 0.8765 0.8167
2020-01-02 03:46:38 0.1482 0.0901
2020-01-02 03:46:39 0.8143 0.8361

100000 rows × 2 columns

必要なカラムを制限することで、読み込み時間が 238ms->16ms と短縮されました。また、メモリの使用量も大きく削減されており、より多くの行を一度に処理できるようになっています。

パーティション

また、Parquetは、指定したカラムの値に従ってファイルをディレクトリに分割して格納し、必要なデータだけを高速に読み込めるようになっています。この分割を、パーティションといいます。

次の例では、データフレームのインデックスから年・月・日・時間を取り出し、パーティション分割のキーとして指定しています。

In [5]:
df['year'] = df.index.year
df['month'] = df.index.month
df['day'] = df.index.day
df['hour'] = df.index.hour
df[:3]
Out[5]:
col1 col2 col3 col4 ... year month day hour
2020-01-01 00:00:00 0.1523 0.1402 0.1013 0.0300 ... 2020 1 1 0
2020-01-01 00:00:01 0.3084 0.6184 0.1328 0.3009 ... 2020 1 1 0
2020-01-01 00:00:02 0.3186 0.9992 0.0131 0.7248 ... 2020 1 1 0

3 rows × 104 columns

In [7]:
%%time
df.to_parquet('partitioned/100k_100_1.parquet', index=True, partition_cols=["year", "month", "day", "hour"])
CPU times: user 1.77 s, sys: 888 ms, total: 2.66 s
Wall time: 1.89 s

このParquetは、データをつぎのようなディレクトリに分割してディスクに格納します。

In [8]:
!tree partitioned
partitioned
└── 100k_100_1.parquet
    └── year=2020
        └── month=1
            ├── day=1
            │   ├── hour=0
            │   │   ├── 21d7bac2acdc4046a1df0256b0a25e0f.parquet
            │   │   └── 78b63a975ca24a06994af7d00f274f51.parquet
            │   ├── hour=1
            │   │   ├── c40adcb16e564b0597b1ddf49f29046f.parquet
            │   │   └── c56965c5cd5d4572a028aafbf7653582.parquet
            │   ├── hour=10
            │   │   ├── 2fefe35de18f4e0f9c5835623046804b.parquet
            │   │   └── c9781c66220a4527b1a05fb853a517d0.parquet
            │   ├── hour=11
            │   │   ├── 426b66ca012a485988a61e28dcb286c3.parquet
            │   │   └── 9c2e37254ab243978c7e3f928829cc9a.parquet
            │   ├── hour=12
            │   │   ├── 2c4a6771ed6a4295a53d66069ce598f3.parquet
            │   │   └── f627c19fa61f4cf59466d9f3719c3a65.parquet
            │   ├── hour=13
            │   │   ├── 3b27e9e0ce5c445aa8f423580ed11f38.parquet
            │   │   └── 6f6edfcedf1f42ab93081f8970fac903.parquet
            │   ├── hour=14
            │   │   ├── 78af86f51a1d44d7bb12eb6a08274fc8.parquet
            │   │   └── c2cab55125ef4600abb25ea0d9a5a57a.parquet
            │   ├── hour=15
            │   │   ├── 37cef3a59e714d13b7f1ea585da92c8d.parquet
            │   │   └── c50999c531bf4a929726496581145b55.parquet
            │   ├── hour=16
            │   │   ├── 441fea25a43a4268844f132ea09fbeb1.parquet
            │   │   └── 8b391772678444bc9e18b1cc902d437e.parquet
            │   ├── hour=17
            │   │   ├── 1b78cdba97ab436e90596fa0a296119e.parquet
            │   │   └── 50021b16c739456fb9cea165f1dc8dbe.parquet
            │   ├── hour=18
            │   │   ├── 1f8737783cac43ffb795b233ea04dee9.parquet
            │   │   └── d9a284bae1fb424f85f814f9e89645b1.parquet
            │   ├── hour=19
            │   │   ├── 1d633fe2239a4f97a0e23f4964ffe22a.parquet
            │   │   └── ba26e562407b451c9afc7a0fbbfa0a4d.parquet
            │   ├── hour=2
            │   │   ├── 0b57743f3ba34328b55c699a77df6360.parquet
            │   │   └── e244775c76de472daf38a68bf386456d.parquet
            │   ├── hour=20
            │   │   ├── 42a74106a0b2444fb6c480d37c38cdaa.parquet
            │   │   └── e2a3be4b81834b62885d80d407c0feba.parquet
            │   ├── hour=21
            │   │   ├── 7c0d1b678e8b42ecb2f6a9df5472fa72.parquet
            │   │   └── ca8e30c85748462cb9822579c77bcdc9.parquet
            │   ├── hour=22
            │   │   ├── 10f80381ac874695a12ddedc8eabdc8d.parquet
            │   │   └── 735db3dad7a6433cb64b21d8d9272214.parquet
            │   ├── hour=23
            │   │   ├── 694aece60bd54653906b596b6ca25ca3.parquet
            │   │   └── 7e446420f59044e2abea9ab0814d790c.parquet
            │   ├── hour=3
            │   │   ├── 5c806e6a493c4960b9083f8ac1cf0988.parquet
            │   │   └── f5083e8f43e24c25aaa98f6cbea130b2.parquet
            │   ├── hour=4
            │   │   ├── 20e041570cf04d10999db2bea427c501.parquet
            │   │   └── e9c2337782994f8fbb0709019c51bd1d.parquet
            │   ├── hour=5
            │   │   ├── 71c07526b67743ff81c5c860a73e94d0.parquet
            │   │   └── b5c73c80d04646d2912695c306bdf6ee.parquet
            │   ├── hour=6
            │   │   ├── 1a653483f2b0454694ffe2058d2ff6e1.parquet
            │   │   └── d8a4d3ea01a1462b8493b60b60cefac0.parquet
            │   ├── hour=7
            │   │   ├── 9f195726521d486db21e33701acf421e.parquet
            │   │   └── f449e74ee5334c3f9fa1983372d69b53.parquet
            │   ├── hour=8
            │   │   ├── 1db707f60add438b82f9bae757ef6b4c.parquet
            │   │   └── cf60dd48cc53484286c784257c30bb1b.parquet
            │   └── hour=9
            │       ├── 1f4aed78647946b9a17810fdf3d04670.parquet
            │       └── b014f759b0774621920a8b3c3a361f0f.parquet
            └── day=2
                ├── hour=0
                │   ├── 0559c4d902e6490cb5af4e701ad287fa.parquet
                │   └── 6cc1b31d9a1645e79e41f09fb63ae452.parquet
                ├── hour=1
                │   ├── 0fedad0eb86c407693e8c7c8123f92f6.parquet
                │   └── 1ae53d33923f42c2a6fdbad98e8b8491.parquet
                ├── hour=2
                │   ├── a2723e2d01da45bb968027645ce2095c.parquet
                │   └── f6137c87945242b8bc091e91a8eb355d.parquet
                └── hour=3
                    ├── 15978471ca22425d80fbf4b427971a2a.parquet
                    └── 25c09fcc37194033baeeb204c0e9fc64.parquet

33 directories, 56 files

このようにパーティションを指定すれば、検索条件を指定して効率的にデータを絞り込むことができます。次の例は、2020年1月2日3時台のデータだけを取得します。

In [14]:
%%time
filters = [
    [('year', '=', 2020), ('month', '=', 1), ('day', '=', 2), ('hour', '=', 3)]
]
df = pd.read_parquet('partitioned/100k_100_1.parquet', columns=["col1"], filters=filters)
CPU times: user 23.8 ms, sys: 4.83 ms, total: 28.6 ms
Wall time: 26.3 ms
In [15]:
df
Out[15]:
col1
2020-01-02 03:00:00 0.5492
2020-01-02 03:00:01 0.3644
2020-01-02 03:00:02 0.7596
2020-01-02 03:00:03 0.9563
2020-01-02 03:00:04 0.9600
... ...
2020-01-02 03:46:35 0.6077
2020-01-02 03:46:36 0.0618
2020-01-02 03:46:37 0.4454
2020-01-02 03:46:38 0.7764
2020-01-02 03:46:39 0.3473

14000 rows × 1 columns

Apache Arrow 1.0

先日、2020年7月24日に最初の安定バージョンとなる Apache Arrow 1.0がリリース され、これ以降、Parquetなどのファイルフォーマットも互換性が保証されるようになりました。

Apache Arrow/PyArrowは今回紹介した以外にも多くの機能が提供しています。この機会にぜひ調べてみてください。