12.1 Tanítás, validáció és kiértékelési mód

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

Ez az egység a tanítási kísérlet legkisebb ismételhető keretét építi meg: egy teljes epoch tanítása, a modell üzemmódjának váltása, a veszteség helyes aggregálása és a validáció szivárgásmentes kiértékelése. Előismeret a 10.4. Tanítási lépés és tanuló MLP tanítási lépése, a 11.4. Egy teljes PyTorch tanítási iteráció PyTorch-iteráció sorrendje és a 11.1. Tensor és autograd-életciklus autograd életciklusa.

Induló kérdés: egy öt mintás epoch két batchből áll. A három mintás batch átlagos vesztesége 22, a két mintás batché 55. A teljes epoch átlagos vesztesége 3,53{,}5 vagy 3,23{,}2? Becsülje meg, mielőtt a levezetést elolvassa.

12.1.2 Definíciók és intuíció

Train/eval üzemmód. A PyTorch nn.Module .training bool flaget hordoz. A model.train() igazra, a model.eval() hamisra állítja. Az üzemmód az állapotfüggő rétegek viselkedését szabályozza: a dropout train módban véletlenül nulláz, eval módban identitás; a BatchNorm train módban a batch-statisztikát használja és frissíti a running statisztikát, eval módban a running statisztikát használja (18.2. Batch normalization train és eval módban tárgyalja részletesen; a keretjegyzet [D12] szerint a két statisztikakezelés nem felcserélhető). A szabály akkor is kötelező, ha az aktuális modellben éppen nincs állapotfüggő réteg, mert a későbbi bővítés nem írhatja át a protokollt.

torch.inference_mode(). Ez nem modell-üzemmód, hanem tenzor-kontextus: a blokkban létrehozott tenzorokból nem épül autograd gráf, és a gráfot nem lehet utólag kiegészíteni. Erősebb, mint a no_grad, mert a kilépés után sem állítható vissza a gráfépítés (11.1. Tensor és autograd-életciklus). A kiértékeléshez mindkét réteg kell: az eval() a modul állapotát, az inference_mode a memória- és gráffegyelmet kezeli.

Loss aggregation. A veszteségfüggvény mean redukcióval scalárt ad: egy n×Kn\times K logit tenzorból és nn darab int64 célból az nn egyedi veszteség átlagát. A tanítási lépésben a backward gradient az átlag deriváltja, ezért a minibatch gradiens az egyedi gradiensek átlaga (10.4. Tanítási lépés és tanuló MLP). Az epoch-loss ezzel szemben a teljes tanítóhalmazra vett monitoring metrika, nem a backprop bemenete.

Batch weighting és szivárgásmentesség (no leakage). A DataLoader az epochot batch-ek particionálja; ha az utolsó batch kisebb, a veszteségek összevetése súlyozást igényel. A szivárgásmentesség azt jelenti: a validációs split egyetlen mintája sem jelenik meg a tanítási veszteségben, a validációs forward nem frissíti a paramétereket, és a validációs metrika nem választhat modellt az előre rögzített protokoll megkerülésével. A GlyphLab adatban a split scene_id szerint csoportosított, ezért egy jelenet két nézete azonos splitbe kerül.

12.1.3 Levezetés: a súlyozott epoch-loss

Állítás (epoch-loss mint súlyozott átlag). Legyen az epoch tanítóhalmaza D={(xj,yj)}j=1ND=\{(x_j,y_j)\}_{j=1}^{N}, rögzített θ\theta paraméter mellett. A batchek D1,…,DBD_1,\ldots,D_B diszjunkt partíciót alkotnak, ni=|Di|n_i=|D_i|, ∑i=1Bni=N\sum_{i=1}^B n_i=N. Minden batchre mean redukcióval definiáljuk

L‾i(θ)=1ni∑(x,y)∈Diℓ(fθ(x),y). \bar L_i(\theta)=\frac{1}{n_i}\sum_{(x,y)\in D_i}\ell(f_\theta(x),y).

Ekkor a teljes epoch mean vesztesége

L‾(θ)=1N∑i=1BniL‾i(θ). \bar L(\theta)=\frac{1}{N}\sum_{i=1}^{B} n_i\,\bar L_i(\theta).

Bizonyítás. A partíció tulajdonság miatt D=⨆i=1BDiD=\bigsqcup_{i=1}^B D_i, tehát a teljes összeg felbontható:

