11.3 Dataset és DataLoader a split után

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

A 11.2. nn.Module és paraméterregisztráció alfejezetben a modell paramétereit már regisztráltuk, és a state_dict menthetővé vált. Most azt az adatutat építjük meg, amely a rögzített train/val/test splitből tanulási batch-eket állít elő anélkül, hogy a GlyphLab scene-szintű függetlenségi szerződését megsértené. Az előismeret a tenzoralakok és a nn.Module használata; a veszteség- és célalakok szerződését a 7.3. Bináris, multilabel és regressziós cél, a numerikus ellenőrzés módszerét a 10.3. Gradiensellenőrzés és numerikus hibamérce adta.

Induló kérdés: egy Dataset osztály __getitem__-je visszaad egy képet, egy címkét és egy maszkot. Ki garantálja, hogy a DataLoader által összeállított batch-ben nem keveredik a train és a val képe, és hogy egy jelenet két nézete mindig ugyanabba a splitbe kerül?

11.3.2 Dataset, sampler, batch és DataLoader

A PyTorch torch.utils.data moduljában a map-style dataset egy indexelt hozzáférést adó objektum: adott i∈{0,…,N−1}i\in\{0,\ldots,N-1\} indexre visszaadja az ii-edik mintát a __getitem__(i) metódus, a __len__ pedig a minták számát. A GlyphLab esetében egy minta nem csak egy kép, hanem egy négyes:

(kép,label,mask,scene_id), (\text{kép},\ \text{label},\ \text{mask},\ \text{scene\_id}),

ahol a kép alakja (1,24,24)(1,24,24) és float32, a label skalár int64, a mask (1,24,24)(1,24,24) és uint8, a scene_id skalár int64 kód (a metadata.csv szöveges jelenetazonosítójának, például G0-0003-nak a rendezett sorszáma; az eredeti szöveget a dataset scene_names tömbje őrzi). A batch-elés után az alakok (B,1,24,24)(B,1,24,24), (B,)(B,), (B,1,24,24)(B,1,24,24) és (B,)(B,) lesznek.

A sampler az indexek sorozatát adja meg. A SequentialSampler a 0,1,…,N−10,1,\ldots,N-1 sorrendet, a RandomSampler egy véletlen permutációt állít elő; a BatchSampler ezt BB hosszú csoportokra bontja. A DataLoader a dataset, a sampler és a collate_fn összekapcsolása: a sampler indexeit bekéri a datasetből, a visszaadott elemek listáját a collate_fn tenzorrészletekből batchelt tensorokká alakítja. Az alapértelmezett default_collate a tuple minden pozíciójára külön-külön stackel, ezért a skalár labelből (B,)(B,) alakú int64 tensor lesz.

A shuffle=True a DataLoader konstruktorban csak egy rövidítés: a RandomSampler-t kapcsolja be. A shuffle és a sampler argumentum együtt nem adható meg, mert mindkettő ugyanazt az indexelési feladatot oldaná meg. A drop_last=True az utolsó, BB-nél kisebb batch-et eldobja; a fixed batch méretet igénylő batch-normalizáló rétegeknél lehet hasznos, de a GlyphLab esetében 240240, 6060 és 6060 mind osztható 44-gyel, így nincs ilyen maradék.

A worker seed kérdése a párhuzamos betöltéshez tartozik. Ha a num_workers nagyobb nullánál, minden worker külön folyamatban fut, és a véletlen mintavétel állapota nem automatikusan azonos a fő folyamatéval. A reprodukálhatósághoz a DataLoader generator paraméterének vagy a worker_init_fn-nek a használata szükséges; ennek pontos, verzióspecifikus viselkedését a [D08] dokumentációjának olvasott szakasza alapján kell rögzíteni, a kiinduló URL státuszát megtartva. A jelen egység laborja num_workers=0 értékkel fut, így az indexelés a fő folyamatban marad, és a seed kérdése nem befolyásolja az eredményt.

