python數據可視化 – 利用Bokeh和Bottle.py在網頁上展示你的數據

在數據科學中,通過圖表將數據可視化是一個很重要的工作,在開始數據分析之前,通過數據可視化可以幫助我們理解數據,而更重要的是,在完成分析、預測等等過程之後,我們需要通過數據可視化講結論展示出來。通過網頁創建可以交互的圖表是展示數據的一個重要手段。

1. 文章重點和項目介紹

本文的重點將是展示如何將bokeh和bottle集成在一起,並部署到服務器上,供他人訪問查閱,因此不會在bokeh和bottle,以及pandas的相關代碼具體實現細節上面面俱到,但是對於我們實現的代碼,還是會進行講解(可能不會那麼深入)。本文將選取中國2017到2019年的AQI數據作為項目的數據集,然後利用這些數據繪制3張表格(一張折線圖和兩張帶分組的柱狀圖),然後通過bottle和bootstrap前端模板建立一個展示網頁,最後會將這個網頁應用部署到Heroku上邊(這一步作為參考,你可以通過localhost訪問本機服務,或者選取其他雲服務商的服務器)。

本文使用的數據集和代碼實現都可以在下邊這個github倉庫中找到:
https://github.com/pythonlibrary/bokeh-bottlepy
我已經將數據集進行過清理,數據集中包含規整的從2017年到2019年的各個城市的日AQI平均值。

2. 數據集研究和圖表準備

在本節中,和大多數數據分析項目一樣,我們將使用jupyter notebook作為我們的環境,因為這個工具能夠方便的實現代碼修改和及時的代碼結果展示。
首先完成最重要的事情,導入必要的python庫

import numpy as np
import pandas as pd

from bokeh.plotting import figure, show
from bokeh.models import ColumnDataSource, HoverTool
from bokeh.transform import dodge

from bokeh.io import output_notebook

然後在notebook中運行,下邊代碼來初始化bokeh,bokeh可以將圖標輸出成不同的格式文件,如html等,但是要在notebook中顯示,則需要在最開始的時候指明。

output_notebook()

成功執行完以後,notebook會提示成功加載bokeh環境,如下:

2.1 導入數據集

我們使用pandas的read_csv方法讀入數據集,並將一些城市的數據拿出來,因為讀入以後date一列的數據格式不是pandas datatime,我們在這裡做一個轉換,方便後邊繪圖使用,因為數據集中還有一些其他空氣質量指標例如PM2.5等,我們僅僅選取AQI作為關註重點形成新的數據幀 df

cities = ['上海', '北京', '杭州', '寧波', '保定', '南京', '蘇州', '深圳', '廈門', '廣州']
df = pd.read_csv('AQI_merged.csv')
df['date'] = pd.to_datetime(df['date'])
df = df.sort_values(by='date').reset_index(drop=True)
df = df[df['type']=='AQI']

我們的基礎數據幀(Dataframe)創建好以後,讓我們來看一下它裡邊包含瞭什麼數據,我們使用如下代碼提取2019年的數據,並且在notebook中展示前5條記錄。

df_2019_day = df[df['date']>='2019-01-01']
df_2019_day.head()

可以看出,在數據幀中,按照每一天為行,記錄瞭當天幾個城市的AQI值。

2.2 繪制圖表

利用導入的數據,我們將繪制3張圖表:

2019年上海,北京,深圳三地的每天AQI變化曲線(曲線圖)
2019年上海,北京,深圳三地的每月平均AQI對比(柱狀圖)
2017年到2019年北京每月平均AQI對比(柱狀圖)

圖表1:2019年上海,北京,深圳三地的每天AQI變化曲線

利用剛剛我們創建的df_2019_day數據幀,使用如下代碼繪制圖表,註意,我們使用瞭Bokeh提供的ColumnDataSource的方式來給bokeh圖表傳遞數據。

簡單說明一下:我們先使用figure創建瞭一個空的圖畫,然後用line方法畫瞭上海的數據,然後重復line方法兩次在圖畫上添加瞭另外兩個城市的數據,最後,通過add_tools方法添加瞭一個鼠標懸停提示,用於顯示鼠標位置的AQI值。

