Skip to content

Commit

Permalink
Section 06 (trainindata#735)
Browse files Browse the repository at this point in the history
  • Loading branch information
ChristopherGS authored May 17, 2021
1 parent 8983db3 commit 7a3df30
Show file tree
Hide file tree
Showing 18 changed files with 438 additions and 2 deletions.
1 change: 0 additions & 1 deletion Procfile

This file was deleted.

1 change: 0 additions & 1 deletion requirements.txt

This file was deleted.

1 change: 1 addition & 0 deletions section-06-model-serving-api/house-prices-api/Procfile
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
web: uvicorn app.main:app --host 0.0.0.0 --port $PORT
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
__version__ = "0.0.2"
49 changes: 49 additions & 0 deletions section-06-model-serving-api/house-prices-api/app/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import json
from typing import Any

import numpy as np
import pandas as pd
from fastapi import APIRouter, HTTPException
from fastapi.encoders import jsonable_encoder
from loguru import logger
from regression_model import __version__ as model_version
from regression_model.predict import make_prediction

from app import __version__, schemas
from app.config import settings

api_router = APIRouter()


@api_router.get("/health", response_model=schemas.Health, status_code=200)
def health() -> dict:
"""
Root Get
"""
health = schemas.Health(
name=settings.PROJECT_NAME, api_version=__version__, model_version=model_version
)

return health.dict()


@api_router.post("/predict", response_model=schemas.PredictionResults, status_code=200)
async def predict(input_data: schemas.MultipleHouseDataInputs) -> Any:
"""
Make house price predictions with the TID regression model
"""

input_df = pd.DataFrame(jsonable_encoder(input_data.inputs))

# Advanced: You can improve performance of your API by rewriting the
# `make prediction` function to be async and using await here.
logger.info(f"Making prediction on inputs: {input_data.inputs}")
results = make_prediction(input_data=input_df.replace({np.nan: None}))

if results["errors"] is not None:
logger.warning(f"Prediction validation error: {results.get('errors')}")
raise HTTPException(status_code=400, detail=json.loads(results["errors"]))

logger.info(f"Prediction results: {results.get('predictions')}")

return results
70 changes: 70 additions & 0 deletions section-06-model-serving-api/house-prices-api/app/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import logging
import sys
from types import FrameType
from typing import List, cast

from loguru import logger
from pydantic import AnyHttpUrl, BaseSettings


class LoggingSettings(BaseSettings):
LOGGING_LEVEL: int = logging.INFO # logging levels are type int


class Settings(BaseSettings):
API_V1_STR: str = "/api/v1"

# Meta
logging: LoggingSettings = LoggingSettings()

# BACKEND_CORS_ORIGINS is a comma-separated list of origins
# e.g: http://localhost,http://localhost:4200,http://localhost:3000
BACKEND_CORS_ORIGINS: List[AnyHttpUrl] = [
"http://localhost:3000", # type: ignore
"http://localhost:8000", # type: ignore
"https://localhost:3000", # type: ignore
"https://localhost:8000", # type: ignore
]

PROJECT_NAME: str = "House Price Prediction API"

class Config:
case_sensitive = True


# See: https://loguru.readthedocs.io/en/stable/overview.html#entirely-compatible-with-standard-logging # noqa
class InterceptHandler(logging.Handler):
def emit(self, record: logging.LogRecord) -> None: # pragma: no cover
# Get corresponding Loguru level if it exists
try:
level = logger.level(record.levelname).name
except ValueError:
level = str(record.levelno)

# Find caller from where originated the logged message
frame, depth = logging.currentframe(), 2
while frame.f_code.co_filename == logging.__file__: # noqa: WPS609
frame = cast(FrameType, frame.f_back)
depth += 1

logger.opt(depth=depth, exception=record.exc_info).log(
level,
record.getMessage(),
)


def setup_app_logging(config: Settings) -> None:
"""Prepare custom logging for our application."""

LOGGERS = ("uvicorn.asgi", "uvicorn.access")
logging.getLogger().handlers = [InterceptHandler()]
for logger_name in LOGGERS:
logging_logger = logging.getLogger(logger_name)
logging_logger.handlers = [InterceptHandler(level=config.logging.LOGGING_LEVEL)]

logger.configure(
handlers=[{"sink": sys.stderr, "level": config.logging.LOGGING_LEVEL}]
)


settings = Settings()
58 changes: 58 additions & 0 deletions section-06-model-serving-api/house-prices-api/app/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
from typing import Any

from fastapi import APIRouter, FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
from loguru import logger

from app.api import api_router
from app.config import settings, setup_app_logging

# setup logging as early as possible
setup_app_logging(config=settings)


app = FastAPI(
title=settings.PROJECT_NAME, openapi_url=f"{settings.API_V1_STR}/openapi.json"
)

root_router = APIRouter()


@root_router.get("/")
def index(request: Request) -> Any:
"""Basic HTML response."""
body = (
"<html>"
"<body style='padding: 10px;'>"
"<h1>Welcome to the API</h1>"
"<div>"
"Check the docs: <a href='/docs'>here</a>"
"</div>"
"</body>"
"</html>"
)

return HTMLResponse(content=body)


app.include_router(api_router, prefix=settings.API_V1_STR)
app.include_router(root_router)

# Set all CORS enabled origins
if settings.BACKEND_CORS_ORIGINS:
app.add_middleware(
CORSMiddleware,
allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)


if __name__ == "__main__":
# Use this for debugging purposes only
logger.warning("Running in development mode. Do not run like this in production.")
import uvicorn

uvicorn.run(app, host="localhost", port=8001, log_level="debug")
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from .health import Health
from .predict import MultipleHouseDataInputs, PredictionResults
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from pydantic import BaseModel


class Health(BaseModel):
name: str
api_version: str
model_version: str
103 changes: 103 additions & 0 deletions section-06-model-serving-api/house-prices-api/app/schemas/predict.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
from typing import Any, List, Optional

from pydantic import BaseModel
from regression_model.processing.validation import HouseDataInputSchema


class PredictionResults(BaseModel):
errors: Optional[Any]
version: str
predictions: Optional[List[float]]


class MultipleHouseDataInputs(BaseModel):
inputs: List[HouseDataInputSchema]

class Config:
schema_extra = {
"example": {
"inputs": [
{
"MSSubClass": 20,
"MSZoning": "RH",
"LotFrontage": 80.0,
"LotArea": 11622,
"Street": "Pave",
"Alley": None,
"LotShape": "Reg",
"LandContour": "Lvl",
"Utilities": "AllPub",
"LotConfig": "Inside",
"LandSlope": "Gtl",
"Neighborhood": "NAmes",
"Condition1": "Feedr",
"Condition2": "Norm",
"BldgType": "1Fam",
"HouseStyle": "1Story",
"OverallQual": 5,
"OverallCond": 6,
"YearBuilt": 1961,
"YearRemodAdd": 1961,
"RoofStyle": "Gable",
"RoofMatl": "CompShg",
"Exterior1st": "VinylSd",
"Exterior2nd": "VinylSd",
"MasVnrType": "None",
"MasVnrArea": 0.0,
"ExterQual": "TA",
"ExterCond": "TA",
"Foundation": "CBlock",
"BsmtQual": "TA",
"BsmtCond": "TA",
"BsmtExposure": "No",
"BsmtFinType1": "Rec",
"BsmtFinSF1": 468.0,
"BsmtFinType2": "LwQ",
"BsmtFinSF2": 144.0,
"BsmtUnfSF": 270.0,
"TotalBsmtSF": 882.0,
"Heating": "GasA",
"HeatingQC": "TA",
"CentralAir": "Y",
"Electrical": "SBrkr",
"FirstFlrSF": 896,
"SecondFlrSF": 0,
"LowQualFinSF": 0,
"GrLivArea": 896,
"BsmtFullBath": 0.0,
"BsmtHalfBath": 0.0,
"FullBath": 1,
"HalfBath": 0,
"BedroomAbvGr": 2,
"KitchenAbvGr": 1,
"KitchenQual": "TA",
"TotRmsAbvGrd": 5,
"Functional": "Typ",
"Fireplaces": 0,
"FireplaceQu": None,
"GarageType": "Attchd",
"GarageYrBlt": 1961.0,
"GarageFinish": "Unf",
"GarageCars": 1.0,
"GarageArea": 730.0,
"GarageQual": "TA",
"GarageCond": "TA",
"PavedDrive": "Y",
"WoodDeckSF": 140,
"OpenPorchSF": 0,
"EnclosedPorch": 0,
"ThreeSsnPortch": 0,
"ScreenPorch": 120,
"PoolArea": 0,
"PoolQC": None,
"Fence": "MnPrv",
"MiscFeature": None,
"MiscVal": 0,
"MoSold": 6,
"YrSold": 2010,
"SaleType": "WD",
"SaleCondition": "Normal",
}
]
}
}
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from typing import Generator

import pandas as pd
import pytest
from fastapi.testclient import TestClient
from regression_model.config.core import config
from regression_model.processing.data_manager import load_dataset

from app.main import app


@pytest.fixture(scope="module")
def test_data() -> pd.DataFrame:
return load_dataset(file_name=config.app_config.test_data_file)


@pytest.fixture()
def client() -> Generator:
with TestClient(app) as _client:
yield _client
app.dependency_overrides = {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import math

import numpy as np
import pandas as pd
from fastapi.testclient import TestClient


def test_make_prediction(client: TestClient, test_data: pd.DataFrame) -> None:
# Given
payload = {
# ensure pydantic plays well with np.nan
"inputs": test_data.replace({np.nan: None}).to_dict(orient="records")
}

# When
response = client.post(
"http://localhost:8001/api/v1/predict",
json=payload,
)

# Then
assert response.status_code == 200
prediction_data = response.json()
assert prediction_data["predictions"]
assert prediction_data["errors"] is None
assert math.isclose(prediction_data["predictions"][0], 113422, rel_tol=100)
4 changes: 4 additions & 0 deletions section-06-model-serving-api/house-prices-api/mypy.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[mypy]
plugins = pydantic.mypy
ignore_missing_imports = True
disallow_untyped_defs = True
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
uvicorn>=0.11.3,<0.12.0
fastapi>=0.64.0,<1.0.0
python-multipart>=0.0.5,<0.1.0
pydantic>=1.8.1,<1.9.0
typing_extensions>=3.7.4,<3.8.0
loguru>=0.5.3,<0.6.0
# We will explain this in the course
tid-regression-model==3.0.1
1 change: 1 addition & 0 deletions section-06-model-serving-api/house-prices-api/runtime.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
python-3.9.5
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-r requirements.txt

# testing requirements
pytest>=6.2.3,<6.3.0
requests>=2.23.0,<2.24.0

# repo maintenance tooling
black==20.8b1
flake8>=3.9.0,<3.10.0
mypy==0.812
isort==5.8.0
Loading

0 comments on commit 7a3df30

Please sign in to comment.