Jupyter を使ってデータを可視化していると、似たようなグラフを何度も描くことがある。 そんなとき、変数の値を変更しながらグラフを描画するセルを実行しまくるのは効率があまりよくない。 そこで、今回は ipywidgets を使って簡単な UI を作ることで、Jupyter でインタラクティブな操作ができるようにしてみる。 グラフの描画には、今回は主に Matplotlib を使うことを想定している。
使った環境は次のとおり。
$ sw_vers ProductName: macOS ProductVersion: 11.2.2 BuildVersion: 20D80 $ python -V Python 3.9.2 $ pip list | grep widgets ipywidgets 7.6.3 jupyterlab-widgets 1.0.0 widgetsnbextension 3.5.1
もくじ
- もくじ
- 下準備
- ウィジェットを作る
- イベントハンドラと標準出力
- 複数のウィジェットを連携させる
- ウィジェットをグローバルスコープに置かない
- 値の変更を監視する
- ウィジェットの配置を工夫する
- Matplotlib と連携させる
- 別のウィジェットのイベントでウィジェットの値を更新する
- 複数のウィジェットで値を同期する
下準備
下準備として、必要なパッケージをインストールしておく。
$ pip install ipywidgets jupyterlab matplotlib
そして、JupyterLab を起動する。
$ jupyter-lab
ウィジェットを作る
ここからは、コードを JupyterLab のセル内で実行することを想定する。
まずは、ipywidgets
パッケージを widgets
という名前でインポートしておこう。
名前を変更しているのは、公式のサンプルコードがこのやり方をしているため。
import ipywidgets as widgets
たとえば、もっとも単純なサンプルとしてボタンを作ってみよう。
はじめに、widgets.Button
クラスをインスタンス化する。
button = widgets.Button(description='Click me')
上記でインスタンス化したボタンを表示するには、たとえばセルの最後で評価させる方法がある。
button
一方で、上記のやり方はウィジェットが複数あったり、複雑なパターンを扱いにくい。
そのため IPython.display.display()
関数の引数にウィジェットを渡していくやり方がおそらく分かりやすいと思う。
JupyterLab だとインポートしないで使えるっぽいけど、念のためインポートした上でボタンを可視化するサンプルコードを以下に示す。
from IPython.display import display display(button)
もちろん、見え方は先ほどと同じ。
ちなみに、ボタン以外にもウィジェットはたくさんある。 一覧はとても紹介しきれないので、以下の公式ページを見てもらいたい。
イベントハンドラと標準出力
さて、先ほど作ったボタンは、押しても何も起こらない。 何も起こらない UI を作っても意味がないので、次はウィジェットにイベントハンドラを登録しよう。
たとえば、widgets.Button
なら widgets.Button#on_click()
というメソッドで、クリックされた時に発火するイベントハンドラを登録できる。
試しに、イベントハンドラの中で print()
関数を呼んでみよう。
button = widgets.Button(description='Click me') def on_click_callback(clicked_button: widgets.Button) -> None: """ボタンが押されたときに発火するイベントハンドラ""" print('Clicked') # イベントハンドラ内で print() 関数を呼んでみる # ボタンにイベントハンドラを登録する button.on_click(on_click_callback) display(button)
さて、上記を実行して表示されたボタンをクリックしてみても、実は何も表示されない。
おや?と思いながらブラウザの下方に目を移すと、"Log" というペインに通知が出てくるはず。
このペインを開くと、print()
関数で出力した内容がそこに表示されていることが分かる。
これはこれで悪くはないけど、毎回ログのペインを確認しながら作業するのも微妙な感じ。 できればセルの出力に表示させたいので `widgets.Output`` というウィジェットを使う。 このウィジェットは ipywidgets を使っていると、かなり登場機会が多い。 後ほどグラフを描画するときにもお世話になる。
widgets.Output
はいくつかの使い方がある。
以下では、コンテキストマネージャとして使っている。
コンテキストマネージャのスコープ内で print()
関数を呼ぶと、出力先が widgets.Output
の描画エリアに向く。
button = widgets.Button(description='Click me') # 標準出力を表示するエリアを用意する output = widgets.Output(layour={'border': '1px solid black'}) def on_click_callback(clicked_button: widgets.Button) -> None: # コンテキストマネージャとして使う with output: # スコープ内の標準出力は Output に書き出される print('Clicked') button.on_click(on_click_callback) # Output も表示対象に入れる display(button, output)
もう一つの使い方は、デコレータとしてコールバック関数をラップするやり方。
これだと、そのコールバック関数内でのデフォルトの標準出力が widgets.Output
に向く。
button = widgets.Button(description='Click me') output = widgets.Output(layout={'border': '1px solid black'}) # デコレータとして使うとデフォルトの向け先になる @output.capture() def on_click_callback(b: widgets.Button) -> None: print('Clicked') button.on_click(on_click_callback) display(button, output)
得られる結果は同じ。
ちなみに widgets.Output
は、そのままだと内容が追記されていく。
もし内容を消去したいときは widgets.Output#clear_output()
メソッドを呼び出せば良い。
以下のサンプルコードでは、ボタンをクリックしたタイミングで前回の内容を消去しつつ、時刻を表示している。
from datetime import datetime button = widgets.Button(description='Click me') output = widgets.Output(layour={'border': '1px solid black'}) def on_click_callback(clicked_button: widgets.Button) -> None: with output: # 表示エリアの内容を消去する output.clear_output() print(f'Clicked at {datetime.now()}') button.on_click(on_click_callback) display(button, output) # 手動でイベントを発生させる button.click()
デコレータの使い方のときは Output#capture()
の引数で clear_output
オプションを有効にすると良い。
これで、コールバック関数が呼ばれる毎に出力内容がクリアされる。
from datetime import datetime button = widgets.Button(description='Click me') output = widgets.Output(layout={'border': '1px solid black'}) # 関数が呼ばれる度に出力をクリアする @output.capture(clear_output=True) def on_click_callback(b: widgets.Button) -> None: print(f'Clicked at {datetime.now()}') button.on_click(on_click_callback) display(button, output) button.click()
複数のウィジェットを連携させる
さて、実際に UI を書いていくと、複数のウィジェットを連携させることが多い。
多くのウィジェットは、選択されている値を value
というアトリビュートで読み出すことができる。
以下のサンプルコードでは、widgets.Button
を押したタイミングで widgets.Select
で選択されているアイテムを widgets.Output
に表示させている。
button = widgets.Button(description='Click me') # 値を選択するセレクタ select = widgets.Select(options=['Apple', 'Banana', 'Cherry']) output = widgets.Output(layour={'border': '1px solid black'}) @output.capture() def on_click_callback(clicked_button: widgets.Button) -> None: # セレクタで選択されているアイテムを使う print(f'Selected item: {select.value}') button.on_click(on_click_callback) display(select, button, output)
ウィジェットをグローバルスコープに置かない
複数のウィジェットを扱うようになると、それらがグローバルスコープにあるとコードがどんどんスパゲッティになっていく。 複数のセルに複数のウィジェットを置くと特にやばい。 そのため、以下のように一連のウィジェットは関数スコープの中で作るようにした方が良いと思う。
def show_widgets(): """ウィジェットを設定する関数""" button = widgets.Button(description='Click me') select = widgets.Select(options=['Apple', 'Banana', 'Cherry']) output = widgets.Output(layour={'border': '1px solid black'}) @output.capture() def on_click_callback(clicked_button: widgets.Button) -> None: print(f'Selected item: {select.value}') button.on_click(on_click_callback) display(select, button, output) # ウィジェットを表示する show_widgets()
ただ、これだと関数スコープ内にあるウィジェットが GC に拾われないか心配だったけど、とりあえず大丈夫そう。 どこに参照が生き残るのかはちょっと気になるね。
もしどうしても気になるようなら、以下のように widgets.VBox
という複数のウィジェットをまとめるウィジェットを使うのはどうだろう。
これなら、少なくともグローバルスコープにウィジェットの参照が残るので、GC に拾われないことは担保できるはず。
def show_widgets() -> widgets.VBox: """ウィジェットを設定する関数""" button = widgets.Button(description='Click me') select = widgets.Select(options=['Apple', 'Banana', 'Cherry']) output = widgets.Output(layour={'border': '1px solid black'}) @output.capture() def on_click_callback(clicked_button: widgets.Button) -> None: print(f'Selected item: {select.value}') button.on_click(on_click_callback) # 一連のウィジェットを VBox にまとめて返す return widgets.VBox([button, select, output]) # ウィジェットを表示する box = show_widgets() display(box)
値の変更を監視する
先ほど使ったセレクタのように、値を選択したり入力する系のウィジェットは、入力値が変更されたタイミングでイベントを発火させたいことが多い。
そのような場合、ウィジェットによっては observe()
というメソッドでイベントハンドラを登録できる。
以下のサンプルコードでは、widgets.Select
で選んだ内容を widgets.Output
に表示している。
from traitlets.utils.bunch import Bunch def show_widgets(): select = widgets.Select(options=['Apple', 'Banana', 'Cherry']) output = widgets.Output(layour={'border': '1px solid black'}) @output.capture() def on_value_change(change: Bunch) -> None: # 値が変更されたイベントを扱う if change['name'] == 'value': output.clear_output() # 変更前と変更後の値を出力する old_value = change['old'] new_value = change['new'] print(f'value changed: {old_value} -> {new_value}') # 値の変更を監視する select.observe(on_value_change) display(select, output) show_widgets()
ただ、実際に UI を作ってみると複数のウィジェットが連携することも多い。
その場合は、個別に observe()
すると煩雑になりがち。
そういったときは ipywidgets.interactive()
を使った方がコードの見通しが良くなると思う。
以下では、IntSlider
と Select
の両方の値の変更を監視している。
def show_widgets(): slider = widgets.IntSlider(value=50, min=1, max=100, description='slider:') select = widgets.Select(options=['Apple', 'Banana', 'Cherry']) output = widgets.Output(layour={'border': '1px solid black'}) @output.capture(clear_output=True) def on_value_change(select_value: str, slider_value: int) -> None: print(f'value changed: {select_value=}, {slider_value=}') # 複数のウィジェットの変更を一度に監視できる widgets.interactive(on_value_change, select_value=select, slider_value=slider) display(select, slider, output) show_widgets()
ウィジェットの配置を工夫する
デフォルトでは display()
関数に渡された順序で、垂直にウィジェットが配置されていく。
しかし、それだと操作が分かりにくいこともあるので配置を工夫する方法について書く。
たとえば、ウィジェットを横に並べたいときは widgets.Box
または widgets.HBox
でウィジェットをまとめると良い。
以下のサンプルコードではスライダーとセレクタを widgets.Box
を使って横に並べている。
なお、既に登場しているとおり widgets.VBox
を使うと縦に並べることができる。
def show_widgets(): slider = widgets.IntSlider(value=50, min=1, max=100, description='slider:') select = widgets.Select(options=['Apple', 'Banana', 'Cherry']) output = widgets.Output(layour={'border': '1px solid black'}) @output.capture(clear_output=True) def on_value_change(select_value: str, slider_value: int) -> None: print(f'value changed: {select_value=}, {slider_value=}') widgets.interactive(on_value_change, select_value=select, slider_value=slider) # 横に並べるときはウィジェットを Box や HBox にまとめる box = widgets.Box([slider, select]) display(box, output) show_widgets()
他にも widgets.GridBox
と widgets.Layout
を組み合わせてグリッドレイアウトを作ったり。
def show_widgets(): labels = [widgets.Label(str(i)) for i in range(8)] # グリッドレイアウト grid_box = widgets.GridBox(labels, layout=widgets.Layout(grid_template_columns="repeat(3, 100px)")) display(grid_box) show_widgets()
widgets.Tab
を使えばタブを使った UI も作れる。
def show_widgets(num_of_tabs: int = 5): # タブ毎のウィジェット contents = [widgets.Label(f'This is tab {i}') for i in range(num_of_tabs)] tab = widgets.Tab(children=contents) # タブのタイトルを設定する for i in range(num_of_tabs): tab.set_title(i, f'tab {i}') display(tab) show_widgets()
Matplotlib と連携させる
さて、やっとかって感じだけど Matplotlib との連携について書いていく。
基本的にはこれまでの延長線上にある。
ポイントは、display()
関数で Matplotlib の Figure
オブジェクトを描画するところ。
このとき、描画先が widgets.Output
オブジェクトになるようにスコープ内で呼んでやれば良い。
以下のサンプルコードではボタンを押す度にグラフの描画を更新している。
from matplotlib import pyplot as plt import numpy as np def show_widgets(): button = widgets.Button(description='Refresh') # グラフの描画領域としての Output を用意する output = widgets.Output() # アクティブな Axes オブジェクトを取得する ax = plt.gca() # NOTE: デコレータを使っても問題はない # @output.capture(clear_output=True, wait=True) def on_click(b: widgets.Button) -> None: # 前回の描画内容をクリアする ax.clear() # 描画し直す rand_x = np.random.randn(100) rand_y = np.random.randn(100) ax.plot(rand_x, rand_y, '+') # Output に書き出す with output: output.clear_output(wait=True) display(ax.figure) button.on_click(on_click) display(button, output) # セルの出力に描画されるのを抑制するために一旦アクティブな Figure を破棄する plt.close() # 最初の 1 回目の描画を手動でトリガーする button.click() show_widgets()
もうひとつサンプルコードを示す。
こちらでは、widgets.IntRangeSlider
の値が変更されたタイミングでグラフを描画し直している。
また、先ほどとの違いとしてプロットされるグラフのサイズを大きくしている。
from __future__ import annotations def show_widgets(): MIN, MAX, STEPS = 1, 11, 1000 range_slider = widgets.IntRangeSlider(value=[2, 4], min=MIN, max=MAX, step=1, description='plot range:') output = widgets.Output() # サイズを指定する場合 fig = plt.figure(figsize=(10, 10)) ax = fig.add_subplot() @output.capture(clear_output=True, wait=True) def on_value_change(selected_range: tuple(int, int)) -> None: # サイン波を作る x = np.linspace(MIN, MAX, num=STEPS) y = np.sin(x) # 選択範囲を取り出す selected_lower, selected_upper = selected_range lower = (selected_lower - MIN) * (STEPS // (MAX - MIN)) upper = (selected_upper - MIN) * (STEPS // (MAX - MIN)) # 前回の描画内容をクリアする ax.clear() # 描画する ax.plot(x[lower:upper], y[lower:upper]) # Output に書き出す display(ax.figure) widgets.interactive(on_value_change, selected_range=range_slider) display(range_slider, output) plt.close() show_widgets()
別のウィジェットのイベントでウィジェットの値を更新する
あとはもはや蛇足っぽいけど、あるウィジェットのイベントを契機に別のウィジェットの値を変更するのもよくあるよねってことで。
以下のサンプルコードでは widgets.Button
をクリックすると widgets.Text
に入力された内容が widgets.Select
に追加されていく。
def show_widgets(): text = widgets.Text() select = widgets.Select(options=[]) output = widgets.Output(layour={'border': '1px solid black'}) button = widgets.Button(description='Add') def on_click_callback(b: widgets.Button) -> None: # テキストの入力を選択肢として追加する select.options = list(select.options) + [text.value] button.on_click(on_click_callback) display(text, button, select) show_widgets()
複数のウィジェットで値を同期する
あとはあるウィジェットと別のウィジェットの値を同期させる、みたいなことも widgets.jslink()
関数を使ってできる。
以下は時系列の情報を表示させるのに便利な widgets.Play
と widgets.IntSlider
の値を同期させている。
def show_widgets(): # アニメーション制御 play = widgets.Play( value=50, min=1, max=100, step=1, interval=500, # 更新間隔 (ミリ秒) description="play:", ) slider = widgets.IntSlider(value=50, min=1, max=100, description='slider:') output = widgets.Output(layour={'border': '1px solid black'}) # ウィジェットの値を連動させる widgets.jslink((play, 'value'), (slider, 'value')) @output.capture(clear_output=True) def on_value_change(slider_value: int) -> None: print(f'value changed: {slider_value=}') widgets.interactive(on_value_change, slider_value=slider) display(play, slider, output) show_widgets()
とりあえず、そんな感じで。