36.2 Késő címke, teljesítmény és újratanítás

36.2.1 Cél és konkrét helyzet

A Northstar rendeléskori modellje azt a valószínűséget becsüli, hogy egy rendelés kézbesítése 7 napnál későbbi lesz. A predikció a predicted_at = order_ts pillanatban készül, tehát semmilyen későbbi kézbesítési adat nem lehet predictor. A target_late címke viszont késve, tényleges kézbesítéskor válik ismertté: label_available_at = actual_delivery_at. Ebből azonnal következik a monitoring-probléma: a friss predikciók egy része még nem értékelhető, mert a címke nem érkezett meg.

A feladat egy gördülő teljesítményriport tervezése, amely az értékelés időpontjában ténylegesen rendelkezésre álló címkéket használja. A bemenet két tábla:

Tábla Megfigyelési egység Fontos mezők
predictions egy predikció, (model_id, order_id) predicted_at, predicted_late_prob, features_schema_version
labels ugyanaz a rendelés, order_id label_available_at, target_late

A riport as-of időpontja legyen V = 2025-07-15T00:00:00+00:00, a predikciós ablak pedig a július 1-jei öt rendelés. Az előzetes becslés: körülbelül 80%-os fedettség várható, mert egy szélsőségesen késő kézbesítés címkéje még hiányzik. A kérdés: mi mondható erről az ablakról tényként, és mikor szabad újratanítást indítani?

36.2.2 Fogalmi magyarázat: késleltetett címke, mérési ablak és visszacsatolás

Késleltetett címke (delayed label) olyan célváltozó, amely a predikció után csak későbbi eseményből ismerhető meg. A Northstar szerződésében a késés nem egységesen 7 nap: a nem késő rendelés hamarabb, a késő rendelés később kap címkét. A címke hiánya tehát nem véletlen hiány, hanem magával a célváltozóval összefüggő szelekció: a hiányzó sorok nagyobb eséllyel pozitívak. Ezt a Sokaság, minta és adatfelvételi folyamat alfejezetben megismert mintavételi kérdéshez hasonlóan kell kezelni.

Értékelési ablak (evaluation window) két időkorlátból áll:

Egy rendelés csak akkor szerepel az értékelés nevezőjében, ha a predikció az ablakon belül készült. A számlálóba viszont csak azok kerülnek, amelyek címkéje V-ig megérkezett:

coveragem,w=nlabeled,m,wnpredicted,m,w, \text{coverage}_{m,w} = \frac{n_{\text{labeled},m,w}}{n_{\text{predicted},m,w}},

ahol mm a modellazonosító, ww az ablak, npredictedn_{\text{predicted}} az összes ablakbeli predikció darabszáma, nlabeledn_{\text{labeled}} a rendelkezésre álló címkéjű soroké. A mennyiség dimenzió nélküli arány, értéke [0,1][0,1]-ben van. A coverage nem modellminőség, hanem adatelérhetőségi mérőszám: azt mondja meg, mekkora részmintáról beszélhetünk egyáltalán.

A részmintán számolható diszkriminációs mutató, például az AUC, rangpár-képlettel:

AUCw=C+0,5TP⋅N, \text{AUC}_w = \frac{C + 0{,}5T}{P \cdot N},

ahol CC azon pozitív–negatív párok száma, ahol a pozitív rendeléshez magasabb pontszám tartozik, TT a döntetlen pároké, PP a pozitív, NN a negatív minták száma. A nevező az összes lehetséges pozitív–negatív pár. Véletlen rangolásnál (\text{AUC}=0{,}5), tökéletes elválasztásnál (1).

Kézi kontrollpélda a laborhoz:

order_id target_late predicted_late_prob label elérhető V-kor?
O1 0 0.10 igen
O2 0 0.30 igen
O3 1 0.20 igen
O4 1 0.90 igen
O5 ? 0.85 nem

