Polarsでの時系列データ処理

Polarsでの時系列データ処理
目次

この記事では Polars で使うことができる時系列処理関連のAPIを実例を示しつつ解説します。
データ型に関する基本的な知識から Temporal Groupby といった少し発展的な内容にも触れます。

本日解説する内容のさらなる詳細は公式のAPI Referrenceを参照ください。

Time Series

Polarsにおける時系列処理関連のAPI

Polarsは、Rust言語で実装された高速なデータ操作ライブラリです。
Polarsを使用すると、大規模な時系列データを効率的に処理できます。
本記事では、Polarsを使用した時系列データ処理に焦点を当て、そのAPIについて紹介します。

この記事ではPolarsの時系列処理関連のAPIを紹介していきます。
具体的には、次のような内容を取り上げます。

  • 日付や時刻のデータ型
  • 移動平均や指数平滑化など時系列データにおける集約統計量の計算
  • リサンプリング

データの準備

まずは今回デモとして用いるデータを取得します。

pandas_datareader を使用してFREDからドル円の日足データを取得し、Polarsのデータ型に変換します。
取得期間は2020年4月1日~2023年4月1日までの3年とします。

FRED(Federal Reserve Economic Data)は、米国連邦準備制度理事会が提供する経済データのオンラインデータベースです。
FREDにはGDPや失業率、インフレ率などの幅広い経済指標が含まれており、経済分析や予測に利用されます。
また、FRED APIを使用することで、Pythonなどのプログラミング言語からFREDのデータにアクセスすることができます。

pandas_datareader は、pandasの拡張機能で、様々なデータソースからデータを読み込むことができるライブラリです。
FREDやYahoo Finance、Google Financeなどから金融データを取得するために使用されます。

以下が pandas_datareader を用いてFREDからドル円の日足データを取得するコード例です。

import pandas_datareader as pdr
import polars as pl

symbol = "DEXJPUS"
start_date = "2020-04-01"
end_date = "2023-04-01"

_df = pdr.DataReader(symbol, "fred", start_date, end_date)
# 日付のindexをpolarsのカラムとして加えるためにindexをdate列として追加しておく
_df["date"] = _df.index
# 日付を文字列にしておく
_df["date"] = _df["date"].dt.strftime("%Y-%m-%d")

# polarsのdfに変換
df = pl.from_pandas(_df)
df = df.select(["date", "DEXJPUS"])

print(df.head(3))

# 出力
│ date       ┆ DEXJPUS │
│ ---        ┆ ---     │
│ str        ┆ f64     │
╞════════════╪═════════╡
│ 2020-04-01 ┆ 107.05  │
│ 2020-04-02 ┆ 107.85  │
│ 2020-04-03 ┆ 108.53  │

上記で日付をPolarsのデータフレームに反映させるために少々複雑なことをしています。
_df において、日付はデータフレームのindexとなっています。
しかし、Polarsにindexという概念はないため、シンプルにPolarsのデータフレームに変換すると日付列がなくなってしまいます。
そのため、 _df の文字列カラムとして日付列を再設定しているわけです。

Polarsにおける日付関連のデータ型

2023/4/13時点では、Polarsには以下4つの日付関連のデータ型が用意されています。

  • pl.Date
  • pl.Datetime
  • pl.Time
  • pl.Duration

各型の詳細を以下にまとめました。

データ型概要表現例
pl.Date日付を表すデータ型。1970年1月1日からの日数で表現される。18869
pl.Datetime日時を表すデータ型。UTC時刻で表現される。2023-04-13T14:30:00Z
pl.Time時刻を表すデータ型。ナノ秒単位で表現される。52200000000000
pl.Duration時間間隔を表すデータ型。ナノ秒単位で表現される。3600000000000

pl.Date 型

では、早速先ほど作成したドル円データで日付型を試してみましょう。
先ほど作成したデータフレームは date カラムが日付を表す文字列となっています。
まずはこちらを pl.Date 型に直します。

df = df.with_columns(
    pl.col("date").str.strptime(pl.Date, "%Y-%m-%d")
)

print(df.head(3))

# 出力
│ date       ┆ DEXJPUS │
│ ---        ┆ ---     │
│ date       ┆ f64     │
╞════════════╪═════════╡
│ 2020-04-01 ┆ 107.05  │
│ 2020-04-02 ┆ 107.85  │
│ 2020-04-03 ┆ 108.53  │

第2引数で日付を表す文字列のフォーマットを指定します。
この辺りはPandasと同様ですね。

pl.Date 型からは年、月、日等の情報を取り出すことができます。

