テーブルデータ処理に悩むあなたに朗報!Polarsの使い方を徹底解説 その3:UDF・日付型

2023.02.24

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

こんちには。

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

本記事では、世間でも話題となっているPolarsについて基本的な使い方を抑えていく記事のその3です。

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

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

なお、記事の一覧は以下となります。

タイトル
テーブルデータ処理に悩むあなたに朗報!Polarsの使い方を徹底解説 その1:基本編
テーブルデータ処理に悩むあなたに朗報!Polarsの使い方を徹底解説 その2:重複・欠損処理編、ほか
テーブルデータ処理に悩むあなたに朗報!Polarsの使い方を徹底解説 その3:UDF・日付型

本記事の内容

本記事では前回までで触れられなかった以下を見ていきます。

  • 日付型の扱い
  • UDF(ユーザ定義関数)とapply

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

使用環境

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

セットアップ

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

!pip install polars

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

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

import polars as pl

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

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

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

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

以降のコードを動かす際は、以下のように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)

UDF(ユーザ定義関数)

UDF(ユーザ定義関数)は関数やlambda式を使って、applyにより適用が行えます。

applyには呼び出す対象によりおおきく3つの処理のパターンがあり、以降はこの3つについてそれぞれ見ていきます。

(分かりやすいように以降はlambda式ではなく実際に名前の付いた関数を定義して動かしていきます。)

DataFrameに対するUDF適用: pl.DataFrame.apply

まずはDataFrameに対するUDF適用pl.DataFrame.applyです。

pl.DataFrame.applyのUDFは、入力も出力もtupleで作成します。

def myfunc(x: tuple) -> tuple:
    return (x[0], x[-1], 1 if x[-1]>100 else 0)

df_receipt.apply(myfunc).head(10)

カラム名は自動で振られてしまうため、以下のようにリネームと組み合わせて使用する必要があります。

def myfunc(x: tuple) -> tuple:
    return (x[0], x[-1], 1 if x[-1]>100 else 0)

df_receipt.apply(myfunc)\
    .rename({
        "column_0": "sales_ymd"
        , "column_1": "amount"
        , "column_2": "amount_flag"
    }).head(10)

カラムに対するUDF適用: pl.Expr.apply

次にカラムに対するUDF適用pl.Expr.applyです。

pl.Expr.applyは、入力も出力もスカラーで作成します。

def myfunc(x):
    return 1 if x>100 else 0

df_receipt.select([
    "sales_ymd", "amount"
    , pl.col("amount").apply(myfunc).alias("amount_flag")
])

結果は以下のように同じになります。

グループ化後のUDF適用: pl.GroupBy.apply

最後にグループ化時のUDF適用pl.GroupBy.applyです。

この場合は入力がpl.DataFrameで出力もpl.DataFrameとなります。

結果はそれぞれのpl.DataFrameは縦方向に結合されたものとなります。

以下は具体的な例です。

P-076: 顧客データフレーム(df_customer)から性別(gender_cd)の割合に基づきランダムに10%のデータを層化抽出し、性別ごとに件数を集計せよ。

def myfunc(x: pl.DataFrame) -> pl.DataFrame:
    return x.sample(frac=0.1)

df_customer.groupby("gender_cd").apply(
    myfunc
).groupby("gender_cd").agg([
    pl.col("customer_id").count()
]).sort("gender_cd")

日付型の扱い

Polarsには4つのデータ型があります。

  • pl.Date : 日付オブジェクトに使用。UNIXエポックからの日数を32ビット符号付き整数で表現。
  • pl.Datetime : datetimeオブジェクトに使用。UNIXエポックからのナノ秒数を64ビットの符号付き整数で表現
  • pl.Time : 時刻に使用。0時からのナノ秒数で表現。
  • pl.Duration : timedeltaオブジェクトに使用。pl.Datepl.Datetimeまたはpl.Timeの差分を、マイクロ秒の分解能を持つ64ビットの符号付き整数で表現

詳細は下記も参照ください。

文字列⇔日付型変換: pl.Expr.str.strptime, pl.Expr.dt.strftime

文字列からの変換は、str.strptimeで実行し、日付型からの変換はdt.strftimeで実行します。

以下はbirth_dayというカラムの文字列をpl.Dateに変換し、strftimeで別のフォーマットに変換する例です。

