2024年8月: 続 - Rust製データフレームライブラリ、Polars1.0リリース記念:進化した機能を試す

門脇(@satoru_kadowaki)です。 8月の「Python Monthly Topics」は、2023年2月に紹介したデータフレームライブラリ「Polars」のバージョン1.0がリリースされたことを受け、続きをお届けします。

前回はPolarsの基本的な機能について紹介しました。 今回は前回からの変更点や、その他の機能についても紹介します。

Polarsとは

前回の繰り返しになりますが、PolarsはPythonでデータ分析に使用される pandas と同様の「データフレーム」というデータ構造オブジェクトを提供するサードパーティライブラリです。 Rustで実装されており、メモリ効率が良く高速に動作する特徴があります。 その他の特徴や基本的な使い方などは前回の記事を参照してください。

バージョン1.0のリリースにより、公式ドキュメントへのリンクも少し変更されています。 旧リンクからのリダイレクトも設定されていますが、執筆時点のリンクについても改めて掲載しておきます。

前回の記事からの変更点

まず最初に前回記事からの変更点について簡単に解説します。 前回記事ではPythonおよびPolarsのバージョンは以下を使用していました。

  • Python 3.11.1

  • Polars 0.16.1

執筆時点で使用しているバージョンは以下のとおりです。

  • Python 3.12.4

  • Polars 1.5.0

0.16.1から1.5.0にバージョンアップされるまでの間、バージョン「0.19」と「0.20」でBreaking Changeと言われる破壊的変更が行われています。 それぞれのバージョンでどのような破壊的変更が行われたか知りたい方は、以下を参照してみてください。

また、未確認ですがアップグレードツールも存在しているようですので、興味のある方は試してみてください。

以降は、前回記事からの変更点について具体的に説明していきます。

データフレームの結合、マージにおけるキーにnull値を使用する

前回記事の内容 では、null値をキーにした結合について触れておりませんが、バージョン0.20以降、デフォルトの挙動が変更されています。 具体的には、バージョン0.20以降では「結合キーのnull値を無視する」ように変更されました。

実際にサンプルスクリプトを使用して見ていきましょう。 以下のようにnull値を含むデータフレームを結合してみます。

null値を含む結合の例
import polars as pl

# データフレームにnull値が含まれる場合のjoin
df1 = pl.DataFrame(
    {
        "writer": ["kadowaki", "terada", "takanory", "ryu22e", None],
        "value": [1, 2, 3, 4, 5],
    }
)
df2 = pl.DataFrame(
    {
        "writer": ["ryu22e", None, "fukuda", "kadowaki"],
        "value": [6, 7, 8, None],
    }
)

# 結合キー writer にもnull値が含まれる
print(df1.join(df2, on="writer", how="inner"))

上記のスクリプトをバージョン0.19以下のPolarsで結合した結果は以下になります。

# Polars 0.19での実行結果
$ python example01.py
shape: (3, 3)
┌──────────┬───────┬─────────────┐
│ writer    value  value_right │
│ ---       ---    ---         │
│ str       i64    i64         │
╞══════════╪═══════╪═════════════╡
│ kadowaki  1      null        │
│ ryu22e    4      6           │
│ null      5      7           │
└──────────┴───────┴─────────────┘

バージョン0.20以上で実行した場合は以下になり、null値が結合対象外になっています。

# Polars 1.5.0での実行結果
$ python example01.py
shape: (2, 3)
┌──────────┬───────┬─────────────┐
│ writer    value  value_right │
│ ---       ---    ---         │
│ str       i64    i64         │
╞══════════╪═══════╪═════════════╡
│ kadowaki  1      null        │
│ ryu22e    4      6           │
└──────────┴───────┴─────────────┘

Polars1.0以上でnull値も結合対象とするには、以下のように join_nulls=True を引数として指定することで同じ結果を得ることができます。

print(df1.join(df2, on="writer", how="inner", join_nulls=True))

また、バージョン0.20以上からは外部結合についても変更が行われています。 具体的には、以下のように結合方式を how=outer とした場合の結果が異なります。

print(df1.join(df2, on="writer", how="outer"))

バージョン0.19までは、結合キーの値を連結した結果が返されていました。

