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だとイメージしてください。

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 day"), 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") - 1
    )
)

date_idxで1を引いているのは、date_idxを0始まりにするためです。あとで5日おきに軸ラベルを振るコードを示しますが、そのとき5で割り切れるdate_idxに軸ラベルを振るというコードで最初の日付にもラベルを振ることができ、コードが分かりやすくなります3

そしてindex列をx軸に取り、scale_x_continuousbreaksminor_breaks引数にそれぞれ軸ラベルと目盛りを振りたい連番を指定してあげます。

また、軸ラベルにする日付の文字列として、breaksの引数と同じ長さのlist[str]labelsに与えます。

date_labels = sorted(df["date"].unique().dt.strftime("%Y-%m-%d").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")
)

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

この場合も、scale_x_continuousbreaks引数に、軸ラベルを振りたいdateの連番を渡せばよいです。polarsで各週の最初の曜日の日付のindexを集計して、それをscale_x_continuousbreaks引数に渡せばよいですね。

idx_first_business_day_of_week = (
    df
    .with_columns(
        year=pl.col("date").dt.year(),
        week=pl.col("date").dt.week()
    )
    .group_by("year", "week")
    .agg(
        first_business_day_of_week_idx=pl.col("date_idx").min()
    )
    .sort("year", "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")
)

注意点として、年をまたぐ同一の週番号を正しく別に扱えるようにyearでもgroup_byして集計しています。

ISO weekの週は月曜日始まりなので、weekgroup_byしてdate_idxminを取ると、月曜日始まりの週の最初の日付を取得できます。月曜日以外の日を週始まりとしたい場合は別の書き方が必要ですが、そうしたいケースに出会ったことはないのでたいていこれで十分だと思います。

同様に月の最初の日だけ軸ラベルを振るようなこともできます。上のコードとほとんど同じコードなので省略しますが、この場合はweekmonthに読み替えればOKです。


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

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

  3. 1始まりでも「5で割って1余るdate_idxに軸ラベルを振る」とできますが、ちょっと分かりにくい気がします。 ↩︎