Trực quan hóa dữ liệu bằng Python Dash

Trực quan hóa dữ liệu bằng Python Dash

Giới thiệu về Dash

Dash là một thư viện mã nguồn mở được phát hành theo giấy phép MIT. Được viết trên Plotly.js và React.js, Dash lý tưởng cho việc xây dựng và triển khai các ứng dụng dữ liệu với UI (giao diện người dùng) tùy chỉnh. Dash đủ đơn giản để bạn có thể liên kết UI với code của mình trong vòng chưa đầy 10 phút. Ứng dụng Dash được render lên trình duyệt web, vì vậy nó có thể chạy trên đa nền tảng và cả thiết bị di động.

Hướng dẫn sử dụng Dash

Cài đặt Dash

Yêu cầu máy cài sẵn Python 3.
Trong terminal chạy lệnh sau để cài đặt Dash:

pip install dash

Với lệnh trên thì ngoài dash, pip cũng sẽ cài đặt thư viện hỗ trợ vẽ đồ thị đó là Plotly.py.

Và cuối cùng chúng ta cần cài đặt thư viện Pandas bằng lệnh:

pip install pandas

Về Pandas

Pandas là một thư viện mã nguồn mở được phát hành theo giấy phép BSD. Pandas cung cấp các cấu trúc dữ liệu hiệu suất cao, dễ sử dụng và các công cụ phân tích dữ liệu cho ngôn ngữ lập trình Python.

Pandas cung cấp 2 cấu trúc dữ liệu chính là DataFrame và Series. DataFrame là một cấu trúc dữ liệu 2 chiều có thể lưu trữ dữ liệu thuộc các loại khác nhau (bao gồm ký tự, số nguyên, giá trị dấu phẩy động, dữ liệu phân loại và hơn thế nữa) trong các cột. Mỗi cột trong DataFrame là một Series.

Có ba quy ước phổ biến để lưu trữ dữ liệu dạng cột:

    • Dữ liệu dạng dài có một hàng cho mỗi quan sát và một cột cho mỗi biến. Dạng này phù hợp để lưu trữ và hiển thị dữ liệu đa biến, tức là với số chiều lớn hơn 2.

Long-form data

    • Dữ liệu dạng rộng có một hàng cho mỗi giá trị của một trong các biến đầu tiên và một cột cho mỗi giá trị của biến thứ hai. Dạng này phù hợp để lưu trữ và hiển thị dữ liệu 2D.

wide-form data

    • Dữ liệu dạng hỗn hợp là kết hợp của dữ liệu dạng dài và dữ liệu dạng rộng.

mixed-form data

Dash Layout

Ứng dụng Dash bao gồm hai phần. Phần đầu tiên là “layout” của ứng dụng và nó mô tả ứng dụng đó trông như thế nào. Phần thứ hai mô tả khả năng tương tác của ứng dụng, nó là “callbacks”.

Layout” là 1 cây tập hợp các “components“.

Dash cung cấp rất nhiều loại component: Dash HTML Components, Dash Core Components, Dash DataTable, Dash DAQ, Dash Bootstrap Components,…

Trong khuôn khổ của bài viết chúng ta sẽ tìm hiểu Dash HTML Components và Dash Core Components.

Dash HTML Components

Là fuction cung cấp các component kiểu HTML, dùng để định nghĩa các HTML tag cho layout.

Để dùng Dash HTML Components chúng ta cần import vào file .py như sau: from dash import html

Ví dụ:

html_demo.py

import dash
from dash import html

app = dash.Dash(__name__)

app.layout = html.Div(children=[
   html.H1(children='Hello Dash'),
])

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

html.H1(children='Hello Dash') sẽ tạo ra <h1>Hello Dash</h1> trên trình duyệt.

Cũng giống như các thẻ HTML tag, chúng ta hoàn toàn có thể thay đổi style của html_component bằng property “style“.

Ví dụ: html.H1('Hello Dash', style={'textAlign': 'center', 'color': '#7FDBFF'})

Đoạn mã trên được hiển thị dưới dạng <h1 style="text-align: center; color: #7FDBFF">Hello Dash</h1>

Có một số khác biệt quan trọng giữa Dash HTML Components và các thuộc tính HTML:

  • Thuộc tính “style” trong HTML là một chuỗi được phân tách bằng dấu chấm phẩy. Trong Dash, bạn cần cung cấp một dictionary.
  • Các key trong dictionary là dạng camelCased. Vì vậy, thay vì “text-align“, trong Dash là “textAlign“.
  • Thuộc tính “class” trong HTML là “className” trong Dash.
  • Con của thẻ HTML được chỉ định thông qua argument với từ khoá “children“.

Ngoài ra thay vì dùng style trực tiếp chúng ta có thể dùng file CSS để định nghĩa style cho layout, chi tiết tham khảo: https://dash.plotly.com/external-resources

Bạn có thể xem tất cả các component có sẵn trong Dash HTML Components Gallery: https://dash.plotly.com/dash-html-components

Dash Core Components

Bao gồm một tập hợp các thành phần cấp cao hơn như dropdown, checkbox, radio, graph, v.v. Bạn có thể xem tất cả các component có sẵn trong Dash Core Components Gallery: https://dash.plotly.com/dash-core-components

Để dùng Dash Core Components chúng ta cần import vào file .py như sau: from dash import dcc

Trong các core component thì “Graph” là component quan trọng đối với Trực quan hóa dữ liệu. “Graph” hiển thị trực quan hóa dữ liệu trên trình duyệt bằng cách sử dụng thư viện javascript vẽ đồ thị mã nguồn mở Plotly.js. Plotly.js hỗ trợ hơn 35 loại biểu đồ và hiển thị biểu đồ ở cả vector-quality SVG và high-performance WebGL. Một lưu ý nhỏ ở đây là Plotly.js chỉ dùng để render lên trình duyệt (do Dash thực hiện) còn khi code chúng ta sẽ dùng thư viện Plotly.py (được cung cấp sẵn khi cài đặt Dash) chứ không code trực tiếp bằng javascript.

Để biết cách sử dụng “Graph” component chúng ta hãy đến với ví dụ hiển thị data csv lên trình duyệt dưới dạng đồ thị đường gấp khúc:

csv/graph_sample.csv

DateTime,DATA 1,DATA 2,DATA 3,DATA 4
20211220 101010.000,30,100,124,197
20211220 101010.010,40,110,134,65
20211220 101010.020,50,140,214,149
20211220 101010.030,60,150,169,-98
20211220 101010.040,70,160,204,-173
20211220 101010.050,80,170,164,-108
20211220 101010.060,90,180,148,150
20211220 101010.070,100,190,180,92
20211220 101010.080,110,200,268,94
20211220 101010.090,120,210,164,-139
20211220 101010.100,130,220,254,-132

Đầu tiên chúng ta cần dùng pandas để load file csv

df = pd.read_csv('csv/graph_sample.csv')

In biến df ra console xem thử cấu trúc của nó

print(df)

example wide-form

Tới đây bạn có thấy hơi quen quen không? Chính xác, nó là Dữ liệu dạng rộng mà chúng ta đã đề cập ở phần tìm hiểu Pandas ở trên!

Bước tiếp theo chúng ta chuyển dữ liệu của cột DateTime từ string thành datetime để chart của chúng ta hiển thị chính xác ngày và giờ của dữ liệu

df['DateTime'] = pd.to_datetime(df['DateTime'], format='%Y%m%d %H:%M:%S.%f')

Bây giờ chúng ta tạo một line figure bằng plotly express

line_fig = px.line(df, x='DateTime', y=['DATA 1', 'DATA 2', 'DATA 3', 'DATA 4'])

Truyền figure vào Graph component

app.layout = html.Div(children=[
    dcc.Graph(id='graph', figure=line_fig)
])

Code hoàn chỉnh
graph_demo.py

import dash
import pandas as pd
import plotly.express as px
from dash import dcc
from dash import html

app = dash.Dash(__name__)

df = pd.read_csv('csv/graph_sample.csv')
print(df)
df['DateTime'] = pd.to_datetime(df['DateTime'], format='%Y%m%d %H:%M:%S.%f')

line_fig = px.line(df, x='DateTime', y=['DATA 1', 'DATA 2', 'DATA 3', 'DATA 4'])

app.layout = html.Div(children=[
   dcc.Graph(id='graph', figure=line_fig)
])

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

Trong terminal chạy lệnh:

python graph_demo.py

Sau đó truy cập http://127.0.0.1:8050/ để xem kết quả

graph example

Trong ví dụ trên:

  • Thư viện Pandas được dùng để xử lý data đầu vào (đọc csv, chuyển dữ liệu của cột DateTime từ string thành datetime).
  • Plotly Express (nằm trong thư viện Plotly.py) chịu trách nhiệm quy định kiểu biểu đồ (đường gấp khúc, phân tán,…), x-axis, y-axis,… của Graph đầu ra.

Dash Callbacks

Callback functions: các hàm được Dash tự động gọi bất cứ khi nào thuộc tính của input component thay đổi, để cập nhật một số thuộc tính trong component khác (output).

Để hiểu về Callbacks chúng ta hãy đến với ví dụ về filter dữ liệu theo ngày, với input lấy từ component dcc.DatePickerRange:
csv/callbacks_sample.csv

DateTime,DATA 1,DATA 2,DATA 3,DATA 4
20211219 101010.010,10,200,178,90
20211219 111010.020,20,150,134,25
20211219 121010.030,5,130,210,11
20211219 131010.040,15,110,100,-97
20211219 141010.050,60,150,143,-17
20211219 151010.060,30,140,132,30
20211219 161010.070,20,180,167,45
20211219 171010.080,16,120,240,123
20211219 181010.090,75,190,153,40
20211219 191010.100,90,250,162,-10
20211220 001010.000,68,142,156,1
20211220 011010.010,40,110,134,65
20211220 021010.020,50,140,214,149
20211220 031010.030,60,150,169,-98
20211220 041010.040,70,160,204,-173
20211220 051010.050,80,170,164,-108
20211220 061010.060,90,180,148,150
20211220 071010.070,100,190,180,92
20211220 081010.080,110,200,268,94
20211220 091010.090,120,210,164,-139
20211220 101010.100,130,220,254,-132
20211221 001010.000,10,90,142,30
20211221 011010.010,30,100,162,55
20211221 021010.020,80,120,180,20
20211221 031010.030,70,110,176,-10
20211221 041010.040,50,130,194,-90
20211221 051010.050,60,140,202,-120
20211221 061010.060,90,150,164,100
20211221 071010.070,120,160,197,132
20211221 081010.080,110,170,186,40
20211221 091010.090,130,210,182,-130
20211221 101010.100,120,230,210,-100

callbacks_demo.py

from datetime import datetime, timedelta

import dash
import pandas as pd
import plotly.express as px
from dash import dcc, Output, Input
from dash import html

app = dash.Dash(__name__)

df = pd.read_csv('csv/callbacks_sample.csv')
df['DateTime'] = pd.to_datetime(df['DateTime'], format='%Y%m%d %H:%M:%S.%f')

init_start_date = df['DateTime'].min().strftime('%Y-%m-%d')
init_end_date = df['DateTime'].max().strftime('%Y-%m-%d')

app.layout = html.Div(children=[
   dcc.DatePickerRange(
       id='date-picker-range',
       start_date=init_start_date,
       end_date=init_end_date,
       minimum_nights=0,
       display_format='YYYY/MM/DD'
   ),
   dcc.Graph(id='scatter-graph'),
])


@app.callback(
   Output('scatter-graph', 'figure'),
   Input('date-picker-range', 'start_date'),
   Input('date-picker-range', 'end_date')
)
def update_figure(start_date, end_date):
   if start_date is not None and end_date is not None:
       start_date = datetime.fromisoformat(start_date)
       end_date = datetime.fromisoformat(end_date) + timedelta(days=1)
       filtered_df = df[(start_date <= df['DateTime']) & (df['DateTime'] <= end_date)]
       scatter_fig = px.scatter(filtered_df, x='DateTime', y=['DATA 1', 'DATA 2', 'DATA 3', 'DATA 4'])

       return scatter_fig


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

Trong Dash, các input và output của ứng dụng của chúng ta chỉ đơn giản là các property của một component cụ thể. Trong ví dụ này, input của chúng ta là property “start_date” và “end_date” của component có ID “date-picker-range“. Output của chúng ta là property “figure” của component có ID “scatter-graph“.

Bất cứ khi nào input property thay đổi, function mà có khai báo decorator @callback sẽ được gọi tự động. Dash cung cấp cho callback function này giá trị mới của input property làm argument (trong ví dụ trên function update_figure có 2 argument là start_date, end_date), và Dash cập nhật property của output component với bất kỳ giá trị nào được function trả về (trong ví dụ trên function update_figure trả về scatter_fig).

Trong terminal chạy lệnh python callbacks_demo.py và truy cập vào http://127.0.0.1:8050/ để xem kết quả.

Callbacks demo 1

Sau khi thay đổi end_date

Callbacks demo 2

Tối ưu hóa và thêm chức năng

Ở phần này chúng ta lấy code ở phần Callbacks để tối ưu hóa và thêm chức năng cho nó.

Đọc n DATA

Hiện tại chúng ta đang set cứng số lượng data đầu vào là 4.

scatter_fig = px.scatter(filtered_df, x='DateTime', y=['DATA 1', 'DATA 2', 'DATA 3', 'DATA 4'])

Giả sử chúng ta có số lượng data bất kỳ DATA 1, DATA 2,…, DATA n thì với đoạn code trên chúng ta chỉ có thể đọc và hiển thị được 4 data mà thôi.
Để đọc và hiển thị lên biểu đồ n DATA, chúng ta cần chỉnh sửa code lại một chút:

# get first columns name for x-axis
x_col_name = df.columns[0]
# get list column name except first column for y-axis
y_col_name_list = df.columns[1:]
filtered_df = df[(start_date <= df[x_col_name]) & (df[x_col_name] <= end_date)]
scatter_fig = px.scatter(filtered_df, x=x_col_name, y=y_col_name_list)

Đọc config từ header của CSV

Xét header của CSV như sau

DateTime(yyyyMMdd HH:mm:ss.fff),DATA 1(minFilter=20;maxFilter=100),DATA 2(maxFilter=140),DATA 3,DATA 4,DATA 5

Chúng ta sẽ thêm chức năng đọc config từ header trên:

  • Đọc config của cột DateTime để set format date time (hiện tại đang set cứng trong code).
  • Đọc config minFilter, maxFilter của các cột DATA để lọc bỏ những data có giá trị nhỏ hơn minFilter và lớn hơn maxFilter của cột DATA đó.

Đầu tiên thêm file utils.py chứa các function common

import re

_format_convertor = (
   ('yyyy', '%Y'), ('yyy', '%Y'), ('yy', '%y'), ('y', '%y'),
   ('MMMM', '%B'), ('MMM', '%b'), ('MM', '%m'), ('M', '%m'),
   ('dddd', '%A'), ('ddd', '%a'), ('dd', '%d'), ('d', '%d'),
   ('HH', '%H'), ('H', '%H'), ('hh', '%I'), ('h', '%I'),
   ('mm', '%M'), ('m', '%M'),
   ('ss', '%S'), ('s', '%S'),
   ('tt', '%p'), ('t', '%p'),
   ('fff', '%f'),
   ('zzz', '%z'), ('zz', '%z'), ('z', '%z'),
)


def convert_py_datetime_format(in_format):
   out_format = ''
   while in_format:
       if in_format[0] == "'":
           apos = in_format.find("'", 1)
           if apos == -1:
               apos = len(in_format)
           out_format += in_format[1:apos].replace('%', '%%')
           in_format = in_format[apos + 1:]
       elif in_format[0] == '\\':
           out_format += in_format[1:2].replace('%', '%%')
           in_format = in_format[2:]
       else:
           for intok, outtok in _format_convertor:
               if in_format.startswith(intok):
                   out_format += outtok
                   in_format = in_format[len(intok):]
                   break
           else:
               out_format += in_format[0].replace('%', '%%')
               in_format = in_format[1:]
   return out_format


def extract_csv_col_config(col_name: str):
   try:
       found = re.search('\\((.*)\\)', col_name)
       col_name = col_name.replace(found.group(0), '')
       config_string = found.group(1)
       config_list = config_string.split(';')
       configs = []
       for config in config_list:
           key_value_list = config.split('=')
           key = key_value_list[0]
           value = key_value_list[1] if len(key_value_list) > 1 else None
           configs.append((key, value))
   except AttributeError:
       configs = []
   return col_name, configs

Ở đoạn code trên:

  • Function convert_py_datetime_format dùng để chuyển đổi format kiểu yyyyMMdd HH:mm:ss.fff sang dạng format của Python.
  • Function extract_csv_col_config sẽ nhận vào tên của cột chứa config và trả về tên cột đã cắt bỏ phần string config cùng với array chứa các config của cột đó. Ví dụ DATA 1(minFilter=20;maxFilter=100) sẽ trả về DATA 1 và array [(minFilter, 20), (maxFilter, 100)]

Tiếp theo thêm function process_csv_variable vào app.py

from datetime import datetime, timedelta

import dash
import numpy as np
import pandas as pd
import plotly.express as px
from dash import dcc, Output, Input
from dash import html

from utils import extract_csv_col_config, convert_py_datetime_format


def process_csv_variable(df_param):
   # process x-axis csv variable
   old_x_col_name = df_param.columns[0]
   new_x_col_name, configs = extract_csv_col_config(old_x_col_name)
   datetime_format = configs[0][0]
   df_param = df_param.rename(columns={old_x_col_name: new_x_col_name})
   df_param[new_x_col_name] = pd.to_datetime(df_param[new_x_col_name],
                                             format=convert_py_datetime_format(datetime_format))
   # process y-axis csv variable
   y_col_name_list = df_param.columns[1:]
   for old_y_col_name in y_col_name_list:
       new_y_col_name, configs = extract_csv_col_config(old_y_col_name)
       df_param = df_param.rename(columns={old_y_col_name: new_y_col_name})
       for config, value in configs:
           if config == 'minFilter':
               df_param.loc[df_param[new_y_col_name] < int(value), new_y_col_name] = np.nan
           elif config == 'maxFilter':
               df_param.loc[df_param[new_y_col_name] > int(value), new_y_col_name] = np.nan
   return df_param


app = dash.Dash(__name__)

app.layout = html.Div(id='container', children=[
   dcc.DatePickerRange(
       id='date-picker-range',
       minimum_nights=0,
       display_format='YYYY/MM/DD'
   ),
   dcc.Graph(id='scatter-graph'),
])


@app.callback(
   Output('date-picker-range', 'start_date'),
   Output('date-picker-range', 'end_date'),
   Input('container', 'id')
)
def update_date_picker(id):
   df = pd.read_csv('csv/app_sample.csv')
   df = process_csv_variable(df)
   x_col_name = df.columns[0]

   init_start_date = df[x_col_name].min().strftime('%Y-%m-%d')
   init_end_date = df[x_col_name].max().strftime('%Y-%m-%d')
   return init_start_date, init_end_date


@app.callback(
   Output('scatter-graph', 'figure'),
   Input('date-picker-range', 'start_date'),
   Input('date-picker-range', 'end_date')
)
def update_figure(start_date, end_date):
   df = pd.read_csv('csv/app_sample.csv')
   df = process_csv_variable(df)
   if start_date is not None and end_date is not None:
       start_date = datetime.fromisoformat(start_date)
       end_date = datetime.fromisoformat(end_date) + timedelta(days=1)
       # get first columns name for x-axis
       x_col_name = df.columns[0]
       # get list column name except first column for y-axis
       y_col_name_list = df.columns[1:]
       filtered_df = df[(start_date <= df[x_col_name]) & (df[x_col_name] <= end_date)]
       scatter_fig = px.scatter(filtered_df, x=x_col_name, y=y_col_name_list)

       return scatter_fig


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

Function process_csv_variable sẽ nhận vào DataFrame, đọc config từ tên cột, xử lý data dựa theo config và sẽ trả về DataFrame sau khi xử lý.
Bây giờ chúng ta thêm file csv/app_sample.csv để test

DateTime(yyyyMMdd HH:mm:ss.fff),DATA 1(minFilter=20;maxFilter=100),DATA 2(maxFilter=140),DATA 3,DATA 4,DATA 5
20211219 101010.010,10,200,178,90,110
20211219 111010.020,20,150,134,25,120
20211219 121010.030,5,130,210,11,90
20211219 131010.040,15,110,100,-97,80
20211219 141010.050,60,150,143,-17,130
20211219 151010.060,30,140,132,30,140
20211219 161010.070,20,180,167,45,150
20211219 171010.080,16,120,240,123,160
20211219 181010.090,75,190,153,40,150
20211219 191010.100,90,250,162,-10,170
20211220 001010.000,68,142,156,1,180
20211220 011010.010,40,110,134,65,130
20211220 021010.020,50,140,214,149,190
20211220 031010.030,60,150,169,-98,200
20211220 041010.040,70,160,204,-173,190
20211220 051010.050,80,170,164,-108,180
20211220 061010.060,90,180,148,150,170
20211220 071010.070,100,190,180,92,150
20211220 081010.080,110,200,268,94,160
20211220 091010.090,120,210,164,-139,140
20211220 101010.100,130,220,254,-132,130
20211221 001010.000,10,90,142,30,150
20211221 011010.010,30,100,162,55,160
20211221 021010.020,80,120,180,20,170
20211221 031010.030,70,110,176,-10,110
20211221 041010.040,50,130,194,-90,90
20211221 051010.050,60,140,202,-120,80
20211221 061010.060,90,150,164,100,70
20211221 071010.070,120,160,197,132,60
20211221 081010.080,110,170,186,40,50
20211221 091010.090,130,210,182,-130,40
20211221 101010.100,120,230,210,-100,30

Trong terminal chạy lệnh python app.py và truy cập vào http://127.0.0.1:8050/ để xem kết quả.

app demo 1

Để dễ kiểm tra kết quả chúng ta ẩn các data khác chỉ hiển thị DATA 1

app demo 2

Chúng ta thấy các data có value nhỏ hơn 20 và lớn hơn 100 đã bị lọc bỏ.

Source code

https://gitlab.com/bwv-hp/python-dash-sample

Tài liệu tham khảo

https://dash.plotly.com/

https://pandas.pydata.org/docs/