# Polars 0.19での実行結果
shape: (6, 3)
┌──────────┬───────┬─────────────┐
│ writer    value  value_right │
│ ---       ---    ---         │
│ str       i64    i64         │
╞══════════╪═══════╪═════════════╡
│ kadowaki  1      null        │
│ terada    2      null        │
│ takanory  3      null        │
│ ryu22e    4      6           │
│ null      5      7           │
│ fukuda    null   8           │
└──────────┴───────┴─────────────┘

執筆時点のバージョン1.5.0で実行してみると、以下のように結合する両方のキーを含む結果が返されます。 出力結果を見ると、 以前のバージョンでは結合されている value=5value_right=7 が同一行として結合されていません。 このことからもわかるように、null値はそれぞれ別の値として扱われる点について注意が必要です。

# Polars 1.5.0での実行結果
┌──────────┬───────┬──────────────┬─────────────┐
│ writer    value  writer_right  value_right │
│ ---       ---    ---           ---         │
│ str       i64    str           i64         │
╞══════════╪═══════╪══════════════╪═════════════╡
│ kadowaki  1      kadowaki      null        │
│ terada    2      null          null        │
│ takanory  3      null          null        │
│ ryu22e    4      ryu22e        6           │
│ null      5      null          null        │
│ null      null   null          7           │
│ null      null   fukuda        8           │
└──────────┴───────┴──────────────┴─────────────┘

なお、バージョン1.0以上でも、以下のように how="full", coalesce=True, join_nulls=True とすることでバージョン0.19までと同じ結果を得ることができます。 [1]

print(df1.join(df2, on="writer", how="full", coalesce=True, join_nulls=True))

【コラム】 null値の扱いは他にも変更が!?

null値の扱いについては、結合以外の機能でもいくつかの変更があります。 詳しくは前述のUpgrade Guidesに記載されていますが、よく使用される機能にも変更があり、間違えやすい点があるので注意しましょう。

たとえば、Series(シリーズ)に対する行や列の数を確認する .len().count() では、以下のような違いがあります。

  • .len() メソッドではnull値を含む

  • .count() メソッドではnull値を含まない

実際に以下のようなスクリプトで確認してみます。

null値を含む数え方の違い
import polars as pl

df = pl.DataFrame(
    {
        "writer": ["ryu22e", None, "fukuda", "kadowaki"],
    }
)

# .len()と.count()の結果を表示
print(".len():", df.select(pl.col("writer").len()).to_dicts())
print(".count():", df.select(pl.col("writer").count()).to_dicts())

実行結果は以下になります。

$ python column_example1.py
.len(): [{'writer': 4}]
.count(): [{'writer': 3}]

また、同じ値を持つデータをまとめる場合に使用する .group_by() メソッドでは、 引数を指定しない .count() の使用は非推奨(deprecated)になり、 .len() が推奨されています。 .count() が非推奨になっているメソッド群や、非推奨の理由については以下のイシューなどがありますので興味のある方は読んでみてください。

概要としては、現在の .count() はSQLで単一のフィールドに対する count(xxx) を実行したときと同様にnullを含まない仕様になっており、SQLに不慣れな人は混乱を招くため、.len() へ変更したということのようです。

参考までに .group_by().len() を使用したサンプルスクリプトは以下になります。

.group_by()メソッドで.len()を使ったサンプル
import polars as pl

df = pl.DataFrame(
    {
        "writer": ["ryu22e", None, "fukuda", "kadowaki", "kadowaki", None],
    }
)

# .group_by()メソッドでwriter列をグループ化し、.len()メソッドで各グループの要素数を取得
print(".len()", df.group_by("writer").len())

# 実行結果
# ┌──────────┬─────┐
# │ writer   ┆ len │
# │ ---      ┆ --- │
# │ str      ┆ u32 │
# ╞══════════╪═════╡
# │ kadowaki ┆ 2   │
# │ null     ┆ 2   │
# │ ryu22e   ┆ 1   │
# │ fukuda   ┆ 1   │
# └──────────┴─────┘

列に対する処理

続いては列に対する処理の変更について説明します。 前回の記事 では pandas と同様に .apply() メソッドを使用すると説明していますが、バージョン0.19以降から .map_elements() メソッドに変更されました。

