plotnineで非営業日を軸から除いたプロットを描く

Pythonのプロット描画ライブラリであるplotnineにおいて、土日祝日などの任意の非営業日を除外して営業日ベースでプロットを描く方法です。

結論からいうと、日付からint型の連番列を作ってこの連番列をx軸に取り、plotnine.scale_x_continuouslabels引数に軸ラベルにするstr型の日付の文字列を渡せばよいです1

バッドノウハウに近いと思いますが、株価のようにデータによっては非営業日を除外して軸を描くケースが頻出なんですよね…よく使うのでメモしておきます。

環境は以下のとおりです。

こういうサンプルデータを考えます。

import polars as pl
import plotnine as p9
from mizani.breaks import breaks_width, breaks_date_width

df = (
    # 2025/1/11-12は土日、1/13は祝日、1/18-19は土日
    pl.DataFrame({
        "date": [
            "2025-01-06", "2025-01-07", "2025-01-08", "2025-01-09", "2025-01-10",
            "2025-01-14", "2025-01-15", "2025-01-16", "2025-01-17",
            "2025-01-20", "2025-01-21", "2025-01-22", "2025-01-23", "2025-01-24"
        ],
        "a": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14],
        "b": [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
    })
    .with_columns(
        date=pl.col("date").str.strptime(pl.Date, "%Y-%m-%d"),
    )
)
df = (
    df
    .unpivot(
        on=["a", "b"],
        index="date",
        variable_name="stock_name",
        value_name="price"
    )
    .sort("date", "stock_name")
)
print(df)
shape: (28, 3)
┌────────────┬────────────┬───────┐
│ date       ┆ stock_name ┆ price │
│ ---        ┆ ---        ┆ ---   │
│ date       ┆ str        ┆ i64   │
╞════════════╪════════════╪═══════╡
│ 2025-01-06 ┆ a          ┆ 1     │
│ 2025-01-06 ┆ b          ┆ 2     │
│ 2025-01-07 ┆ a          ┆ 2     │
│ 2025-01-07 ┆ b          ┆ 3     │
│ 2025-01-08 ┆ a          ┆ 3     │
│ …          ┆ …          ┆ …     │
│ 2025-01-22 ┆ b          ┆ 13    │
│ 2025-01-23 ┆ a          ┆ 13    │
│ 2025-01-23 ┆ b          ┆ 14    │
│ 2025-01-24 ┆ a          ┆ 14    │
│ 2025-01-24 ┆ b          ┆ 15    │
└────────────┴────────────┴───────┘

abという株の株価が記録されたpolars.DataFrameです。

p9.aesxdateを指定してふつうに折れ線グラフを描くと、レコードがない日、すなわち株式市場が空いていない日が直線で繋がってしまいます2。そうではなく、例えば2025/1/10の一つ隣は2025/1/14が来るようにプロットしたいです。

(
    p9.ggplot(
        df,
        p9.aes(x="date", y="price", color="stock_name")
    ) +
    p9.geom_line() +
    p9.geom_point() +
    p9.scale_x_date(breaks=breaks_date_width("1 days"), minor_breaks=None) +
    p9.scale_y_continuous(breaks=breaks_width(1), minor_breaks=None) +
    p9.theme(axis_text_x=p9.element_text(rotation=90))
)

これを解決するには、まずx軸に取りたい日付のdateをintの連番にしたindex列を用意します。

df = (
    df
    # 縦持ちのデータでは同一日が複数行あるので、rankのmethodはdenseを使う
    .with_columns(
        date_idx=pl.col("date").rank("dense").cast(pl.Int32) - 1
    )
)

そしてindex列をx軸に取り、scale_x_continuousbreaksminor_breaksでそれぞれ軸ラベルと目盛りを振りたい連番を指定してあげます。軸ラベルにする日付の文字列として、breaksの引数と同じ長さのlist[str]labelsに与えます。

date_labels = [d.strftime("%Y-%m-%d") for d in sorted(df["date"].unique().to_list())]
date_idx = sorted(df["date_idx"].unique().to_list())

# 軸ラベルは全部の日付に振り、軸ラベルの間のminor_breaksは振らないとする
major_breaks_idx = date_idx
minor_breaks_idx = None

major_labels = [date_labels[i] for i in major_breaks_idx]

(
    p9.ggplot(
        df,
        p9.aes(x="date_idx", y="price", color="stock_name")
    ) +
    p9.geom_line() +
    p9.geom_point() +
    p9.scale_x_continuous(breaks=major_breaks_idx, minor_breaks=minor_breaks_idx, labels=major_labels) +
    p9.scale_y_continuous(breaks=breaks_width(1), minor_breaks=None) +
    p9.theme(axis_text_x=p9.element_text(rotation=90)) +
    p9.labs(x="date")
)

5日おきに軸ラベルを振るということもできます。

major_breaks_idx = [i for i in date_idx if i % 5 == 0]
minor_breaks_idx = date_idx

major_labels = [date_labels[i] for i in major_breaks_idx]

(
    p9.ggplot(
        df,
        p9.aes(x="date_idx", y="price", color="stock_name")
    ) +
    p9.geom_line() +
    p9.geom_point() +
    p9.scale_x_continuous(breaks=major_breaks_idx, minor_breaks=minor_breaks_idx, labels=major_labels) +
    p9.scale_y_continuous(breaks=breaks_width(1), minor_breaks=None) +
    p9.theme(axis_text_x=p9.element_text(rotation=90)) +
    p9.labs(x="date")
)

例えば月曜日を週始まりとして、週の最初の日付だけに軸ラベルを振りたいというケースもよくあります。

この場合は、polarsで各週の最初の曜日の日付のindexを取り出し、それをscale_x_continuousbreaks引数に渡せばよいですね。

idx_first_business_day_of_week = (
    df
    .with_columns(
        week=pl.col("date").dt.week(),
        # pl.Expr.dt.weekday()は月曜日が1
        # 月曜日始まりで週判定をするために月曜日を0にする
        weekday=pl.col("date").dt.weekday() - 1
    )
    .group_by("week")
    .agg(
        first_business_day_of_week=pl.col("date").min(),
        first_business_day_of_week_idx=pl.col("date_idx").min()
    )
    .sort("week")
    .get_column("first_business_day_of_week_idx")
    .to_list()
)
major_breaks_idx = idx_first_business_day_of_week
minor_breaks_idx = date_idx
major_labels = [date_labels[i] for i in major_breaks_idx]

(
    p9.ggplot(
        df,
        p9.aes(x="date_idx", y="price", color="stock_name")
    ) +
    p9.geom_line() +
    p9.geom_point() +
    p9.scale_x_continuous(breaks=major_breaks_idx, minor_breaks=minor_breaks_idx, labels=major_labels) +
    p9.scale_y_continuous(breaks=breaks_width(1), minor_breaks=None) +
    p9.theme(axis_text_x=p9.element_text(rotation=90)) +
    p9.labs(x="date")
)

同様に月の最初の日だけ振るようなこともできます。この場合はweekmonthに、weekdaydayに読み替えればOKです。


  1. ちなみにRのggplot2を使う場合は、bdscaleという便利なパッケージがあり、bdscale::scale_x_bdで一発で描けます。 ↩︎

  2. dfは株式市場が空いていない日はレコードがありませんが、例えば空いていない日をdate列に入れてa列とb列をNoneとしたとしても、直線が途中で切れるだけで所望のプロットは描けません。 ↩︎