学習する天然ニューラルネット

主に機械学習に関する覚書や情報の整理。競プロ水色→Kaggle Master→?

【Streamlitよりいいかも?】機械学習系のデモアプリ作成に最適!Gradio解説

追記 : 動画になりました。

はじめに

機械学習系のデモアプリを作成することがしばしばありStreamlitを使用していたが、パラメーターなどをいじるたびに処理が最初から走るなどといった挙動に悩まされていた。 同僚がGradioというのを使っていたのでサーベイがてらメモしていたらブログが出来上がってしまった。

本ブログでは、GradioのQuickstart以上の内容に踏み込み、実際に実装するときに検索するだろうことをまとめた。 これを読めばGradio中級者と自称できるぐらいにはなるのではないかと思う。

対象読者は以下を想定している。

  • Pythonの基本的な文法(代入や関数宣言、コンテクストマネージャー(with句)など)を知っている方。
  • GradioのQuickstart(チュートリアル)以上に詳しくなり、実際にでもアプリを作るときに発生しがちな疑問点を事前に解消しておきたい方。
  • Streamlitを使ったことのある方だとより楽しめる内容である。

Streamlit vs Gradio

Streamlit使用者に向けて、StreamlitとGradioを比較した所感を共有する。Streamlitを使ったことのない方は読み飛ばしていただいても構わない。

まずStreamlitの良い点を上げると以下の点である。

Streamlit Gradio
文献の多さ 比較的多い 比較的少ない
デザイン 美しい (主観) デフォルトのデザインがダサい (主観)
UIコンポーネント 豊富 機械学習に最適化されたUIコンポーネント

次にGradioの良い点は以下の点だと感じた。

Streamlit Gradio
起動の簡易性 処理をScript化してから専用のコマンドを実行する必要あり 非常に簡単で早い。Scriptにする必要がなく、Jupyter Notebookからも起動可能
コードの可読性 気をつけないとUIを記述するコードと処理を記述するコードが複雑に絡み合ってしまう UIを記述するコードと処理を記述するコードが絡み合わない設計になっており、実装の可読性が高い
共有の容易性 Streamlit sharingが一応あるが… 引数一つで、他人への共有が非常に簡単
重い操作の取扱い 向いていない。ユーザーが操作するたびに処理全体が最初から再実行。 向いている。ボタンを押して実行タイミングを指定することが可能。
データのアップロード制限 50MB (無制限?)

その他の観点では以下のようにまとめられるだろう。

Streamlit Gradio
セキュリティ問題 しばしば脆弱性が発見されている しばしば脆弱性が発見されている
ビジュアライゼーションライブラリのサポート 複数の対話型ビジュアライゼーションライブラリをサポート 複数の対話型ビジュアライゼーションライブラリをサポート
特化しているエリア 対話型のダッシュボード 機械学習モデルのUIを構築
最適な使用シーン データの探索や分析に重点を置いた対話型のダッシュボードを作成 機械学習モデルの結果を視覚化し、それを他のユーザーと共有

Gradioの設計思想

Quick Startの一番最初の例で設計思想がわかる。

def greet(name: str) -> str:
    return "Hello " + name + "!"

demo = gr.Interface(fn=greet,         # ← 関数(処理)をラップする思想
                    inputs="text",    # ← 関数の入力
                    outputs="text")   # ← 関数の出力

このように、関数、関数の入力、関数の出力、の3情報をgr.Interface に受け渡すと、それに応じた Interfaceを提供してくれる。

Interfaceには送信ボタンがあり、このボタンが押されるまで処理は実行されない。

この設計の良いところは、Streamlitの弱点であった「ユーザーが操作するたびに、処理が最初から実行し直される」ことを回避できる点である。

では、Streamlitのような柔軟性が失われたのかというとそうでもなくて、Blocksという書き方ではStreamlitと似たような記述が可能である。Streamlitと異なる点は、ボタンを押すときの処理を記述する必要がある点である。下記のコードの最後の3行に注目してほしい。

with gr.Blocks() as demo:
    name = gr.Textbox(label="Name")
    greet_btn = gr.Button("Greet")
    output = gr.Textbox(label="Output Box")
    greet_btn.click(fn=greet,       # ボタンが押されたときに実行される関数(処理)
                    inputs=name,    # 関数の入力
                    outputs=output) # 関数の出力

ここでも、関数、関数の入力、関数の出力、を渡すことでボタンがクリックされたときに、関数が実行されるようになっている。このように書くことで、「ユーザーが操作するたびに、処理が最初から実行し直される」ことを回避している。

Interface

gr.Blocks で複雑な事ができるが、機械学習系のデモの9割はgr.Interface で実現できると思う。

Inferfaceだけでも以下の事項に対応している。

以上について(時には脱線しつつ)解説していこう。

入出力に応じたUI

先程の例で見たが、基本は fn, inputs, outputsの3つの引数を指定するだけだ。

def greet(name: str) -> str:
    return "Hello " + name + "!"

demo = gr.Interface(fn=greet,         # ← 関数(処理)をラップする思想
                    inputs="text",    # ← 関数の入力
                    outputs="text")   # ← 関数の出力

入力や出力が複数ある場合はリスト形式で書く。例えばこう書くと、

def greet(name1: str, name2: str) -> str:
    ret1 = "Hello " + name1 + "and" + name2 + "!"
    ret2 = {"name1": name1, "name2": name2}
    return ret1, ret2

demo = gr.Interface(
    fn = greet,
    inputs = ["text", "text"],
    outputs = ["text", "json"]
)
demo.launch()

こうなる。

ところで、"text""json" と指定しているが、何を指定できるのか気にならないだろうか?

こいつらは Interface String Shortcut といって詳しくは次の項目で見ていく。

Interface String Shortcut

inputとかoutputとかで”text”とか指定するが、何が指定できて何が出てくるのかをまとめる。

ここのドキュメント で「Interface String Shortcut」と検索しても引っかかるんだけど、一覧にはなってないのでここでは一覧形式にした。代表的なものに絞ったため、もし目当てのものが見つからなければ、公式ドキュメントも見てみよう。

Class Interface String Shortcut
gradio.Button "button"
gradio.Textbox "textbox"
gradio.Image "image"
gradio.Number "number"
gradio.Slider "slider"
gradio.Dropdown "dropdown"
gradio.Checkbox "checkbox"
gradio.Radio "radio"
gradio.Code "code"
gradio.File "file"
gradio.Video "video"
gradio.CheckboxGroup "checkboxgroup"

入出力で受け渡(される|す)変数の型については公式ドキュメントを参考にしてほしい。

入力に関してはGradioで確認できるようなコードを用意した。

# 引数のtypeを見る関数
def inspect_type(*args):
    ret = []
    for i, arg in enumerate(args, start=1):
        ret.append(f"{i}番目の引数の型は"+str(type(arg)))
    return "\n".join(ret)

demo = gr.Interface(
    fn = inspect_type,
    inputs = ["text", "checkbox", "slider", "image", "file"],
    outputs = "text"
)
demo.launch()

“text”や”slider”などは型の想定がし易いが”image”や"file”はどんな型で関数に受け渡されるのかはなかなか想定しづらいところではないだろうか。”image”や"file”に関してはこのあともうちょっと詳しく挙動を解説する予定である。

入力データのサンプルのセット

examples という引数をセットしておくことで、入力データのサンプルを作ることもできる。

同時にcache_examples = True と指定しておくことで、サンプルの入力に対しては出力をキャッシュしておき即座に返すような挙動となる。

例えば、

def greet(name1: str, name2: str) -> str:
    ret1 = "Hello " + name1 + "and" + name2 + "!"
    ret2 = {"name1": name1, "name2": name2}
    return ret1, ret2

demo = gr.Interface(
    fn = greet,
    inputs = ["text", "text"],
    outputs = ["text", "json"],
    examples = [["ずんだもん", "つくよみちゃん"], 
                ["ずんだもん", "四国めたん"]],
    cache_examples=True
)
demo.launch()

と書けば、以下のようにExamples欄が出るようになる。

入力が複数の場合はlist[list[any]]といった形式で指定するのがポイント。

ドキュメンテーション

title, description, articleという引数があり、いい感じに説明をつけることができる。もはや説明不要だと思うので、コードと見た目のサンプルだけ紹介する。

例えば、

title : str = "ユーザーインターフェースによる名前の挨拶"
description : str = "このデモンストレーションは、Gradio(ユーザーインターフェースのライブラリ)を使用して名前を入力として受け取り、その名前に挨拶する機能を提供します。ユーザーはテキストフィールドに名前を入力します。出力は挨拶の文字列です。"
article : str='''このデモンストレーションは、ユーザーインターフェースを作成するためのPythonのライブラリであるGradioを使用しています。コードは、greetという関数を定義し、この関数は2つの文字列(名前)を入力として受け取り、挨拶として結合した文字列と入力名前の辞書を返します。Gradioインターフェースは、この関数を基に設計されています。

入力は2つのテキストフィールドで、各フィールドは1つの名前を受け入れます。出力は2つあり、一つは挨拶の文字列(テキスト形式)、もう一つは入力名前の辞書(JSON形式)です。これらの出力は、ユーザーが入力した名前に基づいて生成されます。

このインターフェースの例として、"ずんだもん"と"つくよみちゃん"または"ずんだもん"と"四国めたん"を入力として使用します。各例で、ユーザーが名前を入力すると、インターフェースは挨拶と名前の辞書をそれぞれテキストとJSON形式で生成し、表示します。

このインターフェースは、異なる名前を受け入れ、それに対して適切な挨拶を生成する能力を持つという点で一貫性を保つために、Gradioのcache_examples=Trueを使用しています。これにより、インターフェースは同じ入力に対して同じ出力を返すことが保証されます。
'''

def greet(name1: str, name2: str) -> str:
    ret1 = "Hello " + name1 + "and" + name2 + "!"
    ret2 = {"name1": name1, "name2": name2}
    return ret1, ret2

demo = gr.Interface(
    fn = greet,
    inputs = ["text", "text"],
    outputs = ["text", "json"],
    examples = [["ずんだもん", "つくよみちゃん"], 
                ["ずんだもん", "四国めたん"]],
    cache_examples=True,
    title=title,
    description=description,
    article=article

)
demo.launch()

と書けば、以下のように表示される。

titleとdescriptionは上方、articleは下側に表示されるのがポイント。

テーマの変更

Gradioのデフォルトの見た目は正直ダサい。CSSを書き換えて調整することもできるが、自分でやるのは流石にめんどくさい。

Gradioでは事前定義されたものや他人のUploadしたテーマをそのまま使うことができる。

他人のテーマはテーマギャラリーで一覧として見ることができる。

個人的には'JohnSmith9982/small_and_pretty''gradio/soft' がなかなか好みのデザインである。

テーマを適応するにはthema=’gradio/soft’ といったように引数にセットするだけである。

例えば、

def greet(name1: str, name2: str) -> str:
    ret1 = "Hello " + name1 + "and" + name2 + "!"
    ret2 = {"name1": name1, "name2": name2}
    return ret1, ret2

demo = gr.Interface(
    fn = greet,
    inputs = ["text", "text"],
    outputs = ["text", "json"],
    theme='gradio/soft'
)
demo.launch()

と書けば、以下のように表示される。

ちなみにgradio/softgr.themes.Soft() でも指定できる。

タイムアウトへの対処

Streamlitで重い処理をする際にタイムアウトによく悩まされていた。Gradioでもこれに簡単に対処する方法も提供されている。

たった一行 demo.queue()を追加するだけである。

追加する位置としてはlaunch()の直前である。

demo = gr.Interface(
    fn = greet,
    inputs = ["text", "text"],
    outputs = ["text", "json"],
    theme='gradio/soft'
)
demo.queue()  # ← ここに一行追加した。
demo.launch()

さらに細かいパラメーターやパフォーマンス改善のテクニックは Setting Up a Demo for Maximum Performance に書かれているので、参考にしてほしい。

以上でユースケースの9割はカバーするはずだ。

もっと複雑にレイアウトや実行のワークフローを組みたいときはInterfaceではなくBlocksを使うが、今回は解説しない。

中級者への第一歩、デモを作る際に知っておきたい処理

ここからはQuickstartでは省かれがちだがよく使うTipsを解説していく。

Gradioが担当する前処理について

“file”や“image”、”audio”などのinputsは関数にどうやって入力されるかを指定できる。binaryやnumpyとして関数に受け渡すのか、ファイルとして保存しておきpathの文字列を関数に受け渡すのかを調節可能である。この前処理はGradioがやってくれる。

例えば、画像をファイルへpathとして受け取り、pathを指定することで画像を表示するには、以下のように書けば良い。

def info(arg: str) -> str:
    ret2 = {"type": str(type(arg)), "filepath": arg}
    return arg, ret2

demo = gr.Interface(
    fn = info,
    inputs = gr.Image(type="filepath"),
    outputs = [gr.Image(), "json"],
)
demo.launch()

お気づきのように前処理を変えるにはInterface String Shortcutではなく、クラスに引数を受け渡す必要がある。typeという引数に”filepath” を受け渡すことで関数への入力がpathになる。

出力時にはgr.Image() と何も引数を指定していない。関数の戻り値の型からよしなに表示してくれるからだ。

各クラスにどのような引数があるかは使うときに公式ドキュメントで確認すればよいだろう。

プログレスバー

実はもともとtqdmを使用していたコードをそのまま流用してプログレスバーをUIに仕込むことができるのですごく楽。追加するのはたった2行

  • 関数の引数に progress=gr.Progress(track_tqdm=True) を仕込むだけ (引数名は何でも良い)
  • demo.queue() でqueueを有効にしておく

具体的なコードとしては、

import time
from tqdm import tqdm

def slowly_reverse(word, progress=gr.Progress(track_tqdm=True)): # 元々あるtqdmごとトラッキングしてくれる
    new_string = ""
    for letter in tqdm(word, desc="Reversing"):
        time.sleep(0.01)
        new_string = letter + new_string
    return new_string

