Introduzione: Cosa Sono le Reti Neurali
Le reti neurali artificiali rappresentano il fondamento del deep learning moderno. Ispirate alla struttura del cervello umano, queste architetture computazionali sono in grado di apprendere pattern complessi dai dati attraverso un processo iterativo di ottimizzazione chiamato backpropagation. Dalla classificazione di immagini alla traduzione automatica, le reti neurali alimentano le applicazioni di intelligenza artificiale più avanzate al mondo.
In questo primo articolo della serie Deep Learning e Reti Neurali, partiremo dalle origini storiche con il Percettrone (1958) per arrivare ai concetti fondamentali che permettono alle reti di apprendere: pesi, bias, funzioni di attivazione, discesa del gradiente e backpropagation. Alla fine, implementeremo una rete neurale da zero in Python e PyTorch.
Cosa Imparerai
- La storia delle reti neurali: dal Percettrone al Deep Learning moderno
- Architettura di una rete neurale: input, hidden e output layer
- Funzioni di attivazione: ReLU, Sigmoid, Tanh e confronto visivo
- Backpropagation: come la rete calcola i gradienti e aggiorna i pesi
- Loss function: MSE e Cross-Entropy per diversi task
- Implementazione pratica in NumPy e PyTorch
Il Percettrone: Il Primo Neurone Artificiale
Nel 1958, Frank Rosenblatt introdusse il Percettrone, il primo modello di neurone artificiale. L'idea era semplice ma rivoluzionaria: un'unita computazionale che riceve input numerici, li moltiplica per dei pesi (weights), somma il risultato e produce un output binario attraverso una funzione di soglia.
Matematicamente, il percettrone calcola:
# Percettrone semplice in Python
import numpy as np
class Perceptron:
def __init__(self, n_inputs, learning_rate=0.01):
self.weights = np.random.randn(n_inputs)
self.bias = 0.0
self.lr = learning_rate
def predict(self, x):
"""Forward pass: somma pesata + soglia"""
linear_output = np.dot(x, self.weights) + self.bias
return 1 if linear_output >= 0 else 0
def train(self, X, y, epochs=100):
"""Regola di apprendimento del percettrone"""
for _ in range(epochs):
for xi, yi in zip(X, y):
prediction = self.predict(xi)
error = yi - prediction
self.weights += self.lr * error * xi
self.bias += self.lr * error
# Esempio: AND gate
X = np.array([[0, 0], [0, 1], [1, 0], [1, 1]])
y = np.array([0, 0, 0, 1])
p = Perceptron(n_inputs=2)
p.train(X, y, epochs=50)
print([p.predict(xi) for xi in X]) # [0, 0, 0, 1]
Il percettrone funziona perfettamente per problemi linearmente separabili come AND e OR. Tuttavia, nel 1969 Minsky e Papert dimostrarono che un singolo percettrone non può risolvere il problema XOR, dove le classi non sono separabili da una linea retta. Questa scoperta rallento la ricerca sulle reti neurali per oltre un decennio, un periodo noto come l'AI Winter.
Il Limite XOR e la Necessità del Deep Learning
Il problema XOR dimostro che servivano layer multipli (hidden layers) per risolvere problemi non lineari. Questa intuizione porto allo sviluppo dei Multi-Layer Perceptron (MLP) e, decenni dopo, al deep learning moderno. Oggi sappiamo che aggiungendo anche un solo hidden layer con funzioni di attivazione non lineari, una rete neurale può approssimare qualsiasi funzione continua (Teorema di Approssimazione Universale).
Architettura di una Rete Neurale: Layer e Neuroni
Una rete neurale e organizzata in layer (strati) di neuroni interconnessi. L'architettura classica comprende tre tipi di layer:
- Input Layer: riceve i dati grezzi. Ogni neurone rappresenta una feature del dataset (es. pixel di un'immagine, parola di un testo)
- Hidden Layer(s): uno o più strati intermedi dove avviene l'elaborazione. Ogni neurone riceve input dal layer precedente, applica pesi e bias, e passa il risultato attraverso una funzione di attivazione
- Output Layer: produce la predizione finale. Per classificazione binaria: 1 neurone con sigmoid. Per multi-classe: N neuroni con softmax
Il forward pass e il processo con cui i dati fluiscono dall'input all'output attraverso tutti i layer. Per ogni neurone, il calcolo segue tre passi: somma pesata degli input, aggiunta del bias e applicazione della funzione di attivazione.
Funzioni di Attivazione: ReLU, Sigmoid e Tanh
Le funzioni di attivazione introducono non-linearita nella rete, permettendole di apprendere relazioni complesse nei dati. Senza di esse, una rete con N layer sarebbe equivalente a un singolo layer lineare, indipendentemente dalla profondità.
Sigmoid
La funzione sigmoid comprime qualsiasi valore nell'intervallo (0, 1). Storicamente usata come attivazione standard, oggi e impiegata principalmente nell'output layer per classificazione binaria. Il suo problema principale e il vanishing gradient: per valori molto alti o molto bassi, il gradiente diventa quasi zero, rallentando drasticamente l'apprendimento.
Tanh
La funzione tanh (tangente iperbolica) mappa i valori nell'intervallo (-1, 1). Centrata sullo zero, offre gradienti più forti della sigmoid, rendendola preferibile negli hidden layer. Tuttavia, soffre anch'essa del problema del vanishing gradient per valori estremi.
ReLU (Rectified Linear Unit)
ReLU e la funzione di attivazione più utilizzata nel deep learning moderno. La sua formula e semplicissima: f(x) = max(0, x). I vantaggi sono molteplici: calcolo efficiente, nessun vanishing gradient per valori positivi e promozione di rappresentazioni sparse. L'unico svantaggio e il problema dei neuroni morti (dying ReLU): neuroni che ricevono sempre input negativi smettono di apprendere.
import numpy as np
import matplotlib.pyplot as plt
# Implementazione delle funzioni di attivazione
def sigmoid(x):
return 1 / (1 + np.exp(-x))
def tanh(x):
return np.tanh(x)
def relu(x):
return np.maximum(0, x)
def leaky_relu(x, alpha=0.01):
return np.where(x > 0, x, alpha * x)
# Derivate per backpropagation
def sigmoid_derivative(x):
s = sigmoid(x)
return s * (1 - s)
def relu_derivative(x):
return np.where(x > 0, 1, 0)
# Confronto visivo
x = np.linspace(-5, 5, 200)
fig, axes = plt.subplots(1, 4, figsize=(16, 4))
for ax, func, name in zip(axes, [sigmoid, tanh, relu, leaky_relu],
['Sigmoid', 'Tanh', 'ReLU', 'Leaky ReLU']):
ax.plot(x, func(x), linewidth=2)
ax.set_title(name)
ax.grid(True, alpha=0.3)
ax.axhline(y=0, color='k', linewidth=0.5)
ax.axvline(x=0, color='k', linewidth=0.5)
plt.tight_layout()
plt.savefig('activation_functions.png', dpi=150)
Loss Function: Misurare l'Errore
La loss function (funzione di perdita) quantifica quanto le predizioni della rete si discostano dai valori reali. E il segnale di errore che guida l'apprendimento durante il training.
MSE (Mean Squared Error)
Utilizzata per problemi di regressione, MSE calcola la media dei quadrati delle differenze tra predizioni e valori reali. Penalizza maggiormente errori grandi, rendendola sensibile agli outlier.
Cross-Entropy
Per problemi di classificazione, la Cross-Entropy misura la distanza tra la distribuzione di probabilità predetta e quella reale. Per classificazione binaria si usa la Binary Cross-Entropy, per multi-classe la Categorical Cross-Entropy. Cross-Entropy produce gradienti più forti quando la rete e molto sicura ma sbagliata, accelerando la correzione.
Backpropagation: Come la Rete Impara
La backpropagation (retro-propagazione dell'errore) e l'algoritmo fondamentale che permette alle reti neurali di apprendere. Inventata da Rumelhart, Hinton e Williams nel 1986, applica la regola della catena del calcolo differenziale per calcolare il gradiente della loss function rispetto a ogni peso della rete.
Il processo si articola in quattro fasi:
- Forward Pass: i dati attraversano la rete dall'input all'output, calcolando le attivazioni di ogni layer
- Calcolo della Loss: si misura l'errore tra output predetto e valore reale
- Backward Pass: si calcolano i gradienti partendo dall'output verso l'input, propagando l'errore all'indietro
- Aggiornamento Pesi: ogni peso viene modificato nella direzione opposta al gradiente, proporzionalmente al learning rate
Gradient Descent: L'Ottimizzatore Fondamentale
Il Gradient Descent aggiorna i pesi secondo la formula: w = w - lr * dL/dw. Varianti moderne includono SGD con momentum (accumula velocità nella direzione del gradiente), Adam (adaptive learning rate per ogni parametro) e AdamW (Adam con weight decay corretto). Adam e l'ottimizzatore predefinito nella maggior parte delle applicazioni deep learning.
Implementazione Completa: MLP in PyTorch
Mettiamo tutto insieme implementando un Multi-Layer Perceptron per la classificazione delle cifre scritte a mano (dataset MNIST) utilizzando PyTorch:
import torch
import torch.nn as nn
import torch.optim as optim
from torchvision import datasets, transforms
from torch.utils.data import DataLoader
# Definizione del modello MLP
class MLP(nn.Module):
def __init__(self, input_size=784, hidden_sizes=[256, 128], num_classes=10):
super().__init__()
self.flatten = nn.Flatten()
self.network = nn.Sequential(
nn.Linear(input_size, hidden_sizes[0]),
nn.ReLU(),
nn.Dropout(0.2),
nn.Linear(hidden_sizes[0], hidden_sizes[1]),
nn.ReLU(),
nn.Dropout(0.2),
nn.Linear(hidden_sizes[1], num_classes)
)
def forward(self, x):
x = self.flatten(x)
return self.network(x)
# Setup dataset e dataloader
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,))
])
train_dataset = datasets.MNIST('./data', train=True, download=True, transform=transform)
test_dataset = datasets.MNIST('./data', train=False, transform=transform)
train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=1000, shuffle=False)
# Training
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = MLP().to(device)
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)
for epoch in range(10):
model.train()
total_loss = 0
for batch_x, batch_y in train_loader:
batch_x, batch_y = batch_x.to(device), batch_y.to(device)
optimizer.zero_grad()
output = model(batch_x)
loss = criterion(output, batch_y)
loss.backward()
optimizer.step()
total_loss += loss.item()
# Valutazione
model.eval()
correct = 0
with torch.no_grad():
for batch_x, batch_y in test_loader:
batch_x, batch_y = batch_x.to(device), batch_y.to(device)
output = model(batch_x)
pred = output.argmax(dim=1)
correct += (pred == batch_y).sum().item()
accuracy = 100. * correct / len(test_dataset)
print(f'Epoch {epoch+1}: Loss={total_loss/len(train_loader):.4f}, '
f'Accuracy={accuracy:.2f}%')
Questo modello raggiunge circa il 98% di accuratezza su MNIST dopo 10 epoche. La rete ha due hidden layer (256 e 128 neuroni), usa ReLU come attivazione, Dropout per la regolarizzazione e l'ottimizzatore Adam con learning rate 0.001.
Deep Learning: perchè Layer Multipli Funzionano
Il deep learning si distingue dal machine learning tradizionale per l'uso di reti con molti layer nascosti. Ma perchè la profondità e cosi importante?
La risposta sta nella composizione gerarchica di feature. Ogni layer impara a riconoscere pattern a un livello di astrazione crescente:
- Layer 1: Rileva bordi, gradienti e texture semplici
- Layer 2: Combina bordi in forme geometriche (angoli, curve)
- Layer 3: Riconosce parti di oggetti (occhi, ruote, finestre)
- Layer 4+: Identifica oggetti completi e scene composte
Questa gerarchia di rappresentazioni e il motivo per cui reti profonde come ResNet (152 layer) possono raggiungere performance sovrumane nella classificazione di immagini, mentre un singolo layer non potrebbe mai catturare la stessa complessità.
Tuttavia, la profondità porta anche sfide: il vanishing gradient rende difficile addestrare reti molto profonde perchè il segnale di errore si attenua passando attraverso molti layer. Soluzioni moderne includono skip connections (ResNet), batch normalization e funzioni di attivazione come ReLU che mantengono gradienti più stabili.
Prossimi Passi nella Serie
- Nel prossimo articolo esploreremo le Reti Neurali Convoluzionali (CNN), l'architettura che ha rivoluzionato la computer vision
- Vedremo come convoluzioni e pooling permettono di estrarre feature spaziali dalle immagini
- Implementeremo architetture classiche come VGG e ResNet in PyTorch







