互動式圖表及 Python

The world is discussed in terms of feelings and ideologies rather than as an area of knowledge.

Hans Rosling

互動式圖表及 R 語言中我們以 Plotly、Shiny 與 R 語言複製了一個輕量的互動 Gapminder 圖表,能夠讓使用者操控元件的氣泡圖,具備一些常見的使用者介面工具:

  • Hover:滑鼠游標移至圖形上會提示資訊
  • Zoom In/Out:將圖形放大或縮小
  • Filter:選取部分資料觀察
  • Slider:單選呈現不同年份的資料快照或以動畫依時序播放
  • Checkbox List:篩選呈現不同洲別的資料點

這篇文章我們打算使用 Dash 與 Python 來複製同一個互動 Gapminder 圖表。

瀏覽最終成品

我們希望利用 Dash 與 Python 複製出 Gapminder 視覺化,點選連結可以在向下閱讀前先瀏覽最終成品:

https://dash-gapminder.herokuapp.com

關於 Plotly 與 Dash

Plotly 是來自加拿大蒙特婁的新創公司,這個團隊為 R 語言,Python、JavaScript 開發並且維護極受歡迎的開源互動視覺化模組與套件,主要產品包含 Plotly.js、Plotly.R、Plotyly.py 與 Dash。

Dash 是建構於 Plotly.js、React.jsFlask 之上的 Python 網頁應用程式框架,能夠將常見的使用者介面元件包含像是下拉式選單、滑桿或圖形與 資料分析應用快速地連結起來,讓以 Python 為主的資料科學團隊不需要 JavaScript 也可以建立出具備高度互動性的圖表與儀表板。

取得 Gapminder 資料

互動式圖表及 R 語言相同,我們繼續使用一個關於預期壽命,人均 GDP 與國家人口數等變數的 Gapminder 資料摘錄版本,這個摘錄版本僅有 1704 個觀測值、6 個變數,涵括 1952 至 2007 年中每 5 年、142 個國家的快照。

import pandas as pd

csv_url = "https://storage.googleapis.com/learn_pd_like_tidyverse/gapminder.csv"
gapminder = pd.read_csv(csv_url)
nrows, ncols = gapminder.shape
min_year = gapminder["year"].min()
max_year = gapminder["year"].max()
year_interval = gapminder["year"].unique()[1] - gapminder["year"].unique()[0]
ncountries = gapminder["country"].nunique()
msg = "這個摘錄版本僅有 {} 個觀測值、{} 個變數,涵括 {} 至 {} 年中每 {} 年、{} 個國家的快照"

print("變數名稱:")
print(list(gapminder.columns))
print(msg.format(nrows, ncols, min_year, max_year, year_interval, ncountries))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
## 變數名稱:
## ['country', 'continent', 'year', 'lifeExp', 'pop', 'gdpPercap']
## 這個摘錄版本僅有 1704 個觀測值、6 個變數,涵括 1952 至 2007 年中每 5 年、142 個國家的快照
1
2
3

安裝 Dash

在終端機以 pip install 指令安裝三個模組:dash、dash-html-components 與 dash-core-components。

# Terminal
pip install dash==0.26.5  # The core dash backend
pip install dash-html-components==0.12.0  # HTML components
pip install dash-core-components==0.28.0  # Supercharged components
1
2
3
4

Dash 套件是 Plotly 團隊於 2017 年 7 月推出的產品,目前處於主動快速更新的階段,可以訂閱 Plotly 團隊的 Medium 帳號關注開發訊息:

https://medium.com/@plotlygraphs

Dash 網頁應用程式的組成

一個典型的 Dash 網頁應用程式由兩部分所組成:

  • layout:網頁應用程式的主題、外觀及使用者介面元件,如果與 R 的 Shiny 套件對照就是在 ui.R 中撰寫的程式碼
  • callbacks:負責產生網頁應用程式的互動,如果與 R 的 Shiny 套件對照就是在 server.R 中撰寫的程式碼

我們接著會先利用 layout 部分繪製 Gapminder 氣泡圖主體,然後加入 callbacks 部分建立能夠與氣泡圖連動的滑桿(Slider)與複選框清單(Checkbox list)互動元件。

繪製氣泡圖

