22.2 Fák, random forest és gradiensboosting

A lineáris modellek után most olyan prediktorokat nézünk, amelyek nem egyetlen 𝐗𝐰\mathbf X \mathbf w súlyozással, hanem egymásba ágyazott döntési szabályokkal írják le a célváltozót. Az alfejezet végére elkülönítjük a modellkapacitást, az ensemble varianciacsökkentését és a boosting iterációit, majd ugyanazon a Northstar-spliten hasonlítunk össze egy sekély fát, egy random forestet és egy hisztogram-alapú gradiensboostingot.

22.2.1 Cél és konkrét helyzet

A feladat a rendelés pillanatában becsülni, hogy a kézbesítés meghaladja-e a 7 napot. A megfigyelési egység egy rendelés, a cél a target_late bináris indikátor. Csak a rendeléskor ismert oszlopok használhatók: distance_km, priority_flag, basket_minor, quantity_sum, account_age_days, segment, region, acquisition_channel. A tanító és teszt splitet a Preprocessing tanulás kizárólag a training részen alfejezetben már megismert időbeli szerződés adja: tanítóba a 2025-05-01T00:00:00+00:00 cutoff előtt rendelt és cutoff előtt megérkezett címkéjű rendelések kerülnek, tesztbe a cutofftól kezdődő rendelések; a kihagyott határsávot nem használjuk. A mérési keret két metrika: ROC AUC és Brier-pontszám, mindkettő a prediktált valószínűségből számítva.

Induló becslésként azt várjuk, hogy a max_depth=2 fa viszonylag alacsony AUC-t és magas Brier-értéket ad, mert kevés szabályt tud megfogalmazni; a random forest és a boosting jobb, de a Northstar adat kevés erős jel miatt AUC-ban valószínűleg csak 0.60–0.70 között mozog. Az alábbi konkrét számok szemléltető várakozások, nem mért eredmények.

22.2.2 Döntési fa: kapacitás, mélység, levélméret

A döntési fa (decision tree) bináris fa: a gyökérből kiindulva minden belső csomópont egy igen/nem kérdést tesz fel egyetlen jellemzőre, például distance_km <= 20. A levelek (leaf) adják a predikciót. Osztályozásnál egy levél a pozitív osztály arányával becsli a valószínűséget.

A fa építésekor a split jóságát tisztasági mértékkel értékeljük. A Gini-szennyezettség (Gini impurity) egy nn elemű csomópontra:

G(S)=1−∑k=1Kpk2,G(S) = 1 - \sum_{k=1}^{K} p_k^2,

ahol pkp_k a kk-adik osztály aránya a csomópontban, KK az osztályok száma. Bináris cél esetén K=2K=2, így G(p)=2p(1−p)G(p)=2p(1-p), ahol pp a pozitív osztály aránya. A lehetséges split súlyozott szennyezettsége:

Gsplit=nLnG(SL)+nRnG(SR),G_{\text{split}} = \frac{n_L}{n}G(S_L) + \frac{n_R}{n}G(S_R),

ahol nLn_L és nRn_R a bal és jobb gyerek mintaszáma. A fa azt a splitet választja, amelyik a legnagyobb szennyezettség-csökkenést adja.

Kézi ellenőrzésként vegyünk hat rendelést:

Rendelés distance_km target_late
A 5 0
B 12 0
C 20 0
D 30 1
E 80 1
F 120 1

A gyökérben p=3/6=0.5p=3/6=0.5, tehát G=0.5G=0.5. A distance_km <= 20 split balra az A–C rendeléseket viszi (mind 0), jobbra a D–F rendeléseket (mind 1). Mindkét gyerek Gini-értéke 0, a súlyozott Gini is 0, így a javulás 0.5. A kapott fa mélysége 1, leveleinek száma 2.

A fa kapacitását két hiperparaméter szabályozza:

A fa a jellemzőteret téglalapokra osztja, ezért nem lineáris modell, de a becsült válaszfelület lépcsős. A max_depth=2 fa legfeljebb 22=42^2=4 levelet hozhat létre.

Gini-szennyezettség bináris cél esetén - Számított függvénypélda: Gini(p)=2p(1-p), nem mérés.

