← Back to Projects House Price Prediction API demo
01

Problem

A machine learning model that lives in a Jupyter notebook is not a product — it is a local artifact that only the person who trained it can use. Sharing predictions with another team, integrating the model into an application, or running it in production all require the same thing: an API. The challenge is that building a robust service around a model involves concerns that notebooks never teach: input validation, startup performance, error handling, endpoint design, and schema enforcement. Without those properties, a model that performs well in isolation fails the moment it encounters unexpected inputs or concurrent requests in a real environment. The gap between "I trained a model" and "I deployed a model" is exactly the gap between a data science project and a data science product.


02

Solution

This project wrapped a pre-trained Random Forest regressor in a FastAPI service that validates all 13 house features at the schema layer, loads the model once at startup to avoid per-request overhead, and exposes three REST endpoints: health check, model metadata, and prediction. FastAPI's automatic OpenAPI documentation made the service self-describing from day one without additional work. Pydantic models enforced strict type checking at the boundary, catching malformed inputs before they reached the model and returning structured error responses to callers. The service went through three rounds of reviewer feedback across the sprint, with each iteration tightening the production correctness of the design — from how health check responses were typed to ensuring consistent output schemas on every prediction. The end result was a service that any downstream application could call with confidence, treating the model as infrastructure rather than a notebook artifact.


03

Skills Acquired

Those tools together answer a question worth sitting with: what does it actually take to move a model out of a notebook and into something that works?


04

Deep Dive

Training a machine learning model is one skill. Making it useful — wrapping it in an API, validating inputs before they ever reach the model, documenting endpoints so someone else can actually call it — that's a different skill entirely. And until now, I hadn't fully learned it.

This project changed that. I built a production-ready FastAPI service that loads a trained Random Forest regressor at startup, exposes three REST endpoints, validates all 13 house features through Pydantic schemas, and returns structured JSON predictions with model metadata. By the time the last line was reviewed, I had gone from writing Python scripts to thinking in services.

The gap between "I trained a model" and "I deployed a model" is exactly the gap between a data science project and a data science product. This sprint was where I crossed that line.

Why This Project?

This was Sprint 11 of my TripleTen AI and Machine Learning Bootcamp, focused on model deployment and API development. The assignment: wrap a pre-trained Random Forest regressor in a FastAPI application that could accept house feature data, validate it, run inference, and return a structured prediction response �� all with proper error handling and health monitoring.

I chose to treat this as a real engineering exercise, not a box to check. Every decision — from loading the model once at startup instead of on every request, to returning Pydantic models from check_health() instead of raw dictionaries — was made with production correctness in mind. The reviewer feedback across three iterations confirmed exactly where those habits were forming.


What You'll Learn from This


Key Takeaways


The Service

The application predicts US apartment prices based on 13 property features: listing images, bedrooms, bathrooms, area, latitude, longitude, and seven binary amenity flags (garden, garage, new construction, pool, terrace, air conditioning, parking). The pre-trained Random Forest regressor and its metadata are loaded from disk; the API wraps them in a clean, documented HTTP interface.

EndpointMethodPurpose
/healthGETCheck if model and metadata are loaded
/model/infoGETReturn model type, version, features, RMSE, training date
/predictPOSTAccept 13 features, return predicted price in USD
/docsGETAuto-generated Swagger UI (FastAPI built-in)
FastAPI auto-generates interactive documentation at /docs from the Pydantic schemas — you get a testable API explorer for free the moment you define your request and response models correctly.

How I Built It

File 1

schemas.py — Defining the Contract

Everything starts with the schema layer. Before writing a single endpoint, I defined exactly what the API accepts and what it returns. HousePredictionRequest uses Pydantic's Field() to validate all 13 inputs with range constraints and descriptions — binary amenity flags are bounded to [0, 1], area must be positive, coordinates must fall within valid lat/lng ranges.

from pydantic import BaseModel, Field
from datetime import datetime

class HousePredictionRequest(BaseModel):
    total_images:      int   = Field(..., ge=0,    le=50)
    beds:              int   = Field(..., ge=0,    le=10)
    baths:             float = Field(..., ge=0,    le=10)
    area:              float = Field(..., gt=0,    le=10000)
    latitude:          float = Field(..., ge=-90,  le=90)
    longitude:         float = Field(..., ge=-180, le=180)
    garden:            int   = Field(..., ge=0,    le=1)
    garage:            int   = Field(..., ge=0,    le=1)
    new_construction:  int   = Field(..., ge=0,    le=1)
    pool:              int   = Field(..., ge=0,    le=1)
    terrace:           int   = Field(..., ge=0,    le=1)
    air_conditioning:  int   = Field(..., ge=0,    le=1)
    parking:           int   = Field(..., ge=0,    le=1)

class PredictionResponse(BaseModel):
    predicted_price: float = Field(description="Predicted price in USD")
    currency:        str   = Field(default="USD")   # default avoids repetition
    model_version:   str   = Field(description="Model version string")

class HealthCheckResponse(BaseModel):
    status:          str  = Field(description="healthy or unhealthy")
    model_loaded:    bool
    message:         str

One iteration note from review: the currency field originally lacked a default value of "USD". Adding Field(default="USD") made it explicit and consistent — callers don't need to supply it unless they want a different currency.

File 2

api.py — The Inference Engine

