Salta ai contenuti

Gradienti e derivate direzionali senza analisi

Quando un modello impara, sta scendendo. Sta guardando un paesaggio di errore con miliardi di dimensioni e, in ogni punto, sta chiedendo a una formula la stessa identica domanda: “in che direzione devo muovermi per fare meno errori?”. La formula che risponde si chiama gradiente. Tutto il resto — momentum, Adam, batch normalization, learning rate scheduler — sono raffinamenti su come usare quella risposta. Capire il gradiente, prima ancora di capire la rete neurale, vuol dire capire perché un modello impara.

Tre osservazioni motivano la trattazione, e sono le stesse che guidano i capitoli matematici di questa parte: non spieghiamo la matematica per amor di matematica, la spieghiamo per leggere paper e sistemi reali con consapevolezza.

La prima osservazione è che ogni training run di una rete neurale moderna è una lunga sequenza di passi di discesa del gradiente. Quando in PyTorch si scrive loss.backward() seguito da optimizer.step(), il framework sta calcolando il gradiente della loss rispetto a tutti i parametri del modello e poi sta aggiornando ogni parametro nella direzione opposta. Le linee di codice sono due, ma sotto ci sono milioni di operazioni elementari di derivata e prodotto. Senza un modello mentale del gradiente, quei due comandi sono incantesimi; con il modello mentale, sono geometria applicata.

La seconda è che molte delle patologie del training — vanishing gradient, exploding gradient, plateau, saddle point, instabilità con learning rate troppo aggressivo — sono interpretabili solo se si sa cosa il gradiente sta misurando e perché può misurarlo male. Diagnosticare un training run che diverge dopo 500 passi non è un esercizio di tuning random: è un esercizio di lettura del paesaggio di loss.

La terza è che concetti che apparentemente non hanno nulla a che vedere con il calcolo — direzione semantica negli embedding, attenzione come prodotto scalare, similarità coseno tra vettori — usano la stessa machinery del gradiente: derivate parziali, prodotto scalare, proiezione di un vettore lungo un’altra direzione. Il gradiente è il primo posto in cui questi pezzi si incastrano in modo non banale, ed è la palestra naturale per fissare le intuizioni.

L’obiettivo del capitolo è installare il gradiente come oggetto geometrico — una freccia che vive nello stesso spazio dei parametri e indica la direzione di massima crescita — e mostrare in tre esempi eterogenei come questa freccia, presa con il segno opposto, basta a guidare il training. Senza analisi formale, senza limiti epsilon-delta, senza Hessiana. Con il tempo di farsi capire, come a una lavagna.

Il personaggio fondante è Augustin-Louis Cauchy (1789-1857, matematico francese, uno degli architetti dell’analisi moderna), che il 18 ottobre 1847 presenta all’Académie des Sciences di Parigi una nota di tre pagine intitolata Méthode générale pour la résolution des systèmes d’équations simultanées (Comptes Rendus, vol. 25, pp. 536-538). Il problema che si pone è pratico, non astratto: dato un sistema non lineare di equazioni, come trovare numericamente una soluzione? Idea: somma i quadrati dei residui in una funzione φ(x_1, …, x_n), e cerca il minimo di φ. Per cercare il minimo, parti da un punto e muoviti ripetutamente nella direzione opposta al gradiente. Tre pagine, in francese, ancora oggi leggibili. È il primo enunciato pubblico del metodo dei gradienti.

Cento e quattro anni dopo, nel 1951, Herbert Robbins (1915-2001, matematico americano della Columbia) e Sutton Monro pubblicano A Stochastic Approximation Method sugli Annals of Mathematical Statistics (vol. 22(3), pp. 400-407). Il problema è una variante: invece del gradiente vero, si ha solo un suo stimatore rumoroso. Robbins e Monro mostrano che, se il learning rate η_t decresce nel modo giusto — Σ η_t = ∞ e Σ η_t² < ∞ — il metodo converge comunque. È la fondazione teorica di SGD (Stochastic Gradient Descent), il modo in cui ogni rete neurale viene effettivamente addestrata oggi: con stime del gradiente calcolate su minibatch piccoli rispetto al dataset.

Il salto al deep learning passa per David Rumelhart (1942-2011, psicologo cognitivo americano), Geoffrey Hinton (1947-, informatico britannico-canadese, premio Turing 2018) e Ronald Williams (informatico americano, Northeastern). Il loro articolo Learning representations by back-propagating errors su Nature (vol. 323, 9 ottobre 1986, pp. 533-536) porta nel mainstream la backpropagation: l’applicazione algoritmica della chain rule al grafo di calcolo di una rete multilayer per ottenere il gradiente della loss rispetto a tutti i parametri in un solo passaggio all’indietro. L’idea matematica preesisteva — Seppo Linnainmaa la descrive in una tesi di master a Helsinki nel 1970, Paul Werbos la applica all’apprendimento nella sua PhD thesis a Harvard nel 1974 — ma è il paper Nature del 1986 a renderla canonica.