首先將 Dash 網頁應用程式的外觀刻畫出來,我們使用一個 div 區塊,並在裡面包含一個 h1 標題與一個圖形;其中的 div 與 h1 以 dash_html_components 模組中的 Div()H1() 函數創建,圖形呼叫 dash_core_components 模組中的 Graph() 函數創建,這個圖形區塊裡頭包含一個氣泡圖本體,是呼叫 plotly.graph_objs 模組中的 Scatter() 函數所創建,氣泡圖與資料的對應關係為:

  • X 軸變數:gdpPercap
  • Y 軸變數:lifeExp
  • 氣泡大小:pop
  • 氣泡顏色:continent

值得注意的技巧是在 X 軸應用 log scale 避免多數的氣泡擠在左邊,並利用圓面積公式調整氣泡大小。

import dash
import dash_core_components as dcc
import dash_html_components as html
import math
import pandas as pd
import plotly.graph_objs as go

app = dash.Dash()

df = pd.read_csv(
    "https://storage.googleapis.com/learn_pd_like_tidyverse/gapminder.csv")
bubble_size = [math.sqrt(p / math.pi) for p in df["pop"].values]
df['size'] = bubble_size
sizeref = 2*max(df['size'])/(100**2)

app.layout = html.Div([
    html.H2(children='A Gapminder Replica with Dash'),
    dcc.Graph(
        id='gapminder',
        figure={
            'data': [
                go.Scatter(
                    x=df[df['continent'] == i]['gdpPercap'],
                    y=df[df['continent'] == i]['lifeExp'],
                    text=df[df['continent'] == i]['country'],
                    mode='markers',
                    opacity=0.7,
                    marker={
                        'size': df[df['continent'] == i]['size'],
                        'line': {'width': 0.5, 'color': 'white'},
                        'sizeref': sizeref,
                        'symbol': 'circle',
                        'sizemode': 'area'
                    },
                    name=i
                ) for i in df.continent.unique()
            ],
            'layout': go.Layout(
                xaxis={'type': 'log', 'title': 'GDP Per Capita'},
                yaxis={'title': 'Life Expectancy'},
                margin={'l': 40, 'b': 40, 't': 10, 'r': 10},
                legend={'x': 0, 'y': 1},
                hovermode='closest'
            )
        }
    )
])

if __name__ == '__main__':
    app.run_server(debug=True)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50

將程式碼編寫在 app.py 檔案之中,並且從終端機(Terminal)執行:

## Terminal
python app.py
## * Running on http://127.0.0.1:8050/ (Press CTRL+C to quit)
1
2
3

將 http://127.0.0.1:8050 複製貼到瀏覽器位址,就可以看到繪製完成的氣泡圖,由於我們是在 debug 模式下啟動伺服器 app.run_server(debug=True) 如果改動程式碼,會自動重啟服務,只要至 http://127.0.0.1:8050 按重新整理就可以觀看更新過後的網頁應用程式。以 plotly.graph_objs 模組繪製出來的圖形包含基礎的互動效果:

  • Hover:滑鼠游標移至圖形上會提示資訊
  • Zoom In/Out:可以將圖形放大或縮小
  • Filter:可以選取部分資料觀察

Hover:滑鼠游標移至圖形上會提示資訊

Zoom In/Out:可以將圖形放大或縮小

Filter:可以選取部分資料觀察

在終端機按下 Ctrl + c 可以停止 Dash 網頁應用程式的服務。

加入時間軸滑桿篩選年份

目前的氣泡圖設置將 1952 至 2007 每 5 年共 11 個時間點的資料快照全都顯示在塗上,因此需要加入一個時間軸滑桿(Timeframe slider)來篩選年份;滑桿可以利用 dash_core_components 模組中的 Slider() 函數創建,再加上 callback 操作連動圖形更新的功能。

Dash 的 callback 部分是以一個 Python Decorator 將 update_figure() 函數包裝起來,當時間軸滑桿的年份改變就會觸發 update_figure() 函數更新氣泡圖。值得注意的技巧是在 Graph() 函數中加入參數 animate=True 讓連動圖形更新的時候更加平滑。

import dash
import dash_core_components as dcc
import dash_html_components as html
import math
import pandas as pd
import plotly.graph_objs as go

app = dash.Dash()

df = pd.read_csv(
    "https://storage.googleapis.com/learn_pd_like_tidyverse/gapminder.csv")
bubble_size = [math.sqrt(p / math.pi) for p in df["pop"].values]
df['size'] = bubble_size
sizeref = 2*max(df['size'])/(100**2)

