Polarsのまわりのライブラリたち


こんにちは。2023年は Polars 流行りましたね。

Redditの「What was for you the biggest thing that happened in the Python ecosystem in 2023?」というスレッドでも Polars の名前が結構あがっているみたいです。

https://www.reddit.com/r/Python/comments/188fo2w/what_was_for_you_the_biggest_thing_that_happened/?share_id=37wlIKkGFkPO1ENwPJ4wk

とはいえ歴史が長い pandas に比べると、エコシステム的なところはまだ発展途上です。

**この記事では Polars に関連する周辺ライブラリとかを雑に紹介していきます**。2024年以降ももっと使いやすくなりそう＆盛り上がっていきそうだという匂いを感じ取ってもらえればと思います。

:::note
紹介するほとんどは2023年末の時点でアルファ版です。ドキュメントがないもの、まともに動きそうにないものもあります。

仕様もじゃんじゃん変わると思うので、使うときは直接ソースを参照ください。
:::

なお、基本的に全て Python の Polars に関する記述です。割と最近めのバージョンの Polars が必要になるものもある気がします。



# 1. polarIFy: if文をwhen.thenに変換
`pl.when` はフクザツな処理も `apply` を使わないで書ける優れものですが、処理がネストしたり複雑になってくると when...then...otherwise を書くのもややこしくなってきます[^apply]。

polarIFyは、**pythonの関数として書いたif文を、polars.Expr に変換してくれるもの**です。polar**IF**yと、IFだけ大文字になっているのが分かりやすくていいですね。

[^apply]: applyを使いたくない理由は、単に遅いからです。

### インストール
```
pip install polarify
```

### 使い方
`@polarify` デコレータを関数に付けるだけで、それを pl.Expr として使えます。簡単！


### コード例[^polarify_ex]

Pythonのif文を使った少し複雑な現実のユースケースの例として、オンラインショップでの割引計算を考えてみましょう。この例では、顧客が購入する商品の合計額に基づいて、異なる割引率が適用されます。また、特定の会員ステータスの顧客には追加の割引が適用されます。

- 商品の合計額が5000円未満の場合、割引は適用されません。
- 5000円以上10000円未満の場合、5%の割引が適用されます。
- 10000円以上の場合、10%の割引が適用されます。
- さらに、顧客がプレミアム会員の場合、上記割引に加えて追加5%の割引が適用されます。

ここで、`total_amount` は購入する商品の合計額、`is_premium_member` は顧客がプレミアム会員かどうかを示すブール値（True/False）です。この情報を基に割引額を計算するコードを書いてみます。

[^polarify_ex]: この例は100%ChatGPTに作ってもらったものです。まあ別に意味を持たない例なので、適当に読み流してください。


```python
from polarify import polarify

@polarify
def calculate_discounted_amount(total_amount: pl.Expr, is_premium_member: pl.Expr) -> pl.Expr:
    discount = 0

    # 商品の合計額に基づいた割引の適用
    if total_amount >= 10000:
        discount = 0.10  # 10%割引
    elif total_amount >= 5000:
        discount = 0.05  # 5%割引

    # プレミアム会員であれば追加割引
    if is_premium_member:
        discount = discount + 0.05

    final_amount = total_amount * (1 - discount)
    return final_amount


# 例
df = pl.DataFrame({
    "total_amount": [1000, 7000, 10000, 7000], 
    "is_premium_member": [True, False, False, True]
})

df.select(
    pl.col("total_amount"), 
    pl.col("is_premium_member"), 
    calculate_discounted_amount(pl.col("total_amount"), pl.col("is_premium_member")).alias('discounted')
)
```

**結果**
<img width="370" alt="polarify-result" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/665792/b47ceba2-0b79-e3a2-d420-7bf464780877.png">



### ポイント

- **できること**
    - multiple statements（複数のif文の組合せ）
    - Nested Statements（ネストしたif文）
    - 複数列に対する処理
    - 変換された関数を眺める
- **できないこと**
    - for / while などのループ処理
    - match .. case
    - セイウチ演算子
    - polarsに関係ない処理は無視される



