11.1 Tensor és autograd-életciklus

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

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 xx 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?

11.1.2 Tenzor, levéltensor és autograd-gráf

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:

  1. Ha egy tenzort requires_grad=True értékkel hozunk létre, az levéltensor, és a .grad tárolója kezdetben None.
  2. Ha egy műveletnek legalább egy ilyen inputja van, a kimenet requires_grad=True, és a gráfban új él jön létre.
  3. Ha egy tenzort 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 gu=Jf⊤gvg_u=J_f^\top g_v 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.

11.1.3 A gradiens útjának megszakadása: a detach lemma

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 xx levéltensor, amelyre requires_grad=True, legyen z=f(x)z=f(x) egy differenciálható tenzorfüggvény eredménye, legyen y=z.𝚍𝚎𝚝𝚊𝚌𝚑()y=z.\texttt{detach}(), és legyen L=g(y)L=g(y) egy differenciálható skalár veszteség. Az autograd nem viszi át a gradienst yy-ból xx-be. Ha gg nem használ más, gradienst igénylő változót, akkor L.𝚛𝚎𝚚𝚞𝚒𝚛𝚎𝚜_𝚐𝚛𝚊𝚍L.\texttt{requires\_grad} hamis, ezért a L.𝚋𝚊𝚌𝚔𝚠𝚊𝚛𝚍()L.\texttt{backward}() hívás RuntimeError kivételt vált ki; ha más, tanulható bemenete is van gg-nek, a visszaterjesztés azon az ágon működik, de x.𝚐𝚛𝚊𝚍x.\texttt{grad} 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 zz értékével, de a requires_grad flagje False, és a gráfban nem jön létre él zz és yy között. A gg függvény yy felől nem épít visszafelé vezető gráfélet zz-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 yy és zz közötti él nem létezik, akkor az út xx-hez nem folytatódik. A láncszabály szintjén ez azt jelenti, hogy a ∂L∂y\frac{\partial L}{\partial y} VJP megvan, de a ∂y∂z\frac{\partial y}{\partial z} él hiányzik, így a Jg⊤(v)J_g^\top(v) szorzatból nem képződik további VJP zz és xx 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ó.

11.1.4 Kézi példa f64-ben

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.0

A második eset számítása: y=x2+3xy=x^2+3x, dydx=2x+3=7\frac{dy}{dx}=2x+3=7; L=y2L=y^2, dLdy=2y=20\frac{dL}{dy}=2y=20; a láncszabályból dLdx=7⋅20=140\frac{dL}{dx}=7\cdot 20=140. Az első esetben a formális dLdz=20\frac{dL}{dz}=20 derivált kiszámítható, de a detach miatt nincs autograd-gráf: L.backward() kivételt okoz, és az x.𝚐𝚛𝚊𝚍x.\texttt{grad} 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.0

Egyetlen backward gradiense 2x=62x=6; a második hívás ugyanahhoz a .grad tárolóhoz hozzáad újabb 66-ot, ezért az eredmény 1212. A tanítási ciklus ezért hív zero_grad-ot minden lépés előtt.

11.1.5 Labor: NumPy-MLP és PyTorch autograd egyeztetése

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: X∈ℝ4×3X\in\mathbb{R}^{4\times 3}, Y∈ℝ4×2Y\in\mathbb{R}^{4\times 2}; a háló egy rejtett réteget tartalmaz 44 egységgel és tanh\tanh aktivációval, a veszteség pedig MSE. A tanh\tanh 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 10−1210^{-12} 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-12

A 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 10−1510^{-15} nagyságrendű, a 10−1210^{-12} 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.

11.1.6 Feladatok

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 x=3x=3 levéltensor, és legyen L=x2+2x+1L=x^2+2x+1. Egyetlen backward hívás után mennyi x.𝚐𝚛𝚊𝚍x.\texttt{grad}? 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 L=2x2L=2x^2-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.

11.1.7 Megoldások és indoklás

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 xx-nek két leszármazottja van: az a=x2a=x^2 ág és a b=2x+1b=2x+1 ág; a veszteség L=a+bL=a+b. A 10.1. Számítási gráf és lokális derivált gradiensösszegzési lemmája szerint

dLdx=∂a∂x+∂b∂x=2x+2. \frac{dL}{dx}=\frac{\partial a}{\partial x}+\frac{\partial b}{\partial x}=2x+2.

x=3x=3 esetén az első ág gradiense 66, a másodiké 22, az összeg 88. 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 2x2x tag deriváltja 22, a konstans 11 deriváltja 00; az x2x^2 ág deriváltja 66, é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 None

Az L=2x2L=2x^2 deriváltja 4x=84x=8 x=2x=2 esetén. A detach nélküli ágon a gradiens 8.08.0; a detach-es ágon a forward érték ugyanúgy 8.08.0, 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 10−1210^{-12} 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.

11.1.8 Tipikus tévedés és önellenőrzés

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 time

Az első backward után a gráf felszabadult, bár x.grad továbbra is tárolja a 2.02.0 értéket. A második backward ezért hibát ad, nem pedig újabb 2.02.0-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.

11.1.9 Rövid összefoglaló

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.

11.1.10 Következő alkalmazás és források

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.