L'approssimazione di una funzione lineare mediante un singolo neurone a comportamento lineare¶
Luca Mari, ottobre 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à: neuron.ipynb
- aprire il notebook
neuron.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
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)$.
Quest'idea venne sviluppata inizialmente assumendo che i componenti elementari di una rete -- i suoi neuroni -- avessero un comportamento lineare:
nel caso di due input.
La situazione più semplice è ovviamente quella di una rete costituita da un solo neurone. Facciamo qualche prova.
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")
Costruiamo la rete usando PyTorch
(il codice ha un po' di dettagli tecnici, non necessariamente importanti: i commenti potrebbero essere comunque utili) e visualizziamo i valori dei suoi parametri, che inizialmente sono casuali.
class OneNeuron(nn.Module):
def __init__(self, device):
super(OneNeuron, self).__init__()
self.neuron = nn.Linear(2, 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.neuron(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 calc_fun(self, fun, X):
return fun(X[:, 0], X[:, 1]).view(-1, 1).to(self.device)
model = OneNeuron(device)
print('I parametri della rete sono:'); model.print_parameters()
I parametri della rete sono: neuron.weight tensor([[ 0.1170, -0.1727]]) neuron.bias tensor([0.3492])
Costruiamo il training set, prima di tutto negli input (features, covariates) come un certo numero di coppie di numeri casuali.
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
Scegliamo la funzione, dunque a due argomenti, da approssimare. Essendo un caso di supervised learning, calcoliamo la funzione per tutte le coppie del training set e aggiungiamo il risultato al training set stesso.
def fun(x1, x2): return (x1 + x2) / 2 # funzione da approssimare, in questo caso la media tra due numeri
Y = model.calc_fun(fun, X) # calcola il valore della funzione per ogni esempio: output del training set
print(f"L'esempio di una tripla nel training set: x1={X[0, 0].item():.3f}, x2={X[0, 1].item():.3f}, y={Y[0, 0].item():.3f}")
L'esempio di una tripla nel training set: x1=1.591, x2=-4.751, y=-1.580
Già ora possiamo mettere in funzione la rete, su un certo numero di esempi che costituiscono dunque un test set, ma ovviamente il risultato non sarà in alcun modo accurato.
model.predict(examples(10), fun) # inferenza prima dell'addestramento
*** Inferenza *** x1 x2 y y prev errore -3.10 3.60 0.25 -0.63 0.89 3.12 0.50 1.81 0.63 1.19 3.73 1.52 2.62 0.52 2.10 2.33 -1.73 0.30 0.92 -0.62 -1.48 -3.13 -2.30 0.72 -3.02 2.02 3.60 2.81 -0.04 2.85 3.96 -2.67 0.64 1.27 -0.63 1.60 4.63 3.11 -0.26 3.38 2.45 3.81 3.13 -0.02 3.15 -3.87 1.27 -1.30 -0.32 -0.97 Errore quadratico medio: 4.68676
Addestriamo allora la rete, dopo aver assegnato valori opportuni ai due iperparametri fondamentali:
-- il numero di volte in cui il processo di addestramento viene ripetuto, e
-- la velocità di apprendimento (learning rate).
num_epochs = 100 # numero di ripetizioni del processo di addestramento
repeat = 10 # 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) 10 0.006 20 0.000 30 0.000 40 0.000 50 0.000 60 0.000 70 0.000 80 0.000 90 0.000 100 0.000
Mettiamo in funzione la rete su un nuovo test set: se l'addestramento ha avuto successo, si dovrebbe ottenere un piccolo errore quadratico medio.
model.predict(examples(10), fun) # inferenza dopo l'addestramento
*** Inferenza *** x1 x2 y y prev errore 1.08 -0.75 0.16 0.16 0.00 -4.01 3.09 -0.46 -0.46 0.00 -0.60 0.50 -0.05 -0.05 0.00 3.19 -3.25 -0.03 -0.03 0.00 -0.02 -2.84 -1.43 -1.43 0.00 -3.96 2.43 -0.76 -0.76 0.00 1.15 2.75 1.95 1.95 0.00 0.76 -0.11 0.32 0.32 0.00 1.96 -2.01 -0.03 -0.03 0.00 -2.10 1.57 -0.27 -0.27 0.00 Errore quadratico medio: 0.00000
Visualizziamo i valori dei parametri della rete: se l'addestramento ha avuto successo, dovrebbero essere vicini ai valori attesi.
model.print_parameters()
neuron.weight tensor([[0.5000, 0.5001]]) neuron.bias tensor([-0.0007])
La struttura della rete è così semplice che possiamo ripetere l'intero processo senza ricorrere a PyTorch
, per mostrare così in modo esplicito la logica della procedura.
num_epochs = 100 # numero di ripetizioni del processo di addestramento
repeat = 10 # numero di ripetizioni dopo le quali visualizzare l'errore
learning_rate = 0.01 # learning rate
minibatch_size = 10 # dimensione del minibatch: numero di esempi estratti dal training set per ogni epoca
k0, k1, k2 = torch.randn(3) # valori casuali di inizializzazione dei parametri
print(f'\n*** Addestramento ***\nepoca\terrore\tk0\tk1\tk2')
for i in range(num_epochs):
indexes = torch.randperm(X.size(0))[:minibatch_size] # seleziona in modo casuale gli indici del minibatch
X1 = X[indexes, 0] # estrai dal training set gli argomenti della funzione
X2 = X[indexes, 1]
Y_prev = k0 + k1 * X1 + k2 * X2 # calcola la previsione
Y_true = Y[indexes, 0] # estrai dal training set il valore della funzione
loss = torch.mean((Y[indexes, 0] - Y_prev)**2) # calcola la funzione di errore (errore quadratico medio)
k0 -= learning_rate * 2 * torch.mean(Y_prev - Y_true) # calcola le derivate parziali della funzione di errore...
k1 -= learning_rate * 2 * torch.mean((Y_prev - Y_true) * X1) # ... e aggiorna i valori dei parametri...
k2 -= learning_rate * 2 * torch.mean((Y_prev - Y_true) * X2) # ... dunque "scendendo lungo il gradiente"
if (i+1) % repeat == 0:
print(f'{i+1}\t{loss.item():.3f}\t{k0:.3f}\t{k1:.3f}\t{k2:.3f}')
*** Addestramento *** epoca errore k0 k1 k2 10 1.231 -1.109 0.573 0.366 20 0.863 -0.901 0.518 0.490 30 0.609 -0.738 0.494 0.538 40 0.310 -0.604 0.503 0.553 50 0.285 -0.499 0.518 0.527 60 0.182 -0.415 0.491 0.542 70 0.112 -0.345 0.509 0.534 80 0.082 -0.283 0.504 0.514 90 0.048 -0.233 0.505 0.523 100 0.044 -0.192 0.491 0.506
Quella che segue è invece un'implementazione semplificata di un algoritmo genetico, per risolvere lo stesso problema di ottimizzazione.
num_individuals = 100 # numero di individui della popolazione in evoluzione
num_survivors = 50 # numero di individui che in ogni epoca sopravvive
num_mutations = 5 # numero di individui che in ogni epoca subisce una mutazione
width_mutations = .1 # ampiezza (deviazione standard) delle mutazioni
minibatch_size = 10 # dimensione del minibatch: numero di esempi estratti dal training set per ogni epoca
num_epochs = 100 # numero di ripetizioni del processo di addestramento
repeat = 10 # numero di ripetizioni dopo le quali visualizzare l'errore
k = torch.randn(num_individuals, 3)
print(f'\n*** Addestramento ***\nepoca\terrore\tk0\tk1\tk2')
for i in range(num_epochs):
indexes = torch.randperm(X.size(0))[:minibatch_size] # seleziona in modo casuale gli indici del minibatch
X1 = X[indexes, 0].view(-1, 1).T # estrai dal training set gli argomenti della funzione
X2 = X[indexes, 1].view(-1, 1).T
Y_true = Y[indexes, 0].view(-1, 1).T # estrai dal training set il valore della funzione
Y_prev = k[:,0].view(-1, 1) + k[:,1].view(-1, 1) * X1 + k[:,2].view(-1, 1) * X2 # calcola la previsione
loss = torch.mean((Y[indexes, 0] - Y_prev)**2, dim=1) # calcola la funzione di errore per ogni individuo
sorted_indexes = torch.argsort(loss, descending=False) # ottieni gli indici degli individui ordinati in base all'errore
k = k[sorted_indexes][:num_survivors] # ordina gli individui per fitness e seleziona i migliori
m0 = torch.randint(num_survivors, (num_mutations, 1)).view(-1) # seleziona casualmente gli indici degli individui da mutare
m1 = torch.randint(3, (num_mutations, 1)).view(-1)
k[m0, m1] += torch.randn(num_mutations) * width_mutations # introduci una mutazione negli individui selezionati
k = torch.cat((k, torch.randn(num_individuals - num_survivors, 3)), 0) # reintegra la popolazione con nuovi individui casuali
if (i+1) % repeat == 0:
best = k[sorted_indexes][0]
print(f'{i+1}\t{loss[sorted_indexes][0].item():.3f}\t{best[0].item():.3f}\t{best[1].item():.3f}\t{best[2].item():.3f}')
*** Addestramento *** epoca errore k0 k1 k2 10 0.091 0.192 0.556 0.433 20 0.190 0.139 0.556 0.314 30 0.123 -0.687 0.507 0.544 40 0.077 -0.199 0.462 0.522 50 0.058 -0.199 0.462 0.475 60 0.007 -0.052 0.513 0.518 70 0.004 -0.052 0.513 0.466 80 0.026 -0.078 0.513 0.569 90 0.019 0.142 0.506 0.477 100 0.016 -0.026 0.513 0.446
Tornando ora a usare PyTorch
: d'altra parte, è evidente che un singolo neurone a comportamento lineare può approssimare efficacemente solo funzioni molto semplici. Anche aumentando il numero di esempi e di ripetizioni del processo di addestramento, per esempio non è in grado di approssimare in modo accettabile la funzione massimo tra due numeri.
num_examples = 1000 # numero di esempi per il training set
X = examples(num_examples) # 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.01) # imposta il learning rate
model.train(X, Y, num_epochs, repeat) # addestra la rete
model.predict(examples(10), fun) # metti in funzione la rete
*** Addestramento *** epoca errore (su cuda) 100 1.429 200 1.402 300 1.402 400 1.402 500 1.402 600 1.402 700 1.402 800 1.402 900 1.402 1000 1.402 *** Inferenza *** x1 x2 y y prev errore -2.97 -2.33 -2.33 -1.08 -1.25 2.32 -2.67 2.32 1.44 0.88 -4.40 -1.10 -1.10 -1.18 0.08 -2.38 0.51 0.51 0.67 -0.15 -4.92 2.27 2.27 0.26 2.01 -2.52 3.99 3.99 2.36 1.63 0.35 -0.05 0.35 1.77 -1.42 -4.51 4.43 4.43 1.57 2.86 0.71 3.10 3.10 3.55 -0.44 0.94 -2.44 0.94 0.85 0.08 Errore quadratico medio: 1.94872
Per ottenere approssimazioni accettabili occorre dunque costruire una rete più complessa.