A transform boundary azt a határt jelöli, ahol a kép- és címketranszformáció megtörténik. A véletlen train-augmentáció a train Dataset __getitem__-jében alkalmazandó, mert ott a transzformáció mintánként, a betöltés pillanatában fut. A val és test dataset csak determinisztikus transzformációt kaphat; a geometriai augmentáció a maszkot is együtt kell hogy transzformálja, különben a pixelcímke és a bemenet elcsúszik. A GlyphLab diagonal osztálya csak egy ferde irányt tartalmaz, ezért a tükrözés és a 90 fokos forgatás nem címkemegőrző; ebben az egységben a labor semmilyen augmentációt nem alkalmaz, csak a betöltési szerződést ellenőrzi.

11.3.3 A split-invariáns levezetése

A GlyphLab adatszerződésének központi állítása, hogy a megfigyelési egység a scene_id, nem az egyes kép. Ezt formálisan a következő invariáns írja le.

Állítás (scene-split invariáns). Legyen ℐ={0,…,359}\mathcal I=\{0,\ldots,359\} a képek indexhalmaza, π:ℐ→𝒮\pi:\mathcal I\to\mathcal S a scene_id hozzárendelés, és σ:ℐ→{train,val,test}\sigma:\mathcal I\to\{\text{train},\text{val},\text{test}\} a split hozzárendelés. A GlyphLab generátor olyan τ:𝒮→{train,val,test}\tau:\mathcal S\to\{\text{train},\text{val},\text{test}\} függvényt valósít meg, amelyre minden i∈ℐi\in\mathcal I esetén σ(i)=τ(π(i)). \sigma(i)=\tau(\pi(i)). Ebből következik, hogy minden i,j∈ℐi,j\in\mathcal I-re π(i)=π(j)⇒σ(i)=σ(j). \pi(i)=\pi(j)\;\Longrightarrow\;\sigma(i)=\sigma(j).

Levezetés. Ha π(i)=π(j)=s\pi(i)=\pi(j)=s, akkor σ(i)=τ(π(i))=τ(s)=τ(π(j))=σ(j), \sigma(i)=\tau(\pi(i))=\tau(s)=\tau(\pi(j))=\sigma(j), tehát az azonos scene-hez tartozó képek splitje megegyezik. A τ\tau függvény létezése a generátor scene-szintű felosztásának formális alakja: a splitet először a 180 jelenetre sorsoljuk, majd minden jelenet minden nézetét ehhez a splithez rendeljük. A DataLoader ezt az invariánst nem ronthatja el, mert a sampler csak indexeket permutál, és a három loader külön indexhalmazt kap a split szűrés után. Az invariáns ellenőrzése tehát két részből áll: a metadata.csv scene-szintű konzisztenciájából és abból, hogy a dataset konstruktora a split oszlop alapján szűr, nem keveri a sorokat.

11.3.4 Kézi kis példa

Négy jelenet, jelenetenként két nézet, összesen nyolc kép. A helyes split-hozzárendelés:

ii scene_id π(i)\pi(i) view split σ(i)\sigma(i)
0 0 0 train
1 0 1 train
2 1 0 val
3 1 1 val
4 2 0 test
5 2 1 test
6 3 0 train
7 3 1 train

Itt τ(0)=τ(3)=train\tau(0)=\tau(3)=\text{train}, τ(1)=val\tau(1)=\text{val}, τ(2)=test\tau(2)=\text{test}. Ha a 0. képet train-be, az 1. képet val-ba tennénk, akkor π(0)=π(1)\pi(0)=\pi(1) teljesülne, de σ(0)≠σ(1)\sigma(0)\neq\sigma(1), tehát az invariáns sérülne; ekkor a val loss függő nézeteket tartalmazna a train képektől, és a val metricát optimistán torzítaná.

11.3.5 Labor: GlyphLab betöltése és a szerződés ellenőrzése

