A Northstar késői kézbesítés prediktor eddig batch feldolgozásban dolgozott: a Batch inference és idempotens output alfejezetben adatverzióhoz és modellhez kötött, idempotens batch outputot állítottunk elő. Most ugyanazt a predikciós szerződést online kérés-válasz formában tesszük elérhetővé. A szolgáltatás a rendelés pillanatában ismert jellemzőket fogadja, és visszaadja a modell által becsült valószínűséget — azaz annak a modell szerinti becslését, hogy a kézbesítés 7 napnál későbbi lesz.
Megfigyelési egység: egy rendelés. A bemenet címkéje kizárt mezőket
nem tartalmazhat: delivery_days,
actual_delivery_at, label_available_at vagy
bármi, amit a rendelés pillanatában nem ismernénk. A kimenet a prediktív
valószínűség, a modell verziója és egy futásazonosító.
A konkrét bemenet, amelyet a fejezet további része használ:
{"order_id":"ORD-1","basket_minor":3500,"quantity_sum":2,
"distance_km":12.5,"priority_flag":0,"account_age_days":42,
"segment":"consumer","region":"central","acquisition_channel":"search"}Előzetes becslés: ez az input a dummy modell logisztikus
regressziójának kimenete, tehát 0 és 1 közötti valószínűség, nem
konfidenciaintervallum. Helyes ellenőrzés helyi loopbacken: hiányzó
mezőre 422, érvényes kérésre 200, a predikció
0–1 közé esik, ugyanaz a bemenet ugyanazt a valószínűséget adja akkor
is, ha a prediction_id minden hívásnál új.
A FastAPI a request validation (bemeneti
séma-ellenőrzés) feladatot Pydantic modellekre bízza. A modell
mezőnevei, típusai és korlátjai együtt jelentik a predikciós szerződést.
A nyers JSON-ból a Pydantic a megadott típusú Python-objektumot állítja
elő; ha a mező hiányzik, nem konvertálható a várt típusra vagy a
korláton kívül esik, a keretrendszer rendszerint
422 Unprocessable Entity státusszal, a hibás mezőt listázó
választ ad. A Pydantic Field korlátai az érvényes
tartományt rögzítik: basket_minor nem lehet negatív,
quantity_sum legalább 1, distance_km nem lehet
negatív, priority_flag csak 0 vagy 1, a kategóriák pedig a
szerződésben felsorolt értékek.
A szolgáltatás indításakor a startup load tölti be a
modellt. A modellt nem kérésenként olvassuk újra: a startup hiba esetén
a folyamat ne induljon el. A FastAPI modern ajánlott eszköze a
lifespan kontextuskezelő; a korábbi
@app.on_event("startup") stílus elavult. A betöltés után a
modellt az app.state objektumban tároljuk.
A request és response sémája külön
Pydantic osztály. A kérés a nyers feature-ket tartalmazza az elemzési
szemcsézettségnek megfelelően; a válasz nem a modell belső
számábrázolását, hanem a döntési cél számára értelmezhető adatot. A
válasz valószínűségi mezőjének shape-ja akkor is skaláris, ha
predict_proba kimenete (1, 2) alakú: a pozitív
oszlopot vesszük.
Error: három hibaszintet különítsünk el. A Pydantic
422 a bemeneti szerződés megsértése; a 503 azt
jelenti, hogy a modell nincs betöltve, tehát a szolgáltatás nem tud
tudományos választ adni; az 500 a nem várt belső hiba,
amelyet nem a kliensnek kell megjavítania. Saját try-ban
történő általános elfogás helyett a bemeneti hibát hagyjuk a
keretrendszerre; a betöltöttséget explicit feltétellel kezeljük.
Latency: a predikció futási ideje működési mérőszám,
nem a válasz része. A kódban logoljuk a belső predikciós időt
time.perf_counter() segítségével. A fejlesztői szerver
által logolt kérésidő nem termelési kapacitásmérés: egyetlen worker,
loopback hálózat és nincs versengő terhelés. A Streamlit mint ellenőrizhető bemutató alfejezetben
terhelési vizsgálatot adunk; ebben a leckében a belső predikciós idő
csak szemléltető.
Concurrency: a predikció CPU-kötött. A FastAPI a
def végpontokat külön threadpoolban futtatja, az
async def végpontokat viszont az eseményhurokban; egy
hosszú CPU- vagy modellszámítás az async def-ben blokkolná
a többi kérést. Kis CPU-s modellnél a szinkron def a
természetes választás. A betöltött modell objektumot több szál közösen
olvassa, de nem módosítja; ha egy becslő vagy transformer mégsem
garantálja a szálonkénti tisztaságot, zárat vagy folyamat-alapú workert
kell bevezetni, és azt külön méréssel igazolni.
A példa a lab könyvtárban egy
fastapi_service mappát használ. A
make_dummy_model.py egy kis, teljesen determinisztikus
modellt készít, a app.py pedig a szolgáltatást. A model
artifact és a meta együtt alkotja az Adatverzió és
splitmanifest alfejezetben tanult szerződést: a verziót a manifestbe
írjuk, nem a fájlnévből következtetjük.
Először a dummy modell:
# fastapi_service/make_dummy_model.py
import json
import joblib
import pandas as pd
from sklearn.linear_model import LogisticRegression
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
FEATURE_COLUMNS = [
"basket_minor", "quantity_sum", "distance_km", "priority_flag",
"account_age_days", "segment", "region", "acquisition_channel",
]
NUMERIC = ["basket_minor", "quantity_sum", "distance_km",
"priority_flag", "account_age_days"]
CATEGORICAL = ["segment", "region", "acquisition_channel"]
X = pd.DataFrame({
"basket_minor": [1000, 4000, 2500, 9000, 6000, 1200],
"quantity_sum": [1, 4, 2, 5, 3, 1],
"distance_km": [5.0, 30.0, 12.0, 80.0, 45.0, 7.0],
"priority_flag": [1, 0, 1, 0, 0, 1],
"account_age_days": [120, 3, 40, 400, 2, 90],
"segment": ["consumer", "business", "consumer",
"business", "consumer", "business"],
"region": ["central", "north", "south", "north", "central", "south"],
"acquisition_channel": ["search", "referral", "direct",
"search", "referral", "direct"],
})
y = [0, 1, 0, 1, 1, 0]
prep = ColumnTransformer([
("num", StandardScaler(), NUMERIC),
("cat", OneHotEncoder(handle_unknown="ignore"), CATEGORICAL),
])
model = Pipeline([
("prep", prep),
("clf", LogisticRegression(max_iter=1000)),
])
model.fit(X, y)
MODEL_PATH = "fastapi_service/model_late_dummy.joblib"
META_PATH = "fastapi_service/model_meta.json"
joblib.dump(model, MODEL_PATH)
with open(META_PATH, "w", encoding="utf-8") as f:
json.dump({
"model_version": "dummy-2026-10-20",
"feature_columns": FEATURE_COLUMNS,
}, f)
print("dummy modell és meta előállítva:", MODEL_PATH, META_PATH)A szolgáltatás:
# fastapi_service/app.py
import json
import time
import uuid
import logging
from contextlib import asynccontextmanager
from typing import Literal
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
import joblib
import pandas as pd
MODEL_PATH = "fastapi_service/model_late_dummy.joblib"
META_PATH = "fastapi_service/model_meta.json"
FEATURE_COLUMNS = [
"basket_minor", "quantity_sum", "distance_km", "priority_flag",
"account_age_days", "segment", "region", "acquisition_channel",
]
logger = logging.getLogger("late_api")
if not logger.handlers:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
class LateDeliveryRequest(BaseModel):
order_id: str = Field(min_length=1, max_length=64)
basket_minor: int = Field(ge=0)
quantity_sum: int = Field(ge=1)
distance_km: float = Field(ge=0)
priority_flag: int = Field(ge=0, le=1)
account_age_days: int = Field(ge=0)
segment: Literal["consumer", "business"]
region: Literal["north", "central", "south"]
acquisition_channel: Literal["search", "referral", "direct"]
class LateDeliveryResponse(BaseModel):
order_id: str
predicted_probability: float = Field(ge=0, le=1)
model_version: str
prediction_id: str
@asynccontextmanager
async def lifespan(app: FastAPI):
try:
model = joblib.load(MODEL_PATH)
with open(META_PATH, encoding="utf-8") as f:
meta = json.load(f)
except FileNotFoundError as exc:
raise RuntimeError(f"modell vagy meta hiányzik: {MODEL_PATH}, {META_PATH}") from exc
expected = meta["feature_columns"]
if list(FEATURE_COLUMNS) != list(expected):
raise RuntimeError(
f"feature-szerződés eltérés: a kód {FEATURE_COLUMNS}, "
f"a modell meta szerint {expected}"
)
app.state.model = model
app.state.model_version = meta["model_version"]
logger.info("modell betöltve: %s", app.state.model_version)
yield
app.state.model = None
app = FastAPI(title="Northstar late-delivery predictor", lifespan=lifespan)
@app.get("/health")
def health():
if getattr(app.state, "model", None) is None:
raise HTTPException(status_code=503, detail="model not loaded")
return {"status": "ok", "model_version": app.state.model_version}
@app.post("/predict", response_model=LateDeliveryResponse)
def predict(req: LateDeliveryRequest):
if getattr(app.state, "model", None) is None:
raise HTTPException(status_code=503, detail="model not loaded")
t0 = time.perf_counter()
row = pd.DataFrame([{col: getattr(req, col) for col in FEATURE_COLUMNS}])
proba = app.state.model.predict_proba(row)
prob = min(max(float(proba[0, 1]), 0.0), 1.0)
elapsed_ms = (time.perf_counter() - t0) * 1000
logger.info("order=%s prob=%.4f predict_ms=%.2f",
req.order_id, prob, elapsed_ms)
return LateDeliveryResponse(
order_id=req.order_id,
predicted_probability=prob,
model_version=app.state.model_version,
prediction_id=uuid.uuid4().hex,
)Futtatás Linuxon vagy macOS-en, a könyv <lab>
könyvtárából:
# Linux/macOS, munkakönyvtár: <lab>
uv add "fastapi[standard]"
uv lock
uv sync --locked
uv run python fastapi_service/make_dummy_model.py
uv run uvicorn fastapi_service.app:app --host 127.0.0.1 --port 8000Windows PowerShellben ugyanez a négy uv parancs; a
kéréshez curl.exe vagy Invoke-RestMethod
használható.
Érvényes kérés:
curl -s http://127.0.0.1:8000/predict \
-H 'Content-Type: application/json' \
-d '{"order_id":"ORD-1","basket_minor":3500,"quantity_sum":2,
"distance_km":12.5,"priority_flag":0,"account_age_days":42,
"segment":"consumer","region":"central","acquisition_channel":"search"}'A válasz szerkezete szemléltető, mivel a fejezet írásakor nem futtattuk a modellt:
{"order_id":"ORD-1","predicted_probability":0.31,
"model_version":"dummy-2026-10-20","prediction_id":"... futtatásonként új ..."}Hiányzó mezővel a kérés csak order_id-t és
distance_km-et küld. A 422 válaszból a fontos
rész: missing típusú hiba, a hiányzó mezők nevével; a
200 tehát elmarad. Negatív basket_minor vagy
ismeretlen region is 422-t ad.
Ha a példát helyben futtatjuk, az alábbi ellenőrző invariantoknak kell teljesülniük:
make_dummy_model.py létrehozza a
model_late_dummy.joblib és model_meta.json
fájlokat; a meta feature_columns tartalma pontosan a nyolc
szerződött oszlop./health indítás után 200-at ad, a
modellverzió dummy-2026-10-20.predicted_probability értéke
0 és 1 közötti float, a model_version a
metából jön, a prediction_id 32 karakter hosszú
hexadecimális string.predicted_probability azonos, a prediction_id
nem; a kettő más-más szerepű.(1, 8) alakú; a
predict_proba kimenete (1, 2), a
[0, 1] az egyes osztály valószínűsége.feature-szerződés eltérés üzenettel megáll,
ha a meta oszlopsorrendjét és a kód FEATURE_COLUMNS értékét
utólag eltérítjük; a helyes javítás ilyenkor a modell újragenerálása a
hiteles szerződéssel, nem a meta csendes átírása.1. Értelmezés. Az alábbi három
POST /predict kérés közül melyik kap 422-t, és
melyik 200-at? Indokolja a Pydantic szabályok alapján.
{"order_id":"A","basket_minor":0,"quantity_sum":1,
"distance_km":0.0,"priority_flag":1,"account_age_days":0,
"segment":"business","region":"north","acquisition_channel":"direct"}
{"order_id":"B","basket_minor":2500,"quantity_sum":0,
"distance_km":5.0,"priority_flag":0,"account_age_days":10,
"segment":"consumer","region":"west","acquisition_channel":"search"}
{"order_id":"C","basket_minor":2500,"quantity_sum":2,
"distance_km":-1.0,"priority_flag":0,"account_age_days":10,
"segment":"consumer","region":"central","acquisition_channel":"search"}2. Implementálás. Az app.py
health végpontja jelenleg csak a modell betöltöttségét
jelzi. Egészítse ki úgy, hogy a válaszban szerepeljen a betöltött modell
feature-listájának hossza és a model_version is; a
503 viselkedést hagyja változatlanul. Írja le a teljes új
health függvényt.
3. Hibadiagnózis. A szerver indítása a következő kimenettel megszakad:
RuntimeError: feature-szerződés eltérés: a kód ['basket_minor', ...],
a modell meta szerint ['basket_minor', 'quantity_sum', 'distance_km',
'priority_flag', 'segment', 'region', 'acquisition_channel']
Mi hiányzik a meta listából, és miért nem szabad ilyenkor egyszerűen
hozzáírni a hiányzó szót a model_meta.json-hoz? Adja meg a
helyes javítási sorrendet.
4. Megváltozott feltétel: új régió. A termékcsapat
be akarja vezetni a region="west" értéket. A jelenlegi
szolgáltatás ezt a kérést azonnal 422-vel elutasítja a
Literal miatt. Indokolja, miért helytelen megoldás csupán a
Pydantic sémát kiegészíteni "west"-tel, és mi a teljes
predikciós szerződés szerinti teendő, ha az új régióra is érvényes
becslést akarunk adni. Térjen ki a modell tanító adataira és a Keresztvalidáció a függőségi szerkezethez csoportos
validáció szempontjára.
1. Az első kérés 200-at kap, mert a
nullák itt megengedettek: basket_minor=0,
distance_km=0.0, account_age_days=0 mind a
ge=0 korláton belül van; a kategóriaértékek érvényesek, a
quantity_sum=1 pedig a ge=1-et teljesíti. A
második 422-t kap, mert quantity_sum=0
megsérti a ge=1 korlátot, és region="west" nem
eleme a Literal["north","central","south"] halmaznak; a
Pydantic mindkét hibát egyszerre jelzi. A harmadik szintén
422-t kap, mert distance_km=-1.0 megsérti a
ge=0 korlátot. Ez az ellenőrzés a bemeneti szerződést védi,
nem a modell statisztikai érvényességét.
2. A módosított health:
@app.get("/health")
def health():
model = getattr(app.state, "model", None)
if model is None:
raise HTTPException(status_code=503, detail="model not loaded")
return {
"status": "ok",
"model_version": app.state.model_version,
"feature_count": len(FEATURE_COLUMNS),
}Ellenőrző invariant: indítás után a válasz
{"status":"ok","model_version":"dummy-2026-10-20","feature_count":8}.
Ha a modellt mesterségesen None-ra állítjuk, a
503 ugyanúgy megmarad.
3. A meta listából hiányzik az
account_age_days mező. A hibaüzenet szerint a kód listája
tartalmazza, a mentett meta nem; a meta a modell mentésének pillanatában
érvényes szerződés. Ha kézzel hozzáírnánk a szót, a joblib
modell a tanításkori feature-oszlopok eltérő halmazával maradna, tehát a
szolgáltatás később predict_proba hibát adna vagy rossz
oszlopokra illesztené a bemenetet. A helyes sorrend: keressük meg a
modellt létrehozó kódot, vegyük fel az account_age_days-t a
tanító feature-listába, futtassuk újra a Batch
inference és idempotens output batch lépést, majd mentsük ki újra a
modellt és a metát együtt. Csak ezt követően indítható el a
szolgáltatás.
4. A Pydantic séma bővítése csak a bemeneti kaput
tágítaná; a logisztikus regresszió OneHotEncoder-e a tanító modellből
nem ismeri a west kategóriát. A
handle_unknown="ignore" technikailag nulla-hot vektort
adhatna, de ez nem helyettesíti a modell új tanítását, mert az új régió
ügyfelei más eloszlást mutathatnak, és a tanító mintában sosem
szerepeltek. A predikciós szerződés szerint ha az új régióra érvényes
becslés kell, újratanítás szükséges olyan tanító adattal, amelyben a
west régió megfelelő időbeli és ügyfél-struktúrával
rendelkezik; a Keresztvalidáció a függőségi
szerkezethez szerinti felosztásban a régiókat hordozó
ügyfélcsoportok nem kerülhetnek egy fold mindkét oldalára, ha
ügyfélfüggő az értékelés. Az Literal tágítása csak a modell
és a manifest frissítésével együtt szabad; külön dokumentálni kell a
határértéket, amíg az új kategória nem támogatott.
A fejlesztői szerver 200-as válasza nem igazol termelési
kapacitást vagy a modell jóságát. Egy 127.0.0.1-re küldött, egyetlen
aktív kéréssel mért predict_ms=2.1 nem ugyanaz, mint húsz
párhuzamos kérés p99 késleltetése egy tényleges gépen: a threadpool
mérete, a modellbetöltés és a hálózati várakozás mind változtat rajta.
Ellenpélda: ha predict_proba egyetlen szálon 2 ms-ig fut,
ötven egyidejű sync kérés nem lesz automatikusan 100 ms alatt; a kérések
a threadpoolban sorba állhatnak, és a p99 a belső predikciós idő
többszöröse is lehet. A kapacitásállításhoz terhelési cél, ismétlésszám,
hardver és rögzített percentilis kell; ezt a Streamlit mint ellenőrizhető bemutató vizsgálja. A
422 is csak a bemeneti szerződést ellenőrzi: a
200 mellett is lehet a predikció rosszul kalibrált, ha a
modell a célsokaságtól eltérő adaton tanult.
A FastAPI-végpont a predikciós szerződést Pydantic kérés- és
válaszsémává alakítja: a Field korlátok az érvényes
tartományt, a Literal a kategóriák halmazát rögzíti. A
modellt a lifespan indításkor tölti be, és a meta-fájl
feature-listájával ellenőrzi a szerződést. A hiányzó vagy rossz mező
422, a modell nélkül 503 a válasz. A CPU-bound
predikció def végpontban, threadpoolban fut; a latency log
működési mérőszám, és egyetlen loopback 200 nem termelési
kapacitás. Következő lépésként Streamlit mint
ellenőrizhető bemutató a kutatói demó Streamlit-változatát
tárgyalja.