PolarsというPandasを100倍くらい高性能にしたライブラリがとても良いので布教します1。PolarsはRustベースのDataFrameライブラリですが、本記事ではPythonでのそれについて語ります。
ちなみにpolarsは白熊の意です。そりゃあまあ、白熊と大熊猫比べたら白熊のほうが速いし強いよねってことです2。
何がいいの?
推しポイントは3つあります
- 高速!
- お手軽!
- 書きやすい!
1. 高速
画像はTPCHのBenchmark(紫がPolars)3。
日本語でも色々記事があるので割愛しますが、RustやApach Arrowなどにお世話になっており、非常に速いです。MemoryErrorに悩まされる問題も解決されます。開発者のRitchieがしゃれおつなツイートをしてるので、そちらも参考にどうぞ ↓ 4。
抄訳:
(ひとつ目)Pandasは黄色くした部分でDataFrameをフルコピーしてて、イケてないよ!
(ふたつ目)一方Polarsでフルコピーしてるのは、2枚目の黄色い部分だよ!
2. お手軽
pip install polars
だけでスタートできます。高速なデータフレーム処理ライブラリとして有名なcuDF(GPUを使う)とかpyspark(sparkを使う)とかと比べてお手軽です。Google Colabだとデフォルトでインストールされてるので、import polars as pl
するだけで普通に使えます。
また、Pandasと似た書き方でわりと動くのも嬉しいところかもしれません5。
3. 書きやすい
実はこれが一番の推しポイントですが、あまり語られることが多くない気がします。ということで、この記事ではPolarsの書きやすさの話をメインでしていきます!!
……の前にまず、概要的なところを簡単に紹介しましょう。
Polars入門
10行で把握するPolars
irisデータセットを例に適当な処理を書いてみましょう。
import polars as pl
df = pl.read_csv("https://j.mp/iriscsv") # データ読み込み
df_agg = (
df
.select([pl.col("^sepal_.*$"), pl.col("species")]) # 列の選択
.with_columns((pl.col("sepal_width") * 2).alias("new_col")) # 列の追加
.filter(pl.col("sepal_length") > 5) # 行の選択
.group_by("species") # グループ化
.agg(pl.all().mean()) # 全列に対して平均を集計
)
Polarsは上のコードように、処理をメソッドチェーンを繋いで記述することが多いです。Rのdplyrのパイプみたいなもんです。基本的には書いた順に処理が走るので、何も考えずにつなげていけばOKです。
上記で行っているのは、
-
read_csv
:データの読み込み -
select
:列の選択 -
with_columns
:列の追加 -
filter
:行の選択 -
gorupby
:グループ化 -
agg
:集計
になります。pl.col
で列を選択し、その列に対する処理を続けます。alias
は列名の変更、pl.all
は全列の選択です。
参考になるページ
Polarsについて学ぶときは一番上、公式のUser Guideが分かりやすいです。3つ目のページではpandasの同じ処理と並べて比較できます。4つ目はチートシートです。
日本語だとこのあたりがまとまっていて参考になると思います。1,2個目は使い方の初歩から網羅的に書いているようなもの、3個目はクエリ最適化の中身の解説も含んでおり、4個目は最近(23/2/18時点)のバージョンまで含めたTipsが説明されています。
- pandasから移行する人向け polars使用ガイド - Qiita
- テーブルデータ処理に悩むあなたに朗報!Polarsの使い方を徹底解説 その1:基本編 | DevelopersIO
- 超高速DataFrameライブラリー「Polars」について
- Polars, 旬の13のお役立ち機能 - Qiita
問題を解きながら身に付けたい場合は以下があります。
- Polarsでデータサイエンス100本ノックを解く(前編) - Qiita
- Polarsでデータサイエンス100本ノックを解く(後編) - Qiita
- Python初学者のためのPolars100本ノック - Qiita
上二つは私が書いた、データサイエンス100本ノック(構造化データ編) を解いたものです。ひたすら例が見たい人はこれらをぜひ!問題としては3つ目に記載されているもののほうが基礎的なところから網羅していて良い気がします6。
pl.Exprを使う
Polarsの書きやすさの中心にあるのが、polars.Expressionというやつです。これは何かというと、
a mapping from a series to a series
と説明されてます7。つまり、ある列から他の列への加工処理の方法を記述したものです。複数列からのmappingもできます。データフレームに対する主な処理は select
, with_columns
, agg
, filter
などですが8、任意の場所でpl.Exprを使うことが出来ます。どういうことか。
たとえば、文字列で入ってる cost
列を整数に変換する処理は
pl.col("cost").str.extract("\$(.*)").cast(pl.Int64)
という pl.Expr で書けます(まず pl.col
でcost列を指定し、それに対し str.extract
で $
に続く部分を抽出する文字列処理を行い、最後に cast
でInt型に変換しています)。これを色んな所で使いまわせます。
# 整数に変換した列の追加
df.with_columns(pl.col("cost").str.extract("\$(.*)").cast(pl.Int64).alias("cost_int"))
# コストが100ドル以上の行を選択
df.filter(pl.col("cost").str.extract("\$(.*)").cast(pl.Int64) > 100)
# 店舗ごとのコストの合計を計算
df.group_by("store").agg(pl.col("cost").str.extract("\$(.*)").cast(pl.Int64).sum())
ここで強調したいのは、同じ表現を繰り返し使えることではありません。ポイントは、どんな処理も同じ頭の働かせ方で済むことです。書き方に悩むことがなくなり、統一感も出しやすいです。
このようにpl.Expressionを用いると柔軟な記述が実現できるため、公式でもExpression APIを推奨しています。
Pandasとの比較
Pythonでのデータフレーム操作のデファクトスタンダードはPandasでしょう。が、正直Pandas好みじゃない人、いますよね。自分はdplyrからPandasに移った人なので「あれ?」となることが多々ありました9。
多くの高速化ライブラリはPandasっぽく書けることを推してます。が、それを超えた良さをPolarsは持っているので、そのあたりを比較していきましょう。
概念とかの違い
1.Indexがない
個人的にあれはバグの温床だと思ってます。あって嬉しい場面はあるものの、まあなくても困りません。
2.同じ列に色んな型が混在できるという謎仕様がない
pd.read_csv
の low_memory
とかの引数がこれに関連します。途中で気づいたとき絶望するやつですね。
Polarsだと当然そんなことはないです。
3.遅延評価ができる
これについては次節で解説します。
4.列の指定が容易
Pandasは(書き方によっては)一つの処理で何回もデータフレーム名を書く必要があります。例えば、以下のようなコードはよく目にします。
df_customer[(df_customer["A"] > 0) & (df_customer["B"] == "XXX")]
まあ別にいいのですが、処理の途中で行数が変わったり列を追加したりすると、対処が面倒になります。Pandasで書かれたコードに、同じデータフレームに繰り返し再代入する記述スタイルが多いのはこのあたりが理由なのだろうと思います。
一方Polarsですが、 pl.col
を用いると操作しているデータフレームに対して素直に列を指定できます。
このあたり具体例で見ていきましょう。
具体例①:複雑な集計処理
具体的にPandasとPolarsで大きく違う例を見ていきます。
まずはやや複雑な集計処理です。列Gをグループとして
- 列Aの最大値
- 列Aの最小値
- 「列Aと列Bの差」の平均値
を計算し、いい感じの列名を付けたいとしましょう。
▼ Pandasの場合
df["A_B_diff"] = df["A"] - df["B"]
df_agg = df.groupby("G").agg({"A": ["min", "max"], "A_B_diff": "mean"})
df_agg.columns = ["A_min", "A_max", "A_B_diff_mean"]
などですかね。。他の書き方もありますが、たぶんどれで書いても若干もっちゃりすると思います。
▼ Polarsの場合
df.group_by("G").agg([
pl.col("A").min().alias("A_min"),
pl.col("A").max().alias("A_max"),
(pl.col("A") - pl.col("B")).mean().alias("A_B_diff_mean"),
])
です。スッキリ書けます。集計処理に pl.Expr を渡せるので、複数列の指定が特に容易です。
列名を alias
でその場で変えられるのもありがたいです。
具体例②:apply処理
Pandasだと複雑な列を作成したいときどうしても apply
が必要になります。例として、以下を満たすような列Cを作成しましょう。
- 列A + 列B が偶数のとき、または 列Aが3のとき:
"い"
- 列Aが偶数のとき:
"ろ"
- それ以外:
"は"
▼ Pandasの場合
def make_col_c(a, b):
if (a + b) % 2 == 0 or a == 3:
return "い"
elif a % 2 == 0:
return "ろ"
else:
return "は"
df["C"] = df.apply(lambda x: make_col_c(x["A"], x["B"]), axis=1)
Pandasだとapplyを使って書くことが多くなるかと思います。が、DataFrameに対するapply処理は非常に遅いです10。このくらいなら頑張ればapplyなしでも書けるでしょうが、書きやすさ・読みやすさとのトレードオフがあります。
▼ Polarsの場合
df.with_columns(
pl.when(((pl.col("A") + pl.col("B")) % 2 == 0) | (pl.col("A") == 3))
.then("い")
.when(pl.col("A") % 2 == 0)
.then("ろ")
.otherwise("は")
.alias("C")
)
pl.when(...).then(...).otherwise(...)
は見ての通り、pythonのif...else文に相当する処理です。applyなしで(かつ自然な書き方で)書け、処理も高速です。
ということで、パッと見だとそんなに違いなさげですが、込み入った操作を行うときにPolarsの良さが出ます。
polarsの apply
は、version0.19から map_elements
などに変更されました。
https://pola-rs.github.io/polars/releases/upgrade/0.19/#groupby-renamed-to-group_by
遅延評価
遅延評価とは何か
明示的にPolarsに計算の実行を指示するまでは計算が走らず、指示した段階で溜まった一連の処理をいい感じにまとめて実行してくれるものです。「いい感じ」とは、Polarsが内部でクエリの最適化や並列実行を行ってくれることを指します11。
Pandasにこの機能はありません。Daskに近いですが、Daskはクエリの最適化は行ってくれないみたいです。
遅延評価のやりかた
lazy()
を挟んで、 collect()
(あるいは fetch()
)で実行するだけで、かなりシンプルです。
(df.
.lazy() # <= 以降の処理を遅延評価にまわす
.select(...)
.with_columns(...)
.group_by(...)
.agg(...)
.with_columns([..., ...])
.collect() # <= 遅延していた処理をまとめて実行
)
デバッグ用途で限られた行数だけ実行したいときは、collect
の代わりに fetch
が使えます。
また、データを読み込むところから遅延で評価したい場合、read_csvの代わりに scan_csv
を使います。
df = (
scan_csv("path/to/your/data.csv") # <= データの読み込みから遅延評価にまわす
.with_columns(...)
.group_by(...)
.agg(...)
.collect() # <= 遅延していた処理をまとめて実行
)
当然どのくらい速くなるかはケースバイケースですが、自分が試したときは半分くらいの実行時間になりました。このような遅延評価の恩恵も、メソッドチェーンを使って一連の処理を一続きで記述できることが前提にあるような気がします。
注意点など
a. matplotlibやsklearnで使う
matplotlib/seaborn
普通に使えます。
import seaborn as sns
import matplotlib.pyplot as plt
df = pl.read_csv("https://j.mp/iriscsv")
# matplotlibの場合
plt.scatter(df["sepal_length"], df["petal_length"])
# seabornの場合
sns.scatterplot(data=df, x="sepal_length", y="petal_length");
plotly
微妙にクセがあります。
version 5.16以降、そのまま使えるようになってました12。
import plotly.express as px
# どちらの書き方でもOK
px.scatter(x=df["sepal_length"], y=df["petal_length"])
px.scatter(df, x="sepal_length", y="petal_length")
scikit-learn
現状だと、to_numpy()
で変換したものを渡す必要があります。もちろん to_pandas()
でも大丈夫です。
そのまま使えるようになってました(sklearnの全てで可能かは試していません)。
from sklearn.linear_model import LinearRegression
model = LinearRegression()
model.fit(df.select(pl.all().exclude(["petal_width", "species"])), df["petal_width"])
なお、LightGBMはsklearn-APIだと大丈夫だけど、Training-APIだとyについては to_numpy()
する必要ありそうです。Xはそのままでいけました。
statsmodelsはまだ add_constant
など一部しか対応していないようです。
b. with_columnsの注意点
並列処理が走る関係で、同じ with_columns
の中で作成した列を別の列の作成時に使うことはできません。以下のように別途 with_columns
を入れる必要があります。
# これならOK
(df
.with_columns([
pl.col("A").cast(pl.Float64).alias("hoge"),
pl.col("B").mean().over("G").alias("fuga")
])
.with_columns((pl.col("hoge") + pl.col("fuga")).alias("piyo"))
)
# これだとダメ(with_columnsを分けて書く必要がある)
(df
.with_columns([
pl.col("A").cast(pl.Float64).alias("hoge"),
pl.col("B").mean().over("G").alias("fuga")
(pl.col("hoge") + pl.col("fuga")).alias("piyo"))
])
)
また、.alias()
を使う代わりに名前付きの引数を渡すこともできます13。
df.with_columns([
hoge=pl.col("A").cast(pl.Float64),
fuga=pl.col("B").mean().over("G")
])
with_column
は with_columns
に統一され、使えなくました。
c. よく出会うエラー
-
Duplicate("Column with name: 'col' has more than one occurrences")
列名を重複させられないよ!って言っているだけなので、alias()
で名前変えてあげればOKです。 -
ちょいちょいクラッシュする?
おそらく、大きいデータを処理する際に特定のエラーが出るとクラッシュすると思われます。クラッシュしたらエラーも出ず原因が分からないので、書いてる段階だとサイズ小さくしたりfetch
を使って確認していくのがとりあえずの対処法になるかと思います。
なお、私の場合はデータフレームの行数と一致しないものをwith_column
しようとする場合に出会った印象があります。
d. その他、雑多に
網羅性も何もないですが、使ってみて引っ掛かったところなど書いておきます。
pandasのtransformに該当する処理
def mean_by_department(col: str) -> pl.Expre:
return pl.col(col).mean().over(pl.col("department"))
(df
.with_columns(mean_by_department("salary").alias("avg_salary_by_department")
.filter(mean_by_department("overtime_hours") > 100)
)
pl.Exprに over
を続ければOKです。SQLっぽいですね。
(上のように関数でpl.Exprを返す書き方は、あまり見ないけど結構便利だと思ってます)
nanとnullを区別する
行方向の処理
pl.DataFrameに対する処理としては axis=1
で実現できます。
df.select(pl.col("^sepal_.*$")).sum(axis=1)
pl.Expr を用いる場合、sum_horizonal
など行方向用のメソッドがv0.18.8以降追加されました14。
他にList型 や fold
を使うこともできます。
グループ化しない集計
agg
ではなく、select
を使います。
df.select([
pl.col("sepal_length").min().alias("length_min"),
pl.col("sepal_length").median().alias("length_med"),
])
時系列での集計
時系列での集計操作は group_by_dynamic
を使えます。なお、先に sort
してから実行しないと意図通りの挙動をしないのには注意です。
df.group_by_dynamic("Date", every="1y").agg(pl.col("A").mean())
groupby
は group_by
に、 groupby_dynamic
は group_by_dynamic
になりました。
英語の単語ごとに _ で区切るように統一しましょうという流れなのだと思います。
定数やnumpyのarrayを列として追加する
定数の場合は pl.lit
が、np.arrayやリストなどは pl.Series
が使えます。
df.with_columns([
pl.lit(3.14).alias("pie"),
pl.lit("B").alias("const"),
pl.Series(np.random.randn(4)).alias('np_random'),
pl.Series([4,3,2,1]).alias('from_list'),
])
特定の要素を変更する
pandasの loc
を用いた代入や where
に相当する処理は、when..then..otherwiseで記述するほか、map_dicts
などを用いる方法があります。
微妙にpandasと違う名前のやつ
目についたものだけ備忘のため表にしておきます(当然これがすべてではないです)。
pandas | polars |
---|---|
isnull | is_null |
notnull | is_not_null |
nunique | n_unique |
groupby | group_by |
astype | cast |
polarsでは英語の単語ごとに_で区切ると思っておけば分かりやすいです。
最後に
Polarsはその高速さを売りに紹介されることが多いのですが、書きやすさとか使いやすさの面でもいいぞ!という主張でした。
実は「ほらね書きやすいでしょ」の多くはpandasでも似たような感じで書けるのですが、どうしてもパフォーマンスが落ちたり、よく流通している書き方がイケてなかったりで、どう書くか悩むことが結構あります。Polarsだと悩まずに最適なコードにたどり着けるのが一番嬉しいポイントだよなあとか思ったりする訳です。
たぶん2020年くらいに出た新しめのライブラリですが、開発は盛んだし、Ritchieはいいやつだし(別に話したことないけど)、今後も勢力を強めていくでしょう。「データが大規模でPandasだと難しいからPolarsを使う」ではなく、デフォでPolarsという選択肢をとるケースも増えてくるのではないかと妄想してます。
-
数字は個人の見解です。なお、本記事を書いている人とPolarsとの関係は、1ヶ月くらい趣味でそこそこな規模のデータを触る際に使ってた程度です。逆にその短期間で信者になるくらい最高なライブラリだとも言えます。 ↩
-
読み方は「ぽらーす」ではなく「ぽーらす」寄りで、私の耳が正しければ「ぽぅらーす」みたいに聞こえます ( https://youtu.be/iwGIuGk5nCE?t=66 ) 。音は「暴発」ではなく「ボーナス」っぽい感じです(このあたりの説明下手くそですいません笑)。極を意味するpole(ぽーる)とか極値を意味するpolar(ぽーらー)から来てるはずなので、それに近い発音をしておけば恥はかかないはずです。 ↩
-
https://www.pola.rs/benchmarks.html Polarsは比較的最近のライブラリなので、こういった評価で対象になってないことが多々あります。。 ↩
-
https://twitter.com/RitchieVink/status/1532067902954029057 。ちなみに私も速い理由をちゃんと分かってはないですが、このあたりにRithcieによる説明があります → https://www.pola.rs/posts/i-wrote-one-of-the-fastest-dataframe-libraries/ ↩
-
が、この後で述べるようにもっとスマートな書き方があるため、推奨はしません。高速化の恩恵を受けにくいなどの理由から、公式にもpandas-likeなAPIは非推奨になる流れで、warningが出たりエラー吐くように変わってきております。 ↩
-
私自身は解いてないのでなんとも言えませんが。。違ったらご指摘ください。 ↩
-
https://pola-rs.github.io/polars-book/user-guide/expressions/operators/ ↩
-
contextなどと呼ばれます。 https://pola-rs.github.io/polars-book/user-guide/concepts/contexts/ ↩
-
例えばこの辺りに書いてあります。こちら頷くことが多い記事で、大変参考にしています。 → https://ill-identified.hatenablog.com/entry/2021/09/18/130716 ↩
-
https://shinyorke.hatenablog.com/entry/pandas-tips#apply%E3%81%AF%E3%81%95%E3%81%BB%E3%81%A9%E9%80%9F%E3%81%8F%E3%81%AA%E3%81%84 などいろんなところで言われてるやつですね。加えて、内部でデータフレームをコピーする際の無駄が大きいです(冒頭のツイートと似た話ですね)。 ↩
-
クエリに対して
.show_graph(optimized=True)
を呼ぶことで実行グラフを可視化できます。何がどうなって速くなっているかはこちらの記事などが参考になりそうです。その1 → https://pola-rs.github.io/polars-book/user-guide/lazy/optimizations/ , その2 → https://towardsdatascience.com/understanding-lazy-evaluation-in-polars-b85ccb864d0c ↩ -
https://github.com/plotly/plotly.py/blob/master/doc/python/px-arguments.md#input-data-as-non-pandas-dataframes ↩
-
こちらで知りました➞ https://qiita.com/hkzm/items/8427829f6aa7853e6ad8#2-dfwith_columns-%E3%81%A7%E3%81%AE%E5%88%97%E5%90%8D%E3%81%AE%E6%8C%87%E5%AE%9A%E3%81%AF%E5%90%8D%E5%89%8D%E4%BB%98%E3%81%8D%E5%BC%95%E6%95%B0%E3%81%A7%E3%82%82%E6%B8%A1%E3%81%9B%E3%82%8B ↩
-
https://pola-rs.github.io/polars/py-polars/html/reference/expressions/api/polars.sum_horizontal.html#polars.sum_horizontal ↩