23.4 Küszöb, kalibráció és ritka esemény

23.4.1 Cél és konkrét helyzet

A Northstar prediktív projektben a Hiperparaméter és nested értékelés egység nested keresztvalidációval kiválasztotta a logisztikus regressziót, és befagyasztotta a split manifestet. A modell minden rendeléshez egy p̂i\hat p_i késési valószínűséget ad, de ez még nem döntés: a működéshez küszöb kell. A kérdés nem az, hogy „mekkora az AUC”, hanem hogy milyen tt küszöbre mondjuk azt, hogy a rendelés várhatóan késik, és mennyibe kerül ez a döntés.

A feladat bemenete:

Megfigyelési egység egy rendelés; a döntés a rendelés pillanatában születik, a címke csak később válik ismertté. A labor kimenete egy befagyasztott küszöb, a validhalmaz kalibrációs és költséggörbéje, végül az érintetlen teszt egyszeri költségértékelése. Induló becslésként ellenőrizze a tanítóhalmaz késési arányát: ha például 9%, akkor a mindig negatív baseline költség 0,09⋅5=0,450{,}09 \cdot 5 = 0{,}45 egység/rendelés; a modellnek ennél kell jobbnak lennie.

23.4.2 Küszöb, várható költség és kalibráció

Küszöb (threshold). A modell pi=P̂(yi=1∣xi)p_i = \hat P(y_i=1 \mid x_i) folytonos kimenetét a ŷi=𝟏[pi>t]\hat y_i = \mathbf{1}[p_i > t] szabállyal alakítjuk bináris döntéssé. A tt megválasztása trade-off: alacsonyabb küszöb több riasztást és több hamis riasztást, magasabb küszöb több kihagyott eseményt ad. A confusion matrix minden tt-re más:

Döntés Tényleges 0 Tényleges 1
ŷ=0\hat y=0 TN FN
ŷ=1\hat y=1 FP TP

Várható költség (expected cost). A költségtábla csak a hibás döntéseket árazza: a hamis riasztás CFPC_{FP}, a kihagyott esemény CFNC_{FN}, a helyes döntés 0. A validhalmazon nvn_v megfigyeléssel a várható költség:

EC(t)=1nv∑i=1nv[𝟏(pi>t,yi=0)CFP+𝟏(pi≤t,yi=1)CFN]. EC(t) = \frac{1}{n_v}\sum_{i=1}^{n_v} \left[ \mathbf{1}(p_i > t, y_i=0)\,C_{FP} + \mathbf{1}(p_i \le t, y_i=1)\,C_{FN} \right].

Itt 𝟏(⋅)\mathbf{1}(\cdot) az indikátorfüggvény, értéke 1, ha a feltétel igaz, különben 0; pip_i a prediktált valószínűség, yiy_i a tényleges címke. Az EC(t)EC(t) mértékegysége költségegység/rendelés. A döntési probléma célja EC(t)EC(t) minimalizálása a validhalmazon.

Ha a modell valószínűségei jól kalibráltak és a költségek nem függnek pp-től, az elméleti optimum levezethető feltételes várható értékkel: egy pp pontszámú rendelés riasztásának várható költsége pCTPp\,C_{TP}-szerű, és a határ ott van, ahol a riasztás várható többletköltsége egyenlő a nem riasztáséval. A gyakorlati képlet egyszerű:

t*=CFPCFP+CFN. t^* = \frac{C_{FP}}{C_{FP} + C_{FN}}.

A jelen költségtáblával t*=1/(1+5)≈0,167t^* = 1/(1+5) \approx 0{,}167. Ez nem azt jelenti, hogy pontosan ezt a küszöböt kell használni; a valid görbe empirikus minimuma a véges minta és a kalibrálatlanság miatt eltérhet.

Kalibrációs görbe (calibration curve). A kalibráció azt kérdezi: amikor a modell pp valószínűséget mond, a tényleges pozitív arány valóban pp körül van-e? A validhalmaz predikcióit binekre osztjuk, minden binben kiszámoljuk az átlagos predikciót p‾k\bar p_k és a tényleges pozitív arányt y‾k\bar y_k. A kalibrációs görbe ezeket ábrázolja; az ideális görbe az átló. A logisztikus regresszió gyakran jól kalibrált, a random forest és a boosting viszont túl magabiztos lehet – erre a későbbi Kalibrált valószínűség és predikciós bizonytalanság épít.

Brier-pontszám (Brier score). Az egyetlen számba sűrített kalibrációs és élességi mérték a predikciók és a címkék négyzetes eltérése:

B=1n∑i=1n(pi−yi)2. B = \frac{1}{n}\sum_{i=1}^{n}(p_i - y_i)^2.