Munkakönyvtár: lab/. Az adatfájlok a data/ alkönyvtárban vannak: images.npy, labels.npy, masks.npy, metadata.csv. A labor csak CPU-t és NumPy/PyTorch alapműveleteket használ; seedre a betöltés miatt nincs szükség, mert a betöltés determinisztikus.

# lab/n043_dataset.py
import csv
from pathlib import Path

import numpy as np
import torch
from torch.utils.data import Dataset

class GlyphLabDataset(Dataset):
    """GlyphLab: kép, label, mask és scene_id együtt, split szerint szűrve.

    transform: opcionális függvény, amely a (2,24,24) alakú, egymásra rakott
    [kép, maszk] tensort kapja és ugyanilyet ad vissza; így egy geometriai
    transzformáció a képet és a maszkot együtt, ugyanúgy alakítja át."""
    def __init__(self, root: str = "data", split: str | None = None, transform=None):
        self.root = Path(root)
        self.transform = transform
        images = np.load(self.root / "images.npy")      # (360,1,24,24) float32
        labels = np.load(self.root / "labels.npy")      # (360,) int64
        masks = np.load(self.root / "masks.npy")        # (360,1,24,24) uint8
        self.images = images
        self.labels = labels
        self.masks = masks

        rows = list(csv.reader(open(self.root / "metadata.csv", newline="")))
        header = rows[0]
        expected = ["index", "scene_id", "view_id", "label", "split",
                    "xmin", "ymin", "xmax", "ymax"]
        if header != expected:
            raise ValueError("metadata fejléc nem egyezik")
        recs = rows[1:]
        idx = np.array([int(r[0]) for r in recs], dtype=np.int64)
        if not np.array_equal(idx, np.arange(len(images))):
            raise ValueError("metadata index nem egyezik a tomb indexxel")
        # a scene_id szöveg (pl. "G0-0003"); int64 kódot a rendezett egyedi értékek adnak
        self.scene_names = np.array([r[1] for r in recs], dtype=object)
        _, codes = np.unique(self.scene_names.astype(str), return_inverse=True)
        self.scene_ids = codes.astype(np.int64)
        self.splits = np.array([r[4] for r in recs], dtype=object)

        if split is not None:
            keep = self.splits == split
            self.images = self.images[keep]
            self.labels = self.labels[keep]
            self.masks = self.masks[keep]
            self.scene_ids = self.scene_ids[keep]
            self.scene_names = self.scene_names[keep]
            self.splits = self.splits[keep]

    def __len__(self):
        return len(self.images)

    def __getitem__(self, i):
        img = torch.from_numpy(self.images[i])
        lab = torch.tensor(self.labels[i], dtype=torch.int64)
        msk = torch.from_numpy(self.masks[i])
        sid = torch.tensor(self.scene_ids[i], dtype=torch.int64)
        if self.transform is not None:
            joint = self.transform(torch.cat([img, msk.to(img.dtype)], dim=0))  # (2,24,24)
            img, msk = joint[:1].contiguous(), joint[1:].round().to(torch.uint8)
        return img, lab, msk, sid

Az ellenőrző szkript a split-invariánst is teszteli:

# lab/n043_check.py
import csv
from collections import defaultdict

from torch.utils.data import DataLoader
from n043_dataset import GlyphLabDataset

if __name__ == "__main__":
    ds_train = GlyphLabDataset("data", split="train")
    ds_val = GlyphLabDataset("data", split="val")
    ds_test = GlyphLabDataset("data", split="test")
    print("hossz:", len(ds_train), len(ds_val), len(ds_test))

    img, lab, msk, sid = ds_train[0]
    print("elem:", img.shape, img.dtype, lab.shape, lab.dtype,
          msk.shape, msk.dtype, sid.shape, sid.dtype)
    print("tartomany:", float(img.min()), float(img.max()),
          int(msk.min()), int(msk.max()))

    loader = DataLoader(ds_train, batch_size=4, shuffle=False)
    batch = next(iter(loader))
    for name, x in zip(["image", "label", "mask", "scene_id"], batch):
        print(name, tuple(x.shape), x.dtype)

    scene_split = {}
    with open("data/metadata.csv", newline="") as f:
        for r in list(csv.reader(f))[1:]:
            s, sp = r[1], r[4]          # scene_id szövegként
            if s in scene_split and scene_split[s] != sp:
                raise SystemExit("INVARIANTS SERUL")
            scene_split[s] = sp
    print("egyedi scene:", len(scene_split))
    splits = defaultdict(int)
    for s in scene_split.values():
        splits[s] += 1
    print("scene per split:", dict(splits))

