Pandas との比較#

Polars と Pandas は、どちらもよく使われるデータ処理ライブラリですが、「1つの表をどう捉えるか」という設計思想がはっきり違います。Pandas は行インデックスを表構造の一部として扱い、Polars は行インデックスを持ちません。この違いは、演算の書き方にも、性能にも、エラーの出方にも表れます。

この章では、行インデックス、列名、演算式、データの可変性、ネスト型という5つの観点から両者を比べます。読み終わるころには、Pandas のコードを Polars に移すときにどこを書き換えるべきか、どの習慣を捨てるべきかが判断できるはずです。

import pandas as pd
import polars as pl
import numpy as np
from helper.jupyter import row

行のインデックス#

Pandas は、行をラベルで参照するための行インデックス(Index オブジェクト)を持ちます。インデックスは DataFrame の構造に組み込まれていて、ラベルによる行の選択や、インデックスをキーにしたデータの整列に使われます。既定は0から始まる整数ですが、任意のラベルや階層型の MultiIndex も指定できます。

ラベルで行を選ぶ df.loc[...] はハッシュ表を使うため、計算量は \(O(1)\) です。インデックスをキーにすれば、異なるデータセットをそろえる操作も簡単に書けます。

data = {"name": ["Alice", "Bob", "Charlie"], "age": [25, 30, 35], "index": ["a", "b", "c"]}
df = pd.DataFrame(data).set_index("index")
df.loc[["a"]]  # インデックス経由なので O(1)
name age
index
a Alice 25

Polars に行インデックスはありません。行は列の値で選びます。filter() に条件式を渡す形になり、計算量は \(O(N)\) の線形探索です。

その代わり、行番号に依存しない設計であるため、メモリ配置と並列処理を最適化しやすくなっています。行の順序そのものに意味を持たせたい場合は、明示的に列として持たせてください。

df = pl.DataFrame(data)
df.filter(pl.col("index") == "a")  # 列の値で探すので O(N)
shape: (1, 3)
nameageindex
stri64str
"Alice"25"a"

両者の違いを表にまとめます。

特徴

Pandas

Polars

行インデックス

あり

なし

アクセス速度

\(O(1)\)(インデックス利用時)

\(O(N)\)(列ベースのフィルタリング)

設計思想

行・列操作の柔軟性を重視

列指向・効率性を重視

向いている用途

小〜中規模データの対話的な処理

大規模データの高速処理

Pandas は行インデックスと列インデックスをそろえてから計算します。Polars にはこの仕組みがないため、同じ結果を得るには明示的に結合します。

次の例では、df1 から df2 を引きます。df2 にある行は a、c、e だけです。

df1 = pd.DataFrame(
    np.random.randint(0, 10, (5, 3)), 
    columns=['X', 'Y', 'Z'], 
    index=['a', 'b', 'c', 'd', 'e']
)
df2 = pd.DataFrame(
    np.random.randint(10, 20, (3, 2)), 
    columns=['X', 'Z'],
    index=['a', 'c', 'e']
)
row(df1, df2, df1.sub(df2, fill_value=0))
X Y Z
a 8 9 4
b 9 8 4
c 4 2 6
d 1 2 9
e 6 3 8
X Z
a 11 19
c 11 16
e 16 13
X Y Z
a -3.0 9.0 -15.0
b 9.0 8.0 4.0
c -7.0 2.0 -10.0
d 1.0 2.0 9.0
e -10.0 3.0 -5.0

Polars で同じ操作をするには、まず join() で行をそろえ、そのあと列ごとの計算式を適用します。ここでは汎用の align_op() 関数を用意しました。

LazyFrame を使っているため、結合・穴埋め・列演算はひとつのクエリにまとまり、Rust 側で最適化されてから実行されます。

def align_op(df1, df2, op, on="index", fill_value=0):
    # 両方に存在する列だけを対象にする(結合キーは除く)
    common_columns = [c for c in df1.columns if c in df2.columns and c != on]
    return (
        df1.lazy()
        .join(df2.lazy(), on=on, how="left", maintain_order="left")
        .fill_null(fill_value)  # df2 にない行は 0 として扱う
        .with_columns(
            [
                op(pl.col(col), pl.col(f"{col}_right")).alias(col)
                for col in common_columns
            ]
        )
        .select(df1.columns)
        .collect()
    )


