なぜ対話型なのか
何らかのパラメータが入ったグラフを出力したい場合、パラメータを変化させる度に確認する方法としては下記のような方法が考えられます。
- 出力関数の引数を都度入力してグラフを出力
- 変化させたいパラメータの全てのグラフを出力
- 対話型でパラメータを設定してグラフを出力
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()
このように、UIで視覚的にパラメータの適正値を探すことが可能となります。
もう一つの例として、グラフの種類を変更してみます。
interact
の引数にタプルやリストを与えるとドロップダウンメニューが表示されます。
これを利用してUIでグラフの種類を変更してみます。
@interact(kind=['line', 'area'])
def plot_line_or_band(kind):
price['Adj Close'].plot(kind=kind)
このように予め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)
ここでのポイントは下記の2点です。
-
t = show(p, notebook_handle=True)
とshow
メソッドのキーワード引数にnotebook_handle=True
を設定し、グラフをハンドリング可能な状態にしていること -
push_notebook(handle=t)
にて上記で設定したハンドラに対して変更をpushしていること
このようにBokeh
ではJupyter notebook
上のグラフオブジェクトに対して変更を加えることが可能です。
前述のmatplotlib
ではグラフ全体の再描画の処理が走りましたが、Bokeh
では必要な部分のみを変更できるため、動的なグラフの処理を軽くすることができます。
Bokeh
はmatplotlib
と比較すると、後発である分、最初から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)
ボタンに関してはon_click
というイベントハンドラで制御を渡すことができます。
詳細についてはWidget Eventsのドキュメントをご確認ください。
今回は乱数でコースの座標を決めましたが実データを突っ込んで球種や球速の情報を加えれば、野球の実況サイトに見劣りしないオレオレ一球速報を実装することができると思います。
実データのとり方は前の人がやってくれたので、われこそはという方は是非作ってみてください。
まとめ
少し長くなりましたが、今回お伝えしたかったことをまとめるとこんな感じになります。
-
interact
をデコレートするだけで超かんたんにUIを作れる - 少し凝ったやつは
widgets
から目的のものを自作できる -
Bokeh
のpush_notebook
を使うと動的なグラフを作りやすくなる
ipywidgets
とBokeh
はJupyter notebook
との相性がよいツールですが、認知度が低い印象です。
これを機に使ってみようかなと思っていただけた方がいれば幸いです。