PandasユーザーのためのPolars入門 ― 合成データで体感するLazy実行・最適化・SQLサポート

PandasユーザーのためのPolars入門 ― 合成データで体感するLazy実行・最適化・SQLサポート

近年、データ処理の高速化や大規模データ対応を目的として、Polars へ移行を検討するケースが増えています。

Polarsは単なる「速いPandas」ではありません。
設計思想そのものが異なり、クエリエンジン型のデータフレームライブラリといえます。

本記事では、Pandas経験者を対象に:

  • PandasとPolarsの設計思想の違い
  • Lazy実行と最適化の仕組み
  • Streamingによるメモリ効率
  • SQLサポート
  • 実際に動かせるサンプルコード

を、サンプルデータを作成し、そのデータを使って解説します。

外部データのダウンロードは不要です。
すべてこの記事内のコードだけで再現できます。

環境準備

python -m venv .venv
source .venv/bin/activate

pip install -U polars pandas pyarrow numpy

デモ用データを生成する

今回は架空のEC購買ログを生成します。

  • 実在企業・実在人物のデータは含みません
  • ランダム生成なので著作権の問題はありません
  • Parquet形式で保存します(Polarsと相性が良い)

make_data.py

import numpy as np
import pandas as pd

def make_dim_tables(n_users: int, n_items: int, seed: int = 42):
    rng = np.random.default_rng(seed)

    users = pd.DataFrame({
        "user_id": np.arange(1, n_users + 1, dtype=np.int32),
        "region": rng.choice(["Kanto", "Kansai", "Tohoku", "Chubu", "Kyushu"], size=n_users),
        "segment": rng.choice(["free", "standard", "premium"], size=n_users, p=[0.55, 0.35, 0.10]),
    })

    items = pd.DataFrame({
        "item_id": np.arange(1, n_items + 1, dtype=np.int32),
        "category": rng.choice(
            ["book", "food", "fashion", "device", "beauty", "toy", "home"],
            size=n_items
        ),
        "base_price": rng.integers(300, 20000, size=n_items, dtype=np.int32),
    })

    return users, items


def make_fact_table(n_rows: int, n_users: int, n_items: int, seed: int = 42):
    rng = np.random.default_rng(seed)

    start = np.datetime64("2025-01-01")
    seconds = rng.integers(0, 365*24*60*60, size=n_rows)
    ts = start + seconds.astype("timedelta64[s]")

    df = pd.DataFrame({
        "ts": ts,
        "user_id": rng.integers(1, n_users + 1, size=n_rows),
        "item_id": rng.integers(1, n_items + 1, size=n_rows),
        "quantity": rng.integers(1, 6, size=n_rows),
        "unit_price": rng.integers(300, 20000, size=n_rows),
        "discount": rng.choice([0.0, 0.05, 0.1, 0.2], size=n_rows),
    })

    df["revenue"] = df["quantity"] * df["unit_price"] * (1 - df["discount"])
    return df


def main():
    n_rows = 1_000_000
    n_users = 200_000
    n_items = 20_000

    users, items = make_dim_tables(n_users, n_items)
    orders = make_fact_table(n_rows, n_users, n_items)

    users.to_parquet("users.parquet", index=False)
    items.to_parquet("items.parquet", index=False)
    orders.to_parquet("orders.parquet", index=False)

    print("Data generated.")

if __name__ == "__main__":
    main()

実行:

python make_data.py

これにより、users.parquet(20万)、items.parquet(2万)、orders.parguet(100万)の3つのパーケットファイルが作成されます。

【Polars】Parquet(パーケット)形式とは?
Parquet(Apache Parquet)は、 列指向(columnar)で保存されるデータファイル形式 です。 主にビッグデータ処理の世界で広く使われています。 * Apache Arrow系 * Spark * DuckDB * Polars * BigQuery * Snowflake など、多くのデータ基盤で採用されています。 CSVとの違い CSV(行指向) id,name,age 1,Alice,30 2,Bob,25 CSVは「行」単位で保存されています。 * 1行ずつ並んでいる * 人間が読める * シンプル * でも大規模処理には不向き Parquet(列指向) イメージ: id列: 1,2,3,4,... name列: Alice,Bob,... age列: 30,

