覚えておきたいStreamlitでの変数取り扱い

## 時間のない方向け
- 入力ウィジェットの表示切り替えや、呼び出し前の計算を行うと変数が意図せぬ挙動をする
- ウィジェットはkeyとvalueを持ち、入力時にはウィジェットが持つvalueだけが更新される
- 変数保存・読み込み用の関数を作り、ウィジェットのvalueを保存・読み込みすると確実

## 概要
StreamlitではPythonを用いて簡単にGUIを作成でき、リアルタイムでデータの可視化やインタラクティブなアプリケーションを構築することができます。  
その際、入力ウィジェットを通じてユーザー側から入力された変数を受け取ることができます。  
便利な一方、**適切な記述法を用いない場合、入力したはずの変数が反映されない・初期化されてしまうといった問題が発生します。**
本記事では、そういった原因と対処法について解説します。

:::note  
動作検証はstreamlit バージョン 1.37.0 で実施しています  
:::

## 目次
- [Streamlitとは](#streamlitとは)
- [入力ウィジェット](#入力ウィジェット)
- [変数管理](#変数管理)
- [変数が更新されない問題](#変数が更新されない問題)
- [ウィジェットの構造](#ウィジェットの構造)
  - [key](#key)
  - [value](#value)
- [valueの取得](#valueの取得)
- [ウィジェットのvalueを取得する際の注意点](#ウィジェットのvalueを取得する際の注意点)
- [解決法](#解決法)
- [まとめ](#まとめ)
- [補足](#補足)
- [Reference](#reference)

## Streamlitとは
Streamlitは、Pythonを用いて手軽にWEBアプリを作成できるフレームワークです。  
フロントエンドの経験がなくても、たった数行のコードで様々なUIの実装を行うことができます。

具体的には、PandasのDataFrameや、MatplotlibやPlotlyなどの描画ライブラリを直接Webアプリ上に表示することが可能です。  
また、ウィジェットを追加することで、ユーザーとインタラクティブなやり取りを行うこともできます。  

さらに、Streamlitはリアルタイムでのデータ更新や、簡単なデプロイメント機能も備えており、データサイエンティストやエンジニアにとって非常に便利なツールです。

参考：[Streamlit: A faster way to build and share data apps](https://streamlit.io/)


## 入力ウィジェット
ユーザーにGUIから任意の値やデータを入力をさせ、計算やデータ取得を行いたいという場合、  
StreamlitではInput widgets関数を呼び出すことで、Webアプリ上に入力ウィジェットを表示させることができます。  

このウィジェットからユーザーは様々な入力を行うことができ、それらをPython上で変数として受け取ることができます。  

下記例のように、多種多様なウィジェットが存在します。
```python:streamlit_sample.py
import streamlit as st

st.title("Streamlit sample")
# 数値入力
st.number_input("input_your_number")  
# チェックボックス
st.checkbox("CHECK") 
# ファイルアップローダー
st.file_uploader("upload_your_file")  
# ボタン
st.button("PUSH")  
```
![streamlit_sample.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3881506/7efaca7b-cdf8-63c0-178a-124f23e67cb7.png)


詳しくは公式のドキュメントなどを参照してください。

参考：[Streamlit: Input widgets](https://docs.streamlit.io/develop/api-reference/widgets)  

## 変数管理
Streamlitではウィジェットから入力が行われる・値が更新される度にPythonスクリプトが再実行されます。  
そのため、通常のPython変数は再実行毎に初期化され、異なる入力間で値を保持することはできません。  
そこで、Streamlitではst.session_steteを用いて実行間で変数を保持します。

```python:st.session_stateを用いた変数の宣言
# 'color'という変数に'Red'という文字列を代入。スクリプトが再実行されても保持されます。
# どちらの記述法でも結果は同じになります。
# 記述法 その1
st.session_state['color'] = 'Red' 
# 記述法 その2
st.session_state.color = 'Rad' 
```


## 変数が更新されない問題
今回は次のようなページを作りたいとします。  
『ユーザーにパラメータを入力させ、入力値を元に様々な計算を行い結果を表示する。』

これを簡略化したサンプルコードが次のようになります。

```python:input_error.py
import streamlit as st

if "is_number_input" not in st.session_state:
    st.session_state["is_number_input"] = False

if not st.session_state["is_number_input"]:
    st.session_state["number"] = st.number_input("input_number", value=0)
    st.session_state["is_number_input"] = True

else:
    st.write(f"Your input number is {st.session_state['number']}")
```

このコードでは以下の処理を行うことを意図したシンプルなコードです。
1. `is_number_input`をFalseで宣言
2. `is_number_input`がFalseであれば数値入力ウィジェットを表示
3. ユーザーがウィジェットを通じて`number`を入力する
4. 入力後は`is_number_input`がTrueになる
5. 入力された数字を"Your input number is `number`"として表示する

![unexpected_return](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3881506/0ae64832-7ed4-79cb-3e7d-2d4cbd3f96b2.gif)


**しかし、このコードを実行すると、どのような入力値であっても結果は常に "Your input number is 0" になります。**  
このように、st.session_stateで変数を取り扱う際には意図しない挙動をする場合があり、注意が必要です。   

上記のコードで意図しない挙動を引き起こすのは次の一文です。
```python
st.session_state["number"] = st.number_input("input_number", value=0)
```
この一文は一見すると『ユーザーから入力された数値が`number`に代入される。』という動作を行っているように見えます。  
しかし、実際には **『keyが"input_number"であるウィジェットが持つvalueを代入する。』** という挙動を行っています。  

この文章だけでは何を言っているのか全く伝わらないと思いますので、ここからkeyとvalueについて詳しく説明していきます。

## ウィジェットの構造
### key
はじめに、各ウィジェットは辞書型のように1つのkeyと1つのvalueを持ちます。  
keyはウィジェットを識別するIDであり、ウィジェット作成時に他のウィジェットと識別できるように個別のkeyが与えられます。  
上記のコードであれば、keyが"input_number"であるst.number_inputウィジェットが作成されています。  
keyは識別子であるため、同一のkeyを持つウィジェットは1つしか存在できません。  
例として、以下のコードはエラーになります。
```python
st.session_state["number1"] = st.number_input("input_number", value=0)
st.session_state["number2"] = st.number_input("input_number", value=0)

# DuplicateWidgetID: There are multiple widgets with the same key='input_number'.
```

keyは引数に指定することで任意の値をつけることも可能です。  
そのため、見た目上同一のウィジェットを作成する際はkeyの指定が必要です。
```python
st.session_state["number1"] = st.number_input("input_number", key="input1")
st.session_state["number2"] = st.number_input("input_number", key="input2")

# OK
```

### value
ウィジェットが持つvalueはユーザーがウィジェットに対して入力した値が反映されます。  
注意すべき点として、 **入力時にウィジェット内のvalueは更新されるものの、変数への代入は行われていないということです。**  
これを踏まえて先ほどのスクリプトの挙動を再度確認してみましょう。

```python:input_error.py
import streamlit as st

if "is_number_input" not in st.session_state:
    st.session_state["is_number_input"] = False

if not st.session_state["is_number_input"]:
    st.session_state["number"] = st.number_input("input_number", value=0)
    st.session_state["is_number_input"] = True

else:
    st.write(f"Your input number is {st.session_state['number']}")
```


1. `is_number_input`をFalseで宣言
2. `is_number_input`がFalseなので、数値入力ウィジェットを表示
3. **"input_number"ウィジェットの初期値であるvalue=0が**`number`**に代入される**
4. `is_number_input`がTrueになる
5. ユーザーがウィジェットを通じて数値を入力する
6. **入力値が"input_number"ウィジェットのvalueに格納される**
7. --スクリプト再実行--
8. `number`=0 なので、"Your input number is 0"と表示される

この流れを見ると分かるように、代入式が読み込まれた時点で、ウィジェットが持つvalueが代入されています。  
そのため、`number`にはウィジェットの初期valueが格納されており、入力値が代入されていません。  
ユーザーの入力値が反映されるためには以下の行をもう一度読み込む必要があります。
```python
st.session_state["number"] = st.number_input("input_number", value=0)
```

そのため、このような代入文を用いた実装で**タブ変更などでウィジェットの表示を切り替える場合や、ウィジェットの呼び出しより先に計算を実行する場合、意図しない挙動が発生する可能性がある**ので十分な注意が必要です。


## valueの取得
このような挙動を防ぐために、代入ではなくウィジェットが持つvalueを直接取得することで意図しない挙動を防ぐことができます。  
**ウィジェットのvalueはst.session_state["key"]で取得することが可能です。**  
st.session_stateは変数を保管する機能であると共に、ウィジェットのvalueを呼び出す機能でもあるのです。


以下のように書き換えることで意図した動作を実現できます。
```diff_python:input_number.py
import streamlit as st

if "is_number_input" not in st.session_state:
    st.session_state["is_number_input"] = False

if not st.session_state["is_number_input"]:
    # ウィジェットのIDを"widget_key"として宣言する    
-   st.session_state["number"] = st.number_input("input_number", value=0)
+   st.number_input("input_number", key="widget_key", value=0)
    st.session_state["is_number_input"] = True

else:
    # ウィジェットのvalueから値を直接呼び出す
-   st.write(f"Your input number is {st.session_state['number']}")
+   st.write(f"Your input number is {st.session_state['widget_key']}")
```
![execute_2_correct_return.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3881506/d2bef969-afdd-300c-3744-52f668730a0c.gif)


これで意図した挙動を実現することができました。  
しかし、これでめでたしめでたし・・・とは行きません。

## ウィジェットのvalueを取得する際の注意点
ここまでで、ウィジェットから直接valueを取得する方法について学びましたが、valueから直接呼び出す場合別の注意が必要です。  
**ウィジェットが持つvalueは一時キーであり、そのウィジェットが呼び出されなかった場合消去されます。**  

下記のコードは先ほどまでのコードの最終行にbuttonウィジェットを追加したものになります。  
ボタンを押すことで新たな入力はされませんが、スクリプトが再実行されます。  
本来であればボタンを押しても何も変わらないはずですが、実際には`number`が存在しないというエラーが発生します。  
**ボタンを押すことでスクリプトが再実行され、呼びされなかったウィジェット("widget_key")が消去されています。**

```python:input_error2.py
import streamlit as st

if "is_number_input" not in st.session_state:
    st.session_state["is_number_input"] = False

if not st.session_state["is_number_input"]:
    st.number_input("input_number", key="widget_key", value=0)
    st.session_state["is_number_input"] = True

else:
    st.write(f"Your input number is {st.session_state['widget_key']}")
    st.button("NEXT") # ボタンを追加

# KeyError: 'st.session_state has no key "widget_key".
```
![execute_3_nokey_error.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3881506/1839b962-d03c-669f-f538-ea2e6eadf515.gif)


これは以下のようなフローになっています。

1. ウィジェット("widget_key")を表示する
2. ユーザーがウィジェットに入力し、ウィジェットのvalueに入力値が格納される
3. --スクリプト再実行--
4. ウィジェットのvalueを呼び出し、
"Your input number is `st.session_state['widget_key']`"と表示される
5. ボタンをクリックする
6. --スクリプト再実行--
7. 呼び出されなかったウィジェット("widget_key")が削除される
8. ウィジェットが消去されており、valueを呼び出せないのでエラーが発生

実行時にウィジェットが呼び出されなかったことで削除され、それが持つkeyとvalueも同時に消去されています。  
そのため、ウィジェットを呼び出さない場合はvalueを別途格納しておく必要があります。


**一方、変数として宣言したst.session_stateは呼び出されなくても消えることはありません。**  
st.session_stateは見た目こそ同じですが、しっかりと区別して使用する必要があります。
```python 
# 実行終了まで保持される
st.session_state["variable"] = 1 

# ウィジェットが呼び出されない場合消去される
st.number_input("input_number", key="widget_key", value=1)
st.session_state["widget_key"] 
```

## 解決法
これらの問題を解決するため、[公式](https://docs.streamlit.io/develop/concepts/architecture/widget-behavior)では次のような提案がされています。

- **解決法その1：文頭でウィジェットのkeyを変数として宣言しなおす**
```python
st.session_state.my_key = st.session_state.my_key
```
このコードはウィジェットのvalue(右辺)を、宣言した変数(左辺)に代入することで変数の消去を防止しています。  
シンプルなやり方ではあるものの、変数の初期化が必要であったり、変数の数だけ記述する必要があるなどコードが煩雑になります。

- **解決法その2：引数on_changeを利用して入力時に変数を宣言して代入する**
```python:store_value.py
import streamlit as st

# 変数の保存・呼び出し用の関数
def store_value(key):
    st.session_state[key] = st.session_state["_"+key]

def load_value(key):
    st.session_state["_"+key] = st.session_state[key]

# ウィジェット入力時に変数に保存する
# ウィジェットの一時keyはアンダースコアを使用して明記
load_value("my_key")
st.number_input("Number of filters", key="_my_key", on_change=store_value, args=["my_key"])
```
このコードではウィジェットに引数on_changeを利用しています。  
on_changeに関数名、argsに引数を指定することで、ウィジェットに入力が行われた際に指定された関数が実行されます。  
ここでは**引数にkeyを渡しstore_value関数を実行することで、ウィジェットのvalueを変数として再宣言**しています。  
これによりウィジェットへの入力が変数に即時反映され、意図しない挙動を防ぐことができます。

また、ウィジェットが消去されてしまった場合でも
**load_value関数を実行することで、保存した変数からvalueを読み込む**ことができます。

この方法であれば変数管理のために逐次特殊な処理を記述する必要もないため、統一した形式でコードを記述できます。  
複雑なウィジェット管理を行う場合には最も確実な変数の利用方法だと言えるでしょう。  
**ただし、変数からウィジェットのvalueを読み込むため、ウィジェットの呼び出しの前に変数の宣言が必要になる点は注意が必要です。**

最初に提示したコードを書き換えると下記のようになります。  
ここでは変数の宣言を行うinit_value関数を実装して、`is_number_input`と`widget_key`の宣言を行っています。
このような実装であれば、ウィジェットへの入力が行われた時点で変数に即時に反映され、ウィジェットが消去されても変数を読み込むことができます。
```python:input_number.py
import streamlit as st

def store_value(key):
    st.session_state[key] = st.session_state["_"+key]

def load_value(key):
    st.session_state["_" + key] = st.session_state[key]

def init_value(key, init_value):
    if key not in st.session_state:
        st.session_state[key] = init_value

init_value("is_number_input", False) # 変数の宣言

if not st.session_state["is_number_input"]:
    # 変数の宣言
    init_value("widget_key", 0) 
    # ウィジェットのvalue読み込み 
    load_value("widget_key")
    # 入力値を変数に保存するウィジェット
    st.number_input(
        "input_number", 
        key="_widget_key", 
        on_change=store_value, 
        args=["widget_key"],
    )
    st.session_state["is_number_input"] = True

else:
    st.write(f"Your input number is {st.session_state['widget_key']}")
```
  
また、このコードでは実装されていませんが`is_number_input`を再度Falseにし、消去されたウィジェットを呼び出す場合にも値を保持することができます。  

また、init_value関数、load_value関数、ウィジェットの呼び出しはセットで記述します。  
そのため、init_valueとload_valueを統合したり、以下のようにload_valueを用いずにウィジェットの引数で値を呼び出すような実装も可能です。
```diff_python
    init_value("widget_key", 0)
-   load_value("widget_key")
    st.number_input(
        "input_number", 
+       value=st.session_state["widget_key"],
        key="_widget_key",
        on_change=store_value,
        args=["widget_key"]
    )
```

参考：[Streamlit: Understanding widget behavior](https://docs.streamlit.io/develop/concepts/architecture/widget-behavior)

## まとめ

- ウィジェット自体がkeyとvalueを持つ
- ウィジェットの入力時にはそのウィジェットが持つvalueだけが更新される
- ウィジェットのvalueはst.session_state["key"]から呼び出すことができる
- ウィジェットが呼び出されない場合、そのkeyとvalueは削除される
- 変数保存・読み込み用の関数を作り、ウィジェットから引数on_changeで呼び出して利用すると確実


**タブやページの切り替えでウィジェットの表示を切り替えたり、  
ウィジェット呼び出し前に計算を行うようなスクリプトでは変数の取り扱いを特に注意しましょう。**


## 補足
st.number_inputウィジェットに限らず、ほとんど全てのウィジェットは同じ仕様を持ちます。  
ただし、st.buttonウィジェットとst.form_submit_buttonウィジェットは仕様が異なるので注意が必要です。

この2つのウィジェットのvalueはbool型であり、ユーザーがボタンを押した次のPythonスクリプト実行でのみ一時的にTrueを持ちます。  
そして、これらのウィジェットは引数にkeyを指定することができません。

そのため、これらの入力を元に何らかの処理を実行したい場合は引数on_clickを利用しましょう。
```python:button.py
import streamlit as st

def do_something(key):
    # 処理
    return

st.button("Push", on_click=do_something, args=["my_key"]) # on_clickで関数を実行する。
```

##
今回の記事は以上になります。  
最後まで読んでいただき、ありがとうございました！

本記事の内容に誤りなどあれば、コメントにてご教授お願いいたします。

## Reference
- [Streamlit: A faster way to build and share data apps](https://streamlit.io/)
- [Streamlit: Input widgets](https://docs.streamlit.io/develop/api-reference/widgets)  
- [Streamlit: Understanding widget behavior](https://docs.streamlit.io/develop/concepts/architecture/widget-behavior)