IT/AI\ML

ipywidgets 사용법 (2)

개발자 두더지 2021. 4. 7. 00:13
728x90

 지난 포스팅에 이어서 기본적인 사용법에 대해 정리해보고자 한다. 지난 번에 간단한 그래프와 이미지 변환에 대해 살펴보았다면 이번 포스팅에서는 조금 더 구체적인 내용을 다뤄보고자한다.

 

버튼 위젯 작성


단순 버튼을 만들어보자. 

import ipywidgets as widgets

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

 한편으로 위의 코드는 위젯이 여러 개 있거나 복잡한 패턴이 되는 경우 다루는 것은 힘들다. 따라서 IPython.display.display()함수의 인수에 위젯을 전달하는 방식이 아마 알기 쉬울 것이라고 생각한다. display는 JupyterLab이라면 import하지 않고 사용가능한 것 같지만 혹시 모르기 때문에 import한 후에 버튼을 다시 가시화하면 샘플 코드는 다음과 같다.

from IPython.display import display
display(button)

 물론 화면에서 여기서 보이는 것은 다르지 않다. 

 

 

이벤트 핸들러와 표준출력


 위의 버튼의 경우는 눌러도 아무 일도 일어나지 않는다. 따라서 여기서 부터 위젯과 이벤트 핸들러를 등록해보자. 예를 들어, 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)

 이 코드를 실행시켜 버튼을 눌러 보면 생각한대로 바로 Clicked가 출력되지 않는다. 출력 결과는 아래 파란색으로 반짝이는 Log버튼을 눌러보면 그 결과과 아래와 같이 출력된다.

 물론 이대로 괜찮을 수 있지만 매번 로그를 확인하는 것은 좋지 않으므로 가능한 쉘에 출력되어 표시되도록 하기 위해서는 `widgets.Output` 이라는 위젯을 사용한다. 이 위젯은 ipywidgets을 사용한다면 꽤 자주 보게 될 것이다. widgets.Output는 몇 가지 사용방법이 있다.

 

(1) 컨텍스트 매니저

 아래와 같이 컨텍스트 매니저로써 사용하고 있다. 컨텍스트 매니저의 스코프 안에 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)

 

(2) 데코레이터 + 콜백 함수

 다른 사용법은 데코레이터로써 콜백 함수을 감싸는 방법이다. 

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는 출력 내용이 축적되는 방식이다 (앞에 봤던 단순히 쉘에 Clicked가 표시되는 코드를 실행시킨 상태에서 계속 버튼을 누르면 아래에 계속 Clicked가 표시되는 것과 같이 ). 따라서 만약 내용을 삭제하고 싶다면 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를 사용할 때 여러 개의 위젯은 연계해서 쓰는 경우가 많다. 많은 위젯은 선택되어 있는 값을 vlaue라는 속성으로 읽을 수 있다.

 아래의 샘플코드에서는 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()를 사용하는 편이 좋다. 아래의 코드는 IntSilder과 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와 연계하기


 기본적으로는 앞서 설명한 내용의 연장선이다. 포인트는 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()


참고자료

blog.amedama.jp/entry/ipywidgets-jupyter-ui

728x90