A rangpárok: O3 pozitív pontszáma (0{,}20) egy negatívnál nagyobb, egynél kisebb; O4 pontszáma mindkét negatívnál nagyobb. Így (C=3), (T=0), (P=2), (N=2), tehát (\text{AUC}=3/4=0{,}75). A coverage (4/5=0{,}80). Fontos: az O5 hiányzó sor valószínűleg pozitív és magas pontszámú, ezért a (0{,}75) csak a négy elérhető címkéjű sorra igaz, nem a teljes ötelemű ablakra.

Visszacsatolási hurok (feedback loop) akkor keletkezik, ha a modell kimenete befolyásolja a folyamatot, amelyből a címke születik. Ha például a magas kockázatú rendeléseket gyorsított kézbesítési sávba terelik, akkor a target_late már nem a természetes kézbesítési folyamat kimenete, hanem a modell által befolyásolt folyamaté. Ilyenkor a javuló AUC nem feltétlenül jobb modellt, hanem a beavatkozás hatását is méri. A riportnak ezért rögzítenie kell, hogy a champion modell mely rendeléseken kapott beavatkozást, és a kauzális állítás külön véletlenített értékelést igényel.

Újratanítási feltétel (retrain trigger) nem maga a modell, hanem explicit szabályzat. Akkor érdemes challenger modellt indítani, ha:

  1. a mérési ablak már érett: coverage >= 0.90 és (n_{\text{labeled}}\ge 300);
  2. legalább két egymást követő, előre definiált és megfelelően címkézett ablakban (\text{AUC}<\max(0{,}75, \text{baseline}-0{,}03));
  3. nincs aktív adatminőségi vagy sémaincidens.

A champion az éles modell, a challenger pedig a jelölt.

Módszertani korlát. A 90%-os címkefedettség sem teszi automatikusan torzítatlanná a részleges AUC-t: a címke késése összefügghet a tényleges késéssel és a predikcióval. A teljesítményjelentésben jelölni kell a cenzorálást, és szükség szerint csak teljesen érett kohorszt, illetve külön indokolt késéskorrekciót szabad használni. A challenger predikciói naplózódnak, de üzleti beavatkozást nem vezérelnek, amíg független, érett ablakon jobbnak nem bizonyul, és a javulás becslési intervalluma nem zárja ki a nullát.

36.2.3 Minimális munkapélda: as-of riport

Az alábbi szándékosan kis, szintetikus példa standard library sqlite3-mal fut. a kód futása még nincs automatizáltan ellenőrizve, ezért a kimenet szemléltető, elméletileg várt.

import sqlite3

AS_OF = "2025-07-15T00:00:00+00:00"
MIN_COVERAGE = 0.90
MIN_LABELED = 300
BASELINE_AUC = 0.80

PREDICTIONS = [
    ("O1", "champ-v3", "2025-07-01T09:30:00+00:00", 0.10, "fs-2"),
    ("O2", "champ-v3", "2025-07-01T09:31:00+00:00", 0.30, "fs-2"),
    ("O3", "champ-v3", "2025-07-01T09:32:00+00:00", 0.20, "fs-2"),
    ("O4", "champ-v3", "2025-07-01T09:33:00+00:00", 0.90, "fs-2"),
    ("O5", "champ-v3", "2025-07-01T09:34:00+00:00", 0.85, "fs-2"),
]

LABELS = [
    ("O1", "2025-07-03T11:00:00+00:00", 0),
    ("O2", "2025-07-04T08:10:00+00:00", 0),
    ("O3", "2025-07-10T12:00:00+00:00", 1),
    ("O4", "2025-07-13T09:00:00+00:00", 1),
]

def auc_pairs(y_true: list[int], scores: list[float]) -> float | None:
    pos = [s for y, s in zip(y_true, scores) if y == 1]
    neg = [s for y, s in zip(y_true, scores) if y == 0]
    if not pos or not neg:
        return None
    concordant = ties = 0
    for sp in pos:
        for sn in neg:
            if sp > sn:
                concordant += 1
            elif sp == sn:
                ties += 1
    return (concordant + 0.5 * ties) / (len(pos) * len(neg))

