№ xlii L'Almanacco di GST · EN IT

Enrico·rubbo.li

Tech · Longevity · Mercati · Opinioni Enrico Rubboli, propr. Dubai, UAE
← I · Scritti
essay June 18, 2026 25 min

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à:

x1x_1x2x_2XOR
000
011
101
110

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 (0,1)(0,1) e (1,0)(1,0), posizioni diagonalmente opposte. I due risultati pari a 0 si trovano invece a (0,0)(0,0) e (1,1)(1,1), 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 output=sign(w1x1+w2x2+b)\text{output} = \text{sign}(w_1 x_1 + w_2 x_2 + b), 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 W(1)W^{(1)}: una matrice 2×22 \times 2 che mappa 2 input in 2 nodi nascosti
  • Bias del livello nascosto b(1)b^{(1)}: un vettore di lunghezza 2
  • Pesi del livello di output W(2)W^{(2)}: una matrice 1×21 \times 2 che mappa 2 nodi nascosti in 1 output
  • Bias del livello di output b(2)b^{(2)}: uno scalare

In totale si ottengono 4+2+2+1=94 + 2 + 2 + 1 = 9 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:

σ(x)=11+ex\sigma(x) = \frac{1}{1 + e^{-x}}

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: σ(x)=σ(x)(1σ(x))\sigma'(x) = \sigma(x)(1 - \sigma(x)). Questa formula verrà utilizzata costantemente durante la backpropagation.

Esprimere la derivata in funzione dell’output (e non dell’input) risulta ancora più conveniente. Se a=σ(z)a = \sigma(z), allora dσdz=a(1a)\frac{d\sigma}{dz} = a(1-a). Non c’è mai bisogno di memorizzare zz per calcolare il gradiente; è sufficiente l’attivazione aa che è già stata calcolata durante il forward pass.

Il forward pass

Assegniamo un’etichetta ai livelli: xx è il vettore di input (2×12 \times 1), a(1)a^{(1)} è l’attivazione del livello nascosto (2×12 \times 1) e a(2)a^{(2)} è l’output (1×11 \times 1).

Il forward pass calcola:

z(1)=W(1)x+b(1)z^{(1)} = W^{(1)} x + b^{(1)}

a(1)=σ(z(1))a^{(1)} = \sigma(z^{(1)})

z(2)=W(2)a(1)+b(2)z^{(2)} = W^{(2)} a^{(1)} + b^{(2)}

y^=a(2)=σ(z(2))\hat{y} = a^{(2)} = \sigma(z^{(2)})

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:

L=1ni=1n(yiy^i)2L = \frac{1}{n} \sum_{i=1}^{n} (y_i - \hat{y}_i)^2

dove yiy_i rappresenta l’etichetta reale e y^i\hat{y}_i la previsione della rete per l’ii-esimo esempio. Nel caso dello XOR, n=4n = 4.

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 LW(2)\frac{\partial L}{\partial W^{(2)}}, Lb(2)\frac{\partial L}{\partial b^{(2)}}, LW(1)\frac{\partial L}{\partial W^{(1)}} e Lb(1)\frac{\partial L}{\partial b^{(1)}}.

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:

L=(yy^)2L = (y - \hat{y})^2

(Il fattore 1n\frac{1}{n} viene assorbito nel momento in cui si calcola la media dei gradienti sull’intero batch).

Il gradiente di LL rispetto all’attivazione di output a(2)=y^a^{(2)} = \hat{y} è:

La(2)=2(yy^)\frac{\partial L}{\partial a^{(2)}} = -2(y - \hat{y})

Poiché l’attivazione di output è a(2)=σ(z(2))a^{(2)} = \sigma(z^{(2)}), applicando la regola della catena si ottiene:

Lz(2)=La(2)a(2)z(2)=2(yy^)a(2)(1a(2))\frac{\partial L}{\partial z^{(2)}} = \frac{\partial L}{\partial a^{(2)}} \cdot \frac{\partial a^{(2)}}{\partial z^{(2)}} = -2(y - \hat{y}) \cdot a^{(2)}(1 - a^{(2)})

Chiameremo questo termine δ(2)\delta^{(2)}. Esso rappresenta il segnale di errore al livello di output.

I pesi di output W(2)W^{(2)} compaiono nell’equazione z(2)=W(2)a(1)+b(2)z^{(2)} = W^{(2)} a^{(1)} + b^{(2)}, pertanto:

LW(2)=δ(2)(a(1))T\frac{\partial L}{\partial W^{(2)}} = \delta^{(2)} \cdot (a^{(1)})^T

Lb(2)=δ(2)\frac{\partial L}{\partial b^{(2)}} = \delta^{(2)}

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 a(1)a^{(1)} alimentano l’output. La funzione di perdita dipende da a(1)a^{(1)} esclusivamente tramite z(2)z^{(2)}:

La(1)=(W(2))Tδ(2)\frac{\partial L}{\partial a^{(1)}} = (W^{(2)})^T \cdot \delta^{(2)}

Questo è il passaggio chiave: si moltiplica l’errore di output δ(2)\delta^{(2)} per la trasposta dei pesi di output. Ogni peso di output Wj(2)W^{(2)}_j determina in che misura l’unità jj 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:

Lz(1)=La(1)a(1)(1a(1))\frac{\partial L}{\partial z^{(1)}} = \frac{\partial L}{\partial a^{(1)}} \odot a^{(1)} \odot (1 - a^{(1)})

dove \odot indica la moltiplicazione elemento per elemento. Chiameremo questo termine δ(1)\delta^{(1)}.

Per quanto riguarda i pesi e i bias del livello nascosto:

LW(1)=δ(1)xT\frac{\partial L}{\partial W^{(1)}} = \delta^{(1)} \cdot x^T

Lb(1)=δ(1)\frac{\partial L}{\partial b^{(1)}} = \delta^{(1)}

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:

WWαLWW \leftarrow W - \alpha \cdot \frac{\partial L}{\partial W}

bbαLbb \leftarrow b - \alpha \cdot \frac{\partial L}{\partial b}

Il tasso di apprendimento (learning rate) α\alpha 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 α=0.5\alpha = 0.5 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 wwαgradw \leftarrow w - \alpha \cdot \text{grad}. 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 (OR)(NAND)(OR) \cap (NAND). 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.