11.4 Egy teljes PyTorch tanítási iteráció

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

Ez az egység a 11.3. Dataset és DataLoader a split után adatbetöltőjét kapcsolja össze a tanítási ciklussal. Egy batchre felírjuk a teljes PyTorch-lépést: zero_grad, forward, loss, backward, gradiens-ellenőrzés, optimizer.step. A cél annak pontos megértése, hogy az autograd által előállított gradiens mikor és hogyan válik paraméterfrissítéssé.

Előismeret: 11.3. Dataset és DataLoader a split után Dataset/DataLoader, 11.2. nn.Module és paraméterregisztráció nn.Module, 11.1. Tensor és autograd-életciklus autograd-életciklus. Visszautalás: 10.4. Tanítási lépés és tanuló MLP a kézzel írt tanítási lépés, 7.4. Numerikus stabilitás és jelentésmegőrző implementáció a stabil loss-számítás.

Induló kérdés: ha loss.backward() sikeresen lefutott, ettől miért maradhat a súly változatlan?

11.4.2 A tanítási lépés mint sorrendi szerződés

Egy batchre a következő hívássorrendet rögzítjük:

1. optimizer.zero_grad()       # a .grad tárolók nullázása
2. logits = model(x)           # forward
3. loss = criterion(logits, y) # skalár loss
4. loss.backward()             # gradiens a levelek .grad mezőjében
5. (opcionális) gradiens-ellenőrzés / clip
6. optimizer.step()            # paraméterfrissítés

Az 1. lépés azért előzi meg a 4.-et, mert a backward a .grad mezőben akkumulál, nem felülír. Egy elmaradt zero_grad hatására több batch gradiense összeadódik, ami nem az aktuális minibatch SGD-frissítés.

11.4.3 Definíciók, alakok és a kapcsolódó objektumok

Logits. Az affin réteg aktiváció előtti kimenete Z∈ℝN×KZ\in\mathbb R^{N\times K}. Egy osztályozó fejben Z=XW⊤+b, Z = XW^\top + b, ahol X∈ℝN×dX\in\mathbb R^{N\times d}, W∈ℝK×dW\in\mathbb R^{K\times d}, b∈ℝKb\in\mathbb R^K. A logit nem valószínűség, és a célvektor y∈{0,…,K−1}Ny\in\{0,\ldots,K-1\}^N osztályindexekből áll.

Loss. A főúton a [D07] szerinti CrossEntropyLoss-t használjuk logits bemenettel és int64 célindexekkel; softmaxot nem hívunk külön. A reduction="mean" konvenció: L(Z,y)=−1N∑i=1Nlogezi,yi∑k=1Kezi,k. L(Z,y) = -\frac1N\sum_{i=1}^N \log\frac{e^{z_{i,y_i}}}{\sum_{k=1}^K e^{z_{i,k}}}. A skalár loss egy zéró dimenziós tensor. A 7.4. Numerikus stabilitás és jelentésmegőrző implementáció alfejezetben tárgyalt logsumexp-stabilizálást a könyvtári implementáció végzi; a levezetésben a matematikai képlettel dolgozunk.

zero_grad. Az optimizer minden hozzárendelt paraméter .grad tárolóját nullázza vagy None-ra állítja. A pontos viselkedés a set_to_none opciótól függ; a labor a visszaellenőrzésben a gradiens értékét, nem a None-kezelést teszteli.

backward. A zéró dimenziós lossból kiindulva reverse-mode autograddal kiszámítja a gráf leveleinek gradiensét. A levelek itt a modell paraméterei, tehát p.grad alakja megegyezik p alakjával. A backward után a számítási gráf alapértelmezésben felszabadul.

optimizer.step. Az éppen tárolt .grad értékek alapján frissíti a paramétereket. Az alap torch.optim.SGD momentum nélkül a θt+1=θt−αgt \theta_{t+1} = \theta_t - \alpha g_t frissítést végzi, ahol α\alpha a lr, gt=∇θL(θt)g_t=\nabla_\theta L(\theta_t).

Véges gradiens (finite gradient). Egy paraméter gradiensét végesnek nevezzük, ha minden eleme véges lebegőpontos érték, azaz torch.isfinite(g).all() igaz. A NaN és a ±∞\pm\infty nem véges; ilyen értékkel az SGD-frissítés is nem véges paramétereket adna, ezért a lépés előtt megállítjuk a futást.

11.4.4 Állítás: a backward gradiens és az SGD-frissítés levezetése

A levezetési kötelezettséghez először a veszteség logits szerinti gradiensét vezetjük le, majd ebből a lineáris réteg paramétergradienseit.