def decide_readiness(coverage, auc, n_labeled):
    if coverage < MIN_COVERAGE:
        return "hold_report", "fedetlen ablak: a részleges AUC nem kezelhető tényként"
    if n_labeled < MIN_LABELED:
        return "wait", "kevés címke a megbízható döntéshez"
    threshold = max(0.75, BASELINE_AUC - 0.03)
    if auc is None:
        return "human_review", "üres AUC, adatfolyam-hiba gyanú"
    if auc < threshold:
        return "retrain_candidate", f"AUC {auc:.3f} < {threshold:.3f}"
    return "none", "champion teljesítménye megfelelő"

con = sqlite3.connect(":memory:")
con.executescript("""
CREATE TABLE predictions (
    order_id TEXT,
    model_id TEXT,
    predicted_at TEXT,
    predicted_late_prob REAL,
    features_schema_version TEXT,
    PRIMARY KEY (model_id, order_id)
);
CREATE TABLE labels (
    order_id TEXT PRIMARY KEY,
    label_available_at TEXT,
    target_late INTEGER
);
""")
con.executemany(
    "INSERT INTO predictions VALUES (?,?,?,?,?)", PREDICTIONS
)
con.executemany(
    "INSERT INTO labels VALUES (?,?,?)", LABELS
)

summary = con.execute(
    """
    SELECT p.model_id,
           COUNT(*) AS n_pred,
           COUNT(l.order_id) AS n_labeled,
           ROUND(1.0 * COUNT(l.order_id) / COUNT(*), 4) AS coverage
    FROM predictions p
    LEFT JOIN labels l
      ON l.order_id = p.order_id
     AND l.label_available_at <= ?
    WHERE p.predicted_at < ?
    GROUP BY p.model_id
    """,
    (AS_OF, AS_OF),
).fetchall()

labelled = con.execute(
    """
    SELECT p.order_id, p.predicted_late_prob, l.target_late
    FROM predictions p
    JOIN labels l ON l.order_id = p.order_id
    WHERE l.label_available_at <= ?
      AND p.predicted_at < ?
    ORDER BY p.order_id
    """,
    (AS_OF, AS_OF),
).fetchall()

model_id, n_pred, n_labeled, coverage = summary[0]
scores = [row[1] for row in labelled]
y_true = [row[2] for row in labelled]
auc = auc_pairs(y_true, scores)
action, reason = decide_readiness(coverage, auc, n_labeled)

print(
    {"model_id": model_id, "n_pred": n_pred, "n_labeled": n_labeled,
     "coverage": coverage, "auc_on_labeled": round(auc, 3) if auc else None,
     "action": action, "reason": reason}
)

A szemléltető várható kimenet:

{'model_id': 'champ-v3', 'n_pred': 5, 'n_labeled': 4,
 'coverage': 0.8, 'auc_on_labeled': 0.75,
 'action': 'hold_report',
 'reason': 'fedetlen ablak: a részleges AUC nem kezelhető tényként'}

A kód a label_available_at <= AS_OF szűrést a joinba teszi, ezért a jövőbeli címke nem számít elérhetőnek. A predicted_at < AS_OF a határon éles predikciókat is a nevezőben hagyja, de a fedettség mutatja meg, hogy közülük mennyi érett.

36.2.4 Eredmény értelmezése

A kézi kontroll és a kód ugyanazt adja: öt predikció, négy címke, coverage (0{,}8), a négy elérhető címkén (\text{AUC}=0{,}75). A riport mégsem mondhatja, hogy „a modell AUC-ja 0,75 ebben az ablakban”. A helyes állítás:

Az ablak predikcióinak 80%-ára van címke; ezen a nem véletlenül elérhető részmintán az AUC 0,75. A teljes ablakra vonatkozó állítás addig nem tehető, amíg a coverage nem éri el a 0,90-es szabályzatot.

Az (O5) hiányzó címkéjű rendelés magas, (0{,}85) pontszámú. Ha később kiderül, hogy valóban késő kézbesítés volt, az öt teljes soron számolt AUC (5/6\approx0{,}833)-ra változik. Ez az ellenpélda mutatja, hogy a késleltetett pozitív címkék mechanikusan torzíthatják az interim AUC-t.