The core logic lives here: loading the model from disk, extracting features in the right order, running inference, and building health/info responses. The model loads once using joblib.load() with full exception handling; metadata loads from a companion JSON file.

import joblib, numpy as np, json

model    = None
metadata = None

def load_model_and_metadata():
    global model, metadata
    try:
        model    = joblib.load('model.pkl')
        with open('model_metadata.json') as f:
            metadata = json.load(f)
        return True
    except Exception as e:
        print(f"Error loading model: {e}")
        return False

def make_prediction(house_features):
    # Extract features in training order — ordering matters for the model
    feature_values = [house_features[f] for f in metadata['features']]
    X = np.array(feature_values).reshape(1, 13)
    return round(float(model.predict(X)[0]), 2)

The critical detail in make_prediction() is the list comprehension: [house_features[f] for f in metadata['features']]. This guarantees the features arrive in exactly the same column order the model was trained on. Swap two features and you'd get nonsense predictions with no error — a silent, dangerous bug. Using the metadata as the source of truth prevents it.

The check_health() function went through one meaningful revision. Initially it returned a raw dictionary. The final version explicitly constructs and returns a HealthCheckResponse Pydantic model — ensuring schema validation happens at the boundary, not just at the endpoint level.

def check_health():
    model_ok    = model    is not None
    metadata_ok = metadata is not None
    return HealthCheckResponse(
        status         = "healthy" if model_ok and metadata_ok else "unhealthy",
        model_loaded   = model_ok,
        metadata_loaded= metadata_ok,
        message        = "Both model and metadata are loaded"
                         if model_ok and metadata_ok
                         else "Model or metadata not loaded"
    )

File 3

main.py — Wiring It Together

FastAPI's application layer connects the schemas to the inference logic. Three things matter most here: the startup event, the 503 error handling, and clean version extraction.

from fastapi import FastAPI, HTTPException

app = FastAPI(title="House Price Prediction API", version="1.0.0")

# Load once at startup — never on each request
@app.on_event("startup")
async def startup_event():
    success = load_model_and_metadata()
    if not success:
        print("WARNING: Failed to load model at startup")

@app.post("/predict", response_model=PredictionResponse)
async def predict(request: HousePredictionRequest):
    try:
        features_dict   = request.dict()
        predicted_price = make_prediction(features_dict)
        model_version   = get_model_info()["version"]  # extract only what's needed
        return PredictionResponse(
            predicted_price=predicted_price,
            currency="USD",
            model_version=model_version
        )
    except ValueError as e:
        raise HTTPException(status_code=503, detail=str(e))
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Prediction failed: {e}")

One reviewer flag worth calling out: the initial version of the predict endpoint assigned model_version = get_model_info() — the entire metadata dictionary — then passed it into PredictionResponse. The fix was a one-line change: get_model_info()["version"]. Small, but it reflects a broader principle — pass only what you need, not everything that's available.


Three Rounds of Reviewer Feedback

Each file went through two rounds of review — an initial submission and a revision pass. The feedback wasn't about correctness (the logic worked from the start). It was about consistency, clarity, and production-readiness.

FileInitial FlagResolution
api.py check_health() returned a raw dict Returns HealthCheckResponse model ✓
main.py model_version assigned full metadata dict Extracts only ["version"]
schemas.py currency field had no default value Field(default="USD") added ✓
All three final revisions were given full marks by the reviewer. What stands out: none of the original versions were broken — they all worked. The feedback was about raising the standard from "functional" to "consistent with how production APIs are designed."

What I Learned & Why It Matters to Employers

Before this sprint, I thought deployment meant "running the model somewhere." After it, I understand that deployment is a design problem — it's about contracts, boundaries, and what happens when things go wrong. I learned to define input schemas before writing endpoints, to think about startup cost versus per-request cost, and to return typed models instead of raw dictionaries so the API surface is always verifiable. The reviewer feedback sharpened something real: the gap between code that works in isolation and code that behaves correctly in a system. That gap is where production engineering lives, and closing it is what this sprint was actually about.

Conclusion & Reflections

The biggest shift in this project wasn't technical — it was conceptual. I stopped thinking in terms of notebooks and started thinking in terms of services. A notebook has no contract. A service does. Every input must be validated. Every output must conform to a schema. Every failure mode must return a meaningful response. Getting that right required understanding why each piece existed, not just how to make it run.

FastAPI made a lot of this natural. The framework enforces schema thinking — you have to define your request and response models to get the routing to work at all. Pydantic handles validation automatically. The startup event pattern keeps expensive operations out of the hot path. These aren't just conveniences; they're guardrails that push you toward building correctly by default.

If I deployed this service today, a client could send a POST to /predict with 13 house features and get back a JSON object with the predicted price, the currency, and the model version — all validated, all typed, all documented in the auto-generated Swagger UI. That's a real thing. That's not a notebook anymore.

Project RequirementStatus
Model loaded at startup, not per requestYES — @app.on_event("startup")
All 3 endpoints implemented with typed responsesYES — /health, /model/info, /predict
Feature ordering preserved from trainingYES — via metadata['features']
Input validation on all 13 featuresYES — Pydantic Field() constraints ✓
503 returned when model not loadedYES — HTTPException(status_code=503)
All reviewer flags resolvedYES — 3/3 revisions accepted ✓

Want to Explore the Full Code?

All three files — api.py, main.py, schemas.py — with the complete implementation are on GitHub.