Lean Baseball

No Engineering, No Baseball.

Jupyterで計算・分析した何かをアプリっぽくプレゼンするまで - 33分4秒ではじめるStreamlit「雑」入門

サムネイルで出してる内容がそのままこのエントリーのテーマです.

Pythonアドベントカレンダー2020の9日目です.

JX通信社のシニアエンジニアで, 趣味で野球*1とヘルスケア*2なデータを分析してるマンの@shinyorkeと申します.

ちょっとしたデータサイエンスでもガチのR&Dでも何でもいいのですが,

プレゼンするためのスライド作るとか, デモのアプリを作るのって相当ダルくないっすか?

いやまあ大事な仕事なので不可避かつちゃんとやろうぜっていうのは事実*3なのですが, 手を抜くところは手を抜くべきだなというのが持論としてありますし, 「怠惰・傲慢・短気」というプログラマーの三大美徳からするとプレゼンの準備は最も「怠惰」であるべきとまで僕は思っています.

そんな中, 今年はStreamlitという, 「データを見せるアプリを雑に作ろうぜ」っていうライブラリがめっちゃ流行りました(っていう印象です).

github.com

The fastest way to build and share data apps.

(データなアプリを構築・共有するのに最速な道やで)

すごくいいこと言ってますね.

このエントリーでは,

f:id:shinyorke:20201209101342j:plain

これに答えるため,

  • ひとまずJupyterでやったことをStreamlitで移植してみる
  • 利用する人視点でのJupyterとStreamlitの違い

的な話を紹介します.

なお, 33分4秒はあくまでも目安です*4

TL;DR

  • StreamlitはJupyterでやったことをアプリにする,最も賢い手段です.
  • 分析とかで試行錯誤した結果をまとめるのにStreamlitはすごくいいかも知れない.
  • 分析タスク(特に複数人)ならJupyter, 勝負なプレゼンでStreamlitという使い分けが良いんじゃないでしょうか.

スタメン

今回のお題とサンプル

今回は野球の統計学「セイバーメトリクス」の基本的な考え方である「得点期待値」をpandasで計算して可視化サンプルを準備しました.

github.com

Jupyter, Streamlit両方のサンプルです, 手元で動かす・壊しながらいじると理解が早いかもです.

得点期待値をすごく雑に説明すると, 「ノーアウト一塁からバントに成功すると点につながるのか?」的なクエスチョンを状況(ランナー×アウトカウント)ごとの平均得点で表す手法で, 上記のクエスチョンの場合だと,

  • ノーアウト一塁の得点期待値は「0.86」
  • バントに成功してワンアウト二塁だと「0.67」
  • バントする前と成功した後で得点期待値が下がっているのでバントする意味は(平均得点評価だと)意味を見出すのが難しい
  • ちなみに失敗(ワンアウト一塁)だと「0.27」, バスターして上手くシングルヒット(ノーアウト一塁二塁)だと「1.44」で後者はほぼ点につながる

的な評価ができます.

これを早見できるヒートマップを描くまでがサンプルのゴールとなります.

f:id:shinyorke:20201209102638j:plain
得点期待値ヒートマップ

ちなみに今回の得点期待値アルゴリズムは「Rによるセイバーメトリクス入門」が下敷きとなっております&RでやったやつをPythonで再構築みたいなノリです*5.

Rによるセイバーメトリクス入門

Rによるセイバーメトリクス入門

Jupyterで作ってStreamlitに移す

というわけで, サンプルを元にエイッと解説します.

Jupyterで書く・動かす

完全版のコードはこちらとなります.

大まかな流れとしては,

  • データを読み込む・定義とかを準備する
  • ひたすら計算して中間結果を求める
  • 中間結果から本丸である得点期待値(RUNS ROI)を出す

という流れになります.

今回はメジャーリーグの2016年データ(オープンデータ)を使っています.

# CSVからDataframeを作る(headerとbody別れてるのでややこしいことになってます)
with open('./data/fields.csv', 'r') as f_header:
    headers = [row.get('Header') for row in csv.DictReader(f_header)]
with open('./data/all2016.csv', 'r') as f_body:
    values = [dict(zip(headers, row)) for row in csv.reader(f_body)]
df = pd.DataFrame(values, columns=DF_COLUMNS.keys()).astype(DF_COLUMNS)
df.head()

また, 中間の計算はひたすらこんなコードが続きます.

# 得点イベント時の総得点
def _run_scored(dests: str) -> int:
    """
    Calc Run Scored
    :param dests: Dests Strings
    :return: Run Scored
    """
    """
    Formatはこちら.DESTはすべて長さが1
    {BAT_DEST_ID}{RUN1_DEST_ID}{RUN2_DEST_ID}{RUN3_DEST_ID}
    """
    runs_scored = 0
    for dest in dests:
        if int(dest) > 3:
            runs_scored += 1
    return runs_scored
df['DESTS'] = df['BAT_DEST_ID'].astype(str) + df['RUN1_DEST_ID'].astype(str) + \
              df['RUN2_DEST_ID'].astype(str) + df['RUN3_DEST_ID'].astype(str)
df['RUNS_SCORED'] = df['DESTS'].map(_run_scored)

# 中略

# プレー後の状況を表すカラム

