5
4

Polars の基礎概念を理解する

Posted at

この記事は、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に作ってもらいました。優秀ですね。

データからDataFrameを作る
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 で主要なものは以下に挙げられます。

  1. df.select: 列名や expression から、列を選び、新たな DataFrame として返す
  2. df.filter: 列名や expression から、条件に合う行を抽出し、新たな DataFrame として返す
  3. df.with_columns: 列名や expression から、既存の列を更新したり、新たな列を作成し、新たな DataFrame として返す

これらは全て、元の DataFrame は変更せずに、新たな DataFrame を返すことに注意してください。

元のDataFrameは変換されない
df.filter(...)
print(df)  # 元の df と同じものが表示される

# おそらく、こうしたかったはず
df_filtered = df.filter(...)

# 元の df が不要なら、以下のようにも出来る
df = df.filter(...)

ここからは、df.selectdf.filter の使い方を、expression を必要としない範囲で確認し、その後で expression を紹介します。

df.select: 列を選ぶ

df.select に列名を文字列の形で渡すことで、列を選んで取り出すことができます。列は複数指定することが出来ます。取り出した結果は DataFrame として返され、また、 df 自体には変更は加わりません。
例えば、 df のうち、 namedepartment 列を選んで取り出すには、以下のように書きます。

selectで列を選ぶ
df.select("name", "department")

df.filter: 条件に合う行を抽出する

df.filter に bool 型を持つ列名を渡すことで、その列が True な行のみを抽出することが出来ます。また、 df.select のときと同様に、複数の条件を渡すことも出来ます。抽出した結果は DataFrame として返され、df 自体には変更は加わりません。
例えば、 is_managerTrue な行を抽出するには、以下のように書きます。

filterで行を抽出する
df.filter("is_manager")

Polars では、このように列名を使って selectfilter の処理を行うことが出来ますが、列名の代わりに expression を指定することで、より実用的な処理をスマートに書くことが出来ます。

Expression を使いこなす

Expression は、直訳すると「式」や「表現」という意味も持ちます。前述の通り、日本語の Polars に関する文献では、 context のことを「式」と訳しているものも多いことにはご注意ください。

Expression を用いた処理は、DataFrame のデータを直接操作するのではなく、少し間接的な印象を受けるかもしれません。Expression は、どのように計算するかの指示を式の形で表現したものであり、実際の計算は、context (すなわち、どの DataFrame に対し、何をしたいか) と組み合わせることで、初めて行えるようになります。

また、context は具体的な型ではなかったのに対し、 expression は、Expr 型を持つオブジェクトです。

df.select に expression を用いる

列名を表すexpression
expr = pl.col("列名")

のようにすると、列を表した expression を作ることが出来ます。
先ほどと似た例ですが、今度は name, salary, age, years_of_service (勤続年数) を取り出してみましょう。

列名を直接指定しselectで列を選ぶ
df.select("name", "salary", "age", "years_of_service")

と同じことを、expression を用いて行うには、以下のように書きます。

expressionを用いてselectで列を選ぶ
df.select(
    pl.col("name"),
    pl.col("salary"),
    pl.col("age"),
    pl.col("years_of_service"),
)

また、

  • salary を 12 で割った、毎月の給与
  • age から years_of_service を引いた、入社時の年齢

を求めるには、以下のように書くことが出来ます。

expressionを用いて列の演算を行う
df.select(
    pl.col("name"),
    pl.col("salary") / 12,
    pl.col("age") - pl.col("years_of_service")
)

expr.alias("新たな列名") とすることで、分かりやすい列名を付けることができます。

expressionに別名を与える
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 を用いて直感的にフィルタリングの条件が指定出来ることが分かります。

salaryが100,000以上かつdepartmentがIT
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の型宣言
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 が指定された場合、その列を置き換える

ただし、前述の通り、 df.with_columns は元の DataFrame を変更しないことに注意してください。 df 自体には列は追加・置き換えされず、列を追加・置き換えした新たな DataFrame が返されます。

以下を行う場合の使用例を示します。

  • salary12 で割った月収を列として追加する
  • 全員の years_of_service1 ずつインクリメントする
df.with_columnsの使用例
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(...).agg(...)
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() を呼び出すことで、 DataFrameLazyFrame に変換できます。

