Tesztfegyelem: a teszteredmény megtekintése után végzett modell-, küszöb- vagy feature-választás a tesztet fejlesztési adathalmazzá alakítja. Ilyenkor új, érintetlen vagy későbbi időablak szükséges a végső független értékeléshez.
A Feature-pipeline, baseline és tanulás
alfejezetben a befagyasztott spliten, foldon belüli preprocessinggel és
kontrollált modellkereséssel elkészült a Northstar késésmodell aktuális
jelöltje: model_v3. Most nem fejlesztünk tovább. Egyetlen,
előre rögzített tesztprotokollt alkalmazunk a félretett tesztablakra, és
visszakereshető értékelési jelentést készítünk.
A bemenet:
artifacts/test_predictions_v3.parquet: a
model_v3 által előállított predikciók. Oszlopok:
order_id, target_late,
score_late, split, segment,
region, priority_flag. A
target_late az utólag megfigyelt cél,
score_late a modell pontszáma
-ben.artifacts/split.json: a befagyasztott split manifestje,
benne a test_order_ids lista.config/evaluation_config.json: az előre rögzített
döntési küszöb, metrikák, szeletek és bootstrap beállítások.A megfigyelési egység egy rendelés, a predikció pillanata az
order_ts. A teszt csak a cutoff utáni rendeléseket
tartalmazza, és a generált adat végére a címkék teljesen megfigyeltek,
ezért nincs cenzorálás ebben a kiértékelésben.
A döntési cél: rendeléskor riasszunk-e a várhatóan 7 napot meghaladó kézbesítésre. A költségarányból levezetett, a teszt kiértékelése előtt befagyasztott küszöb . Előzetes becslésünk a tanítási cross-validation alapján: PR-AUC 0,46, ROC-AUC 0,71; a teszten 0,40–0,52 közötti PR-AUC-t és 0,66–0,75 közötti ROC-AUC-t várunk. A lentebb közölt konkrét értékek szemléltető számok, nem a labor valódi futtatásából származó mérések; a laborban a manifest és az eredményjegyzék az ellenőrzési alap.
A tesztbefagyasztás (test freeze) azt jelenti, hogy a teszt kiértékelése előtt minden, a döntést befolyásoló elemet verzióhoz kötünk: a splitet, a feature-definíciókat, a modellt, a küszöböt, a metrikákat és az intervallumszámítás módját. A teszt eredményének ismeretében a modellt már nem tervezzük át. Ha mégis változtatunk, akkor az új modellhez új, független bizonyíték kell.
A technikai megvalósítás itt nem a modell újbóli betöltése, hanem a
már elmentett predikciók, a split manifest és a config hash-ének
ellenőrzése. A script hibát dob, ha a predikciós parquet
split="test" sorainak order_id halmaza nem
egyezik a manifest test_order_ids halmazával.
A zavaró mátrix (confusion matrix) rögzített küszöbnél négy számot ad:
| Predikált 0 | Predikált 1 | |
|---|---|---|
| Tényleges 0 | TN | FP |
| Tényleges 1 | FN | TP |
A két alapmetrika:
A ROC-görbe az FPR–TPR síkon fut, miközben a küszöböt változtatjuk; a ROC-AUC annak a valószínűsége, hogy egy véletlen pozitív rendelés nagyobb pontszámot kap, mint egy véletlen negatív. A PR-görbe a recall–precision síkon fut; a PR-AUC (average precision, AP) a görbe alatti terület. Amikor a pozitív osztály ritkább, a ROC-AUC magas lehet pusztán a sok negatív mintából; ilyenkor a PR-AUC jobban bünteti a hamis riasztásokat. A Northstar projektben a késések aránya nem extrém ritka, de a döntési cél a késések megtalálása, ezért a PR-AUC az elsődleges görbemérték.
Kis kézi kontroll. Legyen 6 tesztrendelés:
A prediktált osztály: . Ebből , , , , tehát precision , recall . Mivel minden pozitív pontszáma nagyobb minden negatívénál, a ROC-AUC és a PR-AUC is 1.
A kalibráció azt állítja, hogy a pontszám értelmezhető-e valószínűségként: ha a modell 0,30 körüli pontszámot ad sok rendelésnek, akkor ezek között valóban közel 30% a késő kézbesítés. A reliabilitási diagram a pontszám szerinti binekben ábrázolja a bin átlagos előre jelzett valószínűsége és a megfigyelt pozitív arány közötti eltérést. A Brier score mindkét irányú eltérést bünteti:
ahol shape , . A lehetséges érték , alacsonyabb jobb. A konstans baseline , ahol a pozitív arány. Például 29% pozitív aránynál . Ha a modell Brier-értéke 0,187, akkor jobb a konstansnál, de a javulás szerény.
A döntési küszöb az üzleti költségarányból származik, nem a teszt adatból optimalizáljuk. Legyen a hamis riasztás, a kimaradt késés átlagos költsége. Ha a valószínűségű késésre nem riasztunk, a várható költség ; ha riasztunk, . A riasztás akkor olcsóbb, ha
azaz
A projekt configja , egységet rögzít, tehát . A képlet feltételezi a lineáris, konstans költségeket; ez modell, nem tényleges pénzügyi állítás. A küszöböt az F2-mértékkel is jellemezzük, mert a kimaradt késés fontosabb, mint a hamis riasztás:
ahol a precision, a recall.
A szeletelt értékelés (slice evaluation) ugyanazt a befagyasztott
modellt és küszöböt a teszt előre megadott részhalmazain méri:
segment, region, priority_flag.
Nem tanítunk szeletenként új modellt. A cél annak felderítése, hogy a
globális modell nem egyetlen ügyfélkörre optimalizált-e. A kis
szeletekben a metrika mintavételi bizonytalansága nagy, ezért mindig a
mintanagyságot és az intervallumot együtt közöljük. A legrosszabb szelet
alapján utólag módosított modellhez szintén új független értékelés
kell.
A tesztmetrika egy mintából becsült szám, ezért nem elég a pontérték. A rendelésszintű bootstrap 1000 ismétlésben, rögzített seed mellett, a teszt sorok visszatevéses újramintavételezésével ad percentilis 95%-os konfidenciaintervallumot. A mintavételi egység itt a rendelés; ha ugyanazon ügyfél több tesztrendelése is szerepel, az ügyfélen belüli korreláció miatt ez az intervallum optimista lehet. Érvényességi korlátként később visszatérünk rá. A ritka szeletek esetén a binomiális arányokra a Wilson-intervallumot használjuk:
ahol az arány, a nevezőben lévő megfigyelésszám, .
Az értékelő script a projektgyökér evaluate_model.py
fájlja. Nem tölti be a modellt, hanem a batch predikció kimenetét
ellenőrzi a split manifesttel szemben, majd kiszámítja a metrikákat,
kalibrációs bineket, szeleteket és CI-ket.
# evaluate_model.py — befagyasztott tesztprotokoll
import argparse
import hashlib
import json
from datetime import datetime, timezone
from pathlib import Path
import numpy as np
import pandas as pd
from sklearn.metrics import (
average_precision_score,
brier_score_loss,
fbeta_score,
precision_score,
recall_score,
roc_auc_score,
)
def wilson_ci(k: int, n: int, z: float = 1.96):
if n == 0:
return (np.nan, np.nan)
p_hat = k / n
denom = 1 + z * z / n
center = (p_hat + z * z / (2 * n)) / denom
margin = z * np.sqrt(p_hat * (1 - p_hat) / n + z * z / (4 * n * n)) / denom
return (max(0.0, center - margin), min(1.0, center + margin))
def bootstrap_ci(metric_fn, y, p, rng, n_repl=1000):
n = len(y)
idx = np.arange(n)
values = []
for _ in range(n_repl):
sample_idx = rng.choice(idx, size=n, replace=True)
y_s, p_s = y[sample_idx], p[sample_idx]
if len(np.unique(y_s)) < 2:
continue
values.append(metric_fn(y_s, p_s))
arr = np.asarray(values)
return float(np.quantile(arr, 0.025)), float(np.quantile(arr, 0.975))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--config", type=Path, required=True)
ap.add_argument("--predictions", type=Path, required=True)
ap.add_argument("--split-manifest", type=Path, required=True)
ap.add_argument("--report", type=Path, required=True)
args = ap.parse_args()
cfg = json.loads(args.config.read_text(encoding="utf-8"))
pred = pd.read_parquet(args.predictions)
split_manifest = json.loads(args.split_manifest.read_text(encoding="utf-8"))
test_ids_expected = set(split_manifest["test_order_ids"])
test_pred = pred.loc[pred["split"] == "test"].copy()
test_ids_actual = set(test_pred["order_id"])
if test_ids_expected != test_ids_actual:
raise SystemExit("Split manifest nem egyezik a tesztpredikciókkal.")
y = test_pred["target_late"].to_numpy()
p = test_pred["score_late"].to_numpy()
threshold = float(cfg["decision_threshold"])
y_hat = (p >= threshold).astype(int)
rng = np.random.default_rng(cfg["bootstrap_seed"])
roc_low, roc_high = bootstrap_ci(roc_auc_score, y, p, rng)
pr_low, pr_high = bootstrap_ci(average_precision_score, y, p, rng)
precision = precision_score(y, y_hat)
recall = recall_score(y, y_hat)
f2 = fbeta_score(y, y_hat, beta=2)
fp = int((y_hat == 1).sum() - (y[y_hat == 1] == 1).sum())
tn = int((y == 0).sum() - ((y == 0) & (y_hat == 0)).sum())
fpr = fp / (fp + tn) if fp + tn > 0 else np.nan
p_low, p_high = wilson_ci(int((y[y_hat == 1] == 1).sum()), int((y_hat == 1).sum()))
r_low, r_high = wilson_ci(int((y[y_hat == 1] == 1).sum()), int(y.sum()))
bins = np.linspace(0.0, 1.0, 11)
bin_idx = np.digitize(p, bins) - 1
cal_rows = []
for i in range(10):
mask = bin_idx == i
n_bin = int(mask.sum())
if n_bin == 0:
continue
cal_rows.append({
"bin": f"[{bins[i]:.1f},{bins[i+1]:.1f})",
"n": n_bin,
"mean_predicted": round(float(p[mask].mean()), 3),
"observed_rate": round(float(y[mask].mean()), 3),
})
slice_rows = []
for slice_name in cfg["slices"]:
for key, group in test_pred.groupby(slice_name):
y_s = group["target_late"].to_numpy()
p_s = group["score_late"].to_numpy()
y_s_hat = (p_s >= threshold).astype(int)
slice_rows.append({
"slice": f"{slice_name}={key}",
"n": len(group),
"precision": round(float(precision_score(y_s, y_s_hat)), 3),
"recall": round(float(recall_score(y_s, y_s_hat)), 3),
})
report = {
"evaluation_timestamp": datetime.now(timezone.utc).isoformat(),
"decision_threshold": threshold,
"config_sha256": hashlib.sha256(args.config.read_bytes()).hexdigest(),
"n_test": len(test_pred),
"positive_rate": round(float(y.mean()), 3),
"metrics": {
"roc_auc": round(float(roc_auc_score(y, p)), 3),
"roc_auc_ci95": [round(roc_low, 3), round(roc_high, 3)],
"pr_auc": round(float(average_precision_score(y, p)), 3),
"pr_auc_ci95": [round(pr_low, 3), round(pr_high, 3)],
"brier": round(float(brier_score_loss(y, p)), 3),
"precision_at_threshold": round(float(precision), 3),
"precision_ci95": [round(p_low, 3), round(p_high, 3)],
"recall_at_threshold": round(float(recall), 3),
"recall_ci95": [round(r_low, 3), round(r_high, 3)],
"f2_at_threshold": round(float(f2), 3),
"false_positive_rate": round(float(fpr), 3),
},
"calibration_bins": cal_rows,
"slices": slice_rows,
}
args.report.write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
print(json.dumps(report["metrics"], indent=2))
if __name__ == "__main__":
main()A befagyasztott konfiguráció:
{
"decision_threshold": 0.333,
"threshold_rationale": "C_FP=4, C_FN=8, t*=4/(4+8)",
"slices": ["segment", "region", "priority_flag"],
"bootstrap_replications": 1000,
"bootstrap_seed": 20261020
}Futtatás a projektgyökérből:
# Linux/macOS, munkakönyvtár: ~/northstar_lab
python evaluate_model.py \
--config config/evaluation_config.json \
--predictions artifacts/test_predictions_v3.parquet \
--split-manifest artifacts/split.json \
--report artifacts/evaluation_report.jsonWindows PowerShellben a sorvégi backtickkel, a munkakönyvtár azonos.
A parancs kimenete szemléltető, nem tényleges futtatásból származik:
roc_auc: 0.712, roc_auc_ci95: [0.664, 0.757]
pr_auc: 0.418, pr_auc_ci95: [0.351, 0.478]
brier: 0.187
precision_at_threshold: 0.247, recall_at_threshold: 0.621
f2_at_threshold: 0.478, false_positive_rate: 0.392
A jelentést git commit is rögzíti:
# Linux/macOS
git add artifacts/evaluation_report.json config/evaluation_config.json
git commit -m "Evaluate frozen model_v3 on held-out test"A fenti szemléltető futásban a teszt 814 rendelést tartalmaz, a pozitív arány 0,29. A ROC-AUC 0,712 azt jelenti: ha véletlenszerűen kiválasztunk egy késő és egy nem késő rendelést, a modell 71,2% eséllyel ad nagyobb pontszámot a későnek. A PR-AUC 0,418 jóval szerényebb; a görbe a 0,29-es pozitív arány miatt nem éri el a 1-et. A Brier 0,187 alatta van a konstans baseline 0,2059-nek, tehát a modell ad némi valószínűségi értéket, de a javulás nem drámai.
A befagyasztott 0,333-as küszöbnél a modell a valódi késések 62,1%-át találja meg, de a riasztásainak csak 24,7%-a helyes. Az F2 0,478 a recall nagyobb súlyával mondja ugyanezt. A hamis riasztási arány 0,392: tíz nem késő rendelésből közel négyre riasztana a rendszer. Ez a döntési keretben elfogadható lehet, mert a kimaradt késés kétszer drágább, mint a hamis riasztás, de az értékelési jelentésben expliciten szerepel.
A kalibrációs binek (szemléltető részlet):
| bin | n | mean_predicted | observed_rate |
|---|---|---|---|
| [0.1,0.2) | 63 | 0.16 | 0.11 |
| [0.3,0.4) | 97 | 0.35 | 0.31 |
| [0.5,0.6) | 112 | 0.55 | 0.60 |
| [0.8,0.9) | 71 | 0.84 | 0.80 |
A középső binek közel vannak az átlóhoz; a nagyon alacsony binnél a megfigyelt arány 0,11, míg az átlagos pontszám 0,16, tehát a modell ott kissé túlbecsül. A binben alulbecsül: 0,55-öt mond, de 0,60 a megfigyelt arány. A legmagasabb bin jól kalibrált.
A szeletek (szemléltető, kiragadott sorok):
| slice | n | precision | recall |
|---|---|---|---|
| segment=business | 248 | 0.296 | 0.688 |
| segment=consumer | 566 | 0.221 | 0.584 |
| region=north | 271 | 0.238 | 0.602 |
| priority_flag=1 | 194 | 0.310 | 0.714 |
A business szegmensben magasabb a recall; ez önmagában még nem jelenti, hogy a modell a business ügyfelekre „jobb”, mert a pozitív arány és a szegmensbeli döntési küszöb is eltérhet. A szelet csak kérdést tesz fel, nem igazol állítást.
Az intervallumok fontosak: a recall Wilson-CI-je a szemléltető adatban kb. 0,55–0,69, a precisioné 0,18–0,33. A döntéshez ez a bizonytalansági sáv szükséges. Kevés pozitív esetnél a sáv még szélesebb lenne; ez visszhangozza a LLM mint sztochasztikus komponens tanulságát.
Értelmezés. Egy kolléga azt írja a jelentésben: „A teszt ROC-AUC 0,71, tehát a modell az esetek 71%-ában helyesen dönt.” Mi a hiba ebben az állításban? Fogalmazd meg, mit mér a ROC-AUC, és melyik metrika adná a helyes osztályozási arányt a rögzített küszöbön.
Megvalósítás. Írj
manual_pr_auc(y_true, y_score) függvényt, amely a
precision_recall_curve kimenetéből trapéz integrállal
számítja a PR-AUC-t, és hasonlítsd össze az
average_precision_score értékével. Ellenőrizd a 6 soros
kézi példán:
,
.
Hibakeresés. A következő kód a küszöböt a tesztadaton optimalizálja, majd ugyanazon a teszten F2-t számol:
from sklearn.metrics import fbeta_score
import numpy as np
best_f2, best_t = -1, 0.5
for t in np.linspace(0.05, 0.95, 100):
f2 = fbeta_score(y_test, (p_test >= t).astype(int), beta=2)
if f2 > best_f2:
best_f2, best_t = f2, t
print("t=", best_t, "F2=", best_f2)Miért nem használható ez az értékelés a modell jóváhagyásához? Írd át a protokollt úgy, hogy a küszöb a befagyasztott configból jöjjön.
Megváltozott feltétel. Az üzlet döntése szerint a kimaradt késés költsége ötszörösére nő: , marad. Számold ki az új befagyasztott küszöböt. Milyen irányba mozdul várhatóan a recall, a precision és a hamis riasztási arány? Kell-e új modellt tanítani, és kell-e új független teszt a pontszámokhoz?
Kalibráció. A modell Brier-értéke 0,187, a teszt pozitív aránya 0,29. Számold ki a konstans baseline Brier-értéket, és magyarázd, hogy a Brier miért nem tisztán kalibrációs mérőszám.
A ROC-AUC nem az egyedi osztályozások pontossága, hanem a rangsorolási képesség mutatója: annak a valószínűsége, hogy a modell egy véletlen pozitív rendelést nagyobb pontszámmal lát el, mint egy véletlen negatívat. A rögzített küszöbnél a helyes osztályozási arányt az accuracy adná: . A fenti szemléltető számmal ez , és általában magasabb is lehet 0,71-nél; az AUC-nak nincs ilyen egyedi értelmezése. Ha a kolléga 71%-os „esélyt” akar írni, akkor azt mondhatja, hogy a rangsorolási valószínűség 71%.
A precision_recall_curve a precision és recall
vektorokat adja; a recall a sklearn kimenetében monoton növekvő, így a
trapéz integrál a precision–recall síkon:
import numpy as np
from sklearn.metrics import average_precision_score, precision_recall_curve
def manual_pr_auc(y_true, y_score):
precision, recall, _ = precision_recall_curve(y_true, y_score)
return float(np.trapezoid(precision, recall))
y = [0, 0, 1, 0, 1, 1]
p = [0.1, 0.2, 0.6, 0.4, 0.7, 0.9]
print(manual_pr_auc(y, p), average_precision_score(y, p))Mindkét függvény 1,0-t ad a példán, mert minden pozitív pontszáma a
negatívok fölött van. A np.trapezoid NumPy 2.x-ben
szerepel; korábbi verzióban np.trapz. Az
average_precision_score numerikusan stabilabb; valódi
adaton kis eltérés lehet, de az eltérés 0,001 alatt marad.
threshold = float(cfg["decision_threshold"])
y_hat = (p_test >= threshold).astype(int)
f2 = fbeta_score(y_test, y_hat, beta=2)Ha a küszöböt mégis optimalizálni akarjuk, azt a tanító/validációs foldokon, a teszt kiértékelése előtt kell megtenni, és a választott értéket a configba kell fagyasztani.
Az új küszöb . Az alacsonyabb küszöb miatt a modell több rendelést riaszt pozitívnak, ezért a recall várhatóan nő, a precision csökken, és a hamis riasztási arány nő. A modell pontszámai változatlanok, tehát a paraméterek újratanítása nem szükséges. A pontszámokat ugyanazon befagyasztott teszten újra lehet értékelni, mert a küszöböt az üzleti költségekből vezettük le, nem a teszt eloszlásából. Fontos korlát: ha sok küszöböt próbálnánk a teszten ki, és a legjobbat jelentenénk, az már szelekciós torzítást vinne be, és új független adat kellene.
A konstans baseline Brier: . A modell 0,187-es értéke jobb, de a különbség 0,019. A Brier nem tisztán kalibrációs mérőszám: két jól kalibrált modell közül a jobban diszkrimináló kisebb Brier-t kap, ha a pontszámok közelebb vannak 0-hoz és 1-hez. Ezért a Brier-t a reliabilitási binekkel együtt kell értelmezni; a Brier javulhat attól is, hogy a modell határozottabban szélsőséges pontszámokat ad, miközben a kalibráció nem javul.
A leggyakoribb hiba a teszt eredménye utáni újratervezésből utólagos validálásnak álcázása. Tegyük fel, hogy a fejlesztő három modellt tanít, mindhármat lefuttatja a tesztre, és a legjobb ROC-AUC-t, 0,72-t jelenti. Ez a 0,72 már nem független becslés: a három tesztmetrika közül a legnagyobb felfelé torzított, még akkor is, ha mindhárom modell valódi értéke 0,5. Ha 10 modellt próbál a teszten, a legjobb várhatóan már pusztán zajból is 0,55–0,62 közé eshet. A helyes munkafolyamat: a modellek közötti választás a belső validációs foldokon történik; a tesztre csak egyetlen, előre rögzített jelölt kerül. Ha a teszt után bármilyen okból változtatunk a modellen, az új modellváltozathoz új, független bizonyíték szükséges, például a következő időablak tesztje. Ez a szabály összhangban van a LLM mint sztochasztikus komponens alfejezetben tanult mintavételi bizonytalansággal: a ritka pozitív esetek miatt a metrika sávja széles, ezért utólagos szelekcióval könnyű a sávon belül kedvezőnek látszó eredményt találni.
observed_rate=0,31,
mean_predicted=0,35?Válaszok röviden: A teszt utáni küszöboptimalizálás ugyanazt az adatot használja kiválasztásra és értékelésre, ezért a metrika nem független. A rendelésszintű bootstrap figyelmen kívül hagyja az ügyfélen belüli korrelációt, ezért az intervallum szűkebb lehet a ténylegesnél; konzervatívabb ügyfélszintű mintavétel kell. A bin a modell 0,35-ös átlagos pontszámánál 0,31 megfigyelt arányt mutat, tehát a modell kissé túlbecsül, 0,04 eltéréssel.
A független tesztértékelés nem a metrikák kiszámítása, hanem egy befagyasztott mérési szerződés végrehajtása: rögzített modellpontszámok, split manifest, előre levezetett döntési küszöb, rögzített metrikák. A PR-AUC, ROC-AUC, Brier, küszöbös precision/recall/F2, kalibrációs binek és szeletek együtt adnak visszakereshető jelentést, minden pontérték mellett mintavételi bizonytalansággal. A teszt kiértékelése után újrahasznosított adat vagy újrahangolt küszöb már nem hoz független bizonyítékot. A következő lépés a jelentés alapján a modell átadása és új környezetes, reprodukálható ismétlés a záróprojekt további lépésében.