df_customer.select([
    "customer_id"
    , pl.col("birth_day").str.strptime(pl.Date, "%Y-%m-%d").dt.strftime('%Y%m%d')
]).head(10)

こういった処理もpandasのようにconcatなしで記述できる点は便利と感じます。

# pandasの場合
pd.concat([df_customer['customer_id'],
           pd.to_datetime(df_customer['birth_day']).dt.strftime('%Y%m%d')],
          axis = 1).head(10)

エポック秒の操作

エポック秒は一旦文字列に変換後、strptimepl.Datetimeに変換する必要があるようです。

pl.Dateにする必要がある場合でも、一旦pl.Datetimeを経由する必要があるので注意が必要です。

以下はsales_epochpl.Dateに変換する例です。

df_receipt.select([
    pl.col("sales_epoch").cast(pl.Utf8).str.strptime(pl.Datetime, "%s").cast(pl.Date)
    , "receipt_no"
    , "receipt_sub_no"
]).head(10)

年月日などの取り出し: pl.Expr.dt.year, pl.Expr.dt.month, pl.Expr.dt.day

pl.Datepl.Datetimeに変換した後は、年・月・日などを取り出すことが可能となる。

df_receipt.select([
    pl.col("sales_epoch").cast(pl.Utf8).str.strptime(pl.Datetime, "%s").cast(pl.Date)
    , "receipt_no"
    , "receipt_sub_no"
]).with_columns([
    pl.col("sales_epoch").dt.year().alias("year")
    , pl.col("sales_epoch").dt.month().alias("month")
    , pl.col("sales_epoch").dt.day().alias("day")
]).head(10)

ただし後述するように、monthdayなど符号なし整数になるものがある点は注意が必要です。

曜日の取り出し: pl.Expr.dt.weekday

dt.weekdayで取り出すことができます。

ただし月曜から開始で1オリジンとなっている点が注意が必要です。

(pandasも月曜から開始ですが、0オリジンとなっています)

df_receipt.select([
    pl.col("sales_epoch").cast(pl.Utf8).str.strptime(pl.Datetime, "%s").cast(pl.Date)
    , "receipt_no"
    , "receipt_sub_no"
]).with_columns([
    pl.col("sales_epoch").dt.weekday().alias("weekday")
]).head(10)

日付の引き算

前述のとおりpl.Date同士の引き算は、pl.Durationとなります。

以下は少し複雑な例ですが、100本ノックからの例題となります。

P-070: レシート明細データフレーム(df_receipt)の売上日(sales_ymd)に対し、顧客データフレーム(df_customer)の会員申込日(application_date)からの経過日数を計算し、顧客ID(customer_id)、売上日、会員申込日とともに表示せよ。結果は10件表示させれば良い(なお、sales_ymdは数値、application_dateは文字列でデータを保持している点に注意)。

df_receipt.select([
    "customer_id", "sales_ymd"
]).unique().join(
    df_customer.select(["customer_id", "application_date"])
    , how="left", on="customer_id"
).select([
    "customer_id", "sales_ymd", "application_date"
    , (pl.col("sales_ymd").cast(pl.Utf8).str.strptime(pl.Date, "%Y%m%d") \
        - pl.col("application_date").str.strptime(pl.Date, "%Y%m%d")
      ).alias("elapsed_date") # ここがpl.Dateの引き算
]).filter(
    pl.col('elapsed_date').is_not_null()
).sort("customer_id").head(10)

duration型は日数という形で表示されます。

日数を数値として取り出すには、pl.Expr.dt.days()を使用して取り出すことができます。

日付の引き算(月数)

次は同様に月数を計算する例です。こちらも100本ノックからの例題となります。

P-071: レシート明細データフレーム(df_receipt)の売上日(sales_ymd)に対し、顧客データフレーム(df_customer)の会員申込日(application_date)からの経過月数を計算し、顧客ID(customer_id)、売上日、会員申込日とともに表示せよ。結果は10件表示させれば良い(なお、sales_ymdは数値、application_dateは文字列でデータを保持している点に注意)。1ヶ月未満は切り捨てること。

# 複数回登場するため、pl.Exprを一旦変数に格納
sales_ymd = pl.col("sales_ymd").cast(pl.Utf8).str.strptime(pl.Date, "%Y%m%d")
application_date = pl.col("application_date").str.strptime(pl.Date, "%Y%m%d")

