21.4 Predikciós szerződés és hibateszt

21.4.1 Cél és konkrét helyzet

A Northstar prediktív projektben a modell feladata rendeléskor megbecsülni, hogy a kézbesítés meghaladja-e a 7 napot. A megfigyelési egység egy rendelés; a célváltozó target_late bináris. A modell bemenete a rendelés pillanatában ismert nyolc feature: distance_km, priority_flag, basket_minor, quantity_sum, account_age_days, segment, region, acquisition_channel. A kimenet predict_proba alakban annak valószínűsége, hogy a rendelés késik.

Ebben az alfejezetben nem új modellt tanítunk, hanem a predikciós szerződést építjük meg: azt a szabályt, amely garantálja, hogy a betanított pipeline tanításkor és későbbi új adaton pontosan ugyanazt a feature-sémát kapja. A Baseline és egyetlen teljes kísérlet alfejezetben elkészült a baseline kísérlet; most a modell artifacttá válik, és azt kérdezzük: mi történik, ha egy oszlop hiányzik, a kategóriák halmaza megváltozik, vagy az oszlopok sorrendje eltér?

Előzetes becslés: a konkrét valószínűség a betanított modelltől függ, ezért nem írható elő. A szerződéses ellenőrzésnek viszont minden új rekordra garantálnia kell, hogy predict_proba kimenete (1, 2) alakú, az értékek 0 és 1 közé esnek, és soronként 1-re összegződnek.

21.4.2 Fogalmi magyarázat: feature names, dtypes és schema version

A scikit-learn pipeline a betanítás során feature names-ként rögzíti a bemeneti oszlopneveket. A ColumnTransformer a nevek alapján választja ki a numerikus és kategorikus oszlopokat; a kimeneti transzformált mátrix oszlopai például num__distance_km, cat__region_north alakú neveket kapnak. A sklearn 1.0 óta a pipeline .feature_names_in_ attribútuma mutatja a fitkor látott nyers neveket.

A dtype a változó típusa: distance_km lebegőpontos (float64), a zászló és a darabszám egész (int64), a szegmens, régió és csatorna szöveges (object vagy category). A típus nem üres formalitás: ha egy oszlop például string formátumból int64-re konvertálódik, a ColumnTransformer numerikus transzformert hívhat rá, és a modell értelme megváltozik.

A missing columns ellenőrzés azt jelenti, hogy a szerződésben előírt minden oszlopnak jelen kell lennie. A hiányzó oszlop a ColumnTransformer.transform() hívásakor KeyError-t ad, de a hibaszöveg nem mindig mondja meg, melyik oszlop hiányzik. A saját validátor ezt karbantartható üzenetté alakítja.

A schema version egy rövid, ember által is olvasható azonosító, például northstar-order-v1. A modell artifact mellé mentjük. Ha a feature-definíció változik — például új oszlop jelenik meg, vagy a basket_minor egysége centről euróra vált —, a verziót növelni kell, különben a régi modell némán rossz bemenetet kaphat.

A predict_proba bináris osztályozásnál két oszlopot ad vissza. Az első a 0 osztály, a második az 1 osztály valószínűsége; a sorrendet a modell .classes_ attribútuma rögzíti. A késés valószínűsége tehát:

P(late=1∣x)=σ(T(x)⊤w+b),P(\text{late}=1 \mid x) = \sigma(T(x)^\top w + b),

ahol σ(z)=1/(1+e−z)\sigma(z) = 1 / (1 + e^{-z}) a logisztikus függvény, T(x)T(x) a preprocessing-utáni feature-vektor, ww a súlyvektor, bb a tengelymetszet. Minden sorra fennáll:

P(late=0)+P(late=1)=1.P(\text{late}=0) + P(\text{late}=1) = 1.

Ez utóbbi a kézi kontroll alapja.

21.4.3 Dimenzió- és változójelentés-ellenőrzés

