More than 1 year has passed since last update.

なぜ対話型なのか

何らかのパラメータが入ったグラフを出力したい場合、パラメータを変化させる度に確認する方法としては下記のような方法が考えられます。

  1. 出力関数の引数を都度入力してグラフを出力
  2. 変化させたいパラメータの全てのグラフを出力
  3. 対話型でパラメータを設定してグラフを出力

1の方法は入力が少し面倒です。IPythonのコンソール上ならまだしも、Jupyter notebookで関数を実行して、実行した関数の引数を書き換えて・・・はい、面倒ですね。

2の方法はどうでしょうか。2〜3個のグラフを一度に出力して比較するのは十分実用的と言えそうです。

しかし、みたいパラメータが100個あるような状況では人間の目で確認するのが大変そうです。

ということで、3の方法を実装してみます。

ipywidgetsのインストール

Jupyter notebookで対話型のUIを実装するにはipywidgetsを使うのがお手軽です。

インストール方法は下記のとおりです。

pipの場合

pip install ipywidgets
jupyter nbextension enable --py widgetsnbextension

condaの場合

conda install -c conda-forge ipywidgets

ipywidgets.interact

目的の関数にipywidgets.interactをデコレートするだけで、対話型のUIを作ることが可能です。

下記の例では株価を取得し、n日間の終値の移動平均をグラフに出力しています。

interactの引数に数値を与えると、Jupyter notebook上にスライダーが表示され、これを調整することで移動平均のパラメータが変化します。

n=(5, 30)は最小値を5、最大値を30に設定しています。

n=10と、特に制限を設けずにデフォルト値のみ設定する方法もあります。

%matplotlib inline

from pandas_datareader.data import DataReader
from ipywidgets import interact

price = DataReader('^GSPC', 'yahoo', start='2016-01-01', end='2016-12-31')


@interact(n=(5, 30))
def plot_rolling_mean(n):
    price['Adj Close'].plot()
    price['Adj Close'].rolling(n).mean().plot()

rolling_mean.gif

このように、UIで視覚的にパラメータの適正値を探すことが可能となります。

もう一つの例として、グラフの種類を変更してみます。

interactの引数にタプルやリストを与えるとドロップダウンメニューが表示されます。

これを利用してUIでグラフの種類を変更してみます。

@interact(kind=['line', 'area'])
def plot_line_or_band(kind):
    price['Adj Close'].plot(kind=kind)

chart_type.gif

このように予めUIと選択肢を用意しておくことで、プログラムがわからない相手に対しても、直感的に操作を促すことが可能となります。

ipywidgetsをも少し詳しく

ドキュメントにもありますが、ipywidgetsが提供しているUIの一覧はipywidgets.widgets.Widget.widget_typesから参照可能です。

import ipywidgets as widgets
widgets.Widget.widget_types

これだけあります・・・

