23.3 Hiperparaméter és nested értékelés

23.3.1 Cél és konkrét helyzet

A Northstar predikciós projektben a logisztikus regresszió egyetlen hangolandó hiperparamétere a regularizáció erősségét szabályozó C. A kérdés: hogyan becsüljük meg a kiválasztott modell jövőbeli AUC-ját úgy, hogy a hiperparaméter-keresés ne tegye optimistává a becslést? Ebben az alfejezetben egy 400 rendelésből álló szintetikus mintát használunk; a megfigyelési egység egy rendelés, a cél bináris (late = 1, ha a kézbesítés 7 napnál későbbi), a prediktorok 8 rendeléskori jellemző. Az elvárt ellenőrzés: a külső értékelés AUC-átlaga és a teljes modellillesztésszám dokumentálása.

Induló feladat a módszer megismerése előtt: becsüld meg, hány modellillesztés történik, ha 5 külső foldot, belső foldonként 3-foldos keresést, 10 véletlen jelöltet és a legjobb jelölt újraillesztését használjuk? A pontos számítást a példában ellenőrizzük.

23.3.2 Fogalmi magyarázat: hiperparaméter, keresési tér és a két CV szerepe

A modellnek tanult paraméterei (például a logisztikus regresszió súlyvektora ww) és hiperparaméterei (hyperparameters) vannak. A hiperparamétert a tanulási algoritmus elindítása előtt rögzítjük; például a logisztikus regresszió C paramétere a súlyok büntetésének inverz erőssége. Kis C erős regularizációt, nagy C gyenge regularizációt jelent, ahogy azt a Távolság, kernel és méretkorlát alfejezetben az SVM margójánál is láttuk.

A keresési tér (search space) a kipróbálandó hiperparaméter-értékek halmaza. Két gyakori forma:

A költségvetés (budget) a megengedett modellillesztések vagy futási idő felső korlátja. Minden egyes illesztés iteratív optimalizálást futtat – a gradiens alapú lépéseket a Láncszabály és számítási gráf alfejezetben ismertük meg –, ezért a futási idő nem elhanyagolható. A teljes illesztésszám az összes belső foldon végzett jelöltillesztések összege, valamint minden külső fold egy újraillesztése: Kouter(KinnerNjelölt+1)K_{outer}(K_{inner}N_{jelölt}+1), ha az alapértelmezett refit=True.

A belső és külső keresztvalidáció (inner/outer CV) célja a kiválasztás és az értékelés szétválasztása. A belső CV a modellválasztás becslője: minden jelöltet a belső foldokon értékelünk, és a legjobb átlagpontszámút választjuk. A külső CV a teljes kiválasztási eljárás értékelője: minden külső fold tanítórészén belül újra lefuttatjuk a teljes belső keresést, majd a kiválasztott modellt a külső teszt-részen egyszer értékeljük. Így a külső teszt soha nem vesz részt a hiperparaméter-választásban.

A kiválasztási torzítás (selection bias) a legfontosabb statisztikai veszély. Tegyük fel, hogy 20 jelölt valódi AUC-je egyaránt μ=0.70\mu = 0.70, de egy-egy belső CV becslés szórása σ=0.03\sigma = 0.03. A belső CV-pontszámok tehát véletlen zajjal terheltek. Ha kiválasztjuk a legjobbat, annak várható értéke nem μ\mu, hanem

E[maxiXi]=μ+σE[maxiZi], E\Big[\max_i X_i\Big] \;=\; \mu + \sigma\, E\Big[\max_i Z_i\Big],

ahol XiX_i a belső CV AUC-becslések, ZiZ_i standard normális változók, i=1,…,ni=1,\dots,n, és nn a jelöltek száma. Húsz jelöltnél a standardizált maximum várható értéke körülbelül 1.91.9, így a várható nyertes pontszám 0.70+0.03⋅1.9≈0.7570.70 + 0.03 \cdot 1.9 \approx 0.757. A nyertes pontszám tehát akkor is felfelé torzított, ha egyetlen jelölt sem jobb a többinél. Ezt a szimulációt a gyakori tévedésnél ellenőrizzük.

