12 분 소요

dash_icon

(5) Interactive 그래프, Crossfiltering

대화형(Interactive) 그래프


Dash 는 4장에서 다룬 콜백 함수를 이용한 구성 요소 간 대화형(Interactive) 동작 뿐만 아니라, dcc.Graph 라는 컴포넌트로 사용자 상호 작용을 통해 그래프를 변경할 수 있는, 네 가지 그래프 속성(hoverData, clickData, selectedData, relayoutData) 기능을 제공합니다. 이 네가지 기능을 통해 사용자가 마우스를 가져가거나, 점을 클릭하거나, 그래프에서 점의 영역을 선택하면 그래프가 업데이트 됩니다.


dash_core_components 모듈을 통해 import 되는 dcc.Graph 는 Dash 에서 Plotly 가 제공하는 35가지 이상의 차트 기능을 사용할 수 있게 해주는 핵심 컴포넌트입니다. dcc.Graph 가 사용하는 figure 속성들은 Plotly 의 figure 속성과 동일합니다. 사실 이 기능이 시각화 커스터마이징에 매우 편리하기 때문에 많은 파이썬 유저들이 Dash 를 선호하는 듯 합니다.


파이썬에서 많이들 사용하시는 시각화 라이브러리는 Plotly 외에도 Matplotlib 과 Seaborn 등이 있을 텐데요, 아쉽게도 다른 라이브러리에서 표현된 차트를 Dash 에서 렌더링하려면 Plotly 기반의 figure 로 변환해주어야 합니다. 예를 들어 기존에 matplotlib 에서 작성한 차트를 Dash 에 표현하려면 Plotly 가 제공하는 mpl_to_plotly 함수로 figure 를 변환 시킨 객체를 dcc.Graph(figure=fig) 과 같이 연결해 주어야 합니다. 자세한 내용은 mpl_to_plotly 함수 를 참고해 주세요.


그럼 Dash 의 네가지 그래프 속성(hoverData, clickData, selectedData, relayoutData) 이 각각 어떤 방식으로 출력되는지 한번 확인해 보겠습니다.

소스 보기
import json
from dash import Dash
import dash_core_components as dcc
import dash_html_components as html
from dash.dependencies import Input, Output
import plotly.express as px
import pandas as pd

external_stylesheets = ['https://codepen.io/chriddyp/pen/bWLwgP.css']

app = Dash(__name__, external_stylesheets=external_stylesheets)

styles = {
    'pre': {
        'border': 'thin lightgrey solid',
        'overflowX': 'scroll'
    }
}

df = pd.DataFrame({
    "x": [1,2,1,2],
    "y": [1,2,3,4],
    "customdata": [1,2,3,4],
    "fruit": ["apple", "apple", "orange", "orange"]
})

fig = px.scatter(df, x="x", y="y", color="fruit", custom_data=["customdata"])

fig.update_layout(clickmode='event+select')

fig.update_traces(marker_size=20)

app.layout = html.Div([
    dcc.Graph(
        id='basic-interactions',
        figure=fig
    ),

    html.Div(className='row', children=[
        html.Div([
            dcc.Markdown("""
                **Hover Data**

                마우스를 그래프 지점 위로 올렸을 때 출력 값.
            """),
            html.Pre(id='hover-data', style=styles['pre'])
        ], className='three columns'),

        html.Div([
            dcc.Markdown("""
                **Click Data**

                마우스로 그래프 지점을 클릭했을 때 출력 값.
            """),
            html.Pre(id='click-data', style=styles['pre']),
        ], className='three columns'),

        html.Div([
            dcc.Markdown("""
                **Selection Data**
                
                메뉴 바에서 사각형 선택 툴을 사용한 지점 선택 결과
            """),
            html.Pre(id='selected-data', style=styles['pre']),
        ], className='three columns'),

        html.Div([
            dcc.Markdown("""
                **Zoom and Relayout Data**
                
                마우스 드래그를 통한 데이터 줌인 결과
                (그래프를 더블클릭하면 원상복구 됩니다.)
            """),
            html.Pre(id='relayout-data', style=styles['pre']),
        ], className='three columns')
    ])
])


