38.2 Modell, training és best checkpoint

38.2.1 1. Cél, előismeret és induló kérdés

A 38.1. Adat, split és előzetes protokoll egység befagyasztotta a GlyphLab protokollját: a megfigyelési egység a scene_id, a train/val/test felosztás scene-szintű, a tesztet és a corrupted_test.npy változatot csak a végső, választott modellen szabad kiértékelni. Ebben az egységben a tanítási szakaszt építjük fel. A cél egy kis CPU-s CNN tanítása, minden tréning- és validációs adat útjának naplózása, valamint egy valódi best snapshot mentése. A modellválasztás a validációs loss alapján történik egy előre rögzített, véges jelöltkészletből: a tanítás során keletkező epoch-snapshotok közül.

Előismeret. A 37.2. Backward és ellenőrzési lefedettség egységben a konvolúciós forward és backward levezetése, valamint véges differenciás ellenőrzése megtörtént; a jelen fejezetben az autograd által kiszámolt gradienseket már ellenőrzött operátorként kezeljük. A 34.2. Kvantálás és hibatovábbterjedés egység rögzítette, hogy a főút float32 CPU-számítás; itt nem kvantálunk. A 26.2. Egy- és kétlépcsős detektor detektorfejezete emlékeztetett arra, hogy egy osztályozó fej és egy lokalizáló/detektor fej felelőssége különbözik: a mostani modell kimenete csak három osztálylogit, nem doboz vagy maszk.

Induló kérdés. Egy 24×2424\times24-es, egycsatornás képet az alábbi lánc dolgoz fel: 3×33\times3-as konvolúció 8 csatornára, ReLU, 2×22\times2-es max-pooling, 3×33\times3-as konvolúció 16 csatornára, ReLU, 2×22\times2-es max-pooling, majd egy 32 egységes rejtett réteg és egy 3 logitot adó kimeneti réteg. Hány tanulható skalár paramétert tartalmaz a háló? Mielőtt tovább olvas, adjon becslést; a pontos levezetés a 3. szakaszban következik.

38.2.2 2. A modell, a baseline és a választási fogalmak

38.2.2.1 A small CNN definíciója

A laborban használt modell a GlyphCNN:

A bemenet alakja X∈ℝN×1×24×24X\in\mathbb R^{N\times1\times24\times24}, XX elemei a [0,1][0,1] intenzitástartományban vannak, dtype float32, NCHW elrendezésben. A kimenet Z∈ℝN×3Z\in\mathbb R^{N\times3} aktiváció előtti logit. A célvektor y∈{0,1,2}Ny\in\{0,1,2\}^N, dtype int64, ahol 00 vertical, 11 horizontal, 22 diagonal.

A CE-szerződés. A modell logitot ad; a cross_entropy hívás előtt nem alkalmazunk külön softmaxot. A veszteség az NN megfigyelés átlaga:

L(Z,y)=−1N∑n=1Nlog(exp(Zn,yn)∑j=0K−1exp(Zn,j)), L(Z,y)=-\frac1N\sum_{n=1}^N\log\left(\frac{\exp(Z_{n,y_n})}{\sum_{j=0}^{K-1}\exp(Z_{n,j})}\right),

ahol K=3K=3. A sum-redukciós változatot külön jelölnénk; a főút az átlag.

38.2.2.2 Baseline

A 38.1. Adat, split és előzetes protokoll alfejezetben rögzített baseline a lineáris osztályozó: a 24⋅24=57624\cdot24=576 pixelt egyetlen vektorba lapítja, és Z=Wx̃+b∈ℝ3Z=W\tilde x+b\in\mathbb R^3 logitot ad (576⋅3+3=1731576\cdot3+3=1731 paraméter). Ugyanazzal a recepttel, ugyanazon a három seeden és ugyanazzal a választási szabállyal tanítjuk, mint a CNN-t; az egyetlen szándékosan változtatott tényező az architektúra. Emellett külön minimum-referenciaként megadjuk a képfüggetlen prior-osztályozót: minden képre a train-osztálygyakoriságból becsült eloszlást adja. A GlyphLab train halmaza osztályonként 8080 képet tartalmaz, tehát p̂=(1/3,1/3,1/3)\hat p=(1/3,1/3,1/3), a várható pontosság 1/31/3, és a várható cross-entropy

−13(log13+log13+log13)=log3≈1,0986. -\frac13\left(\log\frac13+\log\frac13+\log\frac13\right)=\log 3\approx1{,}0986.

Egy tanított modell akkor hordoz tanult jelet, ha a validációs lossa ettől számottevően kisebb; a lineáris baseline-nak és a CNN-nek is ehhez kell viszonyulnia, ugyanazon a befagyasztott validációs halmazon.

38.2.2.3 Inicializálás

A tanulható súlyokat explicit Kaiming-uniform inicializálással állítjuk be ReLU-hoz, a biasokat nullára. A motivációt a 21.1. Inicializálás és varianciaterjedés varianciaterjedési érvelése adja: a ReLU előtti affin kimenet varianciáját rétegről rétegre meg kell őrizni. Az utolsó lineáris rétegre ugyanezt a kis skálájú inicializálást használjuk, mert a három logit kezdeti nagyságrendje így stabil a softmax számára. Ez heurisztika, nem bizonyított optimum; a labor az explicit receptet naplózza, hogy a kísérlet reprodukálható legyen.

38.2.2.4 Ütemezés

Az η0\eta_0 kezdő tanulási rátát koszinuszos ütemezéssel csökkentjük TT epoch alatt:

ηt=ηmin+η0−ηmin2(1+cosπtT),t=0,1,…,T−1. \eta_t=\eta_{\min}+\frac{\eta_0-\eta_{\min}}{2}\left(1+\cos\frac{\pi t}{T}\right),\qquad t=0,1,\ldots,T-1.

