テーブルデータ処理に悩むあなたに朗報!Polarsの使い方を徹底解説 その1:基本編

2023.02.15

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんちには。

データアナリティクス事業本部 機械学習チームの中村です。

本記事では、世間でも話題となっているPolarsについて基本的な使い方を抑えていきたいと思います。

私自身「データサイエンス100本ノック」をPolarsで一通り実施しましたので、それを元に実践に必要な使い方とノウハウをご紹介します。

本記事でPolarsの使い方とノウハウを習得し、実践的なテクニックを身につけて頂ければと思います。

Polarsとは

pandasのようにデータフレーム形式を扱うライブラリで、高速で遅延評価可能などの特徴があります。

その他以下のような特徴があります。

  • indexがない、マルチカラムもない
  • カラム名の重複不可(いい制約という意味で)
  • pl.Exprという計算式で記述でき、実体化が不要
  • 複雑な処理もワンライナーで書ける(df_tmpなど一時的な実体化が不要)
  • 処理を文字列リテラルではなく関数で記述できるので、IDEの補助を受けられやすい
  • 遅延評価も可能
  • SQLクエリ風のメソッド名(SELECT、JOIN、OVERなど)
  • 標準の数学関数が多めでNumPyのお世話にならなくて済む
  • 処理が高速(らしい。本記事では未検証)

APIリファレンスは以下を参照ください。

使用環境

Google Colab環境でやっていきます。特にスペックは限定しません。

セットアップ

pipでインストールするのみで使用できるようになります。

!pip install polars

執筆時点ではpolars-0.16.4がインストールされました。

インポートはplという略称が使われているようです。

import polars as pl

ここからサンプルとしてデータサイエンス100本ノックのcsvデータを使用します。

※本記事は、「データサイエンティスト協会スキル定義委員」の「データサイエンス100本ノック(構造化データ加工編)」を利用しています※

以降は、以下のファイルが配置されている前提として進めます。動かしてみたい方はダウンロードされてください。

./category.csv
./customer.csv
./geocode.csv
./product.csv
./receipt.csv
./store.csv

ファイルIO

ファイル読込: pl.read_csv

普通にpandasと同様にpl.read_csvが使用できます。

df_receipt = pl.read_csv("receipt.csv")

取得結果はpl.DataFrameオブジェクトとなります。

dtypesを指定して読み込むことも可能です。

dtypes = {
    'store_cd': str
    # ...
}
df_receipt = pl.read_csv("receipt.csv", dtypes=dtypes)

その他、指定可能な型は以下に記載があります。

以降のコードを動かす際は、以下のようにread_csvしたと仮定して進めます。

dtypes = {
    'customer_id': str,
    'gender_cd': str,
    'postal_cd': str,
    'application_store_cd': str,
    'status_cd': str,
    'category_major_cd': str,
    'category_medium_cd': str,
    'category_small_cd': str,
    'product_cd': str,
    'store_cd': str,
    'prefecture_cd': str,
    'tel_no': str,
    'postal_cd': str,
    'street': str,
    'application_date': str,
    'birth_day': str
}
df_category = pl.read_csv("category.csv", dtypes=dtypes)
df_customer = pl.read_csv("customer.csv", dtypes=dtypes)
df_geocode  = pl.read_csv("geocode.csv", dtypes=dtypes)
df_product  = pl.read_csv("product.csv", dtypes=dtypes)
df_receipt  = pl.read_csv("receipt.csv", dtypes=dtypes)
df_store    = pl.read_csv("store.csv", dtypes=dtypes)

出力時の見え方

出力すると以下のような表示となります。(以下、100本ノックのP-001に相当)

df_receipt.head(10)

shapeやデータ型も表示されるのがpandasと異なる点です。

またここからもindexが存在しないことが分かります。

ファイル書込: pl.write_csv

書き込みはpl.write_csvで行います。

indexが存在しないため引数はデフォルトのままでOKです。

df_receipt.write_csv("./output.csv")

デフォルトは、has_header=True, sep=","という設定となっています。

注意点としては文字コードを指定することができません。

文字コードを指定したい場合は、一旦pandasに変換する必要があります。

(その場合、もちろんindexも気にかける必要があります)

# UTF-8以外で出力したい場合
df_receipt.to_pandas().to_csv(
    './output_cp932.csv',
    encoding='CP932', index=False
)

カラム処理

カラム選択: pl.DataFrame.select

