36.1 Adat- és predikciómonitoring címke nélkül

36.1.1 Helyzet és megfigyelési egység

A Northstar modell championja már batch predikcióban fut a rendelés pillanatában. A működő kiadásnál Batch inference és idempotens output rögzítette a bemeneti sémát és Modelljelölt, jóváhagyás és registry a modellverziót, de a valódi célváltozó, a target_late, csak a kézbesítés után keletkezik. Az első hetekben tehát a monitoringgyakran csak magát az inputot, a feature-öket és a prediktált valószínűséget látja. A megfigyelési egység: egy batch predikciós sor, azaz egy rendeléshez tartozó feature-vektor a score oszloppal. A cél az, hogy a címke nélküli ablakban is riasztást adjunk, ha a feldolgozási szerződés sérül vagy a bemeneti eloszlás el távolodik a referenciától. Az ellenőrzés feltétele: a jelentés sémaállapotot, distance_psi-t és a score átlagának eltolódását adja, majd OK, REVIEW, WARN vagy CRITICAL állapotot képez. Előzetes becslés a szintetikus ablakokra: a normál ablak PSI-je 0,1 alatt marad, az eltolt ablaké a 0,15–0,25 sávba kerülhet, a sémahibás ablak pedig biztosan kritikus, mert negatív távolságot és hiányzó oszlopot tartalmaz.

36.1.2 A négy drift fogalom és a baseline window

A „drift” itt nem egyetlen jelenség, ezért először négy fogalmat különítünk el.

Típus Amit mér Címke kell? Tipikus ok
schema drift a bemeneti szerződés sérülése nem upstream hiba, új forrás, hiányzó mező
data drift P(X)P(X) változása a baseline-hoz képest nem szezon, kampány, mérési változás
prediction drift a prediktált score eloszlásának változása nem data drift, küszöbhiba, modellcsere
concept drift P(y∣x)P(y \mid x) változása igen ügyfélviselkedés, folyamatváltozás

A schema drift nem statisztikai kérdés. Ugyanaz a szerződés, amelyet a batch inference Batch inference és idempotens output sémavalidációnál használt: mely oszlopoknak kell létezniük, milyen dtype-pal és tartománnyal. Ha a region oszlopban megjelenik egy west érték, vagy a distance_km negatív lesz, az szerződésszegés, nem eloszlásváltozás. Hiába hibátlan a statisztika, ha a sorokat eleve rossz formában töltöttük be.

A data drift az input eloszlását veti össze egy rögzített baseline window-val. A baseline ablak az az időszak, amikor a modell kiadásához tartozó validáció szerint ismert volt a teljesítmény; példánkban 2025-04-01 és 2025-04-30. A baseline window nem csúszik naponta, mert a folyamatos frissítés lassan követné a trendet, és a riasztás elveszne. Új baseline csak explicit modellváltáskor, a registry jóváhagyásával Modelljelölt, jóváhagyás és registry képződik.

A prediction drift a modell kimenetét, a score eloszlását figyeli. Ha a prediktált valószínűség átlaga vagy a magas kockázatú részarány elmozdul, az lehet data drift következménye, de lehet koncepcionális változás vagy rossz modellverzió is. Önmagában vizsgálati jel, nem bizonyíték.

A concept drift a feltételes kimeneteloszlás változása; nem feltétlenül jelent romló teljesítményt, mert annak mértéke a modellre és az értékelési eloszlásra is függ: ugyanaz a feature-vektor már más P(y∣x)P(y \mid x) valószínűséggel jár. Ehhez címke kell, ezért a jelen alfejezetben csak annyit mondhatunk: a címke nélküli eszközök nem azonosítják, legfeljebb gyanút keltenek.

36.1.3 Eloszlástávolság és mintanagyság-függő tesztértelmezés

A leggyakoribb két mérőszám a PSI és a Kolmogorov–Smirnov-statisztika.

A PSI kategorizált binningre épül:

PSI=∑i=1K(qi−pi)ln(qipi), PSI = \sum_{i=1}^{K} (q_i - p_i) \ln\left(\frac{q_i}{p_i}\right),