app.layout = html.Div([
    html.H2(children='A Gapminder Replica with Dash'),
    dcc.Graph(id='gapminder',
              animate=True
              ),
    dcc.Slider(
        id='year-slider',
        min=df['year'].min(),
        max=df['year'].max(),
        value=df['year'].min(),
        step=None,
        marks={str(year): str(year) for year in df['year'].unique()}
    )
])


@app.callback(
    dash.dependencies.Output('gapminder', 'figure'),
    [dash.dependencies.Input('year-slider', 'value')])
def update_figure(selected_year):
    filtered_df = df[df.year == selected_year]
    traces = []
    for i in filtered_df.continent.unique():
        df_by_continent = filtered_df[filtered_df['continent'] == i]
        traces.append(go.Scatter(
            x=df_by_continent['gdpPercap'],
            y=df_by_continent['lifeExp'],
            text=df_by_continent['country'],
            mode='markers',
            opacity=0.7,
            marker={
                'size': df[df['continent'] == i]['size'],
                'line': {'width': 0.5, 'color': 'white'},
                'sizeref': sizeref,
                'symbol': 'circle',
                'sizemode': 'area'
            },
            name=i
        ))

    return {
        'data': traces,
        'layout': go.Layout(
            xaxis={'type': 'log', 'title': 'GDP Per Capita'},
            yaxis={'title': 'Life Expectancy', 'range': [20, 90]},
            margin={'l': 40, 'b': 40, 't': 10, 'r': 10},
            legend={'x': 0, 'y': 1},
            hovermode='closest'
        )
    }


if __name__ == '__main__':
    app.run_server(debug=True)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69

加入時間軸滑桿篩選年份

加入複選框清單或下拉式選單篩選洲別

複選框清單可以利用 dash_core_components 模組中的 Checklist() 函數創建,再加上 callback 操作連動圖形更新的功能,Dash 的 callback 設計支援一對多連動、多對一連動與多對多連動,我們要將時間軸滑桿與氣泡圖的一對一連動進一步調整為時間軸滑桿、複選框清單與氣泡圖二對一連動,這個調整需要在 Dash 的 callback 部分加入另一個輸入的依賴。

import dash
import dash_core_components as dcc
import dash_html_components as html
import math
import pandas as pd
import plotly.graph_objs as go

app = dash.Dash()

df = pd.read_csv(
    "https://storage.googleapis.com/learn_pd_like_tidyverse/gapminder.csv")
bubble_size = [math.sqrt(p / math.pi) for p in df["pop"].values]
df['size'] = bubble_size
sizeref = 2*max(df['size'])/(100**2)
unique_continents = list(df["continent"].unique())

app.layout = html.Div([
    html.H2(children='A Gapminder Replica with Dash'),
    dcc.Checklist(
        id="continent-checklist",
        options=[
            {'label': i, 'value': i} for i in unique_continents
        ],
        values=unique_continents
    ),
    dcc.Graph(id='gapminder',
              animate=True
              ),
    dcc.Slider(
        id='year-slider',
        min=df['year'].min(),
        max=df['year'].max(),
        value=df['year'].min(),
        step=None,
        marks={str(year): str(year) for year in df['year'].unique()}
    )
])


@app.callback(
    dash.dependencies.Output('gapminder', 'figure'),
    [dash.dependencies.Input('year-slider', 'value'),
     dash.dependencies.Input('continent-checklist', 'values')])
def update_figure(selected_year, selected_continent):
    year_filtered_df = df[df.year == selected_year]
    filtered_df = year_filtered_df[df.continent.isin(selected_continent)]
    traces = []
    for i in filtered_df.continent.unique():
        df_by_continent = filtered_df[filtered_df['continent'] == i]
        traces.append(go.Scatter(
            x=df_by_continent['gdpPercap'],
            y=df_by_continent['lifeExp'],
            text=df_by_continent['country'],
            mode='markers',
            opacity=0.7,
            marker={
                'size': df[df['continent'] == i]['size'],
                'line': {'width': 0.5, 'color': 'white'},
                'sizeref': sizeref,
                'symbol': 'circle',
                'sizemode': 'area'
            },
            name=i
        ))

    return {
        'data': traces,
        'layout': go.Layout(
            xaxis={'type': 'log', 'title': 'GDP Per Capita'},
            yaxis={'title': 'Life Expectancy', 'range': [20, 90]},
            margin={'l': 40, 'b': 40, 't': 10, 'r': 10},
            legend={'x': 0, 'y': 1},
            hovermode='closest'
        )
    }


if __name__ == '__main__':
    app.run_server(debug=True)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79

加入複選框清單篩選洲別