df.with_columns([
    pl.col("date").dt.year().alias("year"),
    pl.col("date").dt.month().alias("month"),
    pl.col("date").dt.week().alias("week_of_year"),
    pl.col("date").dt.day().alias("day_of_month"),
    pl.col("date").dt.ordinal_day().alias("day_of_year"),
    pl.col("date").dt.weekday().alias("weekday")
]).head()

# 出力
│ date       ┆ DEXJPUS ┆ year ┆ month ┆ week_of_year ┆ day_of_month ┆ day_of_year ┆ weekday │
│ ---        ┆ ---     ┆ ---  ┆ ---   ┆ ---          ┆ ---          ┆ ---         ┆ ---     │
│ date       ┆ f64     ┆ i32  ┆ u32   ┆ u32          ┆ u32          ┆ u32         ┆ u32     │
╞════════════╪═════════╪══════╪═══════╪══════════════╪══════════════╪═════════════╪═════════╡
│ 2020-04-01 ┆ 107.05  ┆ 2020 ┆ 4     ┆ 14           ┆ 1            ┆ 92          ┆ 3       │
│ 2020-04-02 ┆ 107.85  ┆ 2020 ┆ 4     ┆ 14           ┆ 2            ┆ 93          ┆ 4       │
│ 2020-04-03 ┆ 108.53  ┆ 2020 ┆ 4     ┆ 14           ┆ 3            ┆ 94          ┆ 5       │
│ 2020-04-06 ┆ 109.11  ┆ 2020 ┆ 4     ┆ 15           ┆ 6            ┆ 97          ┆ 1       │
│ 2020-04-07 ┆ 108.97  ┆ 2020 ┆ 4     ┆ 15           ┆ 7            ┆ 98          ┆ 2       │

weekday() は1が月曜日で7が日曜日に対応します。

pl.Duration 型

pl.Duration型はカラムの型として直接指定することは少ないかもしれませんが、日付の足し引きを行う際に用いる型です。

from datetime import datetime 

df.with_columns(
    (pl.col("date") - datetime(2020, 3, 1)).alias("days_from_20200301"),
    (pl.col("date") + pl.duration(weeks=1)).alias("1week_later")
).head()

# 出力
│ date       ┆ DEXJPUS ┆ days_from_20200301 ┆ 1week_later │
│ ---        ┆ ---     ┆ ---                ┆ ---         │
│ date       ┆ f64     ┆ duration[μs]       ┆ date        │
╞════════════╪═════════╪════════════════════╪═════════════╡
│ 2020-04-01 ┆ 107.05  ┆ 31d                ┆ 2020-04-08  │
│ 2020-04-02 ┆ 107.85  ┆ 32d                ┆ 2020-04-09  │
│ 2020-04-03 ┆ 108.53  ┆ 33d                ┆ 2020-04-10  │
│ 2020-04-06 ┆ 109.11  ┆ 36d                ┆ 2020-04-13  │
│ 2020-04-07 ┆ 108.97  ┆ 37d                ┆ 2020-04-14  │

詳細は polars.durationに関するReferrence を参照ください。

時系列データにおける集約統計量の計算

時系列データでは特定の区間幅における平均(つまり移動平均)などの集約統計量を計算することがあります。
ここではPolarsで特定の区間幅で集約統計量を計算する方法を解説し、為替データで実演してみます。

Polarsでは上記のような計算をするために以下2つの関数が用意されています。

  • groupby_daynamic : 時系列データを指定された日付の単位(日数や週数など)のウィンドウ幅でグループ化するための関数です。区間幅の指定は引数 every と period で行います。
  • groupby_rolling : 時系列データをデータ中の日付の値から定まる区間幅でグループ化するための関数です。区間幅の指定は引数 every で行います。

一点注意なのは、Polarsはgroupbyのキーで自動でソートはしてくれません。
これらのgroupby操作を行う際はgroupbyした後にキーとなるカラム(多くの場合でタイムスタンプ列)でソートしておきましょう。

自分は上記2つの区別を理解するのに時間がかかりました…
なので各々の使い方を説明しつつ、異なる点を解説していきます。

groupby_dynamicと groupby_rolling の違い

APIの説明:

この2つの違いを説明するために、以下の簡単なデータフレームを考えます。
説明を分かりやすくするために、データフレームの各行を区別するための index という列を加えています。

test_df = pl.DataFrame({
    "date": pl.date_range(datetime(2023,4,1), datetime(2023, 4,10), "1d").cast(pl.Date),
    "value": [i+1 for i in range(10)],
    "index": ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"]
})

