Reti neurali in Go: XOR e backpropagation da zero
Nella serie sulla regressione lineare del 2015 abbiamo implementato il gradient descent in Go partendo da zero. L’idea di fondo era semplice: definire una funzione di costo, calcolarne il gradiente in forma analitica, e scendere lungo di esso. I pesi erano un vettore piatto e il gradiente si ricavava con una formula sola.
Le reti neurali usano esattamente la stessa idea, ma applicata su più livelli. Il gradiente non è più una formula unica: si calcola strato per strato, propagando l’errore all’indietro attraverso la rete. Questa procedura si chiama backpropagation. La maggior parte delle introduzioni alle reti neurali o salta la derivazione o la nasconde dietro le astrazioni del framework. Qui non faremo né l’una né l’altra cosa.
Percorreremo la matematica in modo esplicito, implementeremo una rete neurale completa in Go usando solo la libreria standard, e la addestreremo a risolvere XOR. Alla fine mostreremo l’equivalente in PyTorch, che rende visibile esattamente quello che il framework automatizza.
Il problema XOR
XOR è il compito più semplice che mette a nudo i limiti dei modelli lineari. La sua tavola di verità:
| XOR | ||
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 0 |
La proprietà fondamentale di XOR è che nessuna retta riesce a separare la classe 0 dalla classe 1. I due output pari a 1 si trovano in e : diagonalmente opposti. I due output pari a 0 si trovano in e : sull’altra diagonale. Qualunque retta che tenti di separare questi due gruppi taglierà entrambe le diagonali in modo errato, oppure non classificherà correttamente uno dei quattro punti.
Questo è il significato concreto di «non linearmente separabile». Un singolo perceptron, che calcola , può tracciare un solo iperpiano. Basta per AND e OR. Non basta per XOR.
Aggiungere uno strato nascosto cambia la geometria. Lo strato nascosto impara a mappare gli input in un nuovo spazio in cui le classi diventano linearmente separabili. Lo strato di output traccia poi la retta di separazione in quello spazio trasformato. Questa è l’intuizione geometrica che spiega perché una rete a due strati può risolvere XOR mentre un perceptron singolo non ci riesce.
Architettura della rete
Useremo la rete più piccola in grado di risolvere XOR: due nodi in input, due nodi nello strato nascosto, un nodo in output.
I parametri sono:
- Pesi dello strato nascosto : una matrice che mappa 2 input in 2 nodi nascosti
- Bias dello strato nascosto : un vettore di lunghezza 2
- Pesi dello strato di output : una matrice che mappa 2 nodi nascosti in 1 output
- Bias dello strato di output : uno scalare
In totale: parametri. Abbastanza per rappresentare la funzione XOR, pochi abbastanza da tenere il training comprensibile.
La funzione di attivazione
Ogni neurone calcola una somma pesata e poi applica una funzione di attivazione. Usiamo la sigmoid:
Tre proprietà la rendono naturale in questo contesto. Prima: il suo output è compreso tra 0 e 1, coerentemente con le etichette XOR. Seconda: è differenziabile ovunque, quindi il gradiente è sempre calcolabile. Terza: la sua derivata ha una forma particolarmente compatta: . La useremo continuamente nella backpropagation.
La derivata espressa in funzione dell’output, anziché dell’input, è ancora più comoda. Se , allora . Non serve conservare per calcolare il gradiente: basta l’attivazione già calcolata nel forward pass.
Il forward pass
Etichettiamo i livelli: è il vettore di input (), è l’attivazione dello strato nascosto (), è l’output ().
Il forward pass calcola:
Ogni neurone nascosto riceve una combinazione pesata di entrambi gli input, applica la sigmoid, e passa il risultato al neurone di output. Il neurone di output applica a sua volta la sigmoid, mantenendo la predizione tra 0 e 1.
La loss function
Misuriamo l’errore con il mean squared error sull’intero training set:
dove è l’etichetta vera e è la predizione della rete per l’-esimo esempio. Per XOR, .
MSE ha un gradiente pulito e funziona bene per reti piccole che imparano output limitati. Non è l’unica scelta (la cross-entropy è più comune per la classificazione in produzione), ma per derivare la backpropagation da zero rende l’algebra più facile da seguire.
Backpropagation: la regola della catena strato per strato
Questa è la parte che la maggior parte dei tutorial abbrevia. Noi no.
La backpropagation è la regola della catena applicata sistematicamente dall’output verso i pesi. L’obiettivo è calcolare , , e .
Lavoriamo su un esempio di training alla volta e sommiamo i gradienti su tutti e quattro.
Gradienti dello strato di output
Partiamo dalla loss. Per un singolo esempio:
(Il fattore è assorbito quando facciamo la media dei gradienti sul batch.)
Il gradiente di rispetto all’attivazione di output :
L’attivazione di output è , quindi per la regola della catena:
Chiamiamo questo : è il segnale di errore allo strato di output.
I pesi di output compaiono in , quindi:
Questi sono gli aggiornamenti del gradiente per lo strato di output. Ora dobbiamo propagare l’errore a ritroso verso lo strato nascosto.
Gradienti dello strato nascosto
Le attivazioni dello strato nascosto alimentano l’output. La loss dipende da solo attraverso :
Questo è il passaggio chiave: moltiplichiamo l’errore di output per la trasposta dei pesi di output. Ogni peso scala il contributo dell’unità dello strato nascosto all’errore di output. Trasporre la matrice dei pesi e moltiplicare distribuisce l’errore a ciascuna unità nascosta in proporzione al suo contributo.
Applichiamo ora la regola della catena attraverso la sigmoid dello strato nascosto:
dove indica il prodotto elemento per elemento. Chiamiamo questo .
I pesi e i bias dello strato nascosto:
L’aggiornamento con gradient descent
Con tutti i gradienti calcolati, aggiorniamo ogni parametro muovendoci in direzione opposta al gradiente:
Il learning rate controlla la dimensione del passo. Troppo grande e gli aggiornamenti superano il minimo, facendo oscillare o divergere la loss. Troppo piccolo e la convergenza è lenta. Per questo problema, funziona bene.
Un passo di training: si eseguono tutti e quattro gli esempi XOR nel forward pass, si accumulano i gradienti da ciascuno, si fa la media, si applica l’aggiornamento. Si ripete per molte epoch.
L’implementazione in Go
L’implementazione completa. Nessuna libreria esterna, solo 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]))
}
}
Salva il file come main.go ed eseguilo con go run main.go. Output dopo il training:
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 XOR. I casi con output 0 sono vicini a 0, i casi con output 1 sono vicini a 1.
Perché l’inizializzazione casuale è necessaria
Notare la chiamata a rand.Seed(42) e l’inizializzazione con valori casuali tra -1 e 1. Se tutti i pesi vengono inizializzati a zero, tutti i neuroni nascosti ricevono gradienti identici a ogni passo, perché stanno calcolando funzioni identiche. Lo strato nascosto non si differenzia mai. La rete rimane bloccata. L’inizializzazione casuale rompe questa simmetria: ogni neurone parte calcolando una funzione leggermente diversa, e il gradient descent può spingerli in direzioni diverse.
Cosa rende esplicito il codice
L’implementazione in Go mette in luce qualcosa che il codice dei framework nasconde. Ogni gradiente è calcolato a mano: delta2 è il segnale di errore dell’output, delta1[i] lo propaga all’indietro attraverso il peso n.w2[i] e la derivata della sigmoid allo strato nascosto. Il ciclo di accumulazione sul dataset e la successiva divisione per m è la media manuale del batch che un ottimizzatore come torch.optim.SGD esegue automaticamente.
Non c’è nulla di misterioso. È la regola della catena, applicata due volte.
Il confronto con PyTorch
Ecco la stessa rete, stesso compito, in Python con 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 PyTorch e quella Go eseguono lo stesso calcolo. Quello che PyTorch automatizza:
Differenziazione automatica. La chiamata loss.backward() calcola tutti i gradienti attraverso il grafo computazionale. Nel codice Go, questa è l’intera sezione di backpropagation: il calcolo di delta2, di delta1, e l’accumulazione di dw1, dw2, db1, db2. PyTorch costruisce un grafo delle operazioni durante il forward pass e lo percorre al contrario. La matematica è identica.
Accumulazione dei gradienti. Dopo loss.backward(), l’attributo .grad di ogni parametro contiene il gradiente accumulato. Nel codice Go, le nostre variabili dw1, dw2, db1, db2 svolgono la stessa funzione.
Il passo dell’ottimizzatore. optimizer.step() applica l’aggiornamento a ogni parametro. In Go, il ciclo finale fa esattamente questo.
optimizer.zero_grad(). PyTorch accumula i gradienti attraverso chiamate successive a backward(). Chiamare zero_grad() prima di ogni forward pass li azzera. In Go, dichiariamo accumulatori freschi a valore zero all’inizio di ogni chiamata a train(), con lo stesso effetto.
Il framework non fa nulla di diverso. Fa le stesse cose, automaticamente, su grafi computazionali arbitrariamente grandi e complessi. Il codice Go è utile precisamente perché rende visibili questi meccanismi.
Cosa ha imparato davvero questa rete
Vale la pena guardare cosa sta calcolando lo strato nascosto dopo il training. I due neuroni nascosti hanno imparato rappresentazioni degli input XOR.
Uno tende a imparare qualcosa di simile a OR: si attiva quando almeno uno degli input è 1. L’altro tende a imparare qualcosa di simile a NAND: si attiva quando non entrambi gli input sono 1. Insieme, queste due funzioni sono linearmente separabili in XOR: la loro intersezione, ovvero . Il neurone di output impara a calcolare quella combinazione finale.
Le rappresentazioni specifiche variano tra un’esecuzione e l’altra a causa dell’inizializzazione casuale, ma la struttura è sempre la stessa: lo strato nascosto trova una trasformazione dello spazio degli input in cui XOR diventa linearmente separabile, e lo strato di output traccia la retta. Questo è ciò che fa una rete neurale. La procedura di training, il gradient descent guidato dalla backpropagation, trova la trasformazione in modo automatico.
Dove andare da qui
Questa rete ha nove parametri e quattro esempi di training. Le reti reali hanno milioni di parametri, mini-batch gradient descent invece di full-batch, tecniche aggiuntive come momentum e learning rate adattativi, e regolarizzazione per prevenire l’overfitting. La matematica è la stessa. La regola della catena è sempre la regola della catena.
La serie sulla regressione lineare su questo sito copre in dettaglio il gradient descent e la funzione di costo. Il passo successivo naturale da qui è aggiungere più strati, sostituire la sigmoid con ReLU (che addestra più velocemente le reti profonde), e applicare la stessa logica di backpropagation a un problema con dati reali. Il meccanismo non cambia; cambia la scala.
Quello che cambia è la giustificazione pratica per usare un framework. La differenziazione automatica di PyTorch non è solo comoda: per le reti profonde, derivare i gradienti a mano è soggetto a errori, e costruire un motore autodiff corretto è lavoro ingegneristico sostanziale. L’implementazione in Go qui presentata è uno strumento didattico, non una scelta per la produzione. Il suo valore sta nel fatto che non lascia nulla nascosto.