L'approssimazione di una funzione mediante un MLP con uno strato interno¶
Luca Mari, novembre 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 interno (hidden layer), con la condizione che la rete sia fully connected:
-- tutti gli input sono connessi a tutti i nodi dello strato interno, e
-- tutti i nodi dello strato interno sono connessi al nodo di output.
Se per esempio lo strato interno 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")
print(f'In esecuzione su {device}')
In esecuzione su cpu
Implementiamo la rete in modo da poter gestire questa struttura.
class OneHidden(nn.Module):
def __init__(self, device):
super(OneHidden, self).__init__()
self.hidden = nn.Linear(2, 2) # connessioni dai 2 input ai 2 neuroni dello strato interno
self.output = nn.Linear(2, 1) # connessioni dai 2 neuroni dello strato interno all'unico output
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(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.4137, 0.6960], [ 0.2797, 0.3861]]) hidden.bias tensor([-0.0470, 0.5276]) output.weight tensor([[-0.2119, 0.0111]]) output.bias tensor([0.2891]) 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 cpu) 100 1.281 200 1.281 300 1.281 400 1.281 500 1.281 600 1.281 700 1.281 800 1.281 900 1.281 1000 1.281
È chiaro che la presenza dello strato interno 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 interno...
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
model = OneHidden(device)
Ripetiamo il processo di addestramento su questa nuova struttura.
num_epochs = 10000 # numero di ripetizioni del processo di addestramento
repeat = 1000 # 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 cpu) 1000 0.017 2000 0.001 3000 0.000 4000 0.000 5000 0.000 6000 0.001 7000 0.001 8000 0.000 9000 0.000 10000 0.000 *** Inferenza *** x1 x2 y y prev errore -0.15 -2.77 -0.15 -0.14 -0.01 1.03 -3.97 1.03 1.04 -0.01 -0.92 3.76 3.76 3.75 0.01 -4.43 1.96 1.96 1.94 0.01 1.10 -4.23 1.10 1.12 -0.01 -0.03 0.07 0.07 0.07 -0.00 -4.82 -3.39 -3.39 -3.40 0.01 -3.07 -2.35 -2.35 -2.36 0.00 -1.76 -3.66 -1.76 -1.76 -0.00 3.65 3.21 3.65 3.66 -0.01 Errore quadratico medio: 0.00008