StreamlitとFastAPIでwebアプリを作ってみた（デプロイ編）

## はじめに
今回PythonのライブラリであるStreamlitとWebフレームワークであるFastAPIを用いてwebアプリを作ってみたので記事にまとめました。

前回まででバックエンドとフロントエンドの実装が完了し、ローカル環境でアプリを動かすことができました。
最後デプロイ方法について書いていきます。

これまでの記事はこちら

https://qiita.com/kizataka/items/061624fdcb59c6437b84

https://qiita.com/kizataka/items/7e293b5814c5acdbb5f0

https://qiita.com/kizataka/items/ca32ed76e9472bef66ad

フロントエンドはStreamlitで実装しており、Streamlitには無料でデプロイできるサービスがあるので、それを使えばすぐデプロイできるだろうと考えていたのですが、意外と時間がかかってしまったところなので備忘録的にまとめます。

### 記事の目次
1. アプリの概要(再掲)
1. ディレクトリ構成
1. 詰まったところ
1. デプロイ方法


## 1. アプリの概要（再掲）
一言で言うと、誹謗中傷のような批判的なツイートを排除するアプリです。

具体的には、アプリ上で好きな人物名を入力して検索をかけると、Twitterからその人物名に関するツイートを取得してAIが自動でツイート内容を解析し、肯定的なツイートのみを表示させるようにするというものです。

と、理想はこのように考えていたのですが、途中でTwitterAPIの規約変更によりツイート取得ができないことに気づきました。
アプリ自体のテーマを変更するのは面倒なので、アプリの概要はそのままでKaggleにある文章データを利用してそれをツイート取得とみなし、擬似的に動かせるようにしました。

AIについては自然言語処理による感情分析という技術を用います。

