🌊

Streamlitで札幌のオープンデータを可視化する

2020/12/22に公開

ゆるWeb勉強会@札幌 Advent Calendar 2020 の22日目の記事です。

Streamlit のお勉強をします。題材は、アドベントカレンダー的に札幌のオープンデータとします。DATA-SMART CITY SAPPOROで公開されているデータを可視化してみました。

Streamlitについて

Streamlitは、PythonだけでWebアプリケーションを構築することが出来るフレームワークです。

  • データ分析した結果を、
  • フロントエンドの知識無しに、
  • スライダー、セレクトボックス等リッチなウィジェットも使えて、
  • 素早くグラフ等のかたちで可視化できます

https://www.streamlit.io/

データエンジニア、機械学習エンジニア等が、収集・生成したデータをさくっと可視化してデプロイしてみたい、というときに良いフレームワークだと思いました。

DATA-SMART CITY SAPPORO について

札幌市にまつわるオープンデータが公開されています。
https://data.pf-sapporo.jp/

ダッシュボートとして既にいくつか可視化されていますが、テキストデータ(CSV, JSON等)のみの公開も多いです。今回はそれを対象にします。

本記事にはDATA-SMART CITY SAPPOROのデータが含まれたスクリーンショットがあります。今回使用したデータのライセンスは クリエイティブ・コモンズ 表示 4.0 国際 になっています。

環境

  • Python 3.9.0
  • Streamlit 0.73.0

インストールは簡単です。numpypandas等も依存で付いてきます。

$ pip install streamlit

ただし、Windows環境の場合、2020/12/19現在の最新であるnumpy==1.19.4では問題が発生します(参照)。1つバージョンを落とします。

$ pip install numpy==1.19.3

成果物

リポジトリ

https://github.com/shimat/streamlit_sapporo_data

さわってみる

Streamlit公式でデプロイ先を用意してくれています。本記事で作ったものはこちらからどうぞ。

手元で実行

streamlitの実行時にWeb上のPythonファイルへのURLを示せば、それを動かすことができます。

$ streamlit run https://raw.githubusercontent.com/shimat/streamlit_sapporo_data/main/population.py
$ streamlit run https://raw.githubusercontent.com/shimat/streamlit_sapporo_data/main/covid.py
$ streamlit run https://raw.githubusercontent.com/shimat/streamlit_sapporo_data/main/chikaho.py
$ streamlit run https://raw.githubusercontent.com/shimat/streamlit_sapporo_data/main/hospital.py

私のものに限らず、ネットに上がっているStreamlitスクリプトのURLを指定すれば手元で動かせます。

その1. 人口

データ

住民基本台帳人口 令和元年(2019年)総数

ページを開くと、データエクスプローラがあって、表形式でデータを見ることができます。Streamlitでこの表をグラフにすることに取り組んでみましょう

住民基本台帳人口 令和元年(2019年)のWeb上の表形式データ (出典: DATA-SMART CITY SAPPORO)

Graphのところを押せば、以下のようにもうWeb上でグラフにできちゃったりするのですが、必死に見ないふりをします。

住民基本台帳人口 令和元年(2019年)中央区の人口グラフ (出典: DATA-SMART CITY SAPPORO)

JSONを取得しDataFrameに流す

右上のほうにある データAPI というボタンを押すと、データが取得できるURLを教えてくれます。

このURLからGETして、pandas.DataFrameに流します。

# population.py
import requests
import pandas as pd

response = requests.get("https://ckan.pf-sapporo.jp/api/action/datastore_search?resource_id=dcb6abdc-a73a-400d-abf0-82dffa5b5d40&limit=12", verify=False)
df = pd.json_normalize(response.json(), record_path=["result", "records"])

df

df と書いただけですが、これを拾ってStreamlitが表形式で表示してくれます (streamlit.write(df)も可)。さっそくStreamlitを実行すると、自動的にブラウザが開くはずです。

$ streamlit run population.py


住民基本台帳人口 令和元年(2019年)のStreamlitによる表形式データ (出典: DATA-SMART CITY SAPPORO)

Streamlitを起動後にPythonコードを変更し保存すると、ブラウザの右上に Rerun Always rerun というボタン表示が出ます。Always rerunを押しておけば、以後はコードを保存するだけですぐにブラウザも更新されるので捗ります。

数値が文字列になっているので修正する