# データフレームの中身
│ date       ┆ value ┆ index │
│ ---        ┆ ---   ┆ ---   │
│ date       ┆ i64   ┆ str   │
╞════════════╪═══════╪═══════╡
│ 2023-04-01 ┆ 1     ┆ a     │
│ 2023-04-02 ┆ 2     ┆ b     │
│ 2023-04-03 ┆ 3     ┆ c     │
│ 2023-04-04 ┆ 4     ┆ d     │
│ 2023-04-05 ┆ 5     ┆ e     │
│ 2023-04-06 ┆ 6     ┆ f     │
│ 2023-04-07 ┆ 7     ┆ g     │
│ 2023-04-08 ┆ 8     ┆ h     │
│ 2023-04-09 ┆ 9     ┆ i     │
│ 2023-04-10 ┆ 10    ┆ j     │

まずはこのデータフレームに groupby_dynamicを適用してみます。

test_df.groupby_dynamic(
    "date",
    every="1d",
    period="3d"
).agg(
    pl.sum("value").suffix("_sum"),
    pl.col("index")
)

# 出力
│ date       ┆ value_sum ┆ index           │
│ ---        ┆ ---       ┆ ---             │
│ date       ┆ i64       ┆ list[str]       │
╞════════════╪═══════════╪═════════════════╡
│ 2023-04-01 ┆ 6         ┆ ["a", "b", "c"] │
│ 2023-04-02 ┆ 9         ┆ ["b", "c", "d"] │
│ 2023-04-03 ┆ 12        ┆ ["c", "d", "e"] │
│ 2023-04-04 ┆ 15        ┆ ["d", "e", "f"] │
│ 2023-04-05 ┆ 18        ┆ ["e", "f", "g"] │
│ 2023-04-06 ┆ 21        ┆ ["f", "g", "h"] │
│ 2023-04-07 ┆ 24        ┆ ["g", "h", "i"] │
│ 2023-04-08 ┆ 27        ┆ ["h", "i", "j"] │
│ 2023-04-09 ┆ 19        ┆ ["i", "j"]      │
│ 2023-04-10 ┆ 10        ┆ ["j"]           │

index 列のまとまり方からどのように区間が決まっているかが分かると思います。
period 引数が区間の幅を、 period 引数が区間をずらしていく幅を決めています。
各行を起点に、前方3日( period で指定した数)の区間が1日ずつずれながら取られ、区間の中で value の和が取られています。

では、 groupby_rollingを適用してみましょう。

test_df.groupby_rolling(
    "date",
    period="3d"
).agg(
    pl.sum("value").suffix("_sum"),
    pl.col("index")
)

# 出力
│ date       ┆ value_sum ┆ index           │
│ ---        ┆ ---       ┆ ---             │
│ date       ┆ i64       ┆ list[str]       │
╞════════════╪═══════════╪═════════════════╡
│ 2023-04-01 ┆ 1         ┆ ["a"]           │
│ 2023-04-02 ┆ 3         ┆ ["a", "b"]      │
│ 2023-04-03 ┆ 6         ┆ ["a", "b", "c"] │
│ 2023-04-04 ┆ 9         ┆ ["b", "c", "d"] │
│ 2023-04-05 ┆ 12        ┆ ["c", "d", "e"] │
│ 2023-04-06 ┆ 15        ┆ ["d", "e", "f"] │
│ 2023-04-07 ┆ 18        ┆ ["e", "f", "g"] │
│ 2023-04-08 ┆ 21        ┆ ["f", "g", "h"] │
│ 2023-04-09 ┆ 24        ┆ ["g", "h", "i"] │
│ 2023-04-10 ┆ 27        ┆ ["h", "i", "j"] │

groupby_dynamic と異なり、後方3日( period で指定した数)の区間が取られていることが分かります。

では、このデータフレームの日付を歯抜けにしてみましょう。

test_df2 = test_df[0, 2, 5, 7, 8, 9]

# 出力
│ date       ┆ value ┆ index │
│ ---        ┆ ---   ┆ ---   │
│ date       ┆ i64   ┆ str   │
╞════════════╪═══════╪═══════╡
│ 2023-04-01 ┆ 1     ┆ a     │
│ 2023-04-03 ┆ 3     ┆ c     │
│ 2023-04-06 ┆ 6     ┆ f     │
│ 2023-04-08 ┆ 8     ┆ h     │
│ 2023-04-09 ┆ 9     ┆ i     │
│ 2023-04-10 ┆ 10    ┆ j     │

こちらに各々の関数を適用してみます。

groupby_dynamic の場合

