Introduzione: Come le Reti Neurali Imparano
Se l'algebra lineare e il linguaggio del machine learning, il calcolo differenziale e il suo motore di apprendimento. Ogni volta che un modello migliora le sue predizioni, lo fa grazie a un processo chiamato gradient descent, che si basa interamente su derivate e gradienti. Senza il calcolo, le reti neurali non potrebbero apprendere.
In questo articolo vedremo come le derivate parziali ci dicono in quale direzione modificare i pesi, come la chain rule rende possibile la backpropagation, e come tutto si implementa in pratica con NumPy.
Cosa Imparerai
- Derivate: il concetto di tasso di variazione
- Derivate parziali e il vettore gradiente
- Chain rule: come comporre derivate (il cuore della backpropagation)
- Grafi computazionali: forward e backward pass
- Jacobiano e Hessiano: informazioni di ordine superiore
- Implementazione manuale della backpropagation in NumPy
Derivate: Il Tasso di Variazione
La derivata di una funzione f(x) in un punto ci dice quanto velocemente cambia il valore della funzione quando x cambia di una quantità infinitesimale:
Intuizione: la derivata e la pendenza della funzione in un punto. Se e positiva, la funzione sta salendo; se e negativa, sta scendendo; se e zero, siamo in un punto stazionario (minimo, massimo o punto di sella).
Derivate delle funzioni di attivazione comuni nel deep learning:
perchè e Importante: la derivata della sigmoid ha un massimo di 0.25 (quando x = 0). Questo significa che ad ogni layer il gradiente viene moltiplicato per un fattore massimo di 0.25, causando il famoso problema del vanishing gradient nelle reti profonde. Ecco perchè ReLU (derivata = 1 per x > 0) e preferita.
Derivate Parziali e il Gradiente
Quando la funzione dipende da più variabili (come una loss function che dipende da tutti i pesi), calcoliamo le derivate parziali: la derivata rispetto a ciascuna variabile, mantenendo le altre fisse.
Per una funzione f(x_1, x_2, \\ldots, x_n), il gradiente e il vettore di tutte le derivate parziali:
Intuizione cruciale: il gradiente punta nella direzione di massima crescita della funzione. Per minimizzare la loss, ci muoviamo nella direzione opposta al gradiente:
dove \\eta e il learning rate e L(\\theta) la loss function. Questa e la formula fondamentale del gradient descent.
import numpy as np
# Esempio: f(x, y) = x^2 + 3xy + y^2
# Gradiente: [2x + 3y, 3x + 2y]
def f(x, y):
return x**2 + 3*x*y + y**2
def gradient_f(x, y):
df_dx = 2*x + 3*y
df_dy = 3*x + 2*y
return np.array([df_dx, df_dy])
# Punto di partenza
x, y = 3.0, 2.0
print(f"f({x}, {y}) = {f(x, y)}")
print(f"Gradiente: {gradient_f(x, y)}")
# Gradient descent
lr = 0.1
for step in range(20):
grad = gradient_f(x, y)
x -= lr * grad[0]
y -= lr * grad[1]
if step % 5 == 0:
print(f"Step {step}: x={x:.4f}, y={y:.4f}, f={f(x, y):.6f}")
La Chain Rule: Il Cuore della Backpropagation
La chain rule (regola della catena) e il principio matematico che rende possibile l'addestramento delle reti neurali profonde. Se abbiamo funzioni composte y = f(g(x)), la derivata e:
Con più funzioni composte y = f_1(f_2(f_3(x))):
Una rete neurale e esattamente una composizione di funzioni: ogni layer applica una trasformazione lineare seguita da una attivazione non-lineare. La chain rule ci permette di calcolare come la loss cambia rispetto a ogni peso, attraversando tutti i layer in ordine inverso.
Esempio: Backpropagation su un Neurone Singolo
Consideriamo un singolo neurone con loss MSE:
Il gradiente rispetto a w con la chain rule:
dove z = wx + b. Ogni termine nella catena ha un significato preciso: l'errore, la sensibilita dell'attivazione, e l'input.
import numpy as np
def sigmoid(x):
return 1 / (1 + np.exp(-x))
def sigmoid_deriv(x):
s = sigmoid(x)
return s * (1 - s)
# Singolo neurone: forward e backward pass
x = 2.0 # input
y = 1.0 # target
w = 0.5 # peso
b = 0.1 # bias
lr = 0.1
for epoch in range(50):
# Forward pass
z = w * x + b
y_hat = sigmoid(z)
loss = (y - y_hat) ** 2
# Backward pass (chain rule)
dL_dyhat = 2 * (y_hat - y) # dL/d(y_hat)
dyhat_dz = sigmoid_deriv(z) # d(y_hat)/dz
dz_dw = x # dz/dw
dz_db = 1.0 # dz/db
dL_dw = dL_dyhat * dyhat_dz * dz_dw # Chain rule completa
dL_db = dL_dyhat * dyhat_dz * dz_db
# Aggiorna pesi
w -= lr * dL_dw
b -= lr * dL_db
if epoch % 10 == 0:
print(f"Epoch {epoch}: loss={loss:.6f}, w={w:.4f}, b={b:.4f}")
Grafi Computazionali: Visualizzare Forward e Backward
Un grafo computazionale rappresenta una funzione come un albero di operazioni elementari. Ogni nodo esegue un'operazione semplice (somma, prodotto, attivazione) e durante il backward pass il gradiente fluisce attraverso il grafo in ordine inverso grazie alla chain rule.
Consideriamo L = (\\sigma(w_1 x_1 + w_2 x_2 + b) - y)^2:
- Forward: z_1 = w_1 x_1, z_2 = w_2 x_2, s = z_1 + z_2 + b, a = \\sigma(s), L = (a - y)^2
- Backward: calcoliamo \\frac{\\partial L}{\\partial a}, poi \\frac{\\partial L}{\\partial s}, poi \\frac{\\partial L}{\\partial w_1} e \\frac{\\partial L}{\\partial w_2}
Questo e esattamente ciò che PyTorch e TensorFlow fanno automaticamente con l'automatic differentiation.
Jacobiano e Hessiano
Il Jacobiano generalizza il gradiente a funzioni vettoriali. Se \\mathbf{f}: \\mathbb{R}^n \\to \\mathbb{R}^m, il Jacobiano e una matrice m \\times n:
L'Hessiano e la matrice delle derivate seconde, e ci da informazioni sulla curvatura della loss function:
Gli autovalori dell'Hessiano determinano se un punto critico e un minimo (tutti positivi), massimo (tutti negativi), o punto di sella (misti). Nei problemi di ottimizzazione di reti neurali, i punti di sella sono molto più comuni dei minimi locali.
Backpropagation Completa: Rete a 2 Layer
import numpy as np
np.random.seed(42)
# Dataset XOR (non-linearmente separabile)
X = np.array([[0, 0], [0, 1], [1, 0], [1, 1]])
y = np.array([[0], [1], [1], [0]])
# Inizializzazione pesi
W1 = np.random.randn(2, 4) * 0.5 # (2 input, 4 hidden)
b1 = np.zeros((1, 4))
W2 = np.random.randn(4, 1) * 0.5 # (4 hidden, 1 output)
b2 = np.zeros((1, 1))
def sigmoid(x):
return 1 / (1 + np.exp(-np.clip(x, -500, 500)))
lr = 1.0
for epoch in range(10000):
# === FORWARD PASS ===
z1 = X @ W1 + b1 # (4, 2) @ (2, 4) = (4, 4)
a1 = sigmoid(z1) # Attivazione hidden
z2 = a1 @ W2 + b2 # (4, 4) @ (4, 1) = (4, 1)
a2 = sigmoid(z2) # Output
# Loss: MSE
loss = np.mean((y - a2) ** 2)
# === BACKWARD PASS (Chain Rule) ===
m = X.shape[0]
# Gradiente output layer
dL_da2 = 2 * (a2 - y) / m
da2_dz2 = a2 * (1 - a2) # Derivata sigmoid
dz2 = dL_da2 * da2_dz2 # (4, 1)
dW2 = a1.T @ dz2 # (4, 4).T @ (4, 1) = (4, 1)
db2 = np.sum(dz2, axis=0, keepdims=True)
# Gradiente hidden layer (chain rule continua!)
da1 = dz2 @ W2.T # (4, 1) @ (1, 4) = (4, 4)
dz1 = da1 * (a1 * (1 - a1)) # Derivata sigmoid
dW1 = X.T @ dz1 # (2, 4).T @ (4, 4) = (2, 4)
db1 = np.sum(dz1, axis=0, keepdims=True)
# === AGGIORNAMENTO PESI ===
W2 -= lr * dW2
b2 -= lr * db2
W1 -= lr * dW1
b1 -= lr * db1
if epoch % 2000 == 0:
print(f"Epoch {epoch}: Loss = {loss:.6f}")
# Risultato finale
predictions = np.round(a2, 2)
print(f"\nPredizioni finali:\n{predictions.flatten()}")
print(f"Target: {y.flatten()}")
Gradient Checking: Verificare i Gradienti
Per assicurarci che la backpropagation sia implementata correttamente, possiamo confrontare i gradienti analitici con quelli numerici calcolati con differenze finite:
con \\epsilon \\approx 10^{-7}. La differenza relativa tra gradiente analitico e numerico dovrebbe essere inferiore a 10^{-5}.
import numpy as np
def numerical_gradient(f, params, idx, epsilon=1e-7):
"""Calcola gradiente numerico per verifica."""
original = params[idx].copy()
params[idx] = original + epsilon
loss_plus = f()
params[idx] = original - epsilon
loss_minus = f()
params[idx] = original
return (loss_plus - loss_minus) / (2 * epsilon)
# Esempio semplice: f = (w*x - y)^2
w = np.array([0.5])
x, y_true = 2.0, 3.0
def compute_loss():
return (w[0] * x - y_true) ** 2
# Gradiente analitico
grad_analytical = 2 * (w[0] * x - y_true) * x
# Gradiente numerico
grad_numerical = numerical_gradient(compute_loss, [w], 0)
print(f"Analitico: {grad_analytical:.8f}")
print(f"Numerico: {grad_numerical:.8f}")
print(f"Diff relativa: {abs(grad_analytical - grad_numerical) / max(abs(grad_analytical), 1e-8):.2e}")
Riepilogo e Connessioni con il ML
Punti Chiave da Ricordare
- Derivata: misura il tasso di variazione, indica la pendenza della funzione
- Gradiente \\nabla L: punta nella direzione di massima crescita della loss
- Gradient descent: \\theta \\leftarrow \\theta - \\eta \\nabla L - ci muoviamo opposti al gradiente
- Chain rule: permette di calcolare gradienti attraverso composizioni di funzioni
- Backpropagation: applicazione della chain rule al grafo computazionale della rete
- Vanishing gradient: la sigmoid ha derivata max 0.25, ReLU risolve con derivata 1
Nel Prossimo Articolo: esploreremo probabilità e statistica per il ML. Vedremo il teorema di Bayes, le distribuzioni, Maximum Likelihood Estimation, e come quantificare l'incertezza nelle predizioni.







