본문 바로가기
Back-end/Fastapi

#4. Fastapi로 모델 서빙부터 DevOps 까지 : XGBRegressor를 이용한 간단한 모델 구현 및 모델 서빙

by hsloth 2024. 4. 21.

 

본 포스팅은 AI 개발자 분에게 Fastapi을 이용해 간단한 모델을 서빙해보고, AWS에 올려 서버 배포 및 CI/CD 자동화까지 하는 것을 경험시켜주기 위해 제작되었습니다.

 

 

이전 시간에는 Param, Query, Body에 대해 알아보았다.

https://suloth.tistory.com/193

 

#3. Fastapi로 모델 서빙부터 DevOps 까지 : Query, Param, Body

본 포스팅은 AI 개발자 분에게 Fastapi을 이용해 간단한 모델을 서빙해보고, AWS에 올려 서버 배포 및 CI/CD 자동화까지 하는 것을 경험시켜주기 위해 제작되었습니다. 이전 시간에서는 간단하게 route

suloth.tistory.com

 

이번 시간에는 fastapi에서 서빙하기 위한 간단한 모델을 만들어 볼 예정이다.

나는... AI에 대해서는 잘 모르지만, XGBoost Regressor를 사용해서 간단하게 Boston의 집 값을 예측하는 모델을 생성해보려고 한다.

 

AI 모델에 대해 조언해주고 코드까지 알려준 최강석사 함지율군에게 감사 인사를 전합니다 ㅎㅎ

 

...

 

먼제 지난 시간 과제에 대한 정답을 공개하겠습니다...!!!!

# 지난번 과제
http://127.0.0.1:3000/api/user 경로로 Post 요청을 날리면, Param으로 username을 받게끔 하고,

Query로 age를 받게끔하자.

그리고 Body로는 height와 weight를 받아서 이를 리턴하는 함수를 만들자.



ex) Post http://127.0.0.1:3000/api/user/suloth?age=20

Request body : { height: 170, weight: 60 }

으로 요청을 보내면



리턴 값 : { username: "suloth", age: 20, height: "170cm", weight: "60kg" }

 

위가 지난번의 과제였다.

 

답은... 간단하다. 진짜 지난번에 배운 것을 그대로 활용하면 된다.

# router/user.py
from pydantic import BaseModel

class CreateUserByUsernameBodyDto(BaseModel):
  height: int
  weight: int
  
@router.post("/{username}")
async def createUserByUsername(body: CreateUserByUsernameBodyDto, username: str, age: int):
  height = body.height
  weight = body.weight
  
  processed_height = f"{height}cm"
  processed_weight = f"{weight}kg"
  
  return {"username": username, "age": age, "height": processed_height, "weight": processed_weight}

 

이제 가상환경에서 서버를 실행시켜보자.

가상환경 설정 혹은 서버 실행 법을 모른다면... 아래 포스팅을 참고하자. 각각 1번, 6번을 참고하면 된다.

https://suloth.tistory.com/127

 

#1. Fastapi 배우기 : 초기 설정

본 포스팅은 SKT FLY AI Challenge를 하면서 모델 서버 구축을 위해 혼자서 Fastapi에 대해 공부하는 포스팅입니다. 틀린 부분이 있거나, 이상한 부분이 있다면 지적해주시면 감사하겠습니다. 일단 먼저

suloth.tistory.com

 

서버를 실행 시킨 뒤, http://127.0.0.1:3000/docs 주소로 들어가면 Swagger가 나온다.

 

 

위 처럼 설정하고 Execute를 눌러주면 아래와 같이 응답이 온다!

 

 


 

XGBoost Regressor 모델링 하기


 

이 글은 AI에 대해 다루는 글이 아니기 때문에 XGBoost가 무엇인지에 대해서는 다루지 않고 넘어가겠습니다. (저도 잘 몰라요 ㅠ)

 

자, 우리는 이제 기본적인 Fastapi 사용법을 배웠다. 우리의 목표는 Fastapi로 모델을 서빙하는 것(Fastapi로 요청이 들어오면 모델을 돌려서 결과를 리턴하게끔 하는 것) 이기 때문에 Fastapi 서버에 올릴 모델이 필요하다.

 

우리는 XGBoost Regressor 라는 모델을 이용해서 Boston주의 집 값을 예측하는 모델을 만들 것이다.