source = ColumnDataSource(df_2019_day)

p = figure(x_axis_type="datetime", title="2019年AQI日均平均變化曲線", plot_width=900, plot_height=400)
p.line('date', '上海', line_color='blue', legend_label='上海', source=source)
p.line('date', '北京', line_color='green', legend_label='北京', source=source)
p.line('date', '深圳', line_color='orange', legend_label='深圳', source=source)

p.legend.location = "top_right"
p.add_tools(HoverTool(tooltips=[("AQI", "$y")]))
    
show(p)

圖表2:2019年上海,北京,深圳三地的每月平均AQI對比

我們想要畫出每月平均AQI,而數據幀中包含的是每日的AQI,因此,利用dataframe的groupby方法,可以求得每月的平均值。並新建瞭一列month來存放月信息。最後通過head方法查看下我們獲得的新的數據幀是否包含瞭按月平均的AQI信息。

pd.options.mode.chained_assignment = None
df_2019_day['month'] = df_2019_day['date'].apply(lambda x: x.strftime('%Y-%m'))
df_2019_month = df_2019_day.groupby(by='month').mean().reset_index()
df_2019_month.head()

數據集處理結果符合我們的預期,接下來使用這個數據集繪制第二張圖表。因為我們想要比較不同城市同一個月的AQI,因此我們的柱狀圖需要分組顯示,這裡使用瞭bokeh中的dodge方式,每一個dodge為一個城市的數據,並指明瞭在圖表上的相對位置。

source = ColumnDataSource(df_2019_month)

p = figure(x_range=list(df_2019_month['month']), title="2019年AQI", plot_width=900, plot_height=400)

p.vbar(x=dodge('month', -0.25, range=p.x_range), top='上海', width=0.2, color="#c9d9d3", legend_label="上海", source=source)
p.vbar(x=dodge('month', 0, range=p.x_range), top='北京', width=0.2, color="#718dbf", legend_label="北京", source=source)
p.vbar(x=dodge('month', 0.25, range=p.x_range), top='深圳', width=0.2, color="#e84d60", legend_label="深圳", source=source)

p.xgrid.grid_line_color = None
p.y_range.start = 0

p.add_tools(HoverTool(tooltips=[("時間", "@month"), ("上海平均AQI", "@{上海}"), ("北京平均AQI", "@{北京}"), ("深圳平均AQI", "@{深圳}")]))

show(p)

圖表3:2017年到2019年北京每月平均AQI對比

跟圖表2 類似我們對數據幀進行必要的處理,同時因為我們要顯示不同的年月的對比,所以講年份和月份單獨放置到year和month列中。

df['date_ym'] = df['date'].apply(lambda x: x.strftime('%Y-%m'))
df_month = df.groupby(by='date_ym').mean().reset_index()
df_month['month'] = df_month['date_ym'].apply(lambda x: x.split('-')[-1])
df_month['year'] = df_month['date_ym'].apply(lambda x: x.split('-')[0])
df_month.head()

然後創建3個數據幀,每個僅包含一年的數據

df_2017 = df_month[df_month['year']=='2017'][['month', '北京']]
df_2018 = df_month[df_month['year']=='2018'][['month', '北京']]
df_2019 = df_month[df_month['year']=='2019'][['month', '北京']]

最後,還是通過相同的bokeh方法,繪制新的柱狀圖。

source_2017 = ColumnDataSource(df_2017)
source_2018 = ColumnDataSource(df_2018)
source_2019 = ColumnDataSource(df_2019)

p = figure(x_range=list(df_2017['month']), title="2017-2019年北京AQI對比", plot_width=900, plot_height=400)

p.vbar(x=dodge('month', -0.25, range=p.x_range), top='北京', width=0.2, color="#c9d9d3", legend_label="2017", source=source_2017)
p.vbar(x=dodge('month', 0, range=p.x_range), top='北京', width=0.2, color="#718dbf", legend_label="2018", source=source_2018)
p.vbar(x=dodge('month', 0.25, range=p.x_range), top='北京', width=0.2, color="#e84d60", legend_label="2019", source=source_2019)