@app.callback(
    Output('hover-data', 'children'),
    Input('basic-interactions', 'hoverData'))
def display_hover_data(hoverData):
    return json.dumps(hoverData, indent=2)


@app.callback(
    Output('click-data', 'children'),
    Input('basic-interactions', 'clickData'))
def display_click_data(clickData):
    return json.dumps(clickData, indent=2)


@app.callback(
    Output('selected-data', 'children'),
    Input('basic-interactions', 'selectedData'))
def display_selected_data(selectedData):
    return json.dumps(selectedData, indent=2)


@app.callback(
    Output('relayout-data', 'children'),
    Input('basic-interactions', 'relayoutData'))
def display_relayout_data(relayoutData):
    return json.dumps(relayoutData, indent=2)


if __name__ == '__main__':
    app.run_server(debug=True)

dash_ex6


우선 마우스를 그래프 포인트 위로 올렸을 때 포인트의 인덱스, 좌표, 값 정보가 그래프의 hoverData 속성을 업데이트 합니다. 그리고 포인트를 클릭했을 때는 같은 정보가 clickData 속성을 업데이트 합니다.


selectedData 속성은 선택 영역과 그 안의 포인트들의 정보를 나타냅니다. 예를 들어, 메뉴 바에서 Box 형 선택 옵션을 선택하고 마우스 드래그를 통해 영역을 선택하면, 선택된 영역의 시작 과 끝 좌표 정보와 선택 영역 내의 포인트들의 정보가 묶인 결과를 줍니다. (위의 예제에서는 왼쪽 두개의 포인트가 묶였습니다.)


relayoutData 속성은 줌인, 줌아웃과 같이 마우스 드래그를 통해 그래프 레이아웃 변경이 일어나면, 변경된 레이아웃의 좌표 정보를 나타냅니다. 즉, x축의 시작과 끝, y축의 시작과 끝, 4개의 좌표 정보가 저장됩니다.


이렇게 Dash 가 제공하는 대화형(Interactive) 그래프 속성을 사용하면 우리는 좀 더 고차원 적인 작업들을 할 수 있게됩니다. 예를 들어 그래프에서 선택된 값들만 필터링한 연산을 한다던지, 다른 구성 요소에 선택된 값들만 표현하고 싶다면, 그래프 속성 값들을 받아서 해당 작업을 수행하는 코드를 작성하고 연결할 수 있습니다.


아래 예제는 hoverData 그래프 속성을 사용해서, 마우스를 올린 포인트에 따라 다른 구성 요소 정보가 업데이트 되는 예시입니다.

소스 보기
from dash import Dash
import dash_core_components as dcc
import dash_html_components as html
from dash.dependencies import Input, Output
import pandas as pd
import plotly.express as px

external_stylesheets = ['https://codepen.io/chriddyp/pen/bWLwgP.css']

app = Dash(__name__, external_stylesheets=external_stylesheets)

df = pd.read_csv('https://plotly.github.io/datasets/country_indicators.csv')


app.layout = html.Div([
    html.Div([

        html.Div([
            dcc.Dropdown(
                options=df['Indicator Name'].unique(),
                value='Fertility rate, total (births per woman)',
                id='crossfilter-xaxis-column'
            ),
            dcc.RadioItems(
                options=['Linear', 'Log'],
                value='Linear',
                id='crossfilter-xaxis-type',
                labelStyle={'display': 'inline-block', 'marginTop': '5px'}
            )
        ],
        style={'width': '49%', 'display': 'inline-block'}),

        html.Div([
            dcc.Dropdown(
                options=df['Indicator Name'].unique(),
                value='Life expectancy at birth, total (years)',
                id='crossfilter-yaxis-column'
            ),
            dcc.RadioItems(
                options=['Linear', 'Log'],
                value='Linear',
                id='crossfilter-yaxis-type',
                labelStyle={'display': 'inline-block', 'marginTop': '5px'}
            )
        ], style={'width': '49%', 'float': 'right', 'display': 'inline-block'})
    ], style={
        'padding': '10px 5px'
    }),

    html.Div([
        dcc.Graph(
            id='crossfilter-indicator-scatter',
            hoverData={'points': [{'customdata': 'Japan'}]}
        )
    ], style={'width': '49%', 'display': 'inline-block', 'padding': '0 20'}),
    html.Div([
        dcc.Graph(id='x-time-series'),
        dcc.Graph(id='y-time-series'),
    ], style={'display': 'inline-block', 'width': '49%'}),

    html.Div(dcc.Slider(
        min=df['Year'].min(),
        max=df['Year'].max(),
        step=None,
        id='crossfilter-year--slider',
        value=df['Year'].max(),
        marks={str(year): str(year) for year in df['Year'].unique()}
    ), style={'width': '49%', 'padding': '0px 20px 20px 20px'})
])