만약 우리가 미국 보스턴 주에 사는 사람들을 상대로 집 값을 예측하는 서비스를 만든다고 가정하고 말이다!

 

먼저, Colab에 들어가보자. 구글 계정만 있으면 누구나 무료로 Colab을 사용할 수 있다.

 

그리고 파일 - 새 노트를 클릭해서 새 노트를 만들고 파일 명을 xgb.py 로 변경해보자.

 

 

 

그리고 다음 코드를 작성해주자.

라이브러리들은 이미 colab에 설치되어서 따로 설치해줄 필요없다.

# 주석에 대해서는 잘 이해가 안갈수도 있지만, 그냥 아~ 그렇구나라고 생각만하자.

import xgboost as xgb
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_squared_error
from sklearn.datasets import fetch_openml
import pandas as pd
import numpy as np


# Boston 집 값 관련 데이터 로드. 해당 데이터로 모델을 학습할 예정이다.
boston = fetch_openml(name='boston')

# 데이터를 불러와서, column을 feature names로 두고, DataFrame 형식으로 저장한다.
df = pd.DataFrame(boston.data, columns=boston.feature_names)

# MEDV는 집 값이다.
df['MEDV'] = boston.target

# CHAS와 RAD는 str로 불러와지기 때문에 int타입으로 형변환 시켜준다.
df['CHAS'] = df['CHAS'].astype('int')
df['RAD'] = df['RAD'].astype('int')

# df에 존재하는 MEDV(집 값) column을 제거하여 X에 대입한다. (예측 되는 집 값은 y 값으로 쓸 것이기 때문)
X = df.drop('MEDV', axis=1)

# y에 MEDV(집 값)을 대입한다.
y = df['MEDV']

# 데이터 분할. 테스트를 위한 데이터와 학습을 위한 데이터를 분할한다. 비율은 8:2 (train:test)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

# 모델 생성
model = xgb.XGBRegressor(objective ='reg:squarederror', colsample_bytree = 0.3, learning_rate = 0.1,
                max_depth = 5, alpha = 10, n_estimators = 10)

# 모델 학습
model.fit(X_train._get_numeric_data(), np.ravel(y_train, order='C'))

# 모델 저장. xgb_model.json 이라는 이름으로 모델을 저장한다.
model.save_model('xgb_model.json')


# 새 모델 인스턴스 생성 및 모델 로드
loaded_model = xgb.XGBRegressor()
loaded_model.load_model('xgb_model.json')
print("-------------------------")

# 예측
y_pred = loaded_model.predict(X_test)

# 평가. 값이 0에 가까울 수록 정확하다는 뜻이다...!
rmse = mean_squared_error(y_test, y_pred, squared=False)
print("RMSE: %.2f" % rmse)

 

그리고 왼쪽에 재생버튼을 클릭하면 코드가 돌아갈 것이다.

 

코드를 돌리고 나서, 파일 목록을 보면 xgb_model.json이라는 파일이 있을 텐데, 해당 파일을 다운로드 해주자.

 

 

만약 여기까지 했는데, 코드가 잘 돌아가지 않거나 문제가 있었다면, 아래 파일을 다운로드 받도록 하자.

xgb_model.json
0.02MB

 

 

Boston data에 대해 간단하게 설명을 적어보자면 다음과 같다.

# 서버를 위한 로직
# 아래 속성들을 이용해 MEDV를 예측하는 것
# CRIM : 자치시(town) 별 1인당 범죄율. 대략 0~20 사이의 float
# ZN : 25,000 평방피트를 초과하는 거주지역의 비율. 대략 0, 75, 12.5, 18인 것으로 보야. 0~100 사이의 float로 하면 될 듯
# INDUS : 비소매상업지역이 점유하고 있는 토지의 비율. 대충 0~50 사이의 float.
# CHAS : 찰스강에 대한 더미 변수(강 경계의 위치한 경우 1 아니면 0) 0 or 1 int.
# NOX : 10ppm당 농축 일산화질소. 대략 0~1 사이의 float.
# RM : 주택 1가구당 평균 방의 개수. 대략 1~10 사이의 float.
# AGE : 1940년 이전에 건축된 소유주택의 비율. 대략 0~100 사이의 float.
# DIS : 5개의 보스턴 직업센터까지의 접근성 지수. 대략 1~10(좀더 쳐서 1.5~6) 사이의 float.
# RAD : 방사형 도로까지의 접근성 지수. 대략 1~25 사이의 int.
# TAX : 10,000 달러 당 재산세율. 대략 200~1000 사이의 float.
# PTRATIO : 자치시 (town)별 학생/교사 비율. 대략 15~21 사이의 float.
# B : 1000(Bk-0.64)^2, 여기서 Bk는 자치시별 흑인의 비율. 대략 0~400 사이의 float.
# LSTAT : 모집단의 하위계층의 비율(%). 0~100 사이의 float.
# MEDV : 본인 소유의 주택 가격(중앙값) (단위 : $1,000).

 

 