ahol pip_i a baseline ablakbeli relatív gyakoriság az ii-edik binben, qiq_i a jelen ablakbeli relatív gyakoriság ugyanott, KK a binek száma, ln\ln természetes logaritmus. A mennyiség dimenziómentes, mert gyakoriságok hányadosából számoljuk. Kézi kontroll: ha p=(0,5;0,5)p=(0{,}5;0{,}5) és q=(0,4;0,6)q=(0{,}4;0{,}6), akkor

PSI=(0,4−0,5)ln(0,4/0,5)+(0,6−0,5)ln(0,6/0,5)≈0,0405. PSI = (0{,}4-0{,}5)\ln(0{,}4/0{,}5) + (0{,}6-0{,}5)\ln(0{,}6/0{,}5) \approx 0{,}0405.

A formula szimmetrikus: a referencia és a jelen ablak felcserélése azonos értéket ad.

A KS-statisztika folytonosabb leírást ad:

DKS=supx∈ℝ|F̂ref(x)−F̂cur(x)|, D_{KS} = \sup_{x \in \mathbb{R}} \left|\hat{F}_{\text{ref}}(x) - \hat{F}_{\text{cur}}(x)\right|,

ahol F̂\hat{F} a megfelelő ablak empirikus eloszlásfüggvénye. A D_KS a két eloszlásfüggvény legnagyobb függőleges távolsága, szintén dimenziómentes. A scipy.stats.ks_2samp e mellé p-értéket is ad, de itt a matematikai híd kulcsa: a p-érték erősen mintanagyság-függő. Kis mintán még nagy DD is könnyen lehet véletlen; nagy mintán egészen apró, működés szempontjából jelentéktelen eltolódás is „szignifikáns” lehet. Ezért a monitoring riasztás küszöbét hatásméretre, tipikusan PSI-re tesszük, a p-értéket pedig csak vizsgálati támogatásként használjuk. Egy 10 binre számolt PSI-nél a 0,1 alatti érték e projektben alacsony, a 0,1–0,2 közötti vizsgálandó, a 0,2 feletti pedig erős riasztási szintet jelezhet. A PSI érzékeny a binhatárokra, a ritka kategóriákra és az alkalmazott simításra; önmagában nem bizonyít eloszlásazonosságot. Ezek működési küszöbök, nem statisztikai törvények; a projekt a saját döntési költségéhez igazíthatja őket.

36.1.4 Teljes minimális munkapélda

A labor egy lab/monitoring_lab mappában él, és négy ablakot állít elő: egy referenciaablakot és három vizsgált ablakot. A generálás Python standard libraryt, NumPy-t és SciPy-t használ.

