Streamlit, Flask, Docker 를 활용한 머신러닝 데모 앱 만들기 (with XGBoost, Keras, Shap)

7 minute read

PoC 레벨의 분석 프로젝트를 할 때 결과를 보여줄 수 있는 데모 어플리케이션의 필요성을 계속 느꼈었다. 데모 어플리케이션 구성을 위해 웹 프레임워크, 클라우드, 도커, 리눅스 등 다양한 기술 스택들을 찾아봤었고 그 중 데모용으로 적절한 기술 스택의 조합을 찾았다. 이번 포스트에서 UCI의 Combined Cycle Power Plant (CCPP) dataset을 이용해 해당 스택으로 데모 어플리케이션을 만드는 과정을 소개해볼까 한다.

/assets/images//ccpp-demo-screenshot.png

기술 스택

Python

  • streamlit==0.85.1 (front-end)
  • Flask==2.0.1 (model serving)
  • scikit-learn==0.24.2 (machine learning model)
  • xgboost==1.4.2 (machine learning model)
  • tensorflow==2.7.0 (deep learning model)
  • shap==0.39.0

Docker (Optional)

  • docker-compose (container management)

AWS (Optional)

  • AWS Lightsail (computing & deployment)

어플리케이션 구성에 사용된 기술 스택은 위와 같다. 어플리케이션은 streamlit을 통해 만들어진 화면 UI에서 Flask API로 Scikit-learn, XGBoost, Tensorflow를 활용해 만든 모델들을 호출하는 구조로 이루어져있다. Docker의 경우 모델 서빙 부분과 화면 단을 분리하고 시스템의 재현과 배포를 용이하게 하기 위해 적용되었다. 외부 환경 배포를 위해서는 AWS Lightsail을 사용했다. Optional 이라고 표시된 Docker 와 AWS 부분을 제외하더라도 python app.py , streamlit run webapp.py 두 개의 커맨드를 각 터미널에 입력함으로써 데모 어플리케이션을 실행할 수 있도록 만들어져있다. 하지만 좀 더 괜찮은 어플리케이션 구성의 측면에서 AWS 배포를 제외하더라도 Docker 부분까지의 구현은 따라 구성하는 것을 추천한다. 자세한 설정 방법은 jinwoo1990/ccpp-demo README 파일에 작성해놓았다.

데이터 소개 및 구현 목표

CCPP는 한국말로 복합 화력 발전소로 가스 터빈만을 사용하는 기존 화력 발전소의 방식에서 가스 터빈에서 나오는 열을 재사용해서 증기 터빈을 같이 돌리는 방식을 추가해 발전 효율성을 추구하는 발전소의 한 종류다. UCI dataset에서는 2006년부터 2011년까지 발전소의 온도, 대기압, 습도, 설정 압력 데이터와 발전량 데이터를 제공하고 있다. 우리는 이 데이터를 이용해 주변 조건과 기계 설정값 변경에 따라 발전량을 예측하는 데모 어플리케이션을 만들어 볼 것이다. 추가적으로 shap force plot를 제공해 모델의 예측 결과가 어떤 이유로 나왔는지를 설명해줄 수 있는 기능도 같이 구현할 것이다.

논문에서 발췌한 상세 데이터에 대한 설명은 아래와 같다.

Environmental variables

  • Ambient Temperature (AT): This input variable is measured in whole degrees in Celsius as it varies between 1.81°C and 37.11°C.
  • Atomospheric Pressure (AP): This input variable is measured in units of minibars with the range of 992.89 - 1033.30 mbar.
  • Relative Humidity (RH): This variable is measured as a percentage from 25.56% to 100.16%.

Control variables

  • Vaccum (Exhaust Steam Pressure, V): This variable is measured in cm Hg with the range of 25.36-81.56 cm Hg.

Target variables

  • Full Load Electrical Power Output (PE): PE is used as a target variable in the dataset. It is measured in mega watt with the range of 420.26-495.76 MW.

데이터 분석 및 모델링