22.2.3 Random forest: varianciacsökkentés

Egyetlen fa nagy varianciájú becslő: ha a tanító adat kissé megváltozik, a kiválasztott splitek és a predikciók is erősen elmozdulhatnak. Az ensemble ötlete: sok fát tanítunk, és a predikcióikat átlagoljuk.

A random forest két véletlenítést használ. Először minden fát más bootstrap mintán tanít: BB darab, nn méretű visszatevéses mintát húzunk az eredeti tanító sorból. Másodszor minden splitnél a jellemzőknek csak egy véletlen részhalmazát engedjük meg; osztályozásnál tipikusan p\sqrt{p} darabot. Az xx pontra a pozitív osztály becsült valószínűsége:

p̂RF(x)=1B∑b=1Bp̂b(x),\hat p_{\mathrm{RF}}(x) = \frac{1}{B}\sum_{b=1}^{B}\hat p_b(x),

ahol p̂b(x)\hat p_b(x) a bb-edik fa levélbeli aránya. Az átlagolás csökkenti a varianciát, mert a fák hibái részben kioltják egymást. Ha a fák függetlenek lennének, akkor az átlag szórása σ/B\sigma/\sqrt{B} lenne, de a gyakorlatban a fák korrelálnak, ezért a csökkenés kisebb. A véletlen jellemzőválasztás éppen ezt a korrelációt csökkenti.

A fontos különbség: a random forest bagging (bootstrap aggregating) típusú ensemble, minden fát teljesen felépít, és a kombinálás a végén történik. Nincs iteratív javítás.

22.2.4 HistGradientBoosting és early stopping

A gradiensboosting (gradient boosting) ezzel szemben iteratív additív modellt épít:

Fm(x)=Fm−1(x)+ηhm(x),F_m(x) = F_{m-1}(x) + \eta\, h_m(x),

ahol FmF_m az mm iteráció utáni modell, η\eta a tanulási ráta (learning rate), hmh_m pedig egy kis fa, amely a veszteség negatív gradiensét közelíti. Bináris log-loss cél esetén a log-odds szerinti gradiens yi−piy_i - p_i, tehát az új fa a reziduumokra illeszkedik. Minden iteráció egy kicsit javít a hibán; a max_iter az iterációk maximális száma.

A HistGradientBoostingClassifier a scikit-learn hisztogram-alapú megvalósítása: a jellemzőket hisztogramokba sorolja, és a splitkeresést ezeken végzi, ami gyorsabb, mint a klasszikus gradiensboosting. Ugyanúgy tanulási rátát, levélkorlátot és iterációkat használ.

Az early stopping (korai leállítás) az iterációk számának megválasztását oldja meg. A modell a tanító adatból leválaszt egy kis belső validációs részt, például validation_fraction=0.1, és minden iteráció után kiszámítja a validációs veszteséget. Ha az elmúlt n_iter_no_change iterációban nem javult, a modell visszaáll a legjobb iterációra. Ez a validációs rész továbbra is a tanító időbeli tartományából származik; a külső tesztet nem használhatjuk a leállításhoz, mert akkor az értékelés optimistává válik.

22.2.5 Minimális munkapélda

A következő script a lab/orders.csv és lab/customers.csv fájlokat használja. A munkakönyvtár a projekt gyökere, ahonnan a futtatás uv run python lab/s086_ensemble_compare.py. A kód a splitet az order_ts és label_available_at szerint képzi, így a tanítóban csak olyan címkék vannak, amelyek a cutoff előtt már megérkeztek. A kimenetek szemléltető értékek, mert a kapcsolódó példaprojektben még nem ellenőrzött.

# lab/s086_ensemble_compare.py
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import RandomForestClassifier, HistGradientBoostingClassifier
from sklearn.metrics import roc_auc_score

orders = pd.read_csv(
    "lab/orders.csv",
    parse_dates=["order_ts", "actual_delivery_at", "label_available_at"],
)
customers = pd.read_csv("lab/customers.csv", parse_dates=["signup_date"])

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

cutoff = pd.Timestamp("2025-05-01T00:00:00+00:00")
train = orders[(orders["order_ts"] < cutoff) &
               (orders["label_available_at"] < cutoff)]