df_receipt.select([
    "customer_id", "sales_ymd"
]).unique().join(
    df_customer.select(["customer_id", "application_date"])
    , how="left", on="customer_id"
).select([
    "customer_id", "sales_ymd", "application_date"
    , ((sales_ymd.dt.year() - application_date.dt.year()) * 12 \
        + sales_ymd.dt.month() - application_date.dt.month() # ここの演算順がキモ
      ).alias("elapsed_date")
]).filter(
    pl.col('elapsed_date').is_not_null()
).sort("customer_id").head(10)

月数はそもそも定義が曖昧になりがちではあるのですが、もともとのpandasの解答例に沿って計算しています。

また、注意点としてはmonthのデータ型がu32(符号なし32bit整数)であるため、演算順によってはオーバーフローするため注意が必要です。(pandasでは双方符号付きなのでこの問題は発生しない)

符号なしのデータは演算前に符号付きにキャストしておく方が安全かもしれません。

日付の引き算(年数)

次は年数を計算する例題です。こちらも100本ノックからの例題となります。

P-072: レシート明細データフレーム(df_receipt)の売上日(sales_ymd)に対し、顧客データフレーム(df_customer)の会員申込日(application_date)からの経過年数を計算し、顧客ID(customer_id)、売上日、会員申込日とともに表示せよ。結果は10件表示させれば良い(なお、sales_ymdは数値、application_dateは文字列でデータを保持している点に注意)。1年未満は切り捨てること。

# 複数回登場するため、pl.Exprを一旦変数に格納
sales_ymd = pl.col("sales_ymd").cast(pl.Utf8).str.strptime(pl.Date, "%Y%m%d")
application_date = pl.col("application_date").str.strptime(pl.Date, "%Y%m%d")