p.xgrid.grid_line_color = None
p.y_range.start = 0

p.add_tools(HoverTool(tooltips=[("時間", "@month"), ("AQI", "@{北京}")]))

show(p)

到這裡我們的3張圖表已經準備好瞭,但是他們都是在notebook中運行的,後邊我們將對這些代碼進行簡單的轉化,並嵌入到bottle網頁應用中。

3. Bottle網頁應用

bottle是一個超輕量級的python web框架,我們在本文中選擇瞭bottle而沒有選擇flask或者Django的原因就在於它的超輕量級,可以快速的搭建網頁應用,對於以僅僅做數據展示為目的的網頁應用,使用bottle可以讓你快速上手,讓你更專註於數據分析。

我們將采用bootstrap前端模板加bottle內置的模板引擎的方式來實現這個應用,為瞭快速實現這個目標,我們選取瞭https://github.com/arsho/bottle-bootstrap這個項目作為我們的初始代碼,所以,本文項目中使用到的網頁應用代碼99%的實現來自於這個項目,我們僅僅做瞭一點改動。在本節內容中,我們會講解一下bottle應用的重點代碼和概念。

本文對應的代碼可以在 https://github.com/pythonlibrary/bokeh-bottlepy 這個倉庫中找到。

3.1 文件夾結構

我們的bokeh-bottlepy項目目錄結構如下,其中

dataset文件夾:包含瞭數據集csv文件
static文件夾:包含瞭bootstrap前端框架代碼,包括css,JavaScript,以及fonts等,用於以bootstrap的主題來展現html頁面
views文件夾中:包含我們要如何展示數據的模板,本項目作為入門項目,其中僅僅包含瞭一個index.tpl文件,作為我們僅有的一個單頁面網頁的模板,該模板會由bottle應用導入數據來渲染,最總形成用戶看到的頁面
app.py:為我們的入口文件,我們所有的python代碼將在這個裡邊實現,最終運行也是通過:python app.py來啟動服務
Procfile:涉及到Heroku部署,後邊我們會提到

3.2 路由

用python web框架實現的是動態的網頁,也就是說網頁是在用戶訪問的時候生成的,路由這個概念對於第一次接觸網頁應用的人比較陌生,不過其實很簡單,通俗的講,用戶在點擊一個網頁上的鏈接或按鈕,或在瀏覽器地址欄中訪問一個鏈接的時候,網頁服務器端會根據鏈接的不同做不同的動作,並將結果組織成html並呈現給用戶,這一個過程就是路由。

在bottle中實現路由其實就是給每一個url實現一個對應的處理方法。下邊的代碼就是本項目用到的所有相關的部分

dirname = '.'

app = Bottle()
debug(True)

@app.route('/static/<filename:re:.*\.css>')
def send_css(filename):
    return static_file(filename, root=dirname+'/static/asset/css')

@app.route('/static/<filename:re:.*\.js>')
def send_js(filename):
    return static_file(filename, root=dirname+'/static/asset/js')

@app.route('/')
def index():
    data = {
            "developer_organization":"pythonlibrary.net"}
    return template('index', data = data)

所有bottle網頁應用需要實例化一個Bottle對像,作為服務本身,這裡我們起名叫app,同時打開瞭debug模式,即當訪問url的時候,Bottle應用會打印一些調試信息輔助開發人員定位問題。

路由函數的指定是通過@app.route裝飾器實現的,這個裝飾器的參數就是相對url,例如index函數的路由地址為/,如果本地服務端口為8080,則絕對url為:http://localhost:8080/,用戶在訪問這個地址的時候index函數將會被調用,而它的返回值就是用戶看到的頁面,這裡是使用瞭template方法來使用data數據渲染模板,模板的概念我們下一章節會進行介紹。

要做出一個漂亮的頁面,需要使用到復雜的JavaScript和css,所幸的是我們選擇的bootstrap框架為我們實現瞭這些復雜部分,我們隻需要應用它提供的模組就可以搭建出一個漂亮的網站。

