28.2 Tanítási ciklus és adatbetöltés

A Tensor, device és autograd alfejezetben megismert tenzor, dtype, device és autograd most egyetlen ismétlődő munkafolyamatba áll össze: a tanítási ciklusba. A ciklus szereposztása egyszerű, de a részek felcserélése vagy elhagyása csendes, nehezen észrevehető hibákat okozhat.

28.2.1 Cél és konkrét helyzet

Egy kis szintetikus regressziós feladatot oldunk meg: a bemenet X∈ℝn×2X\in\mathbb{R}^{n\times 2}, két folytonos feature-rel; a cél y∈ℝny\in\mathbb{R}^n, folytonos érték, mértékegység nélküli szimulált szám. A megfigyelési egység egy sor, tehát egy (xi,yi)(x_i,y_i) pár. A generáló folyamat lineáris: yi=1.8xi1−0.7xi2+0.3+εiy_i=1.8x_{i1}-0.7x_{i2}+0.3+\varepsilon_i, ahol εi\varepsilon_i független, σ=0.5\sigma=0.5 szórású normális zaj. A cél egy PyTorch lineáris réteg betanítása minibatch-gradiens módszerrel, majd ugyanazon train/test felosztáson összehasonlítani a sklearn.linear_model.LinearRegression baseline-nal.

Előzetes becslés: a lineáris baseline teszt RMSE-je a zaj miatt σ=0.5\sigma=0.5 körül várható, így a jól konvergált PyTorch modell teszt RMSE-jének is 0.5 közelében kell lennie. A két modell közötti eltérés elsősorban az optimalizáló befejezetlenségéből és a numerikus különbségből adódik; a split azonossága az összehasonlítás feltétele.

28.2.2 Fogalmi magyarázat: Dataset, DataLoader és a gradienslépés

Dataset. A torch.utils.data.Dataset egy indexelhető gyűjtemény: tudnia kell a méretét (__len__) és az ii-edik elemet (__getitem__). Nem kötelező az egész adatot tenzorrá alakítani, de kis CPU-s példában ez a legegyszerűbb. A targetet (n,1)(n,1) alakúra hozzuk, mert a veszteségfüggvény a predikcióval azonos shape-et vár.

class RegressionDataset(Dataset):
    def __init__(self, X, y):
        self.X = torch.tensor(X, dtype=torch.float32)
        self.y = torch.tensor(y, dtype=torch.float32).unsqueeze(1)

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

    def __getitem__(self, idx):
        return self.X[idx], self.y[idx]

DataLoader. A DataLoader a Datasetet batch-ekre osztja, és opcionálisan keveri (shuffle=True). A batch a tanításhoz használt részhalmaz; az epoch a teljes tanítóhalmaz egyszeri végigjárása. Ha batch_size=32, akkor egy 960 soros tanítóhalmazból egy epoch 30 batchből áll. A shuffle minden epoch elején új sorrendet állít elő, ezért a batch-összetétel epochról epochra változik. Fontos: a splitet már a Dataset létrehozása előtt elvégeztük; a DataLoader nem végez modellértékelési felosztást.

Veszteség és sztochasztikus gradiens. A teljes tanítóhalmazra vett átlagos négyzetes hiba

L(θ)=1n∑i=1n(fθ(xi)−yi)2, L(\theta)=\frac{1}{n}\sum_{i=1}^{n}\left(f_\theta(x_i)-y_i\right)^2,

ahol θ\theta a modell összes paramétere, lineáris modellnél fθ(x)=w⊤x+bf_\theta(x)=w^\top x+b, w∈ℝ2w\in\mathbb{R}^2, b∈ℝb\in\mathbb{R}. Egy BB batchre

LB(θ)=1|B|∑i∈B(fθ(xi)−yi)2. L_B(\theta)=\frac{1}{|B|}\sum_{i\in B}\left(f_\theta(x_i)-y_i\right)^2.

A gradiens a parciális deriváltak vektora, például a ww-re:

∇wLB=2|B|∑i∈B(fθ(xi)−yi)xi. \nabla_w L_B=\frac{2}{|B|}\sum_{i\in B}\left(f_\theta(x_i)-y_i\right)x_i.

A minibatch-gradiens a teljes adathalmaz gradiensének véletlen mintából számolt, torzítatlan becslése; ezért „sztochasztikus”. Minél kisebb a batch, annál zajosabb a becslés, de annál kevesebb memória kell; a nagyobb batch simább, de azonos epochszám alatt kevesebb paraméterfrissítést jelent.

Optimizer, zero_grad, step. Az optimizer tárolja a paraméterekre vonatkozó állapotot, és elvégzi a frissítést:

θt+1=θt−ηgt, \theta_{t+1}=\theta_t-\eta g_t,

ahol η\eta a tanulási sebesség (learning rate), gtg_t pedig az aktuális batch veszteségének gradiense. A loss.backward() a gradienst hozzáadja a paraméterek .grad mezőjéhez, ezért minden batch előtt optimizer.zero_grad() hívás kell. Ha ezt elhagyjuk, a korábbi batch gradiensei halmozódnak, és a lépés nem az adott batch veszteségét minimalizálja.

train/eval mód. A model.train() és model.eval() a modul training állapotát állítja. A lineáris rétegnél nincs hatása, de a Dropout és a BatchNorm rétegeknél igen: tanítás közben a dropout véletlenszerűen nulláz, kiértékeléskor viszont identitásként viselkedik; a BatchNorm tanítás közben a batch statisztikákat, kiértékeléskor a futó statisztikákat használja. Ezt a későbbi ellenpélda mutatja.

28.2.3 Teljes minimális munkapélda

A példa CPU-n fut. A torch és scikit-learn csomagot a fejezet bővítő környezetébe kell telepíteni; az Interpreter és virtuális környezet alfejezetben bemutatott projektmódszerrel külön környezetet érdemes használni. Az alábbi kódot train_loop.py néven mentve, a munkakönyvtárból a python train_loop.py paranccsal futtathatjuk.

import numpy as np
import torch
from torch import nn
from torch.utils.data import DataLoader, Dataset
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_squared_error

# 1. Szintetikus adat
rng = np.random.default_rng(42)
n = 1200
X = rng.normal(0.0, 1.0, size=(n, 2))
y = 1.8 * X[:, 0] - 0.7 * X[:, 1] + 0.3 + rng.normal(0.0, 0.5, size=n)

# 2. Azonos split mindkét modellhez
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42
)

# 3. Dataset és DataLoader
class RegressionDataset(Dataset):
    def __init__(self, X_data, y_data):
        self.X = torch.tensor(X_data, dtype=torch.float32)
        self.y = torch.tensor(y_data, dtype=torch.float32).unsqueeze(1)

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

    def __getitem__(self, idx):
        return self.X[idx], self.y[idx]

train_ds = RegressionDataset(X_train, y_train)
test_ds = RegressionDataset(X_test, y_test)

# A DataLoader reprodukálható keveréséhez saját generátort adunk
torch_gen = torch.Generator().manual_seed(0)
train_loader = DataLoader(train_ds, batch_size=32, shuffle=True, generator=torch_gen)

# 4. Modell, veszteség, optimalizáló
torch.manual_seed(0)
model = nn.Linear(2, 1)
loss_fn = nn.MSELoss()
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)

# 5. Tanítási ciklus
n_epochs = 40
for epoch in range(n_epochs):
    model.train()
    epoch_loss = 0.0
    for Xb, yb in train_loader:
        optimizer.zero_grad()
        pred = model(Xb)
        loss = loss_fn(pred, yb)
        loss.backward()
        optimizer.step()
        epoch_loss += loss.item() * len(yb)
    epoch_loss /= len(train_ds)

# 6. Kiértékelés eval módban, gradiensgráf nélkül
model.eval()
with torch.no_grad():
    pred_train = model(train_ds.X)
    pred_test = model(test_ds.X)
    pt_train_rmse = torch.sqrt(loss_fn(pred_train, train_ds.y)).item()
    pt_test_rmse = torch.sqrt(loss_fn(pred_test, test_ds.y)).item()

# 7. sklearn baseline ugyanazon a spliten
lr = LinearRegression().fit(X_train, y_train)
sk_test_rmse = float(np.sqrt(mean_squared_error(y_test, lr.predict(X_test))))

print(f"PyTorch train RMSE: {pt_train_rmse:.3f}")
print(f"PyTorch test  RMSE: {pt_test_rmse:.3f}")
print(f"sklearn test  RMSE: {sk_test_rmse:.3f}")

A TensorBoard-naplózás opcionális kiegészítő. Az nn.MSELoss átlagos négyzetes hibát ad; az RMSE ennek gyöke, ezért az epoch átlagveszteségéből a gyököt vonva kapjuk a train RMSE-t. A torch.no_grad() blokk alatt a PyTorch nem építi a számítási gráfot, így a kiértékelés gyorsabb és nem foglal fölösleges memóriát. A futás előtt a „nem futtatott” jelleg miatt az alábbi kimenet szemléltető, a seedtől és a konvergenciától függő:

PyTorch train RMSE: 0.502
PyTorch test  RMSE: 0.508
sklearn test  RMSE: 0.511

A TensorBoardhoz tartozó minimális vázlat:

from torch.utils.tensorboard import SummaryWriter

writer = SummaryWriter("runs/linear_baseline")
# a tanítási ciklusban, minden epoch után:
writer.add_scalar("loss/train_epoch", epoch_loss, epoch)
writer.close()

A görbe megtekintéséhez külön terminálablakban, a projektgyökérből: tensorboard --logdir runs. A pontos telepítési és indítási lépések a helyi Python-környezettől függenek; a tensorboard csomagot a futtató környezetbe külön kell telepíteni.

28.2.4 Eredmény értelmezése

A szemléltető RMSE-értékek azt mutatják, hogy a PyTorch lineáris réteg és a sklearn lineáris regresszió ugyanazon a spliten közel azonos hibát ad. Ez várható, mert mindkettő ugyanazt a lineáris hipotézist és négyzetes veszteséget használja; a sklearn zárt alakú legkisebb négyzetek megoldást számol, a PyTorch pedig iteratív gradienslépésekkel közelíti ugyanazt a minimumot. A teszt RMSE 0.5 körüli értéke a generáló zaj szórásának felel meg: ezt a hibát akkor sem tudjuk eltüntetni, ha a súlyokat pontosan visszanyerjük, mert a cél magában hordozza a σ=0.5\sigma=0.5 szórású zajt.

A közel egyezés azonban nem igazolja, hogy a tanítási ciklus minden eleme helyes; ha például a teszt sorok beszivárognának a tanításba, a hiba akár ennél is alacsonyabbnak tűnhetne. A regressziós súly értéke itt nem oksági következtetés: a Tesztválasztás és hatásméret tanulsága szerint a becsült együttható nem bizonyítja, hogy a feature okozza a cél változását.

A model.train() és model.eval() eltérő viselkedést okozhat dropout és batch normalization mellett; validáció során használjunk torch.no_grad() vagy torch.inference_mode() kontextust.

28.2.5 Feladatok

  1. Értelmezés. Egy tanítási ciklus két batchből áll, a tanulási sebesség η=1\eta=1. Az első batch gradiense g1=0.6g_1=0.6, a másodiké g2=−0.2g_2=-0.2. Számold ki a paraméter teljes változását (a) helyes zero_grad hívással, (b) a zero_grad elhagyásával. Mi a hiba tartalmi jelentése?

  2. Implementálás. Írj eval_rmse(model, loader, loss_fn) függvényt, amely model.eval() módban, torch.no_grad() alatt kiszámolja egy DataLoader adatainak RMSE-jét. A függvény ne építsen gradiensgráfot. Figyelj arra, hogy az utolsó batch mérete kisebb is lehet.

  3. Hibadiagnózis. Egy tanuló a következőt írta:

X_all = torch.tensor(X, dtype=torch.float32)
y_all = torch.tensor(y, dtype=torch.float32).unsqueeze(1)
train_ds = TensorDataset(X_all, y_all)  # az egész X, nem X_train
# ...
sk_model = LinearRegression().fit(X_train, y_train)

Nevezd meg azt a két problémát, amely az összehasonlítást érvénytelenné teszi, és add meg a javítást.

  1. Megváltozott feltétel. Futtasd a példát batch_size=1, batch_size=32 és batch_size=512 értékekkel, azonos lr=0.01, n_epochs=40 mellett. Rögzítsd az utolsó epoch train RMSE-jét és a teszt RMSE-t. Melyik beállításnál várható a legzajosabb tanítási görbe, és miért nem garantálja a legnagyobb batch a legjobb teszt RMSE-t? A válaszban nevezd meg a rögzítendő feltételeket is.

28.2.6 Megoldások és indoklás

  1. Helyes zero_grad esetén az első frissítés −0.6-0.6, a második −(−0.2)=+0.2-(-0.2)=+0.2, összesen −0.4-0.4. Elhagyás esetén az első lépés után is −0.6-0.6, de a második batch előtt a gradiens nem nullázódik, így a .grad értéke 0.6−0.2=0.40.6-0.2=0.4 lesz, és a második frissítés −0.4-0.4. Az összes változás −1.0-1.0 a helyes −0.4-0.4 helyett. A hiba az, hogy a frissítés nem az aktuális batch veszteségének gradiensét, hanem a felhalmozott gradienst használja; a modell rosszabb lépést tesz, mintha két batch együttes, de nem skálázott veszteségét minimalizálná.

  2. Példa megoldás:

def eval_rmse(model, loader, loss_fn):
    model.eval()
    total_sq = 0.0
    n = 0
    with torch.no_grad():
        for Xb, yb in loader:
            pred = model(Xb)
            total_sq += loss_fn(pred, yb).item() * len(yb)
            n += len(yb)
    return (total_sq / n) ** 0.5

Az invariáns: a loss_fn(pred, yb) a batch átlagos MSE-je, ezért a len(yb)-vel való szorzás adja a batch négyzetes hibák összegét; a teljes RMSE ennek a teljes elemszámra vett átlagából számított gyök. Tolerancia: a függvénynek a tanításhoz használt teljes epoch-veszteség számításával azonos eredményt kell adnia ugyanazon adat és modell esetén. A hívás után a tanítás folytatásához model.train() szükséges.

  1. Az első probléma a tesztadat beszivárgása: a train_ds az egész X-et tartalmazza, tehát a PyTorch modell olyan sorokon is tanul, amelyek a tesztben szerepelnek. Ez optimista torzítást okoz, és sérti a Tanítás, validáció és érintetlen teszt alfejezetben megfogalmazott érintetlen teszt elvét. A második probléma az összehasonlítás ellentmondásossága: a sklearn modell X_train-ből tanul, a PyTorch modell X-ből, így a két modell nem ugyanazon a tanítóhalmazon értékelhető. Javítás: mindkét modell kizárólag X_train-ből tanuljon, a tesztelés pedig kizárólag X_test-ből; a TensorDataset-et a train_test_split eredményéből kell felépíteni.

  2. Azonos adatgeneráló folyamat és szokásos mintavételi feltételek mellett a batch_size=1 tipikusan nagyobb varianciájú gradiensbecslést ad, ezért a tanítási görbe erősen ugrál, és az utolsó epoch train RMSE-je kevésbé informatív. A batch_size=512 simább gradienslépéseket ad, de a körülbelül 960 soros tanítóhalmazon egy epoch alatt csak egy vagy két frissítés történik; 40 epoch alatt ez lényegesen kevesebb lépés, mint a 32-es batchnél. Ezért előfordulhat, hogy a legnagyobb batch nem éri el ugyanazt a megoldást. Az összehasonlításhoz rögzíteni kell a seedet, az epochok számát vagy a frissítések számát, a tanulási sebességet, a hardvert és a könyvtárverziókat; az egyetlen futásból származó RMSE-különbség nem önmagában bizonyíték.

28.2.7 Gyakori tévedés ellenpéldával

A model.eval() elmaradása akkor okoz feltűnő hibát, ha a modell tartalmaz dropout vagy BatchNorm réteget. Példaként egy extrém, didaktikus dropout:

class DropoutModel(nn.Module):
    def __init__(self):
        super().__init__()
        self.drop = nn.Dropout(p=1.0)
        self.linear = nn.Linear(1, 1)

    def forward(self, x):
        return self.linear(self.drop(x))

Tanítási módban a p=1.0 dropout minden bemeneti elemet nulláz, ezért a modell csak a bias-t tanulja; kiértékelési módban a dropout kikapcsol, és a bemenet átjut. Ha a tesztelésnél elmarad az eval(), a tesztpredikciók a tanítási viselkedést követik, és az RMSE eltérhet a helyes értéktől. Az eltérés iránya nem általánosítható: a dropout miatt egyes mintákon romolhat, másokon akár javulhat is a mérőszám. A szokásos p=0.1p=0.1–0.50.5 tartományban a hatás kevésbé feltűnő, de az értékelési protokoll így is hibás. A lineáris példánkban nincs dropout, így a hiba ott nem jelentkezett volna; a train/eval szokást mégis minden modellnél be kell tartani.

Önellenőrzés: a torch.no_grad() nem helyettesíti az eval() hívást. A no_grad csak a gradiensgráf építését kapcsolja ki, de a dropout és BatchNorm train-viselkedése a modul training állapotától függ. A kettő külön célt szolgál, és teszteléskor általában együtt van rá szükség.

28.2.8 Rövid összefoglaló

A tanítási ciklus egy epoch során DataLoader-batch-eken iterál, minden batch előtt nullázza a gradienst, kiszámítja a veszteséget, a backward hívással gradienst számol, végül az optimizer frissíti a paramétereket. A batch-gradiens a teljes adat gradiensének zajos becslése; a batch méret a gradiens zaját és a lépésszámot is befolyásolja. A kiértékelést eval módban és gradiensgráf nélkül végezzük, mert a Dropout és BatchNorm viselkedése eltér tanítás közben. A lineáris réteg ugyanazon a spliten közel a sklearn lineáris baseline hibáját éri el, ami az optimalizálás és az invariáns ellenőrzésére használható. A következő lépés a generalizáció és a gyorsító választás felé vezet.