21.1 Tanítás, validáció és érintetlen teszt

21.1.1 Cél és konkrét helyzet

A Northstar késésbecslő projektben a modell feladata rendeléskor megbecsülni, hogy a 7 napos ígért kézbesítési időt túllépi-e a csomag. A célváltozó target_late bináris: értéke 1, ha delivery_days > promised_days, egyébként 0. A megfigyelési egység egy rendelés (order_id). A predikció pillanata az order_ts; a modell csak az ekkor ismert feature-öket használhatja, a címkét azonban csak a kézbesítés után ismerjük meg. A kezdőlabor modellépítési időpontja 2025-05-01T00:00:00+00:00. Ebből készítünk train/test/purged felosztást, amelyet egy split.json manifestben rögzítünk, és ellenőrizzük, hogy minden tanítócímke ténylegesen megérkezett a modellépítés időpontjáig.

Induló feladat a futtatás előtt: a 2400 rendelés 2025 első 180 napjára esik. A cutoff az év 121. napjára esik. Ha a kézbesítés jellemzően néhány nap, körülbelül hány sor kerül a train, a purged és a test halmazba? A labgenerátor pontos eloszlását nem ismerjük, ezért becslésként a train és a test méretét az időeloszlás határozza meg, míg a purged halmaz mérete a cutoff előtt leadott, de akkor még nem ismert címkéjű rendelések számától függ; konkrét intervallum nem igazolható a generátor futtatása nélkül közötti sor várható. A pontos számot a helyi futtatás split.json fájlja adja meg.

21.1.2 Tanító, validációs, teszt és holdout

A prediktív modell építésekor három adathalmazt különböztetünk meg:

A holdout olyan adathalmaz, amelyet a tanítás során információs értelemben nem érintünk: nem nézzük a címkéjét, nem választunk rajta küszöböt, és nem végezünk rajta feature-szelekciót. A teszt holdout célja a bevetési helyzet szimulálása: olyan rendeléseken mérni, amilyeneken a modell a jövőben dolgozik.

A target availability azt mondja meg, hogy a címke mikor válik megfigyelhetővé. A Northstar szerződésben label_available_at = actual_delivery_at: konzervatívan csak a teljes kézbesítés pillanatában tekintjük ismertnek a target_late értéket, még akkor is, ha egy késés korábban már sejthető lenne. Ez a label-availability szerződés része; más projektekben eltérő lehet, de akkor azt explicit dokumentálni kell.

A group fogalma akkor lép be, ha egy magasabb szintű egység több sort is tartalmaz. A Northstar orders táblájában egy ügyfélhez több rendelés is tartozhat. Ha a cél új ügyfelekre való általánosítás, akkor a split nem sorszintű, hanem ügyfélszintű kell legyen: ugyanaz az ügyfél ne kerüljön egyszerre trainbe és testbe. A kezdőlabor időbeli splitet használ, amely nem garantálja ezt a csoportszintű elkülönítést; ez explicit korlát, nem hiba, ha a bevetési kérdés időbeli előrejelzés.

A horizon a predikciós időtáv. Itt 7 nap: a modell a rendelés pillanatában ad becslést a 7 napos ígéret túllépésére. A cutoff kiválasztása ehhez igazodik: a tesztablak a cutoff utáni rendeléseket tartalmazza, és a tesztcímkék a generált adatban teljesen megfigyeltek, tehát a későbbi értékeléshez rendelkezésre állnak, de a modellépítés pillanatában még nem.

21.1.3 Feltételes információ, függetlenség és időrendi validáció

Legyen t0t_0 a modellépítés cutoffja, itt 2025-05-01T00:00+00:00. A modell csak az It0I_{t_0} információs halmazra épülhet, vagyis arra, ami t0t_0-ban ténylegesen ismert. Egy ii rendelésre jelölje

A három halmaz formálisan:

train={i:torder,i<t0éstlabel,i<t0},purged={i:torder,i<t0éstlabel,i≥t0},test={i:torder,i≥t0}. \begin{aligned} \text{train} &= \{\,i:\ t_{\text{order},i} < t_0 \ \text{és}\ t_{\text{label},i} < t_0\,\}, \\ \text{purged} &= \{\,i:\ t_{\text{order},i} < t_0 \ \text{és}\ t_{\text{label},i} \ge t_0\,\}, \\ \text{test} &= \{\,i:\ t_{\text{order},i} \ge t_0\,\}. \end{aligned}

A feltételben a < szigorú: a cutoff pillanatában éppen beérkező címkét nem tekintjük a múltban ismertnek. A purged sáv a cutoff előtti rendelések közül azok, amelyek kézbesítése csak a cutoff után történt meg; ezeket sem tanításra, sem tesztelésre nem használjuk. A test kizárólag jövőbeli rendeléseket tartalmaz, ezért a teljes múltra és a határsávra nézve is tiszta.

A random split akkor ad jól értelmezhető becslést az új, hasonló megfigyelésekre, ha a felosztás megfelel a célzott alkalmazási helyzetnek, és nincs kezeletlen csoport- vagy időfüggés. Az i.i.d. feltevés gyakori elegendő kiindulópont, de önmagában nem garantálja az érvényességet, ha például a mintavétel nem reprezentálja a bevetéskori populációt. A csoportos split a függetlenséget a csoport szintjén követeli meg: a sorok nem függetlenek, a csoportok viszont annak tekinthetők. Az időbeli split nem független mintákat feltételez, hanem időrendi validációt ad: a tanítóablak megelőzi a tesztablakot, így a nem-stacionaritás, a szezonális változás és a későbbi bevetés közelítő szimulációja lesz az értékelés. A példa tehát az Időtengely, resampling és állomásossági kérdés alfejezetben bevezetett időbeli gondolatot ülteti át modellértékelésbe.

21.1.4 Teljes minimális munkapélda

A következő kód a lab projektgyökérből futtatható, feltételezve, hogy a data/orders.csv a generált tiszta fájl. A kimenet szemléltető, mert automatikusan még nem ellenőrzött Python-kód; a tényleges számokat a saját lab futtatás adja.

import json
from pathlib import Path

import pandas as pd

# A CSV ISO timestampjei +00:00 offsettel rendelkeznek.
orders = pd.read_csv("data/orders.csv")
orders["order_ts"] = pd.to_datetime(orders["order_ts"], utc=True)
orders["label_available_at"] = pd.to_datetime(orders["label_available_at"], utc=True)

cutoff = pd.Timestamp("2025-05-01T00:00:00+00:00")

# A split három, egymást kizáró maszkja.
past_order = orders["order_ts"] < cutoff
label_available = orders["label_available_at"] < cutoff

train_mask = past_order & label_available
test_mask = orders["order_ts"] >= cutoff
purged_mask = past_order & ~label_available

# Invariáns ellenőrzések.
assert not (train_mask & test_mask).any(), "train és test átfed"
assert not (train_mask & purged_mask).any(), "train és purged átfed"
assert not (test_mask & purged_mask).any(), "test és purged átfed"
assert train_mask.sum() + test_mask.sum() + purged_mask.sum() == len(orders)

# A tanítócímke label-availability bizonyítása erre a futásra.
assert (orders.loc[train_mask, "label_available_at"] < cutoff).all(), (
    "tanítócímke nem érkezett meg a modellépítés időpontjáig"
)

manifest = {
    "split_id": "northstar-late-cutoff-2025-05-01",
    "cutoff_utc": cutoff.isoformat(),
    "split_rule": (
        "train: order_ts < cutoff AND label_available_at < cutoff; "
        "test: order_ts >= cutoff; "
        "purged: order_ts < cutoff AND label_available_at >= cutoff"
    ),
    "n_total": len(orders),
    "n_train": int(train_mask.sum()),
    "n_test": int(test_mask.sum()),
    "n_purged": int(purged_mask.sum()),
    "train_order_ids": orders.loc[train_mask, "order_id"].tolist(),
    "test_order_ids": orders.loc[test_mask, "order_id"].tolist(),
    "purged_order_ids": orders.loc[purged_mask, "order_id"].tolist(),
    "train_positive_rate": float(orders.loc[train_mask, "target_late"].mean()),
    "test_positive_rate": float(orders.loc[test_mask, "target_late"].mean()),
    "max_train_label_available_at": (
        orders.loc[train_mask, "label_available_at"].max().isoformat()
    ),
}

Path("artifacts").mkdir(exist_ok=True)
Path("artifacts/split.json").write_text(json.dumps(manifest, indent=2))

print(
    f"train={manifest['n_train']}, test={manifest['n_test']}, "
    f"purged={manifest['n_purged']}, "
    f"max_train_label={manifest['max_train_label_available_at']}"
)

A fájl artifacts/split.json néven keletkezik, és tartalmazza a sorazonosítókat, a cutoffot, valamint a felosztási szabály szöveges rögzítését. A manifest az II. ellenőrző pont: adatból ellenőrzött feature-tábla alfejezetben tanult adatszerződés gondolatát viszi tovább: a split nemcsak kód, hanem dokumentált, visszakereshető artefaktum.

21.1.5 Kézi kontroll és az eredmény értelmezése

A maszkokat ellenőrizzük egy hat soros, kézzel átlátható példán is. A cutoff legyen 2025-05-01T00:00+00:00.

order_id order_ts label_available_at besorolás
A 2025-04-20 10:00 2025-04-25 10:00 train
B 2025-04-28 10:00 2025-04-30 10:00 train
C 2025-04-30 10:00 2025-05-03 10:00 purged
D 2025-05-01 10:00 2025-05-08 10:00 test
E 2025-05-02 10:00 2025-05-09 10:00 test
F 2025-04-29 10:00 2025-05-01 10:00 purged

A kézi számolás szerint train =2=2, purged =2=2, test =2=2. A C sor rendelése múltbeli, de a címke jövőbeli, ezért purged. Az F sor címkéje a cutoff után érkezik, ezért szintén purged, bár a rendelés még korábbi. Ez a példa mutatja, hogy a label-availability szűrés nem helyettesíthető az order_ts szerinti vágással.

A kód által kiírt max_train_label_available_at értékének szigorúan kisebbnek kell lennie a cutoffnál; ez a bizonyíték arra, hogy a tanítóhalmazban nincs a modellépítéskor még nem ismert címke. A manifestben a train_positive_rate és test_positive_rate a pozitív osztály arányát mutatja; a teszt értékét csak a végső jelentésben szabad megnézni, mert a tesztcímke a modellépítéskor tiltott információ.

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

A leggyakoribb hiba az, hogy a splitet kizárólag a feature-időre alapozzuk:

train = orders[orders["order_ts"] < cutoff]
test = orders[orders["order_ts"] >= cutoff]

Tegyük fel, hogy egy rendelés order_ts = 2025-04-29T10:00+00:00, ígért kézbesítése 7 nap, tényleges kézbesítése 2025-05-03, ezért label_available_at = 2025-05-03. A fenti kód trainbe teszi, mert a rendelés időpontja múltbeli. A modellépítés pillanatában azonban a target_late érték még nem létezik: a címke jövőbeli információ. Ha a modell ezen tanul, akkor olyan célváltozót lát, amelyet a bevetéskor soha nem kapna meg időben; ez címke-szivárgás. A jövőbeli label elérhetősége tehát akkor is számít, ha a feature-ök mindegyike a rendelés pillanatában ismert. A javítás a label_available_at < cutoff feltétel hozzáadása, a purged sáv kizárásával.

Visszautalás: a Jelölésből végrehajtható számítás alfejezetben bevezetett formális jelölés itt válik döntési eszközzé; az It0I_{t_0} információs halmaz és a < szigorúsága ugyanaz a gondolat, mint az Időtengely, resampling és állomásossági kérdés időbeli validációs követelménye.

21.1.7 Feladatok

  1. Értelmezés. A purged sávba eső C rendelést miért nem tesszük a testbe? Magyarázd el, milyen torzítást okozna, ha mégis odakerülne.

  2. Implementálás. Írj make_time_split(df, cutoff) függvényt, amely egy rendeléseket tartalmazó DataFrame-ből és egy cutoff timestampből kiindulva visszaadja a train_mask, test_mask, purged_mask boolean tömböket, valamint a manifest dictionary-t a fenti szerkezetben. Teszteld a hat soros kézi példán, és ellenőrizd, hogy a visszaadott maszkok összege 6.

  3. Hibakeresés. Az alábbi split a label-availability szűrést kihagyja:

    train = orders[orders["order_ts"] < cutoff]
    test = orders[orders["order_ts"] >= cutoff]

    Adj meg egy konkrét order_id-t a fenti hat soros példából, amelyik így trainbe kerül, pedig a címkéje a cutoffkor még nem ismert. Melyik feltétel hiányzik a javításhoz?

  4. Megváltozott feltétel. Változtasd a cutoffot 2025-04-15T00:00:00+00:00-re. A train, test és purged halmaz mérete hogyan változik a 2025-05-01-eshez képest? Indokold a label-availability feltétel és a rövidebb múlt alapján.

21.1.8 Megoldások és indoklás

  1. A purged sávba eső sor order_ts < cutoff, tehát a modellépítés pillanatában a rendelés már megtörtént. Ha testbe tennénk, a teszt nem kizárólag jövőbeli rendeléseket tartalmazna, hanem olyan múltbelieket is, amelyek címkéje a cutoff után érkezett. Ez kettős torzítást okoz: egyrészt a teszteloszlás keveredik a határsávval, másrészt a modell olyan múltbeli eseteken lenne értékelve, amelyekre a bevetéskor nem lenne analógiája. A purged sáv ezért sem tanításra, sem értékelésre nem használható; a kihagyott határsáv a cutoff közeli szivárgás elleni konzervatív védelem.

  2. A függvény vázlata:

    def make_time_split(df, cutoff):
        past_order = df["order_ts"] < cutoff
        label_available = df["label_available_at"] < cutoff
        train_mask = past_order & label_available
        test_mask = df["order_ts"] >= cutoff
        purged_mask = past_order & ~label_available
        manifest = {
            "cutoff_utc": cutoff.isoformat(),
            "n_total": len(df),
            "n_train": int(train_mask.sum()),
            "n_test": int(test_mask.sum()),
            "n_purged": int(purged_mask.sum()),
        }
        return train_mask, test_mask, purged_mask, manifest

    A hat soros példán n_total = 6, n_train = 2, n_test = 2, n_purged = 2, tehát az összeg 6. Az ellenőrzéshez a maszkokra sum() hívható, mert a True értéke 1.

  3. A C sor order_ts = 2025-04-30 10:00, ami múltbeli, ezért az order_ts < cutoff feltétel trainbe sorolja. A label_available_at = 2025-05-03 10:00 azonban a cutoff után érkezik, tehát a címke jövőbeli. A hiányzó feltétel: label_available_at < cutoff. A javított train feltétel: (orders["order_ts"] < cutoff) & (orders["label_available_at"] < cutoff).

  4. A 2025-04-15 cutoff rövidebb múltat hagy a trainnek, ezért a train mérete csökken, a test mérete nő. A purged sáv aránya várhatóan nő, mert a cutoff előtti rendelések közül nagyobb hányad kézbesítése esik a cutoff utánra: a határsáv ugyanakkora kézbesítési időablakot fed le, de a rendelkezésre álló múlt rövidebb, ezért a purged/train arány romlik. A pontos számot a lab futtatása adja; a tendencia a fenti időrendi szerkezetből következik.

21.1.9 Rövid összefoglaló

A modellértékelés első lépése olyan splitet készíteni, amely a bevetési helyzetet modellezi. A Northstar késésbecslő feladatban a cutoff 2025-05-01, a train feltétele order_ts < cutoff és label_available_at < cutoff, a teszt a cutoff utáni rendeléseket tartalmazza, a purged sáv a múltbeli, de még nem kézbesített rendeléseket zárja ki. A splitmanifest rögzíti a sorazonosítókat és a szabályt; az invariant ellenőrzés bizonyítja, hogy minden tanítócímke megérkezett a modellépítés pillanatáig. A következő alkalmazás Preprocessing tanulás kizárólag a training részen alfejezetben ezt a manifestet használja, hogy a preprocessing minden lépése kizárólag a train halmazon tanuljon.