A nyers bemenet X∈ℝn×pX \in \mathbb{R}^{n \times p} mátrix, ahol nn a rendelések száma, p=8p=8 a feature-ek száma. A preprocessing T:ℝp→ℝqT: \mathbb{R}^p \to \mathbb{R}^q leképezést hajt végre. A példában a numerikus oszlopok száma 5, a kategorikus one-hot kódolás után a szegmens 2, a régió 3, a csatorna 3 dummy oszlopot ad, így q=5+2+3+3=13q = 5+2+3+3 = 13.

A dimenzióellenőrzés tehát két ponton érvényesül:

Ha egy oszlop hiányzik, akkor a numerikus blokk kimenete például 4 dimenziós lesz, a teljes transzformált vektor pedig csak 12 dimenziós. A modell ekkor dimenzióhibát ad, mert a súlyvektor hossza nem egyezik a bemenettel.

A változójelentés-ellenőrzés a mértékegységekre és a szemantikára vonatkozik: distance_km kilométer, basket_minor EUR-cent, account_age_days nap, priority_flag 0/1 indikátor. A szerződés ezt a jelentést dokumentálja, még ha a dtype önmagában nem is hordozza. A scikit-learn nem tudja ellenőrizni, hogy egy float64 oszlop kilométer vagy mérföld; ezt a schema-leírásnak kell rögzítenie.

21.4.4 Minimális munkapélda

A következő kód a lab projektgyökérből futtatható. Betölti a tiszta adatokat, kiszámítja az account_age_days feature-t, a 2025-05-01 cutoff előtti, már megérkezett címkéjű rendelésekre tanít egy pipeline-t, majd a pipeline-t és a szerződést egyetlen joblib artifactba menti. A kódot itt nem futtattuk; a kimenetek a helyi gépen a seedtől és a csomagverzióktól függően alakulnak, de a validátor szerkezete determinisztikus.

# scripts/predict_contract.py -- futtatás a lab projektgyökérből:
# python scripts/predict_contract.py
from pathlib import Path
import warnings
import joblib
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
from sklearn.linear_model import LogisticRegression

DATA = Path("data")
ARTIFACTS = Path("artifacts")
ARTIFACTS.mkdir(exist_ok=True)

orders = pd.read_csv(
    DATA / "orders.csv",
    parse_dates=["order_ts", "actual_delivery_at", "label_available_at"],
)
customers = pd.read_csv(DATA / "customers.csv")
df = orders.merge(customers[["customer_id", "signup_date"]], on="customer_id", how="left")
df["account_age_days"] = (df["order_ts"] - pd.to_datetime(df["signup_date"])).dt.days

FEATURES = [
    "distance_km", "priority_flag", "basket_minor", "quantity_sum",
    "account_age_days", "segment", "region", "acquisition_channel",
]
TARGET = "target_late"

cutoff = pd.Timestamp("2025-05-01T00:00:00+00:00")
train = df[(df["order_ts"] < cutoff) & (df["label_available_at"] < cutoff)]
X = train[FEATURES]
y = train[TARGET].astype(int)

numeric_features = [
    "distance_km", "priority_flag", "basket_minor", "quantity_sum", "account_age_days"
]
categorical_features = ["segment", "region", "acquisition_channel"]

preprocessor = ColumnTransformer(
    transformers=[
        ("num", StandardScaler(), numeric_features),
        ("cat", OneHotEncoder(handle_unknown="ignore"), categorical_features),
    ]
)
pipeline = Pipeline([
    ("prep", preprocessor),
    ("model", LogisticRegression(max_iter=1000, random_state=42)),
])
pipeline.fit(X, y)

SCHEMA_CONTRACT = {
    "schema_version": "northstar-order-v1",
    "features": {
        "distance_km": {"dtype": "float64"},
        "priority_flag": {"dtype": "int64"},
        "basket_minor": {"dtype": "int64"},
        "quantity_sum": {"dtype": "int64"},
        "account_age_days": {"dtype": "int64"},
        "segment": {"dtype": "object", "categories": ["consumer", "business"]},
        "region": {"dtype": "object", "categories": ["north", "central", "south"]},
        "acquisition_channel": {
            "dtype": "object",
            "categories": ["search", "referral", "direct"],
        },
    },
}

