動機
Pythonはグラフ描画系のライブラリが豊富です。例えば、
などがあります。それぞれ長短あるのはまあ言うまでもないですが、統一的なAPIが欲しくなりますよね...ということで統一1 2したのがこちらになります。今後、ちょっとした解析、デスクトップアプリ、ウェブブラウザなど、様々な場面で同じコードで動かせたらきっと楽しいですね。
ラッパーを作るということは、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という関数。ただの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()
レイヤーは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"
軸ラベル・範囲などの更新
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()
複雑なプロットの描画
複雑なプロットほど設定が多くなり、APIが煩雑になります。seabornではhistplotの**line_kw
みたいに、子要素の引数をdict
で渡すことで単純化していますが、当然dict
だとキーが不確定なので暗記とドキュメントに頼る羽目になります3。whitecanvas
では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()
線・マーカー・エラーバーとなると、色だけでも
- 線の色
- マーカーの面の色
- マーカーの枠線の色
- エラーバーの色
の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()
当然引数は分散されるうえ、add_markers
やadd_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()
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()
cat_x()
でx軸がcategoricalだと解釈します。cat_x()
, cat_y()
で区別するため、seaborn
やplotly.express
のorient
的な引数はなくなります。
さらに、plotly
やbokeh
の素晴らしい点として、各点の情報をhoverによって見ることができるというものがあります。大変素晴らしいので、matplotlib
やpyqtgraph
でもできるようにしました。デフォルトでtooltipが表示されます。
各カテゴリの平均値といった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()
他には...
多次元データの可視化
多次元関係が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()
マウスイベント
canvas.events
にcanvasで起きたイベントを処理するオブジェクトがあり、その中のcanvas.events.mouse_moved
でマウスイベントを拾う例です。さすがにマウスイベントのコールバック関数ともなると少し煩雑ですので、気になる方はソースコードをご覧ください。
終わりに
APIが整理されるととても気持ちよくプログラミングができていいですね。
興味がありましたら是非インストールして使ってみてください。