{'Jupyter.Accordion': ipywidgets.widgets.widget_selectioncontainer.Accordion,
 'Jupyter.BoundedFloatText': ipywidgets.widgets.widget_float.BoundedFloatText,
 'Jupyter.BoundedIntText': ipywidgets.widgets.widget_int.BoundedIntText,
 'Jupyter.Box': ipywidgets.widgets.widget_box.Box,
 'Jupyter.Button': ipywidgets.widgets.widget_button.Button,
 'Jupyter.Checkbox': ipywidgets.widgets.widget_bool.Checkbox,
 'Jupyter.ColorPicker': ipywidgets.widgets.widget_color.ColorPicker,
 'Jupyter.Controller': ipywidgets.widgets.widget_controller.Controller,
 'Jupyter.ControllerAxis': ipywidgets.widgets.widget_controller.Axis,
 'Jupyter.ControllerButton': ipywidgets.widgets.widget_controller.Button,
 'Jupyter.Dropdown': ipywidgets.widgets.widget_selection.Dropdown,
 'Jupyter.FlexBox': ipywidgets.widgets.widget_box.FlexBox,
 'Jupyter.FloatProgress': ipywidgets.widgets.widget_float.FloatProgress,
 'Jupyter.FloatRangeSlider': ipywidgets.widgets.widget_float.FloatRangeSlider,
 'Jupyter.FloatSlider': ipywidgets.widgets.widget_float.FloatSlider,
 'Jupyter.FloatText': ipywidgets.widgets.widget_float.FloatText,
 'Jupyter.HTML': ipywidgets.widgets.widget_string.HTML,
 'Jupyter.Image': ipywidgets.widgets.widget_image.Image,
 'Jupyter.IntProgress': ipywidgets.widgets.widget_int.IntProgress,
 'Jupyter.IntRangeSlider': ipywidgets.widgets.widget_int.IntRangeSlider,
 'Jupyter.IntSlider': ipywidgets.widgets.widget_int.IntSlider,
 'Jupyter.IntText': ipywidgets.widgets.widget_int.IntText,
 'Jupyter.Label': ipywidgets.widgets.widget_string.Label,
 'Jupyter.PlaceProxy': ipywidgets.widgets.widget_box.PlaceProxy,
 'Jupyter.Play': ipywidgets.widgets.widget_int.Play,
 'Jupyter.Proxy': ipywidgets.widgets.widget_box.Proxy,
 'Jupyter.RadioButtons': ipywidgets.widgets.widget_selection.RadioButtons,
 'Jupyter.Select': ipywidgets.widgets.widget_selection.Select,
 'Jupyter.SelectMultiple': ipywidgets.widgets.widget_selection.SelectMultiple,
 'Jupyter.SelectionSlider': ipywidgets.widgets.widget_selection.SelectionSlider,
 'Jupyter.Tab': ipywidgets.widgets.widget_selectioncontainer.Tab,
 'Jupyter.Text': ipywidgets.widgets.widget_string.Text,
 'Jupyter.Textarea': ipywidgets.widgets.widget_string.Textarea,
 'Jupyter.ToggleButton': ipywidgets.widgets.widget_bool.ToggleButton,
 'Jupyter.ToggleButtons': ipywidgets.widgets.widget_selection.ToggleButtons,
 'Jupyter.Valid': ipywidgets.widgets.widget_bool.Valid,
 'jupyter.DirectionalLink': ipywidgets.widgets.widget_link.DirectionalLink,
 'jupyter.Link': ipywidgets.widgets.widget_link.Link}

流石にこれを全て説明する体力はないので、一例として、ipywidgets.widgets.Dropdownを使ってみます。

from IPython.display import display
d = widgets.Dropdown(options=['red', 'green', 'blue'], value='blue')


def on_value_change(change):
    print(change['new'])


d.observe(on_value_change, names='value')

display(d)

IPython.display.displayにてドロップダウンを表示し、observeメソッドにてイベントをハンドリングしています。

キーワード引数namesにはon_value_change関数に渡すプロパティが渡されます。通常は'value'を使用します。

ドロップダウンの値が変更されると、on_value_changeが呼び出され、'value'プロパティの値が出力されます。

Bokehのpush_notebookと組み合わせてみる

Bokehにはbokeh.io.push_notebookという便利なメソッドがあり、既に出力されているグラフをハンドリングして、内容の変更をpushさせることができます。

前述したドロップダウンを使用して、グラフオブジェクトの色を動的に変更してみます。

from IPython.display import display
import ipywidgets as widgets
from bokeh.io import output_notebook, push_notebook
from bokeh.plotting import figure, show

d = widgets.Dropdown(options=['red', 'green', 'blue'], value='blue')


def on_value_change(change):
    r.glyph.fill_color = change['new']
    push_notebook(handle=t)


d.observe(on_value_change, names='value')

p = figure(width=250, height=250)
r = p.circle(1, 1, size=20, line_color=None)

output_notebook()

display(d)
t = show(p, notebook_handle=True)

change_color.gif

ここでのポイントは下記の2点です。

  • t = show(p, notebook_handle=True)showメソッドのキーワード引数にnotebook_handle=Trueを設定し、グラフをハンドリング可能な状態にしていること
  • push_notebook(handle=t)にて上記で設定したハンドラに対して変更をpushしていること