test_df2.groupby_dynamic(
    "date",
    every="1d",
    period="3d"
).agg(
    pl.sum("value").suffix("_sum"),
    pl.col("index")
)

# 出力
│ 2023-04-01 ┆ 4         ┆ ["a", "c"]      │
│ 2023-04-02 ┆ 3         ┆ ["c"]           │
│ 2023-04-03 ┆ 3         ┆ ["c"]           │
│ 2023-04-04 ┆ 6         ┆ ["f"]           │
│ 2023-04-05 ┆ 6         ┆ ["f"]           │
│ 2023-04-06 ┆ 14        ┆ ["f", "h"]      │
│ 2023-04-07 ┆ 17        ┆ ["h", "i"]      │
│ 2023-04-08 ┆ 27        ┆ ["h", "i", "j"] │
│ 2023-04-09 ┆ 19        ┆ ["i", "j"]      │
│ 2023-04-10 ┆ 10        ┆ ["j"]           │

groupby_rolling の場合

test_df2.groupby_rolling(
    "date",
    period="3d"
).agg(
    pl.sum("value").suffix("_sum"),
    pl.col("index")
)

# 出力
│ date       ┆ value_sum ┆ index           │
│ ---        ┆ ---       ┆ ---             │
│ date       ┆ i64       ┆ list[str]       │
╞════════════╪═══════════╪═════════════════╡
│ 2023-04-01 ┆ 1         ┆ ["a"]           │
│ 2023-04-03 ┆ 4         ┆ ["a", "c"]      │
│ 2023-04-06 ┆ 6         ┆ ["f"]           │
│ 2023-04-08 ┆ 14        ┆ ["f", "h"]      │
│ 2023-04-09 ┆ 17        ┆ ["h", "i"]      │
│ 2023-04-10 ┆ 27        ┆ ["h", "i", "j"] │

大分異なる結果が得られました。
groupby_dynamic の方は period と every で指定した区間を作れるように無理やり日付を増やした上で前方3日( period で指定した値)の区間を取っています。

一方 groupby_rollingは既存のデータフレームに存在している日付から後方3日( period で指定した値)の区間を取っています。

個人的には元のデータフレームと行数が変わらない groupby_rolling の方が直感的に分かりやすくはあるのですが、日付の抜けがあっても確実に固定の日数幅を取ってくれる groupby_dynamic もとても便利そうに思えます。
結局は目的次第で使い分けられるようにする必要があるのでしょう。

為替データの移動平均

では取得したドル円の日足データで実践してみます。
今回は有名なテクニカル指標である移動平均を計算してみます。
計算する区間の幅は25日間とします。

何のために移動平均を計算するかは分析の目的次第ですが、過去のデータから将来の値の目安を得るという意味で過去25日間の移動平均を算出してみます。
先の解説からこの用途であれば groupby_rolling が適していますね。

df.groupby_rolling(
    "date",
    period="25d"
).agg(
    pl.mean("DEXJPUS").suffix("_5rolling_mean")
).head(30)

# 出力

│ date       ┆ DEXJPUS_5rolling_mean │
│ ---        ┆ ---                   │
│ date       ┆ f64                   │
╞════════════╪═══════════════════════╡
│ 2020-04-01 ┆ 107.05                │
│ 2020-04-02 ┆ 107.45                │
│ 2020-04-03 ┆ 107.81                │
│ 2020-04-06 ┆ 108.135               │
│ 2020-04-07 ┆ 108.302               │
│ 2020-04-08 ┆ 108.368333            │
│ 2020-04-09 ┆ 108.37                │
│ 2020-04-10 ┆ 108.3725              │
│ 2020-04-13 ┆ 108.285556            │
│ 2020-04-14 ┆ 108.165               │
│ 2020-04-15 ┆ 108.097273            │
│ 2020-04-16 ┆ 108.065833            │
│ 2020-04-17 ┆ 108.023846            │
│ 2020-04-20 ┆ 108.0                 │
│ 2020-04-21 ┆ 107.979333            │
│ 2020-04-22 ┆ 107.96875             │
│ 2020-04-23 ┆ 107.95                │
│ 2020-04-24 ┆ 107.921667            │
│ 2020-04-27 ┆ 107.935882            │
│ 2020-04-28 ┆ 107.835882            │
│ 2020-04-29 ┆ 107.771111            │
│ 2020-04-30 ┆ 107.727368            │
│ 2020-05-01 ┆ 107.603684            │
│ 2020-05-04 ┆ 107.367059            │
│ 2020-05-05 ┆ 107.257059            │
│ 2020-05-06 ┆ 107.191111            │
│ 2020-05-07 ┆ 107.147368            │
│ 2020-05-08 ┆ 107.09                │
│ 2020-05-11 ┆ 107.07                │
│ 2020-05-12 ┆ 107.058824            │