Il filone moderno degli optimizer arriva con Boris Polyak (1935-2023, matematico sovietico), che nel 1964 introduce il momentum classico nel paper Some methods of speeding up the convergence of iteration methods (USSR Computational Mathematics and Mathematical Physics, vol. 4(5), pp. 1-17). Il metodo è chiamato heavy ball: il passo non dipende solo dal gradiente attuale ma anche dal passo precedente, come una pallina con inerzia. Nel 1983 Yurii Nesterov (1956-, matematico russo-belga) propone la variante accelerata che porta il suo nome. Nel 2014 Diederik Kingma (1982-, ricercatore olandese ora in OpenAI) e Jimmy Ba (Toronto) pubblicano Adam: A Method for Stochastic Optimization (arXiv:1412.6980, poi ICLR 2015), che combina momentum e una normalizzazione coordinate-wise: diventa l’optimizer di default del deep learning per oltre un decennio.

Sul versante del cosa-può-andare-storto, Yann Dauphin e collaboratori pubblicano nel 2014 Identifying and attacking the saddle point problem in high-dimensional non-convex optimization (NeurIPS 2014), articolando perché in spazi ad alta dimensione i punti critici sono in stragrande maggioranza saddle point e non minimi locali. È un cambio di diagnosi: la frase “il modello è bloccato in un minimo locale” è quasi sempre falsa. È più probabilmente bloccato vicino a un saddle.

Decoder rapido dei termini operativi che useremo:

  • derivata in 1D: la pendenza locale di una funzione, il rapporto tra “di quanto cambia f” e “di quanto cambio x” per un cambiamento piccolo di x.
  • derivata parziale: la derivata fatta tenendo ferme tutte le variabili tranne una.
  • gradiente: il vettore che raccoglie tutte le derivate parziali, una per coordinata.
  • derivata direzionale: il tasso di variazione della funzione lungo una direzione qualsiasi, non necessariamente uno degli assi.
  • chain rule: la regola che dice come si deriva una funzione composta.
  • gradient descent: il metodo iterativo che parte da un punto e ad ogni passo si muove nella direzione opposta al gradiente.
  • SGD: la variante in cui il gradiente viene stimato da un sottoinsieme casuale di dati.
  • autograd: differenziazione automatica in modalità reverse, l’algoritmo che i framework moderni usano per calcolare gradienti.
  • learning rate: il coefficiente η che moltiplica il gradiente nell’update; controlla quanto è grande il passo.

Prima di qualsiasi formula, due immagini. Sono due angoli diversi sullo stesso oggetto e conviene tenerli entrambi presenti.

In una dimensione la situazione è familiare. Se f è una funzione di una sola variabile, in un punto x_0 la sua pendenza locale dice quanto velocemente f cambia quando x cambia. Pendenza positiva: f sale; pendenza negativa: f scende; pendenza zero: piatto, almeno localmente. Se vuoi minimizzare f e sei in un punto con pendenza positiva, devi muoverti verso sinistra. Se la pendenza è negativa, verso destra. In entrambi i casi, ti muovi nella direzione opposta al segno della pendenza. È quasi tautologico.

Il problema interessante comincia in due dimensioni. Adesso f dipende da x e y, e in un punto (x_0, y_0) non c’è una pendenza singola: la funzione sale o scende in modo diverso a seconda della direzione in cui ti muovi. Se vai dritto verso est, magari sale rapidamente; se vai verso nord-est, magari sale poco; se vai verso sud, scende. Esistono infinite direzioni e ognuna ha la sua pendenza. La domanda naturale è: tra tutte queste, qual è la direzione che fa salire più velocemente?

La risposta è il gradiente. Il gradiente in (x_0, y_0) è un vettore con due componenti: la pendenza lungo l’asse x e la pendenza lungo l’asse y, presi indipendentemente. E la magia geometrica — è un teorema, lo dimostriamo dopo — è che combinando queste due pendenze come componenti di un vettore si ottiene esattamente la direzione di massima salita. La sua lunghezza è il tasso di salita lungo quella direzione. La direzione di massima discesa è semplicemente il vettore opposto. La pendenza in qualunque altra direzione si ottiene proiettando il gradiente su quella direzione, cioè facendo un prodotto scalare. È lo stesso prodotto scalare visto in Prodotto scalare come proiezione e somiglianza, nel ruolo di “quanto un vettore punta nella direzione di un altro”.

In dimensione qualsiasi, dieci, mille, un miliardo, cambia solo il numero di componenti del gradiente. La logica è la stessa.

L’altra immagine è quella di una rete neurale come un paesaggio. I parametri del modello — pesi e bias di ogni layer, milioni o miliardi di numeri — sono le coordinate di un punto in uno spazio enorme. La loss function L(θ), valutata su un dataset, è un’altezza: per ogni configurazione θ dei parametri restituisce un numero che misura quanto male il modello predice.

Pensa a un terreno collinare, ma con molte più dimensioni di tre. In ogni punto θ del terreno la loss ha un valore. Training significa partire da un punto iniziale θ_0 (tipicamente scelto con un’inizializzazione random ben studiata) e camminare verso il basso. La domanda “in che direzione mi muovo” diventa la domanda “in che direzione il terreno scende più ripido”. La risposta, come sopra, è -∇L(θ): il gradiente negativo. Cammini un piccolo passo in quella direzione, ricalcoli il gradiente nel nuovo punto, ripeti. Cauchy 1847.

Questa metafora ha un limite e va detto subito. Il paesaggio non è davvero un terreno: è un oggetto in uno spazio ad alta dimensione, con proprietà geometriche controintuitive. In particolare, la maggior parte dei punti dove il gradiente si annulla non sono fondi di valle ma saddle point, configurazioni in cui in alcune direzioni la loss sale e in altre scende. Lo dimostra Dauphin et al. nel 2014. Ma per fissare l’intuizione di base — “il modello impara perché scende un paesaggio” — la metafora è preziosa.