Az újratanítási policy külön választja az adatelérhetőségi feltételt a teljesítményfeltételtől. A hold_report nem indít újratanítást; előbb meg kell várni az érett ablakot, vagy visszafelé kell tolni a predikciós ablak végét. Ha egy fedett ablakban továbbra is romlik az AUC, akkor jön a retrain_candidate, de a champion addig élesben marad.

A riportban mindig szerepelnie kell a model_id mezőnek. Ha a champion és a shadow challenger sorait összevonva mérjük az AUC-t, akkor sem a régi, sem az új modellhez nem tartozó állítást nem kapunk. A challenger csak akkor tekinthető előléptethetőnek, ha az aktuális érett ablakon mért AUC-javulás intervalluma nem tartalmaz nullát, és a beavatkozási környezet azonos.

36.2.5 Feladatok

  1. Értelmezés. A legfrissebb hétnapos ablakban (n_{\text{predicted}}=1000), (n_{\text{labeled}}=600), és a riport szerint (\text{AUC}=0{,}71). Szabad-e közleményt írni arról, hogy a modell teljesítménye ezen a héten (0{,}71)-re romlott? Indokold a coverage és a késleltetett pozitív címkék szerepével.

  2. Implementálás. Egészítsd ki az előző kódot egy label_delay_hours oszloppal, amely a label_available_at - predicted_at különbség. A korábbi, már lezárt történeti ablakban a sorted(label_delay_hours) lista utolsó 95%-os kvantilisét használva számíts mature_cutoff = V - d95 értéket, és csak a predicted_at <= mature_cutoff sorokat nevezd érettnek. Mutasd meg pszeudokóddal, hogyan kerül az így számított d95 a riportba.

  3. Hibadiagnózis. Egy társad riportjában a predictions és labels összekapcsolásnál ez a feltétel szerepel:

    LEFT JOIN labels l
      ON l.order_id = p.order_id
     AND l.label_available_at IS NOT NULL

    V = 2025-07-15 mellett egy rendelés címkéje 2025-07-12-én érkezett meg. Miért hamis a riport „coverage = 1.0” állítása? Add meg a javított feltételt, és magyarázd el, miért nem szabad az IS NOT NULL önmagában.

  4. Megváltozott feltétel. A csapat bevezeti azt a beavatkozást, hogy minden predicted_late_prob >= 0.7 rendelés automatikusan gyorsított logisztikai kezelést kap. Harminc nap múlva a champion AUC-ja meredeken javul. Milyen visszacsatolási hurok állhat a javulás mögött? Milyen kísérleti vagy shadow-kontrolltervezés segítene különválasztani a modell javulását a beavatkozás hatásától?

36.2.6 Megoldások és indoklás

1. megoldás. Nem szabad így közölni. A coverage (600/1000=0{,}60), tehát az ablak 40%-a címke nélküli. A késő kézbesítések címkéje definíció szerint később érkezik, tehát a hiányzó sorokban felülreprezentáltak lehetnek a pozitív esetek. A (0{,}71) a rendelkezésre álló, önszelektált részmintára vonatkozó interim szám, nem a teljes hét ténye. Helyes közlés: „A vizsgált ablak még nem érett; a rendelkezésre álló 60%-os részen az AUC 0,71. A teljes ablakra vonatkozó következtetés elhalasztva.”

2. megoldás. A Python-pszeudokód így nézhet ki:

import math
from datetime import datetime, timedelta

def parse_ts(value):
    return datetime.fromisoformat(value.replace("Z", "+00:00"))

delays = [
    (parse_ts(l) - parse_ts(p)).total_seconds() / 3600
    for p, l in labelled_rows
]
d95_hours = sorted(delays)[math.ceil(0.95 * len(delays)) - 1]
mature_cutoff = parse_ts(AS_OF) - timedelta(hours=d95_hours)

