40.2 Kísérleti és hibakeresési vizsga

40.2.1 Cél, előismeret és induló kérdés

Ez az egység vizsga, nem új tananyag-bevezetés: a korábbi fejezetek eszközeit egyetlen, korábban nem látott hibakötegen kell alkalmazni. Az előfeltétel a 40.1. Összetett levezetési vizsga levezetési vizsga mellett a 39.2. Encoder–decoder vagy lokalizációs fej encoder–decoder és loss-redukciós ismerete, a 36.2. Ismétlések és eredménybizonytalanság seed-ismétlési protokollja és a 28.2. Sziámi és triplet cél triplet-cél marginfeltétele. A vizsgázó nem azt bizonyítja, hogy emlékszik a tanult hibákra, hanem hogy új hibahelyzetben versengő magyarázatokat tud felállítani, és mindegyikhez olyan mérést rendel, amely a többit kizárja.

Induló kérdés: egy GlyphLab-orientációosztályozó validációs pontossága 99,8%, a tiszta teszt pontossága 41%. A hallgató azt mondja: „biztos dropout maradt bekapcsolva”. Miért nem tekinthető ez még diagnózisnak, és milyen két mérés dönti el, hogy a hiba inkább címkeeltolódás, split-szivárgás vagy train/eval módhiba?

40.2.2 Definíciók és a vizsga objektumai

Hibadiagnózis (fault diagnosis). Adott egy megfigyelt anomália EE (például „val acc ≫\gg test acc”) és egy H1,…,HkH_1,\ldots,H_k versengő hipotéziscsalád. A HiH_i hipotézishez tartozik egy Pi(O)P_i(O) predikció valamely OO megfigyelhető statisztikára nézve. A diagnózis akkor teljes, ha találunk olyan OO mérést, amelynek eredménye legalább egy hipotézist kizár, és a fennmaradó halmazt a kísérleti naplóban megjelöljük.

Leakage (információszivárgás). A tanítási folyamat olyan információhoz fér, amely a telepített modell számára a kiértékeléskor nem lenne elérhető, vagy a split függetlenségi egysége sérül. A GlyphLab szerződésben a független egység a scene_id: egy jelenet mindkét nézete ugyanabba a splitbe tartozik, ezért képenkénti véletlen split leakage. Ugyancsak leakage, ha a normalizáló statisztikát a teljes 360 képre számoljuk, mert a teszteloszlás átlaga és szórása beépül a tanított modell bemeneti transzformációjába.

Label bug (címkehiba). A bemeneti tensor és a célérték közötti megfeleltetés sérül: eltolás, helytelen sorrend, vagy olyan geometriai transzformáció, amely a címkét nem őrzi meg. A GlyphLab corrupted_test.npy sorrendje a metadata teszt-sorrendjével egyezik; ha a kiértékelés a labels[:60] szeletet használja, az a train eleje, tehát címkehiba.

Train/eval módhiba. Állapotfüggő rétegek — Dropout és BatchNorm — más számítást végeznek tanításban és kiértékelésben. A keretjegyzet [D12] szerint a BatchNorm statisztikakezelése a két módban eltérhet; a dropout tanításban véletlen maszkkal skáláz, kiértékelésben identitásként viselkedik. A hiba akkor áll fenn, ha a validáció vagy teszt a model.train() állapotban fut.

Wrong reduction (helytelen redukció). A loss numerikus értéke nem a definiált célfüggvényt valósítja meg, mert a mean/sum és a nevező nem a szándékolt megfigyelési egységre vonatkozik. Tipikus példa, amikor a számláló a teljes batch összege, a nevező viszont egy képre vonatkozó mennyiség: a veszteség ekkor a batchmérettel skálázódik. A 39.2. Encoder–decoder vagy lokalizációs fej teljes N×H×WN\times H\times W pixelre vett BCE-átlaga ezzel szemben nem redukcióhiba, hanem legitim cél; az előtér nagyobb súlya csak explicit súlyozással érhető el.

Checkpoint hiba. A betöltött állapot nem teljesíti azt a checkpoint-szerződést, amelyet a felhasználási cél előír; a 12.2. Checkpoint és folytathatóság és a 38.2. Modell, training és best checkpoint két szerződést különít el. (a) Inference- és kiértékelési checkpoint: a protokoll által kiválasztott állapot (itt a legkisebb validációs veszteségű epoch) state_dict-je, azaz a paraméterek és a perzisztens bufferek, az architektúra és az input séma azonosítójával. Ehhez optimizer-, scheduler-, GradScaler- és RNG-állapot nem kell; a hiányuk nem hiba. Checkpoint-hiba itt például: a kiválasztott helyett az utolsó epoch állapotát töltjük be, más architektúrába töltünk, vagy strict=False mellett kulcsok, például a BatchNorm futó statisztikái, csendben elvesznek. (b) Resume-checkpoint: a tanítás pontos folytatásához a súlyok mellett az optimizer, a scheduler, AMP esetén a GradScaler és az RNG-állapotok is kellenek; ezek hiánya csak ennek a szerződésnek a megsértése.

Hipotézis a vizsgán. Mérhető következményekkel rendelkező magyarázat. A „szerintem dropout” tipp nem hipotézis, amíg nem adjuk meg, milyen megfigyelhető különbséget jósol a versenytársakhoz képest.

40.2.3 Levezetési kötelezettség: hipotéziselkülönítő lemma

A vizsga levezetési kötelezettsége annak pontos megfogalmazása, hogy egyetlen mérés mikor dönt két hipotézis között.