サンプルスクリプトは前回記事とほとんど同じですが、 .apply() の代わりに .map_elements() を使用しています。 その他、引数 return_dtype では PolarsDataType で戻り値の型を指定することが推奨されるようになったため、文字列型である pl.Utf8 を指定しています。

.map_elements()で列の値に処理を適用して列を追加するサンプル
import polars as pl


# 偶数か奇数かを判定
def even_odd(x):
    if x % 2 == 0:
        return "Even"
    else:
        return "Odd"


df = pl.DataFrame({"value": [1, 2, 3, 4, 5]})
# 列valueに演算を行い、even_odd列を追加
df = df.with_columns(
    pl.col("value")
    .map_elements(  # 引数: return_dtypeで戻り値の型を指定(推奨
        lambda x: even_odd(x), return_dtype=pl.Utf8
    )
    .alias("even_odd")
)
print(df)

# 実行結果
# shape: (5, 2)
# ┌───────┬──────────┐
# │ value ┆ even_odd │
# │ ---   ┆ ---      │
# │ i64   ┆ str      │
# ╞═══════╪══════════╡
# │ 1     ┆ Odd      │
# │ 2     ┆ Even     │
# │ 3     ┆ Odd      │
# │ 4     ┆ Even     │
# │ 5     ┆ Odd      │
# └───────┴──────────┘

.map_elements() のように命名規則の戦略変更に伴う変更は他にも以下などがあります。(一部抜粋)

変更前

変更後

用途

Series/Expr.rolling_apply()

rolling_map()

シリーズに対してウィンドウサイズを指定した処理を適用する

DataFrame.apply()

map_rows()

データフレームの各行に対して処理を適用する

GroupBy.apply

map_groups()

group_by()操作でグループ化されたデータに対して特定の処理を適用する

その他のよく使う機能

これまでは前回記事に関連する変更点を中心に解説してきました。 ここからは前回記事では紹介しきれなかったPolarsの使い方について紹介します。

polars.when()で条件に応じてデータを操作する

Polarsではif-else文の条件式を .when().then().otherwise() というメソッドにより実現できます。 それぞれのメソッドに記述する内容をまとめると以下になります。

メソッド

内容

.when()

条件式を定義する

.then()

.when() の条件式がTrue(真)である場合に適用される

.otherwise()

.when() の条件式がFalse(偽)である場合に適用される

これらのメソッドを使用することで、データフレームの列に対して .when() で定義した条件に従ってデータの操作を行えます。 たとえば以下のサンプルスクリプトでは、楽器名と演奏者数のデータフレームに対して条件により「Group」列を追加しています。

.when()で指定された条件で新たな列を追加するサンプル
import polars as pl

# サンプルデータフレームを楽器名と演奏者数で作成
df = pl.DataFrame(
    {
        "Instruments": ["Violin", "Trombone", "Flute", "Cello", "Trumpet"],
        "Players": [5, 1, 3, 2, 1],
    }
)

# 新しい列 'Group' を条件に基づいて作成
df = df.with_columns(
    pl.when(pl.col("Players") == 1)  # 条件: 演奏者数が1の場合
    .then(pl.lit("Solo"))  # 'Solo' を追加
    .otherwise(pl.lit("Ensemble"))  # それ以外の場合は 'Ensemble' を追加
    .alias("Group")  # 列名を 'Group' に設定
)

# 結果を表示
print(df)

.then().otherwise() で使用されている pl.lit() はリテラルを返すメソッドです。 サンプルスクリプトでは演奏者数が1の場合に "Solo" が返され、それ以外の場合は "Ensemble"が返されます。 実際の実行結果は以下になります。

$ python example03.py
shape: (5, 3)
┌─────────────┬─────────┬──────────┐
│ Instruments  Players  Group    │
│ ---          ---      ---      │
│ str          i64      str      │
╞═════════════╪═════════╪══════════╡
│ Violin       5        Ensemble │
│ Trombone     1        Solo     │
│ Flute        3        Ensemble │
│ Cello        2        Ensemble │
│ Trumpet      1        Solo     │
└─────────────┴─────────┴──────────┘

また、 .when() に複数の条件を設定したい場合は、以下の論理演算子を使用して条件を連結します。

  • & : AND(かつ)

  • | : OR(または)

上記の演算子はpandasでも同様に使用できますが、Polarsでは & 演算子を暗黙としており省略することができます。