모델 서빙


우리는 http://127.0.0.1:3000/api/house/price/predict 경로로 post요청을 보내면, 집값을 예측하도록 만들 것이다.

 

다운로드 받은 xgb_model.json 파일을 fastapi 폴더에 넣어주자.

xgb_model.json 파일을 models폴더에 넣어주고, services 폴더를 만들어서 house.py 파일을 만들어주자.

그리고 routers 폴더 안에도 house.py 파일을 만들어주자.

 

models와 services폴더는 src폴더 바로 밑에 만들어야한다.

 

 

일단 모델을 불러오는데 필요한 라이브러리를 설치해주자.

xgboost와 pandas가 필요하다. 추가적으로 xgboost를 실행시키기 위한 scikit-learn 라이브러리도 다운받아주자.

pip install xgboost

pip install pandas

pip install scikit-learn

 

 

그리고 services/house.py 파일을 아래와 같이 작성하자.

from typing import List
import xgboost as xgb
import pandas as pd

# XGBRegressor 모델을 불러온다.
loaded_model = xgb.XGBRegressor()

# colab에서 학습시킨 모델의 가중치 파일을 불러와서 모델에 적용시킨다.
loaded_model.load_model('src/models/xgb_model.json')

async def runModel() -> List[float]:
  
  # 미리 준비된 input 데이터(임시). 나중에는 Http Request에 담아서 보낼 것이다.
  dic = {
    "CRIM": [3.7],
    "ZN": [18.0],
    "INDUS": [22.37],
    "CHAS": [0],
    "NOX": [0.145],
    "RM": [4.533],
    "AGE": [66.7],
    "DIS": [4.291],
    "RAD": [13],
    "TAX": [333.333],
    "PTRATIO": [21.0],
    "B": [197.6],
    "LSTAT": [23.4],
  }
    
  # dictionary 형태를 DataFrame 형태로 변환한다.
  input = pd.DataFrame.from_dict(dic, orient='columns')
  
  # input 값을 이용해서 예측값을 만들고, z에 대입한다.  
  z = loaded_model.predict(input)

  # 변수 z의 타입이 numpy이기 때문에 list로 바꿔준다.
  result: List[float] = z.tolist()
  
  return result

 

 

XGBRegressor 모델을 불러와서 가중치 파일을 적용시키고, runModel함수에 예측 로직을 넣어놓았다.

자세한 건 주석을 참고하자!

 

그리고 routers/house.py 파일을 작성하자.

from fastapi import APIRouter
from pydantic import BaseModel
from src.services.house import runModel

house_router = router = APIRouter()

@router.post("/price/predict")
async def getPredictionOfHousePrice():
  price = await runModel()
    
  return price

 

@router.post 함수를 통해 url 경로를 붙여주고, services/house.py에서 runModel 함수를 불러와서 모델을 돌린 결과를 리턴하도록 한다.

 

그리고 routers/index.py 파일에 house_router만 붙여주면 끝이다.

from fastapi import APIRouter
from src.routers.user import user_router
from src.routers.post import post_router

# routers/house에서 house_router를 불러온다.
from src.routers.house import house_router

index_router = router = APIRouter()

router.include_router(user_router, prefix="/user")
router.include_router(post_router, prefix="/post")

# house_router를 /house 라는 경로로 붙여준다.
router.include_router(house_router, prefix="/house")

 

 

이제 /docs 경로(swagger)에 들어가면 다음과 같은 화면을 볼 수 있다.

 

execute를 누르면 predict된 집 값이 나올 것이다. 집 값은 MEDV라는 속성이었다. 이번 포스팅 위의 설명을 보면 MEDV는 천달러 단위이다. 따라서 약 20,000달러 라는 결론이 나온다.

 

 