Állítás (elemi levezetés). Legyen pik=softmax(Z)ikp_{ik}=\operatorname{softmax}(Z)_{ik}, és legyen Yikonehot=𝟙k=yiY^{\text{onehot}}_{ik}=\mathbb{1}_{k=y_i}. A fenti LL mellett ∂L∂zik=1N(pik−Yikonehot). \frac{\partial L}{\partial z_{ik}} = \frac1N\left(p_{ik}-Y^{\text{onehot}}_{ik}\right).

Levezetés. Rögzítsük az ii megfigyelést. A softmax deriváltja ∂pik∂zil=pik(δkl−pil), \frac{\partial p_{ik}}{\partial z_{il}} = p_{ik}(\delta_{kl}-p_{il}), mert a pikp_{ik} tört deriváltjában a számláló és a nevező tagjai adják a két tagot. Mivel Li=−logpi,yiL_i=-\log p_{i,y_i}, ∂Li∂zik=−1pi,yi∂pi,yi∂zik. \frac{\partial L_i}{\partial z_{ik}} = -\frac{1}{p_{i,y_i}} \frac{\partial p_{i,y_i}}{\partial z_{ik}}. Ha k=yik=y_i, akkor ∂pi,yi/∂zik=pik(1−pik)\partial p_{i,y_i}/\partial z_{ik}=p_{ik}(1-p_{ik}), és a hányados 1−pik1-p_{ik}. Ha k≠yik\neq y_i, akkor a derivált −pikpi,yi-p_{ik}p_{i,y_i}, és a hányados −pik-p_{ik}. A két eset együtt: ∂Li∂zik=pik−𝟙k=yi. \frac{\partial L_i}{\partial z_{ik}} = p_{ik}-\mathbb{1}_{k=y_i}. Az NN megfigyelésre átlagolt loss miatt az 1/N1/N szorzó megjelenik. ▫\square

Legyen Gik=1N(pik−Yikonehot),G∈ℝN×K. G_{ik}=\frac1N(p_{ik}-Y^{\text{onehot}}_{ik}), \quad G\in\mathbb R^{N\times K}. Mivel zik=∑jxijWkj+bkz_{ik}=\sum_j x_{ij}W_{kj}+b_k, a láncszabályból: ∇WL=G⊤X∈ℝK×d,∇bL=G⊤𝟏N∈ℝK. \nabla_W L = G^\top X \in \mathbb R^{K\times d}, \qquad \nabla_b L = G^\top \mathbf 1_N \in \mathbb R^K. A második képletből látszik, hogy ebben az utolsó lineáris rétegben a bias gradiens azonosan nulla, ha minden osztályhoz ugyanaz a bias tolás tartozik: zi,:↦zi,:+c𝟏z_{i,:}\mapsto z_{i,:}+c\mathbf 1 a softmaxot és a loss-t invariánsan hagyja. Ez a konstanseltolási invariancia közvetlen következménye a 7.4. Numerikus stabilitás és jelentésmegőrző implementáció alfejezetben tárgyalt logsumexp-tulajdonságnak.

A PyTorch backward implementációjától tehát azt várjuk, hogy a fenti két mátrixot írja a model.weight.grad és model.bias.grad mezőkbe. Az alábbi f64-kontroll ezt numerikusan ellenőrzi; a numerikus egyezés nem bizonyítja az implementáció általános helyességét, de független hibakeresési bizonyíték.

11.4.5 Kézi kis példa

Legyen X=(0.51.0−0.5−1.00.252.0),W=(0.2−0.40.10.30.15−0.2),b=(0.05,−0.05), X=\begin{pmatrix}0.5&1.0&-0.5\\-1.0&0.25&2.0\end{pmatrix}, \quad W=\begin{pmatrix}0.2&-0.4&0.1\\0.3&0.15&-0.2\end{pmatrix}, \quad b=(0.05,-0.05), y=(0,1)⊤y=(0,1)^\top, N=2N=2, K=2K=2, d=3d=3, α=0.25\alpha=0.25.