在html中,JavaScript和css也是通過url來訪問到的,因此如果要使模板生效,需要告知bottle這些JavaScript和css需要從本地哪個路徑中去找,代碼中的send_css和send_js函數就是利用bottle 中的static_file函數來通知應用本地的資源在什麼位置,而上邊的路由地址則是用戶訪問網頁的時候再html中的地址,因此這兩個函數實現瞭,url和本地資源的連接。

3.3 模板實現

所有的Python網頁框架,在不使用前後端分離的方式開發網頁應用的時候,都會包含一個模板的概念,這些框架大部分都繼承瞭自己的模板引擎,bottle中也集成瞭一個他們稱為SimpleTemplate的簡單模板引擎,當然你可以選擇使用其他第三方的模板引擎,如nijia2,mako等。

所謂模板引擎其實即使基於模板關鍵字的替換,引擎提供瞭一系列的語法,引擎可以解析這些語法,做出相應的動作,例如根據不同的情況填入不同的數據,做循環,判斷等等,然後其餘的內容將保持不變的放到輸出中,可以通過python的stringtemplate來類比。

我們這個項目中,index.tpl就是模板,裡邊包含瞭SimpleTemplate可以識別的語法以及其他內容,當SimpleTemplate解析index.tpl總的語法,並填入合適的數據,則最終會得到完整的html內容,因此模板是 html + 引擎語法的集合,至於文件後綴tpl則無關緊要,可以使任何你定義的後綴,隻是一般tpl代表template。

我們對原始代碼的該文件進行一些修改:將head標簽中的信息,按照我們的項目進行修改

<meta name="description" content="Deploy Bokeh Data Visualization with Bottlepy">

<title>China AQI</title>

然後將導航條 navbar div按照我們的要求修改成我們自己的鏈接,將網頁主體container中最上邊的文字框改成我們的項目描述。

<div id="navbar" class="navbar-collapse collapse">
  <ul class="nav navbar-nav navbar-right">
  	<li><a href="../" rel="external nofollow"   >Home</a></li>
  	<li><a href="https://github.com/pythonlibrary/bokeh-bottlepy" rel="external nofollow"  rel="external nofollow"  rel="external nofollow"       >On Github</a></li>
  </ul>
</div><!--/.nav-collapse -->
<div class="row">
  <div class="jumbotron">
  <h2>中國AQI數據可視化</h2>
	  <p>這是一個基於bottlepy, bokeh和Bootstrap的一個數據可視化部署的示例項目,采用瞭中國從2017年到2019年的AQI信息數據作為項目的演示數據。</p>
  </div>
</div>

回到app.py中,在這個文件中下邊這段代碼,通過template方法實現瞭對index模板的渲染,這個方法的參數data,將作為數據動態的傳入到模板中,相對應的模板中有一個 {{data[“developer_organization”]}} 的語句,這就是模板語法,跟python語法類似,通過dict的方式訪問瞭data變量中的developer_organization鍵對應的值。

@app.route('/')
def index():
    data = {
            "developer_organization":"pythonlibrary.net"}
    return template('index', data = data)

3.4 啟動網頁服務

我們在app.py實現瞭類似下邊這樣的入口,如果在終端中運行python app.py,這段代碼將被執行,也就可以啟動網頁服務,服務的端口為8080,同時將host設置為0.0.0.0意思是其他電腦可以訪問這臺電腦上的服務,如果僅想本機本地訪問可以設置為localhost

if __name__ == "__main__":
    port = 8080
    app.run(host="0.0.0.0", port=port, debug=True)

4. 將Bokeh和Bottle集成在一起

4.1 模板修改

首先我們想要在html中顯示bokeh生成的圖表,需要加載bokeh的JavaScript,通過在index.tpl中添加下邊幾個CDN的方式來導入。

<script src="https://cdn.bokeh.org/bokeh/release/bokeh-1.4.0.min.js"></script>
<script src="https://cdn.bokeh.org/bokeh/release/bokeh-widgets-1.4.0.min.js"></script>
<script src="https://cdn.bokeh.org/bokeh/release/bokeh-tables-1.4.0.min.js"></script>