# Pandas のインデックスは列に移してから Polars に渡す
df3 = pl.from_pandas(df1.reset_index())
df4 = pl.from_pandas(df2.reset_index())
row(df3, df4, align_op(df3, df4, pl.Expr.sub))
shape: (5, 4)
indexXYZ
stri32i32i32
"a"894
"b"984
"c"426
"d"129
"e"638
shape: (3, 3)
indexXZ
stri32i32
"a"1119
"c"1116
"e"1613
shape: (5, 4)
indexXYZ
stri32i32i32
"a"-39-15
"b"984
"c"-72-10
"d"129
"e"-103-5

列名の違い#

Pandas では列名が重複していてもかまいません。同じ名前の列をまとめて選ぶこともできます。次の例では age が2回現れるため、df2[["age"]] で2列とも取り出せます。

df = pd.DataFrame(data).set_index('index')
df2 = df[['name', 'age', 'age']]
row(df2, df2[['age']])
name age age
index
a Alice 25 25
b Bob 30 30
c Charlie 35 35
age age
index
a 25 25
b 30 30
c 35 35

Polars は列名の重複を許しません。同じ列を2回使いたいときは alias() で別名を付けます。次のコードでは2回目の age を age_2 に改名し、正規表現 ^age.*$ で age から始まる列をまとめて選んでいます。

df = pl.DataFrame(data)
df2 = df.select('name', 'age', pl.col('age').alias('age_2'))
row(df2, df2.select(pl.col('^age.*$')))
shape: (3, 3)
nameageage_2
stri64i64
"Alice"2525
"Bob"3030
"Charlie"3535
shape: (3, 2)
ageage_2
i64i64
2525
3030
3535

Pandas では列名に整数やタプルも使えます。データフレームを柔軟に構造化できる反面、列名の型がそろっていないコードは読みにくくなります。

data = {10: ['Alice', 'Bob', 'Charlie'], 20: [25, 30, 35]}
df = pd.DataFrame(data)
df[10]
0      Alice
1        Bob
2    Charlie
Name: 10, dtype: str

Polars の列名は必ず文字列です。列名の型がひとつに決まっているぶん、式やメソッドの引数として扱いやすくなります。整数のキーを使いたい場合は、次のように文字列へ変換してから渡します。

df = pl.DataFrame({str(key):value for key, value in data.items()})
df.select(pl.col('10'))
shape: (3, 1)
10
str
"Alice"
"Bob"
"Charlie"

演算式#

式(expression)は、列に対する計算を表すオブジェクトです。Polars では式を組み立ててから select() や with_columns() に渡し、その時点で評価が始まります。この仕組みによって、不要な計算を省き、クエリ全体をまとめて最適化できます。

Pandas に式の概念はなく、書いた操作はその場で実行されます。次のコードでは、代入のたびに中間結果がメモリに残ります。

df = pd.DataFrame(dict(
    x=[1, 2, 3],
    y=[4, 5, 6]
))

df['dist'] = (df['x']**2 + df['y']**2)**0.5
df
x y dist
0 1 4 4.123106
1 2 5 5.385165
2 3 6 6.708204

Pandas でまとめて計算したいときは、eval() に文字列で式を渡す方法もあります。ただし扱える構文には制限があります。

df['dist2'] = df.eval('(x**2 + y**2)**0.5')
df
x y dist dist2
0 1 4 4.123106 4.123106
1 2 5 5.385165 5.385165
2 3 6 6.708204 6.708204

Polars では、まず計算を表す式を組み立て、それを select() や with_columns() に渡したときに評価が始まります。

df = pl.DataFrame(dict(
    x=[1, 2, 3],
    y=[4, 5, 6]
))

df.with_columns(
    dist = (pl.col('x')**2 + pl.col('y')**2).sqrt()
)
shape: (3, 3)
xydist
i64i64f64
144.123106
255.385165
366.708204

LazyFrame を使うと、select() や with_columns() を呼んだ時点では計算せず、collect() まで待ちます。Polars 2.0 では collect() が既定でストリーミングエンジンを使うため、メモリに収まらない規模のデータも扱いやすくなっています。

