Reti neurali con Tensorflow: i principi su cui si basa l’apprendimento automatico

Reti neurali con Tensorflow: i principi su cui si basa l’apprendimento automatico

Condividi con i tuoi amici...

Le reti neurali artificiali sono strumenti potenti per l’apprendimento automatico, in grado di modellare relazioni complesse tra input e output. Ma come funzionano esattamente? Come può una rete neurale imparare a riconoscere volti, tradurre frasi o guidare un’auto autonoma? La risposta risiede nella comprensione dei concetti di retropropagazione dell’errore e di discesa del gradiente, due concetti fondamentali nell’addestramento delle reti neurali.

Consigliamo la lettura anche dell’articolo chiarezza sulle reti neurali.

In questo articolo approfondiremo questi aspetti caratteristici degli algoritmi di AI e li tradurremo in pratica con dei codici python d’esempio che si avvarranno della libreria Tensorflow.

Modello di regressione lineare

Per avvicinarci progressivamente alla comprensione dei concetti indicati nella premessa, consideriamo il semplicissimo modello di regressione lineare.

Anche una semplice calcolatrice scientifica è in grado di effettuare un fit lineare a partire da un insieme di dati (x, y), ma ciò che a noi interessa in questo contesto è mostrare come il fit venga realizzato da un algoritmo che impara dai propri errori mediante una serie di passi successivi di tentativi ed errori guidati da alcuni principi matematici.

Ricordiamo che la regressione lineare si compie quando si presuppone che l’insieme di dati {xi} e l’insieme di dati {yi} siano legati da una relazione lineare del tipo y = w * x + b e non si conoscono i coefficienti w e b. Per trovarli si può applicare il metodo statistico dei momenti o il metodo dei minimi quadrati che in questo caso risulta matematicamente semplice perché comporta il calcolo di derivate parziali di una funzione abbastanza semplice. Nel fare questo si ottengono i valori dei coefficienti w e b in un colpo solo a partire dai dati {xi} e {yi}. Non sempre è così semplice, soprattutto quando si presuppone l’esistenza di relazioni non lineari tra le variabili x e y.

Descriviamo lo schema di una banale rete neurale che effettua una regressione lineare e mostriamo come riesca a determinare w e b per tentativi ed errori passo dopo passo. Sarà poi più semplice generalizzare tale procedura ai casi più complessi.

I dati d’ingresso (input) sono costituiti da singoli valori numerici (xi) i=1…N, dove N è il numero di dati x presenti nel campione di addestramento. La funzione di calcolo W * xi + b è svolta dal solo neurone presente. Inizialmente non conoscendo W e b verranno attribuiti loro valori casuali.

Il risultato (output) y’i = W * xi + b sarà diverso da yi del campione. La differenza y-y’ costituisce l’errore commesso dall’algoritmo in questo primo passo. in realtà si definisce la seguente funzione di costo per l’errore:

L = Σi(y – (W*xi + b))2

L’obiettivo dell’algoritmo è quello di minimizzare questa funzione passo dopo passo in modo da ottenere dei valori di W (peso) e di b (bias) tali per cui il valore di W*xi + b sia il più vicino possibile a quello dei dati di addestramento yi.

In breve abbiamo dei dati di addestramento xi (input) e yi (risultato) e un modello che fornisce una previsione per yi: y’i = W * xi + b che cerca di avvicinarsi ai dati del valore reale yi. Dal punto di vista statistico W*xi + b è uno stimatore della vera relazione y = Wvero * x + bvero

La domanda che sorge a questo punto è:

Come aggiustare il valore del peso W e del bias b in modo che y’ si avvicini a y e la funzione di costo raggiunga il minimo?

La retropropagazione dell’errore (backpropagation)

Quello che deve fare l’algoritmo dopo il primo passo è stabilire in che misura la variazione di W e di b influiscono sulla variazione della funzione di costo. Questa operazione si traduce matematicamente nel calcolare le derivare parziali della funzione di costo L rispetto al peso W e al bias b:

∇L/∇W = Σ -2x(y – (W*x + b))

∇L/∇b = Σ -2(y – (W*x + b))

chiaramente per funzioni di costo più complesse che coinvolgono più pesi W e bias b l’algoritmo adotta un metodo chiamato “autodifferenziazione inversa” o “backpropagation” per calcolare le derivate necessarie. Il meccanismo è complesso e in parte si basa sulla regola della catena di calcolo della derivata delle funzioni composte f(g(h(x))) che sarebbe df/dg * dg/dh * dh/dx.

Al momento non approfondiamo questo aspetto matematico, ma ci limitiamo a dire che mediante questo calcolo l’algoritmo stabilisce di quanto varia la funzione di costo al variare dei singoli pesi W e dei bias b della rete. Nel fare ciò procede all’indietro, nel senso che a partire da una sua variazione risale alla variazione dei singoli pesi e bias della rete.

La discesa del gradiente

Una volta calcolato il gradiente (vettore le cui componenti sono derivate parziali) possiamo aggiornare W e b sottraendo il gradiente moltiplicato per il tasso di apprendimento α:

W = W – α *∇L/∇W

b = b – α *∇L/∇b

Questi passaggi rappresentano un‘iterazione dell’algoritmo di discesa del gradiente. Ripetendo questi passaggi, W e b si aggiorneranno iterativamente fino a quando la differenza tra i nuovi e i vecchi valori (o la funzione di perdita) non sarà sotto una certa soglia o fino a raggiungere un numero massimo di iterazioni.

in pratica il meccanismo di backpropagation dice all’algoritmo di discesa del gradiente quale direzione prendere per scendere più velocemente verso il minimo della funzione di costo.

Vediamo queste cose in pratica.

Tensorflow

TensorFlow è un framework open source utilizzato per creare modelli di machine learning. E’ stato sviluppato da Google e da quando è stato rilasciato al pubblico nel 2015, è diventato uno dei pacchetti di deep learning più noti ed è stato adottato da ricercatori in tutto il mondo.

TensorFlow è progettato per svolgere compiti complessi di calcolo numerico tramite dataflow graph. Un dataflow graph o grafo di flusso di dati è una rappresentazione visiva che mostra come i dati si spostano attraverso diversi processi o operazioni. Ogni nodo nel grafico rappresenta un’operazione matematica, mentre ogni bordo rappresenta i tensori multidimensionali (ovvero array di dati) comunicati tra di essi.

Ne caso del problema semplice della regressione lineare, lo schema è ben rappresentato così:

Per questo compito è possibile impiegare anche librerie come sklearn che hanno funzioni per la linear regression. In questo contesto usiamo TensorFlow.

Riportiamo il codice python che genera il semplice modello di regressione lineare in un caso banale che ci servirà esclusivamente per vedere come funziona il metodo di retropropagazione dell’errore e di discesa del gradiente:


import tensorflow as tf

# dati di input
x_train = [1.0, 2.0, 3.0, 4.0]
y_train = [-1.0, -2.0, -3.0, -4.0]

W = tf.Variable([0.0])
b = tf.Variable([0.0])

# Modello lineare
def linear_model(x):
    return  W * x + b

# Funzione di perdita: errore quadratico medio
def loss_function(y_true, y_pred):
    return tf.reduce_sum(tf.square(y_true - y_pred))

# Ottimizzatore: discesa del gradiente
optimizer = tf.optimizers.SGD(0.01)

# Addestramento del modello per 1000 epoche
for _ in range(1000):
    # Utilizzo di tf.GradientTape per monitorare le operazioni per il calcolo del gradiente
    with tf.GradientTape() as tape:
        predictions = linear_model(x_train)
        loss = loss_function(y_train, predictions)
    # Calcolare il gradiente
    gradients = tape.gradient(loss, [W, b])
    # Ottimizzare i pesi con il gradiente
    optimizer.apply_gradients(zip(gradients, [W, b]))

# Stampa dei pesi ottimizzati
print("W:", W.numpy())
print("b:", b.numpy())