분석 내용은 CCPP_modeling.ipynb에 자세히 작성되어있다. AT, AP, RH, V 네 가지의 변수가 모두 인풋으로 사용되었고 이를 바탕으로 PE를 예측하는 모델링을 수행했다.

Linear Regression, KNN Regression, XGBoost, DNN 등의 알고리즘이 테스트되었고 XGBoost 모델이 그 중 가장 성능이 좋은 모델로 선정되었다. XGBoost 모델은 pickling을 통해 API에서 사용하기 위한 모델 파일로 저장되었다.

어플리케이션 구성

ccpp-demo application

.
├── README.md
├── api
│   ├── Dockerfile
│   ├── app.py
│   ├── helper.py
│   ├── model
│   │   ├── XGB_211129_0103.pkl
│   │   ├── XGB_explainer_211129_0103.pkl
│   │   ├── background_data_211129_0103.pkl
│   │   └── preprocessing_objects_211129_0103.pkl
│   └── requirements.txt
├── dashboard
│   ├── Dockerfile
│   ├── requirements.txt
│   └── webapp.py
├── docker-compose.yml
└── init.sh

전체적인 어플리케이션 구성은 위와 같다. 어플리케이션은 크게 모델 서빙을 위한 Flask API, 화면 UI를 위한 streamlit dashboard와 docker 환경 구성을 위한 yml 파일과 쉘 스크립트로 이루어져있다. 아래에서 하나씩 살펴보겠다.

Flask API

api 모듈은 XGBoost 모델, Shap explainer, 기타 전처리나 API 처리에 필요한 pickle 파일과 모델 서빙을 담당하는 flask 스크립트인 app.py, app.py 의 function을 보조해주는 helper function을 포함하는 helper.py로 이루어져 있다. app.py의 predictcalculate_shap_values에 정의된 url로 요청이 들어오면 helper.py의 function을 이용해 pickle 파일들을 불러와 예측값과 shap 값을 리턴하는 구조로 프로그램이 작성되어있다.

app.py

from flask import Flask, request, jsonify
import pandas as pd
import helper

app = Flask(__name__)

@app.route('/model/predict/', methods=['POST'])
def predict():
    """
    예측 결과를 반환하는 함수

    :return: 전처리된 input 데이터, 예측 결과 및 확률을 포함한 dataframe
    """
    json_data = request.get_json()
    preprocessing_objects, model = helper.load_preprocessor_and_model()
    scaler = preprocessing_objects['scaler']
    features_selected = preprocessing_objects['features_selected']

    data = pd.DataFrame(json_data)[features_selected]
    preprocessed_data = helper.preprocess_record(data, scaler)
    prediction = helper.predict_record(preprocessed_data, model)

    output = data.to_dict('records')[0]
    output['prediction'] = float(prediction)
    output = jsonify(output)

    return output

...

위 예시를 보면 app.py 파일에서 helper 모듈을 import해 predict 함수 내에서 preprocessor와 모델을 불러오기, preprocessing, 예측에 사용하는 것을 볼 수 있다. 예측 결과는 json 형식으로 변환되 streamlit에서 사용된다.

Streamlit Dashboard

dashboard 모듈은 화면 UI에 해당하는 streamlit 파트이다. webapp.py 파일에서 좌측 sidebar를 통해 input features 값을 받고 우측 레이아웃에서 예측 결과값과 사용된 인풋, shap force plot을 보여주도록 UI를 만들었다.

webapp.py

...