カラムの選択はpl.DataFrame.selectが標準的な方法です。

(以下、100本ノックのP-002に相当)

df_receipt.select(
    ["sales_ymd", "customer_id", "product_cd", "amount"]
).head(10)

pandasのような以下の記法でも抽出可能ですが、後述するpl.col("カラム名")に基づく記述ができないため、基本的にselectを使います。

df_receipt["sales_ymd", "customer_id", "product_cd", "amount"].head(10)

以降、様々な操作はpl.col("カラム名")に基づく記述で実現することができますので、覚えておく必要があります。

pl.col("カラム名")の補足とpl.Expr

pl.col("カラム名")が返すオブジェクトはpl.Exprとなっており、さまざまな計算式を定義できます。

この計算式ベースで様々な記述を書くことにより、実体化させずに複雑な処理を、ワンライナーで記述できるようになります。

以降は、pl.DataFrameに基づく関数に加え、pl.Exprに基づく関数も数多く登場します。

どちらにも同じ関数がある場合があるので、そこは混同しないよう注意が必要です。

pl.Exprによる演算結果の格納

pl.Exprを使う簡単な例をまずは見てみましょう。

例えば以下のようなカラム同士の掛け算をするような処理をワンライナーで簡単に記述できます。

df_receipt.select([
    "sales_ymd", pl.col("amount") * pl.col("quantity")
]).head(10)

平均などの計算や比較も簡単に記述することが可能です。

df_receipt.select([
    "sales_ymd", pl.col("amount") - pl.col("amount").mean()
])

pandasの場合は、一度列を作った後に計算後の列を追加する必要があります。

# 同様の処理をpandasで実装した例
df_tmp = df_receipt[["sales_ymd", "amount"]]
df_tmp["amount"] = df_receipt["amount"] - df_receipt["amount"].mean()

(実際はpandasにもassignというメソッドがあり、列の追加が一行で書くことが可能ですが、一度実体化してしまう点は代わりがないです)

Polarsはさらに複雑な処理であっても、このような形での記述が可能です。

より複雑な例は後半の「例題」でで見ていきます。

カラムのリネーム: pl.Expr.alias

以下のように、pl.Expr.aliasを記述すればリネームすることができます。

df_receipt.select([
    pl.col("sales_ymd").alias("sales_date")
    , "customer_id", "product_cd", "amount"
]).head(10)

pandasの場合と比較するとカラムのリネームは冗長さがおさえられていることが分かります。

df_receipt[['sales_ymd', 'customer_id', 'product_cd', 'amount']]\
    .rename(columns={'sales_ymd': 'sales_date'}).head(10)

pl.Expr.aliasが必要なシーンの補足

そもそもPolarsでは、カラム名が重複することが許されないという制約があります。

pandasではカラムの重複はできていたのですが、同名のカラムの存在は、問題の温床にもなりやすいので、重複できないことも良い点として働くかなと思いました。

また重複が禁止されているため、aliasを記述するシーンは結構多くなります。

例えば、何かしらの演算結果を新しい列に格納する場合にaliasを書かない場合、元になったカラム名を自動的に引き継ぎますので、その際にカラムの重複が発生しやすくなります。

# カラムが重複する例
df_receipt.select([
    "sales_ymd", pl.col("amount")
    , pl.col("amount") * 0.9
]).head(10)

エラーメッセージは以下のようになります。(最初のうちはこのエラーに良く遭遇します)

DuplicateError: Column with name: 'amount' has more than one occurrences

これを回避するために、aliasが必要になります。

# 重複を解決する例
df_receipt.select([
    "sales_ymd", pl.col("amount")
    , (pl.col("amount") * 0.9).alias("amount_discount")
]).head(10)

どのように自動でカラム名が引き継がれるか

ちなみにもう少し詳しく見ると、元になった演算の先頭のカラムが引き継がれます。

そのため以下の例ではエラーにはなりません。

df_receipt.select([
    "sales_ymd", pl.col("amount"),  0.9 * pl.col("amount")
]).head(10)

定数が先頭にきているので、literalというカラム名が引き継がれて、重複しなくなっていることが分かります。

ただし自動付与に任せるのは重複エラーとなるリスクがあることに加え、可読性の面からも、特に複雑な処理を書く場合はaliasをうまく使った方が良いと考えられます。

カラムのリネーム2: pl.DataFrame.rename

