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.
No description has been provided for this image

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:

rete
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.

In [166]:
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.

In [167]:
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.

In [168]:
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.

In [169]:
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.

In [170]:
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).

In [171]:
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.

In [173]:
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.

In [174]:
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.

In [206]:
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.

In [209]:
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.

In [10]:
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.