I due angoli convergono. Il gradiente è la freccia che, in un punto, ti dice dove salire più ripido. Il training è una sequenza di passi che vanno nella direzione opposta a quella freccia.

graph LR
  a((a)) --> mul((×))
  b((b)) --> mul
  mul --> add((+))
  c((c)) --> add
  add --> sq((^2))
  sq --> y((y))

Figura 2 — D loss landscape with contour lines and gradient arrows pointing outward from minimum, plus a downhill descent trajectory

Ora le formule. Tutte usano solo aritmetica e prodotto scalare.

Sia f una funzione che porta un numero in un numero. La derivata di f in x_0, scritta f’(x_0), è il numero che soddisfa la seguente proprietà: per h piccolo,

f(x_0 + h) ≈ f(x_0) + f’(x_0) · h

L’approssimazione diventa esatta nel limite h → 0. Operativamente, f’(x_0) risponde alla domanda “se aumento x_0 di un pochino h, di quanto cambia f, in prima approssimazione?”. Geometricamente è la pendenza della retta tangente al grafico di f nel punto (x_0, f(x_0)).

Esempio numerico. Sia f(x) = x². Si dimostra che f’(x) = 2x. In x_0 = 3: f(3) = 9, f’(3) = 6. Per h = 0.1 la formula sopra predice f(3.1) ≈ 9 + 6 · 0.1 = 9.6. Il valore vero è f(3.1) = 9.61. Errore 0.01, che è esattamente h². L’approssimazione lineare è buona per h piccolo.

Sia f una funzione che porta un vettore (x_1, x_2, …, x_n) in un numero. La derivata parziale di f rispetto a x_i, scritta ∂f/∂x_i, è ottenuta trattando f come funzione della sola variabile x_i e considerando le altre come costanti.

Esempio. Sia f(x, y) = x² + 3xy + y³. Allora:

  • ∂f/∂x = 2x + 3y (deriva rispetto a x trattando y come costante)
  • ∂f/∂y = 3x + 3y² (deriva rispetto a y trattando x come costante)
Nelpunto(1,2):f/x=2+6=8,f/y=3+12=15.Nel punto (1, 2): ∂f/∂x = 2 + 6 = 8, ∂f/∂y = 3 + 12 = 15.

Il gradiente di f in un punto x è il vettore di tutte le derivate parziali:

f(x)=(f/x1,f/x2,...,f/xn)∇f(x) = (∂f/∂x_1, ∂f/∂x_2, ..., ∂f/∂x_n)

Nell’esempio sopra ∇f(1, 2) = (8, 15). Vive nello stesso spazio del punto: stessa dimensione, stessi assi. È la freccia di cui si parlava nell’intuizione.

L’analogo della formula 1D in dimensione qualsiasi è:

f(x+h)f(x)+f(x)hf(x + h) ≈ f(x) + ∇f(x) · h

Per h piccolo. Il secondo termine è un prodotto scalare tra il gradiente e lo spostamento h. Letto a parole: “la variazione di f, in prima approssimazione, è il prodotto scalare tra il gradiente e lo spostamento”. Tutto il primo ordine del comportamento locale di f è racchiuso in quella formula.

Fissa un versore u (un vettore con ‖u‖ = 1) che indica una direzione nello spazio. La derivata direzionale di f in x lungo u, scritta D_u f(x), è il tasso di variazione di f quando ti muovi da x in direzione u. Si dimostra (e l’abbiamo appena scritto, per h = εu) che

Duf(x)=f(x)uD_u f(x) = ∇f(x) · u

EQUIVALENZA argomentabile: la derivata direzionale è proprio il prodotto scalare tra gradiente e direzione. Non è un’analogia, non è una somiglianza didattica: è l’identità che uno dimostra. Ed è il motivo per cui il prodotto scalare ricompare ovunque ci sia geometria differenziale del primo ordine.

TEOREMA. Tra tutti i versori u, la derivata direzionale D_u f(x) è massima quando u è parallelo a ∇f(x), e in quel caso vale ‖∇f(x)‖. È minima (più negativa) quando u è antiparallelo a ∇f(x).

Prova in due righe. Per la disuguaglianza di Cauchy-Schwarz applicata al prodotto scalare:

f(x)uf(x)u=f(x)∇f(x) · u ≤ ‖∇f(x)‖ · ‖u‖ = ‖∇f(x)‖

con uguaglianza se e solo se u è parallelo a ∇f(x). Per il valore minimo basta cambiare segno. QED.

Conseguenza operativa: per minimizzare f, in un punto x ti muovi nella direzione -∇f(x). È il metodo di Cauchy.

La regola della catena nel caso scalare dice: se y = f(g(x)), allora

dy/dx = f’(g(x)) · g’(x)

Esempio. Sia y = (3x + 1)². Pongo u = 3x + 1, così y = u². Allora dy/du = 2u, du/dx = 3, e dy/dx = 2u · 3 = 6(3x + 1) = 18x + 6. Confronta con la derivata diretta dopo l’espansione (3x+1)² = 9x² + 6x + 1, derivata 18x + 6. Coincide.

Nel caso multivariato la formula generalizza: se la composizione è y = f(g_1(x), g_2(x), …, g_m(x)), allora