artifact = {
    "schema_version": SCHEMA_CONTRACT["schema_version"],
    "contract": SCHEMA_CONTRACT,
    "pipeline": pipeline,
}
joblib.dump(artifact, ARTIFACTS / "northstar_order_predictor.joblib")
print("artifact saved:", ARTIFACTS / "northstar_order_predictor.joblib")

A betöltés utáni validátor és újrekordos predikció:

class SchemaError(ValueError):
    pass

def validate_schema(frame: pd.DataFrame, contract: dict) -> pd.DataFrame:
    expected = list(contract["features"].keys())
    actual = list(frame.columns)
    if actual != expected:
        missing = set(expected) - set(actual)
        extra = set(actual) - set(expected)
        raise SchemaError(
            f"Column order mismatch; missing={sorted(missing)}, extra={sorted(extra)}"
        )
    for name, spec in contract["features"].items():
        series = frame[name]
        if str(series.dtype) != spec["dtype"]:
            raise SchemaError(
                f"{name}: expected dtype {spec['dtype']}, got {series.dtype}"
            )
        if series.isnull().any():
            raise SchemaError(f"{name}: missing values are not allowed")
        if "categories" in spec:
            unknown = set(series.dropna().unique()) - set(spec["categories"])
            if unknown:
                warnings.warn(
                    f"{name}: unknown categories {sorted(unknown)}; "
                    "the model will see an all-zero one-hot row.",
                    RuntimeWarning,
                )
    return frame

def predict_new_order(artifact_path: Path, features: dict) -> dict:
    bundle = joblib.load(artifact_path)
    contract = bundle["contract"]
    pipeline = bundle["pipeline"]
    row = pd.DataFrame([features])[list(contract["features"].keys())]

    with warnings.catch_warnings(record=True) as caught:
        warnings.simplefilter("always")
        validate_schema(row, contract)
        proba = pipeline.predict_proba(row)

    classes = list(pipeline.named_steps["model"].classes_)
    late_index = classes.index(1)
    return {
        "probability_late": float(proba[0, late_index]),
        "probability_vector": proba[0].tolist(),
        "classes": classes,
        "warnings": [str(w.message) for w in caught if w.category is RuntimeWarning],
    }

# Egy új rendelés rekord, a szerződés szerinti oszlopsorrendben.
new_record = {
    "distance_km": 12.0,
    "priority_flag": 1,
    "basket_minor": 4500,
    "quantity_sum": 3,
    "account_age_days": 120,
    "segment": "consumer",
    "region": "north",
    "acquisition_channel": "search",
}

result = predict_new_order(
    ARTIFACTS / "northstar_order_predictor.joblib", new_record
)
print(result)

A szemléltető kimenet szerkezete:

{
    'probability_late': 0.21,          # a konkrét érték a betanított modelltől függ
    'probability_vector': [0.79, 0.21],
    'classes': [0, 1],
    'warnings': []
}

A három hibateszt:

# 1. Hiányzó oszlop: regiónak jelen kell lennie.
missing_column = {k: v for k, v in new_record.items() if k != "region"}
try:
    predict_new_order(ARTIFACTS / "northstar_order_predictor.joblib", missing_column)
except SchemaError as e:
    print("missing column error:", e)

# 2. Ismeretlen kategória: a modell nem tör el, de figyelmeztetést ad.
unknown_category = {**new_record, "region": "west"}
res = predict_new_order(ARTIFACTS / "northstar_order_predictor.joblib", unknown_category)
print("unknown category warnings:", res["warnings"])

# 3. Elcserélt oszlopsorrend: a szerződés a sorrendet is rögzíti.
swapped = {**new_record}
swapped["region"], swapped["distance_km"] = swapped["distance_km"], swapped["region"]
try:
    predict_new_order(ARTIFACTS / "northstar_order_predictor.joblib", swapped)
except SchemaError as e:
    print("order error:", e)

Futtatási parancs a projektgyökérből, a lab/pyproject.toml alapfüggőségeivel:

# Munka katalógus: lab/
uv run python scripts/predict_contract.py

21.4.5 Eredmény értelmezése