このようにBokehではJupyter notebook上のグラフオブジェクトに対して変更を加えることが可能です。

前述のmatplotlibではグラフ全体の再描画の処理が走りましたが、Bokehでは必要な部分のみを変更できるため、動的なグラフの処理を軽くすることができます。

Bokehmatplotlibと比較すると、後発である分、最初からJupyter notebookを意識した機能があり、Jupyter notebookとの相性がよい可視化ツールであると言えそうです。

おまけ(という名の本編)

ここまでが前置きでした。

本記事はjupyter notebook Advent Calendar 2016 16日目です。

15日目がやきうの人だったので、続けてやきうネタをやってみます。

先程のグラフ上の円の位置を動的に変化させて、上中下に投げ分けができるピッチャーがいるという前提で、簡易的なピッチングマシンを作ってみます。

from random import randint
from bokeh.io import output_notebook, push_notebook
from bokeh.plotting import figure, show
from IPython.display import display
import ipywidgets as widgets


STRIKE_ZONE_HEIGHT = 750  # ストライクゾーンの高さ
STRIKE_ZONE_WIDTH = 432  # ストライクゾーンの幅
STRIKE_ZONE_DIV = 3  # 3分割して投げ分ける
zone_low = (0, int(STRIKE_ZONE_HEIGHT / STRIKE_ZONE_DIV))  # 低めのレンジ
zone_mid = (zone_low[1], zone_low[1] * 2)  # 真ん中のレンジ
zone_high = (zone_mid[1], STRIKE_ZONE_HEIGHT)  # 高めのレンジ


# コースを決定
def get_cause(zone):
    return randint(0, STRIKE_ZONE_WIDTH), randint(*zone)


# 決められたコースに投げる
def pitch(zone):
    x, y = get_cause(zone)
    r.data_source.data['x'] = [x]
    r.data_source.data['y'] = [y]
    # ここがポイント!指定したハンドルにpush
    push_notebook(handle=t)


# ボタンをクリックしたときの動作
def on_button_clicked(b):
    cause_dict = {'High': zone_high, 'Mid': zone_mid, 'Low': zone_low}
    pitch(cause_dict[b.description])

# ボタンオブジェクトを作成
h = widgets.Button(description="High")
m = widgets.Button(description="Mid")
l = widgets.Button(description="Low")

# クリックしたときのイベントをハンドリング
h.on_click(on_button_clicked)
m.on_click(on_button_clicked)
l.on_click(on_button_clicked)


output_notebook()


p = figure(
    width=250,
    height=250,
    x_range=(0, STRIKE_ZONE_WIDTH),
    y_range=(0, STRIKE_ZONE_HEIGHT))
# 初期値
r = p.circle([STRIKE_ZONE_WIDTH / 2], [STRIKE_ZONE_HEIGHT / 2], size=20)
# notebook_handle=Trueをつけることで、後で操作できるように
t = show(p, notebook_handle=True)
display(h)
display(m)
display(l)

pitch.gif

ボタンに関してはon_clickというイベントハンドラで制御を渡すことができます。

詳細についてはWidget Eventsのドキュメントをご確認ください。

今回は乱数でコースの座標を決めましたが実データを突っ込んで球種や球速の情報を加えれば、野球の実況サイトに見劣りしないオレオレ一球速報を実装することができると思います。

実データのとり方は前の人がやってくれたので、われこそはという方は是非作ってみてください。

まとめ

少し長くなりましたが、今回お伝えしたかったことをまとめるとこんな感じになります。

  • interactをデコレートするだけで超かんたんにUIを作れる
  • 少し凝ったやつはwidgetsから目的のものを自作できる
  • Bokehpush_notebookを使うと動的なグラフを作りやすくなる

ipywidgetsBokehJupyter notebookとの相性がよいツールですが、認知度が低い印象です。

これを機に使ってみようかなと思っていただけた方がいれば幸いです。

Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.