A logitok: Z=(−0.300.35−0.05−0.7125). Z=\begin{pmatrix}-0.30&0.35\\-0.05&-0.7125\end{pmatrix}. A softmax kerekítve: P≈(0.34300.65700.65970.3403). P\approx\begin{pmatrix}0.3430&0.6570\\0.6597&0.3403\end{pmatrix}. Ebből G=12(P−Yonehot)≈(−0.32850.32850.3299−0.3299). G=\frac12(P-Y^{\text{onehot}}) \approx \begin{pmatrix}-0.3285&0.3285\\0.3299&-0.3299\end{pmatrix}. A súlygradiens G⊤XG^\top X kerekítve: ∇WL≈(−0.4941−0.24600.82400.49410.2460−0.8240), \nabla_W L\approx \begin{pmatrix} -0.4941 & -0.2460 & 0.8240\\ 0.4941 & 0.2460 & -0.8240 \end{pmatrix}, és ∇bL=(0,0)⊤\nabla_b L=(0,0)^\top a fenti invariancia miatt. Az SGD-frissítés: W1=W0−α∇WL≈(0.3235−0.3385−0.10600.17650.08850.0060),b1=(0.05,−0.05)⊤. W_1=W_0-\alpha\nabla_W L \approx \begin{pmatrix} 0.3235 & -0.3385 & -0.1060\\ 0.1765 & 0.0885 & 0.0060 \end{pmatrix}, \quad b_1=(0.05,-0.05)^\top.

11.4.6 F64 egybatch-kontroll és a nem véges gradiens leállítása

Az alábbi szkript a fenti számítást float64 pontosságon, CPU-n ellenőrzi, majd a véges-gradiens védelmet is bemutatja. A kód futtatható a lab munkakönyvtárból; a kézirat most nem állít elvégzett futást, a labor során a két True és a tervezett RuntimeError a várt kimenet.

# lab/n044_train_step.py
import torch

torch.manual_seed(20261020)
torch.set_default_dtype(torch.float64)
device = torch.device("cpu")

d, K, N = 3, 2, 2
x = torch.tensor([[0.5, 1.0, -0.5],
                  [-1.0, 0.25, 2.0]], device=device)
y = torch.tensor([0, 1], dtype=torch.int64, device=device)

model = torch.nn.Linear(d, K, bias=True).to(device)
with torch.no_grad():
    model.weight.copy_(torch.tensor([[0.2, -0.4, 0.1],
                                      [0.3, 0.15, -0.2]], device=device))
    model.bias.copy_(torch.tensor([0.05, -0.05], device=device))
criterion = torch.nn.CrossEntropyLoss(reduction="mean")
optimizer = torch.optim.SGD(model.parameters(), lr=0.25)

# A lépés sorrendje
optimizer.zero_grad()
logits = model(x)
loss = criterion(logits, y)
loss.backward()

# Kézi gradiens a levezetett képletből
p = torch.softmax(logits, dim=1)
y_onehot = torch.nn.functional.one_hot(y, num_classes=K).to(torch.float64)
G = (p - y_onehot) / N

expected_grad_w = G.T @ x
expected_grad_b = G.sum(dim=0)

print(torch.allclose(model.weight.grad, expected_grad_w, rtol=1e-10, atol=1e-10))
print(torch.allclose(model.bias.grad, expected_grad_b, rtol=1e-10, atol=1e-10))

# Várt SGD-frissítés ellenőrzése
with torch.no_grad():
    w0 = model.weight.data.clone()
    b0 = model.bias.data.clone()
    expected_w = w0 - 0.25 * expected_grad_w
    expected_b = b0 - 0.25 * expected_grad_b

optimizer.step()

print(torch.allclose(model.weight.data, expected_w, rtol=1e-10, atol=1e-10))
print(torch.allclose(model.bias.data, expected_b, rtol=1e-10, atol=1e-10))

# Véges-gradiens védelem: a step előtt állítsuk meg a nem véges futást
def assert_finite_grad(model):
    for name, p in model.named_parameters():
        if p.grad is None:
            continue
        if not torch.isfinite(p.grad).all():
            raise RuntimeError(f"Nem véges gradiens a {name} paraméterben.")

model.zero_grad()
# Új forward gráf: különben a torch.full_like leválasztaná a logitsot,
# és a bad_loss.backward() már a differenciálás előtt RuntimeError-ral leállna.
bad_logits = model(x) * float("nan")
bad_loss = criterion(bad_logits, y)
bad_loss.backward()
try:
    assert_finite_grad(model)
except RuntimeError as e:
    print("Tervezett leállás:", e)

A bad_logits az új model(x) gráfjához kapcsolódik, így a bad_loss.backward() végrehajtható, és a paraméterek gradiense NaN lesz. Egy önálló torch.full_like(logits, float("nan")) tensorral ez nem működne, mert a veszteségnek nem lenne differenciálható útja a modellparaméterekig. A assert_finite_grad hívás a kivételt dobja, ezért az optimizer.step nem fut le, és a paraméterek nem válnak NaN-né a lépésben. A védelmet mindig a step előtt kell meghívni.

11.4.7 Gyakori tévedés és ellenpélda