Note aggiuntive sul codice:

Come si può vedere la retta è semplicissima ed è quella che passa proprio per tutti i punti dati

Gli steps dell’algoritmo possono essere descritti mediante il seguente diagramma realizzato mediante il plug in Diagrams di GPT4:

Mostriamo anche il grafico della funzione di perdita (funzione di costo) realizzata a tratti seguendo l’aggiustamento dei pesi:

Realizzata con Code interpreter (GPT4)

Come si vede il minimo viene raggiunto per W = -1 il coefficiente angolare della retta trovata.

il grafico che mostra la funzione di perdita rispetto a W durante l’addestramento. Come puoi vedere, la perdita diminuisce man mano che W si avvicina al suo valore ottimale. Questo è ciò che ci aspetteremmo da un modello di regressione lineare che si sta addestrando correttamente.

L’esempio della regressione lineare si può estendere al caso di più variabili indipendenti. In quel caso invece di avere un solo valore di input ne avremo diversi. Uno schema di una rete neurale di questo tipo è

Reti neurali con più livelli e molti neuroni per studiare relazioni non lineari

Quando presupponiamo l’esistenza di una relazione non lineare sconosciuta tra l’insieme di variabili indipendenti xi e l’insieme di variabili dipendenti yi, allora è necessario progettare una rete neurale formata di più neuroni e/o più livelli come quella mostrata qui sopra.

Questo tipo di rete è completamente connessa in quanto tutti i neuroni di un livello ricevono imput da tutti i neuroni del livello che lo precede.

Funzioni di attivazione (es: ReLU)

Un’altra caratteristica delle reti addestrabili su dati privi di una relazione di linearità è la presenza di funzioni di attivazione. Queste funzioni agiscono sull’output di un neurone e lo trasformano in modo non lineare. Per esempio la funzione ReLU lascia inalterato l’output se questo è un numero maggiore di zero mentre se è minore o uguale a zero lo pone a zero.

Consideriamo l’esempio di una rete neurale composta da un livello di input con un solo valore numerico di ingresso, un livello nascosto di 16 neuroni per ognuno dei quali all’output viene applicata una funzione di attivazione ReLU.

Creiamo un set di dati di addestramento centrati gaussianamente su una funzione sinusoidale.

Si tratta solo di un esempio di dati che non si adattano ad una relazione lineare. È possibile sostituire questi dati con altri di cui non si conosce la relazione.

Ecco il relativo codice python che si avvale di Tensorflow:


import tensorflow as tf
import numpy as np

# Generazione dei dati di input
x_train = np.linspace(-np.pi, np.pi, 700)
y_train = np.sin(x_train) + np.random.randn(700) * 0.1  # Aggiunta di rumore ai dati

# Normalizzazione dei dati di input
x_mean, x_std = x_train.mean(), x_train.std()
x_train = (x_train - x_mean) / x_std

# Definizione della rete neurale a due livelli
model = tf.keras.Sequential([
    tf.keras.layers.Dense(16, activation='relu', input_shape=(1,)),
    tf.keras.layers.Dense(1)
])

# Compilazione del modello
model.compile(optimizer='adam', loss='mse')

# Addestramento del modello
model.fit(x_train, y_train, epochs=50)

# Stampa dei valori dei pesi
for layer in model.layers:
    weights = layer.get_weights()
    print("Pesos del livello:", weights)

# Generazione di dati di test per la valutazione del modello
x_test = np.linspace(-np.pi, np.pi, 700)
x_test = (x_test - x_mean) / x_std

# Predizione con il modello addestrato
y_pred = model.predict(x_test)

# Grafico dei risultati
import matplotlib.pyplot as plt
plt.scatter(x_train, y_train, label='Dati di training')
plt.plot(x_test, y_pred, color='red', linewidth=2, label='Predizione')
plt.legend()
plt.show()

Note aggiuntive al codice

Nel blocco in rosso è possibile inserire livelli, neuroni per ogni livello e funzioni di attivazione a piacimento.

Il giusto equilibrio