たとえば、以下の2行の実行結果は同じです。

pl.when((pl.col("Players") == 1) & (pl.col("Instruments") == "Trumpet"))
pl.when(pl.col("Players") == 1, pl.col("Instruments") == "Trumpet")

また、以下のように .when() - .then() を連鎖することで、if-elif-elseのような条件を設定することもできます。

df = df.with_columns(
    [
        pl.when(pl.col("Players") == 1)  # 条件: 演奏者数が1の場合
        .then(pl.lit("Solo"))  # 'Solo' を追加
        .when(pl.col("Players") == 2)  # 条件: 演奏者数が2の場合
        .then(pl.lit("Duo"))  # 'Duo' を追加
        .otherwise(pl.lit("Ensemble"))  # それ以外の場合は 'Ensemble' を追加
        .alias("Group")  # 列名を 'Group' に設定
    ]
)

# 実行した結果は以下になります。
# ┌─────────────┬─────────┬──────────┐
# │ Instruments ┆ Players ┆ Group    │
# │ ---         ┆ ---     ┆ ---      │
# │ str         ┆ i64     ┆ str      │
# ╞═════════════╪═════════╪══════════╡
# │ Violin      ┆ 5       ┆ Ensemble │
# │ Trombone    ┆ 1       ┆ Solo     │
# │ Flute       ┆ 3       ┆ Ensemble │
# │ Cello       ┆ 2       ┆ Duo      │
# │ Trumpet     ┆ 1       ┆ Solo     │
# └─────────────┴─────────┴──────────┘

.when().then().otherwise() はデータフレーム内で柔軟な条件付きロジックを実装する際に便利な機能です。 ぜひ試してみてください。

DataFrame.sql()、polars.sql()でデータフレームに対するSQLクエリを実行

ここからは、SQLクエリを直接データフレームに対して実行できる .sql() メソッドについて紹介します。 「SQLクエリを実行できる」と言うと、データフレームに対して直接INSERT文やUPDATE文などを実行できるように思ってしまうかもしれませんが、そうではなく、SELECT文を使用して新しいデータフレームを生成する機能になります。 この機能自体は以前から存在していましたが、SQLを実行できるようにするためにSQLContextオブジェクトを作成する必要があり、少し複雑でした。

バージョン1.0.0からはデータフレームに対してSQLを実行できる DataFrame.sql() メソッドが提供されました。 また、新たに追加された polars.sql() メソッドでは、グローバルネームスペースの変数を扱えるようになり、複数のデータフレームに対してクエリを実行できるようになりました。

2つのメソッドについて、具体的に見ていきます。

DataFrame.sql()

以下のサンプルスクリプトでは、データフレーム内の特定の楽器であるTrombone、Trumpetを条件として抽出し、演奏者数を2倍にする処理をSQLクエリで実行しています。

DataFrame.sql()を使用したSQLクエリの実行
import polars as pl

# サンプルデータフレームの作成
df = pl.DataFrame(
    {
        "Instruments": ["Violin", "Trombone", "Flute", "Cello", "Trumpet"],
        "Players": [5, 1, 3, 2, 1],
    }
)

# トロンボーン、トランペットの演奏者数を2倍にするSQLクエリを実行
result = df.sql("""
    SELECT Instruments, Players * 2 as Players
    FROM self
    WHERE Instruments IN ('Trombone', 'Trumpet')
""")
print(result)

一般的なSQL文と異なる点として、FROM句に self というキーワードが指定されています。 selfはクエリの対象となるデータフレーム自身を指すテーブル名のデフォルト値です。 DataFrame.sqlのリファレンス にあるように、引数に table_name で指定することもできます。

サンプルスクリプトの実行結果は以下のとおりです。

$ python example04.py
shape: (2, 2)
┌─────────────┬─────────┐
│ Instruments  Players │
│ ---          ---     │
│ str          i64     │
╞═════════════╪═════════╡
│ Trombone     2       │
│ Trumpet      2       │
└─────────────┴─────────┘

サンプルコードだけを見ると同じような条件でデータフレームを操作することは、 .filter().when() などのメソッドを使用して実現可能と思うかもしれません。 この指摘についてはその通りなのですが、内容によってはメソッドチェーンよりもSQLの方がコードの可読性が高く、Polarsに慣れていない人でもわかりやすいケースもありそうです。