Ellenőrző invariáns: a lezárt történeti ablakban a delay-ek legalább 95%-a kisebb vagy egyenlő (d_{95})-nél. A riport d95_days mezőjét is menteni kell, mert ha a ciklusidő később megváltozik, a mérési ablak visszafelé tolása igazolhatóvá válik. A kritikus korlát: a (d_{95}) sem garancia, csak stacioner folyamatra érvényes üzemi szabály.

3. megoldás. Az IS NOT NULL csak azt vizsgálja, hogy a rendeléshez tartozik-e valamilyen címke a labels táblában; azt nem, hogy az adott címke az értékelési időpontban már ismert volt-e. A 2025-07-12-i címke 2025-07-15-i as-of nézetből visszatekintve ismert, de a riportnak ezt a jövőt nem lett volna szabad felhasználnia, ha korábbi V-t használt volna. A javítás:

LEFT JOIN labels l
  ON l.order_id = p.order_id
 AND l.label_available_at <= ?  -- AS_OF/V érték

Az IS NOT NULL tehát az adatbázis teljes történetére vonatkozik, a <= V viszont az as-of pillanatra. A kettő csak akkor egyenértékű, ha a label-tábla tranzakciósan nem tartalmaz jövőbeli címkéket, de erre nem szabad építeni.

4. megoldás. A javulás mögött valószínűleg beavatkozási hurok áll: a modell által kiemelt rendeléseket a gyorsított logisztika részben „megmenti”, így a target_late csökken. A champion AUC-ja ezt a módosított kimenetet méri, nem az eredeti, beavatkozás nélküli kockázatot. A különválasztáshoz véletlenített kontrollcsoportra van szükség, amely azonos pontszámküszöbű, de nem beavatkozott rendelésekből áll. Ha ilyen véletlenítés nem áll rendelkezésre, a modellre mint prediktív eszközre tett állítást le kell cserélni a „modell + beavatkozási szabályzat” együttes hatásának leírására. A challenger előléptetése ilyenkor a beavatkozási populációra értelmezett üzleti cél szerint dőljön el.

36.2.7 Gyakori tévedés és önellenőrzés

A leggyakoribb hiba: a friss címke nélküli időszak teljesítményét tényként megadni. A fenti példa éppen ezt cáfolja: a négy elérhető címkén számolt (0{,}75) AUC-ból kiindulva valaki azt írta volna, hogy „a modell AUC-ja 0,75”, pedig O5 címkéjének megérkezése után a helyes érték (5/6\approx0{,}833). A helyes riport a mértéket, a részminta méretét és a fedettséget együtt írja le.

Önellenőrzés a következő riport készítése előtt:

A korábbi Adat- és predikciómonitoring címke nélkül driftriasztásai itt nyerik el a végső értelmüket: a predikcióeloszlás-változás csak gyanú, a teljesítményromlás pedig csak akkor bizonyítható, ha a késleltetett címkék miatti részmintát pontosan kezeljük. A FastAPI végpont és ellenőrzött bemenet alfejezetben bevezetett kötött predikciós szerződés nélkül a predicted_at és a label_available_at összekapcsolása is sérülne.

36.2.8 Rövid összefoglaló

A késleltetett címkék miatt a friss predikciós ablak csak részben értékelhető. A coverage a rendelkezésre álló címkék arányát méri, nem a modell minőségét; ha (0{,}90) alatt van, a részleges AUC nem teljesítménytény. A riport as-of szűréssel zárja ki a jövőbeli címkéket, és külön választja az újratanítási feltételt az emberi vizsgálattól. Ha a modell beavatkozást vezérel, a címke visszacsatolási hurkon keresztül a modell hatását is tartalmazza; ilyenkor csak shadow- vagy véletlenített értékelés különíti el a predikció javulását a beavatkozásétól. A champion mellett a challenger naplózva mérhető. A következő Hozzáférés, titkok és adatminimalizálás alfejezetben a hozzáférés, titokkezelés és adatminimalizálás keretezi ugyanezt az éles monitorozási folyamatot.