def streamlit_main():
    """
    streamlit main 함수

    :return: None
    """
    st.title('CCPP Power Output Predictor')

		...

    # sidebar input 값 선택 UI 생성
    st.sidebar.header('User Menu')
    user_input_data = get_user_input_features()

    st.sidebar.header('Raw Input Features')
    raw_input_data = get_raw_input_features()

    submit = st.sidebar.button('Get predictions')
    if submit:
        results = requests.post(url + predict_endpoint, json=raw_input_data)
        results = json.loads(results.text)

        # 예측 결과 표시
        st.subheader('Results')
        prediction = results["prediction"]
        st.write("Prediction: ", round(prediction, 2))

        # expander 형식으로 model input 표시
        st.subheader('Input Features')
        features_selected = ['AT', 'V', 'AP', 'RH']

        model_input_expander = st.beta_expander('Model Input')
        model_input_expander.write('Input Features: ')
        model_input_expander.text(", ".join(list(raw_input_data[0].keys())))
        model_input_expander.json(raw_input_data[0])
        model_input_expander.write('Selected Features: ')
        model_input_expander.text(", ".join(features_selected))
        selected_features_values = OrderedDict((k, results[k]) for k in features_selected)
        model_input_expander.json(selected_features_values)

        # shap 값 계산
        shap_results = requests.post(url + shap_endpoint, json=raw_input_data)
        shap_results = json.loads(shap_results.text)

        base_value = shap_results['base_value']
        shap_values = np.array(shap_results['shap_values'])

        # shap force plot 표시
        st.subheader('Interpretation Plot')
        draw_shap_plot(base_value, shap_values, pd.DataFrame(raw_input_data)[features_selected])

        ...

        # expander 형식으로 shap detail 값 표시
        shap_detail_expander = st.beta_expander('Shap Detail')
        for key, item in zip(features_selected, shap_values):
            shap_detail_expander.text('%s: %s' % (key, item))

if __name__ == '__main__':
    streamlit_main()

webapp.py 코드를 보면 sidebar, slider, header, subheader, text 등의 정의된 컴포넌트와 레이아웃을 활용해 하나의 파일로 데모 웹어플리케이션에 필요한 화면 UI 요소가 만들어져있다. streamlit은 이처럼 하나의 파일로도 원하는 UI를 구성할 수 있는 쉬운 대시보드 프레임워크이다. Streamlit documentation의 공식문서를 참조한다면 사용된 코드의 이해와 새로운 화면의 작성을 어렵지 않게 할 수 있을 것이다.

streamlit은 데모 개발을 위한 좋은 웹 프레임워크이다. 다만, streamlit은 기본적으로 화면 렌더링마다 처음부터 끝까지 코드를 실행하게 되어있고 (캐시를 통해 일부 개선은 가능) 멀티 페이지 개발, url 관리, 미들웨어 설정 등을 기본적으로 제공하지 않아 프로덕션 시스템에 사용하기에는 적절치 못하다. 프로덕션 시스템 개발에서는 streamlit이 아닌 django나 spring을 활용해야 한다.

Docker

사실 위 두 파트의 코드만 있으면 작동하는 데모 어플리케이션을 만들 수 있다. 하지만, 이를 위해서는 두 파트의 코드를 실행시킬 라이브러리를 모두 포함하는 가상환경이 필요하다. 가상환경을 매뉴얼하게 만들어서 사용할 수도 있지만 실행시키는 서버/컴퓨터의 os나 파이썬 버전 등 다양한 환경에 따라 동일한 설정이라도 코드가 재현되지 않을 수 있다. Docker를 이용하면 os나 기타 환경 문제를 생각하지 않고 일관되게 시스템을 실행시킬 수 있다. ccpp-demo에서는 flask container와 streamlit container를 각각의 Dockerfile로 만들고 docker-compose를 통해 한 번에 관리할 수 있도록 만들었다.

Dockerfile

FROM python:3.7-stretch
WORKDIR /dashboard/
ENV TZ=Asia/Seoul
ENV VIRTUAL_ENV=/dashboard/.venv
ENV PATH=${VIRTUAL_ENV}/bin:$PATH
RUN python -m venv ${VIRTUAL_ENV}
COPY requirements.txt /dashboard/requirements.txt
RUN pip install --upgrade pip && pip install --no-cache-dir -r requirements.txt
COPY . .
ENTRYPOINT ["streamlit", "run"]
CMD ["webapp.py"]