test = orders[orders["order_ts"] >= cutoff]

feature_cols = [
    "distance_km", "priority_flag", "basket_minor", "quantity_sum",
    "account_age_days", "segment", "region", "acquisition_channel",
]
target = "target_late"

X_train = train[feature_cols]
y_train = train[target]
X_test = test[feature_cols]
y_test = test[target]

numeric_cols = ["distance_km", "basket_minor", "quantity_sum",
                "account_age_days", "priority_flag"]
categorical_cols = ["segment", "region", "acquisition_channel"]

preprocessor = ColumnTransformer([
    ("num", "passthrough", numeric_cols),
    ("cat", OneHotEncoder(handle_unknown="ignore"), categorical_cols),
])

def make_model(name):
    if name == "tree_depth2":
        model = DecisionTreeClassifier(
            max_depth=2, min_samples_leaf=50,
            random_state=20261020,
        )
    elif name == "random_forest":
        model = RandomForestClassifier(
            n_estimators=300, max_depth=6, min_samples_leaf=20,
            random_state=20261020, n_jobs=-1,
        )
    elif name == "hist_gb":
        model = HistGradientBoostingClassifier(
            max_iter=300, learning_rate=0.08, max_leaf_nodes=15,
            min_samples_leaf=30, early_stopping=True,
            validation_fraction=0.1, n_iter_no_change=10,
            random_state=20261020,
        )
    else:
        raise ValueError(name)
    return Pipeline([("prep", preprocessor), ("model", model)])

for name in ["tree_depth2", "random_forest", "hist_gb"]:
    pipe = make_model(name)
    pipe.fit(X_train, y_train)
    proba = pipe.predict_proba(X_test)[:, 1]
    auc = roc_auc_score(y_test, proba)
    brier = float(((proba - y_test) ** 2).mean())
    print(f"{name:14s} AUC={auc:.3f}  Brier={brier:.3f}  "
          f"n_iter={getattr(pipe[-1], 'n_iter_', 'n/a')}")

Az elméletileg várt, nem mért kimenet:

tree_depth2    AUC=0.610  Brier=0.205  n_iter=n/a
random_forest  AUC=0.650  Brier=0.195  n_iter=n/a
hist_gb        AUC=0.668  Brier=0.192  n_iter=120

A hist_gb utolsó oszlopa a n_iter_ attribútumból származik; mivel az early stopping megállhat a max_iter=300 előtt, a tényleges iterációszám attól függ, mikor állt le a belső validációs veszteség. A fa és a random forest ilyen attribútumot nem ad, ezért ott n/a szerepel.

Kézi kontroll: a max_depth=2 fa leveleinek száma nem haladhatja meg a 22=42^2=4-et; ezt a tanuló modellben pipe[-1].tree_.n_leaves_ <= 4 ellenőrzi. A random forest predikciói a fák átlagai, ezért minden érték a [0,1][0,1] intervallumba esik. A Brier-pontszám a négyzetes hiba átlaga, ezért mindig nemnegatív; egy nulla lenne tökéletes, a 0.2 körüli érték szerény jelre utal. A proba shape-je (n_test,) float64 dtype, ahol n_test a teszt rendelések száma.

22.2.6 Eredmény értelmezése

A várt sorrend a modellkapacitás és a varianciacsökkentés kombinációjából következik. A sekély fa csak néhány szabályt tud, ezért a legrosszabb. A random forest ugyanolyan mélységű fákat használ, de a bootstrap és a véletlen jellemzőválasztás miatt az átlag simább és stabilabb. A HistGradientBoosting iteratívan csökkenti a log-loss-t, ezért jellemzően a legjobb AUC-t adja, feltéve, hogy a tanulási ráta elég kicsi és az early stopping nem áll le túl korán.

A különbség mégsem garantált: ha az adatban alig van lineárisan megfogható jelméret, mindhárom modell hasonló lehet. Az AUC csak a sorrendiséget méri; a Brier-pontszám viszont a valószínűségek pontosságát is bünteti. Egy fa ritka levélben torzított valószínűséget adhat, ezért az ensemble Brier-értéke általában jobb. A döntési küszöböt nem optimalizáljuk a teszten; a küszöb üzleti döntés, nem az AUC része.