alias以外のリネーム方法として、Polarsでもpandasと同じpl.DataFrame.renameが使用可能です。

df_receipt.select([
    "sales_ymd", "customer_id", "product_cd", "amount"
]).rename({"sales_ymd": "sales_date"}).head(10)

単にリネームしたいだけである場合はこちらも使用できます。

カラムの追加: pl.DataFrame.with_columns

select時に計算結果を格納することができますが、pl.DataFrame.with_columnsを使ってDataFrameに列を追加することも可能です。

df_receipt.select([
    "sales_ymd", pl.col("amount")
]).with_columns(
    (pl.col("amount") * 0.9).alias("amount_discount")
).head(10)

pandasでは、assignという処理が該当します。

# 同様の処理をpandasで実装した例
df_receipt[["sales_ymd", "amount"]]\
    .assign(amount_discount=df_tmp["amount"]*0.9)

定数をカラムに追加: pl.lit

ある定数をデータに追加したいときなどは、pl.litを使って以下のように書けます。

df_receipt.select([
    "sales_ymd", "amount", pl.lit(0)
]).head(10)

すべてのカラムを選択: pl.all

すべてのカラムをselectする際に使用します。

df_receipt.select(
    pl.all()
)

指定カラム以外を選択: pl.exclude

指定したカラム以外をselectする際に使用します。

df_receipt.select(
    pl.exclude(["sales_ymd", "receipt_no"])
)

カラム名にprefix付与: pl.Expr.prefix

以下でprefixを付与できます。

df_receipt.select([
    pl.col(["quantity", "amount"]).prefix("sample_")
])

カラム名にsuffix付与: pl.Expr.suffix

以下でsuffixを付与できます。

df_receipt.select([
    pl.col(["quantity", "amount"]).suffix("_sample")
])

カラム名選択に正規表現を使用

列名には正規表現を用いた検出がselectが可能です。

ただし、先頭^と末尾$を表現しておく必要があります。

以下がその例です。

df_receipt.select(
    pl.col("^receipt_.*$")
)

カラム名選択にデータ型を使用

特定のデータ型を指定してselectすることも可能です。

以下がその例となります。

df_receipt.select(
    pl.col(pl.Int64)
)

カラムに行番号を付与: pl.DataFrame.with_row_count("カラム名")

Polarsにはindexがありませんが、行番号に応じた処理を実施したい場合などに使用します。

以下で行番号が付与できます。

df_receipt.select(
    "sales_ymd"
).with_row_count("row_number")

結果の格納

ここまで既に見てきたように、Polarsは演算結果を新しい列として格納する際の記述方法に優れています。

ここでは普通の四則演算以外の結果格納方法について見ていきます。

条件分岐に応じた結果の格納: pl.when().then().otherwise()

pl.when().then().otherwise()は条件に応じて異なる値を格納したい場合に使用します。

df_receipt.select([
    "sales_ymd", "amount"
    , pl.when(pl.col("amount")>100).then(1)\
        .otherwise(0).alias("sales_flg")
]).head(10)

なお、条件分岐はUDF(ユーザ定義関数)をlambda式を使って作成し、apply(この場合pl.Expr.apply)する形でも可能です。

df_receipt.select([
    "sales_ymd", "amount"
    , pl.col("amount")\
        .apply(lambda x: 1 if x>100 else 0)\
        .alias("sales_flg")
]).head(10)

ただしapplyを使用すると、Rustで処理されずPythonで処理が実行されるため実行がおそくなるようです。

詳細は下記の公式ページも参照ください。

条件分岐に応じた結果の格納(複数条件)

pl.when().then().otherwise()は分岐が3つ以上になる場合にも以下のように使用できます。

県ごとに条件分岐し、数値を割り当てるコード例です。(100本ノックのP-054に相当)

df_customer.select([
    "customer_id", "address"
    , pl.when(pl.col("address").str.starts_with("埼玉県")).then(11)\
        .when(pl.col("address").str.starts_with("千葉県")).then(12)\
        .when(pl.col("address").str.starts_with("東京都")).then(13)\
        .when(pl.col("address").str.starts_with("神奈川県")).then(14)\
        .alias("prefecture_cd")
]).head(10)

使い方に慣れが必要そうですが、この方法が高速に処理ができます。

またこの場合も、applyで処理が可能ですが、前述のとおり速度面では不利になります。

分岐結果自体の格納

条件分岐結果をそのまま新しいカラムに格納することもできます。