dy/dx=Σi(f/gi)(g(x))dgi/dxdy/dx = Σ_i (∂f/∂g_i)(g(x)) · dg_i/dx

In notazione vettoriale è il prodotto degli Jacobian (la matrice delle derivate parziali) lungo la catena. Se la rete neurale ha L layer, la chain rule applicata layer per layer dà il gradiente della loss rispetto ai parametri di ogni layer come prodotto di L Jacobian. Backpropagation è esattamente questo, fatto in modo efficiente: si propaga un vettore all’indietro accumulando il prodotto matrice-per-vettore invece di calcolare le matrici per intero.

graph LR
  a((a)) --> mul((×))
  b((b)) --> mul
  mul --> add((+))
  c((c)) --> add
  add --> sq((^2))
  sq --> y((y))

Figura 2 — grafo di computazione minimo per y = (a·b + c)² con frecce forward in nero e frecce backward annotate con la derivata locale su ogni edge, a illustrare la backpropagation

Il metodo di Cauchy, in una formula:

θt+1=θtηL(θt)θ_{t+1} = θ_t - η · ∇L(θ_t)

Si parte da θ_0, si calcola il gradiente nel punto attuale, si fa un passo nella sua direzione opposta scalato dal learning rate η, si ripete. È il metodo del passo, e qui non ci sono analogie: è il metodo, alla lettera.

Perché funziona, in due righe? Per la linearizzazione locale, L(θ + h) ≈ L(θ) + ∇L(θ) · h. Se scegli h = -η ∇L(θ), allora L(θ + h) ≈ L(θ) - η · ‖∇L(θ)‖². Il secondo termine è non positivo, quindi la loss decresce, almeno finché η è abbastanza piccolo da rendere valida l’approssimazione lineare. Se η è troppo grande, l’approssimazione cessa di valere e tutto può andare male: divergenza, oscillazioni, esplosione.

Nel deep learning la loss è una media su un dataset di N esempi:

L(θ)=(1/N)Σi=1Nli(θ)L(θ) = (1/N) Σ_{i=1}^N l_i(θ)

con l_i la loss del singolo esempio. Calcolare ∇L esatto richiede di valutare il gradiente su tutti gli N esempi: per dataset di milioni di esempi è proibitivo per ogni singolo step. SGD sostituisce ∇L con una stima rumorosa calcolata su un minibatch B di esempi (B tipicamente da 32 a qualche migliaio):

∇̂L(θ) = (1/B) Σ_{i ∈ batch} ∇l_i(θ)

Questa stima è non distorta — la sua media su tutti i possibili minibatch è il gradiente vero — ma rumorosa. Robbins e Monro (1951) hanno mostrato che, sotto condizioni standard sul learning rate, l’iterazione converge comunque. In pratica nel deep learning si usano learning rate constanti o programmi cosine, non i learning rate decrescenti rigorosi della teoria, ma il meccanismo è quello: stimi il gradiente da un campione, fai il passo, ripeti.

Il rumore di SGD non è un bug: è una feature. Aiuta a uscire da regioni piatte, attenua l’attrazione dei saddle point, e c’è evidenza empirica che favorisca minimi piatti su minimi sharp, con benefici di generalizzazione.

Le varianti più usate aggiungono memoria al gradiente.

Momentum (Polyak 1964): mantieni una velocità v_t che è una media mobile esponenziale dei gradienti.

vt+1=μvt+L(θt)θt+1=θtηvt+1- v_{t+1} = μ · v_t + ∇L(θ_t) - θ_{t+1} = θ_t - η · v_{t+1}

Tipico μ = 0.9. Smorza oscillazioni in valli strette e accelera lungo direzioni consistenti. Da qui il nome heavy ball: una pallina che rotola con inerzia.

Nesterov accelerated gradient: come momentum, ma il gradiente viene valutato in un punto leggermente avanzato lungo la velocità (look-ahead). Convergenza teoricamente migliore su problemi convessi smooth.

Adam (Kingma-Ba 2014): combina momentum (m_t, media esponenziale dei gradienti) con una normalizzazione coordinate-wise (v_t, media esponenziale dei loro quadrati):

  • m_{t+1} = β_1 · m_t + (1 - β_1) · ∇L(θ_t)
  • v_{t+1} = β_2 · v_t + (1 - β_2) · ∇L(θ_t)² (al quadrato componente per componente)
  • θ_{t+1} = θ_t - η · m_{t+1} / (sqrt(v_{t+1}) + ε)

Hyperparam tipici: β_1 = 0.9, β_2 = 0.999, ε = 10⁻⁸. L’effetto è un learning rate auto-adattivo per coordinata: parametri con gradienti grandi vengono normalizzati di più, parametri con gradienti piccoli ricevono passi relativamente più grandi. È stato l’optimizer di default del deep learning per oltre un decennio. La trattazione completa, con momentum, Nesterov, AdamW e schedulers, è nel capitolo discesa-gradiente.