特別值得一提的是,Dash 使用者介面工具所提供的下拉式選單(Dropdown list)不僅支援單一輸入的選擇,還同時支援多個輸入,只要在 dash_core_components 模組中的 Checklist() 函數增加 multi=True 這個參數,就能將預設單選的下拉式選單擴增為多選的下拉式選單,達到複選框清單的相同功能。

import dash
import dash_core_components as dcc
import dash_html_components as html
import math
import pandas as pd
import plotly.graph_objs as go

app = dash.Dash()

df = pd.read_csv(
    "https://storage.googleapis.com/learn_pd_like_tidyverse/gapminder.csv")
bubble_size = [math.sqrt(p / math.pi) for p in df["pop"].values]
df['size'] = bubble_size
sizeref = 2*max(df['size'])/(100**2)
unique_continents = list(df["continent"].unique())

app.layout = html.Div([
    html.H2(children='A Gapminder Replica with Dash'),
    dcc.Dropdown(
        id="continent-dropdown",
        options=[
            {'label': i, 'value': i} for i in unique_continents
        ],
        value=unique_continents,
        multi=True
    ),
    dcc.Graph(id='gapminder',
              animate=True
              ),
    dcc.Slider(
        id='year-slider',
        min=df['year'].min(),
        max=df['year'].max(),
        value=df['year'].min(),
        step=None,
        marks={str(year): str(year) for year in df['year'].unique()}
    )
])


@app.callback(
    dash.dependencies.Output('gapminder', 'figure'),
    [dash.dependencies.Input('year-slider', 'value'),
     dash.dependencies.Input('continent-dropdown', 'value')])
def update_figure(selected_year, selected_continent):
    year_filtered_df = df[df.year == selected_year]
    filtered_df = year_filtered_df[df.continent.isin(selected_continent)]
    traces = []
    for i in filtered_df.continent.unique():
        df_by_continent = filtered_df[filtered_df['continent'] == i]
        traces.append(go.Scatter(
            x=df_by_continent['gdpPercap'],
            y=df_by_continent['lifeExp'],
            text=df_by_continent['country'],
            mode='markers',
            opacity=0.7,
            marker={
                'size': df[df['continent'] == i]['size'],
                'line': {'width': 0.5, 'color': 'white'},
                'sizeref': sizeref,
                'symbol': 'circle',
                'sizemode': 'area'
            },
            name=i
        ))

    return {
        'data': traces,
        'layout': go.Layout(
            xaxis={'type': 'log', 'title': 'GDP Per Capita'},
            yaxis={'title': 'Life Expectancy', 'range': [20, 90]},
            margin={'l': 40, 'b': 40, 't': 10, 'r': 10},
            legend={'x': 0, 'y': 1},
            hovermode='closest'
        )
    }


if __name__ == '__main__':
    app.run_server(debug=True)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80

加入下拉式選單篩選洲別

部署 Dash 網頁應用程式

截至目前為止,我們的互動 Gapminder 圖表在本機上已經開發完成,它是一個在 localhost 上運行的 Dash 網頁應用程式,如果希望透過一個網址來分享給其他團隊、部門的成員,最簡易的方式為將網頁應用程式部署到雲端服務,Dash 背後的伺服器引擎是 Flask,一個使用 Python 編寫的輕量級網站應用框架(microframework),這也是部署 Dash 網頁應用程式很簡單原因,因為幾乎每個雲端服務商都支援 Flask 的部署;其中又以雲端服務商 Heroku 所提供的部署方式最為簡單,只需要準備妥當這三個前置作業:

  • 一組 Heroku 帳號
  • Git
  • Python 虛擬環境 virtualenv(或 conda env)

就可以依照以下步驟將互動 Gapminder 圖表部署至 Heroku 雲端伺服器:

步驟一:建立新的資料夾

# Terminal
mkdir python_gapminder_replica
cd python_gapminder_replica
1
2
3

步驟二:啟動 git 與名稱為 dash 的虛擬環境,並且在虛擬環境中安裝所有 dash 網頁應用程式所依賴的套件、模組

# Terminal
git init                      # initializes an empty git repo
virtualenv dash               # create a virtual env named dash
source dash/bin/activate      # activate this env
pip install dash dash-renderer dash-core-components dash-html-components plotly pandas
pip install gunicorn
1
2
3
4
5
6

步驟三:在資料中建立 app.py.gitignorerequirement.txtProcfile 這四個檔案

  • app.py :互動 Gapminder 圖表的 Dash 網頁應用程式