(df
.lazy()
.select(
    x2 = pl.col('x')**2,
    y2 = pl.col('y')**2
)
.select(
    xy2 = pl.col('x2') + pl.col('y2')
)
.select(
    dist = pl.col('xy2').sqrt()
)
.collect()
)
shape: (3, 1)
dist
f64
4.123106
5.385165
6.708204

式を使う利点は次の 3つです。

  1. 遅延評価 — 式は定義しただけでは実行されず、評価を指示するまで待ちます。不要な計算を避けられます。

  2. チェーン — 複数の式をつなげて書けるので、複雑な処理でも流れが読みやすくなります。

  3. 最適化 — Polars はクエリ全体を見てから実行計画を立てるため、式を分けて書いても 1回のパスで処理できます。

データの不変性#

Pandas では、inplace やラベル代入によるインプレース更新をよく使います。次の例は、条件に合う行の給与をその場で書き換えます。

data = {'name': ['Alice', 'Bob', 'Charlie'], 'age': [25, 30, 35], 'salary': [50000, 60000, 70000]}
df = pd.DataFrame(data)

# 年齢が30以上の人を選択し、給与を5000増加させる
df.loc[df['age'] >= 30, 'salary'] += 5000
df
name age salary
0 Alice 25 50000
1 Bob 30 65000
2 Charlie 35 75000

Polars のデータフレームは不変(immutable)です。元のデータフレームを書き換える代わりに、新しいデータフレームを返します。

df = pl.DataFrame(data)

# 年齢が30以上の人を選択し、給与を5000増加させるエクスプレッションを定義
expr = (
    pl.when(pl.col('age') >= 30)
      .then(pl.col('salary') + 5000)
      .otherwise(pl.col('salary'))
)

# エクスプレッションを適用して新しいデータフレームを作成
df2 = df.with_columns(expr.alias('updated_salary'))
row(df, df2)
shape: (3, 3)
nameagesalary
stri64i64
"Alice"2550000
"Bob"3060000
"Charlie"3570000
shape: (3, 4)
nameagesalaryupdated_salary
stri64i64i64
"Alice"255000050000
"Bob"306000065000
"Charlie"357000075000

インプレースで書き換えないことには、次の利点があります。

  1. 安全性 — 元のデータが変わらないので、意図しない副作用を防げます。

  2. デバッグのしやすさ — 各段階のフレームが残るため、どこで値が変わったかを追えます。

  3. チェーンとの相性 — どの操作も新しいフレームを返すので、メソッドチェーンとしてそのままつなげられます。

ネスト型#

Polars はリスト型(List)と構造体型(Struct)をネイティブにサポートしており、ネストしたデータをそのまま列として持てます。

次のコードでは、attrs 列が構造体型で、その中の score フィールドがリストです。❶ の式で、リストの平均値を計算しています。

df = pl.DataFrame({
    "name": ['A', 'B', 'C'],
    "attrs": [
        {"age": 30, "score":[89, 90, 100]},
        {"age": 25, "score":[60]},
        {"age": 35, "score":[70, 80]},
    ]
})

df.select(
    "name",
    mean_score=pl.col("attrs").struct.field("score").list.mean() #❶
)
shape: (3, 2)
namemean_score
strf64
"A"93.0
"B"60.0
"C"75.0

Pandas は複雑なデータ型をそのままは持てないため、ネストしたデータは Python のオブジェクト型(object)の列になります。要素ごとに Python 側で処理するため、行数が増えると遅くなります。次のコードでは、attrs 列の各要素が辞書オブジェクトです。

import numpy as np

df = pd.DataFrame({
    "name": ['A', 'B', 'C'],
    "attrs": [
        {"age": 30, "score":[89, 90, 100]},
        {"age": 25, "score":[60]},
        {"age": 35, "score":[70, 80]},
    ]
})
df['mean_score'] = df['attrs'].str['score'].apply(np.mean)
df
name attrs mean_score
0 A {'age': 30, 'score': [89, 90, 100]} 93.0
1 B {'age': 25, 'score': [60]} 60.0
2 C {'age': 35, 'score': [70, 80]} 75.0