23.3.3 Teljes minimális munkapélda

Az alábbi kód szintetikus adatot generál, majd öt külső foldon belül tíz jelöltes véletlen keresést futtat három belső folddal. A külső tesztet a keresés soha nem látja. A példa a lab/pyproject.toml alap numerikus csomagjait használja; a kód a projektgyökérből futtatható.

from sklearn.datasets import make_classification
from sklearn.model_selection import StratifiedKFold, RandomizedSearchCV
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
import numpy as np
from scipy.stats import loguniform

# 1. Bemenet: 400 független szintetikus rendelés, 8 feature, bináris cél.
rng = np.random.default_rng(42)
X, y = make_classification(
    n_samples=400, n_features=8, n_informative=5, n_redundant=2,
    random_state=42, flip_y=0.12
)

# 2. Külső split: 5 rétegzett fold. A shuffle és a seed rögzített.
outer = StratifiedKFold(n_splits=5, shuffle=True, random_state=123)

# 3. A modell pipeline: skálázás + logisztikus regresszió.
pipe = Pipeline([
    ('scaler', StandardScaler()),
    ('clf', LogisticRegression(max_iter=1000, solver='liblinear'))
])

# 4. Keresési tér: C log-uniform eloszlásból, 10 jelölt.
param_dist = {'clf__C': loguniform(1e-3, 1e1)}

outer_scores = []
inner_best_scores = []
best_params_per_fold = []
n_fits = 0

# 5. Nested CV: minden külső foldban új keresést indítunk.
for train_idx, test_idx in outer.split(X, y):
    X_train, X_test = X[train_idx], X[test_idx]
    y_train, y_test = y[train_idx], y[test_idx]

    search = RandomizedSearchCV(
        pipe, param_dist, n_iter=10, cv=3, scoring='roc_auc',
        random_state=7, refit=True, n_jobs=1
    )
    search.fit(X_train, y_train)

    # Illesztésszám: 10 jelölt * 3 belső fold + 1 refit = 31.
    n_fits += search.n_iter * 3 + 1

    y_score = search.predict_proba(X_test)[:, 1]
    outer_scores.append(roc_auc_score(y_test, y_score))
    inner_best_scores.append(search.best_score_)
    best_params_per_fold.append(search.best_params_)

print("Külső fold AUC-k:", np.round(outer_scores, 3))
print("Külső AUC átlag:", np.mean(outer_scores).round(3))
print("Külső AUC szórás (ddof=1):", np.std(outer_scores, ddof=1).round(3))
print("Belső best_score-k:", np.round(inner_best_scores, 3))
print("Teljes illesztésszám:", n_fits)
print("Kiválasztott C-k:", best_params_per_fold)

Szemléltető kimenet (a pontos számok a véletlen generálás miatt enyhén eltérhetnek; a kód nem futtatott):

Külső fold AUC-k: [0.712 0.689 0.731 0.704 0.718]
Külső AUC átlag: 0.711
Külső AUC szórás (ddof=1): 0.016
Belső best_score-k: [0.752 0.741 0.768 0.755 0.749]
Teljes illesztésszám: 155
Kiválasztott C-k: [{'clf__C': 0.047...}, ...]

Ha nagyobb keresést futtatnánk, a n_jobs=-1 beállítás a joblib segítségével párhuzamosíthatná a belső foldokat, de ez nem változtatja meg az illesztések számát, csak a falidőt. A példában n_jobs=1 a tiszta költségelszámolás miatt szerepel.

23.3.4 Eredmény értelmezése és ellenőrzés

A külső fold AUC-k átlaga 0.711, ami alacsonyabb, mint a belső best_score értékek átlaga (0.753). Ez a különbség a kiválasztási torzítás gyakorlati jele: a belső CV-pontszám a keresési folyamat részeként optimista, míg a külső foldok a kiválasztott eljárást olyan részeken értékelik, amelyeket a keresés nem látott.