A laborban η0=10−3\eta_0=10^{-3}, ηmin=10−5\eta_{\min}=10^{-5}. Az optimizáló AdamW, 10−410^{-4} decoupled weight decay-jel; a 6.4. Momentum és adaptív optimizálók egység rögzítette, hogy az AdamW-féle súlycsökkentés nem azonos a lossba írt L2-tag Adam alatti hatásával. Az ütemezést az epoch ciklusban kézzel írjuk az optimizáló paramétercsoportjaiba, így a labor nem függ egy adott scheduler API belső T_cur-definíciójától.

38.2.2.5 Snapshot és early choice

Definíció (snapshot). A tt. epoch befejezése utáni modellállapot sts_t: a state_dict, vagyis a tanulható tensorok rendezett név–érték leképezése. A snapshot nem tartalmazza az optimizáló állapotát; a jelen kísérletben a kiválasztás után nincs folytatás.

Definíció (validation-alapú snapshot-választás, best-snapshot selection). Legyen 𝒮={s1,…,sT}\mathcal S=\{s_1,\ldots,s_T\} az epochonként mentett, véges jelöltkészlet. Jelölje L̂val(st)\widehat L_{\mathrm{val}}(s_t) a tt. snapshoton a teljes validációs halmazra számolt átlagos cross-entropyt. A választott snapshot indexe

t̂=argmint∈{1,…,T}L̂val(st). \hat t=\arg\min_{t\in\{1,\ldots,T\}}\widehat L_{\mathrm{val}}(s_t).

Az early choice elnevezés arra utal, hogy t̂\hat t nem feltétlenül az utolsó epoch: a legjobb validációs állapot gyakran korábban következik be. A tesztet egyetlenegyszer, a befagyasztott t̂\hat t snapshoton értékeljük.

A L̂val(st)\widehat L_{\mathrm{val}}(s_t) egy véges mintán számolt becslés, ezért a választott snapshot validációs értéke optimista lehet a valódi validációs kockázatra nézve. Ezt a jelenséget a jelen egységben nem bizonyítjuk koncentrációs tétellel; a végső teszt intervallumos értelmezéséhez a 38.4. Mentett modellből független inference egység a [S02] feltételes, ismételt mintavételi keretét használja.

38.2.3 3. Paraméterszám, tenzoralak és a CE-szerződés ellenőrzése

38.2.3.1 Paraméterszám

1. Lemma (rétegparaméterszám). Legyen egy konvolúciós réteg súlytenzora K∈ℝCout×(Cin/g)×kh×kwK\in\mathbb R^{C_{\mathrm{out}}\times(C_{\mathrm{in}}/g)\times k_h\times k_w} és biasvektora b∈ℝCoutb\in\mathbb R^{C_{\mathrm{out}}}. A tanulható paraméterek száma

Pconv=Cout(Cingkhkw+1). P_{\mathrm{conv}}=C_{\mathrm{out}}\left(\frac{C_{\mathrm{in}}}{g}k_hk_w+1\right).

Affin rétegnél W∈ℝm×dW\in\mathbb R^{m\times d}, b∈ℝmb\in\mathbb R^m, tehát Paff=md+mP_{\mathrm{aff}}=md+m.

Bizonyítás. A súlytenzor elemszáma a dimenziók szorzata: Cout⋅(Cin/g)⋅kh⋅kwC_{\mathrm{out}}\cdot(C_{\mathrm{in}}/g)\cdot k_h\cdot k_w. A bias minden kimeneti csatornához egy-egy skalárt ad, ami CoutC_{\mathrm{out}} további elem. Az affin esetben WW elemszáma mdmd, a bias mm. Az állítás a tenzoralak definíciójából következik; nincs szükség közelítésre. ▫\square

Alkalmazás a GlyphCNN-re.

2. Lemma (alakzártság a rétegláncon). A fenti GlyphCNN 24×2424\times24-es bemenetre az 1. táblázat alakjait állítja elő.

Művelet Kimeneti alak Paraméterszám
Conv1, k=3k=3, p=1p=1 (N,8,24,24)(N,8,24,24) 80
MaxPool2d(2) (N,8,12,12)(N,8,12,12) 0
Conv2, k=3k=3, p=1p=1 (N,16,12,12)(N,16,12,12) 1168
MaxPool2d(2) (N,16,6,6)(N,16,6,6) 0
Flatten (N,576)(N,576) 0
Linear1 (N,32)(N,32) 18464
Linear2 (N,3)(N,3) 99

Bizonyítás. Használjuk a 14.1. Stride, padding, dilation és outputméret szimmetrikus padding képletét d=1d=1, s=1s=1 mellett, egy térbeli dimenzióra:

Hout=⌊H+2p−k1⌋+1. H_{\mathrm{out}}=\left\lfloor\frac{H+2p-k}{1}\right\rfloor+1.

(Itt HH a térbeli magasság; a szélességi WW jelölést a súlymátrixszal való ütközés miatt külön mondjuk ki: a betű ebben a bekezdésben térbeli méret, a súlyokat KK jelöli.) Conv1: (24+2−3)+1=24(24+2-3)+1=24. A MaxPool s=k=2s=k=2: ⌊(24−2)/2⌋+1=12\lfloor(24-2)/2\rfloor+1=12. Conv2: (12+2−3)+1=12(12+2-3)+1=12. A második pool: ⌊(12−2)/2⌋+1=6\lfloor(12-2)/2\rfloor+1=6. A flatten méret 16⋅6⋅6=57616\cdot6\cdot6=576. A lineáris rétegek a megadott be- és kimeneti dimenziókat valósítják meg. A paraméterszám az 1. lemma alkalmazása. ▫\square