然後我們要添加數據圖表的占位符(相關的引擎語法代碼),當進行模板渲染的時候,會被動態的替換為python代碼中提供的內容。

在頁面主體container中添加三個圖表的占位符

註意:有別於其他數據傳入語法,這裡在data[“lot1_div”]前邊有一個感嘆號(!),這個非常重要,如果沒有感嘆號意味著,傳入的數據將被認為是字符串,在渲染的時候會被引號括起來,而我們實際想要填充在這裡的是html代碼,而不是被雙引號括起來的html代碼,感嘆號就是告知引擎,我們傳入是的瀏覽器可以處理的html或者JavaScript或者css代碼。

<div class="row">
  {{!data["plot1_div"]}}
</div>
</br></br></br></br>
<div class="row">
  {{!data["plot2_div"]}}
</div>
</br></br></br></br>
<div class="row">
  {{!data["plot3_div"]}}
</div>

在body標簽後邊添加繪制圖表使用的JavaScript腳本占位符

{{!data["plot_script"]}}

這裡模板中未來用到的圖表div和JavaScript腳本將會由bokeh生成,並有bottle渲染,我們會在加下來這一章節說明。

4.2 Python代碼集成

將 2.2 章節中在notebook中調試成功的代碼轉換為函數,並實現到app.py中,註意原本在notebook中顯示圖表我們使用瞭show(p)的方法,在網頁應用中我們僅僅是通過return p將圖表對象返回,返回值將通過bottle提供的方法進行處理。

def get_df_from_source():
    ''' get dataframes from the source dataset, only take the data of some big cities
    '''
    cities = ['上海', '北京', '杭州', '寧波', '保定', '南京', '蘇州', '深圳', '廈門', '廣州']
    df = pd.read_csv(dirname+'/dataset/AQI_merged.csv')
    df['date'] = pd.to_datetime(df['date'])
    df = df.sort_values(by='date').reset_index(drop=True)
    df = df[df['type']=='AQI']
    return df

def draw_daily_AQI(mini_date, df):
    year = mini_date.split('-')[0]
    df_day = df[df['date']>=mini_date]
    source = ColumnDataSource(df_day)

    p = figure(x_axis_type="datetime", title="{}年AQI日均平均變化曲線".format(year), plot_width=1150, plot_height=400)
    p.line('date', '上海', line_color='blue', legend_label='上海', source=source)
    p.line('date', '北京', line_color='green', legend_label='北京', source=source)
    p.line('date', '深圳', line_color='orange', legend_label='深圳', source=source)

    p.legend.location = "top_right"
    p.add_tools(HoverTool(tooltips=[("AQI", "$y")]))

    return p
        
def draw_month_AQI(mini_date, df):
    year = mini_date.split('-')[0]
    df_day = df[df['date']>=mini_date]

    df_day['month'] = df_day['date'].apply(lambda x: x.strftime('%Y-%m'))
    df_month = df_day.groupby(by='month').mean().reset_index()

    source = ColumnDataSource(df_month)

    p = figure(x_range=list(df_month['month']), title="2019年AQI", plot_width=1150, plot_height=400)
    p.vbar(x=dodge('month', -0.25, range=p.x_range), top='上海', width=0.2, color="#c9d9d3", legend_label="上海", source=source)
    p.vbar(x=dodge('month', 0, range=p.x_range), top='北京', width=0.2, color="#718dbf", legend_label="北京", source=source)
    p.vbar(x=dodge('month', 0.25, range=p.x_range), top='深圳', width=0.2, color="#e84d60", legend_label="深圳", source=source)
    p.xgrid.grid_line_color = None
    p.y_range.start = 0
    p.add_tools(HoverTool(tooltips=[("時間", "@month"), ("上海平均AQI", "@{上海}"), ("北京平均AQI", "@{北京}"), ("深圳平均AQI", "@{深圳}")]))
        
    return p

