diff --git a/02-online-serving(fastapi)/projects/web_single/README.md b/02-online-serving(fastapi)/projects/web_single/README.md index 658624e..ff83884 100644 --- a/02-online-serving(fastapi)/projects/web_single/README.md +++ b/02-online-serving(fastapi)/projects/web_single/README.md @@ -1,73 +1,125 @@ -# Web Single Pattern ML API by FastAPI - -Web Single Pattern ML API by FastAPI는 FastAPI를 이용해 만든 웹 서비스로, 단일 모델을 이용해 예측을 수행하는 API를 제공합니다. - -## Pre-requisites +# FastAPI Web Single Pattern +- 목적 : FastAPI를 사용해 Web Single 패턴을 구현합니다 +- 상황 : 데이터 과학자가 model.py을 만들었고(model.joblib이 학습 결과), 그 model을 FastAPI을 사용해 Online Serving을 구현해야 함 + - model.py는 추후에 수정될 수 있으므로, model.py를 수정하지 않음(데이터 과학자쪽에서 수정) +## 설치 - Python >= 3.9 - Poetry >= 1.1.4 -## Installation -```bash +``` poetry install ``` ## Run - ```bash PYTHONPATH=. -poetry run python main.py +python main.py ``` -## Usage - -### Predict - +## Predict ```bash curl -X POST "http://0.0.0.0:8000/predict" -H "Content-Type: application/json" -d '{"features": [5.1, 3.5, 1.4, 0.2]}' - -{"id":3,"result":0} ``` -### Get all predictions - +## Get all predictions ```bash curl "http://0.0.0.0:8000/predict" - -[{"id":1,"result":0},{"id":2,"result":0},{"id":3,"result":0}] ``` -### Get a prediction - +## Get a prediction ```bash curl "http://0.0.0.0:8000/predict/1" -{"id":1,"result":0} ``` -## Build - -```bash +## Docker Build +``` docker build -t web_single_example . ``` -## Project Structure +--- + + + +# 시작하기 전에 +- 여러분들이라면 어떻게 시작할까? 어떻게 설계할까? => 잠깐이라도 생각해보기 + - 여러분들의 생각과 제가 말하는 것을 비교 => Diff => 이 Diff가 왜 생겼는가? + +# FastAPI 개발 +- FastAPI를 개발할 때의 흐름 +- 전체 구조를 생각 => 파일, 폴더 구조를 어떻게 할까? + - predict.py, api.py, config.py + - 계층화 아키텍처 : 3 tier, 4 tier layer + - Presentation(API) <-> Application(Service) <-> Database +- API : 외부와 통신. 건물의 문처럼 외부 경로. 클라이언트에서 API 호출. 학습 결과 Return + - schema : FastAPI에서 사용되는 개념 + - 자바의 DTO(Data Transfer Object)와 비슷한 개념. 네트워크를 통해 데이터를 주고 받을 때, 어떤 형태로 주고 받을지 정의 + - 예측(Request, Response) + - Pydantic의 Basemodel을 사용해서 정의. Request, Response에 맞게 정의. Payload +- Application : 실제 로직. 머신러닝 모델이(딥러닝 모델이) 예측/추론. +- Database : 데이터를 어딘가 저장하고, 데이터를 가지고 오면서 활용 +- Config : 프로젝트의 설정 파일(Config)을 저장 +- 역순으로 개발 + +# 구현해야 하는 기능 +## TODO Tree 소개 +- TODO Tree 확장 프로그램 설치 +- [ ] : 해야할 것 +- [x] : 완료 +- FIXME : FIXME + +# 기능 구현 +- [x] : FastAPI 서버 만들기 + - [x] : POST /predict : 예측을 진행한 후(PredictionRequest), PredictionResponse 반환 + - [x] : Response를 저장. CSV, JSON. 데이터베이스에 저장(SQLModel) + - [x] : GET /predict : 데이터베이스에 저장된 모든 PredictionResponse를 반환 + - [x] : GET /predict/{id} : id로 필터링해서, 해당 id에 맞는 PredictionResponse를 반환 +- [x] : FastAPI가 띄워질 때, Model Load => lifespan +- [x] : DB 객체 만들기 +- [x] : Config 설정 + +# 참고 +- 데이터베이스는 SQLite3, 라이브러리는 SQLModel을 사용 + +# SQLModel +- FastAPI를 만든 사람이 만든 Python ORM(Object Relational Mapping) : 객체 => Database +- 데이터베이스 = 테이블에 데이터를 저장하고 불러올 수 있음 +- Session : 데이터베이스의 연결을 관리하는 방식 + - 외식. 음식점에 가서 나올 때까지를 하나의 Session으로 표현. Session 안에서 가게 입장, 주문, 식사 + - Session 내에서 데이터를 추가, 조회, 수정할 수 있다! => POST / GET / PATCH + - Transaction : 세션 내에 일어나는 모든 활동. 트랜잭션이 완료되면 결과가 데이터베이스에 저장됨 + +## 코드 예시 +``` +SQLModel.metadata.create_all(engine) : SQLModel로 정의된 모델(테이블)을 데이터베이스에 생성 +- 처음에 init할 때 테이블을 생성! +``` -```bash -. -├── .dockerignore # 도커 이미지 빌드 시 제외할 파일 목록 -├── .gitignore # git에서 제외할 파일 목록 -├── Dockerfile # 도커 이미지 빌드 설정 파일 -├── README.md # 프로젝트 설명 파일 -├── __init__.py -├── api.py # API 엔드포인트 정의 파일 -├── config.py # Config 정의 파일 -├── database.py # 데이터베이스 연결 파일 -├── db.sqlite3 # SQLite3 데이터베이스 파일 -├── dependencies.py # 앱 의존성 관련 로직 파일 -├── main.py # 앱 실행 파일 -├── model.joblib # 학습된 모델 파일 -├── model.py # 모델 관련 로직 파일 -├── poetry.lock # Poetry 라이브러리 버전 관리 파일 -└── pyproject.toml # Poetry 프로젝트 설정 파일 -``` \ No newline at end of file +``` +with Session(engine) as session: + ... + # 테이블에 어떤 데이터를 추가하고 싶은 경우 + result = '' + session.add(result) # 새로운 객체를 세션에 추가. 아직 DB엔 저장되지 않았음 + session.commit() # 세션의 변경 사항을 DB에 저장 + session.refresh(result) # 세션에 있는 객체를 업데이트 + + # 테이블에서 id를 기준으로 가져오고 싶다 + session.get(DB Model, id) + + # 테이블에서 쿼리를 하고 싶다면 + session.query(DB Model).all() # 모든 값을 가져오겠다 +``` + +# SQLite3 +- 가볍게 사용할 수 있는 데이터베이스. 프러덕션 용도가 아닌 학습용 + + +# 현업에서 더 고려해야 하는 부분 +- Dev, Prod 구분에 따라 어떻게 구현할 것인가? +- Data Input / Output 고려 +- Database => Cloud Database(AWS Aurora, GCP Cloud SQL) +- API 서버 모니터링 +- API 부하 테스트 +- Test Code \ No newline at end of file diff --git a/02-online-serving(fastapi)/projects/web_single/api.py b/02-online-serving(fastapi)/projects/web_single/api.py index 9192b13..91742cc 100644 --- a/02-online-serving(fastapi)/projects/web_single/api.py +++ b/02-online-serving(fastapi)/projects/web_single/api.py @@ -1,43 +1,44 @@ from fastapi import APIRouter, HTTPException, status -from pydantic import BaseModel -from sqlmodel import Session - -from database import PredictionResult, engine +from schemas import PredictionRequest, PredictionResponse from dependencies import get_model +from database import PredictionResult, engine +from sqlmodel import Session, select router = APIRouter() - -class PredictionRequest(BaseModel): - features: list - - -class PredictionResponse(BaseModel): - id: int - result: int - - -# FastAPI 경로 @router.post("/predict") def predict(request: PredictionRequest) -> PredictionResponse: - # 모델 추론 - model = get_model() + # 모델 load + model = get_model() + + # 예측 : 여러분들의 코드 상황에 따라 구현 prediction = int(model.predict([request.features])[0]) - - # 결과를 데이터베이스에 저장 + + # 예측한 결과를 DB에 저장 + # 데이터베이스 객체를 생성. 그 때 prediction을 사용 prediction_result = PredictionResult(result=prediction) with Session(engine) as session: session.add(prediction_result) session.commit() session.refresh(prediction_result) - # 응답 return PredictionResponse(id=prediction_result.id, result=prediction) + # return PredictionResponse + +@router.get("/predict") +def get_predictions() -> list[PredictionResponse]: + with Session(engine) as session: + statement = select(PredictionResult) + prediction_results = session.exec(statement).all() + # prediction_results = session.query(PredictionResult).all() + return [ + PredictionResponse(id=prediction_result.id, result=prediction_result.result) + for prediction_result in prediction_results + ] @router.get("/predict/{id}") -def get_prediction(id: int) -> PredictionResponse: - # 데이터베이스에서 결과를 가져옴 +def get_preidction(id: int) -> PredictionResponse: with Session(engine) as session: prediction_result = session.get(PredictionResult, id) if not prediction_result: @@ -48,13 +49,3 @@ def get_prediction(id: int) -> PredictionResponse: id=prediction_result.id, result=prediction_result.result ) - -@router.get("/predict") -def get_predictions() -> list[PredictionResponse]: - # 데이터베이스에서 결과를 가져옴 - with Session(engine) as session: - prediction_results = session.query(PredictionResult).all() - return [ - PredictionResponse(id=prediction_result.id, result=prediction_result.result) - for prediction_result in prediction_results - ] diff --git a/02-online-serving(fastapi)/projects/web_single/config.py b/02-online-serving(fastapi)/projects/web_single/config.py index 3c88b1f..38f32f8 100644 --- a/02-online-serving(fastapi)/projects/web_single/config.py +++ b/02-online-serving(fastapi)/projects/web_single/config.py @@ -1,10 +1,10 @@ from pydantic import Field from pydantic_settings import BaseSettings - class Config(BaseSettings): db_url: str = Field(default="sqlite:///./db.sqlite3", env="DB_URL") model_path: str = Field(default="model.joblib", env="MODEL_PATH") + app_env: str = Field(default="local", env="APP_ENV") -config = Config() +config = Config() \ No newline at end of file diff --git a/02-online-serving(fastapi)/projects/web_single/database.py b/02-online-serving(fastapi)/projects/web_single/database.py index e7d19c7..5a4697e 100644 --- a/02-online-serving(fastapi)/projects/web_single/database.py +++ b/02-online-serving(fastapi)/projects/web_single/database.py @@ -1,15 +1,12 @@ import datetime -from typing import Optional - -from sqlmodel import Field, SQLModel, create_engine - +from sqlmodel import SQLModel, Field, create_engine from config import config -class PredictionResult(SQLModel, table=True): - id: Optional[int] = Field(default=None, primary_key=True) +class PredictionResult(SQLModel,table=True): + id: int = Field(default=None, primary_key=True) result: int - created_at: Optional[str] = Field(default_factory=datetime.datetime.now) - + created_at: str = Field(default_factory=datetime.datetime.now) + # default_factory : default를 설정. 동적으로 값을 지정. -engine = create_engine(config.db_url) +engine = create_engine(config.db_url) \ No newline at end of file diff --git a/02-online-serving(fastapi)/projects/web_single/dependencies.py b/02-online-serving(fastapi)/projects/web_single/dependencies.py index 1f5c85c..31afd50 100644 --- a/02-online-serving(fastapi)/projects/web_single/dependencies.py +++ b/02-online-serving(fastapi)/projects/web_single/dependencies.py @@ -1,13 +1,11 @@ model = None - def load_model(model_path: str): import joblib - + global model model = joblib.load(model_path) - def get_model(): global model - return model + return model \ No newline at end of file diff --git a/02-online-serving(fastapi)/projects/web_single/main.py b/02-online-serving(fastapi)/projects/web_single/main.py index 851a8fd..4b1e00c 100644 --- a/02-online-serving(fastapi)/projects/web_single/main.py +++ b/02-online-serving(fastapi)/projects/web_single/main.py @@ -1,33 +1,34 @@ -from contextlib import asynccontextmanager - from fastapi import FastAPI +from contextlib import asynccontextmanager from loguru import logger from sqlmodel import SQLModel -from api import router from config import config from database import engine from dependencies import load_model - +from api import router @asynccontextmanager async def lifespan(app: FastAPI): # 데이터베이스 테이블 생성 - logger.info("Creating database tables") + logger.info("Creating database table") SQLModel.metadata.create_all(engine) # 모델 로드 logger.info("Loading model") load_model(config.model_path) - + # model.py에 존재. 역할을 분리해야 할 수도 있음 => 새로운 파일을 만들고, 거기서 load_model 구현 yield - app = FastAPI(lifespan=lifespan) app.include_router(router) +@app.get("/") +def root(): + return "Hello World!" + if __name__ == "__main__": import uvicorn - uvicorn.run(app, host="0.0.0.0", port=8000) + uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) \ No newline at end of file diff --git a/02-online-serving(fastapi)/projects/web_single/requirements.txt b/02-online-serving(fastapi)/projects/web_single/requirements.txt new file mode 100644 index 0000000..11029de --- /dev/null +++ b/02-online-serving(fastapi)/projects/web_single/requirements.txt @@ -0,0 +1,40 @@ +annotated-types==0.6.0 +anyio==3.7.1 +certifi==2023.11.17 +click==8.1.7 +dnspython==2.4.2 +email-validator==2.1.0.post1 +exceptiongroup==1.2.0 +fastapi==0.105.0 +h11==0.14.0 +httpcore==1.0.2 +httptools==0.6.1 +httpx==0.25.2 +idna==3.6 +itsdangerous==2.1.2 +Jinja2==3.1.2 +joblib==1.3.2 +loguru==0.7.2 +MarkupSafe==2.1.3 +numpy==1.26.2 +orjson==3.9.10 +pydantic==2.5.2 +pydantic-extra-types==2.2.0 +pydantic-settings==2.1.0 +pydantic_core==2.14.5 +python-dotenv==1.0.0 +python-multipart==0.0.6 +PyYAML==6.0.1 +scikit-learn==1.3.2 +scipy==1.11.4 +sniffio==1.3.0 +SQLAlchemy==2.0.23 +sqlmodel==0.0.14 +starlette==0.27.0 +threadpoolctl==3.2.0 +typing_extensions==4.9.0 +ujson==5.9.0 +uvicorn==0.24.0.post1 +uvloop==0.19.0 +watchfiles==0.21.0 +websockets==12.0 diff --git a/02-online-serving(fastapi)/projects/web_single/schemas.py b/02-online-serving(fastapi)/projects/web_single/schemas.py new file mode 100644 index 0000000..ffd31f2 --- /dev/null +++ b/02-online-serving(fastapi)/projects/web_single/schemas.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel + +class PredictionRequest(BaseModel): + features: list + # input 데이터를 여러분들의 상황에 맞게 작성 + +class PredictionResponse(BaseModel): + id: int + result: int \ No newline at end of file