nikkie-ftnextの日記

イベントレポートや読書メモを発信

魔法みたいな機能満載のStreamlit。その中でもとびっきりの魔法、キャッシュについて

はじめに

この記事はデータ可視化 Advent Calendar 2020 21日目の記事です。

頑張れば、何かがあるって、信じてる。nikkieです。
先日参加した #pyhack でデータ可視化アドベントカレンダーに空きがあることを知り1、最近推しているStreamlit2について締切駆動でアウトプットしてみることにしました。

目次

Streamlitとは

サーバサイドのロジックからUIまでカバーした、ラピッドプロトタイピングツールです。
データ可視化アプリや機械学習モデルを使ったアプリが簡単に作れると注目を集めている印象です。

nikkie的にポイントが高いのは、Streamlitを使えば、Pythonを書くだけでブラウザを通して操作できるアプリができるという点。
チュートリアルを見て「こんなに簡単に作れるのか!」と舌を巻きました。

import pandas as pd

import streamlit as st

st.title("My first app")
df = pd.DataFrame(
    {"first column": [1, 2, 3, 4], "second column": [10, 20, 30, 40]}
)
df

f:id:nikkie-ftnext:20201221222702p:plain

Galleryを覗けば、いろいろなアプリが見つかります。

上記のチュートリアルのTIPで@st.cacheというデコレータが紹介されているのですが、このキャッシュ機能も、Streamlitのヤバい✨機能の1つでした!
この記事では@st.cacheについてドキュメントに沿って見ていきます。

お断り:データ可視化アドベントカレンダーではありますが・・

今回の@st.cacheのアウトプットのサンプルアプリは、データ可視化アプリではありません。
ですが、この記事で登場する@st.cacheの使い方は、Streamlitを使ったどんなアプリにも適用できると考えています。

Streamlitはデータ可視化アプリや機械学習アプリに使われることが多いと思いますが、私自身は「Pythonスクリプトを他の人に使ってもらうように変換する」のにも使えるツールととらえています。
今回は画像リサイズスクリプトを元にした便利ツールアプリを例に@st.cacheを見ていきます。

ソースコードはこちら(のディレクトリ)

開発環境

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G4032
$ python -V
Python 3.8.6
$ pip install streamlit==0.72.0 Pillow==8.0.1 pandas==1.1.4

@st.cacheとは

ドキュメントの「Improve app performance」というページに、使い方が詳しく書かれています。
Streamlitを使うなら一読して損はないと思います。

※誤読と思われる箇所に気づきましたら、コメントやTwitterでお知らせください。大変ありがたいです

かいつまんで言うと、StreamlitのキャッシュはインメモリのKVS(key-value store)です3
@st.cacheでデコレートされた関数には、ざっくり言うと、入力された引数のハッシュをキーとし4、関数の実行結果をバリューとするキャッシュが用意されます。

関数を呼び出したとき、引数(のハッシュ)がキャッシュにヒットすれば、キャッシュが使われます(関数は実行されません!)。
キャッシュミスがあった場合は関数が実行され、引数(のハッシュ)と返り値がキャッシュに登録されます。

例(ドキュメントより)

ドキュメントにあるのが以下の例(Example 1):

import time

import streamlit as st


@st.cache
def expensive_computation(a, b):
    time.sleep(2)
    return a * b


a = 2
b = 21
res = expensive_computation(a, b)

st.write("Result:", res)
  • @st.cacheが付いていないとき、ブラウザでページをリロードするたびにexpensive_computationが実行されて、表示までに毎回2秒かかります
  • @st.cacheを付けると、初回表示では2秒かかりますが、2回目以降のリロードはキャッシュが使われるので2秒待つことはなくなります

この状態から

  • expensive_computationに渡す引数bの値を変更(Example 2)
  • expensive_computationの中の実装を変更(Example 3)
  • expensive_computationから別の関数を呼ぶように変更(Example 4)
  • さらに呼び出し先の関数の実装も変更(Example 4)

とすると、いずれも(ハッシュ(すなわちKVSのキー)が変わるので)キャッシュミスが起き、関数が実行されるとドキュメントは説明しています。

ヤバい!と思ったのはExample 5。
ユーザが画面のスライダで選択した値をbとするのですが、

  1. あるタブでスライダで9を選ぶ(→キャッシュミスにより2秒待つ)
  2. 別のタブでも表示し、スライダで9を選ぶ→1で登録されたキャッシュがヒットするので、2秒待たずに画面が表示される!!

Streamlitのキャッシュはグローバルにしており、あるユーザが作ったキャッシュが他のユーザのパフォーマンス向上に寄与できる設計とのことです。