サンプルスクリプトを使用して実際に見比べてみましょう。 スクリプトでは以下の処理を行っています。

  • 取得対象とする列は「Instruments」と「Players」のみ (「Category」を除外)

  • 「Instruments」列の値が ['Trumpet', 'Trombone'] の場合、「Players」列の数値を2倍にする

  • 「Players」列がnullの場合は「0」を返す

メソッドチェーンとDataFrame.sql()の比較例
import polars as pl

# サンプルデータフレームの作成
df = pl.DataFrame(
    {
        "Instruments": ["Violin", "Trombone", "Flute", "Cello", "Trumpet"],
        "Category": ["String", "Brass", "Woodwind", "String", "Brass"],
        "Players": [5, None, 3, None, 1],
    }
)

# 例1 メソッドチェーンを使用して、条件に応じて演奏者数を変更する
df1 = df.with_columns(
    pl.when(pl.col("Players").is_null())
    .then(0)
    .when(
        (pl.col("Instruments").is_in(["Trumpet", "Trombone"]))
        & pl.col("Players").is_not_null()
    )
    .then(pl.col("Players") * 2)
    .otherwise(pl.col("Players"))
    .alias("Players")
).select(["Instruments", "Players"])

# 例2 DataFrame.sqlメソッドを使用して、同じ結果を取得する
df2 = df.sql("""
    SELECT
        Instruments,
        CASE
            WHEN Instruments IN ('Trumpet', 'Trombone') THEN COALESCE(Players, 0) * 2
            ELSE COALESCE(Players, 0)
        END AS Players
    FROM
        self
""")

print(df1)

# 結果はどちらも以下のようになる
# ┌─────────────┬─────────┐
# │ Instruments ┆ Players │
# │ ---         ┆ ---     │
# │ str         ┆ i64     │
# ╞═════════════╪═════════╡
# │ Violin      ┆ 5       │
# │ Trombone    ┆ 0       │
# │ Flute       ┆ 3       │
# │ Cello       ┆ 0       │
# │ Trumpet     ┆ 2       │
# └─────────────┴─────────┘

メソッドチェーンとSQLクエリのサンプルスクリプトはどちらも同じ結果を返しますが、SQLクエリのほうが直感的でわかりやすいのではないでしょうか。

データフレームオブジェクトに対するSQLクエリの実行は、 .filter() メソッドと組み合わせて使用することもでき、とても便利ですので是非試してみてください。 なお、Polarsで利用可能なSQLインタフェースについては、以下のドキュメントを参照してください。

polars.sql()

polars.sql() もデータフレームに対するSQLクエリを実行できるメソッドですが、 この関数はPolarsネームスペースの変数にアクセスできるようになっており、複数のデータフレームを操作する場合に便利です。 たとえばクロスジョインやサブクエリなど、Polarsのメソッドでも少し複雑な処理を実現したいケースなどでの利用が考えられます。

以下のサンプルでは、フルーツごとの最高価格を得るために2つのデータフレームを使用する処理をSQLクエリを使用して実現しています。 DataFrame.sql() ではFROM句にselfキーワードが使用されていましたが、 polars.sql() ではデータフレームオブジェクトの変数名をそのまま使用します。

polars.sql()を使用したサブクエリの例
import polars as pl

# 果物のデータフレーム
fruits_df = pl.DataFrame(
    {
        "fruit_id": [1, 2, 3, 4, 5],
        "fruit_name": ["Apple", "Banana", "Orange", "Strawberry", "Grape"],
    }
)

# 果物の価格データフレーム
costs_df = pl.DataFrame(
    {
        "fruit_id": [1, 2, 1, 4, 5, 3, 2, 4],
        "cost": [150, 120, 200, 350, 180, 220, 160, 400],
    }
)

# フルーツ単位で最も高額な価格を表示するクエリを実行
df = pl.sql("""
    SELECT
        f.fruit_name,
        sub.max_cost
    FROM (
        SELECT
            fruit_id,
            MAX(cost) AS max_cost
        FROM costs_df
        GROUP BY fruit_id
    ) AS sub
    JOIN fruits_df f ON sub.fruit_id = f.fruit_id
""").collect()