L‾(θ)=1N∑(x,y)∈Dℓ(fθ(x),y)=1N∑i=1B∑(x,y)∈Diℓ(fθ(x),y). \bar L(\theta)=\frac{1}{N}\sum_{(x,y)\in D}\ell(f_\theta(x),y) =\frac{1}{N}\sum_{i=1}^{B}\sum_{(x,y)\in D_i}\ell(f_\theta(x),y).

A belső összeg éppen niL‾i(θ)n_i\bar L_i(\theta), mert a batchre vett mean definícióját nin_i-vel visszaszorozva a batch összegét kapjuk:

1N∑i=1B∑(x,y)∈Diℓ(fθ(x),y)=1N∑i=1Bni⋅1ni∑(x,y)∈Diℓ(fθ(x),y)=1N∑i=1BniL‾i(θ). \frac{1}{N}\sum_{i=1}^{B}\sum_{(x,y)\in D_i}\ell(f_\theta(x),y) =\frac{1}{N}\sum_{i=1}^{B} n_i\cdot \frac{1}{n_i}\sum_{(x,y)\in D_i}\ell(f_\theta(x),y) =\frac{1}{N}\sum_{i=1}^{B} n_i\,\bar L_i(\theta).

Ez a bizonyítandó állítás. A partíció tulajdonság az első egyenlőségnél, a mean redukció definíciója a másodiknál lép be. Következmény: ha minden batch azonos nn méretű, akkor N=BnN=Bn, így L‾=1B∑iL‾i\bar L=\frac{1}{B}\sum_i\bar L_i, az egyszerű átlag csak ebben a speciális esetben helyes.

A levezetés rögzített θ\theta-ra vonatkozik. A tanítás közben a modell batch-ek között frissül, ezért a monitorozott epoch-loss a frissítési pálya veszteségeit átlagolja; a fenti súlyozási formula attól függetlenül érvényes, hogy a kiszámított batch-veszteségeket hogyan kombináljuk.

12.1.4 Kézi számpélda

Legyen N=5N=5, B=2B=2; n1=3n_1=3, n2=2n_2=2. A batch 1 egyedi veszteségei (1,2,3)(1,2,3), így L‾1=2\bar L_1=2; a batch 2 egyedi veszteségei (4,6)(4,6), így L‾2=5\bar L_2=5. A súlyozott epoch-loss:

L‾=3⋅2+2⋅55=165=3,2. \bar L=\frac{3\cdot 2+2\cdot 5}{5}=\frac{16}{5}=3{,}2.

Az unweighted batchátlag ezzel szemben (2+5)/2=3,5(2+5)/2=3{,}5, ami nem a teljes mintára vett átlag. A félrevezető érték onnan jön, hogy a kisebb batch ugyanakkora súlyt kap, mint a nagyobb.

12.1.5 Konkrét labor: train és evaluate a GlyphLab adaton

A labor egy n045_train_eval.py scriptet készít a lab/ munkakönyvtárban. Bemenet a GlyphLab data/images.npy, data/labels.npy és data/metadata.csv; a splitet a metadata.csv split oszlopa adja. A train 240, a val 60 képből áll; a kép batch alakja N×1×24×24N\times 1\times 24\times 24, float32, a címke int64. A batch méret 64, így a train loader négy batchre oszlik: 64,64,64,4864,64,64,48; az utolsó kisebb batch a súlyozás kontrollja. A modell egy lapított MLP, a veszteség CrossEntropyLoss mean redukcióval, logits bemenettel, softmax nélkül ([D07] loss-szerződés). A közölt kimenet számított szemléltetés, nem a laborban mért eredmény.

"""n045_train_eval.py -- train/eval üzemmód és súlyozott epoch-loss.
Munkakönyvtár: lab/
Futtatás: python n045_train_eval.py
A kód futási kimenete helyben ellenőrizendő; a közölt számok szemléltetők.
"""
import numpy as np
import pandas as pd
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset

DEVICE = "cpu"
SEED = 20261020
BATCH_SIZE = 64

torch.manual_seed(SEED)

images = np.load("data/images.npy")      # (360,1,24,24) float32
labels = np.load("data/labels.npy")      # (360,) int64
meta = pd.read_csv("data/metadata.csv")

train_idx = meta[meta["split"] == "train"].index.to_numpy()
val_idx = meta[meta["split"] == "val"].index.to_numpy()