A futtatási parancs a lab/ munkakönyvtárból:

python n043_check.py

A lab-profilban (PyTorch 2.14.1+cpu) mért kimenet:

hossz: 240 60 60
elem: torch.Size([1, 24, 24]) torch.float32 torch.Size([]) torch.int64 torch.Size([1, 24, 24]) torch.uint8 torch.Size([]) torch.int64
tartomany: 0.0 0.9264628887176514 0 1
image (4, 1, 24, 24) torch.float32
label (4,) torch.int64
mask (4, 1, 24, 24) torch.uint8
scene_id (4,) torch.int64
egyedi scene: 180
scene per split: {'train': 120, 'val': 30, 'test': 30}

Ez egyezik a szerződéssel: a hosszak 240,60,60240,60,60; az elem alakjai (1,24,24)(1,24,24) float32, ()() int64, (1,24,24)(1,24,24) uint8 és ()() int64; a batch alakjai (4,1,24,24)(4,1,24,24), (4,)(4,), (4,1,24,24)(4,1,24,24), (4,)(4,); a képpixelek a [0,1][0,1] intervallumban, a maszkértékek {0,1}\{0,1\}-ben vannak; az egyedi scene-k száma 180180, és a scene-k splitenkénti száma {train:120,val:30,test:30}\{\text{train}:120,\text{val}:30,\text{test}:30\}.

11.3.6 Feladatok

F1 (fogalmi, nehézség 1). Magyarázza el a Dataset és a DataLoader különbségét. Miért nem elegendő önmagában egy Dataset a minibatch-tanításhoz? Adjon egy kis ellenpéldát, ahol a Dataset indexelése működik, de a batchelt iteráció hibát adna.

F2 (levezetési, nehézség 2). Hat képhez adott a következő scene-hozzárendelés: π(0)=π(1)=0\pi(0)=\pi(1)=0, π(2)=1\pi(2)=1, π(3)=π(4)=π(5)=2\pi(3)=\pi(4)=\pi(5)=2, és a view indexek rendre 0,1,0,0,1,20,1,0,0,1,2. Adjon egy olyan split-hozzárendelést, amely teljesíti a scene-split invariánst, és egy olyat, amely sérti. Indokolja mindkettőt a τ\tau függvény segítségével.

F3 (implementációs, nehézség 2). Írja meg a GlyphLabDataset osztályt a fenti szerződés szerint, majd ellenőrizze: a train dataset hossza 240240; a ds_train[0] elemeinek alakja és dtype-ja pontosan (1,24,24)(1,24,24) torch.float32, ()() torch.int64, (1,24,24)(1,24,24) torch.uint8, ()() torch.int64; a DataLoader(ds_train, batch_size=4, shuffle=False) első batch-ének alakjai (4,1,24,24)(4,1,24,24), (4,)(4,), (4,1,24,24)(4,1,24,24), (4,)(4,). A dtype-okra pontos egyezés, a shape-ekre pontos egyezés a tolerancia.

F4 (hibakeresési, nehézség 2). A következő kódrészlet a val loaderhez véletlen tükrözést ad:

def random_hflip(x, p=0.5):
    # véletlen vízszintes tükrözés a (2,24,24) [kép, maszk] tensoron
    return torch.flip(x, dims=(-1,)) if torch.rand(()) < p else x

val_ds = GlyphLabDataset("data", split="val", transform=random_hflip)
val_loader = DataLoader(val_ds, batch_size=4, shuffle=False)