### データセットについて
Kaggleにある[Women's E-Commerce Clothing Reviews](https://www.kaggle.com/datasets/nicapotato/womens-ecommerce-clothing-reviews?resource=download)を例として利用しました。
ECサイトでの婦人服の売上データとなっており、商品分類名や商品に関するレビュー文が格納されています。
レビュー文をツイートに置き換えて考え、ファイルをアップロードすることでツイートを取得できたと仮定しています。

### 使い方
![web_app_movie](https://github.com/kizataka/web_app_sentiment_analyzer/assets/112063667/c514762d-908b-4e8a-b31a-8f00877578e2) 

1. ファイル（Kaggleから取得してきた文章データ）をアップロードします。
2. 文章データが含まれるカラムを選択します。
2. 取得したいレビューに関するキーワードを入力します(本来ならここで人物名を入力し、その人物に関するツイートを取得します)。デモでは`shirt`に関するレビューを取得しています。
2. 取得したいデータ数を指定します。
2. 今回は日付指定は関係ないです。（本来ならどれくらいの期間のツイートを取得するのかを指定します。）
2. 表示するレビューをポジティブにするのかネガティブにするのかを指定します。
2. 分析開始ボタンをクリックします。クリックすると感情分析が始まります。
2. `shirt`に関する肯定的なレビューのみが表示されます。（本来なら検索した人物名に関するポジティブなツイートが表示されます。）

## 2. ディレクトリ構成
```
.
├── app.py                       Streamlitでフロントエンド実装
└── sql_app
	├── __init__.py
	├── main.py                  FastAPIのバックエンド実装
	├── schemas.py               Pydanticによる型定義
	└── sentiment_analysis.py    感情分析モデルの設定
```

## 3. 詰まったところ
デプロイ方法としては2点考えられました。

* StreamlitにあるStreamlit Cloudというサービスを使う
* Renderを使う

当初はHerokuを候補にしていたのですが有料化になったと聞いて、無料で使えるものを探してRenderというサービスに辿り着きました。

私はどちらか一方を使えばいいと考えていたのですが、それだと上手くいきませんでした。詳細を以下に記します。

### Streamlit Cloudによるデプロイ

https://streamlit.io/cloud

![スクリーンショット 2023-10-07 16.21.20（2）.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2741553/377bdb80-c0f4-d887-e774-5d7ad63c7add.png)

これはStreamlit Cloudのデプロイの設定画面です。`Main file path`にデプロイさせるファイルを指定するのですが、一つしか指定することができません。

前回のフロントエンド編でも書きましたが、ローカルでアプリを起動させる際、`streamlit run app.py`と`uvicorn main:app --reload`の二つコマンドを実行してapp.pyとmain.pyを起動させる必要がありました。

よって、Streamlit CloudだとFastAPIを起動させることができないのでアプリとしては動かないということがわかりました（実際に実行できませんでした）。

### Renderによるデプロイ

Renderに関する使い方は先日以下の記事にまとめたのでよかったら参考にしてください。

https://qiita.com/kizataka/items/10693e87f726a95499e4

![スクリーンショット 2023-10-07 16.37.19（2）.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2741553/ae0f1ee4-e34c-7f8b-9475-415f701d8b09.png)

これはRenderのデプロイの設定画面です。

最後の項目のところに`Start Command`があります。これはデプロイさせるファイルを起動させるコマンドを設定する項目です。

こちらも一つしか設定できません。よって、app.pyかmain.pyのどちらかしかデプロイさせることができないということがわかります。

### 原因調査
せっかくwebアプリを作ったのでデプロイするまで終われないと考えていました。

いろいろ調べていくうちに以下のページに辿り着きました。

https://discuss.streamlit.io/t/deploying-a-dockerized-mlops-app-in-streamlit-cloud-with-streamlit-and-fastapi/45390

ここに、
* Streamlit CloudはFastAPIのようなバックエンドサービスとStreamlitアプリを同時に実行するサービスはなく、FastAPIのバックエンドを別の場所でホストする必要がある
* Herokuなどでバックエンドをデプロイし、FastAPIアプリがホストされているURLにHTTPリクエストすることでStreamlitアプリから接続できる

と記載されていました。

よってバックエンド側の`main.py`をRenderで、`app.py`をStreamlit Cloudでデプロイさせ、エンドポイントのURLをRenderでデプロイしたURLに変更すれば上手くいくということがわかりました。
この方法で再度デプロイに挑戦します。

## 4. デプロイ方法
### Renderで`main.py`をデプロイ
まずは先ほどのRenderのデプロイ設定の画面までいき、各項目を埋めていきます。

Start Commandはmain.pyを起動させるために以下のように設定しました。

```
$ uvicorn sql_app.main:app --reload
```
RenderでデプロイするにはGirHubリポジトリを事前に作成しておく必要があります。レポジトリのディレクトリ構成を考えた時にmain.pyはsql_appフォルダの中にあるのでsql_app.mainとしています。

これでデプロイボタンを押せば約5分ほどでデプロイが完了し、URLが出力されます。

URLにアクセスするとバックエンド編のmain.pyで実装した`{'message': 'Welcome to the sentiment analysis app!'}`というメッセージが表示されていればデプロイ成功です。

### `app.py`のエンドポイントのURLを変更

フロントエンド編では感情分析を実行するエンドポイントのurlを以下のように実装していました。

```app.py
url = 'https://127.0.0.1:8000/analyze_sentiment/'
```
バックエンドのFastAPIのエンドポイントは、Renderでデプロイした時のURLに変更しているのでここを以下のように書き換えます。

```app.py
url = 'https://_ _ _ _ _ .onrender.com/analyze_sentiment/'
```

以下は修正したapp.pyの全体の実装です。
```app.py
import streamlit as st
import pandas as pd
import chardet
import requests

# タイトルの設定
st.title('ツイート感情分析アプリ')
st.write('')
st.write(
    """
    このアプリはTwitterから検索ワードに関するツイートを取得してその内容を感情分析し、
    ポジティブまたはネガティブなツイートのみを表示させるアプリです。
    """
)
st.write('')

# ファイルアップロードの設定
uploaded_file = st.file_uploader('csvファイルをアップロードしてください', type='csv')

if uploaded_file is not None:
    # エンコーディングの設定
    rawdata = uploaded_file.read()
    result = chardet.detect(rawdata)
    encoding = result['encoding']

    uploaded_file.seek(0)
    data = pd.read_csv(uploaded_file, encoding=encoding)

    st.write('')
    st.write('データの先頭5件を表示しています')
    st.write(data.head())
    st.write('')

    # サイドバー（検索条件）の設定
    st.sidebar.header('検索設定')
    column_to_process = st.sidebar.selectbox('ツイートが含まれるカラムの選択:', data.columns)
    st.sidebar.write('')
    keyword = st.sidebar.text_input('検索ワード:', value='キーワードを入力してください')
    st.sidebar.write('')
    num_comments = st.sidebar.slider('取得するツイート数:', min_value=1, max_value=len(data), value=10, step=10)
    st.sidebar.write('')
    st.sidebar.write('取得するツイートの期間指定:')
    date_from = st.sidebar.date_input('何日から')
    date_to = st.sidebar.date_input('何日まで')
    st.sidebar.write('')
    sentiment_filter = st.sidebar.radio('感情選択:', options=['Positive', 'Negative'])

    st.write('検索設定が完了したら下の分析開始ボタンを押してください')
    st.write('')

    if st.button('分析開始'):
        # データの前処理
        filtered_data = data[data[column_to_process].str.contains(keyword, case=False, na=False)]
        filtered_data = filtered_data.head(num_comments)

        # 感情分析の結果を格納するためのカラムの追加
        filtered_data['sentiment'] = ''
        filtered_data['sentiment_score'] = 0.0
        filtered_data = filtered_data[[column_to_process, 'sentiment', 'sentiment_score']]

        # 感情分析の実行
        for index, row in filtered_data.iterrows():

            # この部分を変更
            url = 'https://_ _ _ _ _ .onrender.com/analyze_sentiment/'

            response = requests.post(url, json={'text': row[column_to_process]})

            if response.status_code == 200:
                sentiment_data = response.json()
                filtered_data.at[index, 'sentiment'] = sentiment_data['sentiment']
                filtered_data.at[index, 'sentiment_score'] = sentiment_data['sentiment_score']
                
            else:
                st.error(f'感情分析エラー:{response.text}')  # APIエラーの表示

        # 選択した感情のみのツイートを表示するように設定
        if sentiment_filter == 'Positive':
            filtered_data = filtered_data[filtered_data['sentiment'] == 'POSITIVE']
        elif sentiment_filter == 'Negative':
            filtered_data = filtered_data[filtered_data['sentiment'] == 'NEGATIVE']

        st.success('分析が完了しました')
        st.markdown('## ツイート一覧')
        st.write(f'{sentiment_filter}なツイートのみ表示しています')
        st.write(f'表示件数:{num_comments}')

        # sentiment_scoreの大きい順に並び替え
        filtered_data = filtered_data.sort_values(by='sentiment_score', ascending=False)

        # テキストの表示
        for index, row in filtered_data.iterrows():
            st.markdown(f'#### Tweet {index + 1}')
            st.write(row[column_to_process])
            st.write(f'{row["sentiment"]}: {row["sentiment_score"]}')
            st.write('---')
```

修正が完了したら変更内容を保存して、リモートリポジトリにpushしておきます。

### Streamlit Cloudで`app.py`をデプロイ
先ほどのStreamlit Cloudのデプロイの設定画面までいき、対象のレポジトリやブランチやファイル名、URLの名前を指定します。

ファイル名はapp.pyにしてフロントエンド側のアプリが起動するようにします。

設定が完了したらデプロイボタンを押し、数分待てばデプロイが完了してアプリのURLが出力されるはずです。

### デプロイ完了
Streamlit Cloudでデプロイが完了すれば今回作成したwebアプリのデプロイは完了です。

## 5. まとめ
デプロイするのが初めてだったので、二つのサービスを使ってデプロイするとは思いもしませんでした。
完全に正解かどうかはわからないので、間違っていれば訂正をお願いします。

自分自身初めてのアプリ開発で、エンコーディングのエラーが出たりエンドポイントへのアクセス方法が難しい、そもそもどこから手をつけていいのかわからないなど大変なことが多かったですが、最終的になんとか形にすることができてよかったです。

簡単なアプリですが自分が考えたものがサービスとして形に残るというのはとても感動しました。

今後の展望としては、例外処理をより充実させたりDBを追加して過去のデータを見たり削除したりなどのCRUD処理を実装していきたいです。ユーザーがより使いやすいような機能を増やしていけたらなと考えています。