Az early stopping bekapcsolásakor a belső validációs veszteség a tanító rész 10%-án számítódik. Ha az adat időben trendet tartalmaz, a véletlen validation_fraction nem feltétlenül reprezentálja a teszt időszakát; ezt a Keresztvalidáció a függőségi szerkezethez alfejezetben tárgyalt időbeli keresztvalidáció kezeli majd. Itt egyetlen split mellett nem vonhatunk le erős állítást a modellcsaládok általános rangsoráról.

A fák és boostingmodellek összevetésekor azonos train/validation felosztást, adatfüggetlen preprocessinget és előre kiválasztott metrikát alkalmazzunk. A hiperparaméterek teszthalmazon történő hangolása érvényteleníti a végső tesztet.

22.2.7 Feladatok

  1. Értelmezés. A max_depth=2, min_samples_leaf=50 fa a tanuló modellben 3 levelet hozott létre, pedig a mélység megengedné a 4-et. Milyen mechanizmus akadályozhatja a negyedik levél létrejöttét? Fogalmazza meg a fa három szabályát, ha a gyökér distance_km <= 55, a bal ág priority_flag <= 0.5 és a jobb ág basket_minor <= 7200 volt.

  2. Implementálás. A tanító részt belsőleg ossza időrendben utolsó 20%-os validációs blokkra, és ezen a belső blokkon válassza ki a max_leaf_nodes értéket a [8, 15, 31] listából Brier-pontszám alapján. A külső tesztet ne érintse a választás során. Írja meg a HistGradientBoostingClassifier-rel a választó és kiértékelő függvényt.

  3. Hibakeresés. Egy kolléga az early stoppingot validation_fraction=0.5 értékkel futtatta, és a HistGradientBoosting teszt AUC-ja a várt 0.65 helyett 0.58 lett. Mi a legvalószínűbb magyarázat? Milyen diagnosztikát nézne meg, és milyen beállítást próbálna ki először?

  4. Megváltozott feltétel. A szállítási hálózat átalakítása után a teszt időszakban a distance_km mediánja 25-ről 80 km-re nő, a tanító időszak változatlan marad. Milyen minőségi változásra számít a fa és a boosting esetén? Melyik diagnosztika mutatná ki a problémát a modell újratanítása nélkül?

22.2.8 Megoldások és indoklás

1. megoldás. A min_samples_leaf=50 korlát miatt nem jön létre olyan split, amelynek valamelyik gyereke 50-nél kevesebb tanítómintát kapna. Ezért a fa korábban megáll, mint a mélységi maximum. A három szabály a fenti split-feltételezéssel:

A pontos szabályok persze a tanított splitektől függenek; a lényeg az, hogy a min_samples_leaf felülírhatja a max_depth-et.

2. megoldás. A belső validációs blokk a tanító rész utolsó 20%-a, időrendben:

split_idx = int(len(X_train) * 0.8)
X_inner_train = X_train.iloc[:split_idx]
y_inner_train = y_train.iloc[:split_idx]
X_inner_val = X_train.iloc[split_idx:]
y_inner_val = y_train.iloc[split_idx:]

best = None
best_score = float("inf")
for leaves in [8, 15, 31]:
    base = Pipeline([
        ("prep", preprocessor),
        ("model", HistGradientBoostingClassifier(
            max_leaf_nodes=leaves, learning_rate=0.08,
            max_iter=300, early_stopping=False,
            random_state=20261020,
        )),
    ])
    base.fit(X_inner_train, y_inner_train)
    proba = base.predict_proba(X_inner_val)[:, 1]
    score = float(((proba - y_inner_val) ** 2).mean())
    if score < best_score:
        best_score = score
        best = leaves

print(f"Választott max_leaf_nodes: {best}, belső Brier: {best_score:.3f}")
final = Pipeline([
    ("prep", preprocessor),
    ("model", HistGradientBoostingClassifier(
        max_leaf_nodes=best, learning_rate=0.08,
        max_iter=300, early_stopping=False,
        random_state=20261020,
    )),
])
final.fit(X_train, y_train)
test_proba = final.predict_proba(X_test)[:, 1]
print(f"Teszt AUC: {roc_auc_score(y_test, test_proba):.3f}")