Tévedés: „Az optimizer automatikusan lefut, amint a loss.backward() sikeres volt.” Ez hamis: a backward csak a gradiens tárolókat tölti fel; a paraméterfrissítés külön optimizer.step() hívás.

Ellenpélda:

logits = model(x)
loss = criterion(logits, y)
optimizer.zero_grad()
loss.backward()
# NINCS optimizer.step()
print(model.weight.data is not None)   # True, a súly létezik
print(model.weight.grad is not None)   # True, a gradiens kiszámult
# A súly értéke mégis változatlan marad.

A két True ellenére a paraméter nem frissült, mert a frissítést semmi sem hívta. Ennek ellenőrzése a laborban: mentsük le a súlyt a backward előtt, majd hasonlítsuk össze a lépés utáni értékkel; hiányzó step esetén a két tensor bitre egyezik.

11.4.8 Feladatok

F1 (fogalmi, nehézség 1). Adja meg a tanítási lépés hat hívásának helyes sorrendjét, és magyarázza el, miért nem mindegy, hogy zero_grad a backward előtt vagy után szerepel. Illusztráljon egy rossz sorrendet kis gondolatkísérlettel. Elvárt eredmény: a sorrend és az akkumuláció következménye.

F2 (levezetési, nehézség 2). Az N=1N=1, d=1d=1, K=2K=2 esetben legyen X=(0.5)X=(0.5), W=(0.2,−0.3)⊤W=(0.2,-0.3)^\top, b=(0.1,0.0)⊤b=(0.1,0.0)^\top, y=1y=1, α=0.5\alpha=0.5. Számítsa ki ZZ, PP, a backward után várható ∇WL\nabla_W L és ∇bL\nabla_b L értékeket, majd a frissített W1W_1 és b1b_1 paramétereket. Kerekítsen 4 tizedesjegyre. Elvárt eredmény: a két gradiens és a két frissített paraméter.

F3 (implementációs, nehézség 2). Írjon olyan train_step(model, x, y, criterion, optimizer) függvényt, amely a megfelelő sorrendben elvégzi a lépést, a step előtt minden paraméter gradiensét végesnek ellenőrzi, és nem véges esetén RuntimeError-t dob. Térjen vissza a loss item() értékével. Tesztelje az F2 példáján. Elvárt eredmény: teljes kód és egy sikeres hívás kimenete.

F4 (hibakeresési, nehézség 1). Az alábbi kódban a loss nem változik több lépés után sem, bár a gradiens None-tól különböző. Hol a hiba, mi a következménye, és hogyan javítja?

optimizer.zero_grad()
logits = model(x)
loss = criterion(logits, y)
loss.backward()
# néhány diagnosztikai nyomtatás
print(loss.item())

Elvárt eredmény: a hiba azonosítása, a kimaradt hívás neve, a javítás és az ellenőrzés leírása.

F5 (kísérleti/transzfer, nehézség 3). Tervezzen egybatch-es diagnosztikai kísérletet, amely meggyőződik arról, hogy a véges-gradiens védelem valóban megállítja a futást, mielőtt a paraméterek nem véges értéket kapnának. Adja meg: kérdés, adat, baseline, változó tényező, metrika, a várható kivétel típusa és a következtetési korlát. Elvárt eredmény: korrekt protokoll, alternatív megoldás és a korlát megnevezése.

11.4.9 Megoldások és indoklás

F1. Helyes sorrend: optimizer.zero_grad(), logits = model(x), loss = criterion(logits, y), loss.backward(), gradiens-ellenőrzés, optimizer.step(). A backward a .grad mezőt a kiszámolt gradienssel bővíti. Ha a zero_grad a backward után futna le, a frissítéshez szükséges gradiens törlődne, és az SGD súlyváltozása nulla lenne. Ha a zero_grad teljesen elmarad, két egymás utáni batch gradiense összeadódik, és a lépés nem az aktuális batch átlagának, hanem az összegének megfelelően frissít. A rossz sorrendre példa: backward után zero_grad, majd step; a gradiens mindig nulla.