streamlit을 위한 Dockerfile의 예시다. python:3.7-stretch라는 리눅스 기반의 python 이미지를 내려받아 가상환경 설정과 필요 라이브러리를 다운로드하고 docker 실행 시 streamlit 어플리케이션이 켜지도록 구성되어 있다. RUN pip install --upgrade pip && pip install --no-cache-dir -r requirements.txt 이후에 COPY . . 으로 소스코드를 복사하게 짜여져있는데 이 순서로 해야 소스코드가 바뀔 때마다 라이브러리를 다시 체크하고 설치하는 방식의 동작을 제한할 수 있다.

.env

# Custom env
PREPROCESSING_OBJECTS_PATH=/api/model/preprocessing_objects_211129_0103.pkl
MODEL_PATH=/api/model/XGB_211129_0103.pkl
MODEL_TYPE=tree
BACKGROUND_DATA_PATH=/api/model/background_data_211129_0103.pkl
EXPLAINER_PATH=/api/model/XGB_explainer_211129_0103.pkl

ccpp-demo는 .env 파일의 환경변수를 활용해 작동된다. .env 파일에서 선언된 환경변수들은 docker-compose.yml 파일과 init.sh 파일에서 설정되어 파이썬의 변수로 활용된다. 이런 구조를 활용하면 모델 파일 업데이트를 위해 경로를 바꾸어도 파이썬 소스코드를 바꿀 필요 없이 .env 파일만 수정해도 어플리케이션을 작동시킬 수 있어 관리가 훨씬 수월하다.

docker-compose.yml

version: '3'

services:
  api:
    container_name: api
    restart: always
    build: api/
    env_file:
      - .env
    ports:
      - "5000:5000"
    networks:
      - deploy_network

  dashboard:
    container_name: dashboard
    restart: always
    build: dashboard/
    env_file:
      - .env
    depends_on:
      - api
    ports:
      - "8501:8501"
    networks:
      - deploy_network

networks:
  deploy_network:
    driver: bridge

flask container와 streamlit container를 묶어서 배포하기 위한 docker-compose 파일이다. 해당 파일 설정을 통해 포트나 의존성, 네트워크 등을 설정해 한 번에 container들을 관리할 수 있다.

init.sh

#!/usr/bin/env bash

# .env 파일에서 환경변수 불러오기
if [ -f .env ]; then
  export $(grep -v '^#' .env | xargs)
fi

# docker-compose up 실행
docker-compose up --build -d

shell script로 작성된 데모 어플리케이션 실행 파일이다. sh init.sh 를 통해 어플리케이션을 실행시킬 수 있다. 중지시키고 싶다면 docker-compose stop, 아예 container들을 삭제하고 싶다면 docker-compose down 을 활용할 수 있다.

기타

AWS를 활용해 만든 데모 웹어플리케이션을 공개적으로 띄울 수 있다. 자세한 설정 방법은 jinwoo1990/ccpp-demo README 파일의 배포 섹션을 참조하기 바란다.

[2021.12.9 추가] config.toml 파일을 통해 기본 UI 디자인을 변경했다. 설정 내용은 프로젝트 단위로 적용되고 설정 방법은 Configuration - Streamlit Docs을 참조했다.

마치며

블로그 작성을 목적으로 원래 있던 코드(jinwoo1990/ml-streamlit-app)에서 일부 부분을 단순화시켜 코드를 구성했다. 추후에는 ccpp-demo 코드를 바탕으로 db와 S3를 사용해 모델 학습 결과와 모델 바이너리를 저장하는 기능과 같이 모델 관리에 필요한 부분들을 구현해보려 한다. 원래 코드에서는 별도 패키지 사용 없이 모델 관리 기능들을 구현하고 있었는데 ccpp-demo 뒷 버전에서는 mlflow 등 현재 나와있는 MLOps 라이브러리들을 사용해서 관리 데모 화면도 만들어보려고 한다. 포스트 내용이 분석 프로젝트에서 결과를 보여주기 위해 이리저리 고생했을 나와 같은 사람들에게 도움이 되었으면 한다.

References

Leave a comment