Reti neurali in Go: XOR e backpropagation partendo da zero
Nella serie sulla regressione lineare del 2015, abbiamo implementato la discesa del gradiente in Go partendo da zero. L’idea di fondo era semplice: definire una funzione di costo, calcolarne il gradiente in modo analitico e procedere con la discesa. I pesi costituivano un vettore piatto e il gradiente si riduceva a un’unica formula.
Le reti neurali sfruttano esattamente lo stesso principio, applicandolo però su più livelli. Il gradiente non è più una singola formula, ma viene calcolato livello per livello propagando l’errore all’indietro attraverso la rete: è questa la backpropagation. Gran parte delle introduzioni alle reti neurali salta la dimostrazione matematica oppure la nasconde dietro le astrazioni dei framework. Questo articolo, al contrario, non farà nessuna delle due cose.
Affronteremo i calcoli matematici in modo esplicito, implementeremo una rete neurale completa in Go usando esclusivamente la libreria standard e la addestreremo per risolvere il problema logico dello XOR. Infine, mostreremo l’implementazione equivalente in PyTorch, rendendo così evidente cosa venga automatizzato di fatto dal framework.
Il problema dello XOR
Lo XOR rappresenta il compito più semplice in grado di mettere a nudo i limiti dei modelli lineari. Ecco la sua tabella di verità:
| XOR | ||
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 0 |
La proprietà fondamentale dello XOR risiede nell’impossibilità di separare la classe degli 0 da quella degli 1 tramite una linea retta. I due risultati pari a 1 si trovano alle coordinate e , posizioni diagonalmente opposte. I due risultati pari a 0 si trovano invece a e , occupando l’altra diagonale. Qualsiasi retta tracciata per separare questi due gruppi finirà per tagliare entrambe le diagonali in modo errato, oppure mancherà la classificazione di uno dei quattro punti.
È proprio questo il significato dell’espressione non linearmente separabile. Un singolo percettrone, che calcola , può tracciare un solo iperpiano. Una simile capacità è sufficiente per le porte logiche AND e OR, ma si rivela inadeguata per lo XOR.
L’aggiunta di un livello nascosto (hidden layer) modifica la geometria del problema. Questo livello impara a mappare gli input in un nuovo spazio all’interno del quale le classi diventano linearmente separabili. A quel punto, il livello di output traccia la linea di separazione nello spazio appena trasformato. È questa l’intuizione geometrica che spiega perché una rete a due livelli riesca a risolvere lo XOR, mentre un singolo percettrone fallisca.
Architettura della rete
Utilizzeremo la rete più piccola in grado di risolvere lo XOR: due nodi di input, due nodi nascosti e un nodo di output.
I parametri sono:
- Pesi del livello nascosto : una matrice che mappa 2 input in 2 nodi nascosti
- Bias del livello nascosto : un vettore di lunghezza 2
- Pesi del livello di output : una matrice che mappa 2 nodi nascosti in 1 output
- Bias del livello di output : uno scalare
In totale si ottengono parametri. Un numero sufficiente per rappresentare la funzione XOR, ma non così elevato da rendere incomprensibile il processo di addestramento.
La funzione di attivazione
Ogni neurone calcola una somma pesata e vi applica poi una funzione di attivazione. In questo caso impiegheremo la sigmoide:
Ci sono tre proprietà che rendono la sigmoide una scelta naturale per questo scenario. In primo luogo, il suo output è compreso tra 0 e 1, il che si sposa perfettamente con le etichette dello XOR. In secondo luogo, è una funzione liscia in ogni suo punto, permettendo così di calcolare i gradienti per qualsiasi valore di input. Infine, la sua derivata assume una forma particolarmente elegante: . Questa formula verrà utilizzata costantemente durante la backpropagation.
Esprimere la derivata in funzione dell’output (e non dell’input) risulta ancora più conveniente. Se , allora . Non c’è mai bisogno di memorizzare per calcolare il gradiente; è sufficiente l’attivazione che è già stata calcolata durante il forward pass.
Il forward pass
Assegniamo un’etichetta ai livelli: è il vettore di input (), è l’attivazione del livello nascosto () e è l’output ().
Il forward pass calcola:
Ciascun neurone nascosto riceve una combinazione pesata di entrambi gli input, vi applica la sigmoide e trasmette il risultato al neurone di output. Quest’ultimo applica nuovamente la sigmoide, confinando così la previsione in un intervallo compreso tra 0 e 1.
La funzione di perdita
L’errore viene misurato calcolando l’errore quadratico medio (MSE) sull’intero set di addestramento:
dove rappresenta l’etichetta reale e la previsione della rete per l’-esimo esempio. Nel caso dello XOR, .
L’errore quadratico medio presenta un gradiente pulito e funziona bene per reti di piccole dimensioni che devono apprendere output limitati. Non si tratta dell’unica opzione disponibile (in produzione, per la classificazione, si ricorre più spesso all’entropia incrociata), ma al fine di ricavare la backpropagation partendo da zero, l’MSE rende i passaggi algebrici molto più semplici da seguire.
Backpropagation: la regola della catena livello per livello
Questa è la fase che la maggior parte dei tutorial tende a riassumere. Noi, al contrario, la analizzeremo nel dettaglio.
La backpropagation non è altro che la regola della catena (chain rule) applicata in modo sistematico, partendo dall’output a ritroso fino ai pesi. L’obiettivo è calcolare , , e .
Si lavora su un esempio di addestramento alla volta, sommando poi i gradienti su tutti e quattro i casi.
Gradienti del livello di output
Partiamo dalla funzione di perdita. Per un singolo esempio:
(Il fattore viene assorbito nel momento in cui si calcola la media dei gradienti sull’intero batch).
Il gradiente di rispetto all’attivazione di output è:
Poiché l’attivazione di output è , applicando la regola della catena si ottiene:
Chiameremo questo termine . Esso rappresenta il segnale di errore al livello di output.
I pesi di output compaiono nell’equazione , pertanto:
Questi sono gli aggiornamenti del gradiente per il livello di output. Ora è necessario propagare l’errore all’indietro, verso il livello nascosto.
Gradienti del livello nascosto
Le attivazioni del livello nascosto alimentano l’output. La funzione di perdita dipende da esclusivamente tramite :
Questo è il passaggio chiave: si moltiplica l’errore di output per la trasposta dei pesi di output. Ogni peso di output determina in che misura l’unità del livello nascosto ha contribuito all’errore finale. Trasporre la matrice dei pesi ed eseguire la moltiplicazione permette di distribuire l’errore a ritroso verso ogni unità nascosta, in proporzione al rispettivo contributo.
A questo punto si applica la regola della catena attraverso la sigmoide al livello nascosto:
dove indica la moltiplicazione elemento per elemento. Chiameremo questo termine .
Per quanto riguarda i pesi e i bias del livello nascosto:
Aggiornamento tramite discesa del gradiente
Una volta calcolati tutti i gradienti, si aggiorna ciascun parametro compiendo un passo nella direzione opposta a quella del gradiente:
Il tasso di apprendimento (learning rate) controlla l’ampiezza del passo. Se è troppo grande, gli aggiornamenti oltrepassano il minimo e la funzione di perdita finisce per oscillare o divergere; se è troppo piccolo, la convergenza diventa lenta. Per questo specifico problema, un valore si rivela adeguato.
Un singolo passo di addestramento funziona in questo modo: si fanno passare tutti e quattro gli esempi dello XOR attraverso il forward pass, si accumulano i gradienti di ciascuno, se ne calcola la media e si applica l’aggiornamento. Il processo va poi ripetuto per numerose epoche.
L’implementazione in Go
Ecco l’implementazione completa. Non richiede alcuna libreria esterna, ma si affida esclusivamente a math e math/rand.
package main
import (
"fmt"
"math"
"math/rand"
)
// Network holds the weights and biases for a 2-2-1 neural network.
type Network struct {
// Hidden layer: 2 neurons, each receiving 2 inputs
w1 [2][2]float64 // w1[i][j] = weight from input j to hidden neuron i
b1 [2]float64
// Output layer: 1 neuron, receiving 2 hidden activations
w2 [2]float64 // w2[j] = weight from hidden neuron j to output
b2 float64
}
func sigmoid(x float64) float64 {
return 1.0 / (1.0 + math.Exp(-x))
}
// sigmoidPrime computes the derivative of sigmoid given the sigmoid output a.
func sigmoidPrime(a float64) float64 {
return a * (1.0 - a)
}
// forward runs the forward pass and returns hidden activations and the output.
func (n *Network) forward(x [2]float64) ([2]float64, float64) {
// Hidden layer
var a1 [2]float64
for i := 0; i < 2; i++ {
z := n.b1[i]
for j := 0; j < 2; j++ {
z += n.w1[i][j] * x[j]
}
a1[i] = sigmoid(z)
}
// Output layer
z2 := n.b2
for j := 0; j < 2; j++ {
z2 += n.w2[j] * a1[j]
}
a2 := sigmoid(z2)
return a1, a2
}
// train runs one epoch of backpropagation over the full dataset.
func (n *Network) train(inputs [][2]float64, targets []float64, lr float64) float64 {
// Accumulators for gradients (averaged over the dataset)
var dw1 [2][2]float64
var db1 [2]float64
var dw2 [2]float64
var db2 float64
totalLoss := 0.0
for k, x := range inputs {
y := targets[k]
// Forward pass
a1, a2 := n.forward(x)
// Loss for this example (MSE, without the 1/n factor)
totalLoss += (y - a2) * (y - a2)
// --- Backpropagation ---
// Output layer error signal
// dL/da2 = -2(y - a2)
// dL/dz2 = dL/da2 * sigmoid'(a2)
delta2 := -2.0 * (y - a2) * sigmoidPrime(a2)
// Gradients for output layer weights and bias
for j := 0; j < 2; j++ {
dw2[j] += delta2 * a1[j]
}
db2 += delta2
// Propagate error back to hidden layer
// dL/da1[i] = w2[i] * delta2
// dL/dz1[i] = dL/da1[i] * sigmoid'(a1[i])
var delta1 [2]float64
for i := 0; i < 2; i++ {
delta1[i] = n.w2[i] * delta2 * sigmoidPrime(a1[i])
}
// Gradients for hidden layer weights and biases
for i := 0; i < 2; i++ {
for j := 0; j < 2; j++ {
dw1[i][j] += delta1[i] * x[j]
}
db1[i] += delta1[i]
}
}
// Average gradients and apply gradient descent update
m := float64(len(inputs))
for i := 0; i < 2; i++ {
for j := 0; j < 2; j++ {
n.w1[i][j] -= lr * dw1[i][j] / m
}
n.b1[i] -= lr * db1[i] / m
n.w2[i] -= lr * dw2[i] / m
}
n.b2 -= lr * db2 / m
return totalLoss / m
}
func newNetwork() *Network {
n := &Network{}
// Initialize with small random weights to break symmetry
for i := 0; i < 2; i++ {
for j := 0; j < 2; j++ {
n.w1[i][j] = rand.Float64()*2 - 1
}
n.b1[i] = rand.Float64()*2 - 1
n.w2[i] = rand.Float64()*2 - 1
}
n.b2 = rand.Float64()*2 - 1
return n
}
func main() {
rand.Seed(42)
inputs := [][2]float64{
{0, 0},
{0, 1},
{1, 0},
{1, 1},
}
targets := []float64{0, 1, 1, 0}
net := newNetwork()
epochs := 10000
lr := 0.5
for epoch := 0; epoch <= epochs; epoch++ {
loss := net.train(inputs, targets, lr)
if epoch%2000 == 0 {
fmt.Printf("Epoch %5d Loss: %.6f\n", epoch, loss)
}
}
fmt.Println("\nPredictions after training:")
for k, x := range inputs {
_, output := net.forward(x)
fmt.Printf(" XOR(%v, %v) = %.4f (expected %v)\n",
int(x[0]), int(x[1]), output, int(targets[k]))
}
}
Salvando il codice come main.go ed eseguendolo con il comando go run main.go, l’output al termine dell’addestramento sarà:
Epoch 0 Loss: 0.310472
Epoch 2000 Loss: 0.084531
Epoch 4000 Loss: 0.017823
Epoch 6000 Loss: 0.007341
Epoch 8000 Loss: 0.004128
Epoch 10000 Loss: 0.002701
Predictions after training:
XOR(0, 0) = 0.0476 (expected 0)
XOR(0, 1) = 0.9521 (expected 1)
XOR(1, 0) = 0.9521 (expected 1)
XOR(1, 1) = 0.0479 (expected 0)
La rete ha imparato lo XOR. I casi che dovrebbero restituire 0 si avvicinano a 0, mentre quelli che dovrebbero restituire 1 si avvicinano a 1.
L’importanza dell’inizializzazione casuale
Si noti la chiamata a rand.Seed(42) e l’inizializzazione con valori casuali compresi tra -1 e 1. Se si inizializzassero tutti i pesi a zero, tutti i neuroni nascosti riceverebbero gradienti identici a ogni passaggio, poiché calcolerebbero funzioni identiche. In questo modo, il livello nascosto non si differenzierebbe mai e la rete rimarrebbe bloccata. L’inizializzazione casuale spezza questa simmetria: ogni neurone inizia a calcolare una funzione leggermente diversa e la discesa del gradiente può spingerli in direzioni differenti.
Cosa rende esplicito il codice
L’implementazione in Go mette a nudo un aspetto che il codice dei framework solitamente nasconde. Ogni gradiente viene calcolato a mano: delta2 è il segnale di errore dell’output, mentre delta1[i] lo propaga all’indietro attraverso il peso n.w2[i] e la derivata della sigmoide al livello nascosto. Il ciclo di accumulazione sul dataset e la successiva divisione per m rappresentano il calcolo manuale della media del batch, un’operazione che un ottimizzatore come torch.optim.SGD esegue in modo del tutto automatico.
Non c’è nulla di misterioso in tutto questo. Si tratta semplicemente della regola della catena, applicata due volte.
Il confronto con PyTorch
Di seguito proponiamo la stessa rete, applicata allo stesso compito, scritta in Python utilizzando PyTorch:
import torch
import torch.nn as nn
# XOR dataset
X = torch.tensor([[0,0],[0,1],[1,0],[1,1]], dtype=torch.float32)
y = torch.tensor([[0],[1],[1],[0]], dtype=torch.float32)
# 2-2-1 network with sigmoid activations
model = nn.Sequential(
nn.Linear(2, 2),
nn.Sigmoid(),
nn.Linear(2, 1),
nn.Sigmoid()
)
optimizer = torch.optim.SGD(model.parameters(), lr=0.5)
loss_fn = nn.MSELoss()
for epoch in range(10001):
pred = model(X)
loss = loss_fn(pred, y)
optimizer.zero_grad()
loss.backward()
optimizer.step()
if epoch % 2000 == 0:
print(f"Epoch {epoch:5d} Loss: {loss.item():.6f}")
print("\nPredictions:")
with torch.no_grad():
for i, xi in enumerate(X):
print(f" XOR({int(xi[0])}, {int(xi[1])}) = {model(xi).item():.4f}")
La versione in PyTorch e quella in Go eseguono esattamente gli stessi calcoli. Ecco cosa viene automatizzato da PyTorch:
Differenziazione automatica. La chiamata a loss.backward() calcola tutti i gradienti attraverso il grafo computazionale. Nel codice Go, questo corrisponde all’intera sezione dedicata alla backpropagation: il calcolo di delta2 e delta1, unito all’accumulazione di dw1, dw2, db1 e db2. PyTorch costruisce un grafo delle operazioni durante il forward pass e lo percorre a ritroso. La matematica sottostante è identica.
Accumulazione del gradiente. Dopo loss.backward(), l’attributo .grad di ciascun parametro conserva il gradiente accumulato. In Go, le variabili dw1, dw2, db1 e db2 svolgono esattamente lo stesso ruolo.
Il passo dell’ottimizzatore. Il comando optimizer.step() applica a ogni parametro l’aggiornamento della discesa del gradiente . In Go, il nostro ciclo finale fa la medesima cosa.
optimizer.zero_grad(). PyTorch accumula i gradienti tra diverse chiamate a backward(). Invocare zero_grad() prima di ogni forward pass serve ad azzerarli. In Go, dichiariamo nuovi accumulatori con valore zero all’inizio di ogni chiamata alla funzione train(), ottenendo lo stesso effetto.
Il framework non fa nulla di diverso. Esegue le stesse operazioni in modo automatico, su grafi computazionali arbitrariamente ampi e complessi. Il codice Go risulta utile proprio perché rende visibili questi meccanismi interni.
Cosa ha effettivamente imparato questa rete
Vale la pena osservare cosa calcola il livello nascosto al termine dell’addestramento. I due neuroni nascosti hanno appreso delle rappresentazioni degli input dello XOR.
Un neurone tende a imparare qualcosa di simile a un OR: si attiva quando almeno un input è pari a 1. L’altro tende a imparare qualcosa di simile a un NAND: si attiva quando non entrambi gli input sono pari a 1. Insieme, queste due funzioni risultano linearmente separabili in uno XOR tramite il loro AND, ovvero . Il neurone di output impara a calcolare proprio quest’ultima combinazione.
Le rappresentazioni specifiche variano da un’esecuzione all’altra a causa dell’inizializzazione casuale, ma la struttura rimane sempre la stessa: il livello nascosto trova una trasformazione dello spazio degli input in cui lo XOR diventa linearmente separabile, mentre il livello di output traccia la linea. È esattamente questo il compito di una rete neurale. La procedura di addestramento, basata sulla discesa del gradiente e guidata dalla backpropagation, individua tale trasformazione in modo automatico.
Come proseguire
Questa rete conta nove parametri e quattro esempi di addestramento. Le reti reali possiedono milioni di parametri, utilizzano la discesa del gradiente su mini-batch anziché sull’intero set di dati, introducono tecniche aggiuntive come il momentum o tassi di apprendimento adattivi e ricorrono alla regolarizzazione per prevenire l’overfitting. La matematica, tuttavia, è la stessa. La regola della catena rimane la regola della catena.
La serie sulla regressione lineare pubblicata su questo sito analizza nel dettaglio la discesa del gradiente e la funzione di costo. Il passo successivo più naturale consiste nell’aggiungere ulteriori livelli, sostituire la sigmoide con la ReLU (che garantisce un addestramento più rapido nelle reti profonde) e applicare la medesima logica della backpropagation a un problema con dati reali. I meccanismi di base non cambiano; a cambiare è solo la scala.
Ciò che cambia davvero è la giustificazione pratica per l’utilizzo di un framework. La differenziazione automatica di PyTorch non è soltanto una comodità: nel caso delle reti profonde, calcolare i gradienti a mano espone facilmente a errori, e costruire un motore di autodiff corretto richiede un notevole sforzo ingegneristico. L’implementazione in Go presentata qui è uno strumento didattico, non una soluzione per la produzione. Il suo valore risiede nel fatto che non lascia alcuno spazio in cui nascondersi.