### 変換された関数を眺める
確認用あるいはどういう風に変換されたか眺める用に、変換された関数を出力してみます。

```python
from polarify import transform_func_to_new_source

# polarifyによって変換された関数を出力
print(transform_func_to_new_source(calculate_discounted_amount))

################################################################
# 以下出力結果
################################################################

def calculate_discounted_amount_polarified(total_amount: pl.Expr, is_premium_member: pl.Expr) -> pl.Expr:
    import polars as pl
    return pl.when(total_amount >= 10000).then(pl.when(is_premium_member).then(total_amount * (1 - (0.1 + 0.05))).otherwise(total_amount * (1 - 0.1))).otherwise(pl.when(total_amount >= 5000).then(pl.when(is_premium_member).then(total_amount * (1 - (0.05 + 0.05))).otherwise(total_amount * (1 - 0.05))).otherwise(pl.when(is_premium_member).then(total_amount * (1 - (0 + 0.05))).otherwise(total_amount * (1 - 0))))

# 見にくいので、改行だけ施したもの
def calculate_discounted_amount_polarified(total_amount: pl.Expr, is_premium_member: pl.Expr) -> pl.Expr:
    import polars as pl
    return (
        pl.when(total_amount >= 10000)
        .then(
            pl.when(is_premium_member)
            .then(total_amount * (1 - (0.1 + 0.05)))
            .otherwise(total_amount * (1 - 0.1))
        ).otherwise(
            pl.when(total_amount >= 5000)
            .then(
                pl.when(is_premium_member)
                .then(total_amount * (1 - (0.05 + 0.05)))
                .otherwise(total_amount * (1 - 0.05))
            ).otherwise(
                pl.when(is_premium_member)
                .then(total_amount * (1 - (0 + 0.05)))
                .otherwise(total_amount * (1 - 0))
            )
        )
    )

```

### 関連リンク

https://github.com/quantco/polarify

https://tech.quantco.com/2023/08/25/polarify.html






# 2. Patito: データバリデーション
Patito を使うと**データフレームが満たすべき制約を記述・検証**することができます。pandas における Pandera に相当します[^pandera]。