PandasとPolarsの基本的な違い

PandasPolars
実行モデル即時評価即時 + 遅延
並列処理基本単一スレッド自動並列
最適化なしクエリ最適化あり
大規模データやや不向き得意

Polarsは「書いた順に実行」ではなく、最適化してから実行する設計です。

■ 基本的な集計処理の比較

Pandas

import pandas as pd

df = pd.read_parquet("orders.parquet")

result = (
    df[df["revenue"] > 5000]
    .groupby("user_id")["revenue"]
    .sum()
    .sort_values(ascending=False)
    .head(10)
)

print(result)

Polars(Eager):即時実行

import polars as pl

df = pl.read_parquet("orders.parquet")

result = (
    df.filter(pl.col("revenue") > 5000)
      .group_by("user_id")
      .agg(pl.col("revenue").sum())
      .sort("revenue", descending=True)
      .head(10)
)

print(result)
  • read_parquetは全データを読み込みます。

Polarsの本領発揮(Lazy):遅延実行

import polars as pl

lf = pl.scan_parquet("orders.parquet")

result = (
    lf.filter(pl.col("revenue") > 5000)
      .group_by("user_id")
      .agg(pl.col("revenue").sum())
      .sort("revenue", descending=True)
      .head(10)
      .collect()
)

print(result)

ここで重要なのは:

  • scan_parquet() → まだ読み込まない
  • collect() → ここで初めて実行
  • その直前に最適化

■ 最適化を可視化する

import polars as pl

lf = (
    pl.scan_parquet("orders.parquet")
      .filter(pl.col("revenue") > 5000)
      .select(["user_id", "revenue"])
)

print("=== Unoptimized ===")
print(lf.explain(optimized=False))

print("\n=== Optimized ===")
print(lf.explain(optimized=True))

explain(optimized=True/False) は、最適化前後の実行プランを表示します。
この実行プランから

  • 必要な列だけ読む(Projection Pushdown)
  • フィルタを読み込み時点で適用(Predicate Pushdown)

といった最適化が確認できます。

出力結果:

=== Unoptimized ===
SELECT [col("user_id"), col("revenue")]
  FILTER [(col("revenue")) > (5000.0)]
  FROM
    Parquet SCAN [orders.parquet]
    PROJECT */7 COLUMNS
    ESTIMATED ROWS: 1000000
    
=== Optimized ===
Parquet SCAN [orders.parquet]
PROJECT 2/7 COLUMNS
SELECTION: [(col("revenue")) > (5000.0)]
ESTIMATED ROWS: 1000000

全体像

同じ処理(filterしてselectする)でも、

  • Unoptimized(最適化前):いったん全部読んでから、後段で FILTER / SELECT をかける形
  • Optimized(最適化後):読む段階(SCAN)に FILTER / SELECT を押し込んで、最初から無駄を減らす形

Optimized の解説

Parquet SCAN [orders.parquet]
PROJECT 2/7 COLUMNS
SELECTION: [(col("revenue")) > (5000.0)]
ESTIMATED ROWS: 1000000

1) Parquet SCAN [orders.parquet]

  • orders.parquet読み込み元としてスキャンします。

2) PROJECT 2/7 COLUMNS

  • 元ファイルには 7列あるが、クエリで必要なのは 2列(user_id と revenue)だけ。
  • そのため Polars は Parquetを読む段階で2列だけ読みます(列のpushdown)。
  • これが projection pushdown(射影の押し込み)です。
    • 不要列のI/O・デコード・メモリ確保が減ります。