A kis modell számításigénye is ellenőrizhető: Conv1 forwardja 24⋅24⋅8⋅1⋅9=4147224\cdot24\cdot8\cdot1\cdot9=41\,472 MAC, Conv2-é 12⋅12⋅16⋅8⋅9=16588812\cdot12\cdot16\cdot8\cdot9=165\,888 MAC, a két lineáris réteg 18432+96=1852818\,432+96=18\,528 MAC. A domináns költség a második konvolúció, nem a teljesen kapcsolt rész.

38.2.3.2 A CE-szerződés és a gradiens

3. Lemma (mean-redukált CE-gradiens). Legyen Z∈ℝN×KZ\in\mathbb R^{N\times K} logitmátrix és y∈{0,…,K−1}Ny\in\{0,\ldots,K-1\}^N. Definiáljuk

pn,k=exp(Zn,k)∑j=0K−1exp(Zn,j),L(Z,y)=−1N∑n=1Nlogpn,yn. p_{n,k}=\frac{\exp(Z_{n,k})}{\sum_{j=0}^{K-1}\exp(Z_{n,j})}, \qquad L(Z,y)=-\frac1N\sum_{n=1}^N\log p_{n,y_n}.

Ekkor minden n,kn,k párra

∂L∂Zn,k=1N(pn,k−𝟏k=yn). \frac{\partial L}{\partial Z_{n,k}}=\frac1N\left(p_{n,k}-\mathbf 1_{k=y_n}\right).

A PyTorch F.cross_entropy(Z, y) a fenti LL értéket számolja, stabil log-sum-exp implementációval; a modell kimenetének tehát logitnak kell lennie, és a cél int64 osztályindex.

Bizonyítás. Rögzítsünk egy nn sort, és röviden írjunk ZkZ_k-t Zn,kZ_{n,k} helyett, pkp_k-t pn,kp_{n,k} helyett. Legyen S=∑jexp(Zj)S=\sum_j\exp(Z_j). Ekkor logpy=Zy−logS\log p_y=Z_y-\log S, tehát

∂∂Zklogpy=𝟏k=y−1S∂S∂Zk=𝟏k=y−exp(Zk)S=𝟏k=y−pk. \frac{\partial}{\partial Z_k}\log p_y =\mathbf 1_{k=y}-\frac{1}{S}\frac{\partial S}{\partial Z_k} =\mathbf 1_{k=y}-\frac{\exp(Z_k)}{S} =\mathbf 1_{k=y}-p_k.

A negatív előjel és az 1N\frac1N átlagolás bevitelével

∂L∂Zn,k=−1N(𝟏k=yn−pn,k)=1N(pn,k−𝟏k=yn). \frac{\partial L}{\partial Z_{n,k}} =-\frac1N\left(\mathbf 1_{k=y_n}-p_{n,k}\right) =\frac1N\left(p_{n,k}-\mathbf 1_{k=y_n}\right).

Az eredmény alakja N×KN\times K, megegyezik ZZ alakjával, ahogy a skalár loss tenzorgradiensétől elvárjuk. ▫\square

A lemma egyben megmagyarázza, miért szokták a hibajelet „softmax mínusz one-hot” alakban emlegetni: a batch-átlag osztója NN, nem a KK osztályszám.

38.2.4 4. Eredeti kézi kis példa

CE-számolás. Legyen N=1N=1, K=3K=3, Z=(2,1,0,5)Z=(2,\;1,\;0{,}5) és y=0y=0. Az exponenciális értékek exp(2)≈7.3891\exp(2)\approx7.3891, exp(1)≈2.7183\exp(1)\approx2.7183, exp(0,5)≈1.6487\exp(0{,}5)\approx1.6487, az összeg S≈11.7561S\approx11.7561. A softmax:

p≈(7.389111.7561,2.718311.7561,1.648711.7561)≈(0.6285,0.2312,0.1402). p\approx\left(\frac{7.3891}{11.7561},\frac{2.7183}{11.7561},\frac{1.6487}{11.7561}\right) \approx(0.6285,\;0.2312,\;0.1402).

A loss L=−log(0.6285)≈0.4644L=-\log(0.6285)\approx0.4644. A 3. lemma szerint a gradiens

∇ZL=p−(1,0,0)≈(−0.3715,0.2312,0.1402). \nabla_ZL=p-(1,0,0)\approx(-0.3715,\;0.2312,\;0.1402).

Ellenőrzés a szerződésen. A loss skalár, a gradiens alakja (1,3)(1,3), és a gradiens elemeinek összege −0.3715+0.2312+0.1402=0-0.3715+0.2312+0.1402=0, ami a softmax-kimenet és a one-hot közötti különbség elvárt nulla összegű tulajdonsága. Ha valaki a modell végére tett volna softmaxot, akkor a F.cross_entropy még egy log-softmaxot alkalmazna, és a fenti gradiens helyett másodrendű tagok jelennének meg: ez a leggyakoribb CE-szerződéssértés.

Ugyanez PyTorchban numerikusan ellenőrizhető; a cella a lab-profilban (PyTorch 2.14.1+cpu) futtatva L = 0.4644-et és [-0.3715, 0.2312, 0.1402] gradienst adott, négy tizedesre kerekítve:

import torch
from torch.nn import functional as F
Z = torch.tensor([[2.0, 1.0, 0.5]], dtype=torch.float32, requires_grad=True)
y = torch.tensor([0], dtype=torch.int64)
L = F.cross_entropy(Z, y)
L.backward()
# várt: L ~ 0.4644, Z.grad ~ [[-0.3715, 0.2312, 0.1402]]

38.2.5 5. Labor: training, napló és best snapshot