사용자가 입력한 값에 따른 집 값 예측하기


 

지금까지는 Parameter를 서버에서 설정해서 집 값을 예측했기 때문에 Swagger에 항상 고정된 return 값만 나왔다.

이제는 사용자가 입력한 값에 따라 집 값을 예측해보려고 한다.

 

먼저 services/house.py에 runModel 함수를 보면, 다음과 같은 속성들이 있다.

 

여기서 CRIM과 RM을 사용자로부터 입력을 받아서 집 값을 예측해보려고 한다.

 

먼저, services/house.py 에서 runModel 함수의 인자로 crim과 room을 받아보자.

아래와 같이 코드를 수정할 수 있다.

# services/house.py

async def runModel(crim: float, room: float) -> List[float]:

  # CRIM과 RM에 각각 crim과 room 변수를 넣어준다.
  # CRIM = 범죄율, ROOM = 방 개수
  dic = {
    "CRIM": [crim],
    "ZN": [18.0],
    "INDUS": [22.37],
    "CHAS": [0],
    "NOX": [0.145],
    "RM": [room],
    "AGE": [66.7],
    "DIS": [4.291],
    "RAD": [13],
    "TAX": [333.333],
    "PTRATIO": [21.0],
    "B": [197.6],
    "LSTAT": [23.4],
  }
    
  # dictionary 형태를 DataFrame 형태로 변환한다.
  input = pd.DataFrame.from_dict(dic, orient='columns')
  
  # input 값을 이용해서 예측값을 만들고, z에 대입한다.  
  z = loaded_model.predict(input)

  # 변수 z의 타입이 numpy이기 때문에 list로 바꿔준다.
  result: List[float] = z.tolist()
  
  return result

 

 

그러면 runModel을 사용하는 함수도 바꿔주어야 하지 않겠는가?

router/house.py로 가서 runModel함수의 인자에 crim과 room을 넣어주고, 해당 값들을 URL의 query로 받아보자.

from fastapi import APIRouter
from src.services.house import runModel

house_router = router = APIRouter()

# 원래는 body로 받으려고 post로 했지만.. get을 써서 query로 간단하게 crim과 room만 받는걸로 계획을 수정했다.
@router.get("/price/predict")
async def getPredictionOfHousePrice(crim: float, room: float):
  price = await runModel(crim, room)
    
  return price

 

@router.post가 @router.get으로 바뀐 것에 주의하자.

 

그리고 서버를 실행시키고 Swagger에 들어가보자.

 

이렇게 되어있을 것이다.

crim은 0~20 사이의 소수 / room은 1~10 사이의 소수를 넣어주면 다음과 같이 예측 값이 변동되어 나올 것이다!

 

 

이것으로 모델 서빙 끝이다!

 

하지만 이것은 간단한 ML 모델이기 때문에 이렇게 간단하게 구현할 수 있었지만, 복잡한 모델의 경우 모델의 구조를 직접 알아야하고 input, output에 대한 형태까지 변형을 해줘야하기 때문에 더더욱 복잡하다.

게다가 gpu까지 사용하면 cuda, cudnn 설정도 해야하고 만약에 도커로 이걸 띄우는 과정까지 포함해야한다면 더 복잡해진다.

거기다가 cicd까지 자동화하려고 하면... 세팅이 상당히 복잡할 것이다.

 


과제


 

위에서는 services/house.py의 runModel 함수에서 crim과 room만 인자로 받아서 집 값을 예측하도록 했다.

이번에는 @router.get 말고 @router.post를 사용해서 crim과 room말고 다른 모든 속성들을 post의 body로 받아서 예측하도록 api를 만들어보자.

다른 속성들은 다음과 같다.

  dic = {
    "CRIM": [crim],
    "ZN": [18.0],
    "INDUS": [22.37],
    "CHAS": [0],
    "NOX": [0.145],
    "RM": [room],
    "AGE": [66.7],
    "DIS": [4.291],
    "RAD": [13],
    "TAX": [333.333],
    "PTRATIO": [21.0],
    "B": [197.6],
    "LSTAT": [23.4],
  }

 

기존의 runModel과 routers/house.py의 getPredictionOfHousePrice 함수는 놔두고

rumModel2 함수와 getPredictionOfHousePrice2 함수를 만들어서 작성해보도록 하자.