DataFrame.lazy()でLazyFrameを作る
lazy = df.lazy()

LazyFrame のコンストラクタから LazyFrame を作る

pl.DataFrame() の代わりに pl.LazyFrame() を呼び出すことでも、LazyFrame を作ることができます。

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_* を呼び出すことによって、様々な形式のファイルを読み込むことが出来ます。たとえば、

CSVをDataFrameとして読み込み
df = pl.read_csv(path_to_csv)

のようにすることで、CSVファイルを DataFrame として読み込むことができます。
そして、以下のように pl.scan_* を、呼び出すと LazyFrame として遅延読み込みすることができます。

CSVをLazyFrameとして遅延読み込み
lazy = pl.scan_csv(path_to_csv)

このとき、 pl.scan_*(...) を呼び出した時点ではファイルは読み込まれず、必要になった時点でファイルが読み込まれます。その際、不要な行や列はメモリに読み込む必要がなくなり、場合によっては pl.read_*(...) により読み込むよりもメモリのオーバーヘッドが少なくなります。

LazyFrame を評価する

これまで、 LazyFrame は遅延評価により、必要になったとき評価されるということを書いてきました。では、どのタイミングで評価されるのでしょう。通常考えられるのは、LazyFrame.collect() を呼び出して DataFrame として取り出すときと、 LazyFrame.sink_*() を呼び出してファイルに書き出すときです。

LazyFrame を評価し、 DataFrame にする

以下のように LazyFrame.collect() を呼び出すことで、 DataFrame として評価することができます。

LazyFrame.collect()でDataFrameとして評価する
df = lazy.collect()

LazyFrame を評価し、ファイルに書き出す

LazyFrame.sink_*(...) によって、 LazyFrame を、DataFrame を介さずファイルに書き出すこともできます。

LazyFrameをCSVに書き込み
lazy.sink_csv(path_to_csv)

これは、以下のように一旦 collect() してから DataFrame.write_csv(...) を行うものと結果は同じですが、上で見た LazyFrame.sink_csv(...) を用いる方が、 DataFrame を介さないことで、より効率的に書き込める可能性があります。

LazyFrameをcollect()してからCSVに書き込み
lazy.collect().write_csv(path_to_csv)

LazyFrame の評価計画を見る

LazyFrame.explain() によって、LazyFrame がどのように評価されるかを知ることができます。

LazyFrameの評価計画
lazy.explain()
# または、以下と同等です
lazy.explain(optimized=True)

また、評価計画を最適化せずに、書かれた順番通りに評価すれば (すなわち、遅延評価しなければ) どうなっていたかを optimized=False 引数を与えることで知ることができます。

LazyFrameの最適化を行わない評価計画
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 を行わないと以下のようになり、

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 を行うと以下のようになります。

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.colpl.lit による expression に相当することも紹介しました。

これらを理解していれば、Polars のコードやドキュメントを十分に読みこなすことができ、また、生成AIによる Polars のコードを理解して手直しすることも行えるようになります。

良き Polars ライフを!

  1. 個人的な経験では、Polars 以外で context を「式」と訳しているものを見たことがなく、むしろ expression を式と訳すことが普通なように感じます。この記事では、こういった混乱を避けるため、 context と expression のいずれも「式」とは訳しません。

  2. ただし、Python 側のソースコードを見てみると、この変換処理は Rust ではなく、 Python で行われていることが分かります。

  3. lazy は「怠惰な」、eager は「熱心な」を意味します。

  4. 最も極端な例を挙げると、先行評価では無限ループになるものであっても、遅延評価であれば処理が終わる場合もあります。

  5. 遅延評価のためには、後から評価出来る形に呼び出し内容を保持したり、評価時に効率的に評価する方法を求めるなどの処理が必要となります。こういった遅延評価のオーバーヘッドが、遅延評価による計算の削減よりも大きい場合には、遅延評価の方が先行評価よりも遅くなることもありえます。しかし、遅延評価に伴うオーバーヘッドが問題となるような使い方は稀であり、通常は気にする必要がありません。

  6. DataFrame の実装を見ると、多くの場合、 DataFrame を一度 LazyFrame にして、その評価結果を DataFrame として取り出すように書かれていることが分かります。そのため、Polars は DataFrame よりもむしろ LazyFrame をベースに作られている、と言っても過言ではないでしょう。

5
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
4