上記の方法では端点(最初29点)では過去25日で平均を計算していないことに注意です。
このように移動平均は端点の取り扱いをどうするかに自由度があることは認識しておく必要があります。

リサンプリング

リサンプリングは時系列データの頻度を変更する手法です(日単位→週単位、日単位→時間単位など)。
時系列の頻度を小さくするダウンサンプリングと、頻度を大きくするアップサンプリングに分類されます。

ダウンサンプリング

ダウンサンプリングとは、時系列データの頻度を小さくすることです。
例えば、日ごとのデータを週ごとや月ごとに変換することができます。
ダウンサンプリングは、データのノイズを減らし、大局的なパターンを明確にするのに役立ちます。
また、データ量が多い場合、ダウンサンプリングによってデータの扱いやすさを向上させることができます。

Polarsのダウンサンプリングでは、先に説明した groupby_dynamic や groupby_rolling でダウンサンプリングを行うことができます。

アップサンプリング

アップサンプリングとは、時系列データの頻度を大きくすることです。例えば、日ごとのデータを時間ごとや分ごとに変換することができます。

Polarsでは upsample 関数によってアップサンプリングが実行できます。

polars.DataFrame.upsample

当然、日ごとのデータしか無い状況でどこか1日内の時間ごとの変動は分からないので…
アップサンプリングのために何らかの方法で推定する必要があります。
これは欠損データをどのように補完するかの問題として捉えられます。

では、為替データを1時間ごとのデータにアップサンプリングしてみましょう。

df.with_columns(
    pl.col("date").cast(pl.Datetime)  # 時間情報が必要なのでDate型からDateTime型に変換
).upsample(
    "date",
    every="1h",
    maintain_order=True
).fill_null(
    strategy="forward"  # 欠損は前の値で埋める
).head(30)

# 出力
│ date                ┆ DEXJPUS │
│ ---                 ┆ ---     │
│ datetime[μs]        ┆ f64     │
╞═════════════════════╪═════════╡
│ 2020-04-01 00:00:00 ┆ 107.05  │
│ 2020-04-01 01:00:00 ┆ 107.05  │
│ 2020-04-01 02:00:00 ┆ 107.05  │
│ 2020-04-01 03:00:00 ┆ 107.05  │
│ 2020-04-01 04:00:00 ┆ 107.05  │
│ 2020-04-01 05:00:00 ┆ 107.05  │
│ 2020-04-01 06:00:00 ┆ 107.05  │
│ 2020-04-01 07:00:00 ┆ 107.05  │
│ 2020-04-01 08:00:00 ┆ 107.05  │
│ 2020-04-01 09:00:00 ┆ 107.05  │
│ 2020-04-01 10:00:00 ┆ 107.05  │
│ 2020-04-01 11:00:00 ┆ 107.05  │
│ 2020-04-01 12:00:00 ┆ 107.05  │
│ 2020-04-01 13:00:00 ┆ 107.05  │
│ 2020-04-01 14:00:00 ┆ 107.05  │
│ 2020-04-01 15:00:00 ┆ 107.05  │
│ 2020-04-01 16:00:00 ┆ 107.05  │
│ 2020-04-01 17:00:00 ┆ 107.05  │
│ 2020-04-01 18:00:00 ┆ 107.05  │
│ 2020-04-01 19:00:00 ┆ 107.05  │
│ 2020-04-01 20:00:00 ┆ 107.05  │
│ 2020-04-01 21:00:00 ┆ 107.05  │
│ 2020-04-01 22:00:00 ┆ 107.05  │
│ 2020-04-01 23:00:00 ┆ 107.05  │
│ 2020-04-02 00:00:00 ┆ 107.85  │
│ 2020-04-02 01:00:00 ┆ 107.85  │
│ 2020-04-02 02:00:00 ┆ 107.85  │
│ 2020-04-02 03:00:00 ┆ 107.85  │
│ 2020-04-02 04:00:00 ┆ 107.85  │
│ 2020-04-02 05:00:00 ┆ 107.85  │

今は当然1時間ごとのデータが無い中で strategy="forward" を指定しているので、1日内のデータはすべて同じ値となります。

まとめ

本記事では為替データを題材にPolarsの時系列関連のAPIを用いた時系列データの処理について解説しました。
その中で groupby_dynamic と groupby_rollingの各々による集約の仕方についても説明しました。
本記事の内容が皆様のお役に立てば幸いです。