Ci sono tre famiglie di tecniche per calcolare un gradiente al computer.

  • Differenze finite (numerica): ∂f/∂x_i ≈ (f(x + h e_i) - f(x - h e_i)) / (2h). Sicura, ma costa n+1 valutazioni per un gradiente n-dimensionale e soffre di catastrophic cancellation per h troppo piccolo.
  • Differenziazione simbolica: manipolazione algebrica delle espressioni. Soffre di expression swell: derivate di funzioni lunghe diventano espressioni lunghissime.
  • Differenziazione automatica (autograd): il framework registra ogni operazione elementare durante il forward in un computation graph, poi propaga il gradiente all’indietro applicando la chain rule operazione per operazione. In modalità reverse-mode il costo del gradiente è dello stesso ordine del forward, indipendentemente dal numero di parametri. È quello che fa PyTorch, JAX, TensorFlow. È backpropagation generalizzata.
graph LR
  a((a)) --> mul((×))
  b((b)) --> mul
  mul --> add((+))
  c((c)) --> add
  add --> sq((^2))
  sq --> y((y))

Figura 2 — minimal computation graph for y = (ab + c)^2 with forward and backward arrows annotated with local derivatives*

Tre esempi eterogenei. Numerico a mano, codice numpy from scratch, codice torch realistico.

Esempio 1 — gradient descent a mano su f(x, y) = x² + y²

Sezione intitolata “Esempio 1 — gradient descent a mano su f(x, y) = x² + y²”

Funzione: f(x, y) = x² + y². Minimo globale in (0, 0), valore 0. Gradiente: ∇f = (2x, 2y). Partiamo da (1, 1) con learning rate η = 0.1.

Iter 0: θ = (1, 1), f = 2, ∇f = (2, 2). Update: θ ← (1, 1) - 0.1 · (2, 2) = (0.8, 0.8).

Iter 1: θ = (0.8, 0.8), f = 1.28, ∇f = (1.6, 1.6). Update: θ ← (0.8, 0.8) - (0.16, 0.16) = (0.64, 0.64).

Iter 2: θ = (0.64, 0.64), f = 0.8192. Update: θ ← (0.512, 0.512).

Iter3:θ=(0.512,0.512),f=0.524.Iter 3: θ = (0.512, 0.512), f = 0.524. Iter4:θ=(0.4096,0.4096),f=0.336.Iter 4: θ = (0.4096, 0.4096), f = 0.336.

Pattern: ad ogni iterazione le coordinate vengono moltiplicate per 1 - 2η = 0.8. Decadimento geometrico. Dopo k passi, ‖θ_k‖ = 0.8^k · ‖θ_0‖. Per k = 30 siamo a circa 0.00124, ovvero f ≈ 1.5 · 10⁻⁶. Convergenza pulita.

Cosa succede se η è troppo grande? Prendi f(x) = x² in 1D, ∇f = 2x, update x ← x - η · 2x = (1 - 2η) · x. Se η = 1.1, il fattore è -1.2. Da x_0 = 1: 1, -1.2, 1.44, -1.728, 2.0736, … in modulo cresce. Diverge oscillando. Per f = x² la stabilità richiede 0 < η < 1. Per funzioni più generali la soglia dipende dalla curvatura locale (rigorosamente: dal massimo autovalore dell’Hessiana).

Esempio 2 — regressione lineare con SGD in numpy

Sezione intitolata “Esempio 2 — regressione lineare con SGD in numpy”

Una sola feature: vogliamo trovare w, b tali che y_pred = w · x + b approssimi al meglio i dati. Loss: MSE = (1/N) Σ (y_pred_i - y_i)².

Calcoliamo i gradienti a mano. Sia r_i = w · x_i + b - y_i il residuo dell’esempio i. Allora:

MSE/w=(2/N)ΣrixiMSE/b=(2/N)Σri- ∂MSE/∂w = (2/N) Σ r_i · x_i - ∂MSE/∂b = (2/N) Σ r_i

Implementazione minimale, un minibatch alla volta:

import numpy as np
# dati sintetici: y = 3x + 2 + rumore
np.random.seed(42)
X = np.random.uniform(-5, 5, size=200)
y = 3.0 * X + 2.0 + np.random.normal(0, 0.5, size=200)
# inizializzazione
w, b = 0.0, 0.0
eta = 0.01
batch_size = 16
n_epochs = 50
for epoch in range(n_epochs):
idx = np.random.permutation(len(X))
for start in range(0, len(X), batch_size):
batch = idx[start:start + batch_size]
x_b, y_b = X[batch], y[batch]
# forward
y_pred = w * x_b + b
residual = y_pred - y_b
# gradient
grad_w = (2.0 / len(batch)) * np.sum(residual * x_b)
grad_b = (2.0 / len(batch)) * np.sum(residual)
# update
w -= eta * grad_w
b -= eta * grad_b
if epoch % 10 == 0:
loss = np.mean((w * X + b - y) ** 2)
print(f"epoch {epoch}: w={w:.3f} b={b:.3f} loss={loss:.4f}")

Riga per riga. La permutazione casuale degli indici a ogni epoch decorrelata l’ordine degli esempi. Lo slicing idx[start:start+batchsize]idx[start:start+batch_size] estrae il minibatch. Le tre righe di gradiente sono la traduzione meccanica delle formule sopra. Le due righe di update sono il metodo di Cauchy applicato a w e b separatamente.

Output tipico: epoch 0: w ≈ 1.5, b ≈ 0.7, loss ≈ 30. Epoch 40: w ≈ 3.0, b ≈ 2.0, loss ≈ 0.25 (limite imposto dal rumore della y). Convergenza ai parametri veri.

Esempio 3 — un classificatore minuscolo in PyTorch

