Concepts 🚀 Project 🧮 Labs
🚀 Learn with Project / Stage 8 — First FastAPI route — POST /predict
🚀 Stage 8 of 13
← Previous — Stage 7
Try a better model — RandomForest
←

First FastAPI route — POST /predict

Load model.pkl once at startup. Expose POST /predict that takes JSON input and returns a predicted score.

🧠 The intuition Finally — FastAPI enters. The route is shockingly small: 1. Load model.pkl ONCE when the app starts (don't load it per request — slow!) 2. Define a Pydantic model for the input JSON (StudentInput) 3. POST /predict receives StudentInput, builds a DataFrame, calls model.predict 4. Return the predicted score as JSON This is the moment the ML model becomes a SERVICE. Anyone with curl can now get a prediction.
✏️ Tiny task — write this yourself Run the FastAPI app below and test it with curl. python app/main.py Then in another terminal: curl -X POST http://localhost:8000/predict \ -H 'Content-Type: application/json' \ -d '{"study_hours":6, "previous_score":72, "attendance":85, "assignments_completed":8, "sleep_hours":7, "extracurricular_hours":2, "parental_support":3}' You should get back: {"predicted_score": 76.4}
✅ Check — what you should see If you see {"predicted_score": 76.4} (or similar), the whole ML pipeline is now reachable over HTTP. The model trained offline is now serving real-time predictions. That's the magic moment.

🧮 Code files for this stage

📄 app/main.py python
from fastapi import FastAPI
from pydantic import BaseModel, Field
import joblib
import pandas as pd
import uvicorn

app = FastAPI(title='Student Performance Predictor')

# ── Load model ONCE at startup ──────────────────────────
# Don't load per request — would be 100x slower.
model = joblib.load('app/ml/model.pkl')


# ── Pydantic models (request + response) ───────────────
class StudentInput(BaseModel):
    study_hours: float = Field(..., ge=0, le=24)
    previous_score: float = Field(..., ge=0, le=100)
    attendance: float = Field(..., ge=0, le=100)
    assignments_completed: int = Field(..., ge=0, le=20)
    sleep_hours: float = Field(..., ge=0, le=24)
    extracurricular_hours: float = Field(..., ge=0, le=10)
    parental_support: int = Field(..., ge=1, le=5)


class PredictResponse(BaseModel):
    predicted_score: float
    performance_band: str  # 'Poor' / 'OK' / 'Good' / 'Excellent'


# ── Route: POST /predict ────────────────────────────────
@app.post('/predict', response_model=PredictResponse)
async def predict(student: StudentInput):
    # Pydantic already validated the input. Build a 1-row DataFrame
    # with the EXACT same column order as training.
    X_new = pd.DataFrame([{
        'study_hours': student.study_hours,
        'previous_score': student.previous_score,
        'attendance': student.attendance,
        'assignments_completed': student.assignments_completed,
        'sleep_hours': student.sleep_hours,
        'extracurricular_hours': student.extracurricular_hours,
        'parental_support': student.parental_support,
    }])
    score = float(model.predict(X_new)[0])

    # Bucket into a performance band for the UI
    if score < 50:    band = 'Poor'
    elif score < 70:  band = 'OK'
    elif score < 85:  band = 'Good'
    else:             band = 'Excellent'

    return PredictResponse(predicted_score=round(score, 1),
                           performance_band=band)


if __name__ == '__main__':
    uvicorn.run(app, host='0.0.0.0', port=8000)
📄 test_predict.sh bash
# Start the server
python app/main.py &

# Test with curl
curl -X POST http://localhost:8000/predict \
  -H 'Content-Type: application/json' \
  -d '{
    "study_hours": 6,
    "previous_score": 72,
    "attendance": 85,
    "assignments_completed": 8,
    "sleep_hours": 7,
    "extracurricular_hours": 2,
    "parental_support": 3
  }'

# Response:
# {"predicted_score":76.4,"performance_band":"Good"}

# Try invalid input — FastAPI returns 422 automatically:
curl -X POST http://localhost:8000/predict \
  -H 'Content-Type: application/json' \
  -d '{"study_hours": 999}'
# -> 422 Unprocessable Entity (study_hours must be <= 24)

How it works

Three big ideas in this route: (1) LOAD MODEL ONCE — `model = joblib.load(...)` runs at module import time, not inside the route. If you loaded it per request, every prediction would take 500ms instead of 5ms. (2) Pydantic input — StudentInput validates input. If a user sends study_hours=999, FastAPI returns 422 automatically — your route code never runs. You never write validation code; Pydantic does it. (3) response_model=PredictResponse — FastAPI serializes the return value AND validates the output shape. If your route returns a NaN, FastAPI catches it instead of sending 'NaN' as JSON (which is invalid JSON and breaks the client).

🚧 Routes — what's wired up at this stage ROUTES INTRODUCED HERE: POST /predict -> takes StudentInput JSON, returns PredictResponse JSON Only ONE route so far. We'll add GET / (home page with form) in Stage 9, and the form will POST to this same /predict endpoint. response_model needed here
🧮 Is a response model necessary here? YES — declare a Pydantic response_model

Yes — and here's why. Pydantic response models do THREE jobs at once: 1. VALIDATION of the output -> if your model returns a NaN, FastAPI catches it 2. DOCUMENTATION -> /docs shows the exact shape of the response 3. SERIALIZATION -> Python float -> JSON number automatically Without a response model, FastAPI would still return JSON — but you'd lose validation and docs. For a toy project, you can skip it. For anything real, always declare a response_model. It's 2 lines of code and pays for itself the first time a bug returns the wrong shape.

⚠️ Common mistakes to avoid
  • Loading the model inside the route — every request reloads the .pkl, 100x slower.
  • Not using response_model — you lose validation, lose /docs, and risk returning NaN as JSON.
  • Building the input DataFrame with columns in a different ORDER than training — sklearn will silently give wrong predictions. Always match training column order exactly.
👀 What's next Next: Stage 9 adds the HTML form — Jinja2 templates that let a student type their info and click submit. Same /predict endpoint, now reachable from a browser.
Next — Stage 9
Add Jinja2 templates — the form page
→