22.4 Modellcsalád választása tudományos jegyzőkönyvvel

22.4.1 Cél és konkrét helyzet

A csapat a Predikciós szerződés és hibateszt predikciós szerződését követve a Northstar rendelésadataiban azt szeretné becsülni, hogy egy rendelés kézbesítése meghaladja-e a 7 napos ígéretet. A megfigyelési egység egy order_id; a célváltozó a rendeléskor még ismeretlen target_late, a predikció pillanata az order_ts. A rendeléskor ismert feature-ök: distance_km, priority_flag, basket_minor, quantity_sum, account_age_days, segment, region, acquisition_channel. A cél egy dokumentált, előre rögzített összehasonlító kísérlet: három modellcsalád közül választunk, nem utólag a legszebb számot keresve.

Induló becslés a megoldás előtt: a nullmodell, amely minden rendelésre a tanítóbeli pozitív arányt adja, a lehető legegyszerűbb baseline. Ha a tanító target_late aránya például 22%, akkor az entrópiás nullmodell log loss-a körülbelül 0,53. Azt várjuk, hogy a távolság és a kosárérték legalább enyhe jelet ad, ezért a validációs log loss a baseline alá kerül; ugyanakkor a 2000-nél kisebb tanítóhalmaz miatt a faensemble-ek varianciája már számít. Ennél pontosabb, nem mért jóslatot nem állíthatunk: a számokat a helyi futtatás hozza létre.

22.4.2 Induktív torzítás: mit feltételez a modellcsalád akkor is, ha az adat nem dönt?

Az induktív torzítás (inductive bias) az a feltevéskészlet, amelyet egy modellcsalád akkor is alkalmaz, amikor a minta nem határozza meg egyértelműen a szabályt. Nem hiba, hanem a tanulás előfeltétele: az összes lehetséges osztályozó közül valahogyan szűkíteni kell.

Kis ellenpélda: legyen két input, x1,x2∈{0,1}x_1, x_2 \in \{0,1\}, és y=x1⊕x2y = x_1 \oplus x_2 (XOR). Négy pont:

x1x_1 x2x_2 yy
0 0 0
0 1 1
1 0 1
1 1 0

A lineáris logisztikus modell legjobb becslése minden pontra p̂=0,5\hat p = 0{,}5, mert nincs olyan egyenes, amely a XOR-pontokat szétválasztja. Ennek log loss-a −ln0,5=0,693-\ln 0{,}5 = 0{,}693. Egy döntési fa viszont két tengelypárhuzamos vágással tökéletesen leírja a szabályt, log loss-a 0 lesz. Ez kizárólag az induktív torzítás különbségét mutatja: a lineáris modell sima, korlátos határt feltételez; a fa darabonként konstans, tengelypárhuzamos szabályokat.

A három jelölt család torzítása:

A Távolság, kernel és méretkorlát alfejezetben látott kNN ezzel szemben lokális egyenletességet feltételez: a közeli pontok címkéi hasonlók. Ez a torzítás nagy dimenzióban és rossz skálázás mellett drámaian gyengülhet; az ott tanult skálázási követelményt itt a lineáris modellnél is megőrizzük.

22.4.3 Kapacitás, variancia és hibamérleg

A választás mögött ugyanaz a kapacitás–variancia feszültség áll, amelyet a Sajátérték, SVD és dimenziócsökkentés előképe irányfelbontásnál és a Távolság, kernel és méretkorlát méretkorlátjánál már érintettünk. Négyzetes veszteségre egy rögzített xx feature-vektor mellett:

E𝒟[(y−f̂(x;𝒟))2∣x]=(f(x)−E𝒟[f̂(x;𝒟)])2+E𝒟[(f̂(x;𝒟)−E𝒟[f̂(x;𝒟)])2]+σ2. E_{\mathcal D}\left[\left(y - \hat f(x;\mathcal D)\right)^2 \mid x\right] = \left(f(x) - E_{\mathcal D}[\hat f(x;\mathcal D)]\right)^2 + E_{\mathcal D}\left[\left(\hat f(x;\mathcal D) - E_{\mathcal D}[\hat f(x;\mathcal D)]\right)^2\right] + \sigma^2 .

