24.4 VI. ellenőrző pont: megbízható CNN-training

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

Ez a VI. ellenőrző pont: a 24.1.–24.3. hibadiagnózis- és profilereszközeit egyetlen, átadható csomaggá zárjuk. A cél nem új réteg tanítása, hanem annak bizonyítása, hogy a hallgató az adatot, tanítási receptet, mérési protokollt, metrikákat és hibakezelést együtt tudja lektorálható módon kezelni. Előismeret: a 24.2. CPU/GPU profiling és helyes időmérés wall time, warmup, adatbetöltési és memóriafogalmai, a 23.4. Hibaszeletek és alkalmazási határ szelet- és páros tesztelési szerződése, a 20.4. V. ellenőrző pont: indokolt CNN-választás architektúra-döntési lapja, valamint a 12.4. III. ellenőrző pont: önállóan tanuló kis háló checkpoint- és gradiensegyeztetési eljárása.

Értékelés. A közös 0–4 rubrikát, a PASS-szabályt és a hibatípusonkénti visszautakat az 1.4. Tanulási diagnózis és kísérleti napló „Tanulási főút és ellenőrző pontok” szakasza adja meg; itt csak az eltérés: a riport futási jegyzéke itt a fő leadandó; a súlypont a D3 és a D4.

Induló kérdés: egy CPU-n tanított, 2979 paraméteres kis CNN vajon „megbízhatóbb-e”, mint egy GPU-n tanított nagyobb háló? Becslés: a paraméterszám önmagában nem megbízhatósági mérce; először a modell loss- és alakszerződését, azután a kísérleti protokollt kell ellenőrizni. A GPU jelenléte gyorsíthat, de nem igazol helyes modellválasztást.

24.4.2 2. A kiválasztott modell, loss- és alakszerződés, analitikus kontroll

A laborhoz a 20.4. V. ellenőrző pont: indokolt CNN-választás döntési lapjából a következőket választjuk: saját, kisméretű classic CNN (scratch), CPU-erőforrás, GlyphLab-adat. A modell:

x↦fc(MaxPool(ReLU(Conv2(MaxPool(ReLU(Conv1(x))))))).x \mapsto \mathrm{fc}\!\left(\mathrm{MaxPool}\!\left(\mathrm{ReLU}\!\left(\mathrm{Conv}_2\!\left(\mathrm{MaxPool}\!\left(\mathrm{ReLU}\!\left(\mathrm{Conv}_1(x)\right)\right)\right)\right)\right)\right).

A pontos alak- és paraméterszerződés:

Réteg Bemeneti alak Kimeneti alak Paraméterszám
Conv1: 1→8, 3×33\times3, pad 1 N×1×24×24N\times1\times24\times24 N×8×24×24N\times8\times24\times24 8(1⋅3⋅3)+8=808(1\cdot3\cdot3)+8=80
ReLU, MaxPool 2×22\times2 N×8×24×24N\times8\times24\times24 N×8×12×12N\times8\times12\times12 0
Conv2: 8→16, 3×33\times3, pad 1 N×8×12×12N\times8\times12\times12 N×16×12×12N\times16\times12\times12 16(8⋅3⋅3)+16=116816(8\cdot3\cdot3)+16=1168
ReLU, MaxPool 2×22\times2 N×16×12×12N\times16\times12\times12 N×16×6×6N\times16\times6\times6 0
Flatten N×16×6×6N\times16\times6\times6 N×576N\times576 0
Linear: 576→3 N×576N\times576 N×3N\times3 576⋅3+3=1731576\cdot3+3=1731

Összesen 80+1168+1731=297980+1168+1731=2979 paraméter. A kötelező NCHW-sorrend miatt minden térbeli alakot C×H×WC\times H\times W-ként adunk meg; a batchméret NN kívül áll.

A tanítási loss-szerződés a [D07] keretjegyzet szerint: a modell logitsot ad, a cél int64 osztályindex, nn.CrossEntropyLoss előtt nem alkalmazunk külön softmaxot. Legyen Z∈ℝN×KZ\in\mathbb R^{N\times K} a logitmátrix és y∈{0,…,K−1}Ny\in\{0,\dots,K-1\}^N a cél; a mean reduction veszteség