# drift_report.py
"""Séma-, input- és predikciódrift jelentés szintetikus ablakokra."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import numpy as np
import pandas as pd

SCHEMA = {
    "distance_km": {"kind": "numeric", "min": 0.0, "max": 500.0},
    "basket_minor": {"kind": "numeric", "min": 0.0},
    "quantity_sum": {"kind": "integer", "min": 1, "max": 50},
    "segment": {"kind": "category", "allowed": {"consumer", "business"}},
    "priority_flag": {"kind": "integer", "min": 0, "max": 1},
    "score": {"kind": "numeric", "min": 0.0, "max": 1.0},
}

def check_schema(df: pd.DataFrame) -> list[str]:
    issues: list[str] = []
    for col, spec in SCHEMA.items():
        if col not in df.columns:
            issues.append(f"missing:{col}")
            continue
        s = df[col]
        if spec["kind"] == "numeric" and not pd.api.types.is_numeric_dtype(s):
            issues.append(f"dtype:{col}")
            continue
        if spec["kind"] == "integer" and not pd.api.types.is_integer_dtype(s):
            issues.append(f"dtype:{col}")
            continue
        if spec["kind"] == "category" and not set(s.dropna().unique()) <= spec["allowed"]:
            issues.append(f"allowed:{col}")
        if "min" in spec and s.min(skipna=True) < spec["min"]:
            issues.append(f"min:{col}")
        if "max" in spec and s.max(skipna=True) > spec["max"]:
            issues.append(f"max:{col}")
        if s.isna().any():
            issues.append(f"null:{col}")
    return issues

def make_window(n: int, seed: int, mean_dist: float,
                schema_bad: bool = False) -> pd.DataFrame:
    rng = np.random.default_rng(seed)
    df = pd.DataFrame({
        "distance_km": rng.normal(mean_dist, 25.0, n).clip(0.0, 500.0),
        "basket_minor": rng.integers(200, 50_000, n),
        "quantity_sum": rng.integers(1, 12, n),
        "segment": rng.choice(["consumer", "business"], n, p=[0.8, 0.2]),
        "priority_flag": rng.integers(0, 2, n),
    })
    z = -1.8 + 0.012 * df["distance_km"] + 0.35 * df["priority_flag"]
    df["score"] = 1.0 / (1.0 + np.exp(-z))
    if schema_bad:
        df = df.drop(columns=["priority_flag"])
        df.loc[0, "distance_km"] = -10.0
    return df

def psi_binned(ref: np.ndarray, cur: np.ndarray, bins: int = 10) -> float:
    edges = np.quantile(ref, np.linspace(0.0, 1.0, bins + 1))
    edges[0] = -np.inf
    edges[-1] = np.inf
    p = np.histogram(ref, bins=edges)[0] / len(ref)
    q = np.histogram(cur, bins=edges)[0] / len(cur)
    eps = 1e-4
    p = np.where(p == 0, eps, p)
    q = np.where(q == 0, eps, q)
    return float(np.sum((q - p) * np.log(q / p)))

def report(baseline: pd.DataFrame, current: pd.DataFrame,
           current_name: str, row_floor: int = 500) -> dict:
    schema_issues = check_schema(current)
    if "distance_km" in current.columns:
        psi_dist = psi_binned(
            baseline["distance_km"].to_numpy(),
            current["distance_km"].to_numpy())
    else:
        psi_dist = float("nan")
    score_delta = float(current["score"].mean() - baseline["score"].mean())
    if schema_issues:
        state = "CRITICAL"
    elif psi_dist > 0.2 or abs(score_delta) > 0.05:
        state = "WARN"
    elif len(current) < row_floor:
        state = "REVIEW"
    else:
        state = "OK"
    return {
        "window": current_name,
        "rows": len(current),
        "schema_issues": schema_issues,
        "distance_psi": round(psi_dist, 4),
        "score_mean_delta": round(score_delta, 4),
        "state": state,
    }

def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--baseline", type=Path, required=True)
    ap.add_argument("--current", type=Path, required=True)
    ap.add_argument("--window-name", required=True)
    args = ap.parse_args()
    rep = report(pd.read_csv(args.baseline), pd.read_csv(args.current),
                 args.window_name)
    print(json.dumps(rep, indent=2))
    code = {"OK": 0, "REVIEW": 1, "WARN": 1, "CRITICAL": 2}[rep["state"]]
    return code

if __name__ == "__main__":
    raise SystemExit(main())

A windowokat külön file gyártja:

# make_windows.py
from pathlib import Path
from drift_report import make_window

out = Path("data")
out.mkdir(exist_ok=True)
make_window(2000, 101, 50.0).to_csv(out / "reference.csv", index=False)
make_window(800, 102, 50.0).to_csv(out / "current_normal.csv", index=False)
make_window(800, 103, 65.0).to_csv(out / "current_shift.csv", index=False)
make_window(800, 104, 50.0, schema_bad=True).to_csv(
    out / "current_schema_bad.csv", index=False)

Futtatás Linux/macOS bash alatt, lab/monitoring_lab munkakönyvtárból:

python make_windows.py
python drift_report.py --baseline data/reference.csv \
  --current data/current_normal.csv --window-name current_normal
python drift_report.py --baseline data/reference.csv \
  --current data/current_shift.csv --window-name current_shift
python drift_report.py --baseline data/reference.csv \
  --current data/current_schema_bad.csv --window-name current_schema_bad

Ha a script WARN vagy REVIEW állapotot ad, a folyamat 1-es, CRITICAL esetén 2-es kilépési kódot használ, így a pipeline-ban VIII. ellenőrző pont: átadható modellprojekt is automatikus kapu lehet.

36.1.5 Eredmény értelmezése és kézi kontroll

A könyv írásakor nem futtattuk a fenti kódot, ezért a konkrét JSON-t szemléltetőnek, a generátorparaméterekből levezetettnek tekintjük. A current_normal ablakban a distance_psi közel 0, a score_mean_delta közel 0, az állapot várhatóan OK. A current_shift ablakban a distance_km átlagát 50-ről 65 km-re növeltük; a reference kvantilisek alapján a felső binekbe több sor kerül, ez a PSI-t a 0,15–0,25 sávba viheti, és a score átlaga is emelkedik, mert a demó score egyenlete a távolságtól függ. Ez együtt WARN. A current_schema_bad ablakból hiányzik a priority_flag, és az első sor távolsága negatív; a sémaellenőrzés missing:priority_flag és min:distance_km hibákat ad, az állapot CRITICAL. A kézi kontroll: a PSI-hoz a bin-arányokból a korábbi képlet pontosan kiszámítható; a check_schema semmilyen WARN-állapotot nem adhat, amíg egyetlen sémahiba is van, mert akkor a CRITICAL ág az első.

A példában a score-ot a feature-ökből számítjuk, de ez csak tanulási modell, nem a regisztrált champion. A valódi rendszerben a score a batch manifestből jön Batch inference és idempotens output, és a monitoring nem a feature-ökből újraszámolja, hanem a naplózott kimenetet ellenőrzi. Azt is pontosan mondjuk ki: a PSI a distance_km-re vonatkozik, nem az egész adatsorra; a többi oszlopra külön PSI számítható, de a példa a legérzékenyebb folytonos inputot emeli ki.

36.1.6 Feladatok

  1. Értelmezés. A jelentés szerint distance_psi = 0.31, score_mean_delta = 0.00. Miért nem jelenti ez, hogy a modell használhatatlan? Mondj egy olyan mechanizmust, amely ilyen párost adhat, és írd le, milyen további adat kell a tényleges döntéshez.

  2. Megvalósítás. Bővítsd a report függvényt high_risk_share kiszámításával, ahol a magas kockázatú sor definíciója score >= 0.7. A referencia és a jelen ablak aránykülönbségére risk_delta néven figyelmeztess, ha abs(risk_delta) > 0.05. Készíts hozzá egy 4 soros DataFrame-példát, amelyben a referencia score értékei [0.6, 0.6, 0.8, 0.8], a jelené [0.6, 0.8, 0.8, 0.8], és várd el a WARN állapotot.

  3. Hibadiagnózis. A monitoring WARN állapotot ad: distance_psi = 0.24, score_mean_delta = 0.01. Milyen sorrendben vizsgálnád az upstream adatot, a modellverziót és a score-kvantiliseket? Miért nem írhatod a jegyzőkönyvbe, hogy „igazolt teljesítményromlás” történt?

  4. Transzfer kis ablakra. Tegyük fel, hogy a következő batch csak 200 sort tartalmaz, és a mért distance_psi = 0.19 a küszöb alatt van. Hogyan változtatja ez a döntést? Módosítsd a report függvényt úgy, hogy 500 sor alatt REVIEW állapotot állítson be, és a jelentésbe note mezőt is írjon: „kis minta, vizsgálat javasolt”. A row_floor paraméter már létezik, tehát a feladat a hívásoldal és a note mező kiegészítése.

36.1.7 Megoldások és indoklás

  1. A PSI csak P(X)P(X) eltolódást mér, nem P(y∣x)P(y \mid x)-et. Ha a modell a distance_km-re gyenge érzékenységű, akkor az input eltolódás nem változtatja a predikciót; az is lehet, hogy a modell épp ebben a tartományban kalibrált marad. A további adat: a későbbi címkék, azaz a kézbesítés után számolt target_late, amit a következő alfejezet Késő címke, teljesítmény és újratanítás használ, vagy legalább egy stresszteszt a Robusztusság és korlátokkal írt modellkártya módszeréből, hogy a score érzékenységét ismerjük.

  2. A report bővítése:

ref_high = (baseline["score"] >= 0.7).mean()
cur_high = (current["score"] >= 0.7).mean()
risk_delta = float(cur_high - ref_high)

majd a WARN feltétel:

elif psi_dist > 0.2 or abs(score_delta) > 0.05 or abs(risk_delta) > 0.05:

A példában a referencia magas aránya 2/4=0,52/4=0{,}5, a jelené 3/4=0,753/4=0{,}75, risk_delta = 0.25, tehát WARN. Ellenőrző invariant: az adatsor hossza kicsi, de a mean() itt egyszerű arány; a kapott érték kerekítés nélkül is pontosan 0,250{,}25.

  1. Először a sémaellenőrzést kell megerősíteni: van-e új adatforrás, mértékegységváltás vagy csonkolt batch? Másodszor a modellverziót: a registry Modelljelölt, jóváhagyás és registry szerint a champion hash-e megegyezik-e a futtatott modellel? Harmadszor a score eloszlás kvantiliseit: az átlag alig változhat úgy, hogy a farok eltolódik, ezért a 0,1;0,5;0,90{,}1;0{,}5;0{,}9-es kvantilisek is kellenek. A teljesítményromláshoz címke kell, tehát a „igazolt” szó hibás lenne; a jegyzőkönyv legfeljebb „vizsgálati jel: input drift score-stabilitással” állítást tehet.

  2. Kis mintán a PSI becslés varianciája nagy, ezért a küszöb alatti érték nem megnyugtató, csak nem riaszt. A REVIEW állapot nem blokkoló hiba, hanem emberi vizsgálati igény. A módosítás:

state = "REVIEW" if len(current) < row_floor else "OK"
rep["note"] = "kis minta, vizsgálat javasolt" if len(current) < row_floor else ""

A küszöb feletti PSI továbbra is WARN legyen, tehát a feltételsorrend ne változzon: séma, nagy eltérés, majd kis minta.

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

A leggyakoribb tévedés az, hogy a drift kimutatása a modell használhatatlanságát jelenti. Ellenpélda: a current_shift ablakban a distance_km erősen eltolódik, a PSI magas, de a demó score függvénye csak mérsékelten változik, és ha a tényleges target_late is alig változik, a modell továbbra is jól dönt. A PSI tehát csak annyit mond: „a jelenlegi input már nem hasonlít a baseline-ra”; a következményt külön kell igazolni.

A másik irány sem bizonyíték. Ha a score eloszlása napokon át változatlan, az nem jelenti, hogy a modell jó; lehet, hogy minden szegmensben nő a késés, mert a szállító folyamat megváltozott, és a modell ugyanazt a valószínűséget adja, miközben P(y∣x)P(y \mid x) eltolódott. Ezt a concept driftet a címke nélküli monitor nem látja; a késői címke Késő címke, teljesítmény és újratanítás fogja feltárni. A monitoring tehát riasztási és vizsgálati eszköz, nem a modellminőség független tanúsítványa.

Önellenőrzés: Mi a különbség a séma- és a data drift között a beavatkozás sorrendjében? Miért veszélyes a KS-teszt p-értékét önmagában riasztási küszöbként használni? Mikor szabad megváltoztatni a baseline window-t?

36.1.9 Rövid összefoglaló

A címke nélküli monitoring négy különböző jelet kezel: a séma drift szerződésszegést jelez, azonnal kritikus; a data drift PSI-vel a baseline-hoz méri a bemeneti eloszlást; a prediction drift a score eloszlását; a concept drift csak későbbi címkével igazolható. A baseline window a champion validációs időszaka, és modellváltásig rögzítve marad. A PSI hatásméret-küszöb, a KS-féle p-érték mintanagyság-függő támogatás; kis mintán a bizonytalanságot REVIEW állapottal kell jelölni. A következő alfejezet Késő címke, teljesítmény és újratanítás a megérkező címkét köti a tényleges teljesítményhez és az újratanításhoz.