Jelölések: y=f(x)+εy = f(x) + \varepsilon a zajos cél, ε\varepsilon várható értéke 0, varianciája σ2\sigma^2; f̂(x;𝒟)\hat f(x;\mathcal D) a 𝒟\mathcal D tanítómintára illesztett modell becslése az xx pontban; a várható érték az 𝒟\mathcal D mintavételi eloszlására vonatkozik. Minden tag skalár egy rögzített xx-re. A három tag rendre: torzításnégyzet, variancia, irreducibilis zaj. Bináris osztályozásban a log loss-ra nincs ugyanez a tiszta felbontás, de a logika irányadó: nagyobb kapacitás csökkentheti a torzítást, miközben növeli a varianciát; kis minta a varianciát bünteti.

A nullmodell torzítása nagy, varianciája kicsi. A 100–200 fás ensemble kis mintán már érezhető varianciát vihet a validációs log loss-ba, különösen akkor, ha a validhalmaz kicsi.

22.4.4 Előre rögzített szempontok

A jegyzőkönyv a következő hat dimenziót fagyasztja be, még mielőtt bárki megnézné a számokat.

22.4.5 A jegyzőkönyv és a döntési kapu

decision_protocol:
  target: target_late
  observation_unit: order_id
  prediction_time_ref: order_ts
  split:
    test_cutoff: "2025-05-01T00:00:00+00:00"
    valid_fraction_of_train: 0.2
    purge_rule: train_cimket_cutoff_elott_ismerni_kell
  baseline: prior_model
  primary_metric: valid_log_loss
  guardrail_metric: valid_roc_auc
  latency_gate_p95_ms: 10
  acceptance_rules:
    - valid_log_loss <= 0.95 * baseline_log_loss
    - valid_roc_auc >= 0.5
    - p95_predict_ms <= 10
  tie_break:
    - prefer lower valid_log_loss
    - if diff < 0.005: prefer lower p95_predict_ms
    - if still close: prefer stronger interpretability
  candidates: [logistic, rf, hgb]
  forbidden_test_use: true

Két munkahelyi mozzanat fontos. Először: a validhalmaz a cutoff előtt címkével rendelkező rekordok késői időszakából jön; a tanítás címkéit ezen belül is a validáció kezdetén ismert címkékre korlátozzuk. Ez retrospektív modellszelekció, nem teljes előrecsúszó backtest. A validációs méréskor a validcímkéket utólag használjuk, a tesztet nem nézzük meg a választás előtt. Másodszor: a három jelöltet azonos validhalmazon, azonos előfeldolgozási szerződéssel, azonos seeddel futtatjuk. Azt mérjük, hogy a modellcsalád hogyan teljesít az adott feladaton, nem katalógusígéreteket. A p95-kapuhoz azonos gépen, bemelegítés után külön-külön mérjük az egyrekordos predict_proba hívások teljes idejét, beleértve a pipeline előfeldolgozását. A batch-átbocsátást külön metrikaként érdemes rögzíteni.

22.4.6 Teljes minimális munkapélda

A következő példafájl helye lab/model_protocol.py; a repó gyökeréből python lab/model_protocol.py paranccsal indítható (telepített függőségek mellett). Az adatútvonalak a szkript helyéhez képest feloldott abszolút útvonalak, ezért nem függnek az indításkori munkakönyvtártól. A kimenet környezetfüggő; az automatikus, teljes adatkészletes ellenőrzés a kapcsolódó példaprojekt feladata.

# lab/model_protocol.py — modellcsalád-választási jegyzőkönyv
import time
from pathlib import Path

import numpy as np
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.ensemble import HistGradientBoostingClassifier, RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import log_loss, roc_auc_score
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

ROOT = Path(__file__).resolve().parent
CUTOFF = pd.Timestamp("2025-05-01T00:00:00+00:00")

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

df = orders.merge(
    customers[
        ["customer_id", "signup_date", "segment", "region", "acquisition_channel"]
    ],
    on="customer_id",
)
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"
FORBIDDEN = {
    "delivery_days", "actual_delivery_at", "label_available_at",
    "order_id", "customer_id",
}
assert set(FEATURES).isdisjoint(FORBIDDEN), "tiltott oszlop került a feature-ök közé"

# Retrospektív validáció a CUTOFF előtt címkével már rendelkező rekordokon.
# A validáció kezdő időpontjánál a train-címkéknek is rendelkezésre kell állniuk.
train_all = df[(df["order_ts"] < CUTOFF) & (df["label_available_at"] < CUTOFF)].copy()
test = df[df["order_ts"] >= CUTOFF].copy()
purged = df[(df["order_ts"] < CUTOFF) & (df["label_available_at"] >= CUTOFF)]
print(f"train_all={len(train_all)} purged={len(purged)} test={len(test)}")
if train_all.empty:
    raise ValueError("Nincs a teszt-cutoff előtt ismert címkéjű rekord")