def draw_year_AQI(df):
    df['date_ym'] = df['date'].apply(lambda x: x.strftime('%Y-%m'))
    df_month = df.groupby(by='date_ym').mean().reset_index()
    df_month['month'] = df_month['date_ym'].apply(lambda x: x.split('-')[-1])
    df_month['year'] = df_month['date_ym'].apply(lambda x: x.split('-')[0])

    df_2017 = df_month[df_month['year']=='2017'][['month', '北京']]
    df_2018 = df_month[df_month['year']=='2018'][['month', '北京']]
    df_2019 = df_month[df_month['year']=='2019'][['month', '北京']]

    source_2017 = ColumnDataSource(df_2017)
    source_2018 = ColumnDataSource(df_2018)
    source_2019 = ColumnDataSource(df_2019)

    p = figure(x_range=list(df_2017['month']), title="2017-2019年北京AQI對比", plot_width=1150, plot_height=400)

    p.vbar(x=dodge('month', -0.25, range=p.x_range), top='北京', width=0.2, color="#c9d9d3", legend_label="2017", source=source_2017)
    p.vbar(x=dodge('month', 0, range=p.x_range), top='北京', width=0.2, color="#718dbf", legend_label="2018", source=source_2018)
    p.vbar(x=dodge('month', 0.25, range=p.x_range), top='北京', width=0.2, color="#e84d60", legend_label="2019", source=source_2019)

    p.xgrid.grid_line_color = None
    p.y_range.start = 0

    p.add_tools(HoverTool(tooltips=[("時間", "@month"), ("AQI", "@{北京}")]))

    return p

三個繪圖函數返回瞭圖表對象p,我們如果能夠讓bottle來渲染圖表對象,從而實現在網頁中的圖表展示呢?bokeh提供瞭一個components方法,可以接收圖表對象作為參數,而返回繪圖使用的JavaScript腳本和圖表div,因此修改我們的index路由函數為:

@app.route('/')
def index():
    df = get_df_from_source()
    plot1 = draw_daily_AQI('2019-01-01', df=df)
    plot2 = draw_month_AQI('2019-01-01', df=df)
    plot3 = draw_year_AQI(df=df)
    plots_data = components((plot1, plot2, plot3))

    data = {
            "plot_script":plots_data[0],
            "plot1_div":plots_data[1][0],
            "plot2_div":plots_data[1][1],
            "plot3_div":plots_data[1][2],
            "developer_organization":"pythonlibrary.net"}
    return template('index', data = data)

在這裡,index.tpl模板中的data字典中的plot1_div,plot2_div,plot3_div以及plot_script將被動態的渲染替換。最終實現瞭將圖表展示在網頁上的目的。

你可以clone本項目的倉庫來嘗試運行,或者直接訪問http://china-aqi-data-visulazition.herokuapp.com/來查看效果

5. 部署應用到Heroku

這部分內容跟怎麼將數據圖表展示在網頁上沒有直接的關系,僅僅是一種可選的免費雲服務,可以供你來共享你的頁面,或者瞭解網頁部署。但其實不同的服務可能部署的方式並不相同,因此如果你要部署你的網頁到其他服務提供商,可能這裡的知識完全不適用。

在Heroku上用戶可以免費部署有限的網絡應用,同時過程也非常的簡單,隻需要實現一個Procfile文件,Heroku系統就知道怎麼運行你的服務瞭。我們的項目中Procfile使用如下代碼,跟我們本地運行服務類似。

web: python app.py

而針對app.py的入口代碼,需要將port改為從環境變量讀取,因為Heroku會動態的為應用分配端口,如果指定一個固定值,則會因為Heroku沒有打開其的對外訪問,而導致用戶無法訪問該服務。

if __name__ == "__main__":
    port=int(os.environ.get("PORT", 8080))
    app.run(host="0.0.0.0", port=port, debug=True

最後用戶可以在Heroku頁面上選擇將github倉庫和應用連接在一起,那麼系統會自動的從github拉取最新代碼然後啟動服務。

6. 參考文檔

https://docs.bokeh.org/en/latest/docs/reference/models/plots.html
https://docs.bokeh.org/en/latest/docs/reference/embed.html
https://bottlepy.org/docs/dev/

更多關於python數據可視化的教程請查看下面的相關文章

推薦閱讀: