23.2 Keresztvalidáció a függőségi szerkezethez

23.2.1 Cél és konkrét helyzet: összehasonlító értékelés a Northstar rendeléseken

A feladat a szokásos prediktív kérdés: rendelés pillanatában becsüljük, hogy a kézbesítés meghaladja-e a 7 napot. A bemenet a lab/data/orders.csv és a lab/data/customers.csv; a megfigyelési egység egy rendelés (order_id), a csoportazonosító a customer_id, az időablak pedig az order_ts UTC-időbélyeg. A célváltozó a bináris target_late, a prediktorok a rendelés pillanatában ismert distance_km, priority_flag, basket_minor, quantity_sum, account_age_days, segment, region, acquisition_channel. Az előzetes becslés: a Fák, random forest és gradiensboosting alfejezetben látott HistGradientBoosting időbeli spliten kapott AUC-ja 0.70 körüli volt; most nem egyetlen scoreszám, hanem a felosztási stratégia hatása a kérdés. Ez vizsgálandó hipotézis, nem garantált sorrend: a csoportos és időbeli felosztás akár magasabb AUC-t is eredményezhet; a különbséget a validáció által szimulált alkalmazási helyzet szerint kell értelmezni.

23.2.2 Fogalmak: a fold mint a jövőbeli adat modellje

A keresztvalidáció (cross-validation) azt kérdezi: milyen jól általánosít a tanulási eljárás olyan adatra, amely nem volt a tanításban? A D={(xi,yi)}i=1nD=\{(x_i,y_i)\}_{i=1}^n mintát KK részre osztjuk; a kk-adik iterációban a kk-adik fold a validáció, a többi a tanítás. A modellt a tanítórészen illesztjük, a validációs részen értékeljük, majd átlagolunk:

Err̂CV=1K∑k=1K1|Ikval|∑i∈IkvalL(yi,f̂k(xi)), \widehat{\mathrm{Err}}_{\mathrm{CV}}=\frac{1}{K}\sum_{k=1}^{K}\frac{1}{|I_k^{\mathrm{val}}|}\sum_{i\in I_k^{\mathrm{val}}} L(y_i,\hat f_k(x_i)),

ahol LL a választott veszteség vagy negatív score, f̂k\hat f_k a kihagyott foldon kívüli adaton tanult modell, IkvalI_k^{\mathrm{val}} a validációs indexhalmaz, |Ikval||I_k^{\mathrm{val}}| annak mérete. A cél az, hogy ez a becslés a valódi jövőbeli használat hibáját közelítse.

A KFold indexek szerinti felosztást készít (alapesetben keverés nélkül, shuffle=True esetén véletlenszerűsítve). Független, azonos eloszlású minták mellett hasznos becslést adhat, de a CV-átlag nem általánosan torzítatlan becslő: a kisebb foldonkénti tanítóméret és a kiválasztási lépések is befolyásolhatják. A StratifiedKFold a véletlen felosztást úgy készíti, hogy minden foldban megközelítőleg azonos maradjon a célosztályok aránya; ritka eseménynél ez megakadályozza, hogy egy fold véletlenül nagyon kevés pozitív esetet tartalmazzon. A GroupKFold a csoportazonosítót figyeli: egy csoport minden mintája ugyanabba a foldba kerül, így a tanító- és validációs rész csoporthalmazai diszjunktak:

Gktr∩Gkval=∅, G_k^{\mathrm{tr}} \cap G_k^{\mathrm{val}} = \emptyset,

ahol GkG_k a foldhoz tartozó csoportazonosítók halmaza. A TimeSeriesSplit az időrendet őrzi: a kk-adik splitben a tanítóhalmaz minden eleme korábbi, mint a validációs elemek:

Iktr={i:ti<τk},Ikval={i:τk≤ti<τk+1}. I_k^{\mathrm{tr}} = \{i : t_i < \tau_k\}, \qquad I_k^{\mathrm{val}} = \{i : \tau_k \le t_i < \tau_{k+1}\}.

A split manifest a felosztás rögzített, verziózott leirata. Ha a foldindexek egy JSON-fájlban vannak, akkor a kísérlet pontosan reprodukálható akkor is, ha a környezetet újraépítjük; a manifest az adatsorra, a stratégiára és a seedre hivatkozik.

A matematikai híd itt a függőség. Ha az egy ügyfélhez tartozó rendelések korrelálnak, akkor egy ügyfél rendeléseinek trainbe és validationbe szétosztása olyan, mintha a modell a tesztidőpontban ismerné az ügyfél válaszainak egy részét; ez optimista AUC-hoz vezet. Az egyes foldok metrikái nem független minták: közös adatokból, átfedő tanítóhalmazokból származnak. Ezért a foldok egyszerű szórása nem a becslés mintavételi szórása. A Parciális derivált és gradiens alfejezetben tanult optimalizálás itt is szerepet kap: minden foldban újraoptimalizáljuk a modell paramétereit, így a CV valójában a tanulási eljárás egészét értékeli, nem egyetlen paramétervektort.

23.2.3 Kézi kontroll egy kis példán

Tegyük fel, hogy hat rendelésünk van, két-két rendelés három ügyféltől:

Rendelés Csoport Idő Cél
r1 c1 jan. 0
r2 c1 márc. 1
r3 c2 febr. 0
r4 c2 ápr. 0
r5 c3 máj. 1
r6 c3 jún. 1

Egy két folddal dolgozó KFold például a {r1, r2, r3, r4} tanítóhalmazt és {r5, r6} validációt adhat, majd fordítva. Ekkor c1 és c2 ügyfelek mintái mindkét oldalon előfordulhatnak. A GroupKFold ezzel szemben egy foldot a c1, a másikat a c2, c3 csoportokra épít; az ügyfél soha nem szerepel egyszerre tanítóban és validációban. A TimeSeriesSplit egy bővülő ablakot ad: első splitnél train {r1, r2}, validation {r3, r4}; másodiknál train {r1, r2, r3, r4}, validation {r5, r6}. Látszik a két tulajdonság: a TimeSeriesSplit a jövőt nem engedi a múlt tanításába, de azonos ügyfél továbbra is átkerülhet a foldok között.

23.2.4 Teljes minimális munkapélda

A következő kód a három stratégiát hasonlítja össze a Northstar-adaton, a teljes pipeline-t validálva. A preprocessing a ColumnTransformerben együtt van a modellel, így a cross_validate minden foldban csak a tanítórészen tanítja a skálázást és a one-hot kódolást. A kódot a projektgyökérből futtatva a lab/data/ mappa elérhető.

import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.model_selection import (KFold, StratifiedKFold, GroupKFold,
                                     TimeSeriesSplit, cross_validate)
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

# 1. Adat betöltése; a szerződés szerint a predikció pillanata order_ts.
orders = pd.read_csv("lab/data/orders.csv", parse_dates=["order_ts"])
customers = pd.read_csv("lab/data/customers.csv", parse_dates=["signup_date"])

df = orders.merge(customers[["customer_id", "signup_date", "segment",
                             "region", "acquisition_channel"]],
                  on="customer_id", how="left")
df["account_age_days"] = (df["order_ts"] - df["signup_date"]).dt.days

features = ["distance_km", "priority_flag", "basket_minor", "quantity_sum",
            "account_age_days", "segment", "region", "acquisition_channel"]
target = "target_late"
X = df[features]
y = df[target].astype(int)
groups = df["customer_id"].to_numpy()

preprocessor = ColumnTransformer([
    ("num", StandardScaler(), ["distance_km", "basket_minor",
                                "quantity_sum", "account_age_days"]),
    ("pass", "passthrough", ["priority_flag"]),
    ("cat", OneHotEncoder(handle_unknown="ignore"),
           ["segment", "region", "acquisition_channel"]),
])

model = Pipeline([
    ("prep", preprocessor),
    ("clf", HistGradientBoostingClassifier(
        max_iter=150, learning_rate=0.08, random_state=42)),
])

# 2. Három stratégia, azonos scoring és azonos modell.
scoring = "roc_auc"
cv_choices = {
    "kfold": KFold(n_splits=5, shuffle=True, random_state=20261020),
    "stratified": StratifiedKFold(n_splits=5, shuffle=True, random_state=20261020),
    "group": GroupKFold(n_splits=5),
}

results = {}
for name, cv in cv_choices.items():
    kwargs = {"groups": groups} if name == "group" else {}
    scores = cross_validate(model, X, y, cv=cv, scoring=scoring,
                            n_jobs=1, **kwargs)["test_score"]
    results[name] = scores

# 3. Időbeli split: az adatot order_ts szerint rendezzük.
df_time = df.sort_values("order_ts")
X_time = df_time[features]
y_time = df_time[target].astype(int)
# Demonstráció: az időrendet megtartja, de önmagában NEM garantálja,
# hogy a tanítócímkék rendelkezésre álltak az adott validáció kezdete előtt.
# Éles értékeléshez minden foldban ellenőrizni kell:
# max(label_available_at[train_idx]) < min(order_ts[val_idx]),
# vagy az adott validáció kezdetéig ismert címkékre szűrt train-indexet használni.
tscv = TimeSeriesSplit(n_splits=5)
scores_time = cross_validate(model, X_time, y_time, cv=tscv,
                             scoring=scoring, n_jobs=1)["test_score"]
results["time"] = scores_time

for name, scores in results.items():
    print(f"{name:10s} AUC folds: {scores.round(3)}  "
          f"mean={scores.mean():.3f}  sd={scores.std(ddof=1):.3f}")

A kimenetet szemléltető értékekkel jelölöm, mert ebben az írási módban nem futtattuk a kódot:

kfold       AUC folds: [0.71 0.69 0.73 0.68 0.72]  mean=0.706  sd=0.019
stratified  AUC folds: [0.70 0.71 0.68 0.69 0.73]  mean=0.702  sd=0.019
group       AUC folds: [0.66 0.64 0.67 0.65 0.63]  mean=0.650  sd=0.016
time        AUC folds: [0.68 0.61 0.63 0.65 0.60]  mean=0.634  sd=0.032

A tényleges számok a labor futtatásából származnak; a sorrend azonban a szerkezetből következik.

23.2.5 Eredmény értelmezése

A KFold és a StratifiedKFold átlaga várhatóan magasabb, mert ugyanazon ügyfél korábbi rendelései a tanítóhalmazból szivárogtatnak információt az ügyfél későbbi rendeléseiről. A GroupKFold ezt kizárja, ezért alacsonyabb, de az időrendet nem tiszteli: egy ügyfél jövőbeli rendelése taníthatja a múltbeli rendelésre illesztett modellt. A TimeSeriesSplit a jövőt nem engedi a múltba, viszont megengedi, hogy ugyanazon ügyfél rendelései mindkét oldalon szerepeljenek; ez a kezdőprojekt valós használatában részben elfogadható, ha a modell a rendelés pillanatában valóban látja az ügyfél előzményét. A tervezett használatra — jövőbeli rendelések becslése a rendelés pillanatában — a címkeelérhetőséget is ellenőrző, időalapú felosztás a minimálisan elfogadható felosztás; a GroupKFolddal való összevetés pedig megmutatja az ügyfél-átszivárgás mértékét. Ha új ügyfélre is szeretnénk általánosítani, a kettőt kombináló, időblokkokra osztott csoportsplitet kell építenünk, amelyet a scikit-learn beépítetten nem ad, de a foldindexeket generálva egy GroupKFold logikájával és order_ts szerinti szűréssel előállítható.

23.2.6 Feladatok

  1. Értelmezés. A fenti szemléltető eredményben a TimeSeriesSplit AUC-átlaga a legalacsonyabb. Magyarázza el, miért nem jelenti ez azt, hogy a modell rosszabb lett; mit mér a különbség a KFoldhoz képest?

  2. Implementálás. Adott a df DataFrame a fenti feature-ökkel, y = df["target_late"], groups = df["customer_id"]. Írjon kódot, amely LogisticRegression(max_iter=1000) modellt és GroupKFold(n_splits=4) felosztást használva cross_val_score-szal kiszámolja a roc_auc pontszámokat, majd kiírja az egyes foldok AUC-ját és az átlagot.

  3. Hibadiagnózis. Az alábbi kódban valaki a foldok szórását konfidenciaintervallumként adja meg. Nevezze meg a két hibát, és javítsa az állítást úgy, hogy a kód kimenete ne állítson statisztikai bizonytalanságot a foldokból.

    scores = cross_val_score(model, X, y, cv=5, scoring="roc_auc")
    mean = scores.mean()
    se = scores.std(ddof=1) / np.sqrt(5)
    print(f"95% CI: {mean - 1.96*se:.3f} - {mean + 1.96*se:.3f}")
  4. Megváltozott feltétel. A laboradat 300 ügyfelet tartalmaz, és a predikciót kifejezetten új ügyfelek első rendelésére kell általánosítani. Hány ügyfél kerül egy GroupKFold(n_splits=5) validációs foldjába? Milyen gyakorlati korlátja van ennek a kis csoportszámnak, és milyen két módosítást javasolna az értékelés megbízhatóságának növelésére?

23.2.7 Megoldások és indoklás

1. megoldás. A TimeSeriesSplit nem rosszabb modellt mutat, hanem kevésbé optimista feltételek mellett értékel. A KFoldnál azonos ügyfél tanítórendelései segítenek a validációs rendeléseken; ez a valóságban a predikció pillanatában csak akkor lenne elérhető, ha a modell az ügyfél összes korábbi és jövőbeli rendelését ismerné. A két pontszám különbsége önmagában nem a szivárgás mértéke: eltérő tanítóadat-méret, időbeli eltolódás és osztályarány is alakítja. Az ok megállapításához kontrollált összehasonlítás szükséges.