Azonosítsa a hibát, írja le a következményét a validation loss-ra és a korrupt teszt páros összevetésére, és adja meg a javítást. Hogyan ellenőrizné vissza, hogy a javítás után a val loader determinisztikus?

F5 (kísérleti/transzfer, nehézség 3). Tervezzen egy olyan kísérletet, amely a meglévő GlyphLab splittől független, új scene-szintű train/val/test felosztást készít 70/15/15 arányban, majd a tanítás után a test accuracy-t méri. Adja meg a protokollt: megfigyelési egység, baseline, változó tényező, metrika, erőforrás és a következtetés érvényességi korlátja. Nem szükséges futtatni; a terv legyen végrehajtható.

11.3.7 Megoldások és indoklás

F1. A Dataset egy indexelt adathozzáférés: az ii indexre mintát ad, de nem tud batch méretet, sorrendet vagy párhuzamos betöltést kezelni. A DataLoader a sampler által adott indexsorozatot bekéri, a mintákat a collate_fn segítségével batchelt tensorokká alakítja, és opcionálisan több workerrel dolgozik. Ellenpélda: ha a Dataset.__getitem__ különböző hosszúságú szekvenciákat ad vissza, az indexelés működik, de az alapértelmezett default_collate nem tudja stackelni a különböző hosszúságú tenzorokat, ezért a DataLoader iterációja hibát ad; ilyenkor egyedi collate_fn vagy padding szükséges.

F2. A helyes τ\tau függvény például τ(0)=train\tau(0)=\text{train}, τ(1)=val\tau(1)=\text{val}, τ(2)=test\tau(2)=\text{test}. Ekkor a split-hozzárendelés: 0,1→0,1\to train; 2→2\to val; 3,4,5→3,4,5\to test. Az invariáns teljesül, mert azonos scene-hez tartozó indexek azonos splitet kapnak. Sértő példa: σ(0)=train\sigma(0)=\text{train} és σ(1)=val\sigma(1)=\text{val}, miközben π(0)=π(1)=0\pi(0)=\pi(1)=0. Itt nem létezhet olyan τ\tau, amelyre σ(i)=τ(π(i))\sigma(i)=\tau(\pi(i)) minden ii-re, mert τ(0)\tau(0) egyszerre train és val lenne; ezért a levezetett invariáns sérül.

F3. A megoldás a fenti n043_dataset.py és n043_check.py. Az ellenőrzés kulcsa, hogy a torch.from_numpy megőrzi a NumPy dtype-ot: float32 →\to torch.float32, uint8 →\to torch.uint8. A label és a scene_id explicit torch.tensor(..., dtype=torch.int64) módon int64 lesz, így a default_collate stackelés után is int64 marad. A shape-ek pontos egyezése azt jelenti, hogy nincs engedmény: (1,24,24)(1,24,24) nem lehet (24,24)(24,24). A futtatási parancs python n043_check.py a lab/ könyvtárból.

F4. A hiba: a val dataset transzformációja véletlen augmentáció, ami a transform boundary megsértése. A random_hflip p=0,5p=0{,}5 valószínűséggel tükrözi a képet (a __getitem__ a transform-ot ténylegesen meghívja, a maszkkal együtt); a GlyphLab diagonal osztálya csak egy ferde irányt tartalmaz, ezért a tükrözés a címke geometriai jelentését megváltoztatja, a vertical és horizontal pedig felcserélődik. Következmény: a val loss nem determinisztikus, a best checkpoint kiválasztása zajos; a clean és corrupt teszt páros összevetése is torzul, mert a két nézet nem ugyanazt a véletlen tükrözést kapja. Javítás: a val dataset ne kapjon véletlen transzformációt (transform=None); csak determinisztikus normalizálást, vagy semmit. Visszaellenőrzés ismételt __getitem__-hívásokkal és két teljes iterációval:

torch.manual_seed(0)
bad = GlyphLabDataset("data", split="val", transform=random_hflip)
print(any(not torch.equal(bad[0][0], bad[0][0]) for _ in range(20)))   # True: véletlen
good = GlyphLabDataset("data", split="val")                                # javítva
print(all(torch.equal(good[0][0], good[0][0]) for _ in range(20)))     # True: determinisztikus
a = torch.cat([b[0] for b in DataLoader(good, batch_size=4)])
c = torch.cat([b[0] for b in DataLoader(good, batch_size=4)])
print(torch.equal(a, c))                                                  # True

F5. A kísérleti terv: a 180 egyedi scene_id-ból rétegzett véletlen mintavétellel készítünk 126 train, 27 val és 27 test jelenetet, majd minden jelenet mindkét nézetét a jelenet splitjéhez rendeljük; ez a scene-split invariánst megtartja. A baseline a meglévő, gyárilag rögzített 120/30/30 scene-felosztás; a változó tényező csak a split sorsolása, a modell és a tanítási recept azonos. A metrika a test accuracy; az erőforrás kis CPU, a modell a korábbi kis CNN vagy MLP. Az érvényességi korlát: a GlyphLab 180 jelenete kicsi, ezért a test accuracy szórása nagy; egyetlen új split nem általánosít, a konfidenciaintervallumot több seedelt split ismétlésével kell becsülni, és az eredmény nem természetes képi benchmark.

11.3.8 Tipikus tévedés és ellenpélda

A leggyakoribb hiba a validation/test loaderen alkalmazott véletlen train-augmentáció. Ha a val vagy test dataset __getitem__-je véletlen tükrözést, forgatást vagy kivágást kap, akkor az értékelés nem a modell képességét, hanem egy véletlen transzformációval zajosított bemenetet mér. A páros korrupt teszt összevetése különösen érzékeny: a clean és corrupt kép ugyanazon jelenet és nézet párja, ezért a geometriai transzformációnak is azonosnak kell lennie; a véletlen augmentáció ezt a párosítást felbontja, és a clean–corrupt különbséghez mesterséges varianciát ad. A transform boundary tehát split-függő szerződés: véletlen transzformáció csak a train datasetben, a val/test datasetben kizárólag determinisztikus előfeldolgozás.

11.3.9 Önellenőrzés és korábbi fogalom visszahívása

Ellenőrizze, hogy a dataset konstruktora a split oszlop alapján szűr, és a metadata.csv fejlécét, valamint az indexsorrendet is validálja. A DataLoader batch alakjait a default_collate szabálya szerint vezesse le: a tuple első eleme (1,24,24)(1,24,24), a stackelés után (B,1,24,24)(B,1,24,24); a skalár labelből (B,)(B,) lesz. A 11.2. nn.Module és paraméterregisztráció alfejezetben bevezetett state_dict most az adatbetöltővel együtt lesz használható a tanítási lépésben; a 7.3. Bináris, multilabel és regressziós cél alfejezetben rögzített int64 célalak és logits-bemenet szerződése a 11.4. Egy teljes PyTorch tanítási iteráció alfejezetben találkozik a betöltött batch-ekkel.

11.3.10 Rövid összefoglaló

A Dataset indexelt mintahozzáférést ad, a DataLoader a sampler indexsorozatát batchelt tensorokká alakítja a collate_fn segítségével. A GlyphLab scene-split invariánsa formálisan azt mondja ki, hogy azonos scene_id-hez tartozó képek splitje megegyezik; a τ\tau függvény létezése biztosítja, hogy a split scene-szinten, nem képenként dől el. A labor betölti a train/val/test dataseteket, ellenőrzi a hosszakat, a dtype-okat és az alakokat, valamint a metadata scene-konzisztenciáját. A transform boundary szerint véletlen augmentáció csak a train datasetben megengedett; a val/test loader determinisztikus transzformációt kaphat. A következő 11.4. Egy teljes PyTorch tanítási iteráció egységben ezt a betöltőt kapcsoljuk össze a tanítási iterációval és a loss-számítással.

11.3.11 Források