3) SELECTION: [(col("revenue")) > (5000.0)]

  • revenue > 5000 の条件を スキャン段階で適用する計画です。
  • これが predicate pushdown(条件の押し込み)です。
    • 早い段階で行数を減らせる可能性があります。
    • 特に Parquet は列ごとに統計情報(min/maxなど)を持つことが多く、条件によっては「この塊は全て条件外だから読まない」といった最適化が期待できます(条件次第)。

Unoptimized の解説

SELECT [col("user_id"), col("revenue")]
  FILTER [(col("revenue")) > (5000.0)]
  FROM
    Parquet SCAN [orders.parquet]
    PROJECT */7 COLUMNS
    ESTIMATED ROWS: 1000000

これは「書いた処理を、そのまま順にやるとこうなる」という形です。

1) Parquet SCAN ... PROJECT */7 COLUMNS

  • 最適化前なので、SCANは 7列全部を読む想定になっています(*/7)。
  • つまり「まず全部読みます」。

2) FILTER [(col("revenue")) > (5000.0)]

  • 読み込んだあとに revenue > 5000 を適用します。
  • これは遅いわけではないですが、不要な列を読んだあとにフィルタすることになり得ます。

3) SELECT [col("user_id"), col("revenue")]

  • 最後に必要な2列だけ残します。
  • でも、ここまでに不要な列も読んでいるので、ムダが出ます。

この差が意味すること

今回の差は、言い換えるとこうです。

  • Unoptimized
    「全部読み込む → 条件で絞る → 列を減らす」
  • Optimized
    「読む時点で列を減らし、読む時点で条件も適用する」

これが Polars Lazy の価値で、特に

  • 列数が多いデータ
  • Parquetのような列指向フォーマット
  • 大規模データ

で効いてきます。

ちなみに:なぜ最適化後は FILTER/SELECT が消えている?

最適化後の表示で FILTER や SELECT が消えたように見えるのは、

  • FILTER → SELECTION: として SCAN に統合
  • SELECT → PROJECT 2/7 として SCAN に統合

されているからです。
「消えた」のではなく、もっと前段に押し込まれて合体した、という理解が正しいです。

■ Streaming(メモリ効率)

result = (
    lf.filter(pl.col("revenue") > 10000)
      .group_by("item_id")
      .agg(pl.col("revenue").sum())
      .collect(streaming=True)
)

print(result)

Streamingは:

  • メモリに全体を載せない
  • チャンク単位で処理(サイズは数万〜数十万行程度になることが多い)
  • 大規模データ向け

※高速化というより、メモリ削減が目的です。

■ SQLも書ける

ctx = pl.SQLContext(
    orders=pl.scan_parquet("orders.parquet"),
    users=pl.scan_parquet("users.parquet"),
)

result = ctx.execute("""
    SELECT u.region, SUM(o.revenue) AS revenue_sum
    FROM orders o
    JOIN users u
      ON o.user_id = u.user_id
    GROUP BY u.region
    ORDER BY revenue_sum DESC
""")

print(result.collect())

SQLで書いても内部はPolarsエンジンが最適化します。

出力結果:

shape: (5, 2)

region revenue_sum
str f64
═════ ═════════
Chubu 5.6102e9
Kyushu 5.5635e9
Kanto 5.5577e9
Tohoku 5.5373e9
Kansai 5.5274e9

Polarsのメリット・デメリット

メリット

  • 高速(Rust + 並列)
  • Lazy最適化
  • 大規模データに強い
  • SQLサポート

デメリット

  • Pandasと完全互換ではない
  • エコシステムはまだPandas優勢
  • Expressionに慣れる必要あり

まとめ

Polarsは「Pandasの代替」ではなく、
設計思想が異なる次世代データ処理エンジンです。

特に:

  • Lazy実行
  • クエリ最適化
  • Streaming
  • SQL対応

が特徴です。

小〜中規模データではPandasで十分なケースも多いですが、
大規模データやパフォーマンス重視の場面ではPolarsは強力な選択肢になります。