This happens because the Streamlit cache is global to all users. So everyone contributes to everyone else’s performance.

キャッシュは参照なので、ValueがMutable objectのとき、Valueを書き換えると、キャッシュのValueも書き換わります(Example 6。CachedObjectMutationWarningが出ました)。

@st.cache適用 Before / After

Before

画像5リサイズアプリでは当初以下のような実装をしていました。

uploaded_files = st.file_uploader(
    "Specify image(s) to be resized in your computer",
    type=["png", "jpg", "jpeg"],
    accept_multiple_files=True,
)
max_length = st.slider("Specify max length", 100, 500, 300, 50)
if uploaded_files:
    random_id = uuid.uuid4()
    shrinked_dir_path = Path(f"images/{random_id}")
    shrinked_dir_path.mkdir(exist_ok=True)

    for uploaded_file in uploaded_files:
        resized_image_path = shrinked_dir_path / uploaded_file.name
        has_resized = resize_image(  # 画像を縮小する処理の呼び出し
            uploaded_file, resized_image_path, max_length
        )
        if has_resized:
            st.image(str(resized_image_path))

f:id:nikkie-ftnext:20201221223529p:plain

この実装で気になった点は、選択した画像を1つ削除したり、スライダーのサイズ指定をいじったりすると、すでに縮小済みの画像があったとしても毎回縮小した画像が作られる点です。
ファイルが重複してできていって、リソースを無駄にしている感覚がありました。

After

@st.cacheの存在を知り、画像を縮小する処理を関数にしてからデコレートしました。

  • 入力:画像ファイル1枚、縮小サイズ
  • 処理:画像を縮小して保存する
  • 出力:縮小した画像のパス(表示するのに使う)
@st.cache
def resize_uploaded_image(image_file_object, max_length):
    random_id = uuid.uuid4()
    shrinked_dir_path = Path(f"images/{random_id}")
    shrinked_dir_path.mkdir(exist_ok=True)

    resized_image_path = shrinked_dir_path / image_file_object.name
    has_resized = resize_image(
        image_file_object, resized_image_path, max_length
    )
    if has_resized:
        return resized_image_path


uploaded_files = st.file_uploader(
    "Specify image(s) to be resized in your computer",
    type=["png", "jpg", "jpeg"],
    accept_multiple_files=True,
)
max_length = st.slider("Specify max length", 100, 500, 300, 50)
if uploaded_files:
    for uploaded_file in uploaded_files:
        resized_image_path = resize_uploaded_image(uploaded_file, max_length)
        if resized_image_path:
            st.image(str(resized_image_path))

画像ファイルと縮小サイズの組(のハッシュ)がキャッシュにヒットしたら、すでに縮小済みの画像があるということなので、それを画面に表示します。
キャッシュを使うように処理を書き換えたことで、入力を変更するたびに縮小されなくなりました!

なお、st.file_uploaderの返り値はファイル名以外にファイルのサイズ(や内容?)も見ているようで、全く異なる画像をキャッシュにあるのと同名にしてアップロードしても、キャッシュがヒットしませんでした。
すごいですね!

終わりに

Streamlitの@st.cache、味方につけるためには関数の設計をカスタマイズする必要がありますが、パフォーマンスにもたらす効果は覿面と感じています。
データ可視化にとどまらず、Pythonスクリプトをアプリにするツールとして可能性を感じているStreamlit、2021年も使い倒していく予定です。

ここまで「魔法」が揃っているStreamlit、魔法をどう実現しているか気になってきますよね。
依存関係で入ってくるtornadoがサーバのようですし、UIはReact6と聞いています。
アドベントカレンダーに手を挙げた時点ではStreamlitの中を見るというのもネタの候補にありました。
今回は間に合わず、みんなのPython勉強会の登壇準備中に知ったネタを使ったのですが、時間のある年末年始に中を見てみようと思っています。

Happy prototyping with Streamlit!


  1. この記事は12月のみんなのPython勉強会発表のプチ続編でもあります。「Streamlitヤバい!」と連呼してましたね(語彙力😅)

  2. Think of the cache as an in-memory key-value store, (冒頭(=Example 1より前))

  3. ラピッドプロトタイピングなので、関数の中身が変更されることもあるでしょう。そんな状況でも対応できるように、引数のハッシュ以外に関数のコード(The body of the function)などもキーにしています。上記ドキュメントでは「Advanced caching」の節に詳しいです

  4. 画像はPyCon JP 2019のアルバムより

  5. ref: GKEでStreamlitをホスティングして社内用アプリを作った話 - エムスリーテックブログ