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.
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à: 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:

rete

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

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

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

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

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