その場合、データ型はbool型となって格納されます。

df_receipt.filter(
    pl.col("customer_id").str.starts_with("Z").is_not()
).groupby("customer_id").agg(
    pl.col("amount").sum()
).select([
    "customer_id", "amount"
    , (pl.col("amount")>20).alias("sales_flg") # 判定結果を格納
]).sort("customer_id").head(10)

文字列のスライス結果を格納: pl.Expr.str.slice

str.sliceを使って文字列をスライスすることができます。

df_customer.select([
    "customer_id"
    , pl.col("postal_cd").str.slice(0,3)
])

文字列同士の結合: pl.concat_str

pl.concat_strで文字列のカラム同士を結合することが可能です。

区切り文字を指定するには、sep="_"などのようにします。

以下は複合問題ですが、年齢を年代に書き換え、性別コードと年代を連結した新しい列を作成する処理です。(100本ノックのP-057に相当)

# P-057: 解答例
df_customer.select([
    "customer_id", "birth_day", "gender_cd"
    , pl.when(pl.col("age") >= 60)\
        .then(60)\
        .otherwise(
            ((pl.col("age")/10).floor()*10).cast(pl.Int32)
        ).alias("age")
]).select([
    "customer_id", "birth_day"
    , pl.concat_str([pl.col("gender_cd"), pl.col("age")], sep="_")
]).head(10)

その他の主な文字列操作

その他の主な文字列操作を含め下表にまとめます。

名前 内容
pl.concat_str 文字列同士の結合
pl.Expr.str.slice 文字列のスライス
pl.Expr.str.lengths 文字列バイト長を求める
pl.Expr.str.n_chars 文字列長さを求める
pl.Expr.str.extract 正規表現で一致部分を抽出
pl.Expr.str.replace 文字列の置換

ソート: pl.DataFrame.sort

単一キーのソート

ソートするカラムを指定するのみでOKです。(以下、100本ノックのP-017に相当)

df_customer.sort("birth_day").head(10)

降順にするにはreverse=Trueを指定します。(以下、100本ノックのP-018に相当)

df_customer.sort(pl.col("birth_day"), reverse=True).head(10)

デフォルトが昇順なのはpandasと同様ですね。

また、欠損値などがソート後に先頭に来るのをさけるためには、nulls_last=Trueを引数にしていすれば最後に持ってこれます。

df_customer.sort("birth_day", nulls_last=True).head(10)

複数カラムのソート

普通に複数のキーを指定するだけでOKです。

reverseをリストで与えれば、それぞれ独立して昇順・降順を設定できるところがポイントとなります。

df_receipt.select([
    "sales_ymd", "amount", "customer_id"
]).sort(
    ["amount", "customer_id"], reverse=[True, False]
).head(10)

ソートキーがリストでない場合は、同じ順序が全てのソートキーに適用されます。

キャスト: pl.Expr.cast("データ型")

キャストはpl.Expr.cast("データ型")で実行できます。

以下は数値を文字列にキャストする例です。

df_receipt.select([
    pl.col("sales_ymd").cast(pl.Utf8)
    , "receipt_no", "receipt_sub_no"
]).head(10)

cast(pl.Utf8)cast(str)でも代用可能です。

条件による行抽出: pl.DataFrame.filter

filterにより抽出が可能です。

条件にもpl.col("カラム名")(要するにpl.Expr)と比較演算子で条件を記述できます。

基本的な比較演算子

以下のように、比較演算子そのものが記述できる点がポイントです。

(以下は、100本ノックのP-005相当)

df_receipt.select([
    "sales_ymd", "customer_id", "product_cd", "amount"
]).filter(
    (pl.col("customer_id") == "CS018205000001")
    & (pl.col("amount") >= 1000)
)

各条件は()で囲う必要がありandorは使用できないため、そこは注意が必要です。

pandasのquery記法の場合は、一つの文字列内にすべて押し込める必要があるため、演算子が文字列となったり、クォートの扱いや空白を含むカラムが複雑になっていました。

# 同様の処理をpandasで実装した例
df_receipt[['sales_ymd', 'customer_id', 'product_cd', 'amount']] \
    .query('customer_id == "CS018205000001" & amount >= 1000')

その他、比較演算子を連鎖させる記述はできない点は注意が必要です。(Python標準の条件文であれば可能)

範囲指定: pl.Expr.is_between

