Building an ML API with FastAPI
student_score_model.joblib — but a .joblib file on a laptop isn't a product anyone else can use. This lesson wraps that file in a real, running web API using FastAPI: a Pydantic model defines exactly what input the API expects, the saved pipeline gets loaded once at startup, and a POST /predict endpoint turns a JSON request into a prediction. By the end you'll have a working app.py you can run locally with uvicorn.
What Is FastAPI?
FastAPI is a modern Python web framework built specifically for creating APIs quickly, with a strong focus on correctness and developer experience. It's built on top of Starlette (for the actual web/ASGI handling) and Pydantic (for data validation), which is where its two headline features come from.
async def for I/O-bound work, or plain def — FastAPI runs sync functions in a threadpool automatically, so blocking calls like model.predict() work fine either way./docs (and ReDoc at /redoc) — with zero extra configuration.str, float, int, list), which is also what powers the validation and docs.For serving a scikit-learn model, this combination is a very natural fit: Pydantic describes the exact features a model expects, FastAPI rejects malformed requests before they ever reach model.predict(), and the automatic docs give anyone testing the API a working form to try it from, with no separate tool required.
Installing & a Minimal App Skeleton
FastAPI needs an ASGI server to actually run — uvicorn is the standard choice used throughout this lesson.
pip install fastapi uvicorn
The smallest possible FastAPI app — no model yet, just the framework wiring:
from fastapi import FastAPI app = FastAPI(title="Student Score Predictor API") @app.get("/") def root(): return {"message": "Student Score Predictor API is running"}
uvicorn main:app --reload
Visiting http://127.0.0.1:8000/ in a browser returns the JSON message. Visiting http://127.0.0.1:8000/docs shows the automatically generated, interactive Swagger UI — already working, from four lines of application code.
Pydantic Models for Request Validation
A raw JSON request body has no guaranteed shape on its own — Pydantic's BaseModel fixes that by letting you declare exactly which fields are expected and what type each one must be. FastAPI reads this declaration and automatically validates every incoming request against it.
from pydantic import BaseModel class StudentInput(BaseModel): study_hours: float attendance: float prev_score: float assignments_done: int class PredictionOutput(BaseModel): predicted_score: float confidence: str
These match the exact features the pipeline was trained on in Lesson 21's Section 5: study_hours, attendance, prev_score, and assignments_done. If a client sends a request missing prev_score, or sends attendance as the text "high" instead of a number, FastAPI rejects it automatically with a 422 Unprocessable Entity response — predict() never even runs on bad input.
StudentInput describes what comes IN (the request body); PredictionOutput describes what goes OUT (the response). Keeping them as two distinct BaseModel classes — rather than one shared shape — makes the API's contract explicit and is exactly what FastAPI's response_model= parameter (Section 5) expects.Loading the Model at Startup
The joblib-saved pipeline from Lesson 21 needs to load exactly ONCE, when the application starts — not on every incoming request. Loading it at module level (right after the imports, before any endpoint is defined) achieves this: it runs a single time when uvicorn starts the app, and the resulting object stays in memory for every request afterward.
from fastapi import FastAPI from pydantic import BaseModel import joblib import pandas as pd app = FastAPI(title="Student Score Predictor API") # Loaded ONCE, when the module is imported / the server starts model = joblib.load("student_score_model.joblib")
joblib.load(...) inside def predict(...) would reload the entire pipeline from disk on EVERY single request — needlessly slow, and pointless given the model never changes while the server is running. Loading once at module level is the standard pattern.The /predict Endpoint
With the model loaded and the input/output schemas defined, the endpoint itself is short: accept a validated StudentInput, convert it into the shape the pipeline expects, call .predict(), and return a PredictionOutput.
from fastapi import FastAPI from pydantic import BaseModel import joblib import pandas as pd app = FastAPI(title="Student Score Predictor API") model = joblib.load("student_score_model.joblib") class StudentInput(BaseModel): study_hours: float attendance: float prev_score: float assignments_done: int class PredictionOutput(BaseModel): predicted_score: float confidence: str @app.get("/health") def health(): return {"status": "ok", "model_loaded": model is not None} @app.post("/predict", response_model=PredictionOutput) def predict(student: StudentInput): # Build a one-row DataFrame with the SAME column names used in training features = pd.DataFrame([{ "StudyHours": student.study_hours, "Attendance": student.attendance, "PrevScore": student.prev_score, "AssignmentsDone": student.assignments_done }]) pred = model.predict(features)[0] confidence = "high" if pred > 80 else "medium" if pred > 60 else "low" return PredictionOutput(predicted_score=round(float(pred), 1), confidence=confidence)
PredictionOutput shape before sending it back, AND it documents the exact response shape in the auto-generated /docs page — callers can see precisely what JSON to expect without reading the source code.Running the Server & Testing It
With main.py saved (and student_score_model.joblib from Lesson 21 in the same folder), start the server:
uvicorn main:app --reload
The --reload flag restarts the server automatically whenever the code changes — useful during development, and something to drop for a real production deployment. With the server running, POST a request to /predict using Python's requests library:
import requests payload = { "study_hours": 5.0, "attendance": 88, "prev_score": 70, "assignments_done": 8 } response = requests.post("http://127.0.0.1:8000/predict", json=payload) print(response.json()) # {'predicted_score': 81.4, 'confidence': 'high'}
The exact same request can also be sent straight from the browser at http://127.0.0.1:8000/docs — expand POST /predict, click "Try it out", fill in the form FastAPI generated from StudentInput, and execute it. No separate API-testing tool is required to try the endpoint for the first time.
The Complete API, Start to Finish
Every piece from this lesson combined into one runnable main.py.
from fastapi import FastAPI from pydantic import BaseModel import joblib import pandas as pd # 1. Create the app app = FastAPI(title="Student Score Predictor API", version="1.0") # 2. Load the trained pipeline ONCE, at startup (Lesson 21's saved file) model = joblib.load("student_score_model.joblib") # 3. Define request/response schemas with Pydantic class StudentInput(BaseModel): study_hours: float attendance: float prev_score: float assignments_done: int class PredictionOutput(BaseModel): predicted_score: float confidence: str # 4. Health check endpoint @app.get("/health") def health(): return {"status": "ok", "model_loaded": model is not None} # 5. Prediction endpoint @app.post("/predict", response_model=PredictionOutput) def predict(student: StudentInput): features = pd.DataFrame([{ "StudyHours": student.study_hours, "Attendance": student.attendance, "PrevScore": student.prev_score, "AssignmentsDone": student.assignments_done }]) pred = model.predict(features)[0] confidence = "high" if pred > 80 else "medium" if pred > 60 else "low" return PredictionOutput(predicted_score=round(float(pred), 1), confidence=confidence) # Run with: uvicorn main:app --reload
Use the app, model, StudentInput, and PredictionOutput objects from Section 7 as your starting point for each task below.
Confirm the
/health endpoint from Section 5 returns {"status": "ok", "model_loaded": true} when the model loads successfully. Test it in the browser at /docs.
Create a new endpoint that returns metadata about the loaded model — at minimum its
type(model).__name__ and the list of feature names it expects. This is a common real-world pattern for letting API consumers introspect what they're calling.
Define a new Pydantic model wrapping a
list[StudentInput], accept a batch of students in one request, and return a list of predictions. Think about what should happen if the list is empty.
💡 Show hints if you're stuck
- Task 2:
from sklearn.pipeline import Pipeline— ifmodelis aPipeline,model.named_stepslets you inspect each step. - Task 3:
class BatchInput(BaseModel): students: list[StudentInput], then loop overrequest.studentsinside the endpoint. - Task 3: Consider returning an empty list (not an error) if
request.studentsis empty — a reasonable, simple design choice.