Kézi kontroll: a StratifiedKFold garantálja, hogy minden külső teszt halmaz diszjunkt a hozzá tartozó tanítóhalmaztól, és a foldok lefedik a teljes 400 elemű mintát. A teljes illesztésszámot a kód expliciten számolja: 5×(10×3+1)=1555 \times (10 \times 3 + 1) = 155. Ez a képlet minden nested CV futásnál ellenőrizhető invariáns.

A statisztikai értelmezésnél fontos korlát: a külső foldok AUC-i nem függetlenek, mert a tanítóhalmazok átfednek. A 0.016-os szórást ezért nem szabad konfidenciaintervallumként értelmezni. A külső CV átlaga jobb becslés, mint a belső nyertes pontszám, de a bizonytalanság pontos számszerűsítéséhez ismételt külső splitre vagy további statisztikai módszerre lenne szükség.

23.3.5 Feladatok

1. Értelmezés. Miért magasabb a belső best_score értékek átlaga, mint a külső fold AUC-k átlaga? Magyarázd meg a kiválasztási torzítás fogalmával, és nevezz meg legalább egy helyzetet, amikor a különbség nagyobb lehet.

2. Implementálás. Cseréld a RandomizedSearchCV-t GridSearchCV-re, és használd a {'clf__C': [0.01, 0.1, 1.0]} rácsot. Mennyi lesz a teljes illesztésszám? Módosítsd a kódot, és add meg az új külső AUC átlagot.

3. Hibakeresés. Egy kolléga az egész adaton futtat egy RandomizedSearchCV-t, majd a search.best_score_ értéket közli mint „külső validációs AUC 0.76”. Mi a hiba ebben az állításban? Milyen egyszerű ellenőrzést javasolnál a torzítás kimutatására?

4. Transzfer. Tegyük fel, hogy egy modellillesztés átlagos futási ideje a te gépeden 0.8 másodperc (ez becsült érték, nem mért). A jelenlegi 155 illesztés hány másodperc? Ha a jelöltek számát 20-ra, a belső foldok számát pedig 5-re növeled, mennyi lesz az illesztésszám és a várható futási idő? Ha a teljes futási időnek 5 perc alatt kell maradnia, milyen költségvetési döntést hoznál?

5. Bónusz értelmezés. Miért nem értelmezhető a külső foldok AUC-inak szórása konfidenciaintervallumként? Milyen további lépéssel lehetne a bizonytalanságot jobban becsülni?

23.3.6 Megoldások és indoklás

1. A belső best_score minden foldban a 10 jelölt közül a legjobb CV-pontszám. Mivel minden pontszám zajjal terhelt, a maximum várhatóan magasabb, mint a jelöltek valódi teljesítménye. A külső AUC viszont a kiválasztott modellt olyan tesztrészen méri, amelyet a keresés nem használt, ezért nem tartalmazza ezt a kiválasztási optimizmust. A különbség nagyobb, ha sok jelölt van, a belső foldok kicsik (nagyobb zaj), vagy a valódi hatás gyenge.

2. A GridSearchCV 3 jelöltet próbál, mindegyiket 3 belső foldon: 3×3+1=103 \times 3 + 1 = 10 illesztés belső ciklusonként. Öt külső folddal a teljes illesztésszám 5×10=505 \times 10 = 50. A kódban a RandomizedSearchCV helyett:

from sklearn.model_selection import GridSearchCV

param_grid = {'clf__C': [0.01, 0.1, 1.0]}
search = GridSearchCV(pipe, param_grid, cv=3, scoring='roc_auc', refit=True)

A külső AUC átlag szemléltető értéke 0.705 körül alakulhat; a pontos szám függ a seedtől és a környezettől. Az ellenőrzés: a teljes illesztésszámnak 50-nek kell lennie, és a külső AUC átlagnak közelebb kell lennie a valódi teljesítményhez, mint a belső best_score átlagnak.

3. A kolléga kétszeresen használja ugyanazt az adatot: a kereséshez és a „validációhoz” is. Az adaptív kiválasztás után a best_score_ már nem torzítatlan becslés; ez a selection bias klasszikus esete. Az ellenőrzés: osszuk az adatot egy friss, soha nem látott teszt részre, és mérjük ott a legjobb modell AUC-ját. Ha a friss AUC számottevően alacsonyabb, mint a best_score_, a torzítás kimutatható.

