Matplotlibでdatetimeの日時と経過時間を2軸で表示する方法

## はじめに

データ解析において、日時データと経過時間を同時に表示することは、トレンドや変動の理解を深めるのに有用です。例えば、天文学のライトカーブや株価データの解析など、様々な分野に応用できます。matplotlibでの時刻表示では、[`datetime`](https://docs.python.org/ja/3/library/datetime.html)形式を使うことが多いですが、経過時間を同時に表示したいケースがあると思います。

そこで本記事では、**任意の時刻からの経過時間を様々なスケール（秒、年など）で表示する方法**を紹介します。


## Secondary Axisとは
matplotlibでは異なる単位を表示する方法として[Secondary Axis](https://matplotlib.org/stable/gallery/subplots_axes_and_figures/secondary_axis.html)があります。この方法は単位変換前後の関数を用意し、目盛りの位置関係を対応させることで実装します。

一般的な使い方は

https://qiita.com/yusuke_s_yusuke/items/96cd6aba3aaa30b9c3f3

も合わせてご覧ください。`datatime`ではハマりどころがありそこについて本記事で説明します。

## 方法
`datetime`を用いる場合の変換関数は、数値に直接変換するだけでは2軸描画でうまく表示できないことが多く、公式の[Secondary Axis](https://matplotlib.org/stable/gallery/subplots_axes_and_figures/secondary_axis.html)の例では、では、matplotlibの[`mdates.date2num`](https://matplotlib.org/stable/api/dates_api.html#matplotlib.dates.datestr2num)を使って実装しています。本記事もこのモジュールを使用します。

以下のコードでは、基準時刻`base_date`からの経過時間を`unit`の単位で表示します。
`create_date_time_funcs()`で変換関数を取得して実装します。このように関数内関数を使用する目的は、[コードの補足](#コードの補足)で説明します。

```python:実装コード
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import datetime
import numpy as np


def create_date_time_funcs(base_date, unit='seconds'):
    base_date_num = mdates.date2num(base_date)  # 基準日付を内部形式に変換

    def date_to_time(date):
        """Convert matplotlib date to elapsed time."""
        elapsed_days = date - base_date_num
        if unit == 'seconds':
            elapsed_time = elapsed_days * 24 * 60 * 60
        elif unit == 'minutes':
            elapsed_time = elapsed_days * 24 * 60
        elif unit == 'hours':
            elapsed_time = elapsed_days * 24
        elif unit == 'days':
            elapsed_time = elapsed_days
        elif unit == 'years':
            elapsed_time = elapsed_days / 365.25  # 1年を約365.25日と定義
        else:
            raise ValueError("Invalid unit. Choose from 'seconds', 'minutes', 'hours', 'days', 'years'.")
        return elapsed_time

    def time_to_date(elapsed_time):
        """Convert elapsed time to matplotlib date."""
        if unit == 'seconds':
            elapsed_days = elapsed_time / (24 * 60 * 60)
        elif unit == 'minutes':
            elapsed_days = elapsed_time / (24 * 60)
        elif unit == 'hours':
            elapsed_days = elapsed_time / 24
        elif unit == 'days':
            elapsed_days = elapsed_time
        elif unit == 'years':
            elapsed_days = elapsed_time * 365.25  # 1年を約365.25日と定義
        else:
            raise ValueError("Invalid unit. Choose from 'seconds', 'minutes', 'hours', 'days', 'years'.")
        date = elapsed_days + base_date_num
        return date

    return date_to_time, time_to_date

# 基準日付を定義
base_date = datetime.datetime(2024, 5, 5, 5, 5, 5)

# 単位を指定して変換関数を作成
unit = 'days'  # ここで単位を変更できる（'seconds', 'minutes', 'hours', 'days', 'years'）
date_to_time, time_to_date = create_date_time_funcs(base_date, unit)

# ダミーデータの作成（ランダムウォークにインフレーションバイアスを追加）
np.random.seed(7)
num_points = 10000
dates = [base_date + datetime.timedelta(days=i) for i in range(num_points)]
daily_bias = 0.00025
daily_std_dev = 0.01
values = np.cumsum(np.random.randn(num_points) * daily_std_dev + daily_bias)

# グラフに描画
fig, ax1 = plt.subplots()

ax1.plot(dates, values)
ax1.set_xlabel('Date')
ax1.set_ylabel('Value')

# 副次的なX軸を追加
secax_x = ax1.secondary_xaxis('top', functions=(date_to_time, time_to_date))

# 副次的なX軸のラベルを設定
secax_x.set_xlabel(f'Elapsed Time ({unit})')

# 主軸のフォーマットを設定
ax1.xaxis.set_major_formatter(mdates.DateFormatter('%Y-%m-%d'))
fig.autofmt_xdate()

plt.show()
```
出力結果
![days.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1599258/8c6bc2c6-9eff-c0a9-442f-63d34ce964ae.png)

横軸下に日時が、上に経過時間（`unit = 'days'`）が表示されているのがわかります。`unit`引数を変えた際の結果を以下に示します。

<br>
経過時間の単位`unit`を変えた時の結果
<table>
    <tr>
        <td><code>unit = 'seconds'</code><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1599258/a8291f36-bca9-159f-3a50-7b86df0f631d.png" alt="Image 1" style="width: 100%;"></td>
        <td>
<code>unit = 'minutes'</code><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1599258/fea9ce94-4187-7019-26c7-83a6070bbda7.png" alt="Image 2" style="width: 100%;"></td>
    </tr>
    <tr>
        <td>
<code>unit = 'hours'</code><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1599258/b98cf4f8-4536-d84e-5ef4-0fdef0f5f478.png" alt="Image 3" style="width: 100%;"></td>
        <td>
<code>unit = 'years'</code><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1599258/b2708648-f832-531c-c685-d91d330a74dc.png" alt="Image 4" style="width: 100%;"></td>
    </tr>
</table>


## コードの補足
### Secondary Axisの変換関数に引数を受け取るための工夫
[`secondary_xaxis()`](https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.secondary_xaxis.html)の`functions`引数は、基本関数のみを指定します。このため、`base_date`と`unit`のように引数を受け取りたい場合は工夫が必要です。

変数を受け取るための方法の一つに、[クロージャ](https://qiita.com/naomi7325/items/57d141f2e56d644bdf5f)として受け取る方法があります。これは関数内関数`create_date_time_funcs()`を用意して実装します。

この他の方法として、`create_date_time_funcs()`を使わずに、`base_date`と`unit`をグローバル変数として渡すことも可能です。ただし、グローバル変数を使用すると処理が煩雑になり、デバッグが難しくなる点に注意してください。

また、`lambda`式を使う方法もあります。例えば、日付から経過時間の軸を作成するには、以下のように書きます。
```python:日単位の2軸の場合
secax_x = ax1.secondary_xaxis(
    'top',
    functions=(
        lambda date: date - mdates.date2num(base_date),
        lambda elapsed_time: elapsed_time + mdates.date2num(base_date)
    )
)
```
```python:秒単位の2軸の場合
secax_x = ax1.secondary_xaxis(
    'top',
    functions=(
        lambda date: (date - mdates.date2num(base_date)) * 24 * 60 * 60,
        lambda elapsed_time: elapsed_time / (24 * 60 * 60) + mdates.date2num(base_date)
    )
)
```
この方法は、$n$倍の目盛りを表示するようなシンプルな変換には向いていますが、今回のように処理が複雑な場合は可読性が下がる点に注意してください。

### 様々な経過時間軸を同時に描画する方法
複数の経過時間の単位を描画するには、以下のコードのように`secondary_xaxis()`の位置を数値で指定します。`1.0`が`Top`と指定した場合と同じですが、軸がはみ出ることがあります。適宜、[`plt.subplots_adjust()`](https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.subplots_adjust.html)で配置を調整してください。配置調整のパラメータは[こちら](https://qiita.com/ultimatile/items/bc76104a17e05b8d9388)の記事で図解されており参考になります。

```python:複数の経過時間軸をプロット
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import datetime
import numpy as np

def create_date_time_funcs(base_date, unit='seconds'):
    base_date_num = mdates.date2num(base_date)  # 基準日付を内部形式に変換

    def date_to_time(date):
        """Convert matplotlib date to elapsed time."""
        elapsed_days = date - base_date_num
        if unit == 'seconds':
            elapsed_time = elapsed_days * 24 * 60 * 60
        elif unit == 'minutes':
            elapsed_time = elapsed_days * 24 * 60
        elif unit == 'hours':
            elapsed_time = elapsed_days * 24
        elif unit == 'days':
            elapsed_time = elapsed_days
        elif unit == 'years':
            elapsed_time = elapsed_days / 365.25  # 1年を約365.25日と定義
        else:
            raise ValueError("Invalid unit. Choose from 'seconds', 'minutes', 'hours', 'days', 'years'.")
        return elapsed_time

    def time_to_date(elapsed_time):
        """Convert elapsed time to matplotlib date."""
        if unit == 'seconds':
            elapsed_days = elapsed_time / (24 * 60 * 60)
        elif unit == 'minutes':
            elapsed_days = elapsed_time / (24 * 60)
        elif unit == 'hours':
            elapsed_days = elapsed_time / 24
        elif unit == 'days':
            elapsed_days = elapsed_time
        elif unit == 'years':
            elapsed_days = elapsed_time * 365.25  # 1年を約365.25日と定義
        else:
            raise ValueError("Invalid unit. Choose from 'seconds', 'minutes', 'hours', 'days', 'years'.")
        date = elapsed_days + base_date_num
        return date

    return date_to_time, time_to_date

# 基準日付を定義
base_date = datetime.datetime(2024, 5, 5, 5, 5, 5)

# 単位を指定して変換関数を作成
units = ['days', 'seconds', 'minutes', 'hours', 'years']
funcs = [create_date_time_funcs(base_date, unit) for unit in units]

# ダミーデータの作成（ランダムウォークにインフレーションバイアスを追加）
np.random.seed(7)
num_points = 10000
dates = [base_date + datetime.timedelta(days=i) for i in range(num_points)]
daily_bias = 0.00025
daily_std_dev = 0.01
values = np.cumsum(np.random.randn(num_points) * daily_std_dev + daily_bias)

# グラフに描画
fig, ax1 = plt.subplots()

ax1.plot(dates, values)
ax1.set_xlabel('Date')
ax1.set_ylabel('Value')

# 複数の副次的なX軸を追加
secax_x1 = ax1.secondary_xaxis('top', functions=funcs[0])
secax_x1.set_xlabel(f'Elapsed Time ({units[0]})')

secax_x2 = ax1.secondary_xaxis(1.5, functions=funcs[1])
secax_x2.set_xlabel(f'({units[1]})')

secax_x3 = ax1.secondary_xaxis(2.0, functions=funcs[2])
secax_x3.set_xlabel(f'({units[2]})')
secax_x3.spines['top']

secax_x4 = ax1.secondary_xaxis(2.5, functions=funcs[3])
secax_x4.set_xlabel(f'({units[3]})')

secax_x5 = ax1.secondary_xaxis(3.0, functions=funcs[4])
secax_x5.set_xlabel(f'({units[4]})')

# 主軸のフォーマットを設定
ax1.xaxis.set_major_formatter(mdates.DateFormatter('%Y-%m-%d'))
fig.autofmt_xdate()

# 余白を調整
plt.subplots_adjust(top=0.44)

plt.show()
```
出力結果（`years`の軸が少しはみ出ていますが、そういうことが起きやすいですので、注意しましょう。）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1599258/5a9b5fac-47b2-67e0-81bd-a651f14ed874.png)


## まとめ

本記事では、観測データの日付から経過時間を計算し、matplotlibを使用してグラフに表示する方法について解説しました。2軸を用いることで、日時データと経過時間を同時に表示し、データのトレンドや変動を直感的に把握することができます。この記事が、読者のデータ解析に役立つことを願っています。