Lemma (elkülönítő ellenőrzés). Legyen H1,H2H_1,H_2 két hipotézis, és OO egy megfigyelhető statisztika. Tegyük fel, hogy H1H_1 az O∈A1O\in A_1, H2H_2 pedig az O∈A2O\in A_2 tartományt predikálja, ahol A1,A2⊆ℝA_1,A_2\subseteq\mathbb R. Ha A1∩A2=⌀A_1\cap A_2=\varnothing, akkor OO egyetlen megvalósult mérése legalább az egyik hipotézist kizárja.

Bizonyítás. Ha a mért o∈A1o\in A_1, akkor o∉A2o\notin A_2 a diszjunktság miatt, tehát H2H_2 predikciója hamis; ha o∈A2o\in A_2, akkor szimmetrikusan H1H_1 hamis; ha o∉A1∪A2o\notin A_1\cup A_2, akkor mindkettő hamis. Minden esetben legfeljebb egy hipotézis marad konzisztens a méréssel. ▫\square

A lemma triviális, de a feltétele nem az: a hallgatónak előre, a mérés előtt meg kell adnia A1A_1 és A2A_2 halmazokat, és ezeknek a hipotézisből szükségszerűen kell következniük. Két esetet kell elkülöníteni. (1) Determinisztikus kódhiba-bizonyítás: egy szerkezeti ellenőrzés kimenete (a splitet átlépő jelenetek száma, a célindex és a képindex egyezése, az aktív training flagek listája) a kódból és az adatból egyértelműen következik; itt a lemma szó szerint alkalmazható, egyetlen futtatás kizár. (2) Statisztikai hipotézisvizsgálat: egy pontosság vagy rés zajos, bármely hipotézis mellett több értéket felvehet, ezért a halmazok nem diszjunktak, és a „kizárás” csak döntési szabály, nem logikai következmény. Ehhez meg kell nevezni a mintavételi egységet, a nullhipotézist, a hibaszintet és a döntési szabályt, és a téves elutasítás valószínűsége (α\alpha) pozitív. A GlyphLab tesztje 60 kép, de 30 jelenet két-két nézete: ha a modell kimenete a képtől függ, a két nézet eredménye korrelált lehet, ezért 60 független Bernoulli-próbával nem számolhatunk igazolás nélkül. Konzervatív választás jelenetenként egy nézet: n=30n=30. Ha H0H_0 szerint a modell a címkétől függetlenül, egyenletesen tippel, akkor a helyes találatok száma X∼Bin(30,1/3)X\sim\mathrm{Bin}(30,1/3); α=0,01\alpha=0{,}01 szinten a döntési szabály „H0H_0 elutasítva, ha X≥17X\ge17”, mert P(X≥17)=0,0072P(X\ge17)=0{,}0072 és P(X≥16)=0,0188P(X\ge16)=0{,}0188 (SciPy binom.sf). Ez az 5.2. Várható érték, variancia és likelihood binomiális modelljére épül; az ismételt mintavételi értelmezés az [S02] keretében érvényes.

Alkalmazzuk a lemmát a nyitó anomáliára. Három versengő hipotézis:

Hipotézis Lehetséges teljesítménytünet (nem szükségszerű) Közvetlen szerkezeti ellenőrzés
H1H_1: split-szivárgás magas val acc, val ≫\gg test; a mérték a nézetek hasonlóságától függ a tényleges train/val/test indexek scene_id-metszete; a normalizáló statisztika forrásindexei
H2H_2: címkeeltolódás a kiértékelésben alacsony test acc, permutált átló a konfúziós mátrixban a kiértékelt célok indexe azonos-e a predikált képek indexével (kép–címke azonosító párok)
H3H_3: dropout/BatchNorm train módban a kiértékeléskor ismételt val-kiértékelés ingadozása a kiértékelés pillanatában aktív training flagek listája; a véletlen- és statisztikarétegek listája

A tünetoszlop nem kizáró: a jósolt tartományok nem diszjunktak és nem szükségszerűek. Szivárgás mellett is lehet kicsi a val–test rés (ha a nézetek kevéssé hasonlók), címkehibánál is lehet nagy, és több hiba egyszerre is fennállhat. Ezért a lemmát a harmadik oszlop determinisztikus ellenőrzéseire alkalmazzuk, és mindhármat lefuttatjuk, nem állunk meg az első pozitívnál. Az alábbi kontroll két különböző hibát mutat, amelyek ugyanazt a feltett mintázatot (magas val acc, alacsony clean test acc) adhatják, és a közvetlen ellenőrzések szétválasztják őket; a második rész azt mutatja, hogy a dropout kimenet-különbsége train módban is lehet nulla.

import numpy as np
import torch

# Két különböző hiba, azonos feltett teljesítménymintázattal (val acc magas, clean test acc alacsony).
scene = np.repeat(np.arange(6), 2)                      # 12 kép: 6 jelenet × 2 nézet
split_a = np.array(["train", "val", "train", "train", "val", "test",
                    "test", "test", "train", "train", "val", "val"])   # A: képenkénti split
split_b = np.repeat(["train", "train", "train", "val", "test", "test"], 2)  # B: jelenetszintű split
eval_idx_a = np.flatnonzero(split_a == "test")          # A: a célok indexe helyes
eval_idx_b = np.arange((split_b == "test").sum())       # B: labels[:n_test], a tömb eleje

def leaking_scenes(split):
    return [int(s) for s in np.unique(scene) if len(set(split[scene == s])) > 1]

def targets_match_images(split, eval_idx):
    return bool(np.array_equal(eval_idx, np.flatnonzero(split == "test")))

for name, split, idx in (("A", split_a, eval_idx_a), ("B", split_b, eval_idx_b)):
    print(f"{name}: splitet átlépő jelenetek = {leaking_scenes(split)}, "
          f"célindex = képindex: {targets_match_images(split, idx)}")

