Pandasとの比較#
データ操作と分析の分野で広く利用されているライブラリとして、PolarsとPandasの二つがあります。これらのライブラリはどちらも強力なツールですが、その設計哲学やパフォーマンス特性にはいくつかの重要な違いがあります。以下では、PolarsとPandasの違いを比較し、Polarsの利点と欠点を明らかにします。
import pandas as pd
import polars as pl
import numpy as np
from helper.jupyter import row
行のインデックス#
PandasとPolarsの行インデックスに関する違いは、両ライブラリの設計思想によるものです。以下に詳細を説明します。
Pandasでは、行インデックス(Index
オブジェクト)をサポートしています。インデックスはデータフレームの構造に組み込まれており、ラベル付きの行アクセスやデータ操作を効率化します。デフォルトでは整数インデックスが使用されますが、カスタムラベルや階層的なマルチインデックスを使用することも可能です。
インデックスを利用した操作(例: 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)\)です(線形探索を伴う)。
import polars as pl
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
を減算する際に、インデックスをアラインメントして計算します。
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()
関数を使用します。この関数では LazyDataFrame
を活用し、すべての計算ステップをRustで実装された高速計算ライブラリに渡して最適化された形で実行します。
def align_op(df1, df2, op, on='index', fill_value=0):
common_columns = list(set(df1.columns) & set(df2.columns))
common_columns.remove(on)
df_res = (
df1.lazy()
.join(df2.lazy(), on=on, how="left")
.fill_null(fill_value)
.with_columns(
[
op(pl.col(col), pl.col(f"{col}_right")).alias(col)
for col in common_columns
]
)
.select(df1.columns)
.collect()
)
return df_res
df3 = pl.from_dataframe(df1).insert_column(0, pl.Series('index', df1.index))
df4 = pl.from_dataframe(df2).insert_column(0, pl.Series('index', df2.index))
row(df3, df4, align_op(df3, df4, pl.Expr.sub))
shape: (5, 4)
|
shape: (3, 3)
|
shape: (5, 4)
|
列名の違い#
Pandas では、列名が重複していても問題ありません。同じ名前の列を一度に選択することができます。例えば、以下のコードでは、'age'
列が重複しているため、2 回目の 'age'
列も一緒に選択されます。
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: object
Polarsでは、すべての列名は文字列でなければなりません。これは設計の簡素化と一貫性のためであり、列名が明確に定義されていることを保証します。データ操作時に混乱が少なく、一貫したコードを書くことができます。
df = pl.DataFrame({str(key):value for key, value in data.items()})
df.select(pl.col('10'))
10 |
---|
str |
"Alice" |
"Bob" |
"Charlie" |
演算式#
演算式は、データフレームの列に対する操作を表現するオブジェクトです。Polarsでは演算式を使用して、データ操作の一連の手続きを定義し、それを後で評価(実行)します。この仕組みにより、効率的で柔軟なデータ処理が可能になります。
一方、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 |
さらに、LazyDataFrame
を使用すると、select()
や with_columns()
などのメソッドでも即時計算は行われず、最終的に 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 |
演算式を使用することで、以下のようなメリットがあります:
遅延評価
Polarsの演算式(エクスプレッション)は、定義時には実行されず、明示的に評価(実行)されるまで待機します。これにより、必要のない計算を避け、効率的にデータ処理を行うことができます。チェーン操作
複数の演算式をチェーンすることで、複雑なデータ操作を簡潔かつ読みやすく記述できます。これにより、コードの保守性が向上します。パフォーマンス向上
Polarsは演算式を最適化し、一度に効率よく実行する仕組みを備えています。そのため、大規模データセットでも高速に処理を行うことができます。
データの不変性#
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
)として表現されます。このため、操作や計算の際にパフォーマンスが低下する可能性があります。次のコードでは、attrs
列の各要素がPythonの辞書型オブジェクトとなっています。
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 |