L(Z,y)=−1N∑n=1Nlogexp(zn,yn)∑j=1Kexp(zn,j).L(Z,y)=-\frac1N\sum_{n=1}^N\log\frac{\exp(z_{n,y_n})}{\sum_{j=1}^K\exp(z_{n,j})}.

A softmaxos nevező minden nn-re az összes jj osztályon összegez; a célindex csak a számlálóban jelenik meg. A kimenet skalár.

Analitikus kontroll (lemma). Ha Z=0N×KZ=0^{N\times K}, akkor tetszőleges yy célra L=lnKL=\ln K.

Levezetés: minden n,jn,j-re exp(zn,j)=exp(0)=1\exp(z_{n,j})=\exp(0)=1, tehát a softmax-kimenet

pn,j=1∑ℓ=1K1=1K.p_{n,j}=\frac{1}{\sum_{\ell=1}^K 1}=\frac1K.

Így minden megfigyelésre −logpn,yn=−log(1/K)=lnK-\log p_{n,y_n}=-\log(1/K)=\ln K, és a batchátlag ugyanennyi. GlyphLab esetén K=3K=3, tehát a kontrollérték ln3≈1,098612\ln3\approx1{,}098612. Ezt a labor sanity checkje numerikusan, 10−610^{-6} abszolút tűréssel ellenőrzi.

A lemma csak a loss-modult teszteli, a modellszerkezetet nem. Ezért a labor tartalmaz egy második, zero-init alakkontrollt: a modell minden súlyát és biasát nullára állítjuk. Ekkor minden Conv-réteg kimenete azonosan nulla tetszőleges bemenetre, mert a konvolúció lineáris része nulla, a bias nulla; ezt ReLU, MaxPool és Flatten nullán változatlanul hagyja; a Linear-réteg pedig 0⋅W⊤+b=00\cdot W^\top+b=0-t ad. Ezért a modell kimenete (N,3)(N,3) alakú, azonosan nulla. Ez a kontroll a teljes forward alakszerződést bizonyítja, de nem bizonyítja a tanulás működését: a nulla inicializálás nem töri meg a szimmetriát, ezért tanításra nem használjuk. A labor a kontroll után a rögzített seeddel generált alapértelmezett állapotot automatikusan visszaállítja.

24.4.3 3. Inicializálás (init)

A tanításhoz a torch.manual_seed(20261020) hívással rögzítjük a véletlensúly-inicializálást. A 21.1. Inicializálás és varianciaterjedés alfejezetben levezetett varianciafeltételt itt csak visszahívjuk: ReLU előtti konvolúciónál a cél gyakorlat az, hogy a súly varianciája 2/fan_in2/\mathrm{fan\_in} nagyságrendű legyen, ahol

fan_in=Cinkhkw.\mathrm{fan\_in}=C_{in}k_hk_w.

A mi modellünkben az első Conv fan_in=9\mathrm{fan\_in}=9, a másodiké 8⋅9=728\cdot9=72, a lineáris rétegé 576576. A labor a könyvtár alapértelmezett inicializálását használja, és a gyakorlati ellenőrzést nem a kezdeti súlyokra, hanem arra alapozza, hogy a seed, az architektúra és a betöltött checkpoint együtt megismételhető legyen. A [D02] szerint az azonos seed nem garantál platformok és verziók közötti bitazonos egyezést; ezért a jegyzőkönyv a seedet, a könyvtárverziót és az adatmanifesztet is rögzíti. A seed tehát szükséges, de nem elégséges hordozhatósági garancia.

24.4.4 4. Ütemezés (schedule)

A labor AdamW-t használ λ=10−4\lambda=10^{-4} decoupled weight decayjel. Az ütemező StepLR, step_size=10 és gamma=0.5 paraméterrel, azaz

ηt=η0γ⌊(t−1)/10⌋,η0=10−3,γ=0,5.\eta_t=\eta_0\,\gamma^{\lfloor(t-1)/10\rfloor},\qquad \eta_0=10^{-3},\ \gamma=0{,}5.

