Una cellula nervosa riceve segnali da molte altre, li somma in qualche modo e, se la somma supera una certa soglia, «scatta» e trasmette un segnale a sua volta. Questa immagine, molto semplificata, ha ispirato nel 1958 lo psicologo Frank Rosenblatt a costruire il percettrone, il primo neurone artificiale che imparava dai dati. Va detto con onestà: il neurone artificiale è una metafora ispirata alla biologia, non una sua simulazione; ciò che usiamo oggi è una funzione matematica molto semplice. Ma questa funzione, ripetuta migliaia di volte e collegata a strati, è il mattone di ogni rete neurale.
In questa lezione costruisci il neurone da zero, lo vedi imparare con una regola semplicissima, scopri il limite che nel 1969 ha fermato per anni la ricerca (non riesce a imparare lo XOR) e capisci la lezione più importante di tutto il modulo: senza una funzione di attivazione non lineare, una pila di strati equivale a uno strato solo.
Il neurone artificiale
Un neurone ha tre ingredienti: gli ingressi x₁, ..., x_n; un peso w_j per ogni ingresso, che dice quanto quell'ingresso conta; e un bias b, un numero che sposta la soglia di attivazione (lo stesso ruolo dell'intercetta nella regressione). Il neurone calcola due cose in sequenza:
z = w₁x₁ + w₂x₂ + ... + w_n x_n + b = w · x + b (somma pesata, detta anche preattivazione o logit)
a = f(z) (attivazione: la funzione f decide l'uscita)
Se riconosci la formula è normale: z è la stessa combinazione lineare della regressione lineare e della regressione logistica. La differenza sta nella scelta di f. Con f(z) = z (nessuna attivazione) il neurone è una regressione lineare. Con f = sigmoide è una regressione logistica: una regressione logistica è un neurone con attivazione sigmoide, come anticipato nella lezione precedente. Con f = gradino (step) si ha il percettrone originale:
f(z) = 1 se z ≥ 0, 0 altrimenti
La regola di apprendimento del percettrone
Rosenblatt propose una regola di aggiornamento semplice. Per ogni esempio (x, y) con y ∈ {0, 1}: calcola la previsione ŷ = f(w·x + b); se è giusta non fare nulla; se è sbagliata, sposta il peso nella direzione che avvicina la previsione al valore vero:
w ← w + η · (y − ŷ) · x
b ← b + η · (y − ŷ)
η (eta) è il learning rate, un numero positivo che regola l'ampiezza dello spostamento. Perché funziona? Se y = 1 e ŷ = 0 (il neurone ha detto «no» ma doveva dire «sì»), l'aggiornamento è w ← w + η·x. Il nuovo punteggio sullo stesso esempio diventa (w + ηx)·x + b + η = (w·x + b) + η(‖x‖² + 1), cioè cresce di una quantità positiva: stiamo spingendo z verso il lato giusto della soglia. Se y = 0 e ŷ = 1, il segno è opposto e z cala. Gli esempi già corretti non cambiano nulla. Questo è un caso particolare, molto semplice, della discesa del gradiente.
Una garanzia teorica, il teorema di convergenza del percettrone (Novikoff, 1962): se i dati sono linearmente separabili (esiste un iperpiano che separa perfettamente le due classi), la regola trova un separatore in un numero finito di aggiornamenti. Se non sono separabili, non converge mai. Lo verifichiamo con codice.
Implementazione a mano: AND, OR e il muro dello XOR
Le funzioni logiche con due ingressi binari sono il banco di prova ideale: sono 4 punti in un piano, e si vede subito se esiste una retta che separa gli 1 dagli 0.
import numpy as np
X = np.array([[0, 0], [0, 1], [1, 0], [1, 1]], dtype=float)
def percettrone(X, y, eta=1.0, epoche=100):
w, b, errori_per_epoca = np.zeros(2), 0.0, []
for _ in range(epoche):
errori = 0
for xi, yi in zip(X, y):
pred = 1 if w @ xi + b >= 0 else 0 # funzione gradino
if pred != yi:
w = w + eta * (yi - pred) * xi # regola del percettrone
b = b + eta * (yi - pred)
errori += 1
errori_per_epoca.append(errori)
if errori == 0: # un'epoca senza errori: convergenza
break
return w, b, errori_per_epoca
for nome, y in (("AND", [0, 0, 0, 1]), ("OR", [0, 1, 1, 1]), ("XOR", [0, 1, 1, 0])):
w, b, errori = percettrone(X, np.array(y))
print("%-4s epoche usate: %3d errori per epoca (ultime 3): %s w = %s b = %.0f" % (nome, len(errori), errori[-3:], w, b))
AND epoche usate: 6 errori per epoca (ultime 3): [2, 1, 0] w = [2. 1.] b = -3
OR epoche usate: 4 errori per epoca (ultime 3): [2, 1, 0] w = [1. 1.] b = -1
XOR epoche usate: 100 errori per epoca (ultime 3): [4, 4, 4] w = [-1. 0.] b = 0
Cosa fa il codice. percettrone scorre gli esempi uno alla volta (un'epoca è un passaggio su tutti), applica il gradino, e se la previsione è sbagliata aggiorna pesi e bias con la regola. Si ferma al primo passaggio senza errori. Per AND e OR converge in poche epoche: i quattro punti si possono separare con una retta. Per lo XOR (uscita 1 solo quando i due ingressi sono diversi) il percettrone usa tutte le 100 epoche concesse e continua a sbagliare: nessuna retta separa {(0,1), (1,0)} da {(0,0), (1,1)}, perché le due diagonali del quadrato si incrociano. Nel 1969 Marvin Minsky e Seymour Papert dimostrarono rigorosamente questa limitazione dei percettroni a un solo strato, e per molti anni l'entusiasmo verso le reti neurali si raffreddò.
La via d'uscita: più neuroni, in due strati
Lo XOR si risolve con due strati. L'idea: invece di una sola retta ne servono due, i cui risultati si combinano. Costruiamo la soluzione a mano, scegliendo i pesi con il ragionamento, e verifichiamola:
La costruzione ha tre neuroni, e l'ordine conta perché il terzo legge le uscite dei primi due. Il neurone h₁ calcola OR(x₁, x₂): scatta se la somma degli ingressi è almeno 0,5, cioè step(x₁ + x₂ − 0.5). Il neurone h₂ calcola AND(x₁, x₂): scatta se la somma è almeno 1,5, cioè step(x₁ + x₂ − 1.5). L'uscita dice «OR ma non AND», cioè step(h₁ − h₂ − 0.5), che è proprio XOR.
gradino = lambda z: (z >= 0).astype(int)
h1 = gradino(X @ np.array([1, 1]) - 0.5) # OR
h2 = gradino(X @ np.array([1, 1]) - 1.5) # AND
uscita = gradino(h1 - h2 - 0.5) # OR e non AND
print("x1 x2 | h1(OR) h2(AND) | uscita XOR")
for xi, a, c, o in zip(X.astype(int), h1, h2, uscita):
print(" %d %d | %d %d | %d" % (xi[0], xi[1], a, c, o))
x1 x2 | h1(OR) h2(AND) | uscita XOR
0 0 | 0 0 | 0
0 1 | 1 0 | 1
1 0 | 1 0 | 1
1 1 | 1 1 | 0
Cosa fa il codice. X @ np.array([1, 1]) calcola x₁ + x₂ per ogni riga (prodotto matrice-vettore); sottrarre una costante è il bias. Lo strato nascosto (h₁, h₂) rimappa i quattro punti in un nuovo spazio dove sono separabili: i punti (0,1) e (1,0) diventano (1,0), mentre (0,0) diventa (0,0) e (1,1) diventa (1,1). In questo spazio nuovo basta una retta. È l'idea fondamentale di una rete profonda: gli strati intermedi imparano una rappresentazione dei dati in cui il problema diventa semplice.
La non linearità non è un dettaglio: due strati lineari sono uno strato
Nella soluzione dello XOR ogni neurone usa il gradino, che non è lineare. Cosa succederebbe se gli strati usassero f(z) = z? Impiliamo due strati lineari, con matrici dei pesi W₁ (3×2) e W₂ (1×3): l'uscita è W₂(W₁x) = (W₂W₁)x. Il prodotto W₂W₁ è una singola matrice (1×2): l'intera rete equivale a un unico strato lineare, per quanti strati si impilino. L'associatività del prodotto di matrici cancella tutta la profondità. Verifica numerica:
rng = np.random.default_rng(0)
W1 = rng.normal(size=(3, 2)) # primo strato: 2 ingressi -> 3 neuroni
W2 = rng.normal(size=(1, 3)) # secondo strato: 3 -> 1
x = rng.normal(size=2)
a_due_strati = W2 @ (W1 @ x)
a_uno_strato = (W2 @ W1) @ x
print("W2 (W1 x) == (W2 W1) x ?", np.allclose(a_due_strati, a_uno_strato))
print("forma di W2 @ W1:", (W2 @ W1).shape, " -> un solo strato 1x2")
W2 (W1 x) == (W2 W1) x ? True
forma di W2 @ W1: (1, 2) -> un solo strato 1x2
Il codice mostra che la rete a due strati lineari e il singolo strato con matrice W2 @ W1 producono lo stesso risultato. Per questo servono le funzioni di attivazione non lineari tra gli strati: rompono la proprietà di composizione e permettono alla rete di rappresentare funzioni che nessun modello lineare può imitare.
Le funzioni di attivazione
Il percettrone originale usa il gradino, ma c'è un problema fatale per l'apprendimento moderno: la sua derivata è zero ovunque (tranne che in z = 0, dove non esiste), quindi la discesa del gradiente non ha nessun segnale da seguire. Si sostituiscono funzioni lisce o quasi lisce:
- Sigmoide σ(z) = 1/(1 + e^(−z)): uscita in (0, 1). Derivata σ(1 − σ) con massimo 0,25. Satura: per |z| grande la derivata è quasi zero.
- Tangente iperbolica tanh(z): uscita in (−1, 1), centrata sullo zero (comodo per gli strati successivi). Derivata 1 − tanh²(z), con massimo 1 in z = 0. Satura come la sigmoide.
- ReLU (Rectified Linear Unit) f(z) = max(0, z): zero per z negativo, identità per z positivo. Derivata 0 o 1. Non satura nella zona positiva ed è economica da calcolare: è la scelta di default per gli strati nascosti. Difetto: se un neurone riceve sempre z negativo, la sua derivata è sempre 0 e smette di imparare («dying ReLU»). La variante Leaky ReLU usa una piccola pendenza (per esempio 0,01) per z negativo, per non annullarsi del tutto.
Le tre voci si leggono insieme guardando la derivata, perché è la derivata a decidere se il segnale d'errore attraversa lo strato. Sigmoide e tanh hanno una derivata che si annulla ai due lati: più la rete è profonda, più volte il gradiente passa per una zona piatta. La ReLU ha derivata esattamente 1 per z positivo, quindi non attenua il segnale, ma lo azzera per z negativo: il costo è il rischio di neuroni che non si riprendono più.
Nessuna delle tre è «la migliore» in assoluto; la ReLU è il punto di partenza perché i suoi difetti sono più facili da diagnosticare. Il libro di Goodfellow, Bengio e Courville descrive la ReLU come una funzione lineare a tratti con due pezzi lineari, vicina ai modelli lineari e perciò facile da ottimizzare (capitolo 6, nelle fonti).
Misuriamo la saturazione.
sigmoide = lambda z: 1 / (1 + np.exp(-z))
print(" z deriv. sigmoide deriv. tanh deriv. ReLU")
for z in (0.0, 2.0, 5.0, -5.0):
print("%4.1f %14.5f %11.5f %11.1f" % (z, sigmoide(z) * (1 - sigmoide(z)), 1 - np.tanh(z) ** 2, float(z > 0)))
print("\nprodotto di 10 derivate della sigmoide nel caso migliore (0.25 ciascuna): %.2e" % (0.25 ** 10))
z deriv. sigmoide deriv. tanh deriv. ReLU
0.0 0.25000 1.00000 0.0
2.0 0.10499 0.07065 1.0
5.0 0.00665 0.00018 1.0
-5.0 0.00665 0.00018 0.0
prodotto di 10 derivate della sigmoide nel caso migliore (0.25 ciascuna): 9.54e-07
Cosa fa il codice. Calcola le tre derivate in quattro punti. La sigmoide non supera mai 0,25, e a z = ±5 vale 0,0066; tanh a z = ±5 vale 0,00018. La ReLU vale 1 per z positivo e 0 per z negativo. L'ultima riga è la chiave: se un segnale (nel verso del calcolo del gradiente) deve attraversare 10 strati sigmoidi, viene moltiplicato per 10 derivate, ciascuna al massimo 0,25: nel caso migliore 0,25¹⁰ ≈ 9,5·10⁻⁷. Il gradiente che arriva ai primi strati è quasi zero e quegli strati smettono di imparare. È il problema del gradiente che svanisce (vanishing gradient), uno dei motivi per cui la ReLU ha cambiato la pratica delle reti profonde.
Quale attivazione dove
- Strati nascosti: ReLU come scelta di partenza; Leaky ReLU o varianti se noti neuroni «morti»; tanh in alcuni contesti (reti ricorrenti).
- Uscita per classificazione binaria: sigmoide (una probabilità).
- Uscita per classificazione multiclasse: softmax (K probabilità che sommano a 1).
- Uscita per regressione: nessuna attivazione (lineare), perché il valore da prevedere non ha limiti. Se è sempre positivo si può usare una ReLU o una softplus.
La regola dietro l'elenco è una sola: l'attivazione dello strato di uscita dipende da che cosa vuoi prevedere (un numero senza limiti, una probabilità, una distribuzione su K classi), mentre quella degli strati nascosti dipende da come si comporta il gradiente. Per questo le due scelte vengono fatte separatamente. L'uscita non si sceglie per ottimizzare meglio: si sceglie per rispettare il significato del valore previsto.
Lo XOR imparato dai dati, con una rete scritta in NumPy
Lo XOR si impara anche dai dati con la discesa del gradiente, senza scegliere i pesi a mano. Qui scriviamo la rete 2 → h → 1 direttamente in NumPy, con attivazione tra gli strati e perdita di cross-entropia binaria, e la addestriamo da 20 inizializzazioni casuali diverse per vedere quanto è affidabile. Scrivere i gradienti a mano è istruttivo, perché sono quelli che la lezione sulla backpropagation deriva per intero; subito dopo, la stessa prova è ripetuta con PyTorch, che li calcola da solo.
sigmoide = lambda z: 1 / (1 + np.exp(-z))
yv = np.array([[0.0], [1.0], [1.0], [0.0]])
attivazioni = {"Tanh": (np.tanh, lambda z: 1 - np.tanh(z) ** 2),
"ReLU": (lambda z: np.maximum(0, z), lambda z: (z > 0).astype(float))}
def prova(h, nome, seed, epoche=2000, lr=0.5):
f, df = attivazioni[nome]
rng = np.random.default_rng(seed) # inizializzazione casuale riproducibile
W1, b1 = rng.normal(size=(2, h)), np.zeros(h)
W2, b2 = rng.normal(size=(h, 1)), np.zeros(1)
for _ in range(epoche): # discesa del gradiente sull'intero insieme (4 esempi)
z1 = X @ W1 + b1
a1 = f(z1)
z2 = a1 @ W2 + b2 # logit: la sigmoide sta dentro la perdita
dz2 = (sigmoide(z2) - yv) / len(X) # uscita meno bersaglio
dz1 = (dz2 @ W2.T) * df(z1) # regola della catena attraverso lo strato nascosto
W2, b2 = W2 - lr * a1.T @ dz2, b2 - lr * dz2.sum(axis=0)
W1, b1 = W1 - lr * X.T @ dz1, b1 - lr * dz1.sum(axis=0)
z2 = f(X @ W1 + b1) @ W2 + b2
perdita = (np.logaddexp(0, z2) - yv * z2).mean() # cross-entropia binaria stabile
return perdita, bool(((z2 > 0).astype(float) == yv).all())
print("neuroni nascosti attivazione riusciti su 20 inizializzazioni perdita mediana")
for h in (2, 4):
for nome in ("Tanh", "ReLU"):
esiti = [prova(h, nome, seed) for seed in range(20)]
print("%16d %-12s %20d / 20 %22.4f" % (h, nome, sum(c for _, c in esiti), np.median([p for p, _ in esiti])))
neuroni nascosti attivazione riusciti su 20 inizializzazioni perdita mediana
2 Tanh 9 / 20 0.3480
2 ReLU 2 / 20 0.4775
4 Tanh 19 / 20 0.0023
4 ReLU 13 / 20 0.0019
Il codice ha tre parti. Il passaggio in avanti calcola z1, l'attivazione a1 e il logit z2; la rete restituisce il logit, il punteggio non trasformato, e non una probabilità, perché la sigmoide viene applicata dentro la perdita (np.logaddexp(0, z2) - y*z2 è la cross-entropia binaria scritta in forma numericamente stabile). Se applicassi una seconda sigmoide all'uscita e poi usassi questa perdita, la applicheresti due volte. Il passaggio all'indietro parte da dz2 = uscita - bersaglio, la stessa quantità della sezione seguente, e la moltiplica per i pesi e per la derivata dell'attivazione per arrivare allo strato nascosto: è la regola della catena. L'aggiornamento è la discesa del gradiente della lezione 41.
Il risultato è un messaggio onesto, e vale per questa inizializzazione (pesi normali standard, bias a zero, 2000 passi con learning rate 0,5; misurato con Python 3.14.4 e NumPy 2.3.5, due esecuzioni identiche): con 2 neuroni nascosti la rete impara lo XOR in 9 inizializzazioni su 20 con tanh e in 2 su 20 con ReLU; con 4 neuroni nascosti si sale a 19 su 20 (tanh) e 13 su 20 (ReLU). La perdita mediana con 2 neuroni (0,35 e 0,48) è quella di una rete che in molte prove è rimasta bloccata, mentre con 4 neuroni è dell'ordine di 0,002.
Con pochi neuroni, alcune inizializzazioni restano bloccate in soluzioni sbagliate; più neuroni offrono più strade. Con un'altra inizializzazione i conteggi cambierebbero: è proprio il punto. Le reti neurali non garantiscono la convergenza, e il risultato dipende dall'inizializzazione, dall'architettura e dal learning rate. Un limite dell'esperimento: i soli quattro punti dello XOR non permettono di parlare di generalizzazione, e il «riuscito» significa soltanto che tutti e quattro i punti sono classificati bene.
La stessa rete in PyTorch
Ora la stessa prova con la libreria: torch.nn.Sequential costruisce la rete 2 → h → 1, backward() calcola i gradienti che sopra hai scritto a mano. Il listato è stato eseguito con PyTorch 2.12.0 su CPU (CUDA_VISIBLE_DEVICES="", 2 thread, nessuna GPU), due volte, con uscita identica; i 160 addestramenti richiedono da alcuni minuti a una decina, a seconda del carico della macchina. Usa la matrice X dei quattro punti definita più sopra.
import torch
Xt = torch.tensor(X, dtype=torch.float32)
yt = torch.tensor([[0.0], [1.0], [1.0], [0.0]])
def prova(h, attivazione, seed, epoche=2000, lr=0.5):
torch.manual_seed(seed) # inizializzazione casuale riproducibile
rete = torch.nn.Sequential(torch.nn.Linear(2, h), attivazione(), torch.nn.Linear(h, 1))
ott = torch.optim.SGD(rete.parameters(), lr=lr)
for _ in range(epoche):
ott.zero_grad()
perdita = torch.nn.functional.binary_cross_entropy_with_logits(rete(Xt), yt) # la sigmoide è dentro la perdita
perdita.backward()
ott.step()
corretto = bool(((rete(Xt) > 0).float() == yt).all())
return perdita.item(), corretto
print("neuroni nascosti attivazione riusciti su 20 inizializzazioni perdita mediana")
for h in (2, 4):
for attivazione in (torch.nn.Tanh, torch.nn.ReLU):
esiti = [prova(h, attivazione, seed) for seed in range(20)]
print("%16d %-12s %20d / 20 %22.4f" % (h, attivazione.__name__, sum(c for _, c in esiti), np.median([p for p, _ in esiti])))
neuroni nascosti attivazione riusciti su 20 inizializzazioni perdita mediana
2 Tanh 11 / 20 0.0041
2 ReLU 4 / 20 0.6931
4 Tanh 20 / 20 0.0024
4 ReLU 15 / 20 0.0017
Cosa fa il codice. nn.Sequential impila uno strato lineare Linear(2, h) (la matrice dei pesi e il bias), una attivazione e un secondo strato Linear(h, 1). La perdita binary_cross_entropy_with_logits applica la sigmoide internamente: per questo la rete restituisce il logit e non una probabilità, e se aggiungessi tu una sigmoide all'uscita la applicheresti due volte. backward() e step() sono quelli della lezione sulla discesa del gradiente. Rispetto alla versione NumPy cambia l'inizializzazione (Linear estrae i pesi da una distribuzione uniforme di ampiezza 1/√(ingressi), non da una normale standard), e per questo i conteggi non coincidono; la conclusione sì. Con 2 neuroni nascosti la rete impara lo XOR in 11 inizializzazioni su 20 con tanh e in 4 su 20 con ReLU; con 4 neuroni salgono a 20 su 20 (tanh) e 15 su 20 (ReLU).
La perdita mediana 0,6931 del caso ReLU con 2 neuroni è ln 2, il valore di una rete che prevede 0,5 per tutti e quattro i punti: in almeno metà delle prove (è una mediana) la rete non ha imparato niente. Ognuna delle due versioni ha quindi il suo ruolo: quella in NumPy mostra i gradienti, quella in PyTorch mostra come li scrive la libreria; i conteggi dell'una non si leggono come quelli dell'altra.
Lo stesso oggetto: regressione lineare, regressione logistica e percettrone
Siamo arrivati al punto in cui conviene fermarsi e guardare tutto quello che hai studiato dall'inizio del modulo come un'unica cosa. Hai incontrato tre modelli con nomi diversi: la regressione lineare, la regressione logistica e il percettrone. Sembrano algoritmi distinti, con funzioni di costo diverse e regole di aggiornamento diverse. In realtà sono lo stesso oggetto, il neurone, in tre configurazioni. Il neurone calcola sempre il punteggio z = w·x + b. Le tre configurazioni differiscono solo in due scelte: la funzione di attivazione f che trasforma z nell'uscita a, e la funzione di costo che misura quanto a è lontana dal bersaglio y.
| Modello | Attivazione a = f(z) | Funzione di costo | Cosa predice |
|---|---|---|---|
| Regressione lineare | identità: a = z | errore quadratico medio, ½(a − y)² | un numero qualsiasi |
| Regressione logistica | sigmoide: a = σ(z) | cross-entropia binaria | una probabilità |
| Percettrone | gradino: a = 1 se z ≥ 0, altrimenti 0 | nessuna (regola di correzione diretta) | una classe, 0 o 1 |
C'è però un fatto sorprendente, che avrai già notato derivando i gradienti nelle lezioni precedenti. Per la regressione lineare con costo quadratico, il gradiente rispetto ai pesi di un singolo esempio è (a − y)·x. Per la regressione logistica con cross-entropia, derivando la composizione di sigmoide e logaritmo, avevi trovato di nuovo (a − y)·x. E la regola di Rosenblatt, w ← w + η(y − ŷ)x, è la stessa cosa scritta con il segno rovesciato: w ← w − η(ŷ − y)x.
Tre modelli, una sola regola di aggiornamento: peso ← peso − passo × (uscita − bersaglio) × ingresso. La logica è la stessa in tutti i casi: l'errore (uscita meno bersaglio) viene moltiplicato per l'ingresso che ha contribuito all'errore, e il peso si sposta di conseguenza. Un ingresso grande contribuisce di più, quindi il suo peso viene corretto di più; un ingresso nullo non contribuisce, e il suo peso non si muove.
Scriviamolo come un unico programma. La funzione che segue addestra un neurone con la discesa del gradiente stocastica (un esempio alla volta) e riceve come argomento la funzione di attivazione. Per i tre modelli cambia solo quell'argomento e il learning rate.
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
Xc, yc = make_classification(n_samples=600, n_features=2, n_redundant=0, n_informative=2,
n_clusters_per_class=1, class_sep=1.2, flip_y=0.02, random_state=3)
Xtr, Xte, ytr, yte = train_test_split(Xc, yc, test_size=0.3, stratify=yc, random_state=0)
scaler = StandardScaler().fit(Xtr) # parametri dal solo training
Xtr, Xte = scaler.transform(Xtr), scaler.transform(Xte)
identita = lambda z: z
sigmoide = lambda z: 1 / (1 + np.exp(-np.clip(z, -500, 500)))
gradino = lambda z: float(z >= 0)
def neurone_sgd(X, y, attivazione, eta, epoche, seed=0):
rng = np.random.default_rng(seed)
w, b = np.zeros(X.shape[1]), 0.0
for _ in range(epoche):
for i in rng.permutation(len(y)): # un esempio alla volta, in ordine casuale
a = attivazione(X[i] @ w + b) # uscita del neurone
errore = a - y[i] # uscita meno bersaglio: identico nei tre casi
w = w - eta * errore * X[i]
b = b - eta * errore
return w, b
print("neurone learning rate pesi appresi accuratezza test")
for nome, att, eta in (("lineare", identita, 0.01), ("logistico", sigmoide, 0.1), ("percettrone", gradino, 0.1)):
w, b = neurone_sgd(Xtr, ytr, att, eta, 30)
pred = np.array([att(v) for v in Xte @ w + b]) >= 0.5
print("%-12s %13.2f %-24s %.3f" % (nome, eta, np.round(np.r_[w, b], 3), (pred == yte).mean()))
neurone learning rate pesi appresi accuratezza test
lineare 0.01 [-0.438 0.03 0.498] 0.928
logistico 0.10 [-6.388 -0.138 1.583] 0.944
percettrone 0.10 [-0.452 -0.096 0. ] 0.911
Cosa fa il codice. La funzione neurone_sgd è il cuore: per ogni esempio calcola l'uscita a applicando l'attivazione al punteggio, calcola errore = a - y[i] e sposta pesi e bias di eta * errore * x. Non c'è nessun «if» sul tipo di modello: la differenza sta tutta nella funzione passata come attivazione. I tre neuroni imparano frontiere di decisione diverse ma simili, tutte rette nel piano, e raggiungono accuratezze comparabili sul test: 0,928 per il neurone lineare usato come classificatore (soglia a 0,5 sull'uscita), 0,944 per quello logistico e 0,911 per il percettrone.
Il neurone logistico è il migliore dei tre, ed è coerente con la teoria: ha una funzione di costo pensata per la classificazione, e i suoi pesi sono molto più grandi (circa −6,4 contro −0,4) perché la sigmoide ha bisogno di punteggi grandi per spingere le probabilità verso 0 e 1. Il neurone lineare, pensato per prevedere numeri, può essere usato come classificatore ma ottimizza una cosa leggermente diversa da ciò che ti interessa.
Perché questa lettura unificata è così importante? Per tre ragioni pratiche. Primo: ti dice che cosa scegliere. L'attivazione di uscita e la funzione di costo non sono due scelte indipendenti: vanno accoppiate (identità con errore quadratico, sigmoide con cross-entropia binaria, softmax con cross-entropia categorica) perché solo con l'accoppiamento giusto il gradiente rispetto al punteggio prende la forma pulita «uscita meno bersaglio» che hai visto qui. Secondo: ti dice come costruire una rete.
Una rete neurale è un insieme di questi neuroni disposti in strati, e l'ultimo strato è esattamente uno dei tre oggetti della tabella, scelto in base al problema: regressione, classificazione binaria o multiclasse. Terzo: ti dice che cosa ti aspetta nelle prossime lezioni. Quando derivi la backpropagation, la formula che propaga l'errore all'indietro parte da (uscita − bersaglio), la stessa quantità, e la moltiplica strato dopo strato per le derivate delle attivazioni e per i pesi.
Domande frequenti, e le risposte che evitano gli errori di comprensione più comuni
Il neurone artificiale funziona come un neurone biologico? Solo in modo molto vago. Un neurone biologico è una cellula con dinamiche temporali, molte sostanze chimiche e connessioni complesse. Il neurone artificiale è una funzione: una somma pesata seguita da una non linearità. L'ispirazione biologica è storica e utile come immagine mentale; non va presa come spiegazione del cervello e non deve guidare le scelte di progetto di una rete.
Perché serve il bias, se ho già i pesi? Senza bias il punteggio è w·x, e la frontiera di decisione w·x = 0 passa sempre per l'origine. Se i dati richiedono una frontiera che non passa per l'origine, il modello non ha modo di rappresentarla. Il bias è l'unico parametro che non è moltiplicato da un ingresso: sposta la soglia di attivazione senza ruotare la frontiera. È lo stesso ruolo dell'intercetta nella regressione lineare.
Se lo XOR si risolve con due strati, due strati bastano per tutto? No, e la risposta precisa è più interessante: un solo strato nascosto con abbastanza neuroni può approssimare qualsiasi funzione continua (il teorema di approssimazione universale, che vedrai nella prossima lezione), ma «abbastanza» può significare un numero enorme, e il teorema non dice come trovare i pesi né garantisce che la discesa del gradiente li trovi. In pratica reti più profonde con meno neuroni per strato sono spesso più efficienti da addestrare e più capaci di generalizzare.
La ReLU è una funzione lineare a tratti: perché funziona come non linearità? Perché «lineare a tratti» non significa «lineare». La ReLU max(0, z) ha un angolo in z = 0, e una composizione di funzioni lineari a tratti con angoli diversi può disegnare curve arbitrariamente complesse approssimandole con molti segmenti. Se non ci fosse l'angolo (cioè se usassi f(z) = z) torneresti al collasso in un unico strato lineare che hai verificato prima.
Se il percettrone converge su AND e OR, perché non usare sempre il percettrone? Perché converge solo se i dati sono linearmente separabili, e nella realtà quasi mai lo sono: ci sono rumore, errori di etichetta, sovrapposizione tra classi. Su dati non separabili il percettrone continua a oscillare tra soluzioni senza mai assestarsi. La regressione logistica, con una funzione di costo liscia, trova invece la miglior frontiera possibile anche quando le classi si sovrappongono.
Una rete con più neuroni è sempre migliore? No. Più neuroni significano più capacità e quindi più rischio di overfitting, più tempo di addestramento e più memoria. L'esperimento sullo XOR mostra l'altra faccia: con 2 neuroni nascosti l'addestramento riesce solo in una parte delle inizializzazioni, con 4 quasi sempre. La capacità giusta è un compromesso, che si sceglie in validazione.
Trappole reali
- Dimenticare l'attivazione tra gli strati: la rete diventa lineare, e non te ne accorgi finché non guardi la capacità. Quindici strati lineari valgono uno.
- Sigmoide o tanh in strati nascosti profondi: gradiente che svanisce, addestramento che si blocca o è lentissimo.
- ReLU morte: learning rate troppo alto o inizializzazione sfortunata spingono molti neuroni nella zona negativa, da dove non tornano. Sintomo: una grande frazione di attivazioni sempre a zero.
- Doppia applicazione di sigmoide/softmax: se la perdita include già la trasformazione (come nel nostro
logaddexp), la rete deve restituire i logit, non le probabilità. - Inizializzare tutti i pesi a zero: i neuroni di uno strato diventano identici e ricevono gli stessi gradienti per sempre (simmetria). Si inizializza a caso, con scala adeguata.
- Aspettarsi la convergenza garantita: come visto, dipende dal seme. Un esperimento ripetuto con più semi è più onesto di una singola esecuzione fortunata.
- Shape mismatch:
X @ W1conXdi forma (4, 2) eW1di forma (2, h) dà (4, h); passare un vettore (2,) al posto di una matrice (n, 2) funziona in NumPy ma cambia il significato delle dimensioni.
Le sette trappole hanno una radice comune: una rete sbagliata in questi modi continua a produrre numeri, e non fa errori evidenti. La rete senza attivazioni e quella con i pesi tutti a zero si addestrano senza lamentarsi; il gradiente che svanisce si presenta come una perdita che non scende. Per questo vale il metodo dell'esperimento sullo XOR: più prove con semi diversi, un controllo esplicito sui pesi e sulle attivazioni, e la diffidenza verso un'unica esecuzione riuscita.
Che cosa sai fare adesso
Sai costruire un neurone da zero, addestrarlo con la regola di Rosenblatt e spiegare perché non può imparare lo XOR. Sai verificare numericamente che due strati lineari collassano in uno, e scegliere l'attivazione degli strati nascosti e quella dell'uscita. Sai anche riconoscere regressione lineare, regressione logistica e percettrone come configurazioni dello stesso neurone con la stessa regola di aggiornamento. Hai visto la lezione precedente, overfitting e regolarizzazione; nella prossima, le reti a strati, impili molti neuroni e segui le forme (shape) dei tensori in un passaggio in avanti completo.
Fonti
- F. Rosenblatt, The Perceptron: A Probabilistic Model for Information Storage and Organization in the Brain, Psychological Review 65(6), 1958; A. Novikoff, On convergence proofs on perceptrons, 1962; M. Minsky, S. Papert, Perceptrons, MIT Press, 1969 - il percettrone, il teorema di convergenza e il limite sullo XOR (citazioni bibliografiche, non consultate online)
- I. Goodfellow, Y. Bengio, A. Courville, Deep Learning, capitolo 6: Deep Feedforward Networks - l'esempio dello XOR risolto con una rete a uno strato nascosto, il ruolo di Minsky nella critica ai modelli lineari e la ReLU come funzione lineare a tratti, consultata il 2026-11-12
- scikit-learn: Neural network models (supervised) - il perceptron multistrato addestrato con backpropagation, come riferimento a una implementazione standard, consultata il 2026-11-12
Commenti
Caricamento commenti...
Accedi per commentare