valid_cutoff = train_all["order_ts"].quantile(0.8)
train = train_all[
    (train_all["order_ts"] < valid_cutoff)
    & (train_all["label_available_at"] < valid_cutoff)
].copy()
valid = train_all[train_all["order_ts"] >= valid_cutoff].copy()
print(f"train={len(train)} valid={len(valid)}")
if train.empty or valid.empty:
    raise ValueError("Üres train/valid halmaz; ellenőrizd az idő- és címkecutoffot")
if train[TARGET].nunique() < 2 or valid[TARGET].nunique() < 2:
    raise ValueError("AUC-hoz és modellillesztéshez mindkét osztálynak jelen kell lennie")

base_p = float(np.clip(train[TARGET].mean(), 1e-12, 1 - 1e-12))
baseline_p = np.full(len(valid), base_p, dtype=float)
baseline_log_loss = log_loss(valid[TARGET], baseline_p, labels=[0, 1])
gate = 0.95 * baseline_log_loss
print(f"baseline_log_loss={baseline_log_loss:.4f} gate={gate:.4f}")

CAT_COLS = ["segment", "region", "acquisition_channel"]
NUM_COLS = ["distance_km", "priority_flag", "basket_minor", "quantity_sum", "account_age_days"]


def build(name: str) -> Pipeline:
    if name == "logistic":
        prep = ColumnTransformer([
            ("num", StandardScaler(), NUM_COLS),
            ("cat", OneHotEncoder(handle_unknown="ignore"), CAT_COLS),
        ])
        model = LogisticRegression(max_iter=1000, C=1.0)
    elif name == "rf":
        prep = ColumnTransformer([
            ("num", "passthrough", NUM_COLS),
            ("cat", OneHotEncoder(handle_unknown="ignore"), CAT_COLS),
        ])
        model = RandomForestClassifier(
            n_estimators=200, max_depth=8, min_samples_leaf=5,
            random_state=20261020, n_jobs=-1,
        )
    elif name == "hgb":
        prep = ColumnTransformer([
            ("num", "passthrough", NUM_COLS),
            ("cat", OneHotEncoder(handle_unknown="ignore", sparse_output=False), CAT_COLS),
        ])
        model = HistGradientBoostingClassifier(
            max_iter=100, learning_rate=0.05, max_depth=3,
            l2_regularization=0.1, random_state=20261020,
        )
    else:
        raise ValueError(name)
    return Pipeline([("prep", prep), ("model", model)])

rows = []
for name in ["logistic", "rf", "hgb"]:
    pipe = build(name)

    t0 = time.perf_counter()
    pipe.fit(train[FEATURES], train[TARGET])
    fit_s = time.perf_counter() - t0

    # Egyrekordos end-to-end latency (nem batch-idő / elemszám).
    # Bemelegítés után külön mérünk legalább 100 egyrekordos hívást.
    if valid.empty:
        raise ValueError("Üres validációs halmaz: latency és metrika nem számolható")
    probe = valid[FEATURES].iloc[[0]]
    for _ in range(10):
        pipe.predict_proba(probe)
    lat = []
    for i in range(100):
        one_row = valid[FEATURES].iloc[[i % len(valid)]]
        t0 = time.perf_counter()
        pipe.predict_proba(one_row)
        lat.append((time.perf_counter() - t0) * 1000)
    p95_ms = float(np.percentile(lat, 95))
    # A batch throughput külön mérendő; nem helyettesíti a p95 latency-t.

    p = pipe.predict_proba(valid[FEATURES])[:, 1]
    ll = log_loss(valid[TARGET], p)
    auc = roc_auc_score(valid[TARGET], p)
    rows.append((name, fit_s, p95_ms, ll, auc))
    print(
        f"{name:9s} fit_s={fit_s:6.2f} p95_ms={p95_ms:7.3f} "
        f"valid_log_loss={ll:.4f} valid_auc={auc:.4f}"
    )

accepted = [
    row for row in rows
    if row[3] <= gate and row[4] >= 0.5 and row[2] <= 10.0
]
if not accepted:
    decision = "baseline"
else:
    # először valid_log_loss, majd p95 latency szerint rendezünk
    accepted.sort(key=lambda row: (row[3], row[2]))
    decision = accepted[0][0]
print(f"decision={decision}")

