L'approssimazione di una funzione mediante un MLP con uno strato nascosto¶
Luca Mari, settembre 2024
Quest'opera è distribuita con Licenza Creative Commons Attribuzione - Non commerciale - Condividi allo stesso modo 4.0 Internazionale.
Obiettivo: comprendere, a partire da un esempio concreto, che una rete neurale deve includere degli elementi non lineari per poter approssimare appropriatamente anche delle semplici funzioni non lineari.
Precompetenze: basi di Python; almeno qualche idea di analisi matematica.
Per eseguire questo notebook, supponiamo con VSCode, occorre:
- installare un interprete Python
- scaricare da https://code.visualstudio.com/download e installare VSCode
- eseguire VSCode e attivare le estensioni per Python e Jupyter
- ancora in VSCode:
- creare una cartella di lavoro e renderla la cartella corrente
- copiare nella cartella il file di questa attività: onehidden.ipynb
- aprire il notebook
onehidden.ipynb
- creare un ambiente virtuale locale Python (Select Kernel | Python Environments | Create Python Environment | Venv, e scegliere un interprete Python):
- installare il modulo Python richiesto, eseguendo dal terminale:
pip install torch
Abbiamo già considerato che una rete neurale è l'implementazione di una funzione parametrica $Y = f(X; \theta)$, e può essere intesa come uno strumento di approssimazione di funzioni $F(X)$ date: attraverso un opportuno addestramento, si trovano i valori appropriati dei parametri $\theta$ in modo che $f(X; \theta) \approx F(X)$. E abbiamo sperimentato che una rete costituita da un solo neurone a comportamento lineare non è in grado di approssimare in modo accettabile anche funzioni molto semplici.
Continuando ad assumere di voler approssimare funzioni $F: \mathbb{R} \times \mathbb{R} \rightarrow \mathbb{R}$, rendiamo allora la rete un poco più complessa, introducendo tra i due input e l'output uno o più nodi (d'ora in poi chiameremo così i neuroni) di uno strato nascosto (hidden layer), con la condizione che la rete sia fully connected:
-- tutti gli input sono connessi a tutti i nodi dello strato nascosto, e
-- tutti i nodi dello strato nascosto sono connessi al nodo di output.
Se per esempio lo strato nascosto ha due nodi, la struttura della rete è dunque:
Per costruire e operare sulla rete useremo PyTorch
: importiamo perciò i moduli Python che saranno necessari e verifichiamo se è disponibile una GPU per eseguire la rete.
import torch
import torch.nn as nn
import torch.optim as optim
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
Reimplementiamo la rete in modo da poter gestire questa nuova struttura.
class OneHidden(nn.Module):
def __init__(self, hidden_size, device):
super(OneHidden, self).__init__()
self.hidden = nn.Linear(2, hidden_size)
self.output = nn.Linear(hidden_size, 1)
self.loss = nn.MSELoss() # funzione di errore: Mean Squared Error
self.optimizer = optim.SGD(self.parameters(), lr=0.01) # ottimizzatore: Stochastic Gradient Descent
self.device = device
self.to(device)
def forward(self, x):
x = self.hidden(x)
x = self.output(x)
return x
def set_learning_rate(self, learning_rate):
for param_group in self.optimizer.param_groups:
param_group['lr'] = learning_rate
def train(self, x, y, epochs, repeat):
print(f'\n*** Addestramento ***\nepoca\terrore (su {device})')
for epoch in range(epochs):
self.optimizer.zero_grad() # azzera i gradienti
output = self(x) # calcola l'output
loss = self.loss(output, y) # calcola la funzione di errore
loss.backward() # calcola i gradienti
self.optimizer.step() # aggiorna i valori dei parametri
if (epoch+1) % repeat == 0:
print(f'{epoch+1}\t{loss.item():.3f}')
def predict(self, examples, fun):
print('\n*** Inferenza ***')
x_test = examples
y_test = self(x_test) # calcola la previsione
y_true = self.calc_fun(fun, x_test)
print('x1\tx2\ty\ty prev\terrore')
for i in range(x_test.size(0)):
x1, x2 = x_test[i][0].item(), x_test[i][1].item()
y, y_hat = y_true[i].item(), y_test[i].item()
print(f'{x1:.2f}\t{x2:.2f}\t{y:.2f}\t{y_hat:.2f}\t{y - y_hat:.2f}')
print(f'Errore quadratico medio: {torch.mean((y_test - y_true)**2):.5f}')
def reset_parameters(self):
for layer in self.children():
if hasattr(layer, 'reset_parameters'):
layer.reset_parameters()
def print_parameters(self):
for name, param in self.named_parameters():
print(name, param.data)
def count_parameters(self):
return sum(p.numel() for p in self.parameters() if p.requires_grad)
def calc_fun(self, fun, X):
return fun(X[:, 0], X[:, 1]).view(-1, 1).to(self.device)
model = OneHidden(2, device)
print('I parametri della rete sono:'); model.print_parameters()
print(f"Questa è dunque una rete con {model.count_parameters()} parametri.")
I parametri della rete sono: hidden.weight tensor([[0.6492, 0.0096], [0.2376, 0.6717]], device='cuda:0') hidden.bias tensor([-0.1753, -0.6676], device='cuda:0') output.weight tensor([[0.0497, 0.1581]], device='cuda:0') output.bias tensor([0.5729], device='cuda:0') Questa è dunque una rete con 9 parametri.
Costruiamo il training set, negli input come un certo numero di coppie di numeri casuali e nel corrispondente output, dopo aver scelto la funzione da approssimare. Supponiamo sia la funzione massimo tra due numeri, che avevamo visto un singolo neurone non riesce ad approssimare in modo accettabile.
Quindi, dopo aver assegnato i valori agli iperparametri, addestriamo la rete.
def examples(n): return (10 * torch.rand(n, 2) - 5).to(device) # genera n esempi nella forma ognuno di una coppia di numeri casuali tra -5 e 5
num_examples = 100 # numero di esempi per il training set
X = examples(num_examples) # calcola i valori degli esempi: input del training set
def fun(x1, x2): return torch.max(x1, x2) # funzione da approssimare, in questo caso il massimo tra due numeri
Y = model.calc_fun(fun, X) # calcola il valore della funzione per ogni esempio: output del training set
num_epochs = 1000 # numero di ripetizioni del processo di addestramento
repeat = 100 # numero di ripetizioni dopo le quali visualizzare l'errore
model.reset_parameters() # reinizializza i parametri della rete
model.set_learning_rate(0.02) # imposta il learning rate
model.train(X, Y, num_epochs, repeat) # addestra la rete
*** Addestramento *** epoca errore (su cuda) 100 1.329 200 1.329 300 1.329 400 1.329 500 1.329 600 1.329 700 1.329 800 1.329 900 1.329 1000 1.329
È chiaro che la presenza dello strato nascosto non ha migliorato le cose: nonostante un numero più elevato di ripetizioni, il processo di addestramento non è in grado di ridurre l'errore a valori accettabili.
La ragione dovrebbe essere chiara: stiamo cercando di approssimare una funzione non lineare con una combinazione lineare di funzioni lineari, che è a sua volta una funzione lineare.
La soluzione è di introdurre un qualche genere di non linearità nella rete: la si realizza modificando la funzione calcolata dai nodi dello strato nascosto...
def forward(self, x):
x = self.hidden(x)
x = torch.relu(x) # funzione di attivazione non lineare: ReLU
x = self.output(x)
return x
OneHidden.forward = forward
num_epochs = 1000 # numero di ripetizioni del processo di addestramento
repeat = 100 # numero di ripetizioni dopo le quali visualizzare l'errore
model.reset_parameters() # reinizializza i parametri della rete
model.set_learning_rate(0.02) # imposta il learning rate
model.train(X, Y, num_epochs, repeat) # addestra la rete
model.predict(examples(10), fun) # inferenza dopo l'addestramento
*** Addestramento *** epoca errore (su cuda) 100 1.099 200 1.015 300 0.968 400 0.893 500 0.802 600 0.476 700 0.160 800 0.074 900 0.039 1000 0.023 *** Inferenza *** x1 x2 y y prev errore 4.56 -0.90 4.56 4.03 0.53 -3.79 -2.80 -2.80 -2.95 0.15 -4.57 1.19 1.19 1.17 0.02 -0.72 3.60 3.60 3.60 -0.00 -4.65 -1.97 -1.97 -2.08 0.11 -1.88 0.08 0.08 -0.00 0.09 -2.89 -0.28 -0.28 -0.36 0.09 0.30 0.58 0.58 0.47 0.10 -4.03 -3.35 -3.35 -3.51 0.16 1.45 0.36 1.45 1.53 -0.08 Errore quadratico medio: 0.03735