門脇
前回はPolarsの基本的な機能について紹介しました。今回は前回からの変更点や、その他の機能についても紹介します。
Polarsとは
前回の繰り返しになりますが、PolarsはPythonでデータ分析に使用されるpandasと同様の
バージョン1.
- Github:https://
github. com/ pola-rs/ polars - ユーザーガイド:https://
docs. pola. rs/ - Python APIリファレンス:https://
docs. pola. rs/ api/ python/ stable/ reference/
前回の記事からの変更点
まず最初に前回記事からの変更点について簡単に解説します。
前回記事ではPythonおよびPolarsのバージョンは以下を使用していました。
- Python 3.
11. 1 - Polars 0.
16. 1
今回の執筆時点で使用しているバージョンは以下のとおりです。
- Python 3.
12. 4 - Polars 1.
5.0
0.
また、未確認ですがアップグレードツールも存在しているようですので、興味のある方は試してみてください。
以降は、前回記事からの変更点について具体的に説明していきます。
データフレームの結合、マージにおけるキーにnull値を使用する
前回記事の内容では、null値をキーにした結合について触れておりませんが、バージョン0.
実際にサンプルスクリプトを使用して見ていきましょう。以下のように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.
$ python example01.py shape: (3, 3) ┌──────────┬───────┬─────────────┐ │ writer ┆ value ┆ value_right │ │ --- ┆ --- ┆ --- │ │ str ┆ i64 ┆ i64 │ ╞══════════╪═══════╪═════════════╡ │ kadowaki ┆ 1 ┆ null │ │ ryu22e ┆ 4 ┆ 6 │ │ null ┆ 5 ┆ 7 │ └──────────┴───────┴─────────────┘
バージョン0.
$ python example01.py shape: (2, 3) ┌──────────┬───────┬─────────────┐ │ writer ┆ value ┆ value_right │ │ --- ┆ --- ┆ --- │ │ str ┆ i64 ┆ i64 │ ╞══════════╪═══════╪═════════════╡ │ kadowaki ┆ 1 ┆ null │ │ ryu22e ┆ 4 ┆ 6 │ └──────────┴───────┴─────────────┘
Polars 1.join_
を引数として指定することで、同じ結果を得ることができます。
print(df1.join(df2, on="writer", how="inner", join_nulls=True))
また、バージョン0.how=outer
とした場合の結果が異なります。
print(df1.join(df2, on="writer", how="outer"))
バージョン0.
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.value=5
とvalue_
が同一行として結合されていません。このことからもわかるように、null値はそれぞれ別の値として扱われる点について注意が必要です。
┌──────────┬───────┬──────────────┬─────────────┐ │ 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.how="full", coalesce=True, join_
とすることでバージョン0.
print(df1.join(df2, on="writer", how="full", coalesce=True, join_nulls=True))
列に対する処理
続いては列に対する処理の変更について説明します。前回の記事ではpandasと同様に.apply()
メソッドを使用すると説明していますが、バージョン0..map_
メソッドに変更されました。
サンプルスクリプトは前回記事とほとんど同じですが、.apply()
の代わりに.map_
を使用しています。その他、引数return_
ではPolarsDataTypeで戻り値の型を指定することが推奨されるようになったため、文字列型であるpl.
を指定しています。
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_
のように命名規則の戦略変更に伴う変更は、他にも以下などがあります
変更前 | 変更後 | 用途 |
---|---|---|
Series/ |
rolling_ |
シリーズに対してウィンドウサイズを指定した処理を適用する |
DataFrame. |
map_ |
データフレームの各行に対して処理を適用する |
GroupBy. |
map_ |
group_ |
その他のよく使う機能
これまでは前回記事に関連する変更点を中心に解説してきました。ここからは、前回記事では紹介しきれなかったPolarsの使い方について紹介します。
polars.when()で条件に応じてデータを操作する
Polarsではif-else文の条件式を.when()
、.then()
、.otherwise()
というメソッドにより実現できます。それぞれのメソッドに記述する内容をまとめると以下になります。
メソッド | 内容 |
---|---|
.when() |
条件式を定義する |
.then() |
.when() の条件式がTrue |
.otherwise() |
.when() の条件式がFalse |
これらのメソッドを使用することで、データフレームの列に対して.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.
はリテラルを返すメソッドです。サンプルスクリプトでは演奏者数が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()
メソッドについて紹介します。
バージョン1.DataFrame.
メソッドが提供されました。また、新たに追加された polars.
メソッドでは、グローバルネームスペースの変数を扱えるようになり、複数のデータフレームに対してクエリを実行できるようになりました。
2つのメソッドについて、具体的に見ていきます。
DataFrame.sql()
以下のサンプルスクリプトでは、データフレーム内の特定の楽器であるTrombone、Trumpetを条件として抽出し、演奏者数を2倍にする処理を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.table_
で指定することもできます。
サンプルスクリプトの実行結果は以下のとおりです。
$ 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」 を返す
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クエリを実行できるメソッドですが、 この関数はPolarsネームスペースの変数にアクセスできるようになっており、複数のデータフレームを操作する場合に便利です。たとえばクロスジョインやサブクエリなど、Polarsのメソッドでも少し複雑な処理を実現したいケースなどでの利用が考えられます。
以下のサンプルでは、フルーツごとの最高価格を得るために2つのデータフレームを使用する処理をSQLクエリを使用して実現しています。DataFrame.
ではFROM句にselfキーワードが使用されていましたが、polars.
ではデータフレームオブジェクトの変数名をそのまま使用します。
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)
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=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"])
Polars 1.0のリリースで注目される今後
Polarsの開発は、1.
1.
新しいエンジン設計
最も注目すべき点として、ストリーミングエンジンの再設計が行われているようです。具体的な実装についてはまだ明らかにはされていませんが、将来はモーセル駆動型の並列性[3]とRustの非同期ステートマシンを組み合わせた設計になるとのことです。これにより、NUMA
執筆時点ではまだ開発段階のStreaming APIですが、気になったのでkaggleのデータセットを使用して繰り返し連結した70GB程度のCSVファイルで試してみたところ、Streaming APIでは物理メモリサイズを超えるデータを問題なく処理することができました。検証に使ったサンプルスクリプトは以下になります。
import polars as pl
# チャンクサイズを指定
pl.Config.set_streaming_chunk_size(30000)
# CSVファイルの読み込みとフィルタの実行
q1 = (
pl.scan_csv("Books_rating_large.csv")
.select(pl.col("review/score"))
.filter(pl.col("review/score") < 2)
)
df = q1.collect(streaming=True) # Streaming modeで実行
print(df.select(pl.col("review/score")).head(5))
print(df.select(pl.col("review/score").len()))
検証は16GBの物理メモリを持つLinux環境で行いました。大規模なデータセットを使用メモリを抑えながら効率的に処理できるのは、とても魅力的ですね。
NVIDIA RAPIDSによるGPUアクセラレーション
PolarsにGPUアクセラレーションが導入されます。すでにメソッドのいくつかでdevice="gpu"
として実行できるようになっているものもあり、さらなる進化が期待されます。
Polarsクラウド
Polarsをマネージドサービスとして利用できるクラウドサービスの提供が予定されており、今年中にベータテストが開始される見込みのようです。また、オープンソースユーザーにもメリットがあるように改良されるとのことで、どのようなリリースになるのか楽しみですね。
1.0リリース後に追加されたその他の機能
先述の内容以外にも1.
- Polarsバージョン1.
0アップグレードガイドの内容 (筆者が気になったもの) - NumPy 2.
0をサポート - Hugging Faceからのデータセットの直接読み込みサポート
まとめ
今回の記事では、Polars 1.
データ処理の世界において、スピードと効率性は非常に重要です。メモリ消費を抑えつつ、利便性に優れ、高いパフォーマンスを得ることができるPolarsをぜひ試してみてください。