Munkakönyvtár: a laborcsomag lab/ könyvtára, benne a data/ a GlyphLab fájljaival. A labor három fájlból áll. A lab/glyph_models.py a projekt egyetlen modellforrása: ezt importálja a tanítás (38.2. Modell, training és best checkpoint), a végső teszt (38.3. Végső teszt és páros korrupció) és a csomagolás (38.4. Mentett modellből független inference), így a modellosztály neve és rétegszerkezete nem térhet el a lépések között. A lab/train_small_cnn.py egy (architektúra, seed) futást végez; csak a train és val splitet tölti be, a teszt és a korrupt teszt nem szerepel benne. A lab/run_project2.py a 38.1. Adat, split és előzetes protokoll protokollját hajtja végre egyetlen paranccsal.

# lab/glyph_models.py — a Projekt II egyetlen modellforrása (n150, n151, n152)
import torch
from torch import nn

# Egyszálas CPU-számítás: a lebegőpontos összegzési sorrend így nem függ a gép
# magszámától, és azonos könyvtárverziók mellett a futás bitazonosan ismételhető.
torch.set_num_threads(1)

# A n149 protokollban előre rögzített paraméterszámok és a közös keret.
ARCH_PARAMS = {"cnn": 19811, "linear": 1731}
PARAM_BUDGET = 20000


def _kaiming(module):
    for m in module.modules():
        if isinstance(m, (nn.Conv2d, nn.Linear)):
            nn.init.kaiming_uniform_(m.weight, a=0.0)
            if m.bias is not None:
                nn.init.zeros_(m.bias)