A 20 epochos futásban az 1–10. epoch lépésköze 10−310^{-3}, a 11–20. epoché 5⋅10−45\cdot10^{-4}. Az ütemezés heurisztika: a viszonylag nagy kezdeti ráta gyors felfutást, a csökkentés kisebb validációs ingadozást céloz. Hogy az ütemezés valóban ezt teszi-e, nem elméleti állítás, hanem a validációs loss epochonkénti naplója dönti el; a labor minden epoch falidejét és veszteségét menti.

24.4.5 5. Augmentáció (augmentation)

Az augmentáció definíciója szigorú: g:𝒳→𝒳g:\mathcal X\to\mathcal X olyan címkemegőrző transzformáció, amelyre a feladat értelmezése szerint y(g(x))=y(x)y(g(x))=y(x) teljesül. A GlyphLab adatszerződés erre konkrét korlátot ad: a 90∘90^\circ-os forgatás a vertical és horizontal osztályt felcseréli; a diagonal osztály csak az egyik ferde irányt tartalmazza, ezért tükrözés vagy forgatás új célértelmezést igényel. Ezért a geometriai augmentáció ebben az alapkísérletben nem címkemegőrző, és nem használjuk.

Címkemegőrző lehetőség az intenzitástérbeli perturbáció, például g(x)=clamp[0,1](ux)g(x)=\operatorname{clamp}_{[0,1]}(ux) valamely u∼Uniform(0,8,1,2)u\sim\mathrm{Uniform}(0{,}8,1{,}2) mellett, mert az alakzat orientációját geometriailag nem változtatja. A jelen ellenőrző labor mégis augmentáció nélkül fut: az ellenőrzőpont feladata a minimális, reprodukálható alapvonal rögzítése. Az augmentáció hatását az F5 feladat külön kísérletként tervezi, hogy a két változtatás ne keveredjen.

24.4.6 6. Metrikák (metrics)

A főút két tesztkörnyezetet különböztet meg: a clean tesztet és a data/corrupted_test.npy fájlt, amely ugyanazon 60 scene/view pár zajos, fényességgradienssel módosított változata. A corrupt fájl sorrendje a metadata teszt sorainak sorrendje; ezért a célok páronként egyeznek. A labor három fő metrikát rögzít:

A 23.4. Hibaszeletek és alkalmazási határ alfejezetben kimondott elvet követjük: a corrupt tesztet kizárólag a legjobb, validációs loss alapján kiválasztott checkpoint betöltése után értékeljük; utólagos ráhangolás nincs. Az osztályszintű bontást a jegyzőkönyv per_class_clean és per_class_corrupt mezőkben tárolja, mert a 60 képes összpontosság elfedi az osztályszintű hibakoncentrációt. A scene-szintű bootstrapet itt nem számoljuk; a jelen labor egyetlen seeddel jellemzi a kiválasztott receptet, és nem becsli a training-seedek szórását.

24.4.7 7. Profiling (profiling)

A CPU-főúton nincs aszinkron GPU-ütemező, ezért a 24.2. CPU/GPU profiling és helyes időmérés CUDA-szinkronizációs szabálya nem lép be: a time.perf_counter() hívások közvetlenül a processz falidejét mérik. Az első epochot warmupként kezeljük, mert a fájlrendszer-gyorsítótár, a szálindítás és a Python-importok torzíthatják; a jegyzőkönyv minden epoch falidejét külön menti, így a steady-state elkülöníthető. A labor CPU-n, workers=0 DataLoaderrel fut, ezért az adatbetöltés a fő szálon történik; a labor egyetlen next(iter(train_loader)) hívással méri az első batch betöltésének falidejét is. A batch szintű aktivációmemória becslése az alakszerződésből adódik: N=16N=16 esetén a legnagyobb köztes tensor a 16×8×24×2416\times8\times24\times24 térkép, 7372873\,728 float32 elem. A torch.profiler részletes operátorbontását ez a CPU-labor nem használja; itt a mérési protokoll és a falidő összehasonlíthatósága a cél.

24.4.8 8. Checkpoint

A checkpoint minimális szerződése a következő mezőket tartalmazza:

  1. model: a state_dict, amely a betöltő oldalon deklarált, azonos architektúrához tartozik;
  2. config: modellnév, csatornaszám, osztályszám, bemeneti alak és seed;
  3. kiválasztási adat: epoch, val_loss, val_acc;
  4. adatazonosító: data_manifest mutató a data/manifest.json fájlra;
  5. parameter_count: betöltés utáni konzisztenciaellenőrzéshez.

