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)
shape: (1, 3)
nameageindex
stri64str
"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))
X Y Z
a 2 2 3
b 2 0 3
c 4 0 3
d 7 4 8
e 5 9 5
X Z
a 11 13
c 10 15
e 11 11
X Y Z
a -9.0 2.0 -10.0
b 2.0 0.0 3.0
c -6.0 0.0 -12.0
d 7.0 4.0 8.0
e -6.0 9.0 -6.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)
indexXYZ
stri32i32i32
"a"223
"b"203
"c"403
"d"748
"e"595
shape: (3, 3)
indexXZ
stri32i32
"a"1113
"c"1015
"e"1111
shape: (5, 4)
indexXYZ
stri32i32i32
"a"-92-10
"b"203
"c"-60-12
"d"748
"e"-69-6

列名の違い#

Pandas では、列名が重複していても問題ありません。同じ名前の列を一度に選択することができます。例えば、以下のコードでは、'age' 列が重複しているため、2 回目の 'age' 列も一緒に選択されます。

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: object

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"

演算式#

演算式は、データフレームの列に対する操作を表現するオブジェクトです。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()
)
shape: (3, 3)
xydist
i64i64f64
144.123106
255.385165
366.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()
)
shape: (3, 1)
dist
f64
4.123106
5.385165
6.708204

演算式を使用することで、以下のようなメリットがあります:

  1. 遅延評価
    Polarsの演算式(エクスプレッション)は、定義時には実行されず、明示的に評価(実行)されるまで待機します。これにより、必要のない計算を避け、効率的にデータ処理を行うことができます。

  2. チェーン操作
    複数の演算式をチェーンすることで、複雑なデータ操作を簡潔かつ読みやすく記述できます。これにより、コードの保守性が向上します。

  3. パフォーマンス向上
    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)
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)として表現されます。このため、操作や計算の際にパフォーマンスが低下する可能性があります。次のコードでは、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