# A dropout kimenet-különbsége tünet, nem bizonyíték: train módban is lehet 0.
drop = torch.nn.Dropout(p=0.5).train()
zeros, ones = torch.zeros(4, 8), torch.ones(4, 8)
print("nulla aktiváció, két hívás:", (drop(zeros) - drop(zeros)).abs().max().item())
torch.manual_seed(1); first = drop(ones)
torch.manual_seed(1); second = drop(ones)
print("azonos seed, két hívás:", (first - second).abs().max().item())
print("aktív training flag:", [type(m).__name__ for m in drop.modules() if m.training])

Kimenet a lab-profilban (PyTorch 2.14.1+cpu):

A: splitet átlépő jelenetek = [0, 2], célindex = képindex: True
B: splitet átlépő jelenetek = [], célindex = képindex: False
nulla aktiváció, két hívás: 0.0
azonos seed, két hívás: 0.0
aktív training flag: ['Dropout']

Az A esetben szivárgás van, a célindex helyes; a B esetben a split hibátlan, a célok viszont nem a kiértékelt képekhez tartoznak. A teljesítménymintázat a kettőt nem különbözteti meg, a két szerkezeti ellenőrzés igen. A dropout esetében a nulla kimenet-különbség nem zárja ki a train módot; a training flag közvetlenül mutatja.

40.2.4 Labor: öt eredeti hibás kódrészlet diagnózisa

A labor öt GlyphLab-kontextusú, szándékosan hibás kódrészletet ad. Mindegyiknél közöljük a hiba okát, következményét, az elkülönítő mérést és a javítást. A kimenetek jelölt szemléltetések, mert ez a kézirat nem futtat kódot.

40.2.4.1 1. Leakage: teljes adatra számolt normalizálás és képenkénti split

# HIBÁS
images = np.load("data/images.npy").astype(np.float32)
labels = np.load("data/labels.npy")
mean = images.mean()          # a teszt statisztikája is benne van
std  = images.std()
X = (images - mean) / std
perm = np.random.default_rng(1).permutation(len(X))
idx_tr, idx_va, idx_te = perm[:240], perm[240:300], perm[300:]

Ok. A normalizálás a teljes adatra számol átlagot és szórást; a split képenként véletlen, így egy scene_id két nézete különböző splitbe kerülhet.

Következmény. A modell a teszteloszlás skáláját ismeri, és a validáció egy része a train-nézetek párja. Lehetséges tünet a felfelé torzított val acc; a torzítás mértéke a nézetek hasonlóságától függ, ezért a tünet önmagában nem bizonyít.

Elkülönítő mérés. Nem a metadata split oszlopát kell vizsgálni, hanem a ténylegesen használt indexhalmazokat és a belőlük épített loadereket: hány scene_id fordul elő egynél több loaderben. Ha ez a szám pozitív, a leakage igazolt. A javítás ezért a metadata index mezőjéből újraképzi mindhárom indexhalmazt, ezekből újraépíti a loadereket, a régi perm-alapú indexeket és loadereket nem használja tovább, a normalizáló statisztikát az új train indexekből számolja, és az ellenőrzést a loaderek által ténylegesen visszaadott képindexeken futtatja; a kontroll a hibás split loadereit ugyanezzel a függvénnyel elkapja.

# JAVÍTOTT
import torch
from torch.utils.data import DataLoader, TensorDataset

meta = pd.read_csv("data/metadata.csv")
assert meta["index"].tolist() == list(range(len(images)))  # metadata-sor = képindex
idx_tr, idx_va, idx_te = (meta.loc[meta["split"] == s, "index"].to_numpy()
                          for s in ("train", "val", "test"))   # a perm-alapú indexek helyett
mean = images[idx_tr].mean()          # csak a train képekből
std = images[idx_tr].std()
X = (images - mean) / std

def make_loader(idx, shuffle):        # a loader a képindexet is visszaadja
    ds = TensorDataset(torch.from_numpy(X[idx]), torch.from_numpy(labels[idx]),
                       torch.from_numpy(idx))
    return DataLoader(ds, batch_size=32, shuffle=shuffle)

def loader_indices(loader):
    return np.concatenate([batch[2].numpy() for batch in loader])

def scene_overlap(*loaders):          # a ténylegesen betöltött képek jelenetei
    scenes = [set(meta["scene_id"].to_numpy()[loader_indices(dl)]) for dl in loaders]
    return {(a, b): len(scenes[a] & scenes[b])
            for a in range(len(scenes)) for b in range(a + 1, len(scenes))}

train_loader, val_loader, test_loader = (make_loader(idx_tr, True), make_loader(idx_va, False),
                                         make_loader(idx_te, False))
assert not any(scene_overlap(train_loader, val_loader, test_loader).values())

# kontroll: a hibás, perm-alapú split loadereit ugyanez az ellenőrzés elkapja
bad = [make_loader(i, False) for i in (perm[:240], perm[240:300], perm[300:])]
print("perm-split metszetek:", scene_overlap(*bad))

Kimenet a lab-profilban: perm-split metszetek: {(0, 1): 42, (0, 2): 36, (1, 2): 12}, azaz a perm-alapú split train–val, train–test és val–test párjai rendre 42, 36 és 12 jelenetben fednek át; a javított loaderekre mindhárom metszet üres.

40.2.4.2 2. Label bug: a corrupt tesztet train-címkékkel mérjük

# HIBÁS
corr = torch.from_numpy(np.load("data/corrupted_test.npy"))
model.eval()
with torch.no_grad():
    logits = model(corr)
pred = logits.argmax(1)
y_eval = torch.from_numpy(labels[:60])   # a train eleje, nem a teszt
acc = (pred == y_eval).float().mean().item()

Ok. A labels[:60] a teljes tömb első hatvan eleme, azaz a train split első hatvan címkéje. A corrupt teszt sorrendje a metadata teszt-sorrendjével egyezik, tehát a predikcióhoz nem tartozó célokat hasonlítunk.