[^pandera]: Panderaについてはこちらをぜひ！[【pandera】pandasでも型をしっかりつけたい！ #Python - Qiita](https://qiita.com/kamok0731/items/06f224d45e17e9c4aa85)


### インストール
```
pip install patito
```

### 基本的な使い方

簡単2ステップです。

1. pydantic 的に**クラスでデータモデルを定義**
     `変数:型` で型を宣言し、他に制約があれば `pt.Field()` で追加する
1. `validate()` で定義したデータモデルと合ってるか**検証**
     足りない列や余計な列があったらエラーとなる（pandera の strictモードに相当）

※ 条件として指定できる使い方は[こちら](https://github.com/JakobGM/patito#:~:text=Click%20to%20see%20a%20summary%20of%20dataframe%2Dcompatible%20type%20annotations.)に記載があります


### コード例
```python
from typing import Literal, Optional

import patito as pt
import polars as pl

# data modelの定義
class Product(pt.Model):
    product_id: int = pt.Field(unique=True)             # int型で値がユニークな必要がある
    temperature_zone: Literal["dry", "cold", "frozen"]  # とることができる値を指定
    is_for_sale: bool                                   # 型がboolであることを指定

# dataframeの作成    
df = pl.DataFrame({
    "product_id": [1, 1, 3],                      # 1が重複してるのでエラー
    "temperature_zone": ["dry", "dry", "oven"],   # ovenは候補に入ってないのでエラー
                                                  # is_for_sale列がないのでエラー
})

# validationの実行
Product.validate(df)

# validationの結果：
# 3 validation errors for Product
# is_for_sale
#   Missing column (type=type_error.missingcolumns)
# product_id
#   2 rows with duplicated values. (type=value_error.rowvalue)
# temperature_zone
#   Rows with invalid values: {'oven'}. (type=value_error.rowvalue)
```

### 複数行・複数列が関係する条件

```python
class MyDataFrameModel(pt.Model):
    # f1列を足すと100になるという条件
    f1: float = pt.Field(constraints=pt.field.sum() == 100.0)
    # f2列が0.5以上かつ、f1+f2が2.5以下。という条件
    f2: float = pt.Field(constraints=[
        (pt.field >= 0.5) & (pl.col('f1') + pl.col('f2') < 2.5),
    ])
    f3: float
```

- `pt.Fielt` の `constraints` で、Expression を使って柔軟に制約が書ける
    - 例えば group_by を使うものとかも頭を使わずに同じ感じに書ける
    - `constraints=pl.col('uid').over(pl.col('date')).size().max() <= 1` （たぶん）
- `pt.field` は自身の列を示す
    - つまり、f2 のところで `pt.field` を使えば f2 列を指すし、f1 のところで使っていれば f1 列を指す
    - 他の列を参照する `pt.col` と `pl.col` は単なるエイリアスなので、どちらを使ってもOK
- どこの列でも `constraints` を定義することが可能
    - たとえば、上の条件を f3 のところに書いても問題ないのはちょっと気持ち悪い
- `constraints` にはリストを渡せるが、OR条件で、どれかが成り立っていればパスされることになる
    - これは少し間違えそうなので、単一の `pl.Expr` を渡すのが分かりやすいと思う
- 複数条件があるときにそれぞれに名前を付ける、みたいなことがしたいけど、それは今のところ対応してなさそう


### ポイント
- pandera の `strict=False` に、つまり他の余分な列があっても問題ないことにしたい
    - → これでOK: `Product.validate(df.select(Product.columns))`
- その他機能
    - テスト用のデータを生成したり
    - pt.Model に列を足していったりする処理を書けたり
    - DuckDBに関しても色々機能があったりする
    - （詳細はドキュメントをご覧ください）
- ちなみにpatitoはスペイン語で子ガモとかって意味らしいです。本当かは知らん
- Polars 本体にも hypothesis test に関連する機能が追加されたり[^hy]、`pandera` が Polars のサポートを検討していたりするので[^pandera_poalrs]、どうなるかはよく分からないポジションな気もする

[^hy]: https://pola-rs.github.io/polars/py-polars/html/reference/testing.html

[^pandera_poalrs]: https://github.com/unionai-oss/pandera/issues/1064

### 関連リンク

https://patito.readthedocs.io/en/latest/

https://github.com/JakobGM/patito



# 3. polars-ds: DataScienceの便利ツールキット

大きく分けて数値、文字列、統計の３つに関して、色んな機能があります。


### インストール
```
pip install polars polars_ds
```

### ① num_ext：数値に関する処理

※各関数の説明および分類はLLMにやらせたものであり、適切でない可能性があります

**数学ユーティリティ**
- frac - 小数部分を返す
- max_abs - 絶対値の最大値を計算
- n_bins - n個のビンに分ける処理
- gcd -最大公約数を計算
- lcm - 最小公倍数を計算
- binarize - 数値がある条件を満たすかどうかで二値化 
- trapz - 台形公式による積分

**統計関数**
- std_err - 標準誤差を計算
- std_over_range - 範囲に対する標準偏差の比率を計算
- rms - 二乗平均平方根を計算
- harmonic_mean - 調和平均を計算
- geometric_mean - 幾何平均を計算
- c_o_v - 変動係数を計算
- range_over_mean - 平均値に対する範囲の比率を計算
- z_normalize - zスコア正規化を適用
- min_max_normalize - 最小最大正規化を適用
- count_max - 最大値の出現回数を数える
- count_min - 最小値の出現回数を数える

**二値分類評価指標**
- roc_auc - ROC-AUCを計算
- binary_metrics_combo - 適合率、再現率、F1スコア、平均適合率、ROC-AUCを計算

**評価指標** 
- r2 - 決定係数R2を計算
- adjusted_r2 - 調整済み決定係数を計算

**損失関数**
- hubor_loss - Huber lossを計算
- l1_loss - L1 lossを計算
- l2_loss - L2 loss(MSE)を計算
- msle
- chebyshev_loss
- l_inf_loss
- mape
- smape
- logloss
- bce: binary cross entropy

**リスト操作**
- list_arg_max - リストの最大値のインデックスを取得
- list_jaccard - リストに対するジャッカード指数による類似度を計算

**情報理論**
- cond_entropy - 条件付きエントロピーを計算

**類似度**
- jaccard - ジャッカード係数による類似度

**線形代数**
- lstsq - 線形最小二乗法
- rfft - 実数高速フーリエ変換



**コード例**
※特に意味のない処理を書いてるところもあります


```python
import polars_ds

# データの読み込み (palmer-penguinsのデータを利用)
df = pl.read_csv('https://raw.githubusercontent.com/mcnakhaee/palmerpenguins/master/palmerpenguins/data/penguins.csv', null_values='NA')

# 列に対する処理
df.select(
    pl.col('body_mass_g'),
    pl.col("body_mass_g").num_ext.z_normalize().alias('z_norm'),
    pl.col("body_mass_g").num_ext.min_max_normalize().alias('minmax_norm'),
    pl.col("body_mass_g").num_ext.n_bins(10).alias('bins'),
)

# 集計処理
df.group_by('species').agg(
    pl.col("bill_length_mm").num_ext.std_err().alias('std'),
    pl.col("bill_length_mm").num_ext.mape(pl.col("bill_depth_mm")).alias('mape'),
    pl.col("bill_length_mm").num_ext.t_2samp(pl.col("bill_depth_mm")).alias('t'),
)

# 回帰
df.filter(pl.col("body_mass_g").is_not_null()).select(
    pl.col("body_mass_g").num_ext.lstsq(
        pl.col("bill_length_mm"), pl.col("bill_depth_mm"), add_bias=True
    )
)
```



### ② str_ext: 文字に関する処理
※各関数の説明および分類はLLMにやらせたものであり、適切でない可能性があります

**文字列解析**
- is_stopword() - ストップワード判定
- extract_numbers() - 数字抽出
- line_count() - 行数カウント
- infer_infreq() - 低頻度カテゴリー推測
- merge_infreq() - 低頻度文字列のマージ
- tokenize() - トークン化
- freq_removal() - 頻度に基づく語の削除
- snowball() - ステミング

**距離・類似度**
- str_jaccard() - ジャカード係数
- sorensen_dice() - ソレンセン・ダイス係数
- overlap_coeff() - 重複係数
- levenshtein() - レーベンシュタイン距離 
- levenshtein_within() - レーベンシュタイン距離による距離閾値チェック
- d_levenshtein() - ダーマー・レーベンシュタイン距離
- osa() - 最適文字列アライメント距離
- jaro() - ジャロー類似度
- jw() - ジャロー=ウィンクラー類似度
- hamming() - ハミング距離 

**文字列マッチング**
- ac_match() - Aho-Corasickアルゴリズムでのマッチング
- ac_replace() - Aho-Corasickアルゴリズムでの置換


**コード例**
```python
import polars_ds

# データの読み込み (palmer-penguinsのデータを利用)
df = pl.read_csv('https://raw.githubusercontent.com/mcnakhaee/palmerpenguins/master/palmerpenguins/data/penguins.csv', null_values='NA')

df.with_columns(
    pl.col("species").str_ext.str_jaccard("some_string").alias('jaccard'),
    pl.col("species").str_ext.levenshtein(pl.col('sex')).alias('levenshtein')
)
```


### ③ stat_ext: 確率・統計に関する処理

※説明はLLMに書かせたものであり、適切でない可能性があります

- ttest_ind - 二標本t検定を実行（スチューデントのt検定またはウェルチのt検定）
- ttest_1samp - 一標本t検定を実行
- normal_test - 歪度と尖度に基づく正規性検定を実行（D'Agostino and Pearson's test）
- ks_stats - 二標本コルモゴロフ・スミルノフ検定統計を計算
- ks_binary_classif - 特徴量と二値目的変数間のKS検定統計を計算（target=0とtarget=1での違いがあるかを検定）
- rand_int - ランダム整数を生成
- sample_uniform - 一様分布からサンプリング
- sample_normal - 正規分布からサンプリング
- rand_str - ランダム文字列を生成



### 関連リンク
https://github.com/abstractqqq/polars_ds_extension

https://pypi.org/project/polars-ds/



# その他雑多に紹介
## 4. functime: 時系列の前処理＋予測
時系列データのデトレンドや季節性除去といった前処理から、time-series splitting、予測モデル、バックテスト、metricの計算など豊富に揃っている[^llm]。

単一系列ではなく、複数系列（所謂パネルデータ）の予測に対応しているのが嬉しい。

[^llm]: 変わり種としてLLMによる結果の解釈的な機能もあるけど、それは現段階だとあまり使い勝手良くなさそう。 https://docs.functime.ai/notebooks/llm/

https://github.com/neocortexdb/functime

https://docs.functime.ai/

（私は調べようと思ってて機能多すぎて挫折しました。まだこのカレンダーいくつか空いてるので、興味ある人いたらぜひ！）




## 5. Shirokumas: 各種エンコーダー

One-Hot Encoding や Target Encoding など、各種Encoderを Polars で実装したもの。

作者様による日本語の記事があるので、そちらをご覧ください。

https://blog.amedama.jp/entry/shirokumas

https://github.com/momijiame/shirokumas

https://shirokumas.readthedocs.io/en/latest/



## 6. GeoPolars: 地理データの処理

pandasのgeopandasや、dplyrだとsfとかに相当するものだと思うが、正直ドキュメントやソースコード読んでも使い方がイマイチ分からず……

https://geopolars.org/latest/

https://github.com/geopolars/geopolars


## 7. polars-dsds: DataScienceのダークサイド

sklearn の preprocessing とか model_selection とか feature_selection とか impute とかと pipeline あたりに相当するものだと思う。が、まだpre-alpha で発展途上の段階だしドキュメントもないので詳細は調べられてないです。

https://github.com/abstractqqq/dsds

https://medium.com/@tq9695/the-dark-side-of-data-science-f4957195e17f



## 8. Polugins: pluginのimportをラクに
だと思います。たぶん。

https://github.com/StefanBRas/polugins

## 9. poranges: joinを柔軟に

期間が被っていたら `join` するだとか、Interval operation に関するもの。たぶん。

https://github.com/endrebak/poranges


## 10. Polars-business: ビジネスカレンダーの処理
ビジネスのカレンダーに関する処理ができる。平日で5日後とか、10営業日以内、みたいなExprが作れる。

https://github.com/MarcoGorelli/polars-business


## 11. ipyvuetable: jupyter上でのテーブルの可視化・編集
https://github.com/gab23r/ipyvuetable



# 仕組み

全てがそうではないですが、APIの拡張を使っているものが多いです。
大きく Python だけで完結するものと、Rust も使うものがあります。

## a. Pythonだけで作る

DataFrame や Expression に対して、APIを拡張できます。……と言ってもイメージしにくいと思いますが、何ができるかと言うと、

```python
df.with_columns(pl.col('name').my_extention.hogehoge(pl.col('address'))
```

みたいな形で使えるように、自作の処理を登録できます。

作成方法はドキュメントをご覧ください（そんなに難しくないよ）。

https://pola-rs.github.io/polars/py-polars/html/reference/api.html



## b. Rustも使う

Rust分からんし、これ調べる時間ないなあ…と思っていたら、なんと `12/08` のアドベントカレンダーでuchiiiさんが書いてくれました。神！

https://zenn.dev/uchiiii/articles/2466719bfe2ec5

- pluginに関する公式のドキュメント
    - https://pola-rs.github.io/polars/user-guide/expressions/plugins/
    - https://pola-rs.github.io/polars/py-polars/html/reference/expressions/index.html


# 終わりに

すごくどうでもいいのですが、PoluginだったりpolarIFyだったりShirokumasだったり、名前がイケてるものが多いのが推しポイントです。