demo = gr.Interface(
    fn=slowly_reverse,
    inputs="text",
    outputs="text"
    )
demo.queue()   # プログレスバーを表示するにはこの行が必要
demo.launch()

と書くと、以下のようにプログレスバーを表示できるようになる。

ただしネストしたtqdmの表示はイマイチだったので、あくまでプログレスバーは1つだけにしておこう。プログレスバーが多段になるような表示だったら良かったかもしれない。

もろもろの出力結果を保存するには?

“file”を用いる。関数の出力はファイルへのパスのリストにする。

例としてuploadした画像をpng, jpeg, webp形式で保存して、それらをダウンロードするコードを書いてみよう。

import os
from PIL.Image import Image as PILImage
from PIL import Image

def convert_format(img: PILImage) -> str:
    '''imgをpng, jpeg, webpで保存した後、
    それらをzipファイルにして、zipファイルへのパスを返す関数'''
    # 変数名の設定
    tmp_dir = "./tmp" 
    img_filename= "image"
    formats = ['png', 'jpeg', 'webp']
    path_list= [] 

    # 一時ディレクトリの設定
    if not os.path.exists(tmp_dir):
        os.makedirs(tmp_dir)

    # 各形式で画像を保存する
    for fmt in formats:
        path_list.append(os.path.join(tmp_dir, f'{img_filename}.{fmt}')) #追加
        img.save(os.path.join(tmp_dir, f'{img_filename}.{fmt}'), fmt)
    
    return path_list

demo = gr.Interface(
    fn =  convert_format,
    inputs = gr.Image(type="pil"),
    outputs = "file",
)
demo.queue()
demo.launch()

上記のコードで実行すると、以下のような結果が返ってくる。

これではファイルが増えたときにダウンロードボタンを押すのが面倒である。

そこで、更に各種ファイルまとめてzipファイルにして、そのzipファイルをダウンロードするコードを追加しよう。

from zipfile import ZipFile
import os
from PIL.Image import Image as PILImage
from PIL import Image

def convert_format(img: PILImage) -> tuple[str, str]:
    '''imgをpng, jpeg, webpで保存した後、
    それらをzipファイルにして、zipファイルへのパスを返す関数'''
    # 変数名の設定
    tmp_dir = "./tmp" 
    img_filename= "image"
    formats = ['png', 'jpeg', 'webp']
    zip_file_name = "./image_formats.zip"
    path_list= []

    # 一時ディレクトリの設定
    if not os.path.exists(tmp_dir):
        os.makedirs(tmp_dir)

    # 各形式で画像を保存する
    for fmt in formats:
        path_list.append(os.path.join(tmp_dir, f'{img_filename}.{fmt}'))
        img.save(os.path.join(tmp_dir, f'{img_filename}.{fmt}'), fmt)
    
    # 画像を保存したディレクトリをzipファイルにする
    with ZipFile(zip_file_name, 'w') as zipf:
        for root, _, files in os.walk(tmp_dir):
            for file in files:
                zipf.write(os.path.join(root, file))

    return zip_file_name, path_list

demo = gr.Interface(
    fn =  convert_format,
    inputs = gr.Image(type="pil"),
    outputs = ["file", "file"],
)
demo.queue()
demo.launch()

上記のコードで実行すると、1つのzipファイルとしてダウンロードする事ができるようになる。

これでファイル数が増えても一括でダウンロードができる。

認証認可(というか認可)

簡単に設定するならdemo.launch(auth=("admin", "pass1234")) と引数に設定可能。

複雑に設定するならdemo.launch(auth=auth_function) と関数を受け渡す。

関数は以下のような形式になっている必要がある。

def auth_function(username: str, password: str):
    # 認証のコードを書く
    return # 戻り値がTrueであれば認証される

authをセットするとJupyter上では表示されなくなるので、ブラウザでアクセスする。

するとLogin画面に案内される。ここでは適当に入力しても入れないためある程度のセキュリティは確保できるだろう。

さらに詳細はこちらで

その他、解説しないが需要の有りそうなもの

ここでは、一問一答形式で、行いこととそれに対する回答を記述しているページをまとめていこうと思う。

まとめ

Gradioは、Pythonで書かれたWebサービスで、機械学習系のデモアプリを簡単に作成できるツールだ。

処理、入力、出力をうまく分離して取り扱っており、このおかげでStreamlitでの不満点を解消している。

プログレスバーの表示やファイルのアップロード、認証認可など、様々な機能があり、UIコンポーネントも豊富であるため、非常に使いやすい。

また、Jupyter Notebookからも起動可能だったり、共有の容易性も高いので取り回しがし易いように感じた。

最後に触っていてちょっと感じた不満点は処理の中断ができない点だ。重い処理を途中で止められるようになったらもっと開発の高速化が見込めるだろう。