Come funziona l’autograd di PyTorch: dentro i grafi delle derivate – P1

Quando si inizia a lavorare con PyTorch, una delle righe più misteriose è spesso questa:

loss.backward()

Sembra quasi una magia.

Abbiamo calcolato una loss, chiamiamo backward(), e improvvisamente PyTorch sa come modificare milioni o miliardi di parametri del modello.

Ma cosa succede davvero?

La risposta è: PyTorch costruisce e attraversa un grafo di operazioni matematiche per calcolare le derivate.

Capire questo meccanismo è fondamentale non solo per il deep learning in generale, ma anche per capire algoritmi come PPO, GRPO e RLVR.


1. Il problema che dobbiamo risolvere

Immaginiamo di avere un parametro del modello:ww

e una funzione di loss:L(w)L(w)

Vogliamo sapere:

Se modifico leggermente ww, come cambia la loss?

Questa informazione è la derivata:∂L∂w\frac{\partial L}{\partial w}

Se la derivata è positiva, aumentare ww tende ad aumentare la loss.

Se la derivata è negativa, aumentare ww tende a diminuire la loss.

Il gradient descent sfrutta proprio questa informazione.

L’aggiornamento più semplice è:wnew=w−η∂L∂ww_{new}=w-\eta\frac{\partial L}{\partial w}

dove η\eta è il learning rate.

Quindi il problema fondamentale è:

Come calcoliamo

∂L∂w∂L∂w∂L∂w\frac{\partial L}{\partial w}

quando la rete contiene milioni di operazioni e milioni di parametri?

Qui entra in gioco il computational graph.


2. Un esempio semplicissimo

Consideriamo:z=x⋅yz=x\cdot y

e poi:L=z2L=z^2

Quindi:L=(x⋅y)2L=(x\cdot y)^2

Supponiamo:x=2x=2y=3y=3

Allora:z=2⋅3=6z=2\cdot3=6

e:L=62=36L=6^2=36

Possiamo rappresentare le operazioni come un grafo:

x = 2 ───┐
         ↓
       multiply
         ↓
y = 3 ───┘
         ↓
      z = 6
         ↓
       square
         ↓
      L = 36

Questo è il computational graph.

Non è altro che una rappresentazione delle operazioni matematiche che hanno prodotto il risultato.


3. Perché il grafo è utile?

Perché vogliamo andare nella direzione opposta.

Durante il forward abbiamo fatto:

x
 \
  → multiply → z → square → L
 /
y

Per calcolare le derivate facciamo concettualmente:

x
 \
  ← multiply ← z ← square ← L
 /
y

Partiamo dalla loss e torniamo indietro.

Questo è ciò che intendiamo con:

loss.backward()

backward = percorri il grafo all’indietro calcolando le derivate.


4. La chain rule è il cuore di tutto

Supponiamo:L=z2L=z^2

e:z=x⋅yz=x\cdot y

Vogliamo:∂L∂x\frac{\partial L}{\partial x}

La chain rule dice:∂L∂x=∂L∂z∂z∂x\frac{\partial L}{\partial x} = \frac{\partial L}{\partial z} \frac{\partial z}{\partial x}

Abbiamo:∂L∂z=2z\frac{\partial L}{\partial z}=2z

e:∂z∂x=y\frac{\partial z}{\partial x}=y

quindi:∂L∂x=2zy\frac{\partial L}{\partial x}=2zy

Con z=6z=6 e y=3y=3:∂L∂x=36\frac{\partial L}{\partial x}=36

Analogamente:∂L∂y=2zx=24\frac{\partial L}{\partial y}=2zx=24

PyTorch fa esattamente questo, ma automaticamente e su grafi enormemente più complessi.


5. Cosa succede dentro PyTorch?

Guardiamo questo codice:

import torch

x = torch.tensor(2.0, requires_grad=True)
y = torch.tensor(3.0, requires_grad=True)

z = x * y
loss = z ** 2

loss.backward()

print(x.grad)
print(y.grad)

Il risultato sarà:

tensor(36.)
tensor(24.)

Perché:∂L∂x=36\frac{\partial L}{\partial x}=36

e:∂L∂y=24\frac{\partial L}{\partial y}=24

La cosa importante è capire da dove sono arrivati quei numeri.


6. requires_grad=True

Questa istruzione:

x = torch.tensor(2.0, requires_grad=True)

dice a PyTorch:

“Mi interessa sapere come la funzione finale dipende da x.”

Quindi PyTorch tiene traccia delle operazioni che coinvolgono x.

Lo stesso vale per y.

Se invece scrivessimo:

x = torch.tensor(2.0)

PyTorch non avrebbe motivo di costruire il percorso necessario per calcolare:∂L∂x\frac{\partial L}{\partial x}


7. Il grafo viene costruito durante il forward

Questa è una delle idee più importanti da capire.

Quando scriviamo:

z = x * y

PyTorch non calcola solamente il numero 6.

Registra anche:

“Questo z è stato ottenuto moltiplicando x e y.”

Poi:

loss = z ** 2

registra:

“Questa loss è stata ottenuta elevando z al quadrato.”

Quindi alla fine PyTorch conosce una struttura concettualmente simile a:

x ────────┐
          ↓
       multiply
          ↓
