724
601

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

BrainPadAdvent Calendar 2022

Day 5

超高速…だけじゃない!Pandasに代えてPolarsを使いたい理由

Last updated at Posted at 2022-12-05

PolarsというPandasを100倍くらい高性能にしたライブラリがとても良いので布教します1。PolarsはRustベースのDataFrameライブラリですが、本記事ではPythonでのそれについて語ります。

ちなみにpolarsは白熊の意です。そりゃあまあ、白熊と大熊猫比べたら白熊のほうが速いし強いよねってことです2

何がいいの?

推しポイントは3つあります

  • 高速!
  • お手軽!
  • 書きやすい!

1. 高速

画像はTPCHのBenchmark(紫がPolars)3

日本語でも色々記事があるので割愛しますが、RustApach 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が説明されています。

問題を解きながら身に付けたい場合は以下があります。

上二つは私が書いた、データサイエンス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_csvlow_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_columnwith_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())

groupbygroup_by に、 groupby_dynamicgroup_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という選択肢をとるケースも増えてくるのではないかと妄想してます。

  1. 数字は個人の見解です。なお、本記事を書いている人とPolarsとの関係は、1ヶ月くらい趣味でそこそこな規模のデータを触る際に使ってた程度です。逆にその短期間で信者になるくらい最高なライブラリだとも言えます。

  2. 読み方は「ぽらーす」ではなく「ぽーらす」寄りで、私の耳が正しければ「ぽぅらーす」みたいに聞こえます ( https://youtu.be/iwGIuGk5nCE?t=66 ) 。音は「暴発」ではなく「ボーナス」っぽい感じです(このあたりの説明下手くそですいません笑)。極を意味するpole(ぽーる)とか極値を意味するpolar(ぽーらー)から来てるはずなので、それに近い発音をしておけば恥はかかないはずです。

  3. https://www.pola.rs/benchmarks.html Polarsは比較的最近のライブラリなので、こういった評価で対象になってないことが多々あります。。

  4. https://twitter.com/RitchieVink/status/1532067902954029057 。ちなみに私も速い理由をちゃんと分かってはないですが、このあたりにRithcieによる説明があります → https://www.pola.rs/posts/i-wrote-one-of-the-fastest-dataframe-libraries/

  5. が、この後で述べるようにもっとスマートな書き方があるため、推奨はしません。高速化の恩恵を受けにくいなどの理由から、公式にもpandas-likeなAPIは非推奨になる流れで、warningが出たりエラー吐くように変わってきております。

  6. 私自身は解いてないのでなんとも言えませんが。。違ったらご指摘ください。

  7. https://pola-rs.github.io/polars-book/user-guide/expressions/operators/

  8. contextなどと呼ばれます。 https://pola-rs.github.io/polars-book/user-guide/concepts/contexts/

  9. 例えばこの辺りに書いてあります。こちら頷くことが多い記事で、大変参考にしています。 → https://ill-identified.hatenablog.com/entry/2021/09/18/130716

  10. 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 などいろんなところで言われてるやつですね。加えて、内部でデータフレームをコピーする際の無駄が大きいです(冒頭のツイートと似た話ですね)。

  11. クエリに対して .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

  12. https://github.com/plotly/plotly.py/blob/master/doc/python/px-arguments.md#input-data-as-non-pandas-dataframes

  13. こちらで知りました➞ 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

  14. https://pola-rs.github.io/polars/py-polars/html/reference/expressions/api/polars.sum_horizontal.html#polars.sum_horizontal

724
601
2

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
724
601

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?