F2. A logit: z1=0.5⋅0.2+0.1=0.2,z2=0.5⋅(−0.3)+0.0=−0.15. z_1 = 0.5\cdot 0.2 + 0.1 = 0.2, \qquad z_2 = 0.5\cdot(-0.3) + 0.0 = -0.15. A softmax: e0.2≈1.2214e^{0.2}\approx1.2214, e−0.15≈0.8607e^{-0.15}\approx0.8607, összeg 2.08212.0821, így p=(0.5867,0.4133). p=(0.5867,\ 0.4133). N=1N=1 és y=1y=1, ezért G=(0.5867,−0.5867)G=(0.5867,\ -0.5867). A súlygradiens oszlopvektor: ∇WL=G⊤X=(0.29335,−0.29335)⊤, \nabla_W L = G^\top X = (0.29335,\ -0.29335)^\top, a biasgradiens ∇bL=(0.5867,−0.5867)⊤\nabla_b L = (0.5867,\ -0.5867)^\top. A frissítés α=0.5\alpha=0.5: W1=(0.2,−0.3)⊤−0.5(0.29335,−0.29335)⊤=(0.0533,−0.1533)⊤, W_1 = (0.2,\ -0.3)^\top - 0.5\,(0.29335,\ -0.29335)^\top = (0.0533,\ -0.1533)^\top, b1=(0.1,0.0)⊤−0.5(0.5867,−0.5867)⊤=(−0.1934,0.2934)⊤. b_1 = (0.1,\ 0.0)^\top - 0.5\,(0.5867,\ -0.5867)^\top = (-0.1934,\ 0.2934)^\top. Alakellenőrzés: W1∈ℝ2×1W_1\in\mathbb R^{2\times1}, b1∈ℝ2b_1\in\mathbb R^2. A f64 kódban torch.allclose rtol=1e-10, atol=1e-10 mellett egyezik a backward eredményével.

F3. Egy lehetséges megoldás:

def train_step(model, x, y, criterion, optimizer):
    optimizer.zero_grad()
    logits = model(x)
    loss = criterion(logits, y)
    loss.backward()
    for name, p in model.named_parameters():
        if p.grad is None:
            raise RuntimeError(f"{name}: hiányzó gradiens")
        if not torch.isfinite(p.grad).all():
            raise RuntimeError(f"{name}: nem véges gradiens")
    optimizer.step()
    return loss.item()

Az F2 példájára hívva a visszatérés a megfelelő loss item(), a modell súlya pedig a kiszámolt W1,b1W_1,b_1 értékre módosul. A zero_grad a backward előtt, a step a végső ellenőrzés után van, így akár hiányzó gradiens (p.grad is None), akár NaN/inf megállítja a futást.

F4. A hiba a hiányzó optimizer.step() hívás. A backward feltölti a gradienseket, a loss kiszámítása és a gradiens léte tényleges, de a paraméterek nem frissülnek, ezért a következő forward ugyanazt a loss-t adja. A javítás a diagnosztikai nyomtatás után egy optimizer.step() hívás. Ellenőrzés: a step előtt elmentett w_before = model.weight.data.clone() és a step utáni w_after nem egyezik bitre; a loss a következő forwardon csökken. A javított kód minden iterációban megismétli a zero_grad–forward–loss–backward–step sort.

F5. Egy tervezhető kísérlet:

11.4.10 Önellenőrzés, korábbi fogalom és tipikus hibahatár

Önellenőrzés: a backward után a súlygradiens alakja megegyezik a súly alakjával (K×d)(K\times d), a biasgradiens (K)(K); a kézi példában a biasgradiens nulla a softmax konstanseltolási invarianciája miatt. A numerikus összevetést f64-ben, rtol=1e-10, atol=1e-10 értékkel végezzük. A 10.4. Tanítási lépés és tanuló MLP alfejezetben kézzel végzett három művelet itt pontosan a zero_grad, backward és step hármasnak felel meg; a 7.4. Numerikus stabilitás és jelentésmegőrző implementáció alfejezetben tárgyalt stabil loss itt élesben is véd az overflow ellen, de a gradiens végessége külön futás előtti ellenőrzés.

11.4.11 Rövid összefoglaló

A PyTorch-tanítási lépés sorrendje szerződés: zero_grad, forward, loss, backward, véges-gradiens ellenőrzés, majd optimizer.step. A backward a közvetlen lineáris réteg plusz CrossEntropyLoss esetén a G⊤XG^\top X súly- és G⊤𝟏NG^\top\mathbf 1_N biasgradienst állítja elő, ahol Gik=1N(pik−𝟙k=yi)G_{ik}=\frac1N(p_{ik}-\mathbb{1}_{k=y_i}). Az SGD-lépés θt+1=θt−αgt\theta_{t+1}=\theta_t-\alpha g_t, és a labor f64-ben numerikusan összeveti a levezetett értékkel. A backward sikere önmagában nem frissíti a paramétereket; a step előtti isfinite ellenőrzés a NaN/inf gradiensből eredő hibát állítja meg. A következő 12.1. Tanítás, validáció és kiértékelési mód egység a teljes training-validáció és eval mód keretét építi erre a lépésre.

11.4.12 Felhasznált források