Trovare un equilibrio tra la quantità di dati di addestramento, il numero di livelli della rete, il numero di neuroni per ogni livello e le funzioni di attivazione può essere una sfida. Questi fattori possono influenzarsi reciprocamente e possono variare a seconda del problema specifico che si sta affrontando e dei dati disponibili.

Ecco alcuni punti da considerare quando si cerca di raggiungere un equilibrio tra questi parametri:

  1. Quantità dei dati di addestramento: In generale, avere più dati di addestramento può aiutare a migliorare le prestazioni del modello. Più dati permettono al modello di apprendere modelli complessi e generalizzarli meglio ai dati non visti in fase di addestramento. Tuttavia, esistono casi in cui l’aumento del numero dei dati di addestramento potrebbe fornire rendimenti decrescenti. È quindi importante bilanciare la quantità di dati disponibili con il costo computazionale e temporale dell’addestramento.
  2. Numero di livelli della rete: Aggiungere più livelli alla rete fornisce al modello la capacità di apprendere rappresentazioni gerarchiche e complesse dei dati. Un numero eccessivo di livelli potrebbe portare a un modello troppo complesso, che potrebbe soffrire di overfitting (quando il modello si adatta troppo ai dati di addestramento e non generalizza bene ai dati nuovi). Pertanto, è importante trovare un equilibrio tra la complessità della rete e la disponibilità di dati di addestramento.
  3. Numero di neuroni per ogni livello: Aumentare il numero di neuroni per ogni livello aumenta la capacità del modello di apprendere relazioni complesse. Un numero maggiore di neuroni permette alla rete di approssimare meglio la funzione obiettivo, anche in questo caso si può ottenere anche un aumento nella complessità del modello e alla possibilità di overfitting. Un numero ridotto di neuroni potrebbe invece limitare la capacità del modello di adattarsi ai dati di addestramento. È necessario esplorare diverse configurazioni di numero di neuroni e valutare le prestazioni del modello su dati di validazione o test per trovare un buon equilibrio.
  4. Funzioni di attivazione: Le funzioni di attivazione sono fondamentali per introdurre non linearità nella rete neurale. La scelta della funzione di attivazione può influire sulla capacità del modello di approssimare relazioni complesse. Funzioni di attivazione comuni come ReLU, sigmoid e tanh hanno ciascuna vantaggi e svantaggi. È importante esplorare diverse funzioni di attivazione, in funzione del problema specifico che si sta affrontando, per trovare quella più adatta.

Backpropagation e discesa del gradiente in sintesi

La funzione di costo ha come variabili indipendenti i pesi di tutta la rete neurale. L’obiettivo dell’algoritmo di backpropagation è calcolare i gradienti della funzione di costo rispetto ai pesi, in modo da trovare l’insieme di pesi che minimizza la funzione di costo.

Per calcolare i gradienti, l’algoritmo di backpropagation ricostruisce le relazioni tra i pesi esistenti tra i vari livelli della rete neurale. Utilizza la regola della catena per calcolare le derivate parziali di ogni peso rispetto alla funzione di costo, differenziando uno strato alla volta a partire dallo strato di output verso quello di input. Attraverso questo processo, l’algoritmo calcola i gradienti per ogni peso nella rete neurale.

Una volta ottenuti i gradienti, l’algoritmo utilizza il metodo della discesa del gradiente per ottimizzare i pesi in modo iterativo. La discesa del gradiente consiste nel muoversi nella direzione opposta del gradiente (inverso del gradiente) con un passo determinato dal tasso di apprendimento (learning rate). L’obiettivo è raggiungere progressivamente l’insieme di pesi che minimizza la funzione di costo. L’algoritmo aggiorna i pesi iterativamente, calcolando nuovi valori dei pesi utilizzando la formula del gradient descent.

In sostanza, l’algoritmo di backpropagation calcola i gradienti della funzione di costo rispetto ai pesi, mentre l’algoritmo della discesa del gradiente utilizza questi gradienti per aggiornare iterativamente i pesi fino a raggiungere un minimo della funzione di costo.