35.2 FastAPI végpont és ellenőrzött bemenet

35.2.1 Cél és konkrét helyzet

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 P(late=1∣x)P(\text{late}=1 \mid x) 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.

35.2.2 Fogalmi magyarázat: validation, startup load, request, response, error, latency, concurrency

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.

35.2.3 Teljes minimális munkapélda

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 8000

Windows 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.

35.2.4 Eredmény értelmezése és ellenőrző invariant

Ha a példát helyben futtatjuk, az alábbi ellenőrző invariantoknak kell teljesülniük:

35.2.5 Feladatok

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.

35.2.6 Megoldások és indoklás

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.

35.2.7 Gyakori tévedés ellenpéldával

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.

35.2.8 Rövid összefoglaló

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.