Polarsでの時系列データ処理
この記事では Polars で使うことができる時系列処理関連のAPIを実例を示しつつ解説します。
データ型に関する基本的な知識から Temporal Groupby といった少し発展的な内容にも触れます。
本日解説する内容のさらなる詳細は公式のAPI Referrenceを参照ください。
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
関数によってアップサンプリングが実行できます。
当然、日ごとのデータしか無い状況でどこか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
の各々による集約の仕方についても説明しました。
本記事の内容が皆様のお役に立てば幸いです。