Következmény. A mért pontosság érvénytelen, mert a predikciókat nem a hozzájuk tartozó célokkal hasonlítja; meghatározott chance-szintje nincs. A 1/31/3 várható érték csak akkor adódik, ha a tévesen párosított címkék a predikcióktól függetlenek és a három osztályon egyenletesek. Kiegyensúlyozott osztályok mellett is lehet a mért érték 1 vagy 0: pred = [0, 1, 2] mellett a [0, 1, 2] rossz szelet pontossága 1,01{,}0 (a rossz szelet véletlenül azonos sorrendű), az [1, 2, 0] szeleté 0,00{,}0 (ciklikus permutáció).

Elkülönítő mérés. Közvetlen ellenőrzés: a kiértékelt célok indexe megegyezik-e a predikált képek indexével. A labels[:60] a 0–59. sorok címkéje, a corrupt teszt képei viszont a metadata split == 'test' sorainak felelnek meg; ha a két indexlista eltér, a címkehiba igazolt, a pontosságtól függetlenül. A helyes címkékkel újraszámolt pontosság csak kiegészítő tünet.

# JAVÍTOTT
test_idx = meta.index[meta["split"] == "test"].to_numpy()
y_eval = torch.from_numpy(labels[test_idx])
acc = (pred == y_eval).float().mean().item()

40.2.4.3 3. Train/eval: dropout aktív a validációban

# HIBÁS
for epoch in range(epochs):
    model.train()
    for xb, yb in train_loader:
        ...
    val_acc = evaluate(model, val_loader)   # model.eval() hiányzik

Ok. A model.eval() hívás elmarad a validáció előtt, így a dropout véletlen maszkja továbbra is aktív, a BatchNorm pedig a batch-statisztikát használja.

Következmény. A validációs veszteség batch-összetételtől függően ingadozik, a legjobb checkpoint kiválasztása zajossá válik. A teszt, ha ott már helyesen eval módban fut, jobbnak tűnhet a validációnál, ami inverz anomália.

Elkülönítő mérés. Közvetlen ellenőrzés: az evaluate belsejében, a forward előtt, a [n for n, m in model.named_modules() if m.training] lista üres-e. Ha nem üres, a módhiba igazolt. Ugyanazon a val batch-en két egymás utáni kiértékelés logit-különbsége csak tünet: pozitív érték train módot jelez, a nulla viszont nem zárja ki (nulla aktiváció a dropout előtt, azonos seed és így azonos maszk, vagy olyan modell, amelyben nincs véletlenréteg).

# JAVÍTOTT
model.eval()
for xb, yb in val_loader:
    with torch.no_grad():
        logits = model(xb)
    ...
# a következő epoch előtt vissza kell kapcsolni
model.train()

40.2.4.4 4. Wrong reduction: a batchösszeg és a képenkénti nevező keveredik

A 39.2. Encoder–decoder vagy lokalizációs fej szegmentáló fejének célfüggvénye az összes pixelre átlagolt bináris log loss (BCEWithLogitsLoss(), mean redukció). Ez legitim, széles körben használt cél; az előtér ritkasága önmagában nem implementációs hiba, hanem célválasztási és metrikaértelmezési kérdés, amelyet a validációs Dice/IoU mellett kell megítélni. Ha az előteret szándékosan nagyobb súllyal akarjuk tanítani, ezt explicit súlyozott célként kell definiálni. Képenként normalizálva, majd a képekre átlagolva:

ℒ=1N∑n=1N∑pwn,pℓn,p∑pwn,p,wn,p={wfg,Yn,p=1,1,Yn,p=0, \mathcal L=\frac1N\sum_{n=1}^{N}\frac{\sum_{p}w_{n,p}\,\ell_{n,p}}{\sum_{p}w_{n,p}},\qquad w_{n,p}=\begin{cases}w_{\mathrm{fg}},&Y_{n,p}=1,\\1,&Y_{n,p}=0,\end{cases}

ahol ℓn,p\ell_{n,p} a pixelenkénti BCE-tag, wfg>0w_{\mathrm{fg}}>0. A wfg=1w_{\mathrm{fg}}=1 eset a szokásos pixelátlag (azonos képméret mellett). A nevező képenként legalább H⋅W⋅min(1,wfg)>0H\cdot W\cdot\min(1,w_{\mathrm{fg}})>0, tehát üres célmaszkra is értelmezett, és ilyenkor a háttértagok büntetnek: egy téves előtérpredikció üres GT mellett is nagy veszteséget kap.

A hibás változat egy „javításnak” szánt redukció:

# HIBÁS
logits_flat = logits.view(N, -1)       # (N, H*W)
target_flat = masks.view(N, -1)        # (N, H*W)
loss = F.binary_cross_entropy_with_logits(
    logits_flat, target_flat, reduction="sum")
loss = loss / target_flat.sum(dim=1).clamp(min=1).mean()

Ok. A számláló a teljes batch összege, a nevező az egy képre jutó átlagos előtérszám. A batch megduplázása így megduplázza a veszteséget és a gradienst: a hatásos tanulási ráta a batchmérettől függ. A közös nevező ráadásul az előtér és a háttér relatív súlyát sem változtatja meg, mert mindkettő ugyanazt a szorzót kapja. A clamp(min=1) csak a nullával osztást akadályozza meg; a számlálót nem nullázza, üres maszkra a háttértagok továbbra is benne vannak (négy z=0z=0 logit és üres cél mellett a számláló 4log2≈2,77264\log2\approx2{,}7726).

Következmény. A batchméret vagy a gradiens-akkumuláció változtatása más optimalizálási lépést ad ugyanarra az adatra; a kísérletek nem összehasonlíthatók, és az előtér súlyozása, amelyet a kód sugall, valójában nem történik meg.

Elkülönítő mérés és javítás. A következő float64-es ellenőrzés a hibás redukciót és a fenti képenként normalizált célt veti össze: batch-duplikálás, a wfg=1w_{\mathrm{fg}}=1 eset egyezése a szokásos pixelátlaggal, az előtér/háttér gradiensarány és az üres maszk.

import math
import torch
import torch.nn.functional as F

def wrong_reduction(logits, masks):
    # a fenti HIBÁS redukció függvényként: a számláló a teljes batch összege, a nevező egy képre jutó előtérszám-átlag
    n = logits.shape[0]
    loss = F.binary_cross_entropy_with_logits(
        logits.reshape(n, -1), masks.reshape(n, -1), reduction="sum")
    return loss / masks.reshape(n, -1).sum(dim=1).clamp(min=1).mean()

def weighted_bce_per_image(logits, masks, w_fg=1.0):
    """Képenként normalizált, explicit előtérsúlyos BCE; w_fg=1 a szokásos pixelátlag."""
    per_pixel = F.binary_cross_entropy_with_logits(logits, masks, reduction="none")
    w = torch.where(masks > 0.5, torch.full_like(masks, w_fg), torch.ones_like(masks))
    per_image = (w * per_pixel).flatten(1).sum(1) / w.flatten(1).sum(1)
    return per_image.mean()

torch.manual_seed(20261020)
dt = torch.float64
masks = torch.zeros(3, 1, 8, 8, dtype=dt)
masks[0, 0, 2:6, 3] = 1                       # vékony előtér
masks[1, 0, 4, 1:7] = 1                       # üres a 2-es kép marad
logits = torch.randn(3, 1, 8, 8, dtype=dt)

def grads(fn, lg, mk, **kw):
    lg = lg.clone().requires_grad_(True)
    loss = fn(lg, mk, **kw)
    loss.backward()
    return loss.item(), lg.grad

# 1) Batch-duplikálás: az átlagolt loss és a paramétergradiens maradjon változatlan.
#    A két másolat logitjai ugyanattól a paramétertől függnek, ezért a paraméterekre
#    a két másolat logit-gradiensének összege hat.
for name, fn in (("hibás", wrong_reduction), ("javított", weighted_bce_per_image)):
    l1, g1 = grads(fn, logits, masks)
    l2, g2 = grads(fn, torch.cat([logits, logits]), torch.cat([masks, masks]))
    scale = ((g2[:3] + g2[3:]) / g1).mean().item()
    print(f"{name:9s} loss {l1:.6f} -> duplikálva {l2:.6f}; paramétergradiens-szorzó {scale:.3f}")

# 2) w_fg=1 a szokásos pixelátlag
assert math.isclose(weighted_bce_per_image(logits, masks).item(),
                    F.binary_cross_entropy_with_logits(logits, masks).item(), rel_tol=1e-12)

# 3) Előtér/háttér gradiensarány: a hibás nevező nem változtatja, a w_fg igen.
def fg_bg_ratio(fn, **kw):
    _, g = grads(fn, logits, masks, **kw)
    fg = masks > 0.5
    return (g[fg].abs().mean() / g[~fg].abs().mean()).item()
print("előtér/háttér |grad|-arány: pixelátlag %.3f, hibás %.3f, w_fg=10 %.3f" % (
    fg_bg_ratio(weighted_bce_per_image), fg_bg_ratio(wrong_reduction),
    fg_bg_ratio(weighted_bce_per_image, w_fg=10.0)))

# 4) Üres célmaszk: a háttértagok is büntetnek.
empty = torch.zeros(1, 1, 2, 2, dtype=dt)
z0 = torch.zeros_like(empty)
print("üres maszk, z=0: %.6f (log 2 = %.6f), z=+5: %.6f" % (
    weighted_bce_per_image(z0, empty).item(), math.log(2),
    weighted_bce_per_image(z0 + 5.0, empty).item()))
print("lektori kontroll, hibás redukció: négy z=0 üres -> %.9f, duplikálva %.9f" % (
    wrong_reduction(z0, empty).item(), wrong_reduction(torch.cat([z0, z0]), torch.cat([empty, empty])).item()))

Futási jegyzőkönyv (lab-profil, PyTorch 2.14.1+cpu, float64):

hibás     loss 40.106559 -> duplikálva 80.213117; paramétergradiens-szorzó 2.000
javított  loss 0.765924 -> duplikálva 0.765924; paramétergradiens-szorzó 1.000
előtér/háttér |grad|-arány: pixelátlag 0.690, hibás 0.690, w_fg=10 5.426
üres maszk, z=0: 0.693147 (log 2 = 0.693147), z=+5: 5.006715
lektori kontroll, hibás redukció: négy z=0 üres -> 2.772588722, duplikálva 5.545177444

A hibás redukció a duplikált batchen kétszeres veszteséget és kétszeres paramétergradienst ad, a képenként normalizált cél mindkettőt változatlanul hagyja. Az előtér/háttér gradiensarány a hibás változatban pontosan a pixelátlagé (0,6900{,}690), tehát a „javítás” nem súlyoz; a valódi előtérsúlyozás (wfg=10w_{\mathrm{fg}}=10) az arányt 5,435{,}43-ra emeli. Üres célmaszkra z=0z=0 mellett a veszteség log2\log2, a téves előtérpredikció (z=+5z=+5) pedig nagyobb büntetést kap (5,0075{,}007).

40.2.4.5 5. Checkpoint: az utolsó epoch állapotát értékeljük

# HIBÁS
for epoch in range(epochs):
    train_one_epoch(...)
    val_loss = evaluate(...)
    torch.save({"model": model.state_dict(), "epoch": epoch}, "last.pt")
state = torch.load("last.pt")
model.load_state_dict(state["model"])
test_acc = evaluate(model, test_loader)   # az utolsó, túlillesztett modell

Ok. A kiértékelés nem a legjobb validációs veszteségű epoch modelljét tölti be, hanem az utolsót; a túlillesztés után az utolsó epoch gyakran rosszabb.

Következmény. A riportált eredmény nem a protokoll szerint kiválasztott modellhez tartozik. Az utolsó epoch a teszten lehet rosszabb, azonos, sőt jobb is a kiválasztottnál; a hiba a választási protokoll megsértése, nem a pontosság iránya.

Elkülönítő mérés. A diagnózis nem használja a tesztet: a teszt a kiválasztás után egyszer fut, és nem vehet részt a modellválasztás ellenőrzésében. A betöltött checkpoint útvonalát, SHA-256 hash-ét, epoch és mentett val_loss mezőjét a tanítási napló előre rögzített argminjával vetjük össze, a betöltés helyességét pedig a validációs kontrollon: a betöltött modellel újraszámolt val loss egyezik-e a checkpointban mentett értékkel. A rossz epoch így akkor is kiderül, ha a két modell pontossága azonos.

import hashlib
import torch

def sha256(path):
    with open(path, "rb") as f:
        return hashlib.sha256(f.read()).hexdigest()

def check_checkpoint(path, log, model, val_loss_fn, tol=1e-6):
    """Teszt nélküli diagnózis: a betöltött állapot a napló előre rögzített argminja-e."""
    best = min(log, key=lambda r: r["val_loss"])          # szabály: legkisebb val_loss epoch
    state = torch.load(path, map_location="cpu")
    model.load_state_dict(state["model"])
    model.eval()
    report = {"path": str(path), "sha256": sha256(path), "epoch": state["epoch"],
              "argmin_epoch": best["epoch"], "saved_val_loss": state.get("val_loss"),
              "recomputed_val_loss": val_loss_fn(model)}
    report["selection_ok"] = state["epoch"] == best["epoch"]
    report["load_ok"] = (state.get("val_loss") is not None
                         and abs(report["recomputed_val_loss"] - state["val_loss"]) <= tol)
    return report

# kontroll: azonos súlyok (tehát azonos accuracy), de rossz epoch -> a diagnózis jelez
torch.manual_seed(0)
model = torch.nn.Linear(4, 3)
xv, yv = torch.randn(16, 4), torch.randint(0, 3, (16,))
val_loss_fn = lambda m: torch.nn.functional.cross_entropy(m(xv), yv).item()
v2 = val_loss_fn(model)                                   # az utolsó (2.) epoch valódi val_loss-a
log = [{"epoch": 0, "val_loss": v2 + 0.2}, {"epoch": 1, "val_loss": v2 - 0.1}, {"epoch": 2, "val_loss": v2}]
torch.save({"model": model.state_dict(), "epoch": 2, "val_loss": v2}, "last.pt")
torch.save({"model": model.state_dict(), "epoch": 1, "val_loss": v2 - 0.1}, "best.pt")  # hibás mentés: 1. epoch címke, 2. epoch súlyai
for path in ("last.pt", "best.pt"):
    r = check_checkpoint(path, log, model, val_loss_fn)
    print(path, {k: r[k] for k in ("epoch", "argmin_epoch", "selection_ok", "load_ok")})

Kimenet a lab-profilban (PyTorch 2.14.1+cpu):

last.pt {'epoch': 2, 'argmin_epoch': 1, 'selection_ok': False, 'load_ok': True}
best.pt {'epoch': 1, 'argmin_epoch': 1, 'selection_ok': True, 'load_ok': False}

A last.pt hibátlanul betöltődik, de nem az argmin epoch; a hibásan mentett best.pt epoch-mezője helyes, a súlyai viszont nem az 1. epochéi, ezért az újraszámolt val loss eltér a mentettől. Egyik eset sem igényelt tesztfuttatást.

# JAVÍTOTT
best_val = float("inf")
for epoch in range(epochs):
    train_one_epoch(...)
    val_loss = evaluate(...)
    if val_loss < best_val:
        best_val = val_loss
        torch.save({"model": model.state_dict(), "epoch": epoch,
                    "val_loss": val_loss}, "best.pt")
state = torch.load("best.pt")
model.load_state_dict(state["model"])
model.eval()

40.2.5 Kézi számpélda a hipotéziselkülönítésre

Legyen a teszt hat képe, y=(0,1,2,0,1,2)y=(0,1,2,0,1,2). A H2H_2 címkeeltolódási hipotézis hibás céljai y′=(1,2,0,1,2,0)y'=(1,2,0,1,2,0), azaz ciklikus eltolás. Közvetlen feltevésként legyen a tesztpredikció ŷ=y\hat y=y. Ez feltevés a példa kedvéért; abból, hogy egy modell a trainen tökéletes, a teszten való tökéletesség nem következik.

import numpy as np
y     = np.array([0, 1, 2, 0, 1, 2])
y_hat = y.copy()
y_bug = (y + 1) % 3
acc_true = (y_hat == y).mean()      # 1.000
acc_bug  = (y_hat == y_bug).mean()  # 0.000

A lemma nyelvén O=(acc_true,acc_bug)O=(\text{acc\_true},\text{acc\_bug}). Rögzített ŷ\hat y mellett a H2H_2 címkehiba-hipotézis determinisztikusan O=(1,0)O=(1,0)-t ad. A „a modell egyenletesen, a képtől függetlenül tippel” nullhipotézis mellett a hat találat független Bernoulli(1/3)(1/3)-próba, és mivel y′i≠yiy'_i\ne y_i, az acc_true=1\text{acc\_true}=1 eseményből acc_bug=0\text{acc\_bug}=0 következik: P(O=(1,0)∣H0)=3−6=1/729≈0,00137P(O=(1,0)\mid H_0)=3^{-6}=1/729\approx0{,}00137. Ez pozitív, tehát a (1,0)(1,0) kimenet nem logikai kizárás: a két hipotézis tartománya nem diszjunkt. Döntési szabályként: α=0,01\alpha=0{,}01 szinten H0H_0-t elutasítjuk, ha mind a hat predikció helyes; a téves elutasítás valószínűsége 0,001370{,}00137. A 60 képes teszt standard hibája ide nem vihető át, mert a mintaméret itt 6. Ha a mért érték (1/3,1/3)(1/3,1/3) körüli, az a H2H_2-t a rögzített ŷ=y\hat y=y feltevés mellett kizárja, mert H2H_2 ott determinisztikusan (1,0)(1,0)-t ad.

40.2.6 Feladatok

F1 (fogalmi, nehézség 1). Definiálja a leakage fogalmát a GlyphLab szerződésben. Adjon egy olyan példát, ahol a képenkénti véletlen split nem okoz mérhető validációs torzítást, és egy olyat, ahol igen. Átadandó: legfeljebb fél oldalas magyarázat, a scene_id szerepének megjelölésével.

F2 (levezetési, nehézség 2). Rögzített tesztpredikció ŷ=y\hat y=y mellett H1H_1 szerint a kiértékelés célja ciklikusan eltolódott (y′=y+1mod3y'=y+1\bmod 3), H0H_0 szerint a modell a címkétől függetlenül, egyenletesen tippel. A fenti hat elemű mintán (a) mutassa meg, hogy H1H_1 az O=(acc_true,acc_bug)O=(\text{acc\_true},\text{acc\_bug}) statisztikára determinisztikus predikciót ad, H0H_0 tartója viszont tartalmazza ezt az értéket, tehát a lemma diszjunktsági feltétele nem teljesül; (b) adjon α=0,01\alpha=0{,}01 szintű döntési szabályt H0H_0 ellen, és számítsa ki a téves elutasítás valószínűségét; (c) nevezze meg, mi a mintavételi egység a 60 képes GlyphLab-teszten, és miért. Átadandó: a predikciók, a döntési szabály, a hibavalószínűség és a mintavételi egység indoklása.

F3 (implementációs, nehézség 2). Írjon olyan Python-függvényt, amelynek neve scene_split_check(metadata_csv), és amely a GlyphLab metadata.csv-ből ellenőrzi, hogy minden scene_id összes nézete azonos splitben van. A függvény adja vissza a sértő scene_id-k listáját. Átadandó: futtatható függvény, egy üres és egy mesterségesen megsértett bemenetre adott kimenettel.

F4 (hibakeresési, nehézség 2). A labor 2. kódrészlete a corrupt tesztet labels[:60]-nal értékeli. Lokalizálja a hibát, írja le a következményét a várható pontosságra, és mutassa meg a javítást három elemű ellenőrző példán, ahol a helyes címkék [0,2,1], a hibásak [1,0,0], a predikciók [0,2,1]. Átadandó: ok, következmény, javított kód, a három elemű kontroll értéke.

F5 (kísérleti/transzfer, nehézség 3). Tervezzen kísérletet annak eldöntésére, hogy a 99,8%-os validációs pontosság split-szivárgásból vagy a val jelenetek könnyű összetételéből adódik. Adja meg a protokollt, a megfigyelési egységet, a metrikát és a következtetési korlátot. Átadandó: egy bekezdésnyi protokoll, amely nem használja a teszt-adatot a döntéshez.

40.2.7 Megoldások és indoklás

F1 megoldás. A leakage itt két alakot ölthet. (a) A normalizáló statisztika a teljes adatra számolva a teszteloszlás átlagát és szórását viszi a modellbe; ez akkor is torzít, ha a split egyébként helyes. (b) A képenkénti véletlen split egy jelenet két nézetét külön splitbe teheti; mivel a két nézet ugyanazt a geometriát, csak külön zajt tartalmaz, a validációs kép szomszédos a train képpel, és a pontosság felfelé torzított. A képenkénti split önmagában nem feltétlenül torzít, ha a jelenetek függetlenek lennének és a nézetek nem hasonlítanának; a GlyphLab-ban azonban a scene_id a mintavételi egység, ezért a képenkénti split sérti a függetlenséget. Az ellenőrzés a groupby("scene_id")["split"].nunique() értéke: ha bármely jelenetre 2, a hiba fennáll.

F2 megoldás. (a) ŷ=y\hat y=y és y′i≠yiy'_i\ne y_i miatt H1H_1 mellett acc_true=1\text{acc\_true}=1, acc_bug=0\text{acc\_bug}=0, azaz A1={(1,0)}A_1=\{(1,0)\}. H0H_0 mellett a hat találat független Bernoulli(1/3)(1/3), így OO bármely (k/6,j/6)(k/6,\,j/6) értéket felveheti k+j≤6k+j\le6 mellett, a (1,0)(1,0)-t is 3−63^{-6} valószínűséggel. A tartók nem diszjunktak, ezért egyetlen mérés nem logikai kizárás. (b) Döntési szabály: H0H_0 elutasítva, ha acc_true=1\text{acc\_true}=1. A téves elutasítás valószínűsége P(acc_true=1∣H0)=3−6≈0,00137≤0,01P(\text{acc\_true}=1\mid H_0)=3^{-6}\approx0{,}00137\le0{,}01; a H1H_1 mellett a szabály mindig elutasít. A 3−63^{-6} csak a független, egyenletes tippelés nullhipotézise mellett érvényes. (c) A 60 képes teszten a mintavételi egység a jelenet (30 jelenet, két nézet), mert a képtől függő kimenet mellett a két nézet találata korrelált lehet. Konzervatív eljárás jelenetenként egy nézet: X∼Bin(30,1/3)X\sim\mathrm{Bin}(30,1/3), és α=0,01\alpha=0{,}01 mellett az elutasítási tartomány X≥17X\ge17 (P=0,0072P=0{,}0072). A 60 képre számolt (1/3)(2/3)/60\sqrt{(1/3)(2/3)/60} standard hiba csak független képek esetén lenne érvényes.

F3 megoldás.

# munkakönyvtár: lab/
import pandas as pd

def scene_split_check(path):
    meta = pd.read_csv(path)
    bad = (meta.groupby("scene_id")["split"].nunique() > 1)
    return bad[bad].index.tolist()

# tiszta GlyphLab esetén: []
# mesterséges sértés: meta.loc[0, "split"] = "val"
# ha a 0 indexű sor scene_id-ja s0, az eredmény: ["s0"]

A függvény alakellenőrzése: a bemenet metadata.csv sémája rögzített; a kimenet lista, üres lista a helyes eset. Szélsőeset: egyetlen jelenet egyetlen nézettel sosem sértő, mert nunique() értéke 1.

F4 megoldás. A hiba a labels[:60] szelet: ez a teljes tömb első hatvan eleme, amely a train split eleje, nem a teszt. A corrupt teszt sorrendje a metadata teszt-sorrendje. Következmény: a mért pontosság a modell predikcióit a képekhez nem tartozó címkékkel hasonlítja, ezért érvénytelen, és nincs rögzített chance-szintje; 1/31/3 csak a predikcióktól független, egyenletes hibás címkék feltevésével várható. A javítás a teszt-indexek használata. A három elemű kontroll: predikciók [0,2,1], helyes címkék [0,2,1], hibás címkék [1,0,0]. A helyes pontosság 3/3=13/3=1, a hibás 0/3=00/3=0, tehát a különbség a címkehozzárendelésből fakad, nem a modellből.

F5 megoldás. Protokollvázlat: (1) rögzítsük a modellt és a train splitet. (2) Készítsünk két validációs kiértékelést: az eredeti val spliten és egy új, scene-szinten újrahúzott val spliten, amelyet a train-ből vettünk ki, de a train újratanítása nélkül. (3) Ha az eredeti val acc 99,8%, az újrahúzott val acc pedig 50% alatt van, a magas érték a val jelenetek könnyű összetételéből adódik, nem szivárgásból. (4) Ha a normalizálási statisztikát csak a trainre számolva a val acc jelentősen csökken, a statisztika-szivárgás igazolt. A megfigyelési egység a scene_id, a metrika a pontosság, a következtetési korlát: ez a diagnózis csak a GlyphLab eloszlásra érvényes, és a teszt-adatot nem használhatjuk a döntéshez, mert az a protokoll szerint a végső kiértékelésre van fenntartva.

40.2.8 Tipikus tévedés és ellenpélda

A gyakori tévedés: a helyes tipp nem azonos dokumentált hibadiagnózissal. A hallgató kijavítja a model.eval() sort, a validációs görbe kisimul, és ezt a dropout-hiba bizonyítékának tekinti. De a simább görbe csak a train/eval állapot hatását mutatja; ha a tesztpontosság továbbra is 41%, a label-bug vagy a checkpoint-hiba továbbra is fennállhat. Ellenpélda: a labor 2. kódrészletében a modell hibátlan, a kiértékelés mégis 1/31/3 pontosságot mér; a model.eval() hozzáadása ezen semmit nem változtat. A diagnózis akkor teljes, ha az elkülönítő mérést is elvégezzük, és a naplóban rögzítjük, mely hipotézisek maradtak életben.

40.2.9 Önellenőrzés és visszahívás

Önellenőrző kérdések: (1) Megadtam-e minden hipotézishez a predikált mérési tartományt a mérés előtt? (2) A split ellenőrzése a scene_id-t használja-e, nem a képet? (3) A loss nevezője a definiált megfigyelési egységet osztja-e? (4) A checkpoint a best val állapotot tölti-e, és a betöltés után eval módban fut-e a teszt? Visszahívás a 36.2. Ismétlések és eredménybizonytalanság anyagából: a seed-ismétlés független egysége a teljes futás; ha egy hiba javítása után egyetlen seeddel hasonlítunk, nem tudjuk megkülönböztetni a javítás hatását a seed-zajtól. Visszahívás a 28.2. Sziámi és triplet cél anyagából: a triplet margin nélkül a konstans embedding collapse; hasonlóan, a redukciós nevező hibája azt a benyomást keltheti, hogy a loss nulla, miközben a valódi célfüggvény még romlik.

40.2.10 Rövid összefoglaló

A hibadiagnózis nem tipp, hanem versengő hipotézisek és előre rögzített, diszjunkt predikciós tartományok rendszere. Az elkülönítő lemma szerint egyetlen mérés akkor dönt, ha a hipotézisek által predikált halmazok nem metszik egymást; zajos mérésnél intervallumokat és standard hibát használunk. A labor öt GlyphLab-hibát diagnosztizált: teljes adatra számolt normalizálás, train-címkékkel mért corrupt teszt, hiányzó model.eval(), háttéren átlagolt maszkveszteség és utolsó-epoch checkpoint. A javítások mindegyikét elkülönítő mérés kíséri, mert a helyes tipp önmagában nem bizonyíték.

40.2.11 Következő alkalmazás

A 40.3. Tudományos kézirat és forrásaudit egység a tudományos kézirat és forrásaudit feladatát adja: a most diagnosztizált hibák után a hallgatónak a kísérleti naplóból olyan kéziratot kell összeállítania, amelyben minden állítás forráshoz, státuszhoz és nyers méréshez kötött, és a felhasznált állítások pontos forráshelyei a jegyzőkönyvben szerepelnek.

40.2.12 Felhasznált források