範囲指定は複数の条件文を書くことでも可能ですが、より特化したものとしてはpl.Expr.is_betweenを使用することができます。(以下は、100本ノックのP-007相当)

df_receipt.select([
    "sales_ymd", "customer_id", "product_cd", "quantity", "amount"
]).filter(
    (pl.col("customer_id") == "CS018205000001")
    & (pl.col("amount").is_between(1000, 2000)) # ココが範囲指定
)

引数のclosed'both', 'left', 'right', 'none'のいずれかが指定可能で、開区間・閉区間を設定できます。

デフォルトはbothで、双方の境界値を含む設定(閉区間)となっているようです。

否定条件: pl.Expr.is_not

否定条件は~でも可能ですが、pl.Expr.is_notを使用することでも記述が可能です。

df_receipt.select([
    "sales_ymd", "customer_id", "product_cd", "quantity", "amount"
]).filter(
    (pl.col("customer_id") == "CS018205000001")
    & (pl.col("amount").is_between(1000, 2000).is_not()) # ココが否定条件
)

含まれるかの判定: pl.Expr.is_in

含まれるかの判定はpl.Expr.is_inで記述可能です。

df_receipt.filter(
    pl.col("quantity").is_in([4,5,6]) # ココが含まれるかの判定
)

文字列の条件: pl.Expr.str.メソッド名

文字列に対する条件はpl.Expr.str.メソッド名で一通り記述できるようになっています。

以下は店舗コード"store_cd"が"S14"で始まるものを抽出するコードです。(100本ノックのP-010相当)

df_store.filter(
    pl.col("store_cd").str.starts_with("S14")
).head(10)

pandasでは、engine='python'などの記述が必要であったため、少し複雑でした。

# pandasの解答例
df_store.query("store_cd.str.startswith('S14')", engine='python')\
    .head(10)

またpandasでは、文字列内でstartswith等を記述するためIDEの補助が受けにくい面がありました。 Polarsではこれがメソッドとなるため、IDEの補助を受けることが可能な点も便利です。

主に使用可能な文字列条件のメソッドは以下の通りです。

メソッド 処理
str.starts_with 指定文字列が先頭にあるか
str.ends_with 指定文字列が終端にあるか
str.contains 指定文字列を含むかどうか(正規表現に対応)

str.containsはデフォルトで正規表現対応のため、純粋な文字列条件とするためにはliteral=Trueを引数に指定する必要があります。

(pandasはcontainsで正規表現を使いたい場合は、regex=Trueの指定が必要でした)

集約処理(グループ化なし)

ここではグループ化しない状態での集約処理を見ていきます。

平均: pl.Expr.mean

単純にpl.col("カラム名")からpl.Expr.meanなどを呼び出すことで平均等を計算できます。

また加えて、演算結果に対してmeanをすることも可能です。

以下は、"unit_price"と"unit_cost"から利益をもとめ、"unit_price"で割って利益率を出し、平均をとる例です。(100本ノックのP-064相当)

df_product.drop_nulls().select([
    ((pl.col("unit_price") - pl.col("unit_cost"))\
        /pl.col("unit_price")).mean()
])

また、一応drop_nullsでNULLを除外していますが、meanでは自動的に除外した集計が行われます。

pandasでは、一度DataFrameを複製した上で、演算結果を格納したうえで平均を計算する必要がありました。

# pandasの解答例
df_tmp = df_product.copy()
df_tmp['unit_profit_rate'] = \
    (df_tmp['unit_price'] - df_tmp['unit_cost']) / df_tmp['unit_price']
df_tmp['unit_profit_rate'].mean(skipna=True)

ランク付け: pl.Expr.rank

ランク付けもrankで記述できます。以下は"amount"でランク付けをする例です。(100本ノックのP-019相当)

df_receipt.select([
    "customer_id", "amount"
    , pl.col("amount").rank(method='min', reverse=True).alias("ranking")
]).sort('ranking').head(10)

pandasでは複数のDataFrameを作ってconcatする必要がありました。

# pandasの解答例
df_tmp = pd.concat([df_receipt[['customer_id', 'amount']] 
    ,df_receipt['amount'].rank(method='min', ascending=False)], axis=1)
df_tmp.columns = ['customer_id', 'amount', 'ranking']
df_tmp.sort_values('ranking', ascending=True).head(10)

なお、rankmethodには{'average', 'min', 'max', 'dense', 'ordinal', 'random'}が指定可能です。