y ────────┘
          ↓
          z
          ↓
       square
          ↓
         loss

Questo grafo permette di ricostruire le derivate durante il backward.


8. loss.backward() non aggiorna i pesi

Questo è un errore concettuale molto comune.

Quando scrivi:

loss.backward()

PyTorch non modifica ancora i parametri.

Calcola i gradienti.

Per esempio:

loss.backward()

print(x.grad)

produce:

36

A questo punto PyTorch sa:∂L∂x=36\frac{\partial L}{\partial x}=36

Ma x è ancora:x=2x=2

L’aggiornamento avviene successivamente.


9. È l’optimizer che modifica i parametri

Per esempio:

optimizer = torch.optim.SGD([x, y], lr=0.01)

optimizer.zero_grad()

loss.backward()

optimizer.step()

La sequenza è:

forward
   ↓
calcolo loss
   ↓
backward()
   ↓
calcolo gradienti
   ↓
optimizer.step()
   ↓
modifica parametri

Quindi bisogna distinguere:

backward()

Calcola “come dovrei cambiare i parametri?”

optimizer.step()

Cambia effettivamente i parametri.


10. Perché si chiama computational graph?

Perché non rappresenta soltanto le variabili.

Rappresenta soprattutto le operazioni.

Per esempio:a=x+ya=x+yb=a2b=a^2L=b+5L=b+5

diventa:

x ──┐
    ├── add ──→ a ──→ square ──→ b ──┐
y ──┘                                  ├── add ──→ L
                                  5 ───┘

Ogni operazione ha una regola matematica per la derivata.

Il backward applica queste regole usando la chain rule.


11. Il concetto di “gradiente locale”

Immaginiamo:z=x⋅yz=x\cdot y

L’operazione multiply sa che:∂z∂x=y\frac{\partial z}{\partial x}=y

e:∂z∂y=x\frac{\partial z}{\partial y}=x

Poi abbiamo:L=z2L=z^2

L’operazione square sa che:∂L∂z=2z\frac{\partial L}{\partial z}=2z

Durante il backward questi pezzi vengono combinati.

                  L
                  │
              dL/dz = 2z
                  ↓
                  z
                  │
       ┌──────────┴──────────┐
       ↓                     ↓
    d z/dx                 d z/dy
       ↓                     ↓
       y                     x

Per xx:∂L∂x=∂L∂z∂z∂x\frac{\partial L}{\partial x} = \frac{\partial L}{\partial z} \frac{\partial z}{\partial x}

Questo meccanismo si ripete per ogni operazione del modello.


12. Ora immaginiamo una rete neurale

Una rete neurale è semplicemente un computational graph gigantesco.

In forma molto semplificata:

input
  ↓
Linear
  ↓
ReLU
  ↓
Linear
  ↓
ReLU
  ↓
Linear
  ↓
logits
  ↓
loss

Durante il forward:

input → layer 1 → layer 2 → layer 3 → loss

Durante il backward:

input ← layer 1 ← layer 2 ← layer 3 ← loss

Per ogni parametro PyTorch calcola quanto quel parametro ha contribuito alla loss.


13. Ecco perché possono essere allenati miliardi di parametri

Supponiamo che un modello abbia:θ1,θ2,θ3,…,θN\theta_1,\theta_2,\theta_3,\dots,\theta_N

La loss è:L(θ1,θ2,…,θN)L(\theta_1,\theta_2,\dots,\theta_N)

Il backward calcola:∂L∂θ1,∂L∂θ2,…,∂L∂θN\frac{\partial L}{\partial\theta_1}, \frac{\partial L}{\partial\theta_2}, \dots, \frac{\partial L}{\partial\theta_N}

Questi valori costituiscono il gradiente.

Concettualmente:

loss
 │
 ├──→ ∂L/∂θ1
 ├──→ ∂L/∂θ2
 ├──→ ∂L/∂θ3
 ├──→ ∂L/∂θ4
 ├──→ ...
 └──→ ∂L/∂θN

Poi l’optimizer usa questi gradienti per aggiornare i parametri.


14. Collegamento con un LLM

Ora torniamo al caso che ci interessa.

Un LLM produce dei logits:

input
 ↓
Transformer
 ↓
hidden states
 ↓
linear head
 ↓
logits
 ↓
probabilità dei token

Supponiamo che abbia generato:

"The answer is 42"

Per ogni token il modello ha prodotto una distribuzione:

"The"      → probabilità
" answer"  → probabilità
" is"      → probabilità
" 42"      → probabilità

Possiamo calcolare:log⁡P(y∣x)=∑tlog⁡P(yt∣x,y<t)\log P(y|x) = \sum_t \log P(y_t|x,y_{<t})

Poi costruiamo la loss.

La parte davvero potente è che la loss può essere un singolo scalare, ma quel singolo scalare è collegato, attraverso il computational graph, a tutte le operazioni e a tutti i parametri che l’hanno prodotto.

È per questo che una singola chiamata:

loss.backward()

può produrre gradienti per milioni o miliardi di parametri.

Nella prossima approfondiremo ancora con un esempio piu pratico.

Lascia un commento

Il tuo indirizzo email non sarà pubblicato. I campi obbligatori sono contrassegnati *