Project — APIs & Model Serving
Serve the Adult / Census Income model behind a schema-validated FastAPI /predict
endpoint that survives real traffic, plus a batch path that scores many records in one
call. The service loads the model once at startup, validates every request against a
pydantic schema that excludes any leakage feature, returns a typed response, survives
the model being unavailable with a fast error instead of a hang, and documents the latency
budget you actually measured.
| Dataset | Adult / Census Income — the same dataset and repo you carry through every module’s project (the lessons serve the Lending Club and KKBox models; here you stand up a real serving layer yourself) |
| Start from | Fork dutchengineer-org/phase2-starter — ships train.py and the frozen package (run python train.py to produce model/model.joblib, the artifact you serve), so you are never blocked on your own training code. Already forked it in M5? Keep building in that fork — one Phase 2 repo carries M5 through M9 |
| Aim for | The package matches the ml-pipeline-starter reference — its TrainedModel.load is exactly what your /predict calls; you load that artifact, you do not retrain it or change the package. No version bump: a caller consuming an API is exactly who the version is a promise to — you depend on it, you do not author it, so bumping it is not yours to do here |
| Done when | It passes every line of the rubric below, then you push to GitHub |
Serve the Adult / Census Income model behind a schema-validated FastAPI /predict
endpoint that survives real traffic, plus a batch path that scores many records in one
call. The service loads the model once at startup, validates every request against a
pydantic schema that excludes any leakage feature, returns a typed response, survives
the model being unavailable with a fast error instead of a hang, and documents the latency
budget you actually measured.
| Dataset | Adult / Census Income — the same dataset and repo you carry through every module’s project (the lessons serve the Lending Club and KKBox models; here you stand up a real serving layer yourself) |
| Start from | Fork dutchengineer-org/phase2-starter — ships train.py and the frozen package (run python train.py to produce model/model.joblib, the artifact you serve), so you are never blocked on your own training code. Already forked it in M5? Keep building in that fork — one Phase 2 repo carries M5 through M9 |
| Aim for | The package matches the ml-pipeline-starter reference — its TrainedModel.load is exactly what your /predict calls; you load that artifact, you do not retrain it or change the package. No version bump: a caller consuming an API is exactly who the version is a promise to — you depend on it, you do not author it, so bumping it is not yours to do here |
| Done when | It passes every line of the rubric below, then you push to GitHub |
Start here
- Continue in your Phase 2 fork →
dutchengineer-org/phase2-starter(or keep building in the one you forked in M5), thenuvicorn census_pipeline.serve:app --reload(the starter scaffolds the app atsrc/census_pipeline/serve.py) - Work the tasks below in order — each maps to a lesson you just finished, run against your own machine with
curl - Check yourself against the rubric, then push and submit
By the end, another engineer should be able to clone your repo, run uvicorn, curl your
/predict endpoint with one record or a batch, get a clean prediction, and, when they send
garbage or kill the model, get a clear error instead of a wedged service.
The tasks
Do these in order; each maps to a lesson you just finished. Run everything against your own
machine: uvicorn census_pipeline.serve:app --reload (the starter scaffolds the app at
src/census_pipeline/serve.py), then curl the routes.
1. Get a working /predict endpoint running (from Lesson 1)
- Build a FastAPI app with a
/predictroute that takes a record, runs the model from the Phase 2 starter, and returns a prediction, plus a/healthroute. uvicornserves it locally and acurl -X POSTagainst/predictwith one valid Adult record returns a prediction end to end on the happy path.
2. Make model loading survive real traffic (from Lesson 2)
- Load the model once at startup (module level / lifespan), not inside the request handler, so the disk-read-and-deserialize cost is paid once, not per call.
- The handler reuses the already-loaded model object; nothing in the per-request path re-reads the artifact from disk.
3. Make the endpoint reject bad input (from Lesson 3)
- Add a pydantic request schema with correct types so a malformed field (e.g. a string
where a number is expected) is rejected at the boundary with a clear
422, not a cryptic 500 from inside the model. - The request schema excludes every leakage feature: any field that would only be known after the income outcome it is predicting must not be a field on the schema, so no caller can ever send one. Audit every column, not one name.
4. Add a batch path and measure what it costs (from Lesson 4)
- Add a batch route that scores a list of records in one request and returns predictions in the same order and length as the input.
- Add the serving-contract shape test (the M2 contract, at the serving layer): assert that the single-row path and the batch path produce aligned output for the same records.
- Measure end-to-end latency at the boundary (not just
model.predict) for single-row and batch, and record the numbers.
5. Survive real traffic: tail latency, degradation, scale (from Lesson 5)
- Make the service survive the model being unavailable: a hard per-request timeout and a
health check that reflects the model’s real state, so a dead or hanging model returns a fast,
typed
503instead of holding the connection open. - Document a latency budget in p50/p99 terms you actually measured under load, and note where the single box stops scaling (the saturated resource).
Hints
- The fastest way to catch a per-request load: hit
/predictonce, then fire a burst of concurrent calls. If the first is fast and the burst cliffs, thejoblib.load()is on the per-request path: hoist it to startup. - For the leakage audit, write down the prediction moment first (“the instant we score a
person’s record”), then for each column ask “is this knowable at that instant?” Anything that
is only knowable after the outcome is a leakage field: leave it off the schema and prove it
with an assertion over your schema’s
model_fields. - Measure latency at the boundary with the client, not inside the handler: time the
curl/ client round trip so the number includes validation, JSON encode/decode, and framework overhead, which is most of a small model’s response time.
Rubric — your project is done when
This is the standard the module holds you to (each bar maps to the lesson that taught it):
/predictruns and serves the model —uvicornserves it; acurlPOST with a valid record returns a prediction end to end. (Lesson 1)- Model loaded once at startup — the model is loaded at startup and reused; nothing re-reads the artifact on the per-request path. (Lesson 2)
- Rejects malformed input with a clear error — a bad field is rejected at the boundary with
a clear
422/400, not an opaque 500 from inside the model. (Lesson 3) - Request schema excludes every leakage feature — no post-outcome field is on the request schema, proven by a test over the schema’s fields. (Lesson 3)
- Batch path preserves order, aligned with single-row — the batch route returns predictions in input order and length, and the serving-contract shape test proves single-row and batch produce aligned output. (Lesson 4)
- Survives the model-unavailable case — a dead or hanging model yields a fast, typed
503, never an open-ended hang. (Lesson 5) - Documents its latency budget — a measured p50/p99 budget for single-row and batch, with the scaling ceiling noted. (Lessons 4, 5)
Run the self-audit against your own running service before you submit: for each rubric line,
exercise it with curl (send garbage, send a leakage field, kill the model, fire a burst),
and if one fails, name which lesson’s failure mode you reintroduced.
Submit
Use the branch workflow from the M1 git lesson, not commits straight to main. Branch
off main (git checkout -b m6-prediction-api), build this module’s piece there, and open a
pull request to merge it back once it meets the rubric. main stays the last-good version
of the product you carry forward, so a half-finished module never breaks what later
modules build on.
When your repo meets every rubric line, merge your branch to main, push it to GitHub,
and submit the repository URL here. (Submission coming soon.)
Coming soon
This lesson is not published yet. Join the waitlist to hear when it ships.
Coming soon