Az invariáns az, hogy a best csak a belső validációs blokkon számított Brier-értéken alapul. A külső teszt AUC-t csak a kiválasztott modellen számoljuk ki. A tolerancia a Brier-pontszámnál nem numerikus tűrés, hanem a kiválasztott max_leaf_nodes értékkészletben szereplő egész szám.

3. megoldás. A validation_fraction=0.5 a tanító adat felét elveszi a tanulástól: a modell kevesebb adaton tanul, a korai leállítás pedig a validációs veszteség zajos becslése miatt túl korán megállhat, ezért alultanul. A diagnosztika: rajzoljuk ki a belső validációs veszteséget az iterációk függvényében, és ellenőrizzük a n_iter_ értékét. Ha az iterációszám nagyon alacsony, például 10 alatt van, akkor a leállítás túl korai. Első kísérletként validation_fraction=0.1 vagy 0.2, n_iter_no_change=10, és nagyobb max_iter. A tesztet továbbra sem használjuk a leállításhoz.

4. megoldás. A fa és a boosting is a tanult jellemzőeloszlásra kalibrál. A távolság mediánjának eltolódása covariate shift: a levelek olyan tartományba esnek, ahol a tanító adat ritka volt. A fa lépcsős predikciója a régóta megtanult levélarányokat adja vissza, ezért várhatóan túl magas vagy túl alacsony lesz a késés valószínűsége; a boosting is torzul, mert a fák splithatárai a régi eloszlásra optimalizáltak. A Brier-pontszám romlik, az AUC is csökkenhet. Diagnosztika: a distance_km hisztogramjának vagy empirikus eloszlásfüggvényének összehasonlítása train és teszt között, valamint a prediktált valószínűségek eloszlásának változása. Ha a shift nagy, a modell újratanítása vagy a döntési küszöb újravalidálása szükséges.

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

Gyakori tévedés, hogy a fa és az ensemble „immunis” a rossz adatkezelésre, mert nem igényel skálázást. Az elméleti küszöbös döntési fa a szigorúan monoton jellemzőtranszformációkra lényegében invariáns (véges numerikus pontosságnál és közelítő binningnél eltérések lehetségesek), de nem immunis a target leakage-re és az eloszlásváltozásra. Ha például a tiltott delivery_days oszlop véletlenül bent marad a jellemzők között, a fa egyetlen splitet tesz rá, és a teszten is közel tökéletes AUC-t mutat; ez szivárgás, nem modellteljesítmény. Ugyanez a helyzet, ha a customer_id-t feature-ként adjuk át: a fa memorizálhatja az ügyfeleket. Az eloszlásváltozás ellenpéldája a 4. feladat covariate shiftje: a régi distance_km kvantilisekre épített split az új tartományban hamis bizonyosságot adhat. A tanulság visszautal Preprocessing tanulás kizárólag a training részen alfejezetre: a preprocessing és a modellválasztás mindig a tanító részen belül történjen, és a predikciós időpontban ismert adatokra korlátozódjon.

Önellenőrző kérdések:

Válaszok: a varianciát; a fák korrelálnak; a belső tanító-validációs részen, mert a teszt használata optimista értékelést okozna.

22.2.10 Rövid összefoglaló

A döntési fa kis Gini-szennyezettségű splitekkel lépcsős döntési szabályt épít; a max_depth növeli, a min_samples_leaf csökkenti a kapacitást. A random forest bootstrap mintákon tanított, véletlen jellemzőválasztású fák átlagával csökkenti a varianciát. A HistGradientBoosting ezzel szemben iteratívan ad kis fákat a veszteség negatív gradienséhez, tanulási rátával és belső validáción alapuló early stoppinggal. A Northstar laborban mindhárom modellt ugyanazon időbeli spliten, AUC-val és Brier-pontszámmal hasonlítjuk össze; a boosting várhatóan jobb, de a különbséget validálni kell. Következő lépésként Távolság, kernel és méretkorlát alfejezetben a távolságalapú modelleket és azok méretkorlátait vizsgáljuk.