CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: ipywidgets で Jupyter に簡単な UI を作る

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

もくじ

下準備

下準備として、必要なパッケージをインストールしておく。

$ pip install ipywidgets jupyterlab matplotlib

そして、JupyterLab を起動する。

$ jupyter-lab

ウィジェットを作る

ここからは、コードを JupyterLab のセル内で実行することを想定する。

まずは、ipywidgets パッケージを widgets という名前でインポートしておこう。 名前を変更しているのは、公式のサンプルコードがこのやり方をしているため。

import ipywidgets as widgets

たとえば、もっとも単純なサンプルとしてボタンを作ってみよう。 はじめに、widgets.Button クラスをインスタンス化する。

button = widgets.Button(description='Click me')

上記でインスタンス化したボタンを表示するには、たとえばセルの最後で評価させる方法がある。

button

widgets.Button

一方で、上記のやり方はウィジェットが複数あったり、複雑なパターンを扱いにくい。 そのため IPython.display.display() 関数の引数にウィジェットを渡していくやり方がおそらく分かりやすいと思う。 JupyterLab だとインポートしないで使えるっぽいけど、念のためインポートした上でボタンを可視化するサンプルコードを以下に示す。

from IPython.display import display
display(button)

もちろん、見え方は先ほどと同じ。

ちなみに、ボタン以外にもウィジェットはたくさんある。 一覧はとても紹介しきれないので、以下の公式ページを見てもらいたい。

ipywidgets.readthedocs.io

イベントハンドラと標準出力

さて、先ほど作ったボタンは、押しても何も起こらない。 何も起こらない 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() 関数で出力した内容がそこに表示されていることが分かる。

イベントハンドラ内で 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

もう一つの使い方は、デコレータとしてコールバック関数をラップするやり方。 これだと、そのコールバック関数内でのデフォルトの標準出力が 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()

widgets.Output#clear_output()

デコレータの使い方のときは 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() を使った方がコードの見通しが良くなると思う。 以下では、IntSliderSelect の両方の値の変更を監視している。

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()

ipywidgets.interactive() で複数のウィジェットを監視する

ウィジェットの配置を工夫する

デフォルトでは 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.Box でウィジェットを横に並べる

他にも widgets.GridBoxwidgets.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()

widgets.Tab

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()

Matplotlib の描画をウィジェットのイベントで更新する

もうひとつサンプルコードを示す。 こちらでは、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.Playwidgets.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()

ウィジェット同士の値を同期させる

とりあえず、そんな感じで。