# For Heroku Deployment
import dash
import dash_core_components as dcc
import dash_html_components as html
import math
import pandas as pd
import plotly.graph_objs as go

app = dash.Dash()

df = pd.read_csv(
    "https://storage.googleapis.com/learn_pd_like_tidyverse/gapminder.csv")
bubble_size = [math.sqrt(p / math.pi) for p in df["pop"].values]
df['size'] = bubble_size
sizeref = 2*max(df['size'])/(100**2)
unique_continents = list(df["continent"].unique())

server = app.server # For Heroku Deployment

app.layout = html.Div([
    html.H2(children='A Gapminder Replica with Dash'),
    dcc.Dropdown(
        id="continent-dropdown",
        options=[
            {'label': i, 'value': i} for i in unique_continents
        ],
        value=unique_continents,
        multi=True
    ),
    dcc.Graph(id='gapminder',
              animate=True
              ),
    dcc.Slider(
        id='year-slider',
        min=df['year'].min(),
        max=df['year'].max(),
        value=df['year'].min(),
        step=None,
        marks={str(year): str(year) for year in df['year'].unique()}
    )
])


@app.callback(
    dash.dependencies.Output('gapminder', 'figure'),
    [dash.dependencies.Input('year-slider', 'value'),
     dash.dependencies.Input('continent-dropdown', 'value')])
def update_figure(selected_year, selected_continent):
    year_filtered_df = df[df.year == selected_year]
    filtered_df = year_filtered_df[df.continent.isin(selected_continent)]
    traces = []
    for i in filtered_df.continent.unique():
        df_by_continent = filtered_df[filtered_df['continent'] == i]
        traces.append(go.Scatter(
            x=df_by_continent['gdpPercap'],
            y=df_by_continent['lifeExp'],
            text=df_by_continent['country'],
            mode='markers',
            opacity=0.7,
            marker={
                'size': df[df['continent'] == i]['size'],
                'line': {'width': 0.5, 'color': 'white'},
                'sizeref': sizeref,
                'symbol': 'circle',
                'sizemode': 'area'
            },
            name=i
        ))

    return {
        'data': traces,
        'layout': go.Layout(
            xaxis={'type': 'log', 'title': 'GDP Per Capita'},
            yaxis={'title': 'Life Expectancy', 'range': [20, 90]},
            margin={'l': 40, 'b': 40, 't': 10, 'r': 10},
            legend={'x': 0, 'y': 1},
            hovermode='closest'
        )
    }


if __name__ == '__main__':
    app.run_server()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
  • .gitignore :註記不需要 git 版本管控的檔案
# .gitignore
venv
*.pyc
.DS_Store
.env
1
2
3
4
5
  • Procfile :Heroku 雲端服務所需要的檔案
# Procfile
web: gunicorn app:server
1
2
  • requirement.txt :註記 Python 虛擬環境所使用模組與套件的檔案,在終端機使用 pip freeze > requirements.txt 指令來建立

完成步驟三,資料夾會有四個檔案,其中 .gitignore 是隱藏檔案。

資料夾會有四個檔案

步驟四:安裝 Heroku CLI、登入 Heroku、部署 Dash 應用程式

  • 安裝 Heroku CLI

參考 https://devcenter.heroku.com/articles/heroku-cli 依不同作業系統安裝 Heroku CLI。

  • 登入 Heroku:在終端機輸入 heroku login 指令,並依照提示輸入自己的帳號與密碼
# Terminal
heroku login
Enter your Heroku credentials.
Email: YOUREMAIL@example.com
Password (typing will be hidden):
Authentication successful.
1
2
3
4
5
6
  • 部署 Dash 網頁應用程式
# Terminal
heroku create dash-gapminder
git add . # add all files to git
git commit -m 'Initial app boilerplate'
git push heroku master # deploy code to heroku
heroku ps:scale web=1  # run the app with a 1 heroku "dyno"
heroku open
1
2
3
4
5
6
7

成功完成了部署 Dash 網頁應用程式:https://dash-gapminder.herokuapp.com

小結

在這個小節中我們首先瀏覽最終成品、接著簡介 Plotly 團隊所開發的 Dash 產品、取得 Gapminder 摘錄版本資料、安裝 Dash、Dash 網頁應用程式的組成、繪製氣泡圖、加入時間軸滑桿篩選年份、加入複選框清單或下拉式選單篩選洲別與如何在 Heroku 部署 Dash 網頁應用程式。

延伸閱讀