この記事は、Python Polars 1.5 のドキュメントを元に作成しています。
Polars は比較的若いライブラリのため、API や実装は短期間のうちに変更される可能性があります。
Polars の基礎概念を理解したい
Polars は、高速で洗練された DataFrame のためのライブラリです。従来より Python では、 DataFrame のためのライブラリとして Pandas がよく使われていますが、近年は徐々に Polars の人気も高まっており、名前を聞いたり解説記事を見ることも多くなってきました。
Polars は、Pandas とのコードの互換性を目指しているわけではなく、異なる思想の上で設計されています。Polars の現代的な設計思想は、Polars の大きな魅力のひとつですが、一方で、初学者にとってのとっつきづらさの一因とも言えるでしょう。
「Pandas でやっていた、この処理は、 Polars でどう書くの?」を説明している記事は数多くあります。また、ChatGPT を初めとする LLM も、 Polars を知っており、聞けば Polars を書いてくれます。しかし、その意味を理解したいときや、微修正を自分で行いたいときに、基礎概念を知らなければ、思った以上にエラーが出て、うまくいきません。Polars と心が通じ合うためには、Polars の気持ちを理解することが必要となります。
Polars の概念で重要なものはいくつかありますが、最も重要な概念は Context と Expression です。このことを解説している資料は既にあって、例えば、こちらの記事が有名です。
また、この内容を動画で分かりやすく解説しているものがあります。
じゃあ、新たに記事を書く必要はないのでは、という感じもするのですが、この記事では、個人的に探し当てるのが大変だったことを中心に、Polars の基礎概念を理解するために必要なことを見ていこうと思います。
まずは DataFrame
を作る
DataFrame
の作り方は、一見すると、 Pandas とあんまり変わりません。
この記事では、特に明記がなければ、DataFrame の例として、以下で定義する df
を用いることとします。この例は、Claude 3.5 Sonnetに作ってもらいました。優秀ですね。
import polars as pl
import numpy as np
# ランダムなデータを生成
np.random.seed(42)
n = 100
data = {
"employee_id": np.arange(1, n+1),
"name": [f"Employee_{i}" for i in range(1, n+1)],
"age": np.random.randint(22, 65, n),
"department": np.random.choice(["Sales", "Marketing", "IT", "HR", "Finance"], n),
"salary": np.random.randint(30000, 150000, n),
"years_of_service": np.random.randint(0, 30, n),
"performance_score": np.random.randint(1, 6, n),
"is_manager": np.random.choice([True, False], n, p=[0.2, 0.8]),
}
# DataFrameの作成
df = pl.DataFrame(data)
# DataFrameの表示
print(df)
# 基本的な統計情報
print(df.describe())
Polars は、CSV や Excel をはじめとする、たくさんの形式からデータを読み込むことができます。
どういった形式がサポートされているかは、公式リファレンスのInput/Output を眺めると分かるでしょう。
最初に使いたい Context
Context は、Polars の中核となる概念のひとつです。直訳すると「文脈」という意味ですが、日本語の Polars 関連のドキュメントでは「式」と訳されることもあります。1
文脈ってどういう意味? というのが気になるとは思うのですが、それは後で、再び考えることにします。
すごく雑に context とは何かを述べると、 DataFrame
のメソッドのうち、 expression を引数に取れるもの、ということになります。
そして、そのような context で主要なものは以下に挙げられます。
-
df.select
: 列名や expression から、列を選び、新たなDataFrame
として返す -
df.filter
: 列名や expression から、条件に合う行を抽出し、新たなDataFrame
として返す -
df.with_columns
: 列名や expression から、既存の列を更新したり、新たな列を作成し、新たなDataFrame
として返す
これらは全て、元の DataFrame
は変更せずに、新たな DataFrame
を返すことに注意してください。
df.filter(...)
print(df) # 元の df と同じものが表示される
# おそらく、こうしたかったはず
df_filtered = df.filter(...)
# 元の df が不要なら、以下のようにも出来る
df = df.filter(...)
ここからは、df.select
と df.filter
の使い方を、expression を必要としない範囲で確認し、その後で expression を紹介します。
df.select
: 列を選ぶ
df.select
に列名を文字列の形で渡すことで、列を選んで取り出すことができます。列は複数指定することが出来ます。取り出した結果は DataFrame
として返され、また、 df
自体には変更は加わりません。
例えば、 df
のうち、 name
と department
列を選んで取り出すには、以下のように書きます。
df.select("name", "department")
df.filter
: 条件に合う行を抽出する
df.filter
に bool 型を持つ列名を渡すことで、その列が True
な行のみを抽出することが出来ます。また、 df.select
のときと同様に、複数の条件を渡すことも出来ます。抽出した結果は DataFrame
として返され、df
自体には変更は加わりません。
例えば、 is_manager
が True
な行を抽出するには、以下のように書きます。
df.filter("is_manager")
Polars では、このように列名を使って select
や filter
の処理を行うことが出来ますが、列名の代わりに expression を指定することで、より実用的な処理をスマートに書くことが出来ます。
Expression を使いこなす
Expression は、直訳すると「式」や「表現」という意味も持ちます。前述の通り、日本語の Polars に関する文献では、 context のことを「式」と訳しているものも多いことにはご注意ください。
Expression を用いた処理は、DataFrame のデータを直接操作するのではなく、少し間接的な印象を受けるかもしれません。Expression は、どのように計算するかの指示を式の形で表現したものであり、実際の計算は、context (すなわち、どの DataFrame に対し、何をしたいか) と組み合わせることで、初めて行えるようになります。
また、context は具体的な型ではなかったのに対し、 expression は、Expr
型を持つオブジェクトです。
df.select
に expression を用いる
expr = pl.col("列名")
のようにすると、列を表した expression を作ることが出来ます。
先ほどと似た例ですが、今度は name
, salary
, age
, years_of_service
(勤続年数) を取り出してみましょう。
df.select("name", "salary", "age", "years_of_service")
と同じことを、expression を用いて行うには、以下のように書きます。
df.select(
pl.col("name"),
pl.col("salary"),
pl.col("age"),
pl.col("years_of_service"),
)
また、
-
salary
を 12 で割った、毎月の給与 -
age
からyears_of_service
を引いた、入社時の年齢
を求めるには、以下のように書くことが出来ます。
df.select(
pl.col("name"),
pl.col("salary") / 12,
pl.col("age") - pl.col("years_of_service")
)
expr.alias("新たな列名")
とすることで、分かりやすい列名を付けることができます。
df.select(
pl.col("name"),
(pl.col("salary") / 12).alias("salary_per_month"),
(pl.col("age") - pl.col("years_of_service")).alias("age_at_entry")
)
列の集計 (合計、平均、最小値、最大値、標準偏差など) も select
と expression を使って計算することが出来ます。
df.select(
pl.col("salary").mean().alias("average_of_salary"),
pl.col("age").min().alias("min_of_age"),
pl.len().alias("count_of_rows"),
)
なお、pl.col("列名").mean()
などは、 pl.mean("列名")
などと略記することも可能です。
ドキュメントを眺めると、よく使いそうなものは一通り揃っていることが分かるでしょう。
df.filter
に expression を用いる
こちらは、コードを見た方が早いかと思います。 expression を用いて直感的にフィルタリングの条件が指定出来ることが分かります。
df.filter(
(pl.col("salary") >= 100000) & (pl.col("department") == "IT")
)
IntoExpr
について
先ほどは、 expression を使わずに、カラム名の文字列を使って df.select
, df.filter
を利用しました。なぜ expression の代わりに文字列が使えたのでしょう?
直接的な答えとしては、DataFrame.select
のドキュメントに以下のように書いてあるためです。
*exprs
Column(s) to select, specified as positional arguments. Accepts expression input. Strings are parsed as column names, other non-expression inputs are parsed as literals.
一方、メソッドの型注釈に注意すると、IntoExpr
型を引数に取ることが分かります。
DataFrame.select(
*exprs: IntoExpr | Iterable[IntoExpr],
**named_exprs: IntoExpr,
) -> DataFrame
Polars が書かれている Rust 言語では慣用的に、Into
から始まっている型名は、その後に書かれた型 (この場合は Expr
) へと変換可能な型たちを表しています。そのことを知っていると、型名を見るだけでそういった挙動に気が付けるかもしれません。2
また、(あまり利用する場面がないかもしれませんが) 文字列以外で expression として変換出来るものに、整数や小数点、datetime などがあります。これらは、 literal として扱われ、 pl.lit(1)
という expression に相当します。
さらに使いたい context
他にも、以下は比較的よく使います。
df.with_columns
: 既存の列を更新したり、新たな列を作成し、新たな DataFrame
として返す
df.select
と似ていますが、DataFrame の列を更新したり、新たな列を作成するためには、 df.with_columns
を使います。
df.select
との具体的な違いとしては、以下が挙げられます。
-
df.select
では、空の DataFrame に指定した列を加えた DataFrame を返す -
df.with_columns
では、df
と同じ DataFrame に列を追加した、または置き換えた DataFrame を返す- 元の DataFrame にない列名 (
Expr.alias()
などで指定) の expression が指定された場合、 DataFrame の右側に新たな列として追加する - 元の DataFrame に既にある列名の expression が指定された場合、その列を置き換える
- 元の DataFrame にない列名 (
ただし、前述の通り、 df.with_columns
は元の DataFrame を変更しないことに注意してください。 df
自体には列は追加・置き換えされず、列を追加・置き換えした新たな DataFrame が返されます。
以下を行う場合の使用例を示します。
-
salary
を12
で割った月収を列として追加する - 全員の
years_of_service
を1
ずつインクリメントする
df.with_columns(
(pl.col("salary") / 12).alias("salary_per_month"),
pl.col("years_of_service") + 1,
)
df.select
の場合と違い、元あった DataFrame の列を新たな DataFrame でも引き継いでいることが分かります。
df.sort
: 列や expression をキーにソートする
ここまで読んだ人であれば、これだけで使い方が分かると思うので省略します。
df.group_by
: DataFrame をグループ化して集計する
SQL や Pandas のものと似ていますが、Polars では、ここでも expression を引数に取れます。
また、 DataFrame.group_by(...)
は、これまでに紹介した他の context を作るメソッドとは違い DataFrame
型ではなく GroupBy
型の結果を返します。そして、そこから DataFrame
型を得るには、GroupBy.agg(...)
や GroupBy.first()
, GroupBy.mean(...)
などのメソッドを呼び出して集計を行う必要があります。
行える集計の一覧は GroupBy
のドキュメントを参照してください。
ここでは、そのうちいくつかを取り上げます。
df.group_by(...).agg(...)
: DataFrame をグループ化し、各グループを指定した expression で集計する
例えば、各 department
ごとに、次の項目を集計してみましょう。
- 従業員の数 (すなわち行数)
- マネージャーの数 (すなわち、
is_manager
が True となっている行の総数) - 給料の総計
- 平均年齢
この場合は、次のように書くことが出来ます。
df.group_by("department").agg(
pl.len().alias("num_of_employees"),
pl.col("is_manager").sum().alias("num_of_managers"),
pl.sum("salary").alias("amount_of_salary"),
pl.mean("age").alias("mean_of_age")
)
df.group_by(...).mean()
, .median()
, .min()
, .max()
, ...: 各グループで、各列を指定した方法で集計
全ての列の平均や中央値、最小値、最大値などを求めたいだけであれば、このように一発で行うことが出来ます。
文字列 (今回の例だと、 name
列など) の平均など、計算できないものについては、 null
が入ります。
df.group_by(...).first()
, .last()
: 各グループから最初/最後の行のみを取り出す
最初/最後の行のみを取り出す操作です。どれが取り出されても構わない場合に有効です。
また、事前にソートしてから group_by(...).first()
を用いることも有効です。
# sort(..., descending=True) は、降順 (大きい方から小さい方) にソートすることを示す
df.sort("salary", descending=True).group_by("department").first()
いろんなところに、expression を利用できることを知っていると、便利です。
df.sort(pl.col("age") - pl.col("years_of_service")).group_by("department").first()
遅延評価
コンピュータの世界では、たびたび遅延評価 (lazy evaluation) という概念が現れます。これは、普通に計算すると時間がかかったり無駄が多いような計算を効率よく行うために使われます。
通常は、プログラミングなどにおいては、関数呼び出しなどを行うと、呼び出したときに値が評価 (すなわち、計算) され、その結果が返されるはずです。こういった通常の評価方法を、とくに遅延評価と対比する場合には先行評価 (eager evaluation) 3と呼びます。
先行評価では、書いたとおりの計算が書いたとおりの順番で逐次的に行われますが、遅延評価では、書いた時点では計算は行われず、値が必要になった時点で計算が行われます。このようにすることで、結果的には行う必要のなかった計算を省く、より効率的になるよう計算順序を入れ替える、同時に行える計算をまとめる、といったことが可能になります4。
なお、遅延評価の利点だけでなく欠点も書いておくと、評価が後になるので評価時に発生するエラーが後になって出て、デバッグしづらくなる点が挙げられます。また、遅延評価で必ずしも計算が速くなるわけではなく、不要な計算や効率的に行える箇所がほとんどない場合には計算は速くなりません5。
Polars では、先行評価を行う DataFrame
のほかに、遅延評価を行う LazyFrame
が用意されています。
DataFrame
とほとんど同じ書き方で、 LazyFrame
を使うことができ6、遅延評価の恩恵を受けることが出来ます。一部、DataFrame
でしか出来ない処理もありますが、この記事で紹介したものはすべて LazyFrame
でも実現可能です。
LazyFrame
を作る
LazyFrame
の作り方はいくつかあります。より手軽なものから順に紹介します。
DataFrame
から LazyFrame
を作る
DataFrame.lazy()
を呼び出すことで、 DataFrame
を LazyFrame
に変換できます。
lazy = df.lazy()
LazyFrame
のコンストラクタから LazyFrame
を作る
pl.DataFrame()
の代わりに pl.LazyFrame()
を呼び出すことでも、LazyFrame
を作ることができます。
data = {
"employee_id": np.arange(1, n+1),
"name": [f"Employee_{i}" for i in range(1, n+1)],
"age": np.random.randint(22, 65, n),
"department": np.random.choice(["Sales", "Marketing", "IT", "HR", "Finance"], n),
"salary": np.random.randint(30000, 150000, n),
"years_of_service": np.random.randint(0, 30, n),
"performance_score": np.random.randint(1, 6, n),
"is_manager": np.random.choice([True, False], n, p=[0.2, 0.8]),
}
# LazyFrameの作成
lazy = pl.LazyFrame(data)
これは、
lazy = pl.DataFrame(data).lazy()
と同じことを表しています。
pl.scan_*(...)
で、ファイルを LazyFrame
として読み出す
Polars では、Pandas のように pl.read_*
を呼び出すことによって、様々な形式のファイルを読み込むことが出来ます。たとえば、
df = pl.read_csv(path_to_csv)
のようにすることで、CSVファイルを DataFrame
として読み込むことができます。
そして、以下のように pl.scan_*
を、呼び出すと LazyFrame
として遅延読み込みすることができます。
lazy = pl.scan_csv(path_to_csv)
このとき、 pl.scan_*(...)
を呼び出した時点ではファイルは読み込まれず、必要になった時点でファイルが読み込まれます。その際、不要な行や列はメモリに読み込む必要がなくなり、場合によっては pl.read_*(...)
により読み込むよりもメモリのオーバーヘッドが少なくなります。
LazyFrame
を評価する
これまで、 LazyFrame
は遅延評価により、必要になったとき評価されるということを書いてきました。では、どのタイミングで評価されるのでしょう。通常考えられるのは、LazyFrame.collect()
を呼び出して DataFrame
として取り出すときと、 LazyFrame.sink_*()
を呼び出してファイルに書き出すときです。
LazyFrame
を評価し、 DataFrame
にする
以下のように LazyFrame.collect()
を呼び出すことで、 DataFrame
として評価することができます。
df = lazy.collect()
LazyFrame
を評価し、ファイルに書き出す
LazyFrame.sink_*(...)
によって、 LazyFrame
を、DataFrame
を介さずファイルに書き出すこともできます。
lazy.sink_csv(path_to_csv)
これは、以下のように一旦 collect()
してから DataFrame.write_csv(...)
を行うものと結果は同じですが、上で見た LazyFrame.sink_csv(...)
を用いる方が、 DataFrame
を介さないことで、より効率的に書き込める可能性があります。
lazy.collect().write_csv(path_to_csv)
LazyFrame
の評価計画を見る
LazyFrame.explain()
によって、LazyFrame
がどのように評価されるかを知ることができます。
lazy.explain()
# または、以下と同等です
lazy.explain(optimized=True)
また、評価計画を最適化せずに、書かれた順番通りに評価すれば (すなわち、遅延評価しなければ) どうなっていたかを optimized=False
引数を与えることで知ることができます。
lazy.explain(optimized=False)
例えば、次のような LazyFrame
の評価計画を見てみましょう。
df.lazy().with_columns(
(pl.col("salary") / 12).alias("salary_per_month"),
).filter("is_manager").group_by("department").sum().select("department", "salary_per_month").explain()
optimize を行わないと以下のようになり、
SELECT [col("department"), col("salary_per_month")] FROM
AGGREGATE
[col("employee_id").sum(), col("name").sum(), col("age").sum(), col("salary").sum(), col("years_of_service").sum(), col("performance_score").sum(), col("is_manager").sum(), col("salary_per_month").sum()] BY [col("department")] FROM
FILTER col("is_manager") FROM
WITH_COLUMNS:
[[(col("salary")) / (12)].alias("salary_per_month")]
DF ["employee_id", "name", "age", "department"]; PROJECT */8 COLUMNS; SELECTION: None
optimize を行うと以下のようになります。
AGGREGATE
[col("salary_per_month").sum()] BY [col("department")] FROM
WITH_COLUMNS:
[[(col("salary")) / (12)].alias("salary_per_month")]
DF ["employee_id", "name", "age", "department"]; PROJECT 3/8 COLUMNS; SELECTION: col("is_manager")
パッと見ても、どちらがどうというのは分かりづらいのですが、まずは単純に optimize 後は行数が減っていることは分かります。また、よくよく見ると、 FILTER
を後でするのではなく、下側で初めから条件を満たすものを持ってくる形にしたり、使わない列は AGGREGATE
しないような計画が作られていることが分かります。
このように、遅延評価を用いると、無駄な処理は省くように評価計画を作ることができ、効率的に評価ができるようになります。
まとめ
この記事では、Polars を使う上で、まず知っておきたい基礎概念について紹介しました。
Polars では、データを直接操作するのではなく、 context と expression を用いて DataFrame を操作することを紹介しました。また、expression はそれ単体で評価出来るのではなく、context と expression の両方が与えられてはじめて評価が行えることを紹介しました。
そして、代表的な context である、 select
, filter
, sort
, with_columns
, group_by().agg
などを紹介しました。また、expression の基本的な作り方として、 pl.col()
を用いるものを見たほか、文字列だけや、数値などだけのものが、pl.col
や pl.lit
による expression に相当することも紹介しました。
これらを理解していれば、Polars のコードやドキュメントを十分に読みこなすことができ、また、生成AIによる Polars のコードを理解して手直しすることも行えるようになります。
良き Polars ライフを!
-
個人的な経験では、Polars 以外で context を「式」と訳しているものを見たことがなく、むしろ expression を式と訳すことが普通なように感じます。この記事では、こういった混乱を避けるため、 context と expression のいずれも「式」とは訳しません。 ↩
-
ただし、Python 側のソースコードを見てみると、この変換処理は Rust ではなく、 Python で行われていることが分かります。 ↩
-
lazy は「怠惰な」、eager は「熱心な」を意味します。 ↩
-
最も極端な例を挙げると、先行評価では無限ループになるものであっても、遅延評価であれば処理が終わる場合もあります。 ↩
-
遅延評価のためには、後から評価出来る形に呼び出し内容を保持したり、評価時に効率的に評価する方法を求めるなどの処理が必要となります。こういった遅延評価のオーバーヘッドが、遅延評価による計算の削減よりも大きい場合には、遅延評価の方が先行評価よりも遅くなることもありえます。しかし、遅延評価に伴うオーバーヘッドが問題となるような使い方は稀であり、通常は気にする必要がありません。 ↩
-
DataFrame
の実装を見ると、多くの場合、DataFrame
を一度LazyFrame
にして、その評価結果をDataFrame
として取り出すように書かれていることが分かります。そのため、Polars はDataFrame
よりもむしろLazyFrame
をベースに作られている、と言っても過言ではないでしょう。 ↩