4. A jelenlegi futási idő: 155×0.8=124155 \times 0.8 = 124 másodperc, azaz körülbelül 2 perc. Az új beállítás: 5×(20×5+1)=5×101=5055 \times (20 \times 5 + 1) = 5 \times 101 = 505 illesztés, ami 505×0.8=404505 \times 0.8 = 404 másodperc, azaz körülbelül 6 perc 44 másodperc. Ez meghaladja az 5 perces keretet. Egy lehetséges döntés: 20 jelöltet tartunk, de a belső foldok számát 3-ra csökkentjük; ekkor 5×(20×3+1)=3055 \times (20 \times 3 + 1) = 305 illesztés, 305×0.8=244305 \times 0.8 = 244 másodperc, azaz körülbelül 4 perc. Másik lehetőség: 15 jelölt 5 belső folddal: 5×(15×5+1)=3805 \times (15 \times 5 + 1) = 380 illesztés, 304 másodperc, szintén 5 perc alatt. A döntés a belső foldok stabilitása és a keresési tér lefedettsége közötti kompromisszum.

5. A külső foldok tanítóhalmazai átfednek: minden fold a többi 4 fold adatának nagy részét tartalmazza. Emiatt a foldok AUC-i korreláltak, és a szórásuk alábecsüli a valódi mintavételi bizonytalanságot. A jobb becsléshez ismételt nested CV-t futtathatunk több különböző külső split véletlen maggal, vagy a teljes adaton bootstrap mintákat használhatunk, de ez utóbbi is feltételezi a megfigyelések függetlenségét. A pontosabb módszerek meghaladják ezen alfejezet keretét.

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

A leggyakoribb hiba: a legjobb CV-pontszámot a keresés után torzítatlan külső becslésként közölni. A következő szimuláció megmutatja, hogy ez akkor is félrevezető, ha minden jelölt azonos valódi teljesítményű.

import numpy as np

rng = np.random.default_rng(20261020)
n_candidates = 20
n_repeats = 2000
mu = 0.70
sigma = 0.03

maxima = np.empty(n_repeats)
for i in range(n_repeats):
    scores = rng.normal(mu, sigma, size=n_candidates)
    maxima[i] = scores.max()

print("Átlagos legjobb CV-pontszám:", maxima.mean().round(3))
print("Valódi közös AUC:", mu)
print("Optimizmus (bias):", (maxima.mean() - mu).round(3))

Szemléltető kimenet (a kód nem futtatott, a várható érték a normális közelítésből):

Átlagos legjobb CV-pontszám: 0.757
Valódi közös AUC: 0.7
Optimizmus (bias): 0.057

A szimuláció független normális eloszlást feltételez, ami a valóságban ritkán teljesül pontosan, de az intuíciót jól mutatja: 20 jelölt közül a legjobb pontszám várhatóan 0.76 körül lesz, pedig egyetlen jelölt sem jobb 0.70-nél. Ez a nested CV motivációja: a külső foldok átlagával a kiválasztási optimizmus nagy részét elkerüljük.

23.3.8 Önellenőrzés

23.3.9 Rövid összefoglaló

A hiperparaméter-keresés adaptív adathasználat, ezért a nyertes belső CV-pontszám felfelé torzított. A nested CV szétválasztja a kiválasztást és az értékelést: minden külső fold tanítórészén belül új teljes keresést futtatunk, majd a kiválasztott modellt a külső teszten egyszer értékeljük. A teljes költséget a foldok, a jelöltek és a refit szorzata adja; a példában 155 illesztés történt. A külső fold AUC-k átlaga közelebb áll a valódi általánosításhoz, de a foldok átfedése miatt a szórásuk nem konfidenciaintervallum. A következő alfejezetben a küszöb, kalibráció és ritka esemény problémáit vizsgáljuk ugyanezzel a korrektségi szemlélettel.