Sezione intitolata “Esempio 3 — un classificatore minuscolo in PyTorch”

Stesso meccanismo, framework reale. Usiamo un MLP a un layer nascosto su un dataset toy a due classi.

import torch
import torch.nn as nn
# dati: due gaussiane separabili
torch.manual_seed(0)
N = 500
x_pos = torch.randn(N, 2) + torch.tensor([2.0, 2.0])
x_neg = torch.randn(N, 2) + torch.tensor([-2.0, -2.0])
X = torch.cat([x_pos, x_neg], dim=0)
y = torch.cat([torch.ones(N), torch.zeros(N)]).long()
# modello
model = nn.Sequential(
nn.Linear(2, 16),
nn.ReLU(),
nn.Linear(16, 2),
)
loss_fn = nn.CrossEntropyLoss()
optim = torch.optim.SGD(model.parameters(), lr=0.05)
# training loop
for step in range(200):
optim.zero_grad() # azzera gradienti accumulati
logits = model(X) # forward
loss = loss_fn(logits, y) # cross-entropy
loss.backward() # gradiente di loss rispetto a TUTTI i parametri
optim.step() # θ ← θ - η · ∇loss
if step % 50 == 0:
acc = (logits.argmax(1) == y).float().mean().item()
print(f"step {step}: loss={loss.item():.4f} acc={acc:.3f}")

Cosa succede in loss.backward(). PyTorch ha registrato durante il forward ogni operazione (matmul, ReLU, somma, log-softmax interno alla cross-entropy) in un computation graph dinamico. La chiamata backward() percorre quel grafo all’indietro applicando la chain rule: per ogni nodo intermedio calcola la derivata della loss rispetto al suo output, propaga al suo input moltiplicando per la derivata locale del nodo, e accumula i contributi sui tensori che hanno requiresgrad=Truerequires_grad=True (ovvero i parametri del modello). Risultato: alla fine ogni parametro p ha il suo .grad popolato con ∂loss/∂p.

optim.step() legge .grad per ogni parametro e applica θ ← θ - η · grad. Per SGD puro è esattamente la formula del gradient descent. Per Adam la stessa chiamata applicherebbe la formula con momentum + RMSProp.

optim.zerograd()optim.zero_grad() esiste perché PyTorch accumula i gradienti tra backward() successivi (utile per gradient accumulation su batch grandi che non stanno in memoria); senza azzerare, ogni passo sommerebbe il vecchio gradiente al nuovo, producendo update sbagliati.

Tre righe di codice, e dietro c’è il meccanismo di Cauchy con la chain rule di Rumelhart-Hinton-Williams.

graph LR
  a((a)) --> mul((×))
  b((b)) --> mul
  mul --> add((+))
  c((c)) --> add
  add --> sq((^2))
  sq --> y((y))

Figura 2 — pyramid showing levels: math (Cauchy 1847) at base, algorithm (Rumelhart-Hinton-Williams 1986 backprop) in middle, framework (PyTorch autograd) at top, with three code lines mapped to the three levels

Il gradient descent, nelle sue varianti stocastiche, è il sostrato di praticamente ogni sistema di machine learning moderno. Una rassegna minima, organizzata per dove il gradiente compare.

Training di reti neurali generiche. Ogni rete supervisionata — classificatore di immagini, traduttore neurale, autoregressive language model — è addestrata via SGD o varianti. Il forward calcola la loss, backward() calcola il gradiente, l’optimizer applica l’update. Ripetuto miliardi di volte per i large language model.

Fine-tuning. Adattare un modello pre-addestrato a un dataset specifico è gradient descent con learning rate piccolo, su un sottoinsieme dei parametri (full fine-tuning) o su una manciata di parametri aggiuntivi (LoRA, adapter). La matematica è la stessa, cambiano solo quali tensori sono congelati.

RLHF (reinforcement learning from human feedback). La fase di policy gradient, usata in InstructGPT e nei modelli successivi, calcola un gradiente della reward attesa rispetto ai parametri della policy. È sempre gradient descent, ma il gradiente è stimato via un estimator alla REINFORCE o PPO, non via backprop diretta sulla reward (che non è differenziabile rispetto al token campionato).

Gradient clipping. Quando il gradiente esplode — accade in RNN profonde, in early training di transformer non stabilizzati, o vicino a zone di forte non linearità — si applica un cap sulla norma: se ‖∇L‖ > τ, si rimpiazza il gradiente con τ · ∇L / ‖∇L‖. Standard τ = 1.0. Trick semplice, salva training run interi.

Gradient checkpointing. Per il backward è necessario aver memorizzato le attivazioni del forward. In reti molto profonde questa memoria è il collo di bottiglia. Il gradient checkpointing salva solo un sottoinsieme di attivazioni e ricalcola le altre on demand durante il backward. Memoria O(√L) per L layer, costo computazionale del 30-40% in più. Tecnica di Chen et al. 2016, oggi standard per l’addestramento di modelli di grande scala.

Autograd come API. Frameworks come PyTorch, JAX, TensorFlow espongono autograd come strumento generico, non solo per reti neurali. Si possono ottimizzare hyperparametri, parametri di simulazioni fisiche, geometrie 3D (differentiable rendering), policy di control system. Ovunque ci sia una funzione scalare differenziabile su un vettore di parametri, il gradiente è disponibile gratis.

Il gradient descent funziona, ma ha modi precisi di fallire. Catalogarli è metà del lavoro di un practitioner.

