A 10.4. Tanítási lépés és tanuló MLP
alfejezetben a tanítási lépést három műveletre bontottuk: a
gradiens-tároló nullázása, a minibatch gradiens kiszámítása
visszaterjesztéssel, majd a paraméterfrissítés. Ez az egység a PyTorch
autograd rétegét köti hozzá ehhez a képhez. A cél annak
pontos megértése, hogy a requires_grad flag, a
backward hívás, a .grad tároló, a
detach, a no_grad és az
inference_mode hogyan oszlanak meg a tenzor értéke, a
számítási gráf és a gradiens között. Előfeltétel a 2.4. Kis tudományos szoftverprojekt kis
projekt-szemlélete és a 10.4. Tanítási lépés és
tanuló MLP kézzel írt MLP-tanítási lépése; a 10.1. Számítási gráf és lokális derivált
gráfmodelljét és a 7.1. Entrópia, cross-entropy és
KL loss-fogalmait röviden visszaidézzük.
Induló kérdés: egy
levéltensor két különböző úton befolyásolja a veszteséget; a gradiens
összegződik. Hányszor jelenik meg a gradiens, ha ugyanazon a gráfon
kétszer hívunk backward-ot? És mi történik, ha középen egy
detach szakítja meg az utat?
A PyTorch Tensor egy többdimenziós tömb, amelynek értéke
mellett van dtype, device és egy
requires_grad logikai flagje. A [D03] dokumentációja
szerint a tenzor a számítások alapobjektuma; az autograd szempontjából a
flag azt mondja meg, hogy az adott tenzorból kiinduló műveleteknek
épüljön-e gráf. Az itt tárgyalt fogalmak a 2.14-es API-t írják le.
Egy tenzort levéltensornak (leaf tensor) nevezünk,
ha közvetlenül a felhasználó hozza létre, nem pedig tenzorművelet
eredménye. A gyakorlatban a paramétertenzorok és a bemeneti tenzorok
levéltensorok. A levéltensor az egyetlen olyan tenzor, amelyhez a
backward hívás után a .grad attribútumba
gradiens kerül; a nem-levél tenzorok gradienseit az autograd
alapértelmezésben nem tárolja, legfeljebb a retain_grad()
hívással kérhetjük.
Az autograd-gráf egy dinamikusan épülő, irányított
körmentes gráf (DAG), amelynek csúcsai tenzorok, élei pedig a
végrehajtott műveletek input–output viszonyai. Minden olyan művelet,
amely legalább egy requires_grad=True tenzort kap, létrehoz
egy új gráfcsúcsot, és a kimeneti tenzor requires_grad=True
lesz. A gráf a forward alatt épül, a backward bejárja, majd
a nem-levél csúcsok számára felszabadul. Ez a dinamikus életciklus az,
amit a [D05] autograd-mechanika leírása tárgyal; a labor ezért az
autograd viselkedését külön, futtatható kóddal is szemlélteti.
A requires_grad három szabálya a mindennapi
munkához:
requires_grad=True értékkel hozunk
létre, az levéltensor, és a .grad tárolója kezdetben
None.requires_grad=True, és a gráfban új él jön létre.requires_grad=False értékkel hozunk
létre, abból nem indul gráf; a rá épülő műveletek eredménye is
requires_grad=False lesz.A backward hívás a gráfon visszafelé haladva minden élre
lokális VJP-t számol, a 10.1. Számítási gráf és
lokális derivált alfejezetben bevezetett
alakban. A bejárás végén minden levéltensorra a kapott gradienst
hozzáadja a .grad meglévő értékéhez, nem
felülírja. Ezért a tanítási ciklusban a 10.4.
Tanítási lépés és tanuló MLP alfejezetben megismert gradiensnullázás
külön lépés.
A levezetési kötelezettség az, hogy a számítási gráfon pontosan megindokoljuk, mikor szakad meg a gradiens útja. Az állítás a következő.
Lemma (a detach gráfszakadása). Legyen
levéltensor, amelyre requires_grad=True, legyen
egy differenciálható tenzorfüggvény eredménye, legyen
,
és legyen
egy differenciálható skalár veszteség. Az autograd nem viszi át a
gradienst
-ból
-be.
Ha
nem használ más, gradienst igénylő változót, akkor
hamis, ezért a
hívás RuntimeError kivételt vált ki; ha más, tanulható
bemenete is van
-nek,
a visszaterjesztés azon az ágon működik, de
nem frissül. A két esetben a forward érték ugyanaz lehet.
Indoklás. Az autograd-gráf definíciója szerint a
detach egy új tenzort ad vissza, amely ugyanarra a tárolóra
mutat, tehát az értéke megegyezik
értékével, de a requires_grad flagje False, és
a gráfban nem jön létre él
és
között. A
függvény
felől nem épít visszafelé vezető gráfélet
-hez;
ha más bemenetén sincs requires_grad=True változó, a
kimenete sem rendelkezik autograd-gráffal. A backward
bejárás kizárólag a gráf élei mentén halad visszafelé; ha az
és
közötti él nem létezik, akkor az út
-hez
nem folytatódik. A láncszabály szintjén ez azt jelenti, hogy a
VJP megvan, de a
él hiányzik, így a
szorzatból nem képződik további VJP
és
felé. A detach tehát a gráf topológiáját változtatja meg,
nem a tenzor értékét.
Ez API-szemantikai állítás, nem analitikus deriválási tétel. A [D05]
dokumentálja, hogy a detach „elvágja” a gráfot; a fenti
indoklás a gráfbejárás definíciójából származik, és a labor kísérletileg
is ellenőrzi.
A no_grad és az inference_mode hasonló
hatást érnek el, de más eszközzel. A with torch.no_grad():
kontextusban minden újonnan végrehajtott művelet kimenete
requires_grad=False, tehát az adott blokkban nem épül gráf.
A blokk elhagyása után a gráfépítés visszaáll; a blokkban létrehozott
tenzorok értéke megmarad, de autograd-történetük nincs. Az
inference_mode ennél erősebb: a [D05] szerint a
nézetkövetést is kikapcsolja, ezért gyorsabb lehet, de a benne
létrehozott tenzorok később nem használhatók autograd számításban. A
no_grad elsősorban a kiértékeléshez és a
paraméterfrissítéshez, az inference_mode a végleges
következtetéshez való.
A következő kód a detach hatását mutatja egy kétlépéses gráfon. A számítás egyszerű ahhoz, hogy a várt gradiens kézzel is kiszámolható legyen.
# lab/n041/detach_demo.py
import torch
torch.manual_seed(20261020)
dtype = torch.float64
device = torch.device("cpu")
# 1. eset: detach a közepén
x = torch.tensor(2.0, dtype=dtype, device=device, requires_grad=True)
y = x**2 + 3*x # y = 10
z = y.detach() # gráfél elvágva
L = z**2 # L = 100
assert not L.requires_grad
try:
L.backward()
except RuntimeError:
print("detach: nincs visszaterjeszthető gráf")
else:
raise AssertionError("A backwardnak detached loss esetén hibáznia kell")
print("detach után x.grad:", x.grad) # None
# 2. eset: ugyanaz a számítás detach nélkül
x2 = torch.tensor(2.0, dtype=dtype, device=device, requires_grad=True)
y2 = x2**2 + 3*x2 # y2 = 10
L2 = y2**2
L2.backward()
print("detach nélkül x2.grad:", x2.grad) # 140.0A második eset számítása:
,
;
,
;
a láncszabályból
.
Az első esetben a formális
derivált kiszámítható, de a detach miatt nincs
autograd-gráf: L.backward() kivételt okoz, és az
marad None. A detach() eredménye önmagában nem
requires_grad=True levéltensor.
A gradiensakkumuláció külön jelenség:
# lab/n041/accumulation_demo.py
import torch
x = torch.tensor(3.0, dtype=torch.float64, requires_grad=True)
for _ in range(2):
(x**2).backward()
print("két backward után x.grad:", x.grad) # 12.0, nem 6.0Egyetlen backward gradiense
;
a második hívás ugyanahhoz a .grad tárolóhoz hozzáad újabb
-ot,
ezért az eredmény
.
A tanítási ciklus ezért hív zero_grad-ot minden lépés
előtt.
A labor célja a 10.4. Tanítási lépés és tanuló
MLP alfejezetben kézzel írt NumPy-MLP gradiensének független
ellenőrzése PyTorch float64 autograddal, majd egy
véletlenül bekerült detach hiba lokalizálása és javítása. A
bemenet rögzített:
,
;
a háló egy rejtett réteget tartalmaz
egységgel és
aktivációval, a veszteség pedig MSE. A
azért jó választás, mert sima, így a numerikus összehasonlításnak nincs
a ReLU-nullpontjából származó konvencióproblémája.
A munkakönyvtár lab/n041/. A kiinduló
check_autograd.py szándékosan hibás: a rejtett aktiváció
után egy detach áll. A hallgató feladata: futtassa a kódot,
rögzítse a hiba tünetét, távolítsa el a detach hívást, majd
igazolja, hogy a javítás után mind a négy paramétermátrix gradiense
egyezik a NumPy-analitikus referenciával. Az átadandó eredmény a
javított kód és a maximális abszolút eltérések listája; az ellenőrzési
küszöb
float64-ben. Az itt közölt kód a labor elvárt szerkezete, a
konkrét futást a hallgató végzi; a számszerű kimenet a képletekből
számított várt tartomány, nem a könyv által futtatott mérés.
# lab/n041/check_autograd.py
import numpy as np
import torch
torch.manual_seed(20261020)
np.random.seed(20261020)
dtype = torch.float64
device = torch.device("cpu")
def numpy_mlp_grad(X, W1, b1, W2, b2, Y):
"""NumPy-MLP gradiens, tanh aktiváció, MSE loss, átlagolással."""
Z1 = X @ W1.T + b1
A1 = np.tanh(Z1)
Z2 = A1 @ W2.T + b2
diff = Z2 - Y
N = X.shape[0]
gZ2 = diff / N
gW2 = gZ2.T @ A1
gb2 = gZ2.sum(axis=0)
gA1 = gZ2 @ W2
gZ1 = gA1 * (1.0 - A1**2)
gW1 = gZ1.T @ X
gb1 = gZ1.sum(axis=0)
return gW1, gb1, gW2, gb2
N, d, h, k = 4, 3, 4, 2
X_np = np.random.randn(N, d)
Y_np = np.random.randn(N, k)
W1_np = np.random.randn(h, d)
b1_np = np.random.randn(h)
W2_np = np.random.randn(k, h)
b2_np = np.random.randn(k)
ref = numpy_mlp_grad(X_np, W1_np, b1_np, W2_np, b2_np, Y_np)
X = torch.tensor(X_np, dtype=dtype, device=device, requires_grad=True)
Y = torch.tensor(Y_np, dtype=dtype, device=device)
W1 = torch.tensor(W1_np, dtype=dtype, device=device, requires_grad=True)
b1 = torch.tensor(b1_np, dtype=dtype, device=device, requires_grad=True)
W2 = torch.tensor(W2_np, dtype=dtype, device=device, requires_grad=True)
b2 = torch.tensor(b2_np, dtype=dtype, device=device, requires_grad=True)
# Hibás változat: a detach megszakítja az utat az első réteg felé.
h_lin = X @ W1.t() + b1
h = torch.tanh(h_lin).detach() # <-- a hiba
logits = h @ W2.t() + b2
loss = ((logits - Y) ** 2).mean()
loss.backward()
print("Hibás: W1.grad =", W1.grad, ", b1.grad =", b1.grad)
print("Hibás: X.grad =", X.grad)
assert W1.grad is None and b1.grad is None and X.grad is None
# Javított változat: a detach elhagyása.
W1.grad = None; b1.grad = None; W2.grad = None; b2.grad = None
h = torch.tanh(X @ W1.t() + b1)
logits = h @ W2.t() + b2
loss = ((logits - Y) ** 2).mean()
loss.backward()
errs = [
np.max(np.abs(W1.grad.detach().numpy() - ref[0])),
np.max(np.abs(b1.grad.detach().numpy() - ref[1])),
np.max(np.abs(W2.grad.detach().numpy() - ref[2])),
np.max(np.abs(b2.grad.detach().numpy() - ref[3])),
]
print("max_abs_errors:", errs)
assert max(errs) < 1e-12A hibás változat tünete precízen lokalizálja a gráfszakadást:
W2.grad és b2.grad nem None, mert
a második réteg a detach utáni tenzorból indul, de
W1.grad, b1.grad és X.grad mind
None. A javítás után a négy gradiens a NumPy-referenciával
egyezik; float64-ben a várt maximális eltérés
nagyságrendű, a
küszöb a környezeti eltérésekre hagy tartalékot. A detach
eltávolítása nem változtat a forward értéken, tehát a hibakeresés
pontosan a gráf topológiájára, nem a veszteség számértékére irányul.
F1 (fogalmi, nehézség 1). Mi a különbség a
levéltensor és a nem-levél tenzor között az autograd életciklusában?
Miért None a nem-levél tenzor .grad értéke
alapértelmezésben? Adj egy-egy példát. Elvárt átadandó: definíció, a
.grad tárolás feltétele, két rövid kódrészlet.
F2 (levezetési, nehézség 2). Legyen
levéltensor, és legyen
.
Egyetlen backward hívás után mennyi
?
Vezesd le az elágazó gráfon a gradiensösszegzést, és jelöld meg, melyik
tag melyik ágból jön. Elvárt átadandó: a derivált kiszámítása, az
ágankénti felbontás és a konkrét x.grad érték.
F3 (implementációs, nehézség 2). Írj
split_path(x, detach_middle) függvényt, amely kiszámítja
-et,
de ha detach_middle igaz, akkor a négyzetre emelés után
detach-et hajt végre. A függvény hívjon
backward-ot, és térjen vissza az
(L.item(), x.grad) párral. Ellenőrizd x=2
esetén mindkét ágat. Elvárt átadandó: futtatható kód, a két visszatérési
érték és egy assert ellenőrzés.
F4 (hibakeresési, nehézség 2). A labor
check_autograd.py hibás változatában miért
None az első réteg súlyának gradiense? Azonosítsd a pontos
sort, magyarázd el a gráfszakadást, és add meg a javítást úgy, hogy a
forward érték ne változzon. Elvárt átadandó: a hiba oka, következménye,
javítása és a javítás utáni ellenőrző futás állítása.
F5 (kísérleti/transzfer, nehézség 3). Tervezz egy
kis kísérletet annak eldöntésére, hogy a
with torch.no_grad(): blokkba helyezett forward számítás
megváltoztatja-e egy tanítási lépés paraméterfrissítését egy rögzített
minibatch MLP-n. Add meg a protokollt, a baseline-t, a változó tényezőt,
a metrikát és a következtetési határt. Elvárt átadandó: egy
megvalósítható terv, a várt eredmény indoklása és legalább egy
érvényességi korlát. A [D05] státusza miatt a kísérlet a dokumentációs
állítás empirikus ellenőrzése is.
F1. A levéltensor közvetlenül a felhasználótól
származik, és a backward bejárás végén a .grad
tárolója frissül. A nem-levél tenzor művelet eredménye, és a gráf
felszabadulásakor a köztes gradiense nem marad meg, ezért
.grad-ja alapértelmezésben None; ha kell,
retain_grad()-dal kérhető. Példa:
x = torch.tensor(2.0, requires_grad=True) levéltensor;
y = x * 2 nem-levél. y.backward() után
x.grad értéke 2.0, y.grad értéke
None.
F2. A gráfon -nek két leszármazottja van: az ág és a ág; a veszteség . A 10.1. Számítási gráf és lokális derivált gradiensösszegzési lemmája szerint
esetén az első ág gradiense
,
a másodiké
,
az összeg
.
A kódban
x = torch.tensor(3.0, dtype=torch.float64, requires_grad=True),
(x**2 + 2*x + 1).backward(), ekkor
x.grad.item() == 8.0. A 2x+1-ben a
tag deriváltja
,
a konstans
deriváltja
;
az
ág deriváltja
,
és a kettő összeadódik.
F3. A teljes függvény:
# lab/n041/split_path.py
import torch
torch.manual_seed(20261020)
def split_path(x, detach_middle):
a = x * x
if detach_middle:
a = a.detach()
L = 2.0 * a
# A detach-ágon nincs autograd-gráf; ott a backward kivételt okozna.
if L.requires_grad:
L.backward()
return L.item(), x.grad
x1 = torch.tensor(2.0, dtype=torch.float64, requires_grad=True)
print("detach nélkül:", split_path(x1, False)) # (8.0, tensor(8.0))
x2 = torch.tensor(2.0, dtype=torch.float64, requires_grad=True)
print("detach-tal:", split_path(x2, True)) # (8.0, None)
assert x2.grad is NoneAz
deriváltja
esetén. A detach nélküli ágon a gradiens
;
a detach-es ágon a forward érték ugyanúgy
,
de a gráfél hiánya miatt x.grad marad None. A
backward() csak a requires_grad=True ágon fut;
e feltétel nélkül a detach-ág RuntimeError kivétellel
leállna, amit az eredeti F3-vázlat tévesen nem kezelt. Az
assert a gráfszakadást ellenőrzi, nem a loss értékét.
F4. A hiba sora:
h = torch.tanh(h_lin).detach()A detach új tenzort hoz létre ugyanazzal a tárolóval, de
requires_grad=False flaggel és a gráfban nincs él a
tanh kimenete és az új tenzor között. Ezért a második réteg
(W2, b2) felé még terjed a gradiens, de az
első réteg felé az út megszakad: W1.grad,
b1.grad és X.grad mind None lesz.
A javítás a detach() elhagyása:
h = torch.tanh(h_lin)A forward érték nem változik, mert a detach csak a
gráfot vágta, a tárolót nem módosította. A javítás utáni ellenőrzés a
labor kódjának második felében történik meg: a négy gradiens maximális
abszolút eltérése a NumPy-referenciától
alatt van.
F5. Egy lehetséges terv. Kérdés: a
no_grad blokkban végrehajtott forward megakadályozza-e a
paraméterfrissítést? Protokoll: rögzített MLP, rögzített
minibatch, rögzített seed; a modellparamétereket kétszer ugyanabból a
kezdőállapotból indítjuk. A kontroll esetben a forward,
loss.backward(), optimizer.step() sorrend
normál módon fut. A vizsgált esetben a forward
with torch.no_grad(): blokkban fut, majd megkíséreljük a
backward-ot. Metrika: a paramétertenzorok
L2-normájának változása egy lépés után, valamint annak jelzése, hogy a
loss rendelkezik-e grad_fn-nel. Várt
eredmény: a no_grad alatt a kimenet
requires_grad=False, ezért a loss.backward()
hívás hibát ad, vagy ha a kód a hibát elkapja, a frissítés nulla; a
kontroll esetben a frissítés nem nulla. Következtetési határ:
ez egyetlen lépés, tehát nem állít semmit a tanítás konvergenciájáról,
csak a gráfépítés jelenlétéről. A kísérlet a [D05]
no_grad-szemantikáját ellenőrzi a helyi környezetben.
A leggyakoribb tévedés: „Ha a .grad megvan, a gráf
is megvan.” A gradienstárolás és az autograd-gráf élettartama nem
ugyanaz. Ellenpélda:
# lab/n041/lifetime_counterexample.py
import torch
x = torch.tensor(2.0, dtype=torch.float64, requires_grad=True)
y = x * 2
y.backward()
print("x.grad:", x.grad) # 2.0, a gradienstároló megvan
try:
y.backward()
except RuntimeError as e:
print("második backward:", e)
# Trying to backward through the graph a second timeAz első backward után a gráf felszabadult, bár
x.grad továbbra is tárolja a
értéket. A második backward ezért hibát ad, nem pedig újabb
-t.
A detach ezzel szemben nem a .grad tárolót
törli, hanem a gráfból vág ki élt; a kettőt nem szabad felcserélni.
Rövid önellenőrzés: (1) Ha x.grad már 3.0,
és egy új gráfon backward-ot hívunk, mi lesz az eredmény?
Válasz: 3.0 + új_grad, mert a gradiens akkumulálódik. (2)
Miért nem kap gradienst az első réteg a labor hibás kódjában? Válasz: a
detach után nincs gráfél a rejtett aktiváció és a következő
számítás között. (3) Miben erősebb az inference_mode a
no_grad-nál? Válasz: a [D05] szerint a nézetkövetést is
kikapcsolja, ezért a benne keletkező tenzorok későbbi
autograd-használata kizárt; a no_grad csak a gráfépítést
állítja le az adott blokkban. A pontos különbséget a [D05] teljes
szakaszának ellenőrzése után kell megerősíteni, ezért a labor ezt a
pontot is méri.
Visszahívás: a 10.1. Számítási gráf és lokális derivált szerint az elágazó csúcsnál a leszármazott ágak gradiensei összeadódnak; a 10.4. Tanítási lépés és tanuló MLP szerint a tanítási lépés nullázásból, visszaterjesztésből és frissítésből áll; a 2.4. Kis tudományos szoftverprojekt szerint a labor kódját modulhatárral, explicit input–output szerződéssel és tesztellenőrzéssel szervezzük.
A PyTorch tenzor értéket, dtype-ot, eszközt és
requires_grad flaget hordoz; a flag azt szabályozza, hogy a
tenzorból épül-e autograd-gráf. A levéltensor közvetlenül létrehozott
tenzor, egyedül nála tárolódik .grad; a nem-levél tenzor
gradiense alapértelmezésben nem marad meg. A backward a
gráfon visszafelé VJP-ket számol, és a levéltensorok gradiensét
hozzáadja, ezért kell nullázás. A detach új, gráfél nélküli
tenzort ad ugyanazzal a tárolóval, és megszakítja a gradiens útját; a
no_grad blokkszinten, az inference_mode
erősebben kapcsolja ki a gráfépítést. A labor ezt a NumPy-MLP
gradiensének f64-egyezésével és egy detach hiba javításával
ellenőrzi.
A következő 11.2. nn.Module és
paraméterregisztráció egységben az nn.Module és a
paraméterregisztráció következik: ott a levéltensorok a modell
paraméterlistájába kerülnek, és az optimizer közvetlenül
ezeket a .grad tárolókat nullázza és frissíti.