2. megoldás. A kód a cross_val_score params argumentumában adja át a csoportosítást:

from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import GroupKFold, cross_val_score

model = LogisticRegression(max_iter=1000)
cv = GroupKFold(n_splits=4)
scores = cross_val_score(model, X, y, groups=groups, cv=cv,
                         scoring="roc_auc")
for i, score in enumerate(scores, start=1):
    print(f"fold {i}: {score:.3f}")
print(f"mean AUC: {scores.mean():.3f}")

Ellenőrző invariant: minden fold AUC-ja 0 és 1 közé esik, és az átlag a négy fold átlaga; ha a modell konvergens, az érték tipikusan 0.5 fölött van, de a pontos érték a futástól függ.

3. megoldás. Az első hiba: a foldmetrikák nem függetlenek, mert a tanítóhalmazok átfednek, ezért a scores.std(ddof=1) nem a mintavételi eloszlás szórása. A második hiba: a np.sqrt(5)-tel való osztás úgy tenné, mintha őt független minta lenne, ami hamis. Helyes megfogalmazás: a foldok az adatfelosztás szerinti teljesítmény stabilitását mutatják, nem konfidenciaintervallumot. A kód kimenetét ezért például így kell átírni:

print(f"Fold scores (AUC): {scores.round(3)}")
print(f"Mean: {scores.mean():.3f}, sd across folds: {scores.std(ddof=1):.3f}")
print("A fold sd nem konfidenciaintervallum; a bizonytalansághoz "
      "nested CV vagy bootstrap kell a megfelelő mintavételi egységgel.")

4. megoldás. GroupKFold(n_splits=5) esetén egy validációs foldban körülbelül 60 ügyfél van; a konkrét felosztás ettől kissé eltérhet, mert a csoportokat kell egészben tartani. A korlát: a validációs AUC így mindössze 60 ügyfél rendeléseire épül, ami nagy varianciát okoz; a foldok közötti különbség részben a kis csoportszámból fakad, nem valódi modellkülönbségből. Két módosítás: végezzünk több, kifejezetten csoportszintű újramintavételezést vagy független időszakon értékelést; a nagyobb foldszám önmagában nem növeli a rendelkezésre álló független ügyfelek számát, és a teljesítményt a csoportszintű bootstrap vagy nested CV segítségével értékeljük, ahol a külső ciklus a csoportokat bontja.

23.2.8 Gyakori tévedés: a foldok szórása nem konfidenciaintervallum

A Standard error és konfidenciaintervallum alfejezetben látott standard error a becslő mintavételi eloszlásából származik, nem egyetlen adathalmaz belső szóródásából. A CV-foldok éppen ilyen belső szóródást mutatnak: ugyanazokat az adatokat többféleképpen osztjuk fel, a tanítóhalmazok átfednek, ezért a foldmetrikák korreláltak. Az sd(ddof=1) kiszámítása még nem rossz, de az 1.96*se/√K képlet alkalmazása hamis precizitást ad. A GroupKFold és TimeSeriesSplit foldjai ráadásul eltérő méretűek is lehetnek, ezért a szórásuk még kevésbé értelmezhető közvetlenül. A helyes bizonytalanságbecsléshez a teljes tanulási eljárást ismétlő mintavétel kell, például a megfelelő mintavételi egységre épített bootstrap vagy nested CV.

23.2.9 Önellenőrzés

23.2.10 Rövid összefoglaló

A keresztvalidáció csak akkor becsüli jól a jövőbeli hibát, ha a foldok a valódi használat függőségeit modellezik. A KFold független mintákat feltételez, ezért ügyfél- és időfüggő rendeléseknél optimista; a StratifiedKFold az osztályarányt őrzi. A GroupKFold a csoportot tartja egy oldalon, a TimeSeriesSplit a múltból a jövőbe tanít. A Northstar laborban a TimeSeriesSplit a tervezett rendelés pillanatára vonatkozó predikció minimálisan elfogadható felosztása, de új ügyfélre általánosításhoz csoportos idősplitre van szükség. A foldmetrikák szórása stabilitást mutat, nem konfidenciaintervallumot. Következő lépésként Hiperparaméter és nested értékelés alfejezetben a hiperparaméter-keresés és a nested értékelés kérdését vizsgáljuk.