In dimensione bassa l’intuizione del paesaggio collinare suggerisce che i punti dove ∇f = 0 sono quasi sempre minimi o massimi locali. In dimensione alta è falso. Perché un punto critico sia un minimo locale devono essere positivi tutti gli n autovalori della matrice Hessiana (la matrice delle derivate seconde). Se i segni degli autovalori sono “casuali”, la probabilità che siano tutti positivi va come 2⁻ⁿ. In milioni di dimensioni è zero per ogni scopo pratico. Dauphin et al. 2014 articolano questo argomento con evidenza empirica: nei training di reti neurali la stragrande maggioranza dei punti critici sono saddle point, non minimi locali. La frase “il modello è bloccato in un minimo locale” è quasi sempre una diagnosi errata. È più probabilmente bloccato vicino a un saddle, dove il gradiente è piccolo ma in alcune direzioni la loss scende. SGD se la cava bene perché il rumore stocastico aiuta a fuggire: i saddle non sono attractor stabili sotto perturbazioni casuali.

In una rete profonda, il gradiente al primo layer è il prodotto di L Jacobian — uno per layer — moltiplicati lungo la chain rule. Se gli autovalori tipici di questi Jacobian sono in modulo minori di 1, il prodotto va a zero esponenzialmente con L: vanishing gradient. Se sono maggiori di 1, il prodotto esplode: exploding gradient. In entrambi i casi il training dei layer più profondi è guasto. La storia del deep learning è in larga parte la storia delle tecniche per stabilizzare quel prodotto:

  • inizializzazioni Xavier (Glorot-Bengio 2010) e He (He et al. 2015) tarano la varianza dei pesi iniziali per mantenere autovalori vicini a 1;
  • ReLU al posto delle sigmoidi (che saturano e azzerano i gradienti) attenua il vanishing;
  • skip connection (ResNet 2015, vedi resnet-2015 in preparazione) creano una “scorciatoia” che permette al gradiente di passare quasi inalterato;
  • LayerNorm e BatchNorm normalizzano le attivazioni layer per layer, mantenendo le scale sotto controllo;
  • gradient clipping cura sintomatologicamente l’exploding.

Senza queste tecniche, transformer e RNN profondi non sarebbero addestrabili.

Learning rate troppo grande, troppo piccolo, costante

Sezione intitolata “Learning rate troppo grande, troppo piccolo, costante”

L’approssimazione lineare alla base del gradient descent vale solo per passi piccoli. Se η è troppo grande, l’update spinge oltre il regime in cui ∇L(θ) descrive bene il comportamento di L: la loss può salire invece di scendere, oscillare, divergere. Se η è troppo piccolo, ogni passo è quasi inutile e il training è eterno. La regola empirica: parti da un η ragionevole (10⁻³ per Adam, 10⁻¹ per SGD su modelli piccoli), guarda la loss curve, aggiusta. Schedulers come cosine annealing, linear warmup + cosine decay, o step decay sono lo standard moderno. La fase di warmup — i primi 1-10% degli step con η che cresce linearmente da 0 — stabilizza l’inizio del training di grandi modelli quando le statistiche delle attivazioni sono ancora arbitrarie.

Per problemi convessi, il gradient descent ha garanzie di convergenza al minimo globale (Boyd-Vandenberghe 2004). Per le reti neurali la loss non è convessa: ha molti minimi, saddle, plateau. Il fatto sorprendente, ed empirico, è che SGD trova soluzioni che generalizzano bene comunque. Le ragioni sono ancora dibattute: una congettura è che i minimi che SGD trova siano “piatti”, ovvero abbiano un bacino largo, e che la flatness correli con generalizzazione (Hochreiter-Schmidhuber 1997, Keskar et al. 2017). Un’altra è che la geometria del loss landscape sotto sovraparametrizzazione — molti più parametri che esempi — sia particolarmente benigna, con minimi connessi da percorsi a bassa loss (mode connectivity, Garipov et al. 2018). Non c’è una teoria pulita.

ReLU(x) = max(0, x) non è differenziabile in x = 0. In pratica i framework usano subgradient (di solito 0), e nel float discreto il problema non si manifesta quasi mai. Più sottile: l’argmax usato in alcuni layer (hard attention, top-k routing in MoE) non è differenziabile per niente. Si rilassa con softmax / Gumbel-softmax / straight-through estimators. Tema avanzato, qui basta sapere che esistono regioni in cui “il gradiente” deve essere ridefinito.

Il gradiente calcolato in float32 o, peggio, in fp16/bfloat16 può soffrire di underflow (gradienti diventano 0) o overflow. Per il training in mixed precision sono nate tecniche come loss scaling: si moltiplica la loss per un fattore grande prima del backward, si calcola il gradiente in fp16, si divide per lo stesso fattore prima di applicare l’update. Salva i gradienti piccoli dall’underflow. Bfloat16, formato a 16 bit con lo stesso range di float32 ma metà della mantissa, è oggi il default per addestrare grandi modelli su acceleratori moderni perché evita la maggior parte di questi problemi senza loss scaling, al prezzo di un po’ di precisione.