class GlyphCNN(nn.Module):
    """Kis CNN: (N,1,24,24) float32 -> (N,3) logit."""
    def __init__(self):
        super().__init__()
        self.features = nn.Sequential(
            nn.Conv2d(1, 8, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
            nn.Conv2d(8, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
        )
        self.classifier = nn.Sequential(
            nn.Flatten(), nn.Linear(16 * 6 * 6, 32), nn.ReLU(), nn.Linear(32, 3)
        )
        _kaiming(self)

    def forward(self, x):
        return self.classifier(self.features(x))


class LinearBaseline(nn.Module):
    """Lineáris baseline: flatten 576 -> 3 logit, ugyanazzal a CE-szerződéssel."""
    def __init__(self):
        super().__init__()
        self.classifier = nn.Sequential(nn.Flatten(), nn.Linear(24 * 24, 3))
        _kaiming(self)

    def forward(self, x):
        return self.classifier(x)


def build(arch):
    return {"cnn": GlyphCNN, "linear": LinearBaseline}[arch]()


def count_parameters(model):
    return sum(p.numel() for p in model.parameters() if p.requires_grad)


def load_checkpoint(path, map_location="cpu"):
    """A n150 checkpointsémája: model_state_dict, arch, seed, epoch, val_loss, params."""
    ckpt = torch.load(path, map_location=map_location, weights_only=True)
    model = build(ckpt["arch"])
    model.load_state_dict(ckpt["model_state_dict"])     # strict: minden kulcs egyezik
    model.eval()
    return model, ckpt

A checkpoint sémája. Minden futás runs/<arch>_s<seed>/best.pt fájlja egy szótár: model_state_dict (a súlyok), arch (cnn vagy linear), seed, epoch, val_loss, params és recipe. A betöltés mindig a load_checkpoint függvényen át, weights_only=True módon történik, és a load_state_dict alapértelmezett szigorú kulcsegyezését használja; ugyanezt a sémát olvassa a 38.3. Végső teszt és páros korrupció is, kézi átnevezés nélkül.

# lab/train_small_cnn.py — n150: egy (architektúra, seed) futás, napló, best checkpoint.
import argparse, csv, json, math, os, time
import numpy as np
import torch
from torch.nn import functional as F
from torch.utils.data import DataLoader, TensorDataset

from glyph_models import build, count_parameters, load_checkpoint


def cosine_lr(t, T, lr0, eta_min):
    return eta_min + 0.5 * (lr0 - eta_min) * (1.0 + math.cos(math.pi * t / T))


def split_ids(root, split):
    with open(os.path.join(root, "data", "metadata.csv"), newline="") as f:
        return np.array([int(r["index"]) for r in csv.DictReader(f) if r["split"] == split],
                        dtype=np.int64)


def load_split(root, split):
    """Csak train/val: a teszt és a korrupt teszt ebben a fájlban nem nyílik meg."""
    assert split in ("train", "val")
    ids = split_ids(root, split)
    X = np.load(os.path.join(root, "data", "images.npy")).astype(np.float32)[ids]
    Y = np.load(os.path.join(root, "data", "labels.npy")).astype(np.int64)[ids]
    return TensorDataset(torch.from_numpy(X), torch.from_numpy(Y))


@torch.no_grad()
def evaluate(model, loader):
    model.eval()
    total, correct, n, logits_all = 0.0, 0, 0, []
    for xb, yb in loader:
        logits = model(xb)
        total += F.cross_entropy(logits, yb).item() * xb.shape[0]
        correct += (logits.argmax(dim=1) == yb).sum().item()
        n += xb.shape[0]
        logits_all.append(logits)
    return total / n, correct / n, torch.cat(logits_all)


def train(arch="cnn", seed=20261020, epochs=24, lr=1e-3, wdecay=1e-4,
          batch_size=32, root="."):
    torch.manual_seed(seed)
    np.random.seed(seed)
    tr_loader = DataLoader(load_split(root, "train"), batch_size=batch_size, shuffle=True,
                           generator=torch.Generator().manual_seed(seed))
    va_loader = DataLoader(load_split(root, "val"), batch_size=batch_size, shuffle=False)

    model = build(arch)                    # az init a seedtől függ
    opt = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=wdecay)
    run_dir = os.path.join(root, "runs", f"{arch}_s{seed}")
    os.makedirs(run_dir, exist_ok=True)
    best = {"val_loss": float("inf")}
    with open(os.path.join(run_dir, "metrics.jsonl"), "w", encoding="utf-8") as log:
        for epoch in range(epochs):
            t0 = time.perf_counter()
            lr_t = cosine_lr(epoch, epochs, lr, 1e-5)      # az epoch elején érvényes ráta
            for pg in opt.param_groups:
                pg["lr"] = lr_t
            model.train()
            tr_total, tr_n = 0.0, 0
            for xb, yb in tr_loader:
                opt.zero_grad(set_to_none=True)
                loss = F.cross_entropy(model(xb), yb)
                loss.backward()
                opt.step()
                tr_total += loss.item() * xb.shape[0]
                tr_n += xb.shape[0]
            va_loss, va_acc, va_logits = evaluate(model, va_loader)
            is_best = va_loss < best["val_loss"]
            if is_best:
                best = {"epoch": epoch + 1, "val_loss": va_loss, "val_acc": va_acc,
                        "logits": va_logits.clone()}
                torch.save({"model_state_dict": model.state_dict(), "arch": arch,
                            "seed": seed, "epoch": epoch + 1, "val_loss": va_loss,
                            "params": count_parameters(model), "recipe": "cosine_adamw"},
                           os.path.join(run_dir, "best.pt"))
            log.write(json.dumps({"epoch": epoch + 1, "train_loss": tr_total / tr_n,
                                  "val_loss": va_loss, "val_acc": va_acc, "lr": lr_t,
                                  "best": is_best,
                                  "seconds": time.perf_counter() - t0}) + "\n")

    # Visszatöltési kontroll: a mentett best.pt ugyanazokat a val-logitokat adja.
    reloaded, ckpt = load_checkpoint(os.path.join(run_dir, "best.pt"))
    re_loss, _, re_logits = evaluate(reloaded, va_loader)
    max_diff = float((re_logits - best["logits"]).abs().max())
    assert max_diff <= 1e-6 and abs(re_loss - best["val_loss"]) <= 1e-6, (max_diff, re_loss)
    summary = {"arch": arch, "seed": seed, "best_epoch": best["epoch"],
               "best_val_loss": best["val_loss"], "best_val_acc": best["val_acc"],
               "params": count_parameters(model), "reload_max_abs_logit_diff": max_diff}
    with open(os.path.join(run_dir, "summary.json"), "w") as f:
        json.dump(summary, f, indent=2)
    return summary


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--arch", choices=["cnn", "linear"], default="cnn")
    ap.add_argument("--seed", type=int, default=20261020)
    ap.add_argument("--epochs", type=int, default=24)
    ap.add_argument("--lr", type=float, default=1e-3)
    ap.add_argument("--wdecay", type=float, default=1e-4)
    ap.add_argument("--batch-size", type=int, default=32)
    ap.add_argument("--root", default=".")
    a = ap.parse_args()
    print(json.dumps(train(a.arch, a.seed, a.epochs, a.lr, a.wdecay, a.batch_size, a.root)))

A koszinuszos rátát minden epoch elején állítjuk be, így az első epoch η0\eta_0-lal fut, és a naplóban az az érték szerepel, amellyel az adott epoch ténylegesen tanult. A futás végén a szkript visszatölti a mentett best.pt-t, és összeveti a validációs logitokat a mentés pillanatában számoltakkal; eltérés esetén AssertionError-ral megáll.

# lab/run_project2.py — n150: a teljes tanítási szakasz egy paranccsal.
# Két architektúra × három seed, paraméterszám-ellenőrzés, validációs összevetés,
# és a protokoll választási szabálya szerinti checkpointok a n151 számára.
import json, math, os, shutil, statistics
from glyph_models import ARCH_PARAMS, PARAM_BUDGET
from train_small_cnn import train

SEEDS = (20261020, 20261021, 20261022)      # a n149 protokoll seedlistája


def main(root="."):
    runs = {arch: [train(arch, s, root=root) for s in SEEDS] for arch in ("linear", "cnn")}
    report = {"seeds": list(SEEDS), "prior_val_loss": math.log(3), "prior_val_acc": 1 / 3,
              "archs": {}}
    os.makedirs(os.path.join(root, "checkpoints"), exist_ok=True)
    for arch, rs in runs.items():
        assert all(r["params"] == ARCH_PARAMS[arch] <= PARAM_BUDGET for r in rs), arch
        chosen = min(rs, key=lambda r: r["best_val_loss"])     # választás csak val alapján
        shutil.copyfile(os.path.join(root, "runs", f"{arch}_s{chosen['seed']}", "best.pt"),
                        os.path.join(root, "checkpoints", f"best_val_{arch}.pt"))
        accs = [r["best_val_acc"] for r in rs]
        losses = [r["best_val_loss"] for r in rs]
        report["archs"][arch] = {
            "params": rs[0]["params"], "runs": rs, "chosen_seed": chosen["seed"],
            "val_acc_mean": statistics.mean(accs), "val_acc_sd": statistics.stdev(accs),
            "val_loss_mean": statistics.mean(losses), "val_loss_sd": statistics.stdev(losses)}
    with open(os.path.join(root, "runs", "project2_val_report.json"), "w") as f:
        json.dump(report, f, indent=2)
    print("arch    params  seed      best_ep  val_loss  val_acc")
    for arch, rs in runs.items():
        for r in rs:
            print(f"{arch:7s} {r['params']:6d}  {r['seed']}  {r['best_epoch']:3d}"
                  f"      {r['best_val_loss']:.4f}    {r['best_val_acc']:.3f}")
    for arch, a in report["archs"].items():
        print(f"{arch}: val_acc {a['val_acc_mean']:.3f} ± {a['val_acc_sd']:.3f}, "
              f"választott seed {a['chosen_seed']} -> checkpoints/best_val_{arch}.pt")


if __name__ == "__main__":
    main()

Futtatás egy paranccsal:

cd lab
python run_project2.py

Egyetlen futás külön is indítható, például python train_small_cnn.py --arch cnn --seed 20261020. A kimenet: hat futáskönyvtár (runs/linear_s<seed>, runs/cnn_s<seed>, mindegyikben metrics.jsonl, best.pt, summary.json), a runs/project2_val_report.json összevetés és a checkpoints/best_val_cnn.pt, checkpoints/best_val_linear.pt választott checkpointok. Minden eltérő recept (más --lr, --batch-size, módosított modell) külön könyvtárba kerül; tilos két recept naplóját összemásolni.

Protokoll. A seed az inicializálásra és a train-shuffle generátorára vonatkozik. A [D02] dokumentáció szerint a seed önmagában nem ígér platformok és verziók közötti bitazonosságot; ezért a napló a recept neve mellett a seedet is rögzíti, és az eredményeket csak az adott környezet reprodukciójához kötjük. A glyph_models.py egyszálas CPU-számítást állít be (torch.set_num_threads(1)): többszálas futásnál a lebegőpontos összegzés sorrendje a gép magszámától függ, és az utolsó jegyek gépenként eltérhetnek.

Futási jegyzőkönyv. A python run_project2.py az eredeti GlyphLab-adaton, CPU-n (Python 3.12.3, NumPy 2.3.5, PyTorch 2.14.1+cpu) körülbelül 3 másodperc alatt lefutott, és két teljes, egymás utáni futás bitazonos eredményt adott:

arch    params  seed      best_ep  val_loss  val_acc
linear    1731  20261020    1      1.1155    0.267
linear    1731  20261021    1      1.1031    0.300
linear    1731  20261022    1      1.1192    0.350
cnn      19811  20261020   24      0.3029    0.900
cnn      19811  20261021   15      0.3683    0.900
cnn      19811  20261022   19      0.2326    0.917
linear: val_acc 0.306 ± 0.042, választott seed 20261021 -> checkpoints/best_val_linear.pt
cnn: val_acc 0.906 ± 0.010, választott seed 20261022 -> checkpoints/best_val_cnn.pt

A paraméterszám-ellenőrzés mindkét modellre teljesült (17311731 és 1981119\,811, mindkettő a 2000020\,000-es keret alatt), és mind a hat futásban a visszatöltött best.pt logitjai pontosan egyeztek a mentéskoriakkal (reload_max_abs_logit_diff =0=0). A lineáris baseline validációs lossa mindhárom seeden a prior log3≈1,0986\log3\approx1{,}0986 értéke fölött marad, és a legjobb snapshotja az első epoch: a pixelvektor lineáris függvénye ezen a feladaton nem tanul általánosítható jelet, mert a vonalak helye jelenetenként változik, az orientáció pedig nem lineáris függvénye a pixelintenzitásoknak. A CNN mindhárom seeden 0,90{,}9 körüli validációs pontosságot ér el. Más verziójú könyvtárakkal vagy más CPU-n az utolsó jegyek eltérhetnek; a saját project2_val_report.json a leadandó.

A cnn_s20261021 futás naplójának részlete mutatja a best és a last snapshot különbségét:

epoch train_loss val_loss val_acc lr       best
1     0.990      1.033    0.567   1.00e-03 yes
4     0.475      0.771    0.767   9.62e-04 yes
8     0.121      0.468    0.900   8.06e-04 yes
12    0.043      0.386    0.900   5.70e-04 no
15    0.029      0.368    0.900   3.77e-04 yes
16    0.027      0.371    0.900   3.16e-04 no
20    0.022      0.374    0.900   1.12e-04 no
24    0.021      0.373    0.900   1.42e-05 no
best_epoch=15 best_val_loss=0.3683

A train loss tovább csökken, a validációs loss viszont a 15. epoch után már nem javul: a best.pt a 15. epoch állapota, nem az utolsóé. Ugyanezen a futáson a validációs pontosság a 8. epochtól változatlan, tehát a választást a loss, nem az accuracy dönti el.

Validációs loss (cnn, seed 20261021) - Mért futás, lab-profil: Python 3.12.3, PyTorch 2.14.1+cpu.

38.2.6 6. Feladatok

F1 (fogalmi, nehézség 1). Előfeltétel: a 2.5. szakasz. Fogalmazza meg saját szavaival, mi a különbség a legutolsó snapshot és a validation-alapú best snapshot között, és adjon egy minimális, három epochra vonatkozó val_loss sorozatot, ahol a kettő nem esik egybe. Elvárt átadandó eredmény: definíció és a három elemű számsor.

F2 (levezetési/bizonyítási, nehézség 2). Előfeltétel: a 3. szakasz lemmái. Legyen

Z=(10−1021),y=(0,2). Z=\begin{pmatrix} 1 & 0 & -1\\ 0 & 2 & 1 \end{pmatrix}, \qquad y=(0,2).

Számítsa ki kézzel a mean-redukált cross-entropyt és a 2×32\times3-as gradiensmátrixot. Ellenőrizze az alakot, és azt, hogy minden sor gradiensösszege nulla. Elvárt átadandó eredmény: a loss közelítő értéke, a gradiensmátrix, valamint a 3. lemma alkalmazásának jelölése.

F3 (implementációs, nehézség 2). Előfeltétel: a labor script lefutott. Írjon egy select_best függvényt, amely a runs/<arch>_s<seed>/metrics.jsonl soraiból kiválasztja a legkisebb val_loss sort, betölti a best.pt fájlt weights_only=True módon, és a validációs halmazon reprodukálja a loss értékét 10−410^{-4} tűréssel. Elvárt átadandó eredmény: futtatható kód, best_epoch és a reprodukált val_loss.

F4 (hibakeresési, nehézség 1). Előfeltétel: a labor naplója. Egy hallgató a best.pt mentését így írta:

if epoch + 1 == epochs:
    torch.save({"model_state_dict": model.state_dict(), "arch": arch, "seed": seed,
                "epoch": epoch + 1, "val_loss": va_loss},
               os.path.join(run_dir, "best.pt"))

A summary.json-ban mégis best_epoch=15 áll. Hol a hiba, mi a következménye a zárótesztre, és hogyan ellenőrzi a javítást? Elvárt átadandó eredmény: ok, következmény, javított feltétel és a betöltött modell loss-ellenőrzése.

F5 (kísérleti/transzfer, nehézség 3). Előfeltétel: a 38.1. Adat, split és előzetes protokoll protokollterve. Tegyük fel, hogy egy új munkacsoport két kis architektúrát, A-t és B-t akar kiválasztani a GlyphLab validációs lossa alapján, három seeddel. Írjon előzetes protokollt: megfigyelési egység, split, baseline, változó tényező, döntési metrika, külön futásnaplók és a tesztkiértékelés időpontja. Nevezze meg azt a statisztikai korlátot, amely a validációval végzett architektúraválasztás után a tesztmetrikát terheli. Elvárt átadandó eredmény: egy bekezdésnyi protokoll és a korlát megnevezése indoklással.

38.2.7 7. Megoldások és indoklás

38.2.7.1 F1 megoldása

A legutolsó snapshot a tanítási szerződésben rögzített TT. epoch utáni állapot, minden további válogatás nélkül. A best snapshot ezzel szemben a 𝒮={s1,…,sT}\mathcal S=\{s_1,\ldots,s_T\} véges jelöltkészlet azon eleme, amelyre a teljes validációs halmazon számolt átlagos cross-entropy minimális. A kettő akkor és csak akkor esik egybe, ha t̂=T\hat t=T. Példa: L̂val=(0.50,0.45,0.48)\widehat L_{\mathrm{val}}=(0.50,\;0.45,\;0.48). Ekkor t̂=2\hat t=2, a legutolsó snapshot a 3. epochhoz tartozik, és rosszabb validációs értéket hordoz.

38.2.7.2 F2 megoldása

Első sor: exp(1)=2.7183\exp(1)=2.7183, exp(0)=1\exp(0)=1, exp(−1)=0.3679\exp(-1)=0.3679, összegük 4.08624.0862. A softmax p1=(0.6652,0.2447,0.0900)p_1=(0.6652,\;0.2447,\;0.0900), a cél y1=0y_1=0, így a sor lossza −log(0.6652)=0.4077-\log(0.6652)=0.4077. Második sor: exp(0)=1\exp(0)=1, exp(2)=7.3891\exp(2)=7.3891, exp(1)=2.7183\exp(1)=2.7183, összegük 11.107411.1074. A softmax p2=(0.0900,0.6652,0.2447)p_2=(0.0900,\;0.6652,\;0.2447), a cél y2=2y_2=2, a sor lossza −log(0.2447)=1.4077-\log(0.2447)=1.4077. Az átlag

L=0.4077+1.40772=0.9077. L=\frac{0.4077+1.4077}{2}=0.9077.

A gradiens a 3. lemma szerint soronként (pn−𝟏yn)/N(p_n-\mathbf 1_{y_n})/N, N=2N=2:

∇ZL=12(−0.33480.24470.09000.09000.6652−0.7553)≈(−0.16740.12240.04500.04500.3326−0.3776). \nabla_ZL=\frac12\begin{pmatrix} -0.3348 & 0.2447 & 0.0900\\ 0.0900 & 0.6652 & -0.7553 \end{pmatrix} \approx \begin{pmatrix} -0.1674 & 0.1224 & 0.0450\\ 0.0450 & 0.3326 & -0.3776 \end{pmatrix}.

Alakellenőrzés: 2×32\times3, megegyezik ZZ alakjával. Mindkét sor összege: −0.1674+0.1224+0.0450=0-0.1674+0.1224+0.0450=0 és 0.0450+0.3326−0.3776=00.0450+0.3326-0.3776=0, a kerekítési hibán belül.

38.2.7.3 F3 megoldása

import json, math, os
from torch.utils.data import DataLoader
from glyph_models import load_checkpoint
from train_small_cnn import evaluate, load_split

def select_best(run_dir, root=".", batch_size=32, tol=1e-4):
    with open(os.path.join(run_dir, "metrics.jsonl"), encoding="utf-8") as f:
        rows = [json.loads(line) for line in f]
    best = min(rows, key=lambda r: r["val_loss"])
    model, ckpt = load_checkpoint(os.path.join(run_dir, "best.pt"))  # weights_only=True
    assert ckpt["epoch"] == best["epoch"], (ckpt["epoch"], best["epoch"])
    va_loader = DataLoader(load_split(root, "val"), batch_size=batch_size, shuffle=False)
    val_loss, val_acc, _ = evaluate(model, va_loader)
    assert math.isclose(val_loss, best["val_loss"], abs_tol=tol), (val_loss, best["val_loss"])
    return model, best["epoch"], val_loss, val_acc

print(select_best("runs/cnn_s20261021")[1:])

A labor futása után a runs/cnn_s20261021 könyvtáron a függvény a 15. epochot és a 0,36830{,}3683-as validációs losst adja vissza. A tűrés 10−410^{-4}: a mentett érték ugyanabból a determinisztikus előreszámításból származik, és a validációs halmazt ugyanazzal a batch-felosztással építjük fel, ezért a gyakorlatban az eltérés nulla; a tűrés más verziójú könyvtárak összegzési sorrendjére hagy helyet. A teszt lényegi része nem az assert, hanem a reprodukált val_loss érték és az, hogy a checkpoint epoch mezője a napló legjobb sorára mutat.

38.2.7.4 F4 megoldása

A hiba oka: a best.pt mentési feltétele az utolsó epochhoz kötött, nem a legjobb validációs értékhez. Következmény: a fájl a TT. epoch állapotát tartalmazza, miközben a napló szerint a 15. epoch vesztesége kisebb; a záróteszt a túlillesztettebb utolsó modellt kapná. Javítás (a labor szkriptjének mintájára):

if va_loss < best["val_loss"]:
    best = {"epoch": epoch + 1, "val_loss": va_loss}
    torch.save({"model_state_dict": model.state_dict(), "arch": arch, "seed": seed,
                "epoch": epoch + 1, "val_loss": va_loss},
               os.path.join(run_dir, "best.pt"))

Ellenőrzés: a javított futás summary.json best_epoch értéke megegyezik a metrics.jsonl legkisebb val_loss sorával és a checkpoint epoch mezőjével, és a betöltött best.pt validációs lossát a best_val_loss értékkel 10−410^{-4} tűrésen belül reprodukáljuk. Ha a tűrés nem teljesül, akkor a mentés nem a kiértékelt állapothoz tartozik.

38.2.7.5 F5 megoldásminta

A protokoll a jelen egységben nem hajtja végre a receptkeresést, mert a 38.1. Adat, split és előzetes protokoll a befagyasztott receptet rögzíti; a következő terv tehát transzferfeladat. Megfigyelési egység: scene_id; a train/val/test felosztás változatlan. A két architektúra (A: a fenti 8–16 csatornás lánc, B: 16–32 csatornás változat) mindegyikét három seeddel, külön runs/<arch>_s<seed> naplóban tanítjuk. Döntési metrika: az utolsó tíz epoch snapshotjainak validációs lossa seedenként, azaz összesen 10×310\times3 snapshot architektúránként; a kiválasztott modell az az architektúra–seed–snapshot hármas, amelynek a legkisebb a validációs lossa. A baseline az uniform prior-osztályozó. A tesztet csak a választás után, egyetlen alkalommal értékeljük. Korlát: mivel a validációt modellválasztásra is használtuk, a nyertes modell tesztmetrikája optimista lehet a valódi kockázathoz képest; a [S02] szerint az intervallumok feltételes, ismételt mintavételi értelmezésűek, és a választási lépést mint az eljárás részét kell kezelni, nem mint előre ismert modellt.

38.2.8 8. Tipikus tévedés ellenpéldával, önellenőrzés és visszahívás

Tipikus tévedés. „Az utolsó epoch a legjobban tanult modell.” Ellenpélda a fenti mért cnn_s20261021 futás: az utolsó epoch val_loss=0.373, a legjobb 15. epoch val_loss=0.368. Ha a train loss az epochok végén 0,0210{,}021, az csak a train halmazhoz való illeszkedést mutatja; a validációs görbe emelkedése éppen a túlillesztés jele. A zárótesztet ezért a best snapshoton kell futtatni, nem az utolsó epoch állapotán. A tévedés következménye a clean testen is mérhető romlás, a corrupt teszten pedig jellemzően nagyobb érzékenységnövekedés lehet, mert a túlillesztett döntési határ a zajra és a fényességgradiensre is érzékenyebb.

Önellenőrzés.

  1. Mennyi a GlyphCNN paraméterszáma, és melyik réteg adja a legtöbbet?
  2. A modell kimenete logit vagy valószínűség? Hol történik a softmax a CE-szerződésben?
  3. Melyik split alapján választunk snapshotot, és melyik splitet tilos a választás előtt megnyitni?
  4. A best.pt betöltéséhez miért kell újraépíteni az architektúrát, és melyik modul adja az egyetlen modelldefiníciót?

Visszahívás. A 37.2. Backward és ellenőrzési lefedettség gradiensellenőrzése biztosítja, hogy a Conv2d tanulási jele a levezetett backwarddal egyezik; a jelen egység ezt az operátort már nem ellenőrzi újra, hanem feltételezi. A 34.2. Kvantálás és hibatovábbterjedés figyelmeztetett, hogy a kisebb bitszélességű reprezentáció további hibát vinne be; a mostani labor ezért float32 marad. A 26.2. Egy- és kétlépcsős detektor detektor-felelősségmegosztása azt hangsúlyozta, hogy az osztályozó logit nem dobozregresszió: a GlyphLab xmin, ymin, xmax, ymax oszlopait ebben a kísérletben nem használjuk, azok a szegmentálós/lokalizációs projekt annotációi.

38.2.9 9. Rövid összefoglaló

A 38.2. Modell, training és best checkpoint egység a befagyasztott GlyphLab-protokoll tanítási szakaszát valósította meg. Definiáltuk a kis CNN-t, a lineáris baseline-t és a prior minimum-referenciát, a Kaiming-inicializálást, a koszinuszos tanulásiráta-ütemezést, a snapshotot és a validation-alapú best-snapshot választást. Levezettük a paraméterszámot és a rétegenkénti alakokat, valamint a mean-redukált cross-entropy gradiensét, amely softmax mínusz one-hot alakú. A labor egyetlen paranccsal mindkét modellt mindhárom seeddel tanítja, ellenőrzi a paraméterszámot, futásonként JSON-naplót és visszatöltéssel ellenőrzött best.pt checkpointot ír, majd a protokoll választási szabálya szerint előállítja a 38.3. Végső teszt és páros korrupció bemenetét; a közölt napló mért futásból származik. A következő egység a befagyasztott modell végső tesztjét és a páros corrupt kiértékelést készíti elő.

38.2.10 Felhasznált források