print(df)

# df出力結果
# shape: (5, 2)
# ┌────────────┬──────────┐
# │ fruit_name ┆ max_cost │
# │ ---        ┆ ---      │
# │ str        ┆ i64      │
# ╞════════════╪══════════╡
# │ Apple      ┆ 200      │
# │ Banana     ┆ 160      │
# │ Orange     ┆ 220      │
# │ Strawberry ┆ 400      │
# │ Grape      ┆ 180      │
# └────────────┴──────────┘

SQLクエリの実行においては、 .collect() メソッドが使用されています。 これは、戻り値のデータフレームオブジェクトがLazyFrameという遅延評価データフレーム [2] であることを意味しています。 遅延評価では .collect() メソッドが使用されますが、実行結果をすぐに返す先行評価(Eager Evaluation) で実行したい場合は、以下のように引数に eager=True を指定することもできます。

df = pl.sql("""
    SELECT
     ..(省略)
    JOIN fruits_df f ON sub.fruit_id = f.fruit_id
""", eager=True)

上記のサンプルスクリプトも、メソッドチェーンを使用しても実現可能です。 どちらが利用しやすいかは人によって異なると思いますが、利用シーンに合わせて検討していただければと思います。

# メソッドチェーンで同じ結果を得る方法
max_costs = costs_df.group_by("fruit_id").agg(pl.col("cost").max().alias("max_cost"))
# max_costsを果物データフレームと結合
df = max_costs.join(fruits_df, on="fruit_id").select(["fruit_name", "max_cost"])

Polars1.0のリリースで注目される今後

Polarsの開発は、1.0.0がリリースされた2024年7月1日から1ヶ月ちょっとの間に1.5.0がリリースされており、以前と変わらず頻繁に開発が行われています。 新しい機能の追加や変更は、以下のPolarsブログサイトで解説されることもありますので興味のある方は、ぜひ目を通してみてください。

1.0リリースのアナウンスブログ ではPolarsの将来機能についても触れられています。 主な将来の機能として上げられている内容には以下がありました。

新しいエンジン設計

最も注目すべき点として、ストリーミングエンジンの再設計が行われているようです。 具体的な実装についてはまだ明らかにはされていませんが、将来はモーセル駆動型の並列性 [3] とRustの非同期ステートマシンを組み合わせた設計になるとのことです。 これにより、 NUMA(Non-Uniform Memory Acces) を意識した柔軟かつ高速なデータ処理を実現すると謳われており、その性能がどれほどのものなのか、とても気になるところです。

執筆時点ではまだ開発段階の Streaming API ですが、 気になったので kaggleのデータセット を使用して、繰り返し連結した70GB程度のCSVファイルで試してみたところ、Streaming APIでは物理メモリサイズを超えるデータを問題なく処理することができました。 大規模なデータセットを、使用メモリを抑えながら効率的に処理できるのはとても魅力的ですね。 [4]

NVIDIA RAPIDSによるGPUアクセラレーション

PolarsにGPUアクセラレーションが導入されます。 すでにメソッドのいくつかで device="gpu" として実行できるようになっているものもあり、さらなる進化が期待されます。

Polarsクラウド

Polarsをマネージドサービスとして利用できるクラウドサービスの提供が予定されており、今年中にベータテストが開始される見込みのようです。 また、オープンソースユーザーにもメリットがあるように改良されるとのことで、どのようなリリースになるのか楽しみですね。

1.0リリース後に追加されたその他の機能

先述の内容以外にも1.0リリース以降に追加、更新された機能はたくさんあります。 いくつかを挙げておきますので、興味のある内容があれば確認してみてください。

まとめ

今回の記事では、Polars1.0リリース記念として、前回の内容からよりステップアップした機能を紹介しました。 バージョンアップに伴う破壊的変更は開発者には負担が大きい部分もありますが、Polarsはより高速で柔軟なデータ処理を可能にするために常に進化しているように思います。 これから使ってみたいという方も、今後の追加機能も気にしながら実際に利用可能なシーンに合わせて試してみることをお勧めします。

データ処理の世界において、スピードと効率性は非常に重要です。 メモリ消費を抑えつつ、利便性に優れ、高いパフォーマンスを得ることができるPolarsをぜひ試してみてください。