Immagina una catena di montaggio. Il materiale grezzo entra da un lato; ogni postazione lo trasforma un po', passandolo alla successiva; alla fine esce il prodotto finito. Nessuna postazione, da sola, fa qualcosa di impressionante, ma la sequenza sì. Una rete neurale a strati funziona così: l'ingresso (le feature) attraversa una serie di strati, e ciascuno ne produce una rappresentazione nuova, più adatta al compito finale dell'ultimo. Nelle lezioni precedenti hai studiato il singolo neurone e hai visto che due strati risolvono lo XOR.
Ora impili davvero molti neuroni, e impari la cosa più importante del lavoro quotidiano con le reti: seguire con precisione le forme (shape) dei tensori da un capo all'altro. Il percorso porta a un passaggio in avanti completo scritto a mano, al conto dei parametri, al perché l'inizializzazione dei pesi non è un dettaglio e a una rete che approssima una funzione con un errore che puoi misurare e far scendere a piacere, per capire che cosa afferma (e che cosa non afferma) il teorema di approssimazione universale.
Il vocabolario di una rete
- Strato (layer): un gruppo di neuroni che ricevono tutti lo stesso ingresso e producono ciascuno un'uscita. L'uscita di uno strato è un vettore con tanti numeri quanti sono i suoi neuroni.
- Strato di ingresso: non calcola nulla, è semplicemente il vettore delle feature.
- Strati nascosti (hidden): gli strati intermedi. Si chiamano così perché il loro valore non è osservato direttamente né nei dati né nell'uscita.
- Strato di uscita: l'ultimo, la cui attivazione dipende dal problema (lineare per la regressione, sigmoide per la classificazione binaria, softmax per la multiclasse).
- Rete densa (fully connected, feed-forward): ogni neurone di uno strato è collegato a tutti quelli dello strato successivo, e l'informazione procede solo in avanti.
- Batch: un gruppo di m esempi elaborati insieme, in un'unica moltiplicazione di matrici. È la ragione per cui i tensori hanno una dimensione in più rispetto a «un esempio».
Le sei voci si possono leggere in qualsiasi ordine, ma disegnano insieme una sola struttura: l'ingresso entra, gli strati nascosti lo trasformano uno dopo l'altro, lo strato di uscita lo converte in ciò che serve al problema. Il batch non è un livello della rete ma una dimensione dei dati: quando in seguito vedrai la forma (m, n), la m conta gli esempi e la n i neuroni dello strato, e le due dimensioni non vanno mai confuse.
Notazione e convenzioni sulle forme
Conviene scegliere una convenzione e rispettarla ovunque. Usiamo quella abituale con NumPy, in cui la prima dimensione è sempre il batch: un batch di m esempi con n feature è una matrice di forma (m, n). Lo strato l ha n_l neuroni. Indichiamo con A⁰ = X l'ingresso, con W^l la matrice dei pesi dello strato l e con b^l il suo vettore dei bias. La regola da ricordare è una sola: W^l ha forma (n_{l−1}, n_l), cioè (neuroni dello strato precedente, neuroni di questo strato). Con questa convenzione il prodotto A^{l−1} · W^l ha forma (m, n_{l−1}) × (n_{l−1}, n_l) = (m, n_l): le dimensioni interne si semplificano e restano quelle esterne.
Il disegno mostra una rete 4 → 5 → 3 → 1: sotto a ogni strato c'è la forma dell'attivazione per un batch di m esempi, e tra due strati la forma della matrice dei pesi che li collega. Se riesci a verificare a occhio che ogni coppia di forme si incastra, sei già in grado di individuare l'errore più frequente del lavoro con le reti.
Una nota sulle librerie, verificata con PyTorch 2.12.0 (vedi la sezione sulle trappole di forma e di tipo): nn.Linear(n_in, n_out) memorizza i pesi con forma (n_out, n_in), cioè trasposta rispetto alla nostra convenzione, e calcola x · Wᵀ + b. Il risultato matematico è lo stesso; se estrai i pesi di una libreria per ispezionarli, controlla quale delle due convenzioni usa.
Il passaggio in avanti
Per ogni strato l = 1, ..., L si calcolano due cose, esattamente come per il singolo neurone ma per tutti i neuroni e tutti gli esempi insieme:
Z^l = A^{l−1} · W^l + b^l preattivazione, forma (m, n_l) [b^l si somma a ogni riga: broadcasting]
A^l = f_l(Z^l) attivazione, forma (m, n_l) [f_l applicata elemento per elemento]
L'uscita della rete è A^L. Il nome «forward pass» (passaggio in avanti) indica che l'informazione scorre dall'ingresso all'uscita; nella prossima lezione, per addestrare, farà il percorso inverso (la backpropagation). Il broadcasting merita attenzione: b^l ha forma (n_l,) e A^{l−1}W^l ha forma (m, n_l); NumPy «allarga» il vettore sommandolo a ciascuna delle m righe. È comodo, ma è anche il meccanismo con cui un errore di forma può passare in silenzio.
Un esempio completo con i numeri, a mano
Prendi la rete più piccola con uno strato nascosto: 2 ingressi, 2 neuroni nascosti con ReLU, 1 uscita lineare. Un solo esempio, x = (1, 2). Pesi e bias scelti per comodità di calcolo:
W¹ = [[ 0.5, −1.0], b¹ = [0.0, 1.0] forma (2, 2): riga i = ingresso i, colonna j = neurone j
[ 0.25, 0.5]]
Z¹ = x · W¹ + b¹ = [1·0.5 + 2·0.25 + 0.0 , 1·(−1.0) + 2·0.5 + 1.0] = [1.0, 1.0]
A¹ = ReLU(Z¹) = [1.0, 1.0]
W² = [[2.0], b² = [0.5] forma (2, 1)
[−1.0]]
Z² = A¹ · W² + b² = 1.0·2.0 + 1.0·(−1.0) + 0.5 = 1.5 uscita lineare: ŷ = 1.5
Calcola con calma i prodotti: la colonna j di W¹ contiene i pesi che arrivano al neurone j, quindi Z¹_j è il prodotto scalare tra x e la colonna j, più il bias. Verifichiamo con NumPy, mostrando anche le forme di ogni passaggio:
import numpy as np
x = np.array([[1.0, 2.0]]) # un batch di m = 1 esempio: forma (1, 2)
W1 = np.array([[0.5, -1.0], [0.25, 0.5]]) # (2, 2)
b1 = np.array([0.0, 1.0]) # (2,)
W2 = np.array([[2.0], [-1.0]]) # (2, 1)
b2 = np.array([0.5]) # (1,)
Z1 = x @ W1 + b1 # (1, 2) @ (2, 2) -> (1, 2), poi + b1 con broadcasting
A1 = np.maximum(0, Z1) # ReLU
Z2 = A1 @ W2 + b2 # (1, 2) @ (2, 1) -> (1, 1)
print("Z1 =", Z1, " forma", Z1.shape)
print("A1 =", A1, " forma", A1.shape)
print("uscita =", Z2, " forma", Z2.shape)
Z1 = [[1. 1.]] forma (1, 2)
A1 = [[1. 1.]] forma (1, 2)
uscita = [[1.5]] forma (1, 1)
Cosa fa il codice. L'operatore @ è il prodotto matriciale. La riga di Z1 verifica il calcolo fatto a mano: 1.0 e 1.0. np.maximum(0, Z1) applica la ReLU a ogni elemento. L'uscita 1.5 coincide con il conto. Abituati a stampare le forme: in un programma più grande, la riga print(Z1.shape) è lo strumento di debug più economico che esista.
Contare i parametri
I parametri sono i numeri che l'addestramento deve imparare: tutti i pesi e tutti i bias. Lo strato l ha una matrice W^l con n_{l−1}·n_l elementi e un bias con n_l elementi, quindi:
parametri dello strato l = n_{l−1} · n_l + n_l = n_l · (n_{l−1} + 1)
parametri totali = somma su tutti gli strati
Per la rete del disegno (4 → 5 → 3 → 1): 5·(4+1) = 25, poi 3·(5+1) = 18, poi 1·(3+1) = 4, in totale 47 parametri. Il numero di parametri è la misura della «capacità» di una rete, e determina memoria e tempo di calcolo. Una rete 784 → 128 → 64 → 10 (un classificatore per immagini 28×28) ha 128·785 + 64·129 + 10·65 = 100 480 + 8 256 + 650 = 109 386 parametri: più degli esempi di molti dataset, ed è una prima ragione per cui l'overfitting è un rischio concreto.
Una rete completa a mano: 4 → 8 → 3 sul dataset Iris
Mettiamo tutto in una piccola classe, con un'inizializzazione ragionevole (vedremo tra poco perché) e un forward pass per un problema a 3 classi, con softmax in uscita. Non la addestriamo ancora: vogliamo verificare forme e correttezza di un modello non addestrato, un controllo che si fa prima di ogni addestramento serio.
from sklearn.datasets import load_iris
from sklearn.preprocessing import StandardScaler
Xi, yi = load_iris(return_X_y=True)
Xi = StandardScaler().fit_transform(Xi) # per un controllo di forme; in un progetto reale, fit sul solo training
def softmax(Z):
Z = Z - Z.max(axis=1, keepdims=True)
E = np.exp(Z)
return E / E.sum(axis=1, keepdims=True)
class Rete:
def __init__(self, dimensioni, seed=0):
rng = np.random.default_rng(seed)
self.W, self.b = [], []
for n_in, n_out in zip(dimensioni[:-1], dimensioni[1:]):
self.W.append(rng.normal(size=(n_in, n_out)) * np.sqrt(2.0 / n_in)) # scala "He": vedi sotto
self.b.append(np.zeros(n_out))
def forward(self, X, verboso=False):
A = X
for l, (W, b) in enumerate(zip(self.W, self.b), start=1):
Z = A @ W + b
ultimo = (l == len(self.W))
A = softmax(Z) if ultimo else np.maximum(0, Z) # ReLU nascosti, softmax in uscita
if verboso:
print("strato %d: W %s Z %s -> A %s" % (l, W.shape, Z.shape, A.shape))
return A
def n_parametri(self):
return sum(W.size + b.size for W, b in zip(self.W, self.b))
rete = Rete([4, 8, 3])
P = rete.forward(Xi, verboso=True)
perdita = -np.log(P[np.arange(len(yi)), yi]).mean()
print("parametri:", rete.n_parametri(), " (8*(4+1) + 3*(8+1) = %d)" % (8 * 5 + 3 * 9))
print("le righe di P sommano a 1:", np.allclose(P.sum(axis=1), 1.0), " forma di P:", P.shape)
print("cross-entropia non addestrata: %.4f (ln 3 = %.4f)" % (perdita, np.log(3)))
strato 1: W (4, 8) Z (150, 8) -> A (150, 8)
strato 2: W (8, 3) Z (150, 3) -> A (150, 3)
parametri: 67 (8*(4+1) + 3*(8+1) = 67)
le righe di P sommano a 1: True forma di P: (150, 3)
cross-entropia non addestrata: 1.5728 (ln 3 = 1.0986)
Cosa fa il codice. La classe memorizza una lista di matrici W e di vettori b, uno per strato, costruiti a partire dalla lista delle dimensioni [4, 8, 3]: zip(dimensioni[:-1], dimensioni[1:]) produce le coppie (4, 8) e (8, 3), cioè (neuroni in ingresso, neuroni in uscita) di ogni strato. forward scorre gli strati: A @ W + b, poi ReLU per i nascosti e softmax per l'ultimo. Con verboso=True stampa le forme di ogni passaggio: 150 esempi di Iris, una matrice (150, 4) che diventa (150, 8) e poi (150, 3). La cross-entropia di una rete non addestrata ha come riferimento ln 3 ≈ 1,0986, il valore che si avrebbe se il modello assegnasse a ciascuna delle 3 classi probabilità uguale 1/3.
Qui si ottiene 1,57: un po' più alta, perché con l'inizializzazione He i punteggi in uscita non sono esattamente nulli e il modello è «sicuro per caso» di qualche classe sbagliata. Va bene: l'ordine di grandezza è giusto. Se la perdita iniziale fosse molto diversa (per esempio 15, o nan) avresti un bug o un'inizializzazione sbagliata, e conviene accorgersene prima di spendere ore di addestramento. È uno dei controlli di sanità più utili di tutta la pratica.
L'inizializzazione dei pesi non è un dettaglio
Perché la classe usa np.sqrt(2.0 / n_in) come scala dei pesi? Perché a ogni strato il segnale viene moltiplicato per una matrice di pesi. Se questi sono troppo piccoli, il segnale si smorza strato dopo strato fino a svanire; se sono troppo grandi, esplode. In entrambi i casi non c'è più informazione utile per imparare, né segnale di gradiente che torni indietro. Misuriamolo: facciamo passare 1000 vettori di 100 numeri casuali attraverso 10 strati da 100 neuroni, con scale diverse dei pesi, e guardiamo la deviazione standard delle attivazioni al primo, al quinto e al decimo strato.
rng = np.random.default_rng(0)
X100 = rng.normal(size=(1000, 100))
scale = {"deviazione 0.01": lambda n: 0.01,
"deviazione 1": lambda n: 1.0,
"Xavier sqrt(1/n)": lambda n: np.sqrt(1.0 / n),
"He sqrt(2/n)": lambda n: np.sqrt(2.0 / n)}
print("scala dei pesi attivazione std strato 1 std strato 5 std strato 10")
for nome, scala in scale.items():
for att in ("tanh", "relu"):
A, std = X100, []
for _ in range(10):
W = rng.normal(size=(100, 100)) * scala(100)
Z = A @ W
A = np.tanh(Z) if att == "tanh" else np.maximum(0, Z)
std.append(A.std())
print("%-20s %-10s %12.5f %12.5f %13.5f" % (nome, att, std[0], std[4], std[9]))
scala dei pesi attivazione std strato 1 std strato 5 std strato 10
deviazione 0.01 tanh 0.09867 0.00001 0.00000
deviazione 0.01 relu 0.05850 0.00000 0.00000
deviazione 1 tanh 0.95916 0.95650 0.95732
deviazione 1 relu 5.82850 15393.49385 323956922.93796
Xavier sqrt(1/n) tanh 0.62508 0.31668 0.22479
Xavier sqrt(1/n) relu 0.58026 0.15518 0.02418
He sqrt(2/n) tanh 0.71849 0.56174 0.55186
He sqrt(2/n) relu 0.82523 1.04275 1.41455
Cosa fa il codice. Genera i dati di ingresso, poi per ogni combinazione di scala dei pesi e attivazione impila 10 strati casuali e registra quanto sono «grandi» le attivazioni (la loro deviazione standard). Con pesi di deviazione 0,01 il segnale svanisce già al quinto strato (praticamente zero): un addestramento non può partire. Con deviazione 1 e ReLU le attivazioni crescono di fattori enormi (dell'ordine di 10⁸ al decimo strato): la rete esplode.
Con tanh e deviazione 1 le uscite si incollano ai valori ±1 (saturazione, con derivata quasi nulla). Le inizializzazioni di Xavier (o Glorot; qui nella variante semplificata con varianza 1/n_in, mentre l'originale di Glorot e Bengio usa 2/(n_in + n_out), pensata per tanh e sigmoide) e di He (con varianza 2/n_in, pensata per la ReLU) mantengono le attivazioni in un intervallo ragionevole: con He e ReLU la deviazione resta tra 0,8 e 1,4 per tutti i 10 strati.
Il fattore 2 di He compensa il fatto che la ReLU azzera circa metà delle attivazioni; la tabella lo conferma, perché Xavier applicata a una ReLU lascia scendere la deviazione da 0,58 a 0,02 in dieci strati. Ogni attivazione ha la sua inizializzazione adatta.
Il teorema di approssimazione universale
Quanto può essere complessa la funzione che una rete è in grado di rappresentare? Una risposta fondamentale è il teorema di approssimazione universale (Cybenko, 1989, per attivazioni sigmoidi; Hornik, 1991, per una classe più ampia di attivazioni). Enunciato in parole: una rete con un solo strato nascosto e un numero finito, ma sufficientemente grande, di neuroni con una attivazione non polinomiale può approssimare qualsiasi funzione continua su un insieme limitato con precisione arbitraria.
L'enunciato è potente, ma va letto con attenzione. Che cosa afferma: che per ogni funzione continua esiste una rete (a uno strato nascosto) che la approssima con l'errore che vuoi. Che cosa non afferma: (1) quanti neuroni servono, che può essere un numero enorme; (2) come trovare i pesi, né che la discesa del gradiente li trovi; (3) che la rete generalizzi: approssimare bene i dati di training non è approssimare bene i dati nuovi; (4) che una rete a un solo strato sia il modo efficiente di rappresentare la funzione. È un teorema di esistenza, non una ricetta.
Una costruzione esplicita con la ReLU
Invece di fidarti, costruisci a mano una rete con un solo strato nascosto di ReLU che approssima una funzione, e misura l'errore. L'idea: una funzione continua si approssima con una spezzata (lineare a tratti) con H segmenti, e una spezzata si scrive come somma di ReLU. Scegli H+1 «nodi» t₀ < t₁ < ... < t_H e sia y_k = f(t_k). La pendenza del segmento k (tra t_k e t_{k+1}) è s_k = (y_{k+1} − y_k)/(t_{k+1} − t_k). La spezzata che interpola i punti è:
g(x) = y₀ + s₀ · (x − t₀) + Σ_{k=1..H−1} (s_k − s_{k−1}) · ReLU(x − t_k)
Perché è giusta? Per x tra t₀ e t₁ tutti i termini ReLU(x − t_k) con k ≥ 1 sono zero, e resta la retta y₀ + s₀(x − t₀), cioè il primo segmento. Quando x supera t₁ si «accende» il termine ReLU(x − t₁), che aggiunge pendenza (s₁ − s₀): la pendenza totale diventa s₀ + (s₁ − s₀) = s₁, proprio quella del segmento successivo; e così via a ogni nodo. Ogni ReLU è un neurone nascosto con peso 1, bias −t_k e un peso in uscita (s_k − s_{k−1}). Con H neuroni hai una spezzata di H segmenti.
def rete_a_spezzata(f, a, b, H):
t = np.linspace(a, b, H + 1) # nodi
y = f(t)
s = np.diff(y) / np.diff(t) # pendenze dei H segmenti
pesi_uscita = np.diff(s) # (s_k - s_{k-1}) per k = 1..H-1
def rete(x):
uscita = y[0] + s[0] * (x - t[0]) # termine lineare iniziale
for k in range(1, H):
uscita = uscita + pesi_uscita[k - 1] * np.maximum(0, x - t[k]) # un neurone nascosto ReLU per nodo
return uscita
return rete
xs = np.linspace(0, 2 * np.pi, 2001)
print("H (neuroni) errore massimo su [0, 2*pi] rapporto con l'errore precedente")
precedente = None
for H in (2, 4, 8, 16, 32, 64):
errore = np.abs(rete_a_spezzata(np.sin, 0, 2 * np.pi, H)(xs) - np.sin(xs)).max()
print("%11d %27.5f %s" % (H, errore, "-" if precedente is None else "%.2f" % (precedente / errore)))
precedente = errore
H (neuroni) errore massimo su [0, 2*pi] rapporto con l'errore precedente
2 1.00000 -
4 0.21051 4.75
8 0.07038 2.99
16 0.01885 3.73
32 0.00479 3.93
64 0.00120 3.99
Cosa fa il codice. rete_a_spezzata riceve la funzione da approssimare (qui il seno su [0, 2π]), calcola i nodi, le pendenze e i pesi d'uscita con la formula sopra, e restituisce una funzione che valuta la «rete» con H neuroni nascosti ReLU. Lo si valuta su 2001 punti confrontandolo con il seno vero. L'errore massimo scende al crescere di H: da circa 0,21 con 4 neuroni a 0,0012 con 64. Il rapporto con l'errore precedente tende a 4 quando H raddoppia: l'errore decresce come 1/H², come ci si aspetta da un'interpolazione lineare a tratti di una funzione liscia, il cui errore è proporzionale al quadrato dell'ampiezza dei segmenti.
Hai quindi una dimostrazione costruttiva, numerica, che un solo strato nascosto con abbastanza neuroni arriva dove vuole. Hai anche la misura del prezzo: per ottenere un errore di un millesimo servono decine di neuroni anche per una funzione semplice come il seno. In dimensioni alte, una costruzione a griglia come questa richiederebbe un numero di neuroni che cresce in modo esponenziale con la dimensione (ragionamento, non misurato qui).
Profondità o larghezza?
Il teorema parla di una rete larga e poco profonda. Nella pratica si usano reti con più strati di larghezza moderata. Il motivo, solo in parte teorico, è che molte funzioni interessanti hanno una struttura gerarchica (pixel → bordi → forme → oggetti), e gli strati successivi possono riusare le rappresentazioni costruite dai precedenti.
Esistono funzioni che una rete profonda rappresenta con pochi neuroni e che una rete poco profonda rappresenterebbe solo con un numero esponenzialmente maggiore: Telgarsky lo dimostra per una classe di nodi che include la ReLU (nelle fonti). Il prezzo della profondità è un addestramento più delicato: gradienti che svaniscono o esplodono (visti sopra), più iperparametri, più sensibilità all'inizializzazione. Le tecniche che vedrai nelle ultime lezioni (inizializzazione corretta, ReLU, batch normalization, connessioni residuali) esistono per rendere addestrabili reti profonde.
Le trappole di forma, viste in NumPy
Gli errori di forma sono i più comuni nel lavoro con le reti. Riusiamo la classe Rete per elencare le forme dei suoi parametri e proviamo a passarle un batch con il numero sbagliato di feature, per vedere come NumPy segnala il problema.
print("forme dei parametri:", [tuple(a.shape) for W, b in zip(rete.W, rete.b) for a in (W, b)])
try:
rete.forward(np.ones((5, 3))) # il batch ha 3 feature invece di 4
except ValueError as e:
print("errore di forma:", e)
logit = (np.maximum(0, np.ones((5, 4)) @ rete.W[0] + rete.b[0])) @ rete.W[1] + rete.b[1]
print("uscita senza softmax: forma", logit.shape, " - sono 'logit', non probabilità")
forme dei parametri: [(4, 8), (8,), (8, 3), (3,)]
errore di forma: matmul: Input operand 1 has a mismatch in its core dimension 0, with gufunc signature (n?,k),(k,m?)->(n?,m?) (size 4 is different from 3)
uscita senza softmax: forma (5, 3) - sono 'logit', non probabilità
Cosa fa il codice. La prima riga conferma i parametri della rete 4 → 8 → 3: due matrici (4, 8) e (8, 3), due bias (8,) e (3,), 67 numeri in tutto, come i 8·5 + 3·9 calcolati sopra. Il blocco try riproduce l'errore più frequente: un batch con 3 feature non si moltiplica con una matrice di pesi che ne aspetta 4, e il prodotto matriciale non può «indovinare».
Il messaggio, a differenza del broadcasting, è esplicito. L'ultima riga calcola a mano il passaggio in avanti fino ai punteggi dell'ultimo strato: sono logit, e per averne le probabilità serve la softmax. Le librerie con funzioni di perdita «con logit» includono la softmax nella perdita e vogliono i punteggi grezzi; se la applichi due volte il modello si addestra peggio senza segnalare nulla.
La stessa rete in PyTorch
Chi passa a PyTorch troverà le stesse trappole di forma e in più una di tipo di dato: NumPy usa di norma float64, mentre PyTorch inizializza i pesi in float32. Il listato è stato eseguito con PyTorch 2.12.0 su CPU (CUDA_VISIBLE_DEVICES="", 2 thread), due volte, con uscita identica. Il seme fisso riguarda solo i pesi iniziali: le tre uscite pubblicate non dipendono dai valori casuali.
import torch
torch.manual_seed(0)
rete_t = torch.nn.Sequential(torch.nn.Linear(4, 8), torch.nn.ReLU(), torch.nn.Linear(8, 3))
print("forme dei parametri:", [tuple(p.shape) for p in rete_t.parameters()])
print("numero totale di parametri:", sum(p.numel() for p in rete_t.parameters()))
# Trappola 1: tipo di dato. NumPy usa float64 di default, PyTorch float32.
try:
rete_t(torch.tensor(np.random.rand(5, 4)))
except RuntimeError as e:
print("errore di tipo:", str(e)[:70])
# Trappola 2: forma. Il batch ha 3 feature invece di 4.
try:
rete_t(torch.rand(5, 3))
except RuntimeError as e:
print("errore di forma:", str(e)[:70])
logit = rete_t(torch.rand(5, 4))
print("uscita corretta: forma", tuple(logit.shape), " - prima della softmax sono 'logit', non probabilità")
forme dei parametri: [(8, 4), (8,), (3, 8), (3,)]
numero totale di parametri: 67
errore di tipo: mat1 and mat2 must have the same dtype, but got Double and Float
errore di forma: mat1 and mat2 shapes cannot be multiplied (5x3 and 4x8)
uscita corretta: forma (5, 3) - prima della softmax sono 'logit', non probabilità
Cosa fa il codice. nn.Sequential costruisce la stessa rete 4 → 8 → 3 del blocco in NumPy. I parametri hanno forme (8, 4), (8,), (3, 8), (3,): Linear li conserva come (n_out, n_in), trasposti rispetto alla nostra convenzione, e il totale è 67, come in NumPy. Il primo try riproduce l'errore di tipo: np.random.rand produce float64, i pesi sono float32, e il prodotto tra tipi diversi dà un errore esplicito; si corregge con torch.tensor(x, dtype=torch.float32). Il secondo è di forma, lo stesso della versione NumPy, con un messaggio di formulazione diversa. L'uscita corretta ha forma (5, 3) ed è fatta di logit.
Trade-off e trappole reali
- Forme sbagliate che passano in silenzio: il broadcasting accetta (m,) e (m, 1) in combinazioni che producono una matrice (m, m) invece di un vettore. Un esempio classico: sottrarre un vettore y di forma (m,) a una predizione di forma (m, 1) dà una matrice (m, m), e la media dell'errore ha un valore plausibile ma sbagliato. Controlla sempre le forme dopo ogni operazione non ovvia.
- Matrici dei pesi trasposte: (n_in, n_out) nella convenzione di questo articolo, (n_out, n_in) in altre librerie (vedi la nota sopra). Quando carichi o confronti pesi tra le due convenzioni, ricorda di trasporre.
- Inizializzazione a zero o troppo piccola/grande: segnale che svanisce o esplode (vedi sopra). Usa He per ReLU, Xavier per tanh e sigmoide; molte librerie scelgono già un'inizializzazione di default (controlla quale), ma se scrivi a mano devi farlo tu.
- Ingressi non standardizzati: le attivazioni del primo strato dipendono dalla scala delle feature; con ingressi nell'ordine delle migliaia, anche una buona inizializzazione saturerà tanh e sigmoide.
- Applicare softmax due volte (nella rete e nella perdita).
- Confondere approssimazione con generalizzazione: il teorema di approssimazione universale non dice nulla sui dati nuovi. Una rete abbastanza grande approssima anche il rumore del training.
- Troppa capacità: 109 386 parametri per un classificatore piccolo su poche migliaia di esempi significano overfitting quasi certo senza regolarizzazione.
Le sette trappole sono di due tipi. Le prime quattro (forme, trasposizioni, inizializzazione, scala degli ingressi) producono un numero sbagliato senza dare alcun errore: si scoprono solo con controlli espliciti, come stampare le forme e guardare la perdita iniziale. Le ultime tre (softmax doppia, approssimazione confusa con generalizzazione, capacità eccessiva) riguardano il giudizio su ciò che il modello fa: si prevengono con la validazione e con la lettura attenta di ciò che un teorema afferma davvero.
Tre esercizi svolti, per verificare di aver capito
Esercizio 1. Quanti parametri ha una rete 20 → 64 → 32 → 1? Applica la formula n_l·(n_{l−1} + 1) a ogni strato: 64·21 = 1 344, poi 32·65 = 2 080, poi 1·33 = 33; totale 3 457. Nota che quasi due terzi dei parametri stanno nel secondo strato: sono i collegamenti tra i due strati più larghi a pesare di più.
Esercizio 2. Che forme attraversano una rete 10 → 16 → 4 con un batch di 32 esempi? L'ingresso è (32, 10); il primo strato ha W¹ (10, 16) e produce (32, 16); il secondo ha W² (16, 4) e produce (32, 4). I bias hanno forme (16,) e (4,) e si sommano a ogni riga.
Esercizio 3. Perché un errore quadratico medio sembra «funzionare» ma dà un valore sbagliato? Il caso classico è una predizione di forma (m, 1) confrontata con un vettore di etichette di forma (m,). Verifichiamolo:
pred = np.array([[1.0], [2.0], [3.0], [4.0], [5.0]]) # (5, 1): uscita della rete
y = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) # (5,): etichette perfette, predizione perfetta
print("forma della differenza:", (pred - y).shape, " (attesa: (5, 1) o (5,))")
print("MSE sbagliato (broadcast a 5x5): %.3f" % ((pred - y) ** 2).mean())
print("MSE corretto con pred.ravel(): %.3f" % ((pred.ravel() - y) ** 2).mean())
n_20_64_32_1 = sum(n_out * (n_in + 1) for n_in, n_out in zip([20, 64, 32], [64, 32, 1]))
print("parametri di 20-64-32-1:", n_20_64_32_1)
forma della differenza: (5, 5) (attesa: (5, 1) o (5,))
MSE sbagliato (broadcast a 5x5): 4.000
MSE corretto con pred.ravel(): 0.000
parametri di 20-64-32-1: 3457
Con la predizione perfetta l'errore deve essere zero, ma il calcolo con le forme sbagliate restituisce 4,0: pred - y trasmette (5, 1) contro (5,) in una matrice (5, 5) che confronta ogni predizione con ogni etichetta. Nessun errore, nessun avviso, un numero plausibile e sbagliato. La cura è la disciplina: dare alle predizioni e alle etichette la stessa forma, e stampare .shape prima di ogni calcolo di perdita. L'ultima riga verifica anche il primo esercizio: 3 457.
Domande frequenti
Quanti strati e quanti neuroni devo usare? Non esiste una formula. Si parte da un'architettura piccola e semplice, si verifica che riesca a fare overfitting su un sottoinsieme minuscolo dei dati (un controllo di sanità: se non ci riesce c'è un bug), e poi si aumenta la capacità finché la validazione migliora. Per dati tabellari bastano spesso uno o due strati nascosti.
Perché la dimensione del batch compare in tutte le forme? Perché la GPU (e NumPy) sono molto più efficienti a moltiplicare matrici grandi che a ripetere moltiplicazioni di vettori. Elaborare m esempi insieme è matematicamente identico a elaborarli uno alla volta, ma molto più veloce.
Il bias è uguale per tutti gli esempi? Sì: è un parametro del neurone, non dell'esempio. Per questo ha forma (n_l,) e viene sommato a ogni riga del batch.
Una rete con più neuroni nascosti impara sempre meglio? No: la capacità in più si converte in miglior adattamento ai dati di training, che può significare overfitting. Si valuta sempre sulla validazione.
Che cosa sai fare adesso
Sai scrivere un passaggio in avanti completo con NumPy e verificare a occhio che le forme si incastrino, contare i parametri di una rete con la formula n_l·(n_{l−1} + 1) e controllare la perdita iniziale prima di addestrare. Sai scegliere la scala dei pesi per tanh e per ReLU e riconoscere dall'esperimento quando il segnale svanisce o esplode, e sai che cosa dice e che cosa non dice il teorema di approssimazione universale, avendo misurato l'errore della rete a spezzata. La lezione precedente è il percettrone e le funzioni di attivazione; nella prossima, la backpropagation derivata per intero, vedrai come si addestra una rete a strati.
Nella stessa serie rimandano a questa lezione anche: Una rete neurale da zero in NumPy, poi in PyTorch.
Fonti
- G. Cybenko, Approximation by superpositions of a sigmoidal function, Mathematics of Control, Signals and Systems 2, 1989; K. Hornik, Approximation capabilities of multilayer feedforward networks, Neural Networks 4, 1991 - il teorema di approssimazione universale (citazioni bibliografiche, non consultate online)
- X. Glorot, Y. Bengio, Understanding the difficulty of training deep feedforward neural networks, AISTATS 2010 - l'inizializzazione di Xavier e il problema del segnale che svanisce in reti profonde, scheda consultata il 2026-11-12
- K. He, X. Zhang, S. Ren, J. Sun, Delving Deep into Rectifiers (arXiv:1502.01852) - l'inizializzazione per reti con ReLU, scheda consultata il 2026-11-12
- M. Telgarsky, Benefits of depth in neural networks (arXiv:1602.04485) - reti profonde e reti poco profonde con ReLU: separazione esponenziale in numero di nodi, abstract letto il 2026-11-12
Commenti
Caricamento commenti...
Accedi per commentare