Kézi kontroll: ha a validációs halmaz pozitív aránya éppen megegyezik a tanítóhalmaz p=0,22p = 0{,}22 arányával, a konstans baseline log loss-a −(0,22ln0,22+0,78ln0,78)≈0,526-\left(0{,}22\ln 0{,}22 + 0{,}78\ln 0{,}78\right) \approx 0{,}526. Eltérő validációs prevalencia mellett a tényleges log loss más: ezért a kaput a rögzített tanítóarányból előállított valószínűségekkel, a validációs címkéken számítjuk. A XOR-példa másik független kontrollja: a mindig p̂=0,5\hat p = 0{,}5 modellt adó osztályozó log loss-a −ln0,5≈0,693-\ln 0{,}5 \approx 0{,}693; ezt a log loss definíciójából kézzel megkapjuk.

22.4.7 Eredmény értelmezése

A log loss definíciója a validhalmazra:

ℓlog=−1n∑i=1n[yilnp̂i+(1−yi)ln(1−p̂i)], \ell_{\log} = -\frac{1}{n}\sum_{i=1}^{n}\left[y_i \ln \hat p_i + (1-y_i)\ln(1-\hat p_i)\right],

ahol yi∈{0,1}y_i \in \{0,1\} az ii-edik rendelés megfigyelt késésjelzője, p̂i∈(0,1)\hat p_i \in (0,1) a modell becsült pozitív valószínűsége; a bemeneti tömbök alakja (n,)(n,), az eredmény skalár. A p̂i=1\hat p_i = 1 numerikusan instabil lenne; a gyakorlati implementációk ezért kis epszilonnal vágják a valószínűséget. A validációs log loss nem vihető át automatikusan más időszakra: azt az adott validablakra, a modellépítés pillanatában mértük.

Három azonnali diagnosztikai szabály:

A kód által kiírt decision tehát az előre rögzített kapuból következik; a tesztet legfeljebb a kiválasztott modell egyszeri jelentéséhez nyitjuk meg, és akkor is beírjuk a jegyzőkönyvbe a döntés időpontjában ismeretlen státuszt.

A három modellcsalád összehasonlításához előzetesen különítsük el a hiperparaméter-választásra használt validációt a lezárt teszttől; egyetlen eredménykülönbségből ne következtessünk általános modellfölényre.

22.4.8 Feladatok

  1. Értelmezés: A csapat egyik tagja accuracy szerint választana modellt. Magyarázza el a log loss előnyét ebben a döntésben, és fogalmazza meg saját szavakkal, miért lehet a logisztikus regresszió kedvezőbb kis tanítóhalmaz mellett.
  2. Implementálás: Futtassa a lab/model_protocol.py fájlt a repó gyökeréből (python lab/model_protocol.py), a lab/data/orders.csv és lab/data/customers.csv bemenetekkel. Rögzítse a kiírt train/valid/test méreteket, a három modell valid log loss-át, egyrekordos p95 predikciós idejét és a végső decision értékét. Írja le a futtató környezet verzióját és a mérési parancsot.
  3. Hibadiagnózis: Egy kolléga a következő eredményt küldi: valid log loss = 0,02, valid AUC = 0,99, teszt log loss = 0,68. Milyen hiba a legvalószínűbb, és milyen két ellenőrző kódot futtatna ennek megerősítésére?
  4. Megváltozott feltétel: A modellt egy mobilalkalmazásban, rendelésenként legfeljebb 15 ms p95 predikciós idővel szeretnék futtatni. A modellfájl méretére 2 MB felső korlátot adnak. Melyik jelölt hogyan viselkedik? Írjon három mérési lépést, amellyel a modellméret, a p95 latency és a valid log loss kompromisszumát kísérletileg összehasonlítaná.
  5. Átvitel: Tegyük fel, hogy a késés valószínűségét nem sorrendbe állításra, hanem közvetlenül egy 20%-os riasztási küszöbhöz használják. Hogyan változtatná meg a jegyzőkönyv uncertainty és primary_metric sorát?