A betöltés torch.load(..., weights_only=True) módon, majd model.load_state_dict(...) strict módban történik. A strict mód garantálja a kulcskészlet és alakok egyezését, de a hibakezelés akkor teljes, ha ezután egy rögzített bemeneti batchre a kimeneti alakot és a végességet is ellenőrizzük. A checkpoint önmagában nem helyettesíti a seedet, az adatverziót és a tanítási konfigurációt; ezért a labor ezeket a runlog.json fájlban együtt menti.

24.4.9 9. Labor: reprodukálható CPU-kísérlet és futásjegyzék

Státusz. E kézirat írásakor a kódot nem futtattuk, ezért a labor numerikus kimenetét nem állítjuk mért eredménynek. Az alábbi kód a runlog.json futásjegyzéket írja; a tényleges számok a hallgató helyi futtatásából származnak. A kódban szereplő 0.0 értékű JSON-mező a séma illusztrációja.

Munkakönyvtár: lab/. Futtatás:

cd lab && python n096_checkpoint.py
# lab/n096_checkpoint.py
from pathlib import Path
import json, time, math
import numpy as np
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset

BASE = Path(__file__).resolve().parent
DATA = BASE / "data"
SEED = 20261020
DEVICE = torch.device("cpu")
DTYPE = torch.float32
EPOCHS = 20
BATCH = 16
LR = 1e-3
WEIGHT_DECAY = 1e-4
STEP_SIZE = 10
GAMMA = 0.5
K = 3

torch.manual_seed(SEED)
np.random.seed(SEED)