df_receipt.select([
    "customer_id", "sales_ymd"
]).unique().join(
    df_customer.select(["customer_id", "application_date"])
    , how="left", on="customer_id"
).select([
    "customer_id", "sales_ymd", "application_date"
    , ((sales_ymd - application_date).dt.days()//365).alias("elapsed_date")
]).filter(
    pl.col('elapsed_date').is_not_null()
).sort("customer_id").head(10)

こちらも年数は365日ではないことがあるため多少の曖昧さがありますが、一旦365で割った商を年数としています。

relativedeltaとの連携

pandasの日付の引き算の解答例には、dateutilライブラリのrelativedeltaを使われています。

Polarsでもrelativedeltaで処理させることは可能で、以下のようにapplyによるUDF(ユーザ定義関数)を使います。

from dateutil.relativedelta import relativedelta

# 複数回登場するため、pl.Exprを一旦変数に格納
sales_ymd = pl.col("sales_ymd").cast(pl.Utf8).str.strptime(pl.Date, "%Y%m%d")
application_date = pl.col("application_date").str.strptime(pl.Date, "%Y%m%d")

df_receipt.select([
    "customer_id", "sales_ymd"
]).unique().join(
    df_customer.select(["customer_id", "application_date"])
    , how="left", on="customer_id"
).select([
    "customer_id", sales_ymd, application_date
]).apply(
    lambda x: (x[0], x[1], x[2], relativedelta(x[1], x[2]).years)
).rename({
    "column_0": "customer_id"
    , "column_1": "sales_ymd"
    , "column_2": "application_date"
    , "column_3": "elapsed_date"
}).drop_nulls().sort("customer_id").head(10)

日付の引き算(エポック秒): pl.Expr.dt.epoch

pl.Durationで、pl.Expr.dt.epoch(tu='s')とすればエポック秒を取得できます。

# 複数回登場するため、pl.Exprを一旦変数に格納
sales_ymd = pl.col("sales_ymd").cast(pl.Utf8).str.strptime(pl.Date, "%Y%m%d")
application_date = pl.col("application_date").str.strptime(pl.Date, "%Y%m%d")

df_receipt.select([
    "customer_id", "sales_ymd"
]).unique().join(
    df_customer.select(["customer_id", "application_date"])
    , how="left", on="customer_id"
).select([
    "customer_id", "sales_ymd", "application_date"
    , ((sales_ymd - application_date).dt.epoch(tu='s')).alias("elapsed_date")
]).filter(
    pl.col('elapsed_date').is_not_null()
).head(10)

tuにはs, ms, us, ns, dなどが指定可能となっていますが、この場合で正常な値がとれたのはsmsのみであったため注意が必要です。

指定単位への切り捨て: pl.Expr.dt.truncate

pl.Expr.dt.truncateにより日付型について指定単位への切り捨てを実施できます。

たとえば、以下は月曜日への切り捨てが必要な100本ノックの例題です。

P-074: レシート明細データフレーム(df_receipt)の売上日(sales_ymd)に対し、当該週の月曜日からの経過日数を計算し、売上日、当該週の月曜日付とともに表示せよ。結果は10件表示させれば良い(なお、sales_ymdは数値でデータを保持している点に注意)。

sales_ymd = pl.col("sales_ymd").cast(pl.Utf8).str.strptime(pl.Date, "%Y%m%d")
monday = sales_ymd.dt.truncate(every='1w').alias("monday") # 月曜日への切り捨てとなる

df_receipt.select([
    sales_ymd, monday
    , (sales_ymd - monday).dt.days().alias("elapsed_weekday")
]).head(10)

ここからさらにオフセットを付与することも可能です。

sales_ymd = pl.col("sales_ymd").cast(pl.Utf8).str.strptime(pl.Date, "%Y%m%d")
monday = sales_ymd.dt.truncate(every='1w', offset='1d')\
    .alias("tuesday") # 火曜日への切り捨てとなる

df_receipt.select([
    sales_ymd, monday
    , (sales_ymd - monday).dt.days().alias("elapsed_weekday")
]).head(10)

truncateeveryに設定可能な切り捨て単位には、"1mo""1y"など様々あります。

詳細は以下を確認ください。

ある範囲内の日付型を作成: pl.date_range

datetime.datetimeで始点・終点を指定し、インターバルを設定することである期間の日付についてのデータを作成できます。

import datetime

pl.date_range(
    low=datetime.datetime(2022, 1, 1),
    high=datetime.datetime(2022, 12, 31),
    interval="1mo",
    name="month"
)

DataFrameにしたい場合には以下のようにします。(ついでに時間情報は不要なのでpl.Dateにキャストしています)

pl.DataFrame({
    "name": pl.date_range(
        low=datetime.datetime(2022, 1, 1),
        high=datetime.datetime(2022, 12, 31),
        interval="1mo"
    ).cast(pl.Date)
})

これにより月単位での集計などのベースに使用することができます。 (ただし、そういった用途の場合後述のpl.DataFrame.groupby_dynamicを使う方が簡単にできます。)

intervalには様々な表記を指定可能です。詳細は下記を参照ください。

なお、intervaldatetime.timedeltaも使用可能ですが、単位の上限がdaysなので注意が必要です。

時系列でグループ化して集計: pl.DataFrame.groupby_dynamic

pl.DataFrame.groupby_dynamicで時系列にある単位でグループ化して集計することが可能です。通常のgroupbyと同じような使い方ができるため使いやすくなっています。

以下はreceiptのamountを月毎に合算する例です。

df_receipt.select([
    pl.col("sales_ymd").cast(pl.Utf8).str.strptime(pl.Date, "%Y%m%d")
    , "amount"
]).sort("sales_ymd").groupby_dynamic("sales_ymd", every="1mo").agg([
    pl.col("amount").sum()
])

集計前に対象とする日付型でソートする必要がある点は注意が必要です。ソートされてない場合以下のエラーが表示されます。

PanicException: Subslice check showed that the values in `groupby_rolling/groupby_dynamic` were not sorted. Pleasure ensure the index column is sorted.

everyで周期を指定できますが、別途periodを指定することで集計範囲を変更することも可能です。

df_receipt.select([
    pl.col("sales_ymd").cast(pl.Utf8).str.strptime(pl.Date, "%Y%m%d")
    , "amount"
]).sort("sales_ymd").groupby_dynamic("sales_ymd", every="1mo", period="2mo").agg([
    pl.col("amount").sum()
])

pandasでもresampleを使うことで同様のことができますが、groupbyと使用感が違うので使い方に注意が必要です。

まとめ

いかがでしたでしょうか。今回はユーザ定義関数と日付型について記事にしました。

残りの次回以降では以下のような内容を書いていこうと思います。

  • 他ライブラリとの連携(scikit-learnなど)
  • 処理時間の計測・検証

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

参考記事