【Python】Plot系ライブラリ全統一した

# 動機

Pythonはグラフ描画系のライブラリが豊富です。例えば、

- [matplotlib](https://github.com/matplotlib/matplotlib) / [seaborn](https://github.com/mwaskom/seaborn)
- [pyqtgraph](https://github.com/pyqtgraph/pyqtgraph)
- [plotly](https://github.com/plotly/plotly.py)
- [bokeh](https://github.com/bokeh/bokeh)
- [vispy](https://github.com/vispy/vispy)

などがあります。それぞれ長短あるのはまあ言うまでもないですが、統一的なAPIが欲しくなりますよね...ということで統一[^1] [^2]したのがこちらになります。今後、ちょっとした解析、デスクトップアプリ、ウェブブラウザなど、様々な場面で同じコードで動かせたらきっと楽しいですね。

[^1]: **Q:** [altair](https://github.com/altair-viz/altair)のサポートは？ **A:** 難しくて諦めた。
[^2]: **Q:** [holoviews](https://github.com/holoviz/holoviews)を使えばいいのでは？ **A:** holoviewsもmatplotlib, plotly, bokehを切り替えられる設計になっているが、(1) Qtアプリケーションでpyqtgraphを使いたい、(2) 可変長引数使いすぎ、(3) import遅い、などの理由で気に入らなかった。

https://github.com/hanjinliu/whitecanvas

ラッパーを作るということは、APIの設計をより理想的にできるということでもあります。これまでのPlot系ライブラリの悪いところを一掃しましょう。

# 既存のPlot系ライブラリのダメなところ

### 1. IDEの型チェック・補完が効かない

matplotlibは`plt.subplots`や`plt.errorbar`など、返り値が引数によって変ったり、`*args`や`**kwargs`のせいで補完が効かずドキュメントを見ないと使い方が曖昧になりがちです。しかしmatplotlibは比較的まだマシ。plotlyやbokehはランタイムの型チェックは（JavaScriptに渡して動かす関係上?）非常に厳しい割に静的解析に全く活かされていない。pyqtgraphに至っては`setattr`でmethodを動的に付加しまくっているせいでmethod名すら補完されず使いこなすのは至難の業。

せっかくPythonは型注釈が充実しているのだから、コーディングの段階で安全に書けるようにしていきたいですよね。

### 2. ハイレベルなAPIに頼りすぎ

pyqtgraphはQtの思想を強く受け継いでいるので、この点はほぼクリアしています。vispyもローレベルの実装を意識しているため、エンドユーザーとして使うには使いづらいもののこの点は問題ないです。しかし問題はほかの皆さん。カスタマイズ用のパラメータを全部関数の引数に突っ込んだせいでカオスなことになっています。例えばplotlyの[scatter](https://plotly.com/python-api-reference/generated/plotly.express.scatter.html)という関数。ただのscatter plotで引数が（数え間違えていなければ）41個あります。どうしてこうなった...

また、ハイレベルなAPIにありがちな欠点として、関数が無駄に賢かったり、相容れない引数があったりします。例えば[seabornのkdeplot](https://seaborn.pydata.org/generated/seaborn.kdeplot.html)なんかは、x/yで与えられた情報から判断してプロットの仕方を変えたり、何故か累積分布をサポートしていたりと、謎に機能を詰め込み放題という状況。

丁寧に関数を設計し、method chainなどで機能を分類していくのが望ましいでしょう。

### 3. プロパティが整然としていなかったりする

タイトルの文字、色、フォント、x軸の範囲などなど、ここらのプロパティをどう変えるかはそうそう覚えられません。namespaceをうまく分けることで整理し、自動補完と合わせてどうにかドキュメントなしでスムーズにコーディングできるようにしたいですね。

# `whitecanvas`を用いたデータ可視化

では`whitecanvas`でデータを可視化していきましょう。PyPIからインストールします。

```sh
pip install whitecanvas -U
pip install whitecanvas[matplotlib] -U  # matplotlibを使う場合
```

基本的に`Canvas`にレイヤーを追加していく構成になります。

### 共通の引数名

- 線を持つグラフ (line plot, error barなど)
    - `color`
    - `width`
    - `style`
- 面を持つグラフ (bar plot, scatter plotなど)
    - `color`
    - `hatch`

### 共通のメソッド名

- `add_*` ... 新しいレイヤーをCanvasに追加し、そのレイヤーを返す (`add_line`など)。
- `with_*` ... すでにあるレイヤーに何かを追加し、追加されたあとレイヤーを返す (`with_edge`など)。
- `update` ... すでにあるグラフの要素の情報を更新し、自身を返す。

## 基本的な描画

`new_canvas`で描画用の`Canvas`を用意します。引数で"(ライブラリ名):(さらに1つ階層が下のバックエンド名(省略可))"を指定します。例えば、matplotlibのQtバックエンドは次のように指定します。

```python
import numpy as np
from whitecanvas import new_canvas

canvas = new_canvas("matplotlib:qt")
```

関数は基本的に`matplotlib`に似た書き方になります。`show()`で表示します。

```python
x = np.linspace(-np.pi, np.pi, 100)
line = canvas.add_line(np.cos(x*2), np.sin(x*3), color="red")
markers = canvas.add_markers(np.cos(x), np.sin(x), color="gray")
canvas.show()
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1752357/b9cbfc55-4c9f-c57c-7a53-1c346a452e5d.png)

レイヤーは`canvas.layers`に格納されます。

```python
canvas.layers
# Out: LayerList([Line<'line'>, Markers<'markers'>])
```

色などのデザインは後からも変更可能です（これはGUIを作るうえでは非常に重要）。

```python
# Lineは線だけなので、直下のpropertyから変更
line.color = "blue"  # 色の変更
line.style = "--"
line.width = 3

# Markersは線と面を持つので、`face`と`edge`に分かれている
markers.face.color = "pink"
markers.edge.color = "purple"

# Markers固有のものは直下のpropertyにある
markers.size = 18
markers.symbol = "D"
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1752357/34667f47-ad2f-5325-fdbd-b487dae1d689.png)

## 軸ラベル・範囲などの更新

`canvas`のnamespaceとしてきっちり整理しました。

- `canvas.x`: x軸に関するプロパティ。以下y軸も同様。
    - `canvas.x.label`: x軸ラベルに関するプロパティ。
    - `canvas.x.ticks`: x軸の目盛りに関するプロパティ
- `canvas.title`: タイトルに関するプロパティ。

例えば次のように書きます。ここではbokehで表示してみましょう。

```python
canvas = new_canvas("bokeh")
canvas.add_bars([0, 1, 2], [5, 4, 6])  # とりあえず棒グラフ

canvas.x.lim = (-1, 3)  # 範囲の更新
canvas.x.label.text = "X axis"  # x軸ラベルの文字を更新
canvas.x.label.color = "red"  # x軸ラベルの色を更新
canvas.y.ticks.set_labels([4, 5, 6], ["[4]", "[5]", "[6]"])  # y軸の目盛りを自前で定義
canvas.title.text = "Test namespaces"  # タイトルを更新
canvas.title.size = 28  # タイトルのフォントサイズ更新
canvas.show()
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1752357/fa9b4f05-802d-95ad-3f54-335174d8c9d8.png)


## 複雑なプロットの描画

複雑なプロットほど設定が多くなり、APIが煩雑になります。seabornでは[histplot](https://seaborn.pydata.org/generated/seaborn.histplot.html#seaborn.histplot)の`**line_kw`みたいに、子要素の引数を`dict`で渡すことで単純化していますが、当然`dict`だとキーが不確定なので暗記とドキュメントに頼る羽目になります[^3]。`whitecanvas`ではmethod chainで要素を随時追加していく方針です。

[^3]: これは一応`TypedDict`をまじめに定義すれば解決します。

以下、例として線・マーカー・エラーバーで時系列データの可視化をする場合です。適当に減衰振動っぽいデータを用意します。データサイエンスらしくpyqtgraphを使いましょう。とりあえず線だけプロットしてみます。

```python
# データの用意
time = np.linspace(0, 5, 25)
observations = np.exp(-time/3) * np.cos(time*3)
errors = np.abs(observations) / 3 + 0.3

# 観測値だけプロット
canvas = new_canvas("pyqtgraph")
canvas.add_line(time, observations)
canvas.show()
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1752357/3bbafb8a-99a8-24e8-068e-0be49cb551fa.png)

線・マーカー・エラーバーとなると、色だけでも

- 線の色
- マーカーの面の色
- マーカーの枠線の色
- エラーバーの色

の4つあり、これに加えて点線にするかどうかやらマーカーのサイズやら指定できるようにすれば当然カオスになります。かといって、それぞれ別々にプロットすると引数に重複が生じて無駄や書き間違えが生じます。

`whitecanvas`では`with_markers`でマーカーを、`with_yerr`でエラーバーを追加します。鉛筆でグラフを書くように、1つずつ丁寧に情報を指定していきます。

```python
canvas = new_canvas("pyqtgraph")
layer = (
    canvas
    .add_line(time, observations)
    .with_markers()
    .with_yerr(errors, capsize=0.2)
)
canvas.show()
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1752357/58901bb2-6096-8651-42d1-6dd89f0049d5.png)

当然引数は分散されるうえ、`add_markers`や`add_errorbars`といった個別でプロットするメソッドと（座標を除いて）全く同じ引数を取るので、別々でプロットする場合と同様に混乱が生じないです。

## DataFrameからの描画

既存のライブラリのいいところは真似するべきです。真似すべき点として、DataFrameを直接プロットするというものが代表的だと言えます。`seaborn`, `plotly.express`がこれに対応し、また`bokeh`は初めからこれを意識した設計です。

このアイデアを踏襲しつつ、より良いものにするために、`whitecanvas`では以下の点に注意しています。

- [pandas](https://github.com/pandas-dev/pandas)にこだわらない &rarr;  [polars](https://github.com/pola-rs/polars)使いとしては、pandasのimport時間が無駄なうえ、できればインストールしたくない。
- データとx/yの名前で一度plotterを作る &rarr; これは[seaborn.objects.Plot](https://seaborn.pydata.org/generated/seaborn.objects.Plot.html)から着想を得たもの。確かに考えてみれば、1つのDataFrameに対してx/y軸に用いるデータが変わることはほとんどない（変わったら同じ場所に重ねる意味があまりない）ので、新しくオブジェクトを作ってしまうのが最適解だと思われる。
- categorical dataかnumeric dataかでplotterを分ける &rarr; いい感じに判断してもらう、みたいな実装は良くない。numeric &times; numericでバイオリンプロットなんて書かないわけなので、plotter自体を分類してしまうのがよい。

例として、irisの可視化をしてみます。データセットはseabornから取ってくることにします。

#### numerical &times; numerical

```python
from seaborn import load_dataset
from whitecanvas import new_canvas

# irisのデータをpandas.DataFrameで取得
df = load_dataset("iris")

canvas = new_canvas("matplotlib:qt")

(
    canvas
    .cat(df, x="sepal_length", y="sepal_width")
    .add_markers(color="species")
)
canvas.add_legend()
canvas.show()
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1752357/4127df70-84bb-ac39-bae6-ff48a2b4190f.png)

`cat()`でnumeric &times; numericの可視化を行うplotterを作成します。ここで使うDataFrameとx/y軸を指定します。その後`add_markers()`メソッドによって、どのようにマーカーを表示するかを決めていきます。ここでは"species"ごとに色を分けています。`canvas.add_legend()`で自動で凡例をつけられます。

#### categorical &times; numerical

続いてはカテゴリ変数を横軸に取る場合です。"petal_width"が大きいかどうかのカラム"is_petal_wide"も足しておきます。これをマーカーシンボルで区別してみましょう。

```python
from seaborn import load_dataset
from whitecanvas import new_canvas

# irisのデータをpandas.DataFrameで取得
df = load_dataset("iris")
df["is_petal_wide"] = df["petal_width"] > df["petal_width"].mean()

canvas = new_canvas("matplotlib:qt")

(
    canvas
    .cat_x(df, x="species", y="sepal_length")
    .add_stripplot(symbol="is_petal_wide")
)
canvas.show()
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1752357/c081192b-7430-2577-c7f0-5ba0e4250acf.png)

`cat_x()`でx軸がcategoricalだと解釈します。`cat_x()`, `cat_y()`で区別するため、`seaborn`や`plotly.express`の`orient`的な引数はなくなります。

さらに、`plotly`や`bokeh`の素晴らしい点として、各点の情報をhoverによって見ることができるというものがあります。大変素晴らしいので、`matplotlib`や`pyqtgraph`でもできるようにしました。デフォルトでtooltipが表示されます。

![メディア1-output.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1752357/1fc3d64f-3a82-03d9-287e-0617acea67df.gif)


各カテゴリの平均値といったaggregationもplotterに実装されています。`mean()`によって新しいplotterを返すという設計です。

```python
from seaborn import load_dataset
from whitecanvas import new_canvas

# irisのデータをpandas.DataFrameで取得
df = load_dataset("iris")

canvas = new_canvas("matplotlib:qt")

plotter = canvas.cat_x(df, x="species", y="sepal_width")
plotter.add_stripplot(color="orange")
plotter.mean().add_line(width=3, color="red")
canvas.show()
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1752357/36438d13-ffb4-041f-9db4-4cfee0606907.png)

## 他には...

### 多次元データの可視化

多次元関係が`dims` namespaceに集められています。まだローレベルなものしか用意できてませんがいい感じに動きます。

```python
from skimage.data import brain
from whitecanvas import new_canvas

img = brain()

canvas = new_canvas("matplotlib:qt")

# x, yと"time"軸に沿って3次元画像imgを表示
canvas.dims.in_axes("time").add_image(img)

# スライダーウィジェットを作って表示
canvas.dims.create_widget().show()

# canvasを表示
canvas.show()
```

![メディア2-output.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1752357/5e4ddd4c-65bd-2909-a3c5-120470c1834f.gif)

### マウスイベント

`canvas.events`にcanvasで起きたイベントを処理するオブジェクトがあり、その中の`canvas.events.mouse_moved`でマウスイベントを拾う例です。さすがにマウスイベントのコールバック関数ともなると少し煩雑ですので、気になる方は[ソースコード](https://github.com/hanjinliu/whitecanvas/blob/main/examples/draw_with_mouse.py)をご覧ください。

![メディア3-output.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1752357/1a357f03-1618-147c-38b9-2bb3c304745d.gif)

# 終わりに

APIが整理されるととても気持ちよくプログラミングができていいですね。
興味がありましたら是非インストールして使ってみてください。