@app.callback(
    Output('crossfilter-indicator-scatter', 'figure'),
    Input('crossfilter-xaxis-column', 'value'),
    Input('crossfilter-yaxis-column', 'value'),
    Input('crossfilter-xaxis-type', 'value'),
    Input('crossfilter-yaxis-type', 'value'),
    Input('crossfilter-year--slider', 'value'))
def update_graph(xaxis_column_name, yaxis_column_name,
                 xaxis_type, yaxis_type,
                 year_value):
    dff = df[df['Year'] == year_value]

    fig = px.scatter(x=dff[dff['Indicator Name'] == xaxis_column_name]['Value'],
            y=dff[dff['Indicator Name'] == yaxis_column_name]['Value'],
            hover_name=dff[dff['Indicator Name'] == yaxis_column_name]['Country Name']
            )

    fig.update_traces(customdata=dff[dff['Indicator Name'] == yaxis_column_name]['Country Name'])

    fig.update_xaxes(title=xaxis_column_name, type='linear' if xaxis_type == 'Linear' else 'log')

    fig.update_yaxes(title=yaxis_column_name, type='linear' if yaxis_type == 'Linear' else 'log')

    fig.update_layout(margin={'l': 40, 'b': 40, 't': 10, 'r': 0}, hovermode='closest')

    return fig


def create_time_series(dff, axis_type, title):

    fig = px.scatter(dff, x='Year', y='Value')

    fig.update_traces(mode='lines+markers')

    fig.update_xaxes(showgrid=False)

    fig.update_yaxes(type='linear' if axis_type == 'Linear' else 'log')

    fig.add_annotation(x=0, y=0.85, xanchor='left', yanchor='bottom',
                       xref='paper', yref='paper', showarrow=False, align='left',
                       text=title)

    fig.update_layout(height=225, margin={'l': 20, 'b': 30, 'r': 10, 't': 10})

    return fig


@app.callback(
    Output('x-time-series', 'figure'),
    Input('crossfilter-indicator-scatter', 'hoverData'),
    Input('crossfilter-xaxis-column', 'value'),
    Input('crossfilter-xaxis-type', 'value'))
def update_y_timeseries(hoverData, xaxis_column_name, axis_type):
    country_name = hoverData['points'][0]['customdata']
    dff = df[df['Country Name'] == country_name]
    dff = dff[dff['Indicator Name'] == xaxis_column_name]
    title = '<b>{}</b><br>{}'.format(country_name, xaxis_column_name)
    return create_time_series(dff, axis_type, title)


@app.callback(
    Output('y-time-series', 'figure'),
    Input('crossfilter-indicator-scatter', 'hoverData'),
    Input('crossfilter-yaxis-column', 'value'),
    Input('crossfilter-yaxis-type', 'value'))
def update_x_timeseries(hoverData, yaxis_column_name, axis_type):
    dff = df[df['Country Name'] == hoverData['points'][0]['customdata']]
    dff = dff[dff['Indicator Name'] == yaxis_column_name]
    return create_time_series(dff, axis_type, yaxis_column_name)


if __name__ == '__main__':
    app.run_server(debug=True)

dash_ex7


왼쪽의 그래프는 전세계 나라 별 여성 1인당 합계 출산율과 기대 수명과의 관계를 나타낸 차트입니다. 연도가 지날 수록 기대 수명은 높아지지만, 출산율은 줄어드는 양상입니다.

우측의 그래프는 좌측 그래프에서 마우스를 특정 나라에 올리면, 그 나라의 출산율과 기대 수명을 시계열 연도 그래프로 출력해줍니다. 좌측 그래프의 hoverData 속성 값을 바탕으로 우측 그래프 입력 값을 필터링 하는 원리입니다.