X_train = torch.from_numpy(images[train_idx])
y_train = torch.from_numpy(labels[train_idx])
X_val = torch.from_numpy(images[val_idx])
y_val = torch.from_numpy(labels[val_idx])

train_loader = DataLoader(TensorDataset(X_train, y_train),
                          batch_size=BATCH_SIZE, shuffle=True)
val_loader = DataLoader(TensorDataset(X_val, y_val),
                        batch_size=BATCH_SIZE, shuffle=False)

model = nn.Sequential(
    nn.Flatten(),
    nn.Linear(24 * 24, 64),
    nn.ReLU(),
    nn.Linear(64, 3),
)
loss_fn = nn.CrossEntropyLoss()
optimizer = torch.optim.SGD(model.parameters(), lr=0.05)

def train_one_epoch(model, loader, loss_fn, optimizer, device=DEVICE):
    model.train()
    total = 0.0
    n = 0
    for Xb, yb in loader:
        Xb = Xb.to(device)
        yb = yb.to(device)
        optimizer.zero_grad()
        loss = loss_fn(model(Xb), yb)
        loss.backward()
        optimizer.step()
        total += loss.item() * yb.size(0)
        n += yb.size(0)
    return total / n

def evaluate(model, loader, loss_fn, device=DEVICE):
    model.eval()
    total = 0.0
    n = 0
    correct = 0
    with torch.inference_mode():
        for Xb, yb in loader:
            Xb = Xb.to(device)
            yb = yb.to(device)
            logits = model(Xb)
            total += loss_fn(logits, yb).item() * yb.size(0)
            n += yb.size(0)
            correct += (logits.argmax(dim=1) == yb).sum().item()
    return total / n, correct / n

batch_sizes = []
for _, yb in train_loader:
    batch_sizes.append(yb.size(0))
print("train batch méretek:", batch_sizes)   # [64, 64, 64, 48]
assert batch_sizes == [64, 64, 64, 48]
assert sum(batch_sizes) == 240

for epoch in range(5):
    train_loss = train_one_epoch(model, train_loader, loss_fn, optimizer)
    val_loss, val_acc = evaluate(model, val_loader, loss_fn)
    print(f"epoch {epoch+1}: train_loss={train_loss:.4f} "
          f"val_loss={val_loss:.4f} val_acc={val_acc:.3f}")

A függvények magja a súlyozás: total += loss.item() * yb.size(0) és n += yb.size(0), majd total / n. A kontroll assert ellenőrzi, hogy az utolsó 48 mintás batch bekerült a számításba; ha a DataLoadert drop_last=True-szal hívnánk, a 48 minta elveszne, és az epoch-loss nem a 240 mintára vonatkozna. A validációs forward model.eval() alatt és torch.inference_mode() blokkban fut, így sem paramétergradiens, sem autograd-gráf nem keletkezik; a BatchNorm running statisztikája sem frissül (a jelen MLP-ben nincs ilyen réteg, de a protokoll a későbbi CNN-re is érvényes kell legyen).

Az értelmezéshez: a train loss csökkenése önmagában nem általánosítási bizonyíték; a val loss a 8.1. Kockázat, mintavétel és baseline empirikus kockázat becslése a val spliten. A 60 val minta miatt a görbe zajos, és egyetlen futás nem helyettesíti a seed-ismétlést. A [D02] dokumentáció szerint az azonos seed nem garantál platformok és verziók közötti bitazonos kimenetet, ezért a seed a protokoll része, nem a reprodukció egyetlen feltétele.

12.1.6 Feladatok

F1 (fogalmi, nehézség 1). Magyarázza el, miért kell az evaluate függvényben egyszerre model.eval() hívás és torch.inference_mode() blokk. Mi történne egy dropout réteget tartalmazó modell kiértékelésekor, ha a model.eval() hiányzik? Fogalmazza meg a két mechanizmus különbségét egy-egy mondatban.

F2 (levezetési, nehézség 2). Legyen a tanítóhalmaz rögzített θ\theta mellett BB darab nin_i méretű batch partíciója, a veszteség mean redukció. Vezesse le a

L‾=1N∑i=1BniL‾i,N=∑i=1Bni \bar L=\frac{1}{N}\sum_{i=1}^{B} n_i \bar L_i,\qquad N=\sum_{i=1}^{B} n_i

formulát. Jelölje meg, melyik lépés használja a partíció tulajdonságot, és ellenőrizze a B=1B=1 szélsőesetet.