A kézi kontroll nem a modell pontosságát, hanem a szerződés teljesülését ellenőrzi:

A dimenzióinvariáns: a pipeline prep lépése a nyolc bemeneti oszlopból 13 transzformált oszlopot állít elő; a model.coef_ hossza szintén 13. Ezt a tanuló a futtatás után így ellenőrizheti:

assert pipeline.named_steps["model"].coef_.shape[1] == 13
assert len(pipeline.named_steps["prep"].get_feature_names_out()) == 13

A bemeneti séma ellenőrzése ne csak az oszlopneveket vizsgálja: típus, hiányzóérték-szabály, mértékegység, kategorikus ismeretlen értékek és időbeli elérhetőség is része az adatkontraktusnak.

21.4.6 Feladatok

  1. Értelmezés. Magyarázza el, miért nem elegendő a szerződésben csak az oszlopnevek meglétét ellenőrizni, és miért kell a sorrendet is rögzíteni. Adjunk példát arra, hogy a nevek megléte ellenére hogyan kaphat a modell téves feature-t, ha a fogyasztó nem a nevekre, hanem a pozícióra épít.

  2. Implementálás. Írja meg a validate_schema függvényt úgy, hogy a SchemaError üzenete egyszerre sorolja fel a hiányzó és a felesleges oszlopokat. Futtassa a három hibatesztet, és győződjön meg arról, hogy az ismeretlen kategória valóban RuntimeWarning-ot ad, nem pedig kivételt.

  3. Hibakeresés. A csapat egyik tagja kihagyta az account_age_days oszlopot a betöltéskor. A pipeline először ezt a hibát adja: X has 7 features, but StandardScaler is expecting 8 features as input. Mi a hiba gyökere, és miért nem elég a hibaüzenet a diagnózishoz? Hogyan segíti a validátor a pontos azonosítást?

  4. Megváltozott feltétel. Tegyük fel, hogy az üzlet új régiót vezet be (west). A modell a handle_unknown="ignore" miatt nem tör el, de a predikció all-zero one-hot sort kap. Módosítsa a predict_new_order függvényt úgy, hogy ilyenkor a visszaadott dict kapjon egy "confidence": "low" mezőt. Indokolja meg, miért csak figyelmeztetés, és miért nem helyes automatikusan elutasítani a rekordot.

21.4.7 Megoldások és indoklás

  1. A DataFrame oszlopnevei a scikit-learn számára a szemantikát hordozzák, de a környező rendszerekben az adat gyakran pozíciós listaként érkezik. Ha a fogyasztó X.to_numpy()-t ad át, akkor a modell már nem látja a neveket, és a betanított súlyok a tömb oszlopindexeire vonatkoznak. Ha egy CSV-exportáló eszköz az oszlopokat más sorrendben írja ki, a to_numpy() által adott mátrix első oszlopa nem feltétlenül distance_km, hanem például region, és a modell a régió dummy oszlopait próbálja kilométerként értelmezni. A sorrend rögzítése a szerződésben megakadályozza, hogy egy downstream folyamat pozícióra építsen.

  2. A validate_schema fenti implementációja a actual != expected összehasonlítással egyszerre ellenőrzi a sorrendet és a hiányzó/felesleges oszlopokat. A tesztfuttatás során a hiányzó oszlop esetén SchemaError keletkezik missing=['region'] üzenettel; az ismeretlen kategória esetén a kimenet warnings listája tartalmazza a figyelmeztetést, a probability_vector pedig továbbra is két elemből áll; az elcserélt sorrend esetén a hibaüzenet Column order mismatch szöveggel indul. Ellenőrző invariant: a szerződésben szereplő list(contract["features"].keys()) minden hívásnál ugyanazt a sorrendet adja.

  3. A hibaüzenet a belső StandardScaler dimenziójára utal, de a gyökér az, hogy a ColumnTransformer a hiányzó account_age_days nélkül csak 4 numerikus oszlopot kapott, ezért a numerikus blokk kimenete 7 oszlop lett a várt 8 helyett. A validátor már a pipeline hívása előtt megmondja, hogy melyik oszlop hiányzik; a SchemaError üzenete így közvetlenül a missing={'account_age_days'} listát adja. A modell belső dimenzióhibája tehát a séma eltérés tünete, nem a diagnózis forrása.

  4. A módosított függvény:

def predict_new_order(artifact_path: Path, features: dict) -> dict:
    bundle = joblib.load(artifact_path)
    contract = bundle["contract"]
    pipeline = bundle["pipeline"]
    row = pd.DataFrame([features])[list(contract["features"].keys())]

    with warnings.catch_warnings(record=True) as caught:
        warnings.simplefilter("always")
        validate_schema(row, contract)
        proba = pipeline.predict_proba(row)

    classes = list(pipeline.named_steps["model"].classes_)
    late_index = classes.index(1)
    warnings_text = [str(w.message) for w in caught if w.category is RuntimeWarning]
    return {
        "probability_late": float(proba[0, late_index]),
        "probability_vector": proba[0].tolist(),
        "classes": classes,
        "warnings": warnings_text,
        "confidence": "low" if any("unknown categories" in w for w in warnings_text) else "normal",
    }

Az automatikus elutasítás azért lenne hibás, mert az ismeretlen kategória nem feltétlenül jelenti a rekord érvénytelenségét: a handle_unknown="ignore" a modell explicit szerződése arra, hogy ismeretlen kategóriánál nullákkal kódol. A "low" bizalomjelzés viszont figyelmezteti a döntéshozót, hogy a modell ilyenkor kevésbé megbízható. Ha az új kategória gyakorivá válik, a séma verzióját növelni és a modellt újra kell tanítani.

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

A leggyakoribb tévedés, hogy a DataFrame-oszlopok sorrendje garantált a fájltípus miatt. A CSV és a Parquet egyaránt oszlopneveket tárol, de a fájlba íráskor az oszlopsorrend a kiíró könyvtártól függ. Ugyanaz a logikai tábla érkezhet [distance_km, priority_flag, ...] és [priority_flag, distance_km, ...] sorrendben is. A scikit-learn a DataFrame nevei alapján dolgozik, ezért a pipeline közvetlen hívása mindkét sorrendnél ugyanazt az eredményt adja, de ha valaki a DataFrame-et to_numpy()-ként adja tovább, a nevek eltűnnek. A sklearn 1.2 óta ilyenkor figyelmeztet, hogy a transzformer feature-neveket vár, de a modell mégis a tömb pozícióit használja. A predikciós szerződés ezért a nevek, a dtype-ok és a sorrend egységét ellenőrzi.

Visszautalás: a Baseline és egyetlen teljes kísérlet baseline kísérletében a pipeline a tanító adaton belül tanulta a transzformációt; a GLM: bináris és számlálási cél logisztikus modellben a predict_proba a sigmoid függvényből származott; a Stabil numerika és érzékenység stabil numerika pedig arra emlékeztet, hogy a valószínűségek összegzésekor a lebegőpontos kerekítés toleranciát igényel. Önellenőrzés: tartalmazza-e a szerződés a séma verzióját, a feature-neveket, a dtype-okat és a kategóriák halmazát? Hibát ad-e hiányzó oszlopra? Figyelmeztet-e ismeretlen kategóriánál anélkül, hogy leállna? A predict_proba sorösszege 1-e?

21.4.9 Rövid összefoglaló

A predikciós szerződés a betanított pipeline és az új adat közötti stabilitást garantálja. A szerződés rögzíti a séma verzióját, a feature-neveket, a dtype-okat és a kategorikus változók megengedett értékeit. A hiányzó oszlopra és az elcserélt sorrendre a validátor pontos hibát ad; az ismeretlen kategóriát a modell a handle_unknown="ignore" beállítás miatt nullákkal kódolja, de a validátor alacsony bizalomjelzést ad. A predict_proba kimenete mindig (1, 2) alakú, és soronként 1-re összegződik. Következő alkalmazás: a Lineáris és logisztikus prediktív modellek lineáris és logisztikus prediktív modellek, ahol ugyanezt a szerződést használjuk a modellcsaládok összehasonlításához.