Un semplice confronto tra comportamento programmato e comportamento appreso¶
Luca Mari, ottobre 2024
Quest'opera è distribuita con Licenza Creative Commons Attribuzione - Non commerciale - Condividi allo stesso modo 4.0 Internazionale.
Obiettivo: a partire da un esempio elementare -- un problema di regressione lineare semplice -- comprendere le differenze fondamentali tra sistemi a comportamento programmato ("programmed machines") e sistemi a comportamento appreso ("learned machines").
Precompetenze: basi di Python; almeno qualche idea di analisi matematica.
Per eseguire questo notebook con VSCode sul proprio calcolatore, 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à: linear.ipynb
- aprire il notebook
linear.ipynb
- creare un ambiente virtuale locale Python (Select Kernel | Python Environments | Create Python Environment | Venv, e scegliere un interprete Python):
- installare i moduli Python richiesti, eseguendo dal terminale:
pip install numpy matplotlib torch
Supponiamo di disporre di un insieme di coppie di numeri $\{(x_i, y_i)\}$, e da questo, per ogni nuovo $x$, di voler trovare l'$y$ più coerente con il contenuto dell'insieme. Si tratta dunque di un problema data driven, che ci richiede di trovare un modello in grado di interpretare la relazione tra le due variabili $x$ e $y$.
Una scelta molto semplice per tale modello è di considerare $x$ e $y$ come le coordinate di una retta, dunque in relazione attraverso l'equazione $y = a + b x$, cioè una funzione $y = f(x; a, b)$ a due parametri, $a$ e $b$, di cui dobbiamo stimare i valori. Si tratta di un problema noto in statistica come regressione lineare.
In altri termini, il modello che abbiamo assunto ha una struttura –- data dall’ipotesi che la relazione tra $x$ e $y$ sia lineare – che però non identifica completamente il modello, dato il fatto che ci sono infinite funzioni lineari, cioè infinite rette, al variare dei valori dei parametri $a$ e $b$: il nostro problema è perciò di trovare i valori di $a$ e $b$ che corrispondono alla retta che meglio interpreta i dati nell'insieme $\{(x_i, y_i)\}$.
Risolveremo questo problema in accordo a due strategie alternative, implementando prima una soluzione "a comportamento programmato", dunque con una "programmed machine", e poi una soluzione "a comportamento appreso", dunque con una "learned machine".
Importiamo prima di tutto i moduli Python necessari.
import numpy as np
import matplotlib.pyplot as plt
import torch
import numpy as np
from ipywidgets import interact, FloatSlider
Generiamo ora sinteticamente l'insieme di coppie di numeri $\{(x_i,y_i)\}$ da interpretare per stimare i valori dei parametri del modello. Supponiamo che ogni coppia sia effettivamente disposta su una retta, a meno di un po' di "rumore", dunque $y_i = a + bx_i + r_i$, dove assumiamo che $r_i$ sia un numero casuale a distribuzione gaussiana con media $0$. Quindi visualizziamo i punti dell'insieme.
num_coppie = 100 # numero di coppie di punti (x_i, y_i) da generare
a_vero = 10.0 # valore vero, che tratteremo come ignoto e da stimare, del parametro a
b_vero = 2.0 # valore vero, che tratteremo come ignoto e da stimare, del parametro b
X = 10 * np.random.rand(num_coppie) - 5 # 20 numeri casuali a distribuzione uniforme tra -5 e 5
Y = a_vero + b_vero * X + 5 * np.random.randn(num_coppie) # y = 3x + 4 + errore gaussiano a media 0 e deviazione standard 5
plt.plot(X, Y, 'o')
plt.show()
Il problema di stimare i valori dei parametri $a$ e $b$ di una funzione lineare viene generalmente interpretato in accordo alla tecnica “dei minimi quadrati”, nota da molto tempo e basata sull'ipotesi che i valori in questione siano quelli che minimizzano la distanza della retta che stiamo cercando dai punti dell'insieme, valutata minimizzando la funzione di errore (loss function) definita come la media $\overline{r_i^2}$ dei valori $r_i$ elevati al quadrato per evitare che valori positivi e negativi si compensino.
Potremmo cercare di risolvere il problema variando manualmente i valori dei due parametri per cercare i valori che minimizzano la funzione di errore.
def straight_line(slope, intercept):
x = np.linspace(-10, 10, 10)
y = intercept + x * slope # coordinate y della retta
mse = np.mean(((intercept + X * slope) - Y) ** 2) # funzione di errore da minimizzare
plt.figure(figsize=(8, 4))
plt.plot(X, Y, 'o')
plt.plot(x, y)
plt.title(f'Retta con pendenza {slope:.1f} e intercetta {intercept:.1f}; MSE = {mse:.1f}')
plt.xlabel('x')
plt.ylabel('y')
plt.xlim(-5, 5)
plt.ylim(-20, 30)
plt.grid(True)
plt.show()
interact(straight_line,
slope=FloatSlider(value=1, min=-5, max=5.0, step=0.1, description='Pendenza'),
intercept=FloatSlider(value=0, min=-20, max=20, step=0.1, description='Intercetta')
)
interactive(children=(FloatSlider(value=1.0, description='Pendenza', max=5.0, min=-5.0), FloatSlider(value=0.0…
<function __main__.straight_line(slope, intercept)>
Evidentemente, questa strategia non è efficiente né garantisce di trovare la soluzione migliore. Proseguiamo allora, introducendo prima una soluzione a comportamento programmato e poi una soluzione a comportamento appreso.
Una soluzione "a comportamento programmato"¶
La funzione di errore, di cui occorre dunque cercare il minimo, è sufficientemente semplice perché esista una soluzione analitica, nella forma di formule algebriche. Si tratta allora di ricavare, o recuperare, le formule in questione, che sono:
$$b = \frac{\sum_i (x_i - \bar x)(y_i - \bar y)}{\sum_i (x_i - \bar x)^2}$$
$$a = \bar y - b \bar x$$
dove $\bar x$ è il valor medio degli $x_i$, e calcolarle per ottenere la stima di $a$ e $b$.
Implementiamo queste formule (non sorprendentemente, potremmo anche usare una versione già implementata, per esempio nel metodo polyfit
di numpy
).
x_medio = np.mean(X)
y_medio = np.mean(Y)
b = (np.sum((X - x_medio) * (Y - y_medio))) / np.sum((X - x_medio) ** 2)
a = y_medio - b * x_medio
print(f'Stima di a: {a:.2f} e suo valore vero: {a_vero:.2f}')
print(f'Stima di b: {b:.2f} e suo valore vero: {b_vero:.2f}')
plt.plot(X, Y, 'o')
plt.plot(X, a + b * X)
plt.show()
Stima di a: 9.43 e suo valore vero: 10.00 Stima di b: 2.10 e suo valore vero: 2.00
Questa è dunque una soluzione a comportamento programmato: esseri umani identificano una procedura per risolvere il problema, la implementano in software, e fanno eseguire il programma così ottenuto a un calcolatore.
Una soluzione "a comportamento addestrato"¶
Una strategia alternativa alla precedente per risolvere il problema di trovare il minimo della funzione di errore è basata su una procedura che può essere sommariamente descritta così:
- si assegnano inizialmente valori casuali ad $a$ e $b$;
- in accordo a questi valori si calcola la funzione $a+bx_i$ per ogni $x_i$;
- si confrontano i risultati ottenuti con i corrispondenti $y_i$ e si calcola così il valore di $\overline{r_i^2}$;
- si modificano i valori di $a$ e $b$ in modo da cercare di ridurre il valore di $\overline{r_i^2}$;
- fintanto che una condizione di termine non è soddisfatta, si torna al passo 2 (la condizione potrebbe richiedere un numero massimo di ripetizioni -- le si chiama abitualmente “epoche” -- o un valore abbastanza piccolo di $\overline{r_i^2}$);
- infine, si usano i valori di $a$ e $b$ come la stima cercata (questa è evidentemente una descrizione generica, che in particolare non specifica come, al passo 4, si adattano i valori dei parametri del modello: è il compito di una funzione di ottimizzazione, per esempio realizzata come una “discesa lungo il gradiente” della funzione di errore).
Implementiamo questa procedura, con un modello che è la più semplice rete neurale.
modello = torch.nn.Linear(1, 1) # costruzione di una rete con un neurone di input e uno di output (i valori dei parametri sono inizializzati casualmente)
learning_rate = 0.1 # tasso di apprendimento
num_epoche = 20 # numero di epoche di apprendimento
XX = torch.from_numpy(X[:, None].astype(np.float32)) # conversione di X in un tensore di PyTorch
YY = torch.from_numpy(Y[:, None].astype(np.float32)) # conversione di Y in un tensore di PyTorch
funz_errore = torch.nn.MSELoss() # funzione di errore
funz_ottimizz = torch.optim.SGD(modello.parameters(), lr = learning_rate) # algoritmo di ottimizzazione
vett_errori = np.zeros(num_epoche) # vettore per memorizzare l'errore
for epoca in range(num_epoche): # ciclo di apprendimento
Y1 = modello(XX) # calcolo delle previsioni
errore = funz_errore(Y1, YY) # calcolo dell'errore
funz_ottimizz.zero_grad() # azzeramento del gradiente
errore.backward() # calcolo del gradiente
funz_ottimizz.step() # aggiornamento dei valori dei parametri
vett_errori[epoca] = errore.item() # memorizzazione del valore attuale della funzione di errore
aa = modello.bias.item() # valore stimato di a
bb = modello.weight.item() # valore stimato di b
print(f'Stima di a: {aa:.2f} e suo valore vero: {a_vero:.2f}')
print(f'Stima di b: {bb:.2f} e suo valore vero: {b_vero:.2f}')
plt.plot(X, Y, 'o')
plt.plot(X, aa + bb * X)
plt.show()
Stima di a: 9.32 e suo valore vero: 10.00 Stima di b: 2.10 e suo valore vero: 2.00
Questo è l'andamento della funzione di errore al variare del numero di epoche:
from matplotlib.ticker import MaxNLocator
plt.plot(range(num_epoche), vett_errori)
plt.gca().xaxis.set_major_locator(MaxNLocator(integer=True))
plt.show()
Il programma che implementa questa procedura opera adattando progressivamente i valori dei parametri del modello, in modo da ottenere un comportamento che interpreta sempre meglio i dati disponibili. Questa è dunque una soluzione a comportamento appreso: esseri umani costruiscono un modello parametrico, e dunque una struttura più o meno generica, che viene poi specificato “imparando” dai dati.