def _new_base(dests: str) -> str:
    """
    Create New Base State
    :param dests: Dests
    :return: New Base
    """
    """
    Formatはこちら.DESTはすべて長さが1
    {BAT_DEST_ID}{RUN1_DEST_ID}{RUN2_DEST_ID}{RUN3_DEST_ID}_{func(OUTS_CT + EVENT_OUTS_CT)}
    """
    bat, run1, run2, run3 = int(dests[:1]), int(dests[1:2]), int(dests[2:3]), int(dests[3:4])
    nrunner1, nrunner2, nrunner3 = '0', '0', '0'
    if run1 == 1 or bat == 1:
        nrunner1 = '1'
    if run1 == 2 or run2 == 2 or bat == 2:
        nrunner2 = '1'
    if run1 == 3 or run2 == 3 or run3 == 3 or bat == 3:
        nrunner3 = '1'
    return f"{nrunner1}{nrunner2}{nrunner3}"

def _new_state(dest_outs: str) -> str:
    """
    Create New State
    :param dest_outs: Dests & Outs
    :return: New State
    """
    """
    Formatはこちら.DESTはすべて長さが1
    {BAT_DEST_ID}{RUN1_DEST_ID}{RUN2_DEST_ID}{RUN3_DEST_ID}_{func(OUTS_CT + EVENT_OUTS_CT)}
    """
    list_dest_outs = dest_outs.split('_')
    return f"{_new_base(list_dest_outs[0])} {list_dest_outs[1]}"


df['OUTS_CNT'] = df['OUTS_CT'] + df['EVENT_OUTS_CT']
df['DESTS_OUTS'] = df['DESTS'] + '_' + df['OUTS_CNT'].astype(str)
df['NEW_BASE'] = df['DESTS'].map(_new_base)
df['NEW_STATE'] = df['DESTS_OUTS'].map(_new_state)

ひとまず雰囲気共有ってことで.

このnotebookが最後まで動くとヒートマップが出てきます(再掲)

f:id:shinyorke:20201209102638j:plain
得点期待値ヒートマップ

Streamlitに移す・動かす

完全版のコードはこちらとなります.

これはコード全容見てもらったほうが理解が早いかも.

得点期待値サンプル - Streamlit

$ streamlit run sample_run_expectancy.py

これで動かすとこんな感じでWebアプリとして動いてくれます.

f:id:shinyorke:20201209110107j:plain
コードも出ていないスッキリした感じに!

中間データの確認もできるのでいい感じです(かつこれは隠すこともできます).

まず, 大声でいいたいポイントとして,

計算と可視化のコードは99%コピペです!

notebookのセル番号とかいかにも「まだやってます」感を排除するのにコピペで済むのは素晴らしいですね.

また, notebook上でmarkdownとして記載した部分は,

st.write('# 得点期待値をPythonで算出するサンプル')

st.write('## データ読み込みと下処理')

みたいな感じでやれます.

また今回は登場しませんでしたが, 簡易的なフォームも作れます.

前回のエントリー(柳田悠岐の生涯成績予測)で出たこれ.

f:id:shinyorke:20201123163409j:plain
サイドバーの入力フォームに着目

こちらはたったこれしか書いていません.

import streamlit as st

st.sidebar.markdown(
    """
    # プロ野球成績予測マン
    名前を入れてね
    """
)

# 名前
name = st.sidebar.text_input('お名前', '柳田悠岐')
# 結果のチューニング
level = st.sidebar.number_input('劣化レベル', value=0.75, min_value=0.25, max_value=1.0, step=0.05)
st.sidebar.write("※全盛期: 1.0, 劣化: 0.25")

デモとして使うには十分すぎではないでしょうか, 1ファイルで終わりますし.

JupyterとStreamlitの使い分け

使った感想&知ってることベースでの比較です.

項目 Jupyter Streamlit
分析・試行錯誤なタスク 超強い できなくはないけど冗長な気が
デモ・プレゼンの作りやすさ ウィジェットやRISEでいい感じにできるがそこそこ知らないと難しい Hello world程度のアプリ作った経験あるマンなら学習コストは低い
コード管理 Colabとかで動く状態での共有がベスト, Git向きではない .pyファイルなのでGit向き.
チーム作業 Colabとかで動く状態だとやりやすい 基本は一人用かな...
ここぞの強み Rとか多言語でイケる deployできたり, ホスティングもあったりするので共有最強説!?

個人的な印象としてはどっちも使えると幸せなのかなって思いました.

結び

今年のはじめはStreamlitをあまり理解できてなかったのですが,

@takapy0210さんの発表を見て真似してスタートし,

www.m3tech.blog

note.com

などの素晴らしい事例を元に色々とやってハマりました.

まだ仕事の方では本格的に活かしてない(実験的に使っている程度)なのでその後, 仕事でもいい感じに使えたので,更に使いこなせたらなって思いました.

後日談(2021/1/25追記)

実業務で活かすポイントなどを自社のテックブログで書きました.

tech.jxpress.net

*1:なおちょっと前は仕事にしてました.

*2:いわゆるライフログ的なものから色々やるためにコツコツ個人開発しています.

*3:個人的にはあんまり好きじゃないアレですが, コンサルにはパワポ強いチームいるとかはその背景ですよね. 大事なんですよ, うん.

*4:なんでや阪神関係ないやろ

*5:もうちょい詳しい説明はWikipediaや私の過去エントリーを御覧ください. 得点期待値の細かい話や是非については本題から外れるのでここでは解説しません.