個人的なグラフ描画用のパッケージに機能を追加した

github.com

個人的なグラフ描画用のパッケージに機能を追加した。その前にこのutataneというパッケージが何をするものなのかを整理してみる気になった。 何をするものなのかというと、グラフを描く等に関する忌避感を緩和するためのもの。だるいと思う率を下げたい感じ。

グラフを描くだるさ

グラフを書くだるさには2つある

  • matplotlib内の話
  • matplotlib外の話

前者はmatplotlib(pyplot)が状態を持っていてつらいという話。もっときれいにわかりやすくコードが書けるのでは?という話。 これはこれで魅力的ではあるのだけれど。今回のスコープの対象外。

後者の方が改善対象。

matplotlibの外の話

前回の記事でも少し触れたけれどグラフの利用方法は2つある(まだ2つしか考えていないという意味)。

  • グラフの表示(show)
  • グラフを画像として保存(dump)

そして何らかのコードが実行され、その結果に対して上の2つのどちらかのaction(show,dumpの内のどちらか)が行われるという風に捉えていた。

00plot.py

from utatane import as_command


@as_command
def main(plt):
    xs = list(range(1, 11))
    ys = list(map(lambda x: x * x, xs))
    plt.plot(xs, ys)

このコードの中にmatplotlibの外のだるさが存在する。さてここで f(x) = x * x という関数ではなく g(x) = math.sqrt(x) のような関数の値をplotしたくなったらどうするか?あるいは[1,11]の範囲ではなく[1,20]の範囲で表示したくなったらどうするかという話がある。

データの収集部分とデータの描画部分を分ける

先程のだるさを考えて見るに、データの収集部分は描画部分から切り離しておきたいという気持ちが生まれてくる。ところで、データの収集部分とデータの描画部分を分ける試みは結構普通のことでmatplotlibのexamplesでも行われている(e.g. レーダーチャートの例とか。収集部分はexample_data()という関数に切り出している)。

真似してみると以下の様な形。

01plot.py

from utatane import as_command


def example_data(f):
    xs = list(range(1, 11))
    ys = list(map(f, xs))
    return [xs, ys]


@as_command
def main(plt):
    data = example_data(lambda x: x * x)
    plt.plot(data[0], data[1])

たしかにデータの収集部分と描画部分を分けてあげるなら見通しは良くなっている気がする。

でも、例えばここで他の関数を適用した結果をplotしたいとなったらどうだろう?結局ほとんどコピペした01plot2.pyのようなものを作る必要が出てくる。

yield_fixtureの話

ところで現在作っている環境ではちょっと違う形で依存するデータを渡している。pytestのfixtureに擬えてグラフに必要な部分をfixtureと呼ぶことにする。そしてそのfixtureはyield_fixtureというデコレータが付与された関数で計算される。結果は引数となって注入される。以下の様な感じ。

02usefixture.py

from utatane import as_command, yield_fixture


@yield_fixture
def data():
    f = lambda x: x * x  # NOQA
    xs = list(range(1, 11))
    ys = list(map(f, xs))
    yield {"data": [xs, ys]}


@as_command
def render(plt, data):
    plt.plot(data[0], data[1])

ちょっと、f()に当たる部分をどの位置におけば良いのかまだ完全にはイメージがついていないので少しコードは変わっているけれど。以下の様に考えると良い感じ。

  • yield_fixture付きの関数によってグラフに必要なデータ(fixture)が収集される
  • as_command付きの関数はグラフの描画を行う関数

何が言いたいかというと、main()という名前の関数ではなくrender()という名前の関数にしたことも関係するのだけれど、utatane.as_commandというのは、グラフの描画の処理を、コマンドラインアプリケーションに持ち上げる(変換する)修飾子(decorator)ということ。

そのように考えると何が嬉しいのかというと、グラフの描画関数に引数として渡されるfixture部分は何かという意味合いを決める事ができる。例えばyield_fixtureで計算された値はデフォルトの依存値として扱うと良さそうという風に。そしてas_commandによって変換されたアプリケーションはこの依存値をどうするかということを考えてみると、コマンドライン引数として扱うというのが自然な気がしてきた。

つまりこういう形。

$ python 02usefixture.py -h
usage: 02usefixture.py [-h] [--data DATA] {dump,show} ...

positional arguments:
  {dump,show}

optional arguments:
  -h, --help   show this help message and exit
  --data DATA

yield_fixture でfixtureとして扱われて欲しい値はコマンドライン引数として受け取れるようになる。例えばjson,yamlとか拡張子を見て良い感じに値を取り出してあげれば良さそう。すると何が嬉しいかというと、無引数ではどのような表示になるかのサンプルを見ることができ、引数を与えてバッチスクリプトのようにグラフを生成するコマンドとして扱うことができるようになる。fixtureという形で依存値を切り出しておいたのでこういうことが出来るようになった。

こういう感じに。

$ python 02usefixture --data data0.json dump data0.png
$ python 02usefixture --data data1.json dump data1.png
$ python 02usefixture --data data2.json dump data2.png

もちろん、その時々でのグラフを単に表示して確認したい場合にはshowすれば良い。

$ python 02usefixture --data data0.json

サンプル

サンプルはこの辺に書いた

例えば、元々テキトウな関数をplotする例を書いておき

$ python plot.py

without-data

その後、dict,set,list毎のメモリー消費量の様な値をplotしてみるというように

$ python gen.py > data.json
$ python plot.py --data data.json

with-data

この時のコードはこういう感じ

plot.py

import math
from utatane import as_command, yield_fixture


@yield_fixture
def data():
    data = [
        [(i, i) for i in range(1, 11)],
        [(i, i * i) for i in range(1, 11)],
        [(i, math.sqrt(i)) for i in range(1, 11)],
    ]
    yield {"data": {"values": data, "labels": ["i", "i*i", "sqrt(i)"]}}


@as_command
def render(plt, *, data):
    for label, rows in zip(data["labels"], data["values"]):
        xs = [row[0] for row in rows]
        ys = [row[1] for row in rows]
        plt.plot(xs, ys, label=label)