FastAPIとHerokuを使用した機械学習モデルのデプロイとホスティング

# TL;DR
- PythonとFastAPIを使用してRESTfulAPIを開発
- 株価を予測するための基本的な機械学習モデルを構築
- FastAPIアプリをHerokuにデプロイし，ホスティングを確認

# FastAPIとは
2018年12月にリリースされたPython製APIフレームワークです
https://fastapi.tiangolo.com/

特徴としては，Node.jsやGoと同等のパフォーマンス性能でかつFlaskライクな書き方で簡単にAPI構築することができます.
ハッカソンなど時間が限られた開発で有効なフレームワークだと思います

> 詳細については公式ドキュメントの[機能ガイド](https://fastapi.tiangolo.com/features/)を確認してください．また，FastAPIが他のWebフレームワークや技術とどのように異なるのかを詳しく説明している，[Alternatives, Inspiration and Comparisons](https://fastapi.tiangolo.com/alternatives/)を確認することをお勧めします．

# 準備
`「fastapi-heroku」`というプロジェクトフォルダを作成します．

```bash
$ mkdir fastapi-heroku
$ cd fastapi-heroku
```

次に，新しいPythonの仮想環境を作成して有効化します．

```bash
$ python3.8 -m venv env
$ source env/bin/activate
```

依存関係をインストールするために`requirements.txt`を作成します．

```txt:requirements.txt
fastapi==0.58.1
uvicorn==0.11.5
```
作成した依存関係ファイルに基づきパッケージのインストールを行います．

```bash
$ pip install -r requirements.txt
```

次に，動作確認を行うために簡単なAPI処理を書いた`main.py`を作成します．

```python:main.py
from fastapi import FastAPI


app = FastAPI()


@app.get("/ping")
def pong():
    return {"ping": "pong!"}
```

アプリを起動します．

```bash
$ uvicorn main:app --reload --workers 1 --host 0.0.0.0 --port 8080
```

上記uvicornコマンドでは次のような設定を行いました．

- `--reload` 自動リロードを有効にして，コードに変更が加えられたときサーバが再起動する
- `--workers 1` シングルワーカープロセスを提供する
- `--host 0.0.0.0` サーバをホストするアドレスを定義する
- `--port 8080` サーバをホストするポートを定義する



正常に動作していれば，下記URLにアクセス出来るはずです．
http://localhost:8080/ping

このような結果が出力されていればOKです

```json
{
  "ping": "pong!"
}
```

# MLモデル
展開するモデルは，[Prophet](https://facebook.github.io/prophet/)を使用して株式市場の価格を予測します．

次の関数を追加して，モデルを学習し，`model.py`という新しいファイルに予測を生成します.

```python:model.py
import datetime
from pathlib import Path

import joblib
import pandas as pd
import yfinance as yf
from fbprophet import Prophet

BASE_DIR = Path(__file__).resolve(strict=True).parent
TODAY = datetime.date.today()


def train(ticker="MSFT"):
    # data = yf.download("^GSPC", "2008-01-01", TODAY.strftime("%Y-%m-%d"))
    data = yf.download(ticker, "2020-01-01", TODAY.strftime("%Y-%m-%d"))
    data.head()
    data["Adj Close"].plot(title=f"{ticker} Stock Adjusted Closing Price")

    df_forecast = data.copy()
    df_forecast.reset_index(inplace=True)
    df_forecast["ds"] = df_forecast["Date"]
    df_forecast["y"] = df_forecast["Adj Close"]
    df_forecast = df_forecast[["ds", "y"]]
    df_forecast

    model = Prophet()
    model.fit(df_forecast)

    joblib.dump(model, Path(BASE_DIR).joinpath(f"{ticker}.joblib"))


def predict(ticker="MSFT", days=7):
    model_file = Path(BASE_DIR).joinpath(f"{ticker}.joblib")
    if not model_file.exists():
        return False

    model = joblib.load(model_file)

    future = TODAY + datetime.timedelta(days=days)

    dates = pd.date_range(start="2020-01-01", end=future.strftime("%m/%d/%Y"),)
    df = pd.DataFrame({"ds": dates})

    forecast = model.predict(df)

    model.plot(forecast).savefig(f"{ticker}_plot.png")
    model.plot_components(forecast).savefig(f"{ticker}_plot_components.png")

    return forecast.tail(days).to_dict("records")


def convert(prediction_list):
    output = {}
    for data in prediction_list:
        date = data["ds"].strftime("%m/%d/%Y")
        output[date] = data["trend"]
    return output
```

ここでは，次の3つの関数を定義しました．

### train
yfinanceを使用して過去の株式データをダウンロードし，新しいProphetモデルを作成します．
作成したモデルを株式データに適用させてから，モデルをシリアル化させ，Joblibファイルとして保存します．

### predict
保存したモデルを読み込み逆シリアル化します．
新しい予測を生成し，予測プロットと予測コンポーネントの画像を作成し、予測に含まれる日数をdictリストとして返します．

### convert
`predict`からdictリストを取得し，日付と予測値のdictを出力します．
例えば，`{"12/09/2020": 200}`

## 依存関係の更新
`requirements.txt`に依存関係を新しく追加します．

```txt:requirements.txt
fastapi==0.58.1
uvicorn==0.11.5

fbprophet==0.6
joblib==0.16.0
pandas==1.0.5
plotly==4.8.2
yfinance==0.1.54
```

仮想環境に追加したパッケージをインストールします．

```bash
$ pip install -r requirements.txt
```

## モデルのテスト
新しいPythonシェルを開き，次のコマンドを実行します

```bash
$ python
>>> from model import train, predict, convert
>>> train()
>>> prediction_list = predict()
>>> convert(prediction_list)
```

次のようなものが表示されます．

```python
{
    '12/11/2020': 212.64344057979804, 
    '12/12/2020': 212.67327416296965, 
    '12/13/2020': 212.70310774614126, 
    '12/14/2020': 212.73294132931287, 
    '12/15/2020': 212.76277491248447, 
    '12/16/2020': 212.79260849565608, 
    '12/17/2020': 212.8224420788277
}
```

以下の画像は，Microsoft Corporation（MSFT）の今後7日間の予測価格です．MSFT.joblibモデルと2つの画像が保存されます．
![MSFT_plot.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/130670/9e0bec2d-7f8a-19c5-f1f5-fc103a61a964.png)

![MSFT_plot_components.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/130670/0786254c-89f3-9ff8-5c55-be504d2152ee.png)


## モデルに対してさらにトレーニングを実行

```bash
>>> train("GOOG")
>>> train("AAPL")
>>> train("^GSPC")
```

上記コマンド実行後，シェルを終了します．

# FastAPIにMLモデルを組み込む
次のように`main.py`に`/predict`エンドポイントを追加してみましょう．

```python:main.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

from model import convert, predict

app = FastAPI()


# pydantic models


class StockIn(BaseModel):
    ticker: str


class StockOut(StockIn):
    forecast: dict


# routes


@app.get("/ping")
async def pong():
    return {"ping": "pong!"}


@app.post("/predict", response_model=StockOut, status_code=200)
def get_prediction(payload: StockIn):
    ticker = payload.ticker

    prediction_list = predict(ticker)

    if not prediction_list:
        raise HTTPException(status_code=400, detail="Model not found.")

    response_object = {"ticker": ticker, "forecast": convert(prediction_list)}
    return response_object
```

新しく作成した`get_prediction`関数では，モデルの`predict`関数にティッカー（銘柄コード）を渡し，`convert関数`を使用して応答オブジェクトを作成します．

また，**Pydanticスキーマ**を利用して，JSONペイロードを**StockInオブジェクトスキーマ**に変換しました．これにより，**型検証**が自動で行われます．

応答オブジェクトは**StockOutスキーマオブジェクト**を使用してPythondict---`{"ticker": ticker, "forecast": convert(prediction_list)}`をJSONに変換します．これも，同様に型検証されます．

## アプリの実行
下記コマンドを実行し，APIサーバを起動します．

```bash
 $ uvicorn main:app --reload --workers 1 --host 0.0.0.0 --port 8080
```

特にエラーが発生しなければ大丈夫です．

## 動作チェック
次の，curlコマンドを実行してみましょう．

```bash
$ curl \
  --header "Content-Type：application/json" \
  --request POST \
  --data '{"ticker"： "MSFT"}' \
  http://localhost:8080/predict
```

次のようなものが表示されるはずです．

```bash
{
  "forecast": {
    '12/11/2020': 212.64344057979804, 
    '12/12/2020': 212.67327416296965, 
    '12/13/2020': 212.70310774614126, 
    '12/14/2020': 212.73294132931287, 
    '12/15/2020': 212.76277491248447, 
    '12/16/2020': 212.79260849565608, 
    '12/17/2020': 212.8224420788277
  },
  "ticker": "MSFT"
}
```

ティッカーモデルが存在しない場合は次のように表示されます．

```bash
$ curl \
  --header "Content-Type: application/json" \
  --request POST \
  --data '{"ticker":"NONE"}' \
  http://localhost:8080/predict

{
  "detail": "Model not found."
}
```

# Herokuへデプロイ
Herokuは，Webアプリケーションのホスティングを提供するPlatform as a Service（PaaS）です．
これらは，基盤となるインフラストラクチャを管理する必要がない抽象化された環境を提供し，**Webアプリケーションの管理**，**デプロイ**，および**スケーリング**を容易にします．数回クリックするだけで，アプリを起動して実行，トラフィックを受信する準備を整えることができます．

## ログイン
Herokuアカウントに[サインアップ](https://signup.heroku.com/)し（まだアカウントを持っていない場合），[Heroku CLI](https://devcenter.heroku.com/articles/heroku-cli)を[インストール](https://devcenter.heroku.com/articles/heroku-cli)します（まだインストールしていない場合）．

次に，CLIを介してHerokuアカウントにログインします．

```bash
 $ heroku login
```

ログインを完了するには，任意のキーを押してWebブラウザを開くように求められます．

## デプロイ準備

gitがインストールされていると仮定して，新しい**gitリポジトリ**を初期化します．

```bash
$ git init
```

次に，`.gitignore`ファイルを追加します．

```txt:.gitignore
__pycache__ 
env
```

ファイルをステージングし，新しいコミットを作成します．

```bash
$ git add -A
$ git commit -m "init"
```

Heroku上にアプリを作成する

```bash
$ heroku create
```

成功すると，次のように表示されます．

```bash
Creating app... done, ⬢ salty-escarpment-xxxxx
https://salty-escarpment-xxxxx.herokuapp.com/ | https://git.heroku.com/salty-escarpment-xxxxx.git
```

git remoteにherokuへのリモートが反映されていることを確認

```bash
$ git remote -v 

Herokuのhttps://git.heroku.com/salty-escarpment-xxxxx.git (fetch) 
Herokuのhttps://git.heroku.com/salty-escarpment-xxxxx.git (push)
```

最後に，`Procfile`(拡張子なし)を作成して，起動時にHerokuで実行するコマンドを指定します．

```txt:Procfile
web: gunicorn -w 3 -k uvicorn.workers.UvicornWorker main:app
```

ここでは，本番環境用途のWSGIアプリケーションサーバである`Gunicorn`を使用して，3つのワーカープロセスでUvicornを起動します．
この構成は，並行性（Uvicorn経由）と並列処理（Gunicornワーカー経由）の両方を利用します．

次に，`requirements.txt`を更新します

```txt:requirements.txt
fastapi==0.58.1
gunicorn==20.0.4
uvicorn==0.11.5

fbprophet==0.6
joblib==0.16.0
pandas==1.0.5
plotly==4.8.2
yfinance==0.1.54
```

これまでの変更をコミットします．

```bash
$ git add -A
$ git commit -m "add procfile"
```

## デプロイ
コードをpushしてHerokuへデプロイします．

```bash
$ git push heroku master
```

これで，アプリを表示できるようになります．

## 動作チェック
curlコマンドで`/predict`エンドポイントを叩いてテストしてみましょう．

```bash
$ curl \
  --header "Content-Type: application/json" \
  --request POST \
  --data '{"ticker":"MSFT"}' \
  https://<YOUR_HEROKU_APP_NAME>.herokuapp.com/predict
```

FastAPIでは自動でAPIドキュメントが生成されます．
ここからも実行して確認することができます．

![swagger.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/130670/677b19eb-1784-31c6-e431-0e4312ba2301.png)

# まとめ
本記事では，株価を予測するための機械学習モデルを，FastAPIを利用してHerokuの本番環境へデプロイする方法を説明しました．

機械学習はPythonで書かれることが多いため，Python性フレームワークであるFastAPIと親和性が高いです．また，今回記述したコードを見ていただければ分かる通り，シンプルに書くことができデプロイも非常に簡単なため全人類に強くおすすめします．