PandasからPolarsに移るべき? CSV・Parquetを数万〜数百万行で比較してみた
はじめに
PythonでCSVを扱う場合、まず候補に挙がるのがPandasです。
Pandasは、NumPyを基盤とした高速な表操作・欠損値処理や長い歴史からくる、柔軟さや実績、高い互換性などから、Pythonにおける表形式データ操作のデファクトスタンダードとして10年以上君臨し続けてきました。
自分もこれまで、CSVの確認や読み込みなどといえばPandasを使っていました
しかし最近は高速なDataFrameライブラリとしてPolarsを見かける機会が増えてきました。
PolarsはRust製で作成されており、マルチコアを活用した高速性と省メモリ性能を誇ります
特にPolarsには、
- マルチスレッドを活用した高速な処理
- 式ベースのデータ操作
- Lazy APIによるクエリ最適化
- Parquetとの親和性
- Apache Arrowをベースにしており、メモリ使用量が少なく大容量データを扱いやすい
といった特徴があります。
とはいえ、
実際どのくらい違うのか?
どの程度のCSVだとPolarsへ移行する意味があるのか?
という疑問があったため、ダミーデータを使って比較してみました。
今回は単純な読み込み速度だけではなく、
- CSVの読み込み
- 必要な列の取得
- 条件による絞り込み
- 新しい判定列の生成
- 実際のアプリケーションで使える形式への変換
- CSVからParquetへの変更
- メモリ使用量
まで比較しています。
検証のきっかけ
もともとは業務で使うために次のようなダミー動画処理を作っていました。
ダミー動画
↓
フレームを1枚ずつ取得
↓
フレーム情報CSVを参照
↓
フレーム番号に対応する情報を取得
↓
画像内に特定条件を満たす領域があるか判定
↓
AI相当の判定 または CSV側の強制保存条件
↓
画像保存
AIモデルはまだ用意していなかったため、今回は疑似的に、
画面中央付近に一定サイズ以上の指定した色の領域が存在する
というOpenCVによる判定を使用しました。
CSV側には概念的に次のような情報があります。
frame,label,mark
0,pos0000,
1,pos0001,
2,pos0002,y
3,pos0003,
mark列にyが存在する場合は、画像判定の結果に関係なくそのフレームを保存します。
この処理ではCSVから、
読み込み
↓
必要列選択
↓
フレーム番号による検索
↓
mark列による強制保存判定
↓
アプリケーション用データ生成
という処理を行うため、PandasとPolarsの比較題材としてちょうどよさそうでした。
また、大量データを扱う際の処理時間も気になっていたため、性能比較の題材としてちょうど良さそうでした。
最初は約4万行で比較
まず、約4万行のCSVを使いました。
PandasとPolarsで同じ処理を実行し、最終的には次のようなPythonの辞書へ変換します。
frame_info = {
100: {
"label": "pos0100",
"force_output": False,
},
101: {
"label": "pos0101",
"force_output": True,
},
}
動画側では、
info = frame_info.get(frame_no)
pseudo_detected = pseudo_ai_detect(frame)
should_save = (
pseudo_detected
or info["force_output"]
)
のように利用できます。
まず実際の動画処理で比較
ダミー動画は2340フレームありました。
PandasとPolarsでそれぞれCSVを処理して動画処理を実行したところ、CSV部分はどちらも数十ミリ秒以下でした。
動画全体では、
動画デコード
OpenCVによる画像変換
輪郭検出
JPEG保存
などの処理時間の方が圧倒的に大きく、PandasとPolarsの違いはほぼ体感できませんでした。
ここで一つ分かったことがあります。
数万行程度のCSVを1回読み込む程度なら、Pandasでも十分速い
ということです。
「Polarsの方が何倍速い」という結果が出ても、
0.015秒
↓
0.003秒
の違いなら、人間にはまず分かりません。
そこで動画処理とは切り離し、CSV処理そのものを大きくして比較することにしました。
データを10倍、100倍に増やす
元データを複製し、
約4万行
約40万行
約400万行
の3種類を作りました。
ただし、最終的に欲しいデータは2340行だけにしています。
つまり、
400万行のCSV
↓
必要なのは特定範囲の2340行
↓
必要な列だけ取得
↓
条件判定
という処理です。
これは大量データから一部分だけを取り出す処理を想定しています。
比較した3方式
Pandas
df = pd.read_csv(
path,
usecols=[
"frame",
"label",
"mark",
],
)
df = df[
df["frame"].between(
START_FRAME,
END_FRAME,
)
].copy()
df["force_output"] = (
df["mark"]
.fillna("")
.eq("y")
)
Polars Eager
df = (
pl.read_csv(
path,
columns=[
"frame",
"label",
"mark",
],
)
.filter(
pl.col("frame")
.is_between(
START_FRAME,
END_FRAME,
)
)
.with_columns(
(
pl.col("mark")
.fill_null("")
== "y"
)
.alias("force_output")
)
)
Polars Lazy
df = (
pl.scan_csv(path)
.filter(
pl.col("frame")
.is_between(
START_FRAME,
END_FRAME,
)
)
.select([
"frame",
"label",
"mark",
])
.with_columns(
(
pl.col("mark")
.fill_null("")
== "y"
)
.alias("force_output")
)
.collect()
)
Lazy版ではscan_csv()の時点では処理を実行せず、最後のcollect()でまとめて実行します。
そのため、
filter
select
with_columns
などを含めた処理全体を見て、Polars側で最適化できます。
約400万行で差が大きくなった
約400万行相当のCSVでは、Pipeline処理の中央値が次のようになりました。
| 方法 | 処理時間 |
|---|---|
| Pandas + CSV | 約0.95〜1.16秒 |
| Polars Eager + CSV | 約0.036〜0.040秒 |
| Polars Lazy + CSV | 約0.05秒前後 |
今回の単純なCSV処理では、Polars Eagerが非常に高速でした。
PandasとPolars Eagerでは、条件によって20倍以上の差が出ています。
一方で興味深かったのは、Polars Lazyが必ずしもEagerより速いわけではなかったことです。
Lazy APIにはクエリ計画や最適化という処理があります。
今回のように、
3列選択
↓
1回filter
↓
1列追加
程度の比較的単純な処理では、Lazy化によるメリットより固定コストの方が目立つケースもあるようです。
つまり、
Polars = Lazyにすれば必ず最速
というわけではありませんでした。
CSVからParquetへ変更してみる
次に、データ形式そのものをCSVからParquetへ変更しました。
Parquetは列指向のファイル形式です。
CSVでは基本的にテキストを解析して、
文字列を読む
↓
区切り文字を解析
↓
型変換
↓
列を生成
する必要があります。
一方Parquetでは、必要な列を直接取得しやすくなります。
さらにRow Groupや統計情報を利用することで、条件に一致しないデータ領域を読み飛ばせる場合があります。
今回の約400万行相当のデータでは、ファイルサイズもCSVの約79MBに対してParquetは約8MBとなり、およそ1/10まで小さくなりました。
※今回のダミーデータでは約1/10になりました。圧縮率はデータ型や値の繰り返し方などによって変化します。
Parquetでは状況が大きく変わった
400万行相当から2340行を抽出するPipelineでは、
Pandas + CSV
約1.16秒
Polars Eager + CSV
約0.040秒
Pandas + Parquet
約0.006秒
Polars Lazy + Parquet
数ミリ秒
という結果になりました。
ここで重要なのは、
ライブラリを高速化する以前に、そもそも不要なデータを読まない
という効果です。
CSVでは大量の文字列データを解析する必要があります。
Parquetでは、
必要な列だけ読む
+
条件外のデータ領域をスキップ
できるため、今回のような大量データから一部分だけを取り出す処理では処理量そのものを大きく減らせます。
つまり、
PandasからPolarsへ変更する
だけでなく、
CSVからParquetへ変更する
こと自体が非常に大きな最適化になりました。
Pandas vs Polars Lazyを30回比較
Parquetでは数ミリ秒という非常に短い処理になったため、5回程度の計測では実行時間にばらつきがありました。
そこで約400万行のParquetに対象を絞り、
Pandas + Parquet
VS
Polars Lazy + Parquet
を30回実行しました。
結果は次の通りです。
Pandas
count : 30
median : 0.004979 sec
mean : 0.005016 sec
stdev : 0.000195 sec
min : 0.004687 sec
max : 0.005452 sec
Polars Lazy
count : 30
median : 0.002792 sec
mean : 0.002826 sec
stdev : 0.000165 sec
min : 0.002523 sec
max : 0.003145 sec
勝敗は、
Pandas : 0
Polars Lazy : 30
となりました。
中央値では、
4.979 ms
↓
2.792 ms
なので、今回の条件ではPolars Lazyが約1.8倍高速でした。
さらに標準偏差もPolarsの方がわずかに小さく、30回の測定では安定して高速でした。
メモリ使用量も比較
速度だけでなく、処理中のRAM使用量も比較しました。
psutilを使用し、PandasとPolarsを完全に別プロセスとして起動しています。
これにより、
Python + Pandas + PyArrow
Python + Polars
をできるだけ独立した状態で比較しました。
結果は次の通りです。
Pandas + Parquet
rows : 2340
before RSS : 18.36 MB
peak RSS : 98.54 MB
peak delta : 80.17 MB
Polars Lazy + Parquet
rows : 2340
before RSS : 18.36 MB
peak RSS : 52.85 MB
peak delta : 34.49 MB
開始時のRSSはどちらも18.36MBでした。
追加で使用したピークメモリを比較すると、
Pandas
80.17 MB
Polars Lazy
34.49 MB
となりました。
Polars LazyはPandasの約43%の追加メモリで処理できており、今回の条件では約57%の削減となりました。
速度だけでなくメモリ効率でも大きな違いが確認できました。
なぜParquet + Polars Lazyが効いたのか
今回の処理は、
約400万行
↓
必要なのは3列
↓
必要なのは2340行
↓
条件列を1つ生成
というものでした。
Polars Lazyでは処理全体を確認してから実行できるため、概念的には、
scan_parquet
↓
必要列を限定
↓
filter条件を読み込み側へ反映
↓
不要なデータを極力読み飛ばす
↓
必要な小さな結果だけcollect
という処理ができます。
ここで重要なのは、
大量データを高速に処理した
というより、
処理する必要のないデータを可能な限り処理しなかった
という点です。
大量データ処理では、「1行を何ナノ秒速く処理するか」だけでなく、そもそも何行・何列を読み込む必要があるのかを考えることが重要だと感じました。
結論
今回の検証から、自分の中では次のような使い分けになりました。
数万行程度のCSV
Pandasで十分
数万行程度なら、Pandasでも処理時間は数十ミリ秒以下です。
Polarsの方が高速でも、人間が体感できるほどの差にはなりませんでした。
Pandasの実績やコミュニティの情報量、既存コードやライブラリとの互換性を考えれば、無理にPolarsへ変更する必要はなさそうです。
数十万〜数百万行のCSV
Polarsが有力
データが大きくなるほど、CSV処理ではPolarsの優位性が明確になりました。
特に単純なCSV処理では、今回Polars Eagerが非常に高速でした。
大規模データを保存し、何度も絞り込む
Parquet
+
Polars Lazy
が有力な選択肢になりそうです。
Parquetによって、
- ファイル容量削減
- 必要列のみ読み込み
- 不要なデータ領域のスキップ
が可能になり、さらにPolars Lazyによってクエリ全体を最適化できます。
今回の条件では、Pandas + Parquetと比べても、
- 約1.8倍高速
- 30回の比較で30勝
- 追加ピークRAMを約57%削減
という結果になりました。
最後に
今回の比較を始めた時点では、
PandasとPolarsでは実際どのくらい速さが変わるのか?
という単純な疑問でした。
しかし実際に試してみると、もっと重要だったのは、
ライブラリだけでなく、データ形式と処理方法まで含めて考える
ことでした。
今回の結果をかなり雑にまとめるなら、
小さいCSV
↓
Pandasで十分
大きいCSV
↓
Polarsが強い
巨大データを繰り返し処理
↓
Parquet + Polars Lazy
という印象です。
特に印象に残ったのは、
「高速に全部処理する」より「不要なものを最初から処理しない」
方が強いケースがあることでした。
確かに総じてPolarsの方が早く軽いとなった結果でしたが、数万程度のデータでは気にするほどの違いはなく、Pandasにはこれまで積み上げてきた歴史からなる、信頼性や圧倒的な情報量の多さなどの強みがあります。
PandasとPolarsは完全な二者択一ではなく、データ量や用途によって使い分けるのが良さそうです。
まとめると
今回のように数万行程度なら、実績・自由度などがあるPandasを使い続け、データ量が数百万行規模へ成長した段階でPolarsやParquetを検討する、という判断でも十分だと思います。