Értéke mindig 0 és 1 között van; B=0B=0 a tökéletes, mindig magabiztos előrejelzés. A mindig y‾\bar y átlag-arányt mondó nullmodell Brier-értéke y‾(1−y‾)\bar y(1-\bar y), tehát ritka eseménynél már egy egyszerű konstans is alacsony Brier-t ad; a modellt ehhez, nem 0-hoz kell viszonyítani.

Imbalance és a resampling helye. A késés ritka esemény: ha a pozitív arány π≈0,09\pi \approx 0{,}09, a mindig negatív modell accuracy-je már 0,910{,}91. Az accuracy ezért nem választ küszöböt. Ha a ritkaságot SMOTE-tal vagy más resamplinggel kezeljük, a resampling csak a keresztvalidáció tanító foldján belül futhat, különben a szintetikus pontok a validációs fold információját szivárogtatják. A imblearn.pipeline.Pipeline ezt a korlátot beépítve kezeli.

23.4.3 Teljes minimális munkapélda

A következő szkript a lab gyökeréből futtatható:

python notebooks/s092_threshold_calibration.py

A kód előfeltétele, hogy a starter_experiment.py már létrehozta az artifacts/split.json-t. A split kulcsait a projekt manifestjében ellenőrizze; a példa a train, test, purged neveket használja.

# notebooks/s092_threshold_calibration.py
from pathlib import Path
import json
import joblib
import numpy as np
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.metrics import (
    brier_score_loss, confusion_matrix, roc_auc_score
)
from sklearn.calibration import calibration_curve

DATA = Path("data")
ART = Path("artifacts")
SPLIT_PATH = ART / "split.json"

C_FP = 1.0   # hamis riasztás: fölösleges figyelmeztetés
C_FN = 5.0   # kihagyott késés: ügyfélkompenzáció
SEED = 20261020

orders = pd.read_csv(DATA / "orders.csv", parse_dates=["order_ts", "actual_delivery_at", "label_available_at"])
customers = pd.read_csv(DATA / "customers.csv", parse_dates=["signup_date"])
df = orders.merge(
    customers[["customer_id", "signup_date", "segment", "region", "acquisition_channel"]],
    on="customer_id", how="inner"
)
df["account_age_days"] = (df["order_ts"] - df["signup_date"]).dt.days

with open(SPLIT_PATH) as f:
    split = json.load(f)
train_ids = split["train"]
test_ids = split["test"]
purged_ids = split["purged"]  # a kihagyott határsáv, itt nem használjuk

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

X_train_full = df[df["order_id"].isin(train_ids)][feature_cols].copy()
y_train_full = df[df["order_id"].isin(train_ids)][target_col].copy()
X_test = df[df["order_id"].isin(test_ids)][feature_cols].copy()
y_test = df[df["order_id"].isin(test_ids)][target_col].copy()

# Belső split: inner_train a modellhez, threshold_valid a küszöbválasztáshoz.
X_inner, X_valid, y_inner, y_valid = train_test_split(
    X_train_full, y_train_full, test_size=0.30,
    random_state=SEED, stratify=y_train_full
)

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

model = Pipeline([
    ("prep", preprocessor),
    ("clf", LogisticRegression(max_iter=1000, random_state=SEED))
])
model.fit(X_inner, y_inner)

p_valid = model.predict_proba(X_valid)[:, 1]
brier_valid = brier_score_loss(y_valid, p_valid)
auc_valid = roc_auc_score(y_valid, p_valid)

thresholds = np.linspace(0.01, 0.99, 199)
costs = []
for t in thresholds:
    y_pred = (p_valid >= t).astype(int)
    tn, fp, fn, tp = confusion_matrix(y_valid, y_pred).ravel()
    costs.append((fp * C_FP + fn * C_FN) / len(y_valid))
costs = np.array(costs)
best_t = thresholds[int(np.argmin(costs))]
best_valid_cost = costs.min()

frac_pos, mean_pred = calibration_curve(y_valid, p_valid, n_bins=10)

# Befagyasztott küszöb: az érintetlen teszt egyszeri értékelése.
model.fit(X_train_full, y_train_full)  # újratanítás a teljes trainen
p_test = model.predict_proba(X_test)[:, 1]
y_pred_test = (p_test >= best_t).astype(int)
tn_t, fp_t, fn_t, tp_t = confusion_matrix(y_test, y_pred_test).ravel()
test_cost = (fp_t * C_FP + fn_t * C_FN) / len(y_test)
brier_test = brier_score_loss(y_test, p_test)
auc_test = roc_auc_score(y_test, p_test)