Crossfiltering


위와 같이 대화형 그래프 속성을 기반으로 여러 구성요소의 입력 값들을 필터링 하는 기능을 Dash 에서는 Crossfiltering 이라고 합니다.

소스 보기
from dash import Dash
import dash_core_components as dcc
import dash_html_components as html
import numpy as np
import pandas as pd
from dash.dependencies import Input, Output
import plotly.express as px

external_stylesheets = ['https://codepen.io/chriddyp/pen/bWLwgP.css']

app = Dash(__name__, external_stylesheets=external_stylesheets)

# 6개 컬럼의 샘플 데이터 생성
np.random.seed(0)
df = pd.DataFrame({"Col " + str(i+1): np.random.rand(30) for i in range(6)})

app.layout = html.Div([
    html.Div(
        dcc.Graph(id='g1', config={'displayModeBar': False}),
        className='four columns'
    ),
    html.Div(
        dcc.Graph(id='g2', config={'displayModeBar': False}),
        className='four columns'
        ),
    html.Div(
        dcc.Graph(id='g3', config={'displayModeBar': False}),
        className='four columns'
    )
], className='row')

def get_figure(df, x_col, y_col, selectedpoints, selectedpoints_local):

    if selectedpoints_local and selectedpoints_local['range']:
        ranges = selectedpoints_local['range']
        selection_bounds = {'x0': ranges['x'][0], 'x1': ranges['x'][1],
                            'y0': ranges['y'][0], 'y1': ranges['y'][1]}
    else:
        selection_bounds = {'x0': np.min(df[x_col]), 'x1': np.max(df[x_col]),
                            'y0': np.min(df[y_col]), 'y1': np.max(df[y_col])}

    # `selectedpoints` 속성에 따라 어떤 포인트가 선택될 지를 결정
    # `selected` 와 `unselected` 로 스타일 속성 부여
    # 참고: https://medium.com/@plotlygraphs/notes-from-the-latest-plotly-js-release-b035a5b43e21

    fig = px.scatter(df, x=df[x_col], y=df[y_col], text=df.index)

    fig.update_traces(selectedpoints=selectedpoints,
                      customdata=df.index,
                      mode='markers+text', marker={ 'color': 'rgba(0, 116, 217, 0.7)', 'size': 20 }, unselected={'marker': { 'opacity': 0.3 }, 'textfont': { 'color': 'rgba(0, 0, 0, 0)' } })

    fig.update_layout(margin={'l': 20, 'r': 0, 'b': 15, 't': 5}, dragmode='select', hovermode=False)

    fig.add_shape(dict({'type': 'rect',
                        'line': { 'width': 1, 'dash': 'dot', 'color': 'darkgrey' } },
                       **selection_bounds))
    return fig

# 3가지 figure 값을 가지는 콜백 생성
@app.callback(
    Output('g1', 'figure'),
    Output('g2', 'figure'),
    Output('g3', 'figure'),
    Input('g1', 'selectedData'),
    Input('g2', 'selectedData'),
    Input('g3', 'selectedData')
)
def callback(selection1, selection2, selection3):
    selectedpoints = df.index
    for selected_data in [selection1, selection2, selection3]:
        if selected_data and selected_data['points']:
            selectedpoints = np.intersect1d(selectedpoints,
                [p['customdata'] for p in selected_data['points']])

    return [get_figure(df, "Col 1", "Col 2", selectedpoints, selection1),
            get_figure(df, "Col 3", "Col 4", selectedpoints, selection2),
            get_figure(df, "Col 5", "Col 6", selectedpoints, selection3)]


if __name__ == '__main__':
    app.run_server(debug=True)

dash_ex7


3개의 figure 가 모두 그래프의 selectedData 속성과 연결되어 있는 콜백을 만들었습니다. selectedData 속성은 콜백의 Input 과 연결되어, 그래프에서 선택된 포인트만 진한색으로 하이라이트한 후, 콜백 Output으로 업데이트 된 figure 를 각각 반환해줍니다.



본 포스트는 Dash 공식 가이드 를 참고하였습니다.

댓글남기기