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)
| name | age | index |
|---|---|---|
| str | i64 | str |
| "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))
|
|
|
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)
|
shape: (3, 3)
|
shape: (5, 4)
|
列名の違い#
Pandas では列名が重複していてもかまいません。同じ名前の列をまとめて選ぶこともできます。次の例では age が2回現れるため、df2[["age"]] で2列とも取り出せます。
df = pd.DataFrame(data).set_index('index')
df2 = df[['name', 'age', 'age']]
row(df2, df2[['age']])
|
|
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)
|
shape: (3, 2)
|
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'))
| 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()
)
| x | y | dist |
|---|---|---|
| i64 | i64 | f64 |
| 1 | 4 | 4.123106 |
| 2 | 5 | 5.385165 |
| 3 | 6 | 6.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()
)
| dist |
|---|
| f64 |
| 4.123106 |
| 5.385165 |
| 6.708204 |
式を使う利点は次の 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)
|
shape: (3, 4)
|
インプレースで書き換えないことには、次の利点があります。
安全性 — 元のデータが変わらないので、意図しない副作用を防げます。
デバッグのしやすさ — 各段階のフレームが残るため、どこで値が変わったかを追えます。
チェーンとの相性 — どの操作も新しいフレームを返すので、メソッドチェーンとしてそのままつなげられます。
ネスト型#
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() #❶
)
| name | mean_score |
|---|---|
| str | f64 |
| "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 |