result = {
    "inner_n": int(len(X_inner)),
    "valid_n": int(len(X_valid)),
    "test_n": int(len(X_test)),
    "train_late_rate": float(y_train_full.mean()),
    "brier_valid": float(brier_valid),
    "auc_valid": float(auc_valid),
    "theoretical_threshold": C_FP / (C_FP + C_FN),
    "chosen_threshold": float(best_t),
    "valid_cost_at_chosen": float(best_valid_cost),
    "test_cost_at_chosen": float(test_cost),
    "test_confusion": {"tn": int(tn_t), "fp": int(fp_t),
                        "fn": int(fn_t), "tp": int(tp_t)},
    "brier_test": float(brier_test),
    "auc_test": float(auc_test),
}

ART.mkdir(exist_ok=True)
joblib.dump((model, best_t), ART / "s092_threshold_model.pkl")
with open(ART / "s092_threshold_result.json", "w") as f:
    json.dump(result, f, indent=2, ensure_ascii=False)

print(json.dumps(result, indent=2, ensure_ascii=False))

A tényleges futás a helyi lab projektben ellenőrzi a számokat. Az alábbiak a várt tartományt szemléltetik, nem a mért eredményt: a train késési arány körülbelül 8–10%, a valid optimum t*t^* közelében, körülbelül 0,15–0,20 között, a tesztköltség pedig a baseline 0,40{,}4–0,50{,}5 alatt várható. A pontos értékeket a manifest és a saját futtatás igazolja.

23.4.4 Eredmény értelmezése

A kód három dolgot választ el. A threshold_valid halmaz a küszöbgörbe és a kalibráció tere; a választott tt a valid költséggörbe minimuma. Ezután a modellt újratanítjuk a teljes trainen, és a befagyasztott küszöbbel a tesztet pontosan egyszer értékeljük. A teszt confusion matrix és költség már nem vezérel további modell- vagy küszöbválasztást.

A valid költséggörbe aszimmetrikus: a CFN=5C_{FN}=5 miatt a túl magas küszöb drága, mert kihagy késéseket. A t*=0,167t^* = 0{,}167 elméleti optimum csak akkor pontos, ha a kalibrációs görbe az átlón van. Ha a valid halmazon a modell alacsony pp tartományban alulbecsül, az optimum jobbra tolódik. A Brier-pontszám együtt olvasandó a kalibrációs görbével: az alacsony Brier nem garancia a jó kalibrációra, ha az esemény ritka.

A tesztköltség becslése maga is mintavételi becslés: a kis tesztmintán a költség standard hibája a költségarányok binomiális szórásából közelíthető, de egyszeri futtatásban csak a pontbecslés jelenik meg. A nested CV Hiperparaméter és nested értékelés becsléséhez hasonlóan ez a teszt is egyetlen véletlen szelet; a szórását nem a teszt többszöri újraoptimalizálásával, hanem resamplinggel vagy a jövőbeli monitoringgal lehet becsülni.

A kalibrálót kizárólag a modell kiválasztásától és a végső teszttől elkülönített adaton illesszük, vagy használjunk szivárgásmentes CV-t. Ritka eseménynél a PR-görbe baseline-ja az adott halmaz pozitív aránya.

23.4.5 Feladatok

  1. Értelmezés. Az alábbi valid confusion matrix adott: TP=40, FP=20, FN=10, TN=930. Számítsa ki a várható költséget CFP=1C_{FP}=1, CFN=5C_{FN}=5 mellett, a pontosságot (accuracy), valamint a mindig negatív baseline költségét. Miért vezet félre az accuracy a küszöbválasztásban?

  2. Megvalósítás. Változtassa a költségtáblát CFP=2C_{FP}=2, CFN=10C_{FN}=10 értékekre, és futtassa újra a küszöbgörbét. Mi az elméleti t*t^*, és miért nem változik? Mi változik viszont a költség mértékegységében és a tesztköltség értékében?

  3. Hibadiagnózis. A valid költség 0,36, a teszt költség viszont 0,71. Soroljon fel legalább három lehetséges okot, és nevezze meg, melyik okot hogyan zárná ki a split manifest, a label-availability szűrés és a feaure-ök időbeli eloszlásának összehasonlításával.

  4. Megváltozott feltétel. Tegyük fel, hogy a train halmaz késési aránya 6%, és egy kolléga a teljes trainen SMOTE-ot futtat, mielőtt a keresztvalidációt lefuttatja. Magyarázza el, miért szivárogtat ez információt a valid foldokba, és írja le a helyes imblearn.pipeline.Pipeline szerkezetet, amelyben a resampling a tanító foldon belül marad.

23.4.6 Megoldások és indoklás

1. feladat. A confusion matrix cellái: FP=20FP=20, FN=10FN=10, n=1000n=1000. A várható költség:

EC=20⋅1+10⋅51000=0,07. EC = \frac{20 \cdot 1 + 10 \cdot 5}{1000} = 0{,}07.

Az accuracy (40+930)/1000=0,97(40+930)/1000 = 0{,}97. A mindig negatív modell esetén FP=0FP=0, FN=50FN=50, tehát EC=50⋅5/1000=0,25EC = 50 \cdot 5 / 1000 = 0{,}25, accuracy 950/1000=0,95950/1000 = 0{,}95. Az accuracy alig különbözik, miközben a költség több mint háromszoros; ritka eseménynél az accuracy a konstans negatív szabályt is magasra jutalmazza, ezért nem alkalmas működési küszöb választására.

2. feladat. Az elméleti küszöb 2/(2+10)=2/12≈0,1672/(2+10) = 2/12 \approx 0{,}167, ugyanaz, mert a két költség aránya nem változott: mindkét tábla az 1:5 arányt használja. A valid görbe minimuma a véletlen miatt kissé elmozdulhat, de a döntési határ elvileg változatlan. A teljes költség viszont kétszeresére skálázódik, mert minden hibás döntés ára duplázódik; a tesztköltség várhatóan körülbelül 2⋅EC2 \cdot EC, ahol ECEC az eredeti költségtáblával mért tesztköltség.

3. feladat. Lehetséges okok:

4. feladat. A SMOTE a kisebbségi osztály pontjainak szomszédjai alapján szintetikus mintát generál. Ha ezt a teljes trainen, a CV előtt futtatjuk, a valid fold valódi pontjainak szomszédságát is felhasználja, ezért a valid fold nem marad érintetlen; az értékelés optimista lesz. A helyes megoldás:

from imblearn.pipeline import Pipeline as ImbPipeline
from imblearn.over_sampling import SMOTE
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score

pipe = ImbPipeline([
    ("smote", SMOTE(random_state=20261020)),
    ("lr", LogisticRegression(max_iter=1000, random_state=20261020))
])

scores = cross_val_score(pipe, X_train_full, y_train_full,
                         scoring="neg_log_loss", cv=5)

Itt a cross_val_score minden foldon belül előbb a tanítórészen futtatja a SMOTE-ot, a validációs fold pedig a nyers, eredetileg kiegyensúlyozatlan címkékkel értékel. A Pipeline tehát nem a fold előtt, hanem a fold tanító ágában hajtja végre a resamplinget.

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

A leggyakoribb hiba a teszten optimalizált küszöb. Tegyük fel, hogy a valid optimum t=0,18t=0{,}18, a teszt költséggörbéjének minimuma viszont t=0,24t=0{,}24-nél van, és ott a tesztköltség 0,39 a valid-küszöbös 0,43-mal szemben. Ha a jelentésbe a 0,39-et és a 0,24-et írjuk, az első ránézésre jobb, de a döntési szabály most már a teszt címkéit ismerve alakult ki. A teszt zajához idomuló küszöb a jövőbeli mintán általában rosszabb, mint a validon befagyasztott; a 0,24-es választás tehát optimista önértékelés. A helyes sorrend: validon választunk küszöböt, a tesztet utána egyszer, változatlan szabállyal értékeljük. Ugyanez a logika ismétlődött a Hiperparaméter és nested értékelés belső/ külső CV szétválasztásánál: minden adaptív döntést a teszt előtti rétegben kell lezárni.

Önellenőrzés: a teszt confusion matrix oszlopösszegei egyeznek-e a teszt tényleges osztálygyakoriságával; a költség nem negatív; a Brier-érték 0 és 1 között van; a split.json train/test/purged halmazai diszjunktak. Ha bármelyik sérül, a küszöb vagy az értékelés nem megbízható.

23.4.8 Rövid összefoglaló

A modell valószínűségi kimenetéből a döntési küszöböt a döntési költségtábla alakítja működési szabállyá. A validhalmazon kiszámított várható költség görbéje választja a tt küszöböt; az elméleti CFP/(CFP+CFN)C_{FP}/(C_{FP}+C_{FN}) érték csak jól kalibrált modellnél pontos. A kalibrációs görbe és a Brier-pontszám azt ellenőrzi, hogy a prediktált valószínűségek tényleges gyakoriságként értelmezhetők-e. A küszöböt a teszt előtt befagyasztjuk; a teszten optimalizált küszöb már nem független értékelés. Ritka eseménynél az accuracy helyett a költség és a kalibráció vezet, a resampling pedig csak a tanító foldon belül élhet. A következő egység a PCA: skála, vetítés és megőrzött variancia PCA-val és nem felügyelt módszerekkel bővíti a predikciós eszköztárat.