plotnineで非営業日を軸から除いたプロットを描く
Pythonのプロット描画ライブラリであるplotnineにおいて、土日祝日などの任意の非営業日を除外して営業日ベースでプロットを描く方法です。
結論からいうと、日付からint型の連番列を作ってこの連番列をx軸に取り、plotnine.scale_x_continuousのlabels引数に軸ラベルにするstr型の日付の文字列を渡せばよいです1。
株価のようにデータによっては非営業日を除外して軸を描くケースが頻出なんですよね。よく使うのでメモしておきます。
環境は以下のとおりです。
- Python=3.13.7
- polars=1.33.1
- plotnine=0.15.0
- mizani=0.14.2
こういうサンプルデータを考えます。
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 │
└────────────┴────────────┴───────┘
aとbという株の株価が記録されたpolars.DataFrameだとイメージしてください。
aesのxにdateを指定してふつうに折れ線グラフを描くと、レコードがない日、すなわち株式市場が空いていない日が直線で繋がってしまいます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_continuousのbreaksとminor_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_continuousのbreaks引数に、軸ラベルを振りたいdateの連番を渡せばよいです。polarsで各週の最初の曜日の日付のindexを集計して、それをscale_x_continuousのbreaks引数に渡せばよいですね。
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の週は月曜日始まりなので、weekでgroup_byしてdate_idxのminを取ると、月曜日始まりの週の最初の日付を取得できます。月曜日以外の日を週始まりとしたい場合は別の書き方が必要ですが、そうしたいケースに出会ったことはないのでたいていこれで十分だと思います。
同様に月の最初の日だけ軸ラベルを振るようなこともできます。上のコードとほとんど同じコードなので省略しますが、この場合はweekをmonthに読み替えればOKです。