Una tentazione naturale è ridurre il rumore di SGD usando batch sempre più grandi. Empiricamente, oltre una certa scala, batch troppo grandi peggiorano la generalizzazione anche se il gradiente è meno rumoroso (Keskar et al. 2017). Il rumore di SGD non è un difetto da minimizzare: contribuisce alla regolarizzazione implicita. Esiste una zona Goldilocks per batch size, e il famoso “scaling rule” lineare lega la batch size al learning rate ottimale (Goyal et al. 2017): se raddoppi B, raddoppia η, almeno fino a una soglia.

Per funzioni non differenziabili o per le quali un gradiente non ha senso (loss combinatorie, reward black-box di un ambiente RL, hyperparametri categorici), il gradient descent non si applica direttamente. Servono surrogati: REINFORCE per stimare gradienti via campionamento, evolution strategies, Bayesian optimization, random search. Sono lenti, ma quando il gradiente non c’è è quello che resta.

Anche fuori dai saddle point, il loss landscape di reti profonde ha vaste regioni in cui il gradiente è piccolo ma non zero: plateau. In questi tratti il gradient descent rallenta drasticamente — passi proporzionali alla norma del gradiente, e se il gradiente è piccolo i passi sono piccoli — ma non si ferma. Il rimedio standard è momentum, che accumula direzione anche quando il gradiente istantaneo è debole, e Adam, che normalizza per l’ordine di grandezza locale dei gradienti rendendo i passi indipendenti dalla scala. È una delle ragioni per cui “Adam funziona quasi sempre” anche su loss landscape complicate.

Ultima crepa, di natura concettuale. Il gradient descent ottimizza la loss sul training set. Quello che ti interessa davvero è la loss su dati nuovi (test set, distribuzione vera). Ridurre la training loss a zero non implica ridurre la test loss: implica overfitting se il modello è troppo capiente per i dati. La regolarizzazione (weight decay, dropout, data augmentation, early stopping), il rumore di SGD, e la geometria dei minimi piatti contribuiscono a far sì che minimizzare la training loss correli con minimizzare la test loss. È la differenza tra “il gradiente sta facendo il suo lavoro” e “il modello sta imparando qualcosa di utile”. Tema che torna in tutto il deep learning e che il gradiente da solo non risolve.

  • Vettori, spazi vettoriali, intuizione geometrica: il gradiente è un vettore, vive nello spazio dei parametri. Tutto il linguaggio di vettore-modulo-direzione è quello standard di lì.
  • Prodotto scalare come proiezione e somiglianza: la derivata direzionale è ∇f · u. Il prodotto scalare, in incognito, è il cuore della geometria del gradiente.
  • Norme, distanze, similarità: la lunghezza ‖∇f‖ misura il tasso di crescita; gradient clipping usa la norma euclidea direttamente.
  • Matrici come trasformazioni: la chain rule multivariata è prodotto di Jacobian. La curvatura locale è descritta dalla matrice Hessiana.
  • Autovalori e autovettori a intuizione: convergenza di gradient descent su funzioni quadratiche è governata dagli autovalori dell’Hessiana; condition number = rapporto autovalore massimo su minimo.
  • Entropia, cross-entropy, KL divergence: è la loss che si minimizza nel 99% dei training run di language model; il gradiente di cross-entropy ha forma chiusa elegante.
  • discesa-gradiente: trattazione completa di SGD, momentum, Nesterov, Adam, AdamW, learning rate schedulers, warmup. Questo capitolo prepara il terreno.
  • backpropagation (in preparazione): l’algoritmo concreto che applica la chain rule al computation graph. Qui ne abbiamo dato l’intuizione, lì la meccanica algoritmica passo per passo.
  • ottimizzazione (in preparazione): inquadramento generale del problema, condizioni di ottimalità, casi convessi e non convessi.
  • ResNet 2015: le skip connection sono la cura strutturale al vanishing gradient. La storia delle reti profonde è la storia di come domare il prodotto degli Jacobian.
  • Goodfellow, Bengio, Courville, Deep Learning, MIT Press, 2016. Capitolo 4 (Numerical Computation) per gradiente, condizionamento, Hessiana. Capitolo 8 (Optimization for Training Deep Models) per SGD, momentum, Adam, batch normalization come stabilizzatore. Gratis online, è il riferimento standard.
  • Sebastian Ruder, An overview of gradient descent optimization algorithms, arXiv:1609.04747, 2016. Survey compatto: ogni variante con formula e intuizione, da vanilla SGD ad AdamW.
  • Boyd, Vandenberghe, Convex Optimization, Cambridge University Press, 2004. Per la teoria pulita sul caso convesso, condizioni di ottimalità, analisi di convergenza. Gratis online.
  • 3Blue1Brown, serie Essence of Calculus (2017) e i video di Neural Networks sulla chain rule e backpropagation calculus. Standard per intuizioni geometriche, animazioni eccellenti.
  • Cauchy 1847, Méthode générale pour la résolution des systèmes d’équations simultanées, Comptes Rendus, vol. 25. Tre pagine, in francese, ancora leggibili. Il documento originale del metodo. Consigliato anche solo per il piacere di leggere il primo enunciato di un’idea che governa il deep learning del 2026.
  • Kingma, Ba, Adam: A Method for Stochastic Optimization, ICLR 2015 (arXiv:1412.6980). L’optimizer di default del deep learning per oltre un decennio: leggerlo aiuta a capire perché momentum + RMSProp insieme sono diventati lo standard, e dove invece SGD con momentum batte ancora Adam (training di reti convoluzionali su ImageNet, in alcuni regimi).