詳しくは以下も参照ください。

ウィンドウ関数: pl.Expr.over

SQLのウィンドウ関数のようなpl.Expr.overが使用できます。以下は"customer_id"ごとに平均を計算して、それを新しい列として追加するコードです。

df_receipt.select([
    pl.all()
    , pl.col("amount")\
        .mean().over("customer_id").alias("amount_mean_by_customer_id")
])

overには複数のカラムも指定可能です。

df_receipt.select([
    pl.all()
    , pl.col("amount")\
        .mean().over(["customer_id", "sales_ymd"]).alias("amount_mean_by_customer_id")
])

グループ化: pl.DataFrame.groupby

グループ化した後の集約はpandasと同様、pl.DataFrame.groupby -> pl.GroupBy.aggという形で記述します。ここでもpl.col("カラム名)が生きます。

注意点としては、groupbyした結果の順序が実行の都度変化しますので、固定したい場合はgroupbymaintain_order=Trueを引数に設定するか、グループ化したキーでpl.DataFrame.sortをする必要があります。

集約関数の一例 合計: pl.Expr.sum

以下は"store_cd"ごとにsumを使用する例です。(100本ノックのP-023相当)

df_receipt.groupby("store_cd").agg([
    pl.col("amount").sum()
    , pl.col("quantity").sum()
]).sort("store_cd")

このようにselectと同じような形でaggの処理が記述できる点がポイントです。 集約関数が文字列ではなく関数で記述できるため、ここでもIDEの補助が受けられます。

pandasの場合は、groupbyしたキーがindexとなってしまうため、カラムにするためにはreset_indexが必要でした。Polarsはindexが無いため、こういった操作が不要となります。集約関数も文字列である必要があります。

以下はpandasの例です。

# pandasの解答例
df_receipt.groupby('store_cd').agg({
    'amount':'sum'
    , 'quantity':'sum'
}).reset_index()

集約関数の一例 最大・最小: pl.Expr.min, pl.Expr.max

同じカラムに対して異なる集約値を求めると、カラム名が同じになります。Polarsではエラーとなるため、aliasでカラム名をリネームする必要があります。

以下は同じカラムについて最大と最小を求める例です。(100本ノックのP-026に相当)

df_receipt.groupby('customer_id').agg([
    pl.col("sales_ymd").min().alias("sales_ymd_min")
    , pl.col("sales_ymd").max().alias("sales_ymd_max")
]).filter(
    pl.col('sales_ymd_min') != pl.col('sales_ymd_max')
).sort('customer_id').head(10)

pandasでは同じカラムに対して集約関数で処理すると、マルチカラムとなってしまうためそのままでは扱いが複雑でした。一旦以下のようにリネームして扱うなどが必要です。Polarsではマルチカラムがないため、この辺りを気にする必要がなくなります。

# pandasの解答例
df_tmp = df_receipt.groupby('customer_id'). \
    agg({'sales_ymd': ['max','min']}).reset_index() # この時点ではマルチカラム
df_tmp.columns = \
    ["_".join(pair) for pair in df_tmp.columns] # マルチカラムを連結してフラットに
df_tmp.query('sales_ymd_max != sales_ymd_min').head(10)

集約関数の一例 最頻値: pl.Expr.mode

最頻値もメソッドが準備されています。最頻値は複数ある可能性があるため、戻り値がリストとなります。そのため、pl.DataFrame.explodeで複数レコードに展開する必要があります。

以下は、"store_cd"ごとに"product_cd"の最頻値を求める例です。(100本ノックのP-029相当)

df_receipt.groupby("store_cd").agg([
    pl.col("product_cd").mode()
]).select([
    "store_cd"
    , pl.col("product_cd")
]).explode(pl.col("product_cd"))

最頻値の先頭のみで良い場合は、arr.first()という形でリストの先頭要素で結果を取得することも可能です。

df_receipt.groupby("store_cd").agg([
    pl.col("product_cd").mode()
]).select([
    "store_cd"
    , pl.col("product_cd").arr.first()
])

pandasでは、groupbyのメソッドとしてmodeがないため、lambda式でSeriesのmodeを使います。 またgroupby -> aggではなくgroupby -> カラム選択 -> applyとなり少し込み入った記述が必要となります。

# pandasの解答例
df_receipt.groupby('store_cd').product_cd. \
    apply(lambda x: x.mode()).reset_index()

グループ化と条件抽出

たとえば「平均以上」など他の演算結果を使用した条件抽出を、一時的な変数を使用せずに記述することも可能です。

以下は、"customer_id"ごとに合計を計算し、合計値の平均以上のものを抽出するコードです。(100本ノックのP-035相当)

# P-035: 解答例
df_receipt.filter(
    pl.col("customer_id").str.starts_with("Z").is_not()
).groupby("customer_id").agg(
    pl.col("amount").sum()
).filter(
    pl.col("amount") >= pl.col("amount").mean()
).sort("customer_id").head(10)

pl.DataFrame.groupbyに対する主な集約関数のまとめ

紹介したものを含めた主な集約関数は以下のようなものがあります。

処理 Polars pandas
合計 sum sum
中央値 median median
最頻値 mode なし(lambda式でSeriesmodeを使用)
標本分散 var (ただしddof=0とする) var (ただしddof=0とする)
標本標準偏差 std (ただしddof=0とする) std (ただしddof=0とする)
パーセンタイル値 quantile quantile(ただし引数を記述するためlambda式を使用)
最大値 max max
最小値 min min

結合・分割処理

指定キーでの結合: pl.DataFrame.join

Polarsの結合処理はjoinという関数名で実装されており、SQLクエリの間隔に直感的に近くなっています。

以下はdf_receiptとdf_storeをjoinする例です。(100本ノックのP-036相当)

df_receipt.join(
    df_store.select(["store_cd", "store_name"]),
    how="inner", on="store_cd"
).head(10)

pandasの場合はmergeという名前でしたが、使い方は似ています。

また複数キーでももちろんjoinは可能です。

単純な縦方向結合: pl.concat

DataFrame同士の縦に結合(UNIONのような処理)は、pl.concatを使用します。

pl.concat([
    df_customer.select(["customer_id"]).head(10)
    , df_customer.select(["customer_id"]).head(10)
])

pandasの場合、列方向の結合にもpd.concatが使用できていましたが、Polarsのpl.concatには引数にaxisがないため、列方向の結合には後述のアンパックを使用する必要があります。

単純な横方向結合: アンパック

select内でDataFrameをアンパックすると、アンパックした列が結合されます。

df_customer.select([
    "customer_id"
    , *df_customer.select(["customer_name"])
]).head(10)

行数が同じで異なるDataFrameを結合することが可能です。

指定カラムによるDataFrame分割: pl.DataFrame.partitiy_by

キーを指定することで、その条件で分割されたpl.DataFrameのリストを取得できます。

df_gener0, df_gender1, df_gender9 = \
    df_customer.sort("gender_cd").partition_by("gender_cd")

リストの順番が想定外とならないよう、事前にparititionにするキーをソートしておくと安全です。

例題

ここまでを踏まえて、100本ノックから例題を取り出してチャレンジしてみてみましょう。

P-059

P-059: レシート明細データフレーム(df_receipt)の売上金額(amount)を顧客ID(customer_id)ごとに合計し、売上金額合計を平均0、標準偏差1に標準化して顧客ID、売上金額合計とともに表示せよ。標準化に使用する標準偏差は、不偏標準偏差と標本標準偏差のどちらでも良いものとする。ただし、顧客IDが"Z"から始まるのものは非会員を表すため、除外して計算すること。結果は10件表示させれば良い。

解答例は以下となります。

df_receipt.filter(
    pl.col("customer_id").str.starts_with("Z").is_not()
).groupby("customer_id").agg([
    pl.col("amount").sum()
]).select([
    "customer_id"
    , ((pl.col("amount") - pl.col("amount").mean())\
        /pl.col("amount").std()).alias("amount_ss")
]).sort("customer_id").head(10)

カラムの分散や平均を参照して新しい列を追加することができ、記述が便利なことが分かります。

P-069

P-069: レシート明細データフレーム(df_receipt)と商品データフレーム(df_product)を結合し、顧客毎に全商品の売上金額合計と、カテゴリ大区分(category_major_cd)が"07"(瓶詰缶詰)の売上金額合計を計算の上、両者の比率を求めよ。抽出対象はカテゴリ大区分"07"(瓶詰缶詰)の売上実績がある顧客のみとし、結果は10件表示させればよい。

解答例は以下となります。

# pl.Exprを変数に格納
amount_sum_all = \
    pl.col("amount").sum().alias("amount_sum_all")
amount_sum_07 = \
    pl.col("amount").filter(pl.col("category_major_cd") == "07").sum().alias("amount_sum_07")

df_receipt.join(
    df_product
    , how="left", on="product_cd"
).groupby("customer_id").agg([
    amount_sum_all
    , amount_sum_07
    , (amount_sum_07/amount_sum_all).alias("amount_rate_07")
]).filter(
    pl.col('amount_rate_07').is_not_null()
).sort("customer_id").head(10)

pl.Exprはあくまで実体ではなく計算式であるため、事前定義したpl.Exprpl.GroupBy.aggのなかで使用することが可能です。

P-084

P-084: 顧客データフレーム(df_customer)の全顧客に対し、全期間の売上金額に占める2019年売上金額の割合を計算せよ。ただし、売上実績がない場合は0として扱うこと。そして計算した割合が0超のものを抽出せよ。 結果は10件表示させれば良い。また、作成したデータにNAやNANが存在しないことを確認せよ。

解答例は以下となります。

df_customer.join(
    df_receipt.groupby("customer_id").agg([
        pl.col("amount").filter(
            pl.col("sales_ymd")\
                .is_between(20190101, 20191231, closed='both')
        ).sum().alias("amount_2019")
        , pl.col("amount").sum().alias("amount_all")
    ]).with_columns([
        (pl.col("amount_2019")/pl.col("amount_all")).alias("amount_rate")
    ]), on="customer_id", how='left'
).fill_null(0).filter(
    pl.col("amount_2019") > 0
).select([
    "customer_id", "amount_2019", "amount_all", "amount_rate"
]).sort("customer_id").head(10)

こちらはシンプルに複雑な例ですが、これでもワンライナーで書けるという例になります。

P-086

P-086: 前設問で作成した緯度経度つき顧客データフレーム(df_customer_1)に対し、申込み店舗コード(application_store_cd)をキーに店舗データフレーム(df_store)と結合せよ。そして申込み店舗の緯度(latitude)・経度情報(longitude)と顧客の緯度・経度を用いて距離(km)を求め、顧客ID(customer_id)、顧客住所(address)、店舗住所(address)とともに表示せよ。計算式は簡易式で良いものとするが、その他精度の高い方式を利用したライブラリを利用してもかまわない。結果は10件表示すれば良い。

距離算出の数式例は以下です。

解答例は以下となります。

import math
def distance(x1: pl.Expr, y1: pl.Expr
    , x2: pl.Expr, y2: pl.Expr) -> pl.Expr:

    lon1_rad = x1 * math.pi / 180
    lon2_rad = x2 * math.pi / 180
    lat1_rad = y1 * math.pi / 180
    lat2_rad = y2 * math.pi / 180

    L = 6371 * (
        lat1_rad.sin() * lat2_rad.sin() + \
            lat1_rad.cos() * lat2_rad.cos() * (lon1_rad - lon2_rad).cos()
    ).arccos()

    return L

df_customer.join(
    df_geocode.groupby("postal_cd").agg([
        pl.col("longitude").mean(), pl.col("latitude").mean()
    ]), on="postal_cd", how="left"
).join(
    df_store, left_on="application_store_cd", right_on="store_cd"
).select([
    "customer_id"
    , pl.col("address").alias("customer_address")
    , pl.col("address_right").alias("store_address")
    , distance( pl.col("longitude") , pl.col("latitude")
        , pl.col("longitude_right") , pl.col("latitude_right")
        ).alias("distance")
]).sort("customer_id").head(10)

このようにpl.Exprを引数にとり、pl.Exprを返すような関数を記述することで複雑な処理の可読性を挙げることができます。pl.Exprを変数に格納するのと本質的には同じことです。(UDF(ユーザ定義関数)とは異なりますのでご注意ください)

まとめ

今回記事が長くなってしまうため、基礎編ということで以下を省いています。

  • 遅延評価
  • ユニーク処理・重複の対応
  • 欠損値の対応
  • 日付型の扱い
  • UDF(ユーザ定義関数)とapply
  • 数学関数
  • データ処理(ダミー変数化、pivot、サンプリングなど)
  • 他ライブラリとの連携(scikit-learnなど)
  • 処理時間の計測・検証

今後これらの記事についても出していこうと思います。

100本ノックの解答例は他の方の記事もあるので、需要があれば出そうかなと思います。

本記事がPolarsを使われる方の参考になれば幸いです。

参考記事