F3 (implementációs, nehézség 2). Bontsa ki a labor súlyozó logikáját önálló függvénnyé:

def weighted_epoch_loss(batch_losses, batch_sizes):
    ...

A függvény két listából scalár epoch-loss-t ad. Input: batch_losses = [2.0, 5.0], batch_sizes = [3, 2]. Elvárt kimenet: 3.2, tolerancia 1e-6. Adja meg a teljes importot és a függvényt.

F4 (hibakeresési, nehézség 1). Egy társ ezt írta: total += loss.item(), majd a végén final = total / len(loader). Lokalizálja a hibát, számítsa ki a kézi példán a hibás eredményt, és adja meg a javítást.

F5 (kísérleti, nehézség 3). Tervezzen GlyphLab kísérletet: az első öt epochban a train loss csökken-e, míg a val loss növekedésnek indul. Adja meg a kérdést, az adatot, a splitet, a seedet, a baseline-t, a metrikákat és a következtetés érvényességi korlátait.

12.1.7 Megoldások és indoklás

F1 megoldás. Az eval() a modell állapotflagjét hamisra állítja: a dropout eval módban identitás, ezért a kiértékelés determinisztikus és a tanítási véletlen nullázás nem rontja a predikciót. Ha elmarad, a dropout minden forwardnál más-más neuronokat nulláz, így a val loss és pontosság zajossá válik, és a predikció nem egyezik a későbbi éles használattal. Az inference_mode() ezzel szemben az autograd gráf építését tiltja: az evaluate forwardja alatt nem keletkezik gráf, így nem fogy memória, és véletlen backward hívás sem szennyezhetné a paramétergradienst. A kettő ortogonális: az egyik a modul viselkedését, a másik a tenzor-könyvelést szabályozza.

F2 megoldás. A kiinduló definíció L‾i=1ni∑(x,y)∈Diℓ(fθ(x),y)\bar L_i=\frac{1}{n_i}\sum_{(x,y)\in D_i}\ell(f_\theta(x),y). Az epoch mean loss:

L‾=1N∑(x,y)∈Dℓ(fθ(x),y). \bar L=\frac{1}{N}\sum_{(x,y)\in D}\ell(f_\theta(x),y).

A partíció tulajdonság D=⨆iDiD=\bigsqcup_i D_i engedi a felbontást:

L‾=1N∑i=1B∑(x,y)∈Diℓ(fθ(x),y). \bar L=\frac{1}{N}\sum_{i=1}^{B}\sum_{(x,y)\in D_i}\ell(f_\theta(x),y).

Ez az a lépés, amely a partíciót használja. A belső összeg a batch mean definícióját visszaszorozva niL‾in_i\bar L_i, így

L‾=1N∑i=1BniL‾i. \bar L=\frac{1}{N}\sum_{i=1}^{B} n_i\bar L_i.

Szélsőeset: B=1B=1, ekkor n1=Nn_1=N, tehát L‾=1N⋅N⋅L‾1=L‾1\bar L=\frac{1}{N}\cdot N\cdot \bar L_1=\bar L_1, a formula visszaadja a teljes adatra vett mean loss-t. A levezetés feltétele, hogy a batch-ek páronként diszjunktak és lefedik a teljes epochot; a mean redukció definíciója rögzített θ\theta-ra vonatkozik.

F3 megoldás.

def weighted_epoch_loss(batch_losses, batch_sizes):
    """Súlyozott epoch-loss a batch-veszteségek és batch-méretek listájából."""
    total = sum(l * n for l, n in zip(batch_losses, batch_sizes))
    return total / sum(batch_sizes)

result = weighted_epoch_loss([2.0, 5.0], [3, 2])
assert abs(result - 3.2) < 1e-6, result
print(f"súlyozott epoch-loss: {result:.6f}")  # 3.200000

Alakellenőrzés: minden lil_i scalár, nin_i pozitív egész; a nevező ∑ni=N\sum n_i=N; a kimenet scalár float. A labor train_one_epoch függvénye pontosan ezt a számítást végzi beágyazva, ahol a loss.item() a batch mean loss scalárja.

