matplotlibで線グラフの途中で色を変える

## 環境
+ Visual Studio Code: 1.85.1
+ Docker: 24.0.7
+ Python 3.11.6
+ matplotlib: 3.8.1

## 要約
matplotlibで折れ線グラフの途中で線の色を変更したい時には、`matplotlib.pyplot.plot()`を区間の数だけ呼び出してもよいが、実行速度に問題を感じる時は、

+ `None`を使って途切れたグラフを作成し、色の数だけ重ねる。
+ `matplotlib.collections.LineCollection`を用いる。

といった解決法もある。
~~`LineCollection`を用いるのが`matplotlib`の意図に沿っている気がするが公式文書などの確認はしていない。~~

## 動機
matplotlibで折れ線グラフ(で近似した曲線)を描く時に、グラフの途中で線の色を変更したいと思った(たとえば右肩上がりと右肩下がりの区間で色を変えるなど)。しかし`matplotlib.pyplot.plot()`にはグラフの線毎に色を変える機能はあるが、グラフの途中で色を変える機能はないらしい。

すぐに思いつきそうな解決法として、[この記事](https://ja.stackoverflow.com/questions/40938/pandas-dataframe-plot%E3%81%A7%E6%8F%8F%E5%86%99%E3%81%97%E3%81%9F%E6%8A%98%E3%82%8C%E7%B7%9A%E3%82%B0%E3%83%A9%E3%83%95%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6-x%E8%BB%B8%E3%81%AE%E9%80%94%E4%B8%AD%E3%81%8B%E3%82%89%E8%89%B2%E3%82%92%E5%A4%89%E3%81%88%E3%81%9F%E3%81%84)にあるように、`matplotlib.pyplot.plot()`を区間の数だけ呼び出し、独立したグラフの重ね合わせとして表現する方法がある。

実際この方法は機能して想定通りのグラフを描画できるのだが、VSCode上で`matplotlib.pyplot.show()`で表示させた時に、区間数が(数千とか数万とか)多くて`matplotlib.pyplot.plot()`もそれくらいの回数呼び出されると、描画にかなり(もちろん条件によるが秒〜10秒単位の)時間がかかる。VSCode上だとグラフは別ウィンドウで表示され、リサイズすると再描画されるが、その際も初回描画ほどではないが時間がかかり、反復してグラフを検討する上で不便である。

日本語で書かれた記事は少し検索しても見当たらなかった(追記: 試した後で`LineCollection`を軸に調べたらあった)ので、今回試した2つ方法について記述する。

## 解決法
### 欠損値のあるグラフを、色の数だけ重ねる方法
[こちらの記事](https://qiita.com/_Alignof/items/2200bc4448a861b826e5)にあるように、データに`None`があるところは折れ線が欠落する。これを用いて色毎に断続的なグラフを描画することができ、それらを重ね合わせることで途中で色が変わるグラフとして表示できる。

ただ、`None`の「両側の」線分が欠落するので、欠落させる区間に新たに架空の点を用意してそこに`None`の値を入れないといけないのが手間である。

### LineCollectionを用いる方法
`matplotlib.collections.LineCollection`のコンストラクタに線分のデータと対応する線分の色をそれぞれリストで渡し、下記サンプルコードのように`axes.add_collection(lc)`でグラフに一括して追加する。

## サンプルコード
``` Python3
"""
折れ線グラフの途中で色を変える.
"""
from collections import defaultdict
from datetime import datetime
from itertools import pairwise

from matplotlib.axes import Axes
import matplotlib.collections as mc

def colorful_plot(axes: Axes, x: list[float | datetime], y: list[float], colors: list[str]) -> Axes:
    """
    グラフの途中で色を変えながら描画する.

    Args:
        axes (Axes): plotの対象.
        x (list[float] | datetime): x座標. floatでなくdatetimeでも動作する。
        y (list[float]): y座標. len(x) = len(y)であるべき。
        colors (list[str]): 上記x, yの対応する座標を「始点とする」線分の色指定.
            太さや点線なども指定可能?
            len(clolrs) = len(x) -1 であるべきだが、len(clolrs) = len(x)でも良い。
            ただしlen(x)番目の色指定は無視される。
    Returns:
        描画が済んだaxes自体を返す.
    """
    data_dict = defaultdict(lambda: ([], []))
    """指定色に対応するデータのリストを返すdict."""

    # 色が違うところは欠損値として描画しないという仕様を利用して色毎に描画する方針でデータ作成
    for index1 in range(1, len(x)):
        color = colors[index1 - 1]
        data_x, data_y = data_dict[color]
        if not data_x or not data_y:
            # まだその色による描画が行われたことがない場合
            # index1 - 1 と index1 を両方追加
            data_x.append(x[index1 - 1])
            data_y.append(y[index1 - 1])
            data_x.append(x[index1])
            data_y.append(y[index1])
        elif data_x[-1] == x[index1 - 1] and data_y[-1] == y[index1 -1]: # color == colors[index -2]
            # 同じ色が連続している場合
            # index1のみ追加
            data_x.append(x[index1])
            data_y.append(y[index1])
        else:
            # 前が違う色の場合は、Noneを間に挟む
            # index1 = 1 の時は、not data_x or not data_y == trueなので、index1 >= 2
            data_x.append(x[index1 - 2])     # 想定としてdata_x[-1] =< x[index1 -2] < x[index1]
            data_y.append(None)
            # index1 - 1 と index1 を両方追加
            data_x.append(x[index1 - 1])
            data_y.append(y[index1 - 1])
            data_x.append(x[index1])
            data_y.append(y[index1])

    # 描画
    for color, data in data_dict.items():
        axes.plot(*data, color=color)
    return axes


def collection_plot(axes: Axes, x: list[float], y: list[float], colors: list[str]) -> Axes:
    """
    LineCollectionを用いてcolorful_plotと同じことを行う.
    """
    lines =  pairwise(zip(x, y))
    lc = mc.LineCollection(lines, colors=colors)
    axes.add_collection(lc)
    axes.autoscale()
    return axes
```

## 結果
この記事のためにサンプルコードを用意するのが手間なので正確な検証はしておらず体感時間になるが、初回描画に数十秒かかっていたところ、上記どちらの方法でも1秒程度で描画されるようになった。再描画も6秒くらいかかっていたグラフが、1秒以内で再描画されているように感じた。

## 考察
### LineCollectionを用いる方法
欠損値を用いる方法だと、グラデーションをつけるなど色数が区間数並に多い場合は`matplotlib.pyplot.plot()`の呼び出し回数が増えるので、描画の遅延を起こす可能性が残ると思われる。

上記サンプルコードを見ても分かるように、`LineCollection`を用いる方がコードもシンプルであり、架空の点を追加しないという意味で論理的にもシンプルである。
matplotlibの機能をうまく用いていると言え、確認はしていないがこちらが正統な解決方法なのかもしれない。

### 欠損値を使う方法の利点
一方、サンプルコードにある`colorful_plot()`では、`axes`に`matplotlib.pyplot`を入れることが可能(`collection_plot()`は`matplotlib.pyplot`に`.add_collection()`メソッドがないので`axes`に代入できない)。

また、`colorful_plot()`の方は`x: list[float | datetime]`でも動作するという柔軟性がある。

`collection_plot()`の方は内部で`axes.autoscale()`を呼んで調整する必要があるし、横軸が日付の場合はメモリに表示される値を日付に変換しなければいけない点も少し面倒といえるかもしれない。

### 既出記事との比較
試す前は[3D plotの記事](https://qiita.com/ultimatile/items/90e528ee5ac508e5ce38#%E4%B8%8D%E9%80%A3%E7%B6%9A%E5%A4%89%E5%8C%96%E7%89%88)の方がやりたいことに近いと思っていた。
試した後で改めて`LineCollection`を中心に調べてみると[こちらの記事](https://qiita.com/yusuke_s_yusuke/items/f25ca31c1069c64c06bc)で2Dの`LineCollection`を用いる方法が用いられているが、今回行いたいのは、グラデーションというよりは線分を区間ごとに数種類の色で塗り分けるような色付けであり、色指定引数の渡し方も異なっている。

英語では`LineCollection`を用いる方法について[似たような記述](https://stackoverflow.com/questions/38051922/how-to-get-differents-colors-in-a-single-line-in-a-matplotlib-figure)があるようだが、`LineCollection`に渡す名前付き引数に`cmap`を用いるか`colors`を用いるかが異なっている。[リファレンス](https://matplotlib.org/stable/api/collections_api.html#matplotlib.collections.LineCollection)を見る限りは、コンストラクタの専用引数である`colors`を用いる想定のように見えるが、どちらが良いのかまでは今回検討していない。

## 改訂履歴(表現の修正などは除く)
+ 2024/1/3: 作成
+ 2024/1/7: 既出の記事との関係性を検討した後、公開。指摘に応じて微修正。