class TinyCNN(nn.Module):
    def __init__(self, in_ch=1, num_classes=K):
        super().__init__()
        self.features = nn.Sequential(
            nn.Conv2d(in_ch, 8, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
            nn.Conv2d(8, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
            nn.Flatten()
        )
        self.fc = nn.Linear(16 * 6 * 6, num_classes)

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

def count_params(model):
    return sum(p.numel() for p in model.parameters())

def evaluate(model, loader, criterion=None):
    model.eval()
    total_loss = 0.0
    correct = 0
    n = 0
    with torch.inference_mode():
        for xb, yb in loader:
            xb = xb.to(DEVICE, dtype=DTYPE)
            yb = yb.to(DEVICE)
            logits = model(xb)
            if criterion is not None:
                total_loss += criterion(logits, yb).item() * xb.shape[0]
            correct += (logits.argmax(dim=1) == yb).sum().item()
            n += xb.shape[0]
    return correct / n, (total_loss / n if criterion is not None else None)

def predict_all(model, loader):
    model.eval()
    preds, ys = [], []
    with torch.inference_mode():
        for xb, yb in loader:
            xb = xb.to(DEVICE, dtype=DTYPE)
            yb = yb.to(DEVICE)
            preds.append(model(xb).argmax(dim=1).cpu().numpy())
            ys.append(yb.cpu().numpy())
    return np.concatenate(preds), np.concatenate(ys)

def per_class_acc(preds, labels):
    out = []
    for c in range(K):
        mask = labels == c
        out.append(float(np.mean(preds[mask] == c)) if mask.sum() else None)
    return out

def main():
    criterion = nn.CrossEntropyLoss()

    # 1) loss-sanity: zero logits -> ln K
    z0 = torch.zeros(4, K, dtype=DTYPE, device=DEVICE)
    y0 = torch.tensor([0, 1, 2, 0], device=DEVICE)
    l0 = criterion(z0, y0).item()
    assert abs(l0 - math.log(K)) < 1e-6, (l0, math.log(K))
    print(f"[sanity] zero-logit loss = {l0:.6f} (expected {math.log(K):.6f})")

    model = TinyCNN().to(DEVICE, dtype=DTYPE)

    # 2) zero-init forward contract
    x_dummy = torch.randn(2, 1, 24, 24, dtype=DTYPE, device=DEVICE)
    default_state = {k: v.detach().clone() for k, v in model.state_dict().items()}
    with torch.no_grad():
        for p in model.parameters():
            p.zero_()
        z_zero = model(x_dummy)
    assert z_zero.shape == (2, K) and torch.allclose(z_zero, torch.zeros_like(z_zero)), z_zero
    model.load_state_dict(default_state)
    print(f"[sanity] zero-init output {tuple(z_zero.shape)}, all zero: {bool((z_zero == 0).all())}")

    # 3) adat a metadata splitje szerint
    meta = np.loadtxt(DATA / "metadata.csv", delimiter=",", dtype=str, skiprows=1)
    indices = meta[:, 0].astype(np.int64)
    split = meta[:, 4]
    images = np.load(DATA / "images.npy").astype(np.float32)
    labels = np.load(DATA / "labels.npy").astype(np.int64)

    def make_loader(split_name, shuffle=False):
        idx = indices[split == split_name]
        x = torch.from_numpy(images[idx]).to(DEVICE, dtype=DTYPE)
        y = torch.from_numpy(labels[idx]).to(DEVICE)
        ds = TensorDataset(x, y)
        gen = torch.Generator().manual_seed(SEED) if shuffle else None
        return DataLoader(ds, batch_size=BATCH, shuffle=shuffle, generator=gen)

    train_loader = make_loader("train", shuffle=True)
    val_loader = make_loader("val")
    test_loader = make_loader("test")

    t = time.perf_counter()
    next(iter(train_loader))
    data_load_s = time.perf_counter() - t

    optimizer = torch.optim.AdamW(model.parameters(), lr=LR, weight_decay=WEIGHT_DECAY)
    scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=STEP_SIZE, gamma=GAMMA)

    run = {
        "config": {"seed": SEED, "device": str(DEVICE), "dtype": str(DTYPE),
                   "epochs": EPOCHS, "batch": BATCH, "lr": LR,
                   "weight_decay": WEIGHT_DECAY,
                   "schedule": {"step_size": STEP_SIZE, "gamma": GAMMA}},
        "parameter_count": count_params(model),
        "data_load_s": data_load_s,
        "epochs": []
    }
    best = {"val_loss": float("inf")}
    total_start = time.perf_counter()

    for ep in range(1, EPOCHS + 1):
        model.train()
        t0 = time.perf_counter()
        total_loss = 0.0
        n = 0
        for xb, yb in train_loader:
            xb = xb.to(DEVICE, dtype=DTYPE)
            yb = yb.to(DEVICE)
            optimizer.zero_grad(set_to_none=True)
            loss = criterion(model(xb), yb)
            loss.backward()
            optimizer.step()
            total_loss += loss.item() * xb.shape[0]
            n += xb.shape[0]
        train_loss = total_loss / n
        val_acc, val_loss = evaluate(model, val_loader, criterion)
        scheduler.step()
        wall = time.perf_counter() - t0
        run["epochs"].append({"epoch": ep, "train_loss": train_loss,
                              "val_loss": val_loss, "val_acc": val_acc,
                              "wall_s": wall})
        print(f"epoch {ep:02d} train {train_loss:.4f} val {val_loss:.4f} acc {val_acc:.4f} {wall:.3f}s")
        if val_loss < best["val_loss"]:
            best = {"epoch": ep, "val_loss": val_loss, "val_acc": val_acc}
            torch.save({
                "model": model.state_dict(),
                "config": {"name": "TinyCNN", "in_ch": 1, "num_classes": K,
                           "input_shape": [1, 24, 24], "seed": SEED},
                "epoch": ep,
                "val_loss": val_loss,
                "val_acc": val_acc,
                "data_manifest": "data/manifest.json",
                "parameter_count": count_params(model)
            }, BASE / "best.pt")

    ckpt = torch.load(BASE / "best.pt", map_location="cpu", weights_only=True)
    model.load_state_dict(ckpt["model"])
    model.eval()

    clean_preds, clean_ys = predict_all(model, test_loader)
    clean_acc = float(np.mean(clean_preds == clean_ys))

    corrupt_np = np.load(DATA / "corrupted_test.npy").astype(np.float32)
    test_idx = indices[split == "test"]
    corrupt_x = torch.from_numpy(corrupt_np).to(DEVICE, dtype=DTYPE)
    corrupt_y = torch.from_numpy(labels[test_idx]).to(DEVICE)
    corrupt_loader = DataLoader(TensorDataset(corrupt_x, corrupt_y),
                                batch_size=BATCH, shuffle=False)
    corrupt_preds, corrupt_ys = predict_all(model, corrupt_loader)
    corrupt_acc = float(np.mean(corrupt_preds == corrupt_ys))
    matched_agreement = float(np.mean(clean_preds == corrupt_preds))

    run["best"] = best
    run["test"] = {
        "clean_acc": clean_acc,
        "corrupt_acc": corrupt_acc,
        "matched_agreement": matched_agreement,
        "per_class_clean": per_class_acc(clean_preds, clean_ys),
        "per_class_corrupt": per_class_acc(corrupt_preds, corrupt_ys)
    }
    run["total_wall_s"] = time.perf_counter() - total_start
    with open(BASE / "runlog.json", "w", encoding="utf-8") as f:
        json.dump(run, f, indent=2)
    print(json.dumps(run["test"], indent=2))

if __name__ == "__main__":
    main()

A kód által létrehozott runlog.json szerkezete:

{
  "config": {"seed": 20261020, "device": "cpu", "dtype": "torch.float32",
             "epochs": 20, "batch": 16, "lr": 0.001,
             "weight_decay": 0.0001,
             "schedule": {"step_size": 10, "gamma": 0.5}},
  "parameter_count": 2979,
  "data_load_s": 0.0,
  "epochs": [{"epoch": 1, "train_loss": 0.0, "val_loss": 0.0,
              "val_acc": 0.0, "wall_s": 0.0}],
  "best": {"epoch": 0, "val_loss": 0.0, "val_acc": 0.0},
  "test": {"clean_acc": 0.0, "corrupt_acc": 0.0, "matched_agreement": 0.0,
           "per_class_clean": [0.0, 0.0, 0.0],
           "per_class_corrupt": [0.0, 0.0, 0.0]},
  "total_wall_s": 0.0
}

A fenti 0.0 értékek helykitöltők, nem mért futásjegyzék. A tényleges jegyzőkönyvet a futtatás hozza létre.

24.4.10 10. Feladatok

F1 (fogalmi, nehézség 1). Mondja ki a labor loss-szerződésének három elemét: reduction, logits vagy valószínűség, target típusa. Adjon ellenpéldát arra, amikor a softmax előzetes alkalmazása más numerikus eredményt ad, mint a szerződéses CrossEntropyLoss logits-bemenettel.

F2 (levezetési, nehézség 2). Bizonyítsa be, hogy a labor TinyCNN modellje minden súly és bias nullázása esetén tetszőleges x∈ℝ1×24×24x\in\mathbb R^{1\times24\times24} bemenetre 0∈ℝ30\in\mathbb R^3 kimenetet ad. Pontosan adja meg, mit mutat és mit nem mutat ez a kontroll.

F3 (implementációs, nehézség 2). Írjon lab/check_best.py szkriptet, amely a best.pt betöltése után ellenőrzi: (a) a config mező név- és alakszerződését; (b) a state_dict kulcskészletét; (c) egy rögzített torch.randn(4,1,24,24) bemenetre a kimeneti alakot és végességet. Adja meg a futtatási parancsot.

F4 (hibakeresési, nehézség 2). A hallgatótárs a tanítási ciklusban minden epoch után a clean test pontosságát használja a legjobb epoch kiválasztására, majd a betöltött modellen a corrupt tesztet értékeli. Lokalizálja a hibát, írja le a következményét, és javítsa a protokollt.

F5 (kísérleti/transzfer, nehézség 3). Tervezzen egy kísérletet, amely az intenzitás-augmentáció (g(x)=clamp(ux), u∼U(0,8,1,2)u\sim U(0{,}8,1{,}2)) hatását méri a GlyphLab clean és corrupt tesztjén úgy, hogy a corrupt tesztet nem használja modellválasztásra. Adja meg a protokollt, a baseline-t, a metrikát és legalább két érvényességi korlátot.

24.4.11 11. Megoldások és indoklás

F1 megoldás. A szerződés: (1) a loss mean reduction, azaz a batch megfigyeléseinek átlaga; (2) a bemenet nyers logits, mert a CrossEntropyLoss magában végzi a log-softmaxot; (3) a target int64 osztályindex, egy megfigyeléshez egy skalár. Ellenpélda: legyen N=1N=1, logit z=(0,10)z=(0,10) és helyes cél y=1y=1. A szerződéses loss: −loge10e0+e10≈4,54⋅10−5-\log\frac{e^{10}}{e^0+e^{10}}\approx4{,}54\cdot10^{-5}. Ha előbb softmaxot alkalmazunk, p≈(4,54⋅10−5,0,99995)p\approx(4{,}54\cdot10^{-5}, 0{,}99995), majd ezt logitnak tekintve a loss −loge0,99995e4,54⋅10−5+e0,99995≈0,693-\log\frac{e^{0{,}99995}}{e^{4{,}54\cdot10^{-5}}+e^{0{,}99995}}\approx0{,}693. A dupla softmax tehát számottevően nagyobb, értelmetlen értéket ad.

F2 megoldás. Rétegről rétegre: a Conv-réteg W=0W=0 súllyal és b=0b=0 biassal bármely bemenetre 00 kimenetet ad, mert a kernel-összeg minden tagja 0⋅x...=00\cdot x_{...}=0. A ReLU nullpontbeli értéke konvenció szerint 00, a nn.ReLU() implementációja itt 00-t ad; a MaxPool nulla bemeneten nullát; a Flatten az azonosan nulla tensor alakját (N,576)(N,576)-ra rendezi, értéke nulla; a Linear 0⋅W⊤+b=00\cdot W^\top+b=0. Ezért a kimenet (N,3)(N,3) nulla. A bizonyítás a forward kompozíció alakszerződését igazolja bármely inputra. Nem mutatja, hogy a modell nemtriviális gradienseket ad, és nem mutatja a tanulás sikerét; a zero init nem töri meg a szimmetriát, ezért tanításra alkalmatlan. A labor ezért csak sanity checkként használja, majd visszaállítja a seedelt alapállapotot.

F3 megoldás.

# lab/check_best.py
from pathlib import Path
import torch
from n096_checkpoint import TinyCNN, DEVICE, DTYPE

BASE = Path(__file__).resolve().parent
ckpt = torch.load(BASE / "best.pt", map_location="cpu", weights_only=True)
cfg = ckpt["config"]
assert cfg["name"] == "TinyCNN"
assert cfg["in_ch"] == 1 and cfg["num_classes"] == 3
assert cfg["input_shape"] == [1, 24, 24]

model = TinyCNN(cfg["in_ch"], cfg["num_classes"]).to(DEVICE, dtype=DTYPE)
model.load_state_dict(ckpt["model"])

expected_keys = {
    "features.0.weight", "features.0.bias",
    "features.3.weight", "features.3.bias",
    "fc.weight", "fc.bias"
}
assert set(ckpt["model"].keys()) == expected_keys, set(ckpt["model"].keys())

x = torch.randn(4, 1, 24, 24, dtype=DTYPE, device=DEVICE)
with torch.inference_mode():
    y = model(x)
assert y.shape == (4, 3)
assert torch.isfinite(y).all()
print("check OK:", tuple(y.shape), sum(p.numel() for p in model.parameters()))

Futtatás: cd lab && python check_best.py. Az elvárt kimenet check OK: (4, 3) 2979. A kontroll szigorú: a strict load_state_dict önmagában is hibát dob, ha kulcs vagy alak eltér; a kimeneti alakellenőrzés a 24×24 bemeneti séma és a háromosztályos kimenet együttes tesztje. A tolerancia itt szükségtelen, mert alak- és végességellenőrzésről van szó, nem lebegőpontos egyezésről.

F4 megoldás. A hiba a modellválasztás adatszivárgása: a clean test a tanítási ciklus során befolyásolja az epochválasztást, ezért a clean test többé nem független teszt. A következmény kettős: a clean és corrupt teszten mért pontosság szelekciós torzítást kap, és a választott epoch általában optimistább, mint a tényleges generalizáció. A korrekt protokoll: a legjobb epochot kizárólag val_loss alapján kell választani; a clean testet csak a befagyasztott checkpoint betöltése után, egyszer szabad értékelni; a corrupt tesztet ugyancsak csak ezután, páros clean-korrupt összehasonlítással. A hibás futtatásjegyzék nem javítható utólag az epochok újbóli értékelésével, a tanítást újra kell indítani.

F5 megoldás. Protokoll: (1) baseline = a fenti TinyCNN augmentáció nélkül; kezelés = ugyanaz a modell, ugyanaz a seed, de minden training batchre u∼U(0,8,1,2)u\sim U(0{,}8,1{,}2) skalárral szorzott, [0,1]-re vágott képek. (2) A training/val/test split a GlyphLab szerződéses 120/30/30 scene marad; a val loss alapján mindkét modellnél külön epochválasztás. (3) A kiértékelés a befagyasztott modelleken clean és corrupt teszten clean_acc, corrupt_acc, matched_agreement és osztályszintű bontás. (4) A két modell különbségét páros scene/view szinten számoljuk, mert a view-k összetartoznak. Érvényességi korlát: egyetlen seed, ezért az eredmény a training-véletlent nem választja el a kezelés hatásától; a corrupt eloszlás csak a GlyphLab-féle zajt és fényességgradienst reprezentálja, természetes képi shiftre nem vihető át. Emellett az intenzitástranszformáció csak akkor címkemegőrző, ha a feladatszerződés az orientációt a fényességtől függetlenül definiálja; ha a későbbi adatverzió ezt megváltoztatja, a protokollt újra kell validálni.

24.4.12 12. Tipikus tévedés ellenpéldával

Gyakori tévedés: „a GPU gyorsabb, tehát a kísérlet helyesebb.” Ellenpélda: az egyik hallgató GPU-n 10 epochot futtat nagy batchszel, és közben a modellválasztáshoz a test accuracy-t használja; a másik CPU-n 20 epochot futtat a fenti val-loss protokollal. A GPU-s futás gyorsabb, de szelekciós torzítást visz a tesztbe, így a mért pontossága nem független általánosítási becslés. A nagyobb batch a lépésszámot is megváltoztatja, és ha a learning-rate ütemezést nem igazítják hozzá, a két konfiguráció más optimalizálási pályát jár be. A GPU jelenléte tehát számítási erőforrás, nem tudományos érvényességi garancia.

24.4.13 13. Önellenőrzés és visszahívás

Önellenőrző kérdések: szerepel-e a loss-szerződésben a mean reduction és az int64 target? A corrupt tesztet tényleg csak a legjobb validációs checkpoint betöltése után értékeli a kód? A runlog.json tartalmazza-e a seedet, az adatmanifesztet és az ütemezést? A checkpoint betöltése strict módban történt-e? Visszahívás: a 24.2. CPU/GPU profiling és helyes időmérés alfejezetben tanított warmup-szabály szerint itt az első epoch falidejét a steady-state átlagtól külön kezeljük; a 23.4. Hibaszeletek és alkalmazási határ páros tesztelési elve szerint a corrupt fájl sorrendje a clean teszt sorrendjével párosul.

24.4.14 14. Rövid összefoglaló

A VI. ellenőrző pont egyetlen csomagba zárja a kis CNN alak- és loss-szerződését, a seedelt inicializálást, a StepLR-ütemezést, a címkemegőrző augmentáció korlátját, a clean/corrupt metrikákat, a CPU-falidejű profilmérést és a szigorú checkpoint-betöltést. Az analitikus kontroll belátja, hogy nulla logitokra a cross-entropy lnK\ln K, a zero-init modell pedig minden bemenetre nulla kimenetet ad; ez a forward-szerződést igazolja, a tanulást nem. A labor teljes CPU-kódot ad, amely runlog.json futásjegyzéket ír; a kéziratban nem állítunk mért eredményt. A clean és corrupt teszt kizárólag a validációs loss alapján kiválasztott checkpointon értékelhető.

24.4.15 15. Következő alkalmazás

A 25.1. Teljes képosztályozó adatfolyam teljes képosztályozó adatfolyamot épít: az itteni checkpoint- és metrikaszerződést kiterjeszti bináris, többosztályos és multilabel célokra, és a betanított modellt új felhasználási felületre illeszti.

24.4.16 16. Felhasznált források