F4 megoldás. A hiba a nevezőben van: total / len(loader) a batch-veszteségek egyszerű átlaga, nem a minták szerinti súlyozott átlag. A kézi példán L‾1=2\bar L_1=2, L‾2=5\bar L_2=5, így a hibás érték (2+5)/2=3,5(2+5)/2=3{,}5, szemben a helyes 3⋅2+2⋅55=3,2\frac{3\cdot 2+2\cdot 5}{5}=3{,}2 értékkel. A következmény: az epoch-loss torzított monitoring metrika, és a különböző méretű utolsó batch indokolatlanul nagy súlyt kap. Javítás: total += loss.item() * yb.size(0); n += yb.size(0); return total / n. Visszaellenőrzés a labor assertével: a batch méretek [64,64,64,48][64,64,64,48], a visszaadott érték a súlyozott képletből számolódik.

F5 megoldásminta. Kérdés: a train loss öt epoch alatt monoton csökken-e, és a val loss a megfigyelési ablakban növekedni kezd-e? Adat: GlyphLab data/images.npy, data/labels.npy, split a data/metadata.csv split oszlopából, a scene_id csoportosítás megtartásával. Modell: a labor három osztályos MLP-je, SGD, lr =0,05=0{,}05, batch 64, seed 20261020, device CPU, float32. Baseline: a leggyakoribb osztályt prediktáló konstans modell; a val pontossága itt legfeljebb az osztályarány legnagyobbika, GlyphLab val spliten osztályonként 10 kép, tehát 1/31/3 körüli felső korlát. Metrikák: súlyozott train és val cross-entropy, val pontosság. Protokoll: a val loss-t nem használjuk modellválasztásra; csak a tanítás után elemezzük a görbét. Korlát: a 60 val minta miatt a görbe nagy varianciájú; egyetlen futás nem ad konfidencia-intervallumot, több seed kellene az [S02] ismételt mintavételi értelmezéséhez; a train loss a frissítés közbeni vándorló paraméterek vesztesége, nem állandó θ\theta-ra vett epoch-loss.

12.1.8 Gyakori tévedés és ellenpélda

Az unweighted batchátlag gyakori hibája: total += loss.item(); return total / len(loader). Az öt mintás, 3+23+2 felbontású példán a helyes L‾=3,2\bar L=3{,}2, a hibás 3,53{,}5; a 0,30{,}3 eltérés nem kerekítési hiba, hanem strukturális súlyozási hiba. Ha minden batch mérete egyenlő, a két képlet egybeesik, ezért a hiba kis egyenletes batch-ek mellett rejtve maradhat, és az utolsó kisebb batch esetén jelentkezik.

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

Ellenőrzés: a levezetésben i=1,…,Bi=1,\ldots,B és j=1,…,nij=1,\ldots,n_i indexhatárok teljesek, ∑ini=N\sum_i n_i=N a partícióból következik, minden tag skalár. A kézi példa szélsőesete, B=1B=1, a formulát L‾=L‾1\bar L=\bar L_1-re redukálja. A 11.1. Tensor és autograd-életciklus visszahívása: az inference_mode erősebben zárja a gráfépítést, mint a no_grad, ezért a kiértékeléshez ezt használjuk. A 8.1. Kockázat, mintavétel és baseline visszahívása: a val loss empirikus kockázat a val spliten, nem populációs kockázat; a kis val minta becslési varianciája nem tűnik el a seed rögzítésétől.

12.1.10 Rövid összefoglaló

Az epoch tanítási vesztesége nem a batch-veszteségek egyszerű átlaga: a különböző nin_i méretű batchek mean loss-aiból csak a súlyozott aggregátum L‾=1N∑iniL‾i\bar L=\frac{1}{N}\sum_i n_i\bar L_i adja a teljes mintára vett mean loss-t. A levezetés a partíció tulajdonságon és a mean redukció definícióján áll, rögzített paraméter mellett. A train mód a modell állapotát, az inference mode a gráfépítést szabályozza; az evaluate ezért egyszerre hív model.eval()-t és inference_mode blokkot. A validáció közben sem paraméter, sem running statisztika nem frissül, így a val loss a rögzített modell empirikus becslése a val spliten. A labor a súlyozást az utolsó kisebb batch esetén asserttel is ellenőrzi.

12.1.11 Következő alkalmazás

A 12.2. Checkpoint és folytathatóság a checkpoint írását és folytathatóságát tárgyalja; ott az itt bevezetett eval mód a mentés előtti állapotkonzisztencia feltétele lesz, a súlyozott epoch-loss pedig a tanítási napló standard metrikája.

12.1.12 Felhasznált források