LoginSignup
168
189

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

Last updated at Posted at 2024-02-08

動機

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

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

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

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

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

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

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

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

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

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

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

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

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

whitecanvasを用いたデータ可視化

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

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バックエンドは次のように指定します。

import numpy as np
from whitecanvas import new_canvas

canvas = new_canvas("matplotlib:qt")

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

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

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

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

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

# 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

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

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

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

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

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

複雑なプロットの描画

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

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

# データの用意
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

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

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

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

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

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

image.png

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

DataFrameからの描画

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

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

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

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

numerical × numerical

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

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

categorical × numerical

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

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

cat_x()でx軸がcategoricalだと解釈します。cat_x(), cat_y()で区別するため、seabornplotly.expressorient的な引数はなくなります。

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

メディア1-output.gif

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

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

他には...

多次元データの可視化

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

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

マウスイベント

canvas.eventsにcanvasで起きたイベントを処理するオブジェクトがあり、その中のcanvas.events.mouse_movedでマウスイベントを拾う例です。さすがにマウスイベントのコールバック関数ともなると少し煩雑ですので、気になる方はソースコードをご覧ください。

メディア3-output.gif

終わりに

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

  1. Q: altairのサポートは? A: 難しくて諦めた。

  2. Q: holoviewsを使えばいいのでは? A: holoviewsもmatplotlib, plotly, bokehを切り替えられる設計になっているが、(1) Qtアプリケーションでpyqtgraphを使いたい、(2) 可変長引数使いすぎ、(3) import遅い、などの理由で気に入らなかった。

  3. これは一応TypedDictをまじめに定義すれば解決します。

168
189
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
168
189