22.4.9 Megoldások és indoklás

  1. Az accuracy egy küszöbnél binarizál, és elrejti, mennyire bizonytalan a modell; a p̂=0,51\hat p=0{,}51 és a p̂=0,99\hat p=0{,}99 ugyanabba az osztályba eshet. A log loss a valószínűség pontosságát is bünteti, ezért a késési riasztásnál a bizonytalan, de rendezésben jó modell is feltűnik. Kis mintán a logisztikus regresszió kevesebb paramétert illeszt, a varianciája kisebb; a faensemble-ek ugyanazon a mintán nagyobb szórással váltogathatják a valid log loss-t.
  2. A futtatás outputja a fenti formátumot követi. Ellenőrző invariantok: az elfogadott modellekre valid_auc >= 0.5 (az elutasított jelöltekre ez nem garantált); a baseline_log_loss pozitív és az adott trainarányból kézzel visszaszámolható; a train + valid legfeljebb train_all mérete (a valid kezdete előtt még meg nem érkezett címkék kieshetnek). A konkrét számjegyek a géptől és a könyvtárverziótól függenek; elfogadható numerikus tolerancia a log loss-ban 0,005, a p95 ms környezet- és terhelésfüggő, ezért ismételt mérési sorozatok szükségesek a stabil döntéshez.
  3. A majdnem tökéletes validáció és a gyenge teszt szivárgásra, validációs túlillesztésre vagy eloszlásváltozásra is utalhat. Vizsgálandó lehetőség: delivery_days vagy actual_delivery_at bekerült a feature-ök közé, esetleg a validhalmazt a tesztidőszakból vágták, és a modell a jövőben ismertté váló címkét látta. Két ellenőrzés: (a) assert set(FEATURES).isdisjoint(FORBIDDEN), ahogy a kód elején; (b) pd.merge(train, test, on="order_id", how="inner") üres halmaz kell legyen.
  4. A lineáris modell egyetlen koeficiensmátrix-szorzás, p95 latency-ja tipikusan a legkisebb, és a modellfájl is kicsi. A random forest 200, legfeljebb 8 mélységű fájának bejárása nagyobb predikciós költség; a HGB 100 iterációja hasonló nagyságrendű. Mérési terv: (a) pickle.dumps(pipe).__len__() minden jelöltre; (b) a fenti bemelegített, 100 egyrekordos hívásból számított p95 mérés; (c) a valid log loss változását a 15 ms-os korlátot sértő modellek kizárása után. A döntés innen a jegyzőkönyv tie_break-jét követi.
  5. A log loss továbbra is megfelelő szigorúan proper scoring rule; a Brier-score alternatív proper mérőszám. Küszöbös döntésnél emellett a 20%-os küszöbhöz tartozó költségérzékeny hasznosságot, a kalibrációt és a bizonytalanságot külön is értékelni kell. Az uncertainty szakaszba kalibrációs görbe vagy CalibratedClassifierCV kerül. A kódban ehhez brier_score_loss(valid[TARGET], p) és a kalibrációs görbe meredeksége illeszkedne.

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

„A random forest mindig jobb, mert a legnépszerűbb eszköz.” Ez a tévedés az eszközhírnevet összekeveri az adott adatra mért teljesítménnyel. Ellenpélda: képzeljünk el 120 rendelést, ahol a késés főként a távolság monoton függvénye, de erős zajjal. A default 200 fás, mélységi regularizálás nélküli erdő a kis mintán túlilleszt; a valid log loss 0,68, míg a lineáris modellé 0,58. A számok szemléltetőek, nem e könyv által futtatott mérés – de pontosan azt mutatják, hogy a népszerűség nem ad felmentést a Predikciós szerződés és hibateszt szerződésben rögzített teszt alól. A Kísérlet eredményének tudományos közlése jelentésszemlélete ugyanezt kéri: a módszer neve nem bizonyíték, a mért hatás és a design korlátja az.

22.4.11 Önellenőrzés

22.4.12 Rövid összefoglaló

A modellcsalád választása előre rögzített tudományos jegyzőkönyv, nem utólagos esztétikai döntés. A jegyzőkönyv rögzíti a célt, a validhalmazt, a baseline-t, a log loss elsődleges metrikát, az AUC védőkorlátot, a p95 latency kaput és a döntési szabályt. A három jelölt – logisztikus regresszió, random forest és hisztogramos gradiens boosting – eltérő induktív torzítást, kapacitást és értelmezhetőséget képvisel; a választást a mért valid log loss és a latency hozza, a tesztet kizárólag a kiválasztott modell egyszeri jelentéséhez használjuk. A következő egység a Hibamértékből döntési cél hibamértékből döntési céllá alakításával pontosítja ezt a jegyzőkönyvet.

További olvasmány és elsődleges dokumentáció: [SklearnHGB] scikit-learn: Gradient Boosting and Categorical Features, https://scikit-learn.org/stable/modules/ensemble.html (hozzáférés: 2026-10-21).