これでさっそくグラフ化へ、といきたいのですが、ところで取得したJSONデータの数値はstrになっています。

{ ... "records": [{"手稲区の女": "75206", "西区の女": "115863", ... }

DataFrameに入れるとobjectになりました。

print(df["手稲区の女"].dtype)  # object

このままグラフを作ると意図した結果になりません。intやfloatにするスマートな解決策を探したのですが見つからず、以下のStack Overflowを参考にしました。https://stackoverflow.com/questions/45068797/how-to-convert-string-int-json-into-real-int-with-json-loads/45069099

import json

class MyDecoder(json.JSONDecoder):
    def decode(self, s):
        result = super().decode(s)
        return self._decode(result)

    def _decode(self, o):
        if isinstance(o, str):            
            try:
                if '.' in o:
                    return float(o)
                return int(o)
            except ValueError:
                return o
        elif isinstance(o, dict):
            return {k: self._decode(v) for k, v in o.items()}
        elif isinstance(o, list):
            return [self._decode(v) for v in o]
        else:
            return o

以後はこのMyDecoderを使うことにします。

折れ線グラフにする

streamlit.line_chart にDataFrameを渡すだけです。

列が多すぎるので、少し絞ってからグラフ化してみます。

import json
import pandas as pd
import requests
import streamlit as st

class MyDecoder(json.JSONDecoder):
   ...  # (省略)

response = requests.get("https://ckan.pf-sapporo.jp/api/action/datastore_search?resource_id=5678d107-d9a4-4f81-8f57-092aac11db5e&limit=100", verify=False)
response_json = MyDecoder().decode(response.text)
df = pd.json_normalize(response_json, record_path=["result", "records"])

view = df.set_index("月")[["手稲区の人口", "清田区の人口"]]
st.line_chart(view)

実につまらないグラフが出来上がりました。
住民基本台帳人口 令和元年(2019年)の手稲区と清田区の人口のグラフ (出典: DATA-SMART CITY SAPPORO)

軸の調整など細かい設定は、不可能ではなさそうですが簡単にはできないようです。このへんにStreamlitを使うべきか否かのユースケースの判断材料が垣間見えます。さっそく微妙な例を出しましたが、気を取り直し次行きましょう。

列を選択できるようにする

次に行く前に便利なTipsのメモです。multiselectを使うと、列をGUIからインタラクティブに選択できるようになります。

response = requests.get("https://ckan.pf-sapporo.jp/api/action/datastore_search?resource_id=dcb6abdc-a73a-400d-abf0-82dffa5b5d40&limit=12", verify=False)
response_json = MyDecoder().decode(response.text)

df = pd.json_normalize(response_json, record_path=["result", "records"])
df.set_index("月", inplace=True)
df.drop(columns=["_id", "年"], inplace=True)
    
selected_targets = st.multiselect('select targets', sorted(df.columns))
view = df[selected_targets]
st.line_chart(view)


北区と南区の世帯数を選択したときのグラフ (出典: DATA-SMART CITY SAPPORO)

その2. 新型コロナウイルス陽性患者数

札幌市内の新型コロナウイルス(COVID-19)陽性患者数

これのニュースを聞かない日は無いと思うホットな話題です。日付と陽性患者数が1対1でシンプルなテーブルです。(先にこっちをやればよかった)

さくっと例示して終わりにします。

import json
import streamlit as st
import pandas as pd
import requests

class MyDecoder(json.JSONDecoder):
   ...  # (省略)

@st.cache
def load_data():
    response = requests.get("https://ckan.pf-sapporo.jp/api/action/datastore_search?resource_id=b83606f6-3aa2-4e0c-8a1a-509dd36be2ae&limit=300", verify=False)
    response_json = MyDecoder().decode(response.text)
    df = pd.json_normalize(response_json, record_path=["result", "records"])
    df["日付"] = df["日付"].map(lambda x: re.search(r'\d{4}-\d{2}-\d{2}', x).group())
    return df


df = load_data().copy() \
        .set_index("日付") \
        .drop(columns="_id")
st.write(df)
st.line_chart(df)

下のラベルがやばいですが、これも line_chart を使う以上はどうにもならなそうです。グラフの形はニュースで見慣れた感じで、11月に急増したのが見て取れます。

札幌市内の新型コロナウイルス(COVID-19)陽性患者数のグラフ (出典: DATA-SMART CITY SAPPORO)

今回の実装にて @st.cache というのを使いましたが、これによりDataFrameの値をキャッシュしてくれます。再実行のたびにデータソースのWebAPIにリクエストされることを防げます。基本的には積極的に使うべきでしょう。仕様は以下を参照してください。キャッシュする際は、取得したDataFrameを破壊しないようにするのが重要で、破壊の疑いがあるときは警告が出ます。
https://docs.streamlit.io/en/stable/caching.html

その3. チカホ人流データ

1アレイでのグラフ

札幌駅前通地下歩行空間(チ・カ・ホ)人流データ 2020年11月

札幌市民の誇りである地下通路の通過人数です。このデータは、表を見るとわかるようにTidy Dataになっています。日時とアレイの2変数によって結果(人流)が定まります。このデータはDATA-SMART CITY SAPPOROのWeb上では単純にグラフ化できないようでした。ようやくStreamlit、というかpandasの面目躍如になりそうです(私のpandas力が低いのでだいぶ厳しいですが)。

例として、アレイJ1に絞ってグラフ化してみます。

import json
import streamlit as st
import pandas as pd
import requests

class MyDecoder(json.JSONDecoder):
   ...  # (省略)

@st.cache
def load_data():
    response = requests.get("https://ckan.pf-sapporo.jp/api/action/datastore_search?resource_id=5678d107-d9a4-4f81-8f57-092aac11db5e&limit=100", verify=False)
    response_json = MyDecoder().decode(response.text)
    df = pd.json_normalize(response_json, record_path=["result", "records"])
    return df


df = load_data().copy()
df_pt = df[df['アレイ'].isin(['J1'])].set_index('日時')[['大通り→札幌', '札幌→大通り']]

st.write(df_pt)
st.line_chart(df_pt)


札幌駅前通地下歩行空間(チ・カ・ホ)人流データとグラフ (出典: DATA-SMART CITY SAPPORO)

ピボットテーブル

以下を参考にすると、ピボットテーブルとして可視化できるみたいです。
https://discuss.streamlit.io/t/is-there-a-way-to-incorporate-pivottablejs-in-streamlit/4461/4

多分こんな感じでいけると思いますが、あまり動作芳しくなく、いったん保留です・・・。一応足跡を残しておきます。

$ pip install pivottablejs
import json
import pandas as pd
import requests
import streamlit as st
import streamlit.components.v1 as components
from pivottablejs import pivot_ui

df = load_data().copy()  # load_data()は既出の通り。省略。
df.drop(columns=["_id", "補正"], inplace=True)
df_pt = df.set_index(['日時', 'アレイ']).unstack(['アレイ'])
st.write(df_pt)

r = pivot_ui(df)
with open(r.src, encoding="utf-8") as t:
    components.html(t.read(), width=800, height=600, scrolling=True)

その4. 医療機関

札幌市内の医療機関一覧

病院などの医療機関の情報が、緯度経度付きでリストになっています。Streamlitの地図へのプロット機能を試しましょう。

これもぶっちゃけWeb上でできますが、気にせず進めます。

医療機関一覧の地図プロット結果 (出典: DATA-SMART CITY SAPPORO)

ScatterplotLayer

どんなレイヤーが使えるかは、こちらを参照してください。はじめに ScatterplotLayer を使います。

pydeckを追加で使います。

$ pip install pydeck

レイヤー定義の get_positionget_fill_color にて、DataFrameの特定の列を割り当てます。区コード(1101=中央区, 1102=北区, ...)ごとに別々の色を割り当てて、新しい列に入れておきます。

import json
import streamlit as st
import pandas as pd
import pydeck as pdk
import requests

class MyDecoder(json.JSONDecoder):
   ...  # (省略)

@st.cache
def load_data():
    response = requests.get("https://ckan.pf-sapporo.jp/api/action/datastore_search?resource_id=f2599ba4-0340-40e1-9735-5516541649f6&limit=3000", verify=False)
    response_json = MyDecoder().decode(response.text)
    df = pd.json_normalize(response_json, record_path=["result", "records"])
    return df


df = load_data().copy() \
        .drop(columns=["名称_カナ", "方書", "備考", "市町村名", "電話番号", "都道府県名"])

WARD_COLORS = {
    1101: [255, 32, 32, 160],
    1102: [64, 128, 64, 160],
    1103: [32, 128, 255, 160],
    1104: [0, 255, 0, 160],
    1105: [0, 0, 255, 160],
    1106: [255, 0, 255, 160],
    1107: [128, 0, 255, 160],
    1108: [255, 0, 128, 160],
    1109: [255, 128, 0, 160],
    1110: [139, 69, 19, 160],
}
df["ward_color"] = df["区コード"].apply(lambda x: WARD_COLORS[x])

st.pydeck_chart(pdk.Deck(
    map_style='mapbox://styles/mapbox/streets-v11',
    initial_view_state=pdk.ViewState(
        latitude=43.05,
        longitude=141.35,
        zoom=10.5,
        pitch=50,
    ),
    layers=[
        pdk.Layer(
            'ScatterplotLayer',
            data=df,
            get_position='[経度, 緯度]',
            get_fill_color="ward_color",
            get_radius=100,
        ),
    ],
))


医療機関の区ごとの地図プロット結果 (出典: DATA-SMART CITY SAPPORO)

HeatmapLayer

続いて HeatmapLayer を使う例です。1つ目は医療機関の位置によるヒートマップ、2つ目が病床数によるヒートマップです。get_weightを自分で指定するかどうかで変わります。

df はScatterplotLayerの例と同じですので省略しています。

st.pydeck_chart(pdk.Deck(
    map_style='mapbox://styles/mapbox/light-v10',
    initial_view_state=pdk.ViewState(
        latitude=43.05,
        longitude=141.35,
        zoom=10.5,
        pitch=50,
    ),
    layers=[
        pdk.Layer(
            'HeatmapLayer',
            data=df,
            get_position='[経度, 緯度]',
            radius=200,
            elevation_scale=4,
            elevation_range=[0, 1000]
        )
    ],
))

st.pydeck_chart(pdk.Deck(
    map_style='mapbox://styles/mapbox/light-v10',
    initial_view_state=pdk.ViewState(
        latitude=43.05,
        longitude=141.35,
        zoom=10.5,
        pitch=50,
    ),
    layers=[
        pdk.Layer(
            'HeatmapLayer',
            data=df,
            get_position='[経度, 緯度]',
            get_weight="病床数",
            opacity=0.8,
            cell_size_pixels=15,
            elevation_scale=4,
            elevation_range=[0, 1000]
        )
    ],
))

医療機関の位置によるヒートマップです。下がズームしたところで、順当に大通・札幌駅近辺が赤いですね。

医療機関の位置によるヒートマップ地図 (出典: DATA-SMART CITY SAPPORO)

医療機関の位置によるヒートマップ地図[中心部にズーム] (出典: DATA-SMART CITY SAPPORO)

続いて病床数です。下がズームしたところです。札幌医大付属病院は病床数2位ですが、近辺にはNTT東日本札幌病院・中村記念病院という大病院がひしめいており、遠目では合わさって最も赤くなります。

医療機関の病床数によるヒートマップ地図 (出典: DATA-SMART CITY SAPPORO)

医療機関の病床数によるヒートマップ地図[札医大・市立札幌病院・北大病院を俯瞰] (出典: DATA-SMART CITY SAPPORO)

デプロイ

一般公開して問題ないデータであれば、 https://share.streamlit.io/ にデプロイすることができます。

申し込んで数日でメールが来ると思います。GitHubリポジトリ、デプロイする単一.pyファイルを指定するだけで、30秒くらいで完了します。

まとめ・感想

  • Streamlitで、手元のデータ(主にpd.DataFrame)をほんの10行前後で可視化してWebアプリケーションにできます。JSONのデシリアライズが長くなってしまいましたが、実装の短さは特徴的で、データサイエンティスト等が使うのに適している感触があります。
    • 凝ったことをしようとするとかなり早期につまづく印象で、フロントエンド (主にJavaScriptの各種可視化ライブラリや、そのPythonラッパ) の知識から逃れられはしません。凝らない前提と割り切ったり、デプロイのしやすさを良いと感じるなら、有用だと思いました。
    • streamlit run <URL> でWeb上のスクリプトを動かせることを考えると、単一ファイルで全て実装するのがStreamlit流かと思われます。もしコードが長くなって厳しくなってきたとすればStreamlitを卒業する潮時かもしれません。実装が短くて済む範囲において使い倒すのが醍醐味に思えます。
  • 何か面白い見た目のグラフが作れるだろうと思っていたのですが、素人故かそこまでパンチのきいた例が作れませんでした。他のデータでも試したいですね。

Discussion