Il Neurone Artificiale Adaline, Introduzione

Parte (1/2)

Potrebbe interessarti:

Il progetto Python, che comprende gli script mostrati in questo post, e quelli relativi alla creazione dei grafici Matplotlib e Plotly, è disponibile assieme al corso Introduzione al Machine Learning.

Dimmi di Più

Cos’è un Neurone Artificiale

Adaline (Adaptive Linear Neuron) è un neurone artificiale, un tipo di rete neurale artificiale a singolo layer, sviluppato da Bernard Widrow e Ted Hoff nel 1960. Adaline è un miglioramento rispetto al Percettrone, introdotto nel 1943 da Warren McCulloch e Walter Pitts, e viene considerato come il precursore dei modelli di rete neurale modeni.

Un neurone artificiale è un modello lineare che può essere utilizzato per svolgere compiti di classificazione binaria, dove i dati in ingresso possono essere classificati come classe “$-1$”, oppure classe “$1$” (oppure “$0$” e “$1$”, a seconda del contesto e dell’implementazione specifica). Il neurone artificiale è una singola unità di calcolo, e viene solitamente usato per definire modelli di classificazione più complessi1.

Possiamo pensare ad Adaline come al prototipo di un classificatore moderno, un modello semplice, con limitazioni intrinseche, che ha “aperto le porte” a modelli molto più potenti. Il suo sviluppo è avvenuto prima della comprensione o dell’uso diffuso di alcuni strumenti matematici quali la funzione sigmoide, o la backpropagation, in un contesto nel quale le risorse di calcolo erano estremamente limitate, e la semplicità del modello era fondamentale. Per questo motivo, useremo Adaline come uno strumento didattico, per comprendere il funzionamento di base di un classificatore binario. Poi passeremo a modelli più moderni e funzionali, come la Regressione Logistica.

L’Algoritmo di Classificazione

Adaline estende l’implementazione del nostro modello di regressione lineare, riutilizzando i concetti che già conosciamo (come la funzione di ipotesi, la funzione di costo, il training iterativo del modello, ecc.), ed aggiungendo di un paio di nuove funzionalità che vedremo di seguito. Il diagramma delle sue componenti è mostrato nell’immagine seguente:

Diagramma di Adaline e delle sue parti costituenti
Figura 1: Diagramma di Adaline (Adaptive Linear Neuron)

Nel contesto del ML, spesso si incontrano definizioni che indicano che “un neurone in una rete neurale artificiale è un’approssimazione matematica di un neurone biologico”, oppure che “un neurone è un’unità di calcolo fondamentale che imita il comportamento di un neurone biologico”. Volendo essere pignoli, questi parallelismi non sono appropriati, in quanto non conosciamo esattamente ne come funzioni il cervello, ne come funzionino i suoi neuroni. Quindi non abbiamo prove a sostegno di queste dichiarazioni.

In dettaglio, Adaline è definito dai seguenti componenti:

Le Feature di Input

Adaline riceve in input una o più variabili indipendenti, o feature, $x^{(i)}_1$, …, $x^{(i)}_n$.

I Pesi

A ogni variabili indipendente $x^{(i)}_j$ è associato un peso $w_j$, che, tramite la somma pesata (weighted sum), determina l’importanza di tale feature nel calcolo del valore di ritorno del neurone. I pesi sono parametri regolabili, che l’algoritmo di apprendimento modifica durante la fase di training. Un peso elevato (positivo o negativo) indica che la feature corrispondente aumenterà o diminuirà fortemente il valore o la probabilità prevista dal predittore, assumendo che le altre feature rimangano costanti. Di contro, un peso vicino allo zero indica che la feature associata avrà un’influenza minima o nulla sulla previsione.

Il Bias

Adaline usa un termine di bias, $b$, anch’esso regolato durante il processo di addestramento. Questo parametro permette al modello di adattarsi a dati che non sono centrati intorno all’origine dello spazio delle feature.

La Somma Pesata

La somma pesata delle feature di input, $\eqref{wsum}$, definisce la funzione di ipotesi2 del modello, $h\left(\textbf{x}^{(i)}\right)$, usata per approssimare la funzione target e fare predizione di valori continui:

$$ \begin{align*} z^{(i)} & = w_1x^{(i)}_1 + w_2x^{(i)}_2 + \dots + w_nx^{(i)}_n + b\label{wsum}\tag{1}\\[6pt] & = \sum_{j=1}^{n} w_jx^{(i)}_j + b\\[6pt] & = \textbf{w} \cdot \textbf{x}^{(i)} + b\label{wsum-vect}\tag{2}\\[6pt] & = h\left(\textbf{x}^{(i)}\right)\label{hypo}\tag{3}\\[6pt] \end{align*} $$

A livello geometrico, come abbiamo già visto, la funzione di ipotesi rappresenta l’equazione di un iperpiano in uno spazio di $n+1$ dimensioni, dove $n$ è il numero di variabili indipendenti dell’equazione stessa. Ad esempio, in uno spazio vettoriale tridimensionale $(x_1, x_2, h)$, possiamo rappresentare $\eqref{hypo}$ come il piano che cerca di interpolare o approssimare il più possibile la distribuzione del dataset di training, mostrato in Figura 2:

Figura 2: Funzione di ipotesi in uno spazio 3D

La Funzione di Attivazione

La somma pesata $z^{(i)}$ viene passata in input ad una funzione di attivazione $\sigma$ (sigma) per produrre il valore di uscita del neurone, che rappresenta il valore predittivo di regressione, associato al vettore feature $\textbf{x}^{(i)}$:

$$ \text{out}^{(i)} = \sigma\left(z^{(i)}\right)\label{activation_identity}\tag{4}\\[6pt] $$

In una moderna rete neurale multi-layer, la funzione di attivazione svolge un ruolo fondamentale: da un lato restituisce un valore reale e continuo, dall’altro introduce una non-linearità nel modello. Questa non-linearità è essenziale per consentire al predittore di apprendere relazioni e pattern complessi nei dati, rendendo possibile la classificazione (o regressione) anche su dataset non linearmente separabili.

In particolare, la funzione di attivazione sigmoide è spesso utilizzata perché, oltre a introdurre una trasformazione non lineare, permette di mappare un valore reale arbitrario appartenente all’intervallo $\left(-\infty, \infty\right)$ in un valore interpretabile come probabilità, compreso nel range $\left[0, 1\right]$. Questa proprietà la rende particolarmente adatta ai problemi di classificazione binaria.

Nel modello Adaline, per motivi storici e concettuali, la funzione di attivazione adottata non introduce alcuna non-linearità. Si tratta infatti di una semplice funzione identità, che restituisce in output il valore di ingresso $z^{(i)}$ senza applicare trasformazioni:

$$ \begin{align*} \text{out}^{(i)} & = \sigma\left(z^{(i)}\right) = z^{(i)} \label{identity}\tag{5}\\[6pt] \end{align*} $$

A differenza del Percettrone, che utilizza una Step function — una funzione discreta e non differenziabile — Adaline produce un output continuo. Questa scelta è cruciale perché rende il modello differenziabile, e quindi addestrabile tramite l’algoritmo della discesa del gradiente, utilizzando come funzione di costo l’errore quadratico medio, MSE. Grazie a questa caratteristica, Adaline non è limitato ai soli compiti di classificazione, ma può essere impiegato anche per problemi di regressione.

L’utilizzo di un logit continuo $z^{(i)}$ evita l’introduzione di soglie e discontinuità nel modello. Impiegato come input della funzione di costo MSE — una funzione convessa e ovunque derivabile — esso garantisce che la loss sia derivabile su tutto il suo dominio, senza ambiguità dovute a minimi locali. Di conseguenza, è possibile calcolare il gradiente della funzione di costo rispetto ai parametri del modello ($w_1, \cdots, w_n$ e $b$) per ogni osservazione $\mathbf{x}^{(i)}$, rendendo possibile l’addestramento del modello tramite la discesa del gradiente.

Una limitazione del modello è data dal fatto che Adaline non ritorna valori di probabilità, ma solo un punteggio “grezzo”.

La funzione di attivazione, una volta completata la fase di addestramento del modello, consente di effettuare previsioni di valori reali su dati che non sono stati osservati durante il training. Dal punto di vista geometrico, le previsioni di regressione associate a un qualsiasi vettore di feature $\mathbf{x}^{(i)}$ generano punti $\left(\mathbf{x}^{(i)}, \text{out}^{(i)}\right)$ che giacciono sull’iperpiano definito dalla funzione di ipotesi $\eqref{hypo}$. Ad esempio:

Figura 3: Funzione di attivazione in uno spazio vettoriale 3D

La Funzione di Soglia

La funzione di soglia, $\hat{y}$, serve a trasformare un valore di regressione in un valore di classificazione. Essa prende il valore continuo ritornato dalla funzione di attivazione $\eqref{identity}$, e ritorna un valore categorico, usato come previsione di classificazione:

$$ \hat{y}\left(z^{(i)}\right) = \label{threshold_1}\tag{6} \begin{cases} \text{ClassA} & \text{se $z^{(i)} \ge \theta$}\\[6pt] \text{ClassB} & \text{altrimenti}\\[6pt] \end{cases} $$

La funzione $\eqref{threshold_1}$ indica che, se il valore di predizione $z^{(i)}$ è maggiore o uguale al valore di soglia $\theta$ (theta), allora viene ritornata la classe $\text{ClassA}$. Altrimenti viene ritornata la classe $\text{ClassB}$. Se usiamo valori label “$0$” e “$1$”, possiamo settare $\theta = 0.5$, e definire la funzione di soglia come:

$$ \hat{y}\left(z^{(i)}\right) = \label{threshold_2}\tag{7} \begin{cases} 1 & \text{se $z^{(i)} \ge 0.5$}\\[6pt] 0 & \text{altrimenti}\\[6pt] \end{cases} $$

In relazione al grafico sottostante, Figura 4, la funzione di soglia indica che i punti di predizione verdi con coordinata $z^{(i)}$ maggiore o uguale a $0.5$ appartengono alla classe “$1$”, e quelli con $z^{(i)}$ minore di $0.5$ appartengono alla classe “$0$”. Il piano generato dalla funzione di ipotesi, e “deformato” dalla funzione di soglia, genera le due regioni decisionali risultanti, una con $\hat{y} = 0$, l’altra con $\hat{y} = 1$, ed il confine decisionale che le separa. Il confine decisionale è la linea o la superficie di demarcazione nello spazio vettoriale delle feature in cui il modello di classificazione cambia la sua previsione da una classe all’altra. La sua forma dipende dal tipo di classificatore e dalla natura dei dati; può essere lineare o non lineare.

Ritornando a Figura 4, per un dato vettore feature $\textbf{x}^{(i)} = \left({x}^{(i)}_1, x^{(i)}_2\right)$, i punti verdi trasparenti $\left(x^{(i)}_1, x^{(i)}_2, \text{out}^{(i)}\right)$ indicano i valori di regressione ritornate dalla funzione di attivazione, mentre i punti rossi e blu $\left(x^{(i)}_1, x^{(i)}_2, \hat{y}^{(i)}\right)$ indicano i relativi valori categorici, ritornati dalla funzione di soglia. In questo esempio, i punti appartenenti alla regione decisionale “rossa” vengono assegnati alla classe “$0$”, mentre quelli appartenenti alla regione decisionale “blu” vengono assegnati alla classe “$1$”.

Figura 4: Funzione di soglia in 3D

La Funzione di Costo

Adaline adotta la stessa funzione di costo della regressione lineare, MSE (Mean Squared Error), che viene usata dal modello per minimizzare la differenza quadratica media tra i valori reali e quelli previsti di un’osservazione. Per questo modello, invece di usare $\hat{y}^{(i)}$, definiamo l’$i$-mo valore previsto con il termine $\text{out}^{(i)}$, per indicare che esso è il valore di output della funzione di attivazione, $\sigma$:

$$ \begin{align*} \text{MSE}(\textbf{w}, b) & = \frac{1}{m} \sum_{i=1}^{m}\left(y^{(i)} - \text{out}^{(i)}\right)^2 \label{mse_loss}\tag{8}\\[6pt] & = \frac{1}{m} \sum_{i=1}^{m}\left[y^{(i)} - \left(\textbf{w} \cdot \textbf{x}^{(i)} + b\right)\right]^2\\[6pt] \end{align*} $$

Come classificatore, Adaline usa la funzione di costo $\text{MSE}$ (errore quadratico medio), che non è ideale per le attività di classificazione, per diversi motivi. Infatti, $\text{MSE}$ presuppone che le uscite siano continue, mentre la classificazione ritorna valori discreti. Inoltre, tale funzione di costo forza l’iperpiano definito dalla funzione di ipotesi del modello ad interpolare il più possibile le osservazioni del dataset, invece che a separare gli esempi appartenenti a classi diverse.

L’Algoritmo di Training

Anche in questo caso, Adaline adotta l’algoritmo di training della regressione lineare, ovvero la discesa del gradiente (GD). Dopo aver inizializzato $\textbf{w}$ e $b$, le iterazioni del GD calcolano i nuovi valori di tali parametri questo modo:

$$ \begin{align*} \require{physics} \textbf{g}_w & = \pdv{L}{\textbf{w}} = \frac{2}{m} \sum_{i=1}^{m} \textbf{x}^{(i)}\left(\text{out}^{(i)} - y^{(i)}\right)\label{g_w}\tag{9}\\[6pt] g_b & = \pdv{L}{b} = \frac{2}{m} \sum_{i=1}^{m} \left(\text{out}^{(i)} - y^{(i)}\right)\label{g_b}\tag{10}\\[6pt] \textbf{w} & = \textbf{w} - \eta \textbf{g}_w\label{update_w}\tag{11}\\[6pt] b & = b - \eta g_b\label{update_b}\tag{12}\\[6pt] \end{align*} $$

Implementazione del Modello

Vediamo ora com’è possibile implementare il modello Adaline in Python. Il codice seguente, rispetto agli esempi precedenti, utilizza il paradigma della programmazione orientata agli oggetti (OOP). Andremo a creare la classe Python AdalineGD che implementi il comportamento di Adaline, ed useremo una sua istanza per testarne la capacità di classificazione. Questo moderato aumento della complessità nella scrittura del codice porta alcuni vantaggi. In particolare:

  • OOP consente la creazione di codice modulare, in cui i diversi modelli ML possono essere incapsulati in classi, le quali possono essere riutilizzate in progetti diversi. Questo riduce la ridondanza del codice e i relativi tempi di sviluppo. L’incapsulamento del codice garantisce inoltre che lo stato interno di un oggetto sia “nascosto” al codice esterno alla classe, rendendo il codice più robusto e più facile da mantenere.

  • è possibile definire modelli con la stessa interfaccia, rendendo più facile cambiare un modello con un altro, tenendo costante il codice di training e valutazione delle metriche di performance. Questo fatto ci tornerà utile quando cominceremo a confrontare alcuni dei nostri modelli “giocattolo” con quelli messi a disposizione dalla libreria Scikit-Learn.

  • possiamo identificare i metodi privati dando loro un nome che inizi con doppio carattere underscore __, come ad esempio __gradient, oppure __loss. L’interfaccia pubblica utilizzabile dal codice client sarà quindi definita da tutti gli altri metodi, come ad esempio: fit, predict, predict_regression.

Il Costruttore della Classe

Il costruttore della classe, definito dal metodo __init__, definisce lo stato dell’istanza di classe, e consente di settare i seguenti parametri:

  • eta: il tasso di apprendimento, o “learning rate”, utilizzato nella fase di training, che controlla la grandezza dei “passi” compiuti dalla discesa del gradiente verso il minimo della funzione di costo. La scelta del giusto valore di “eta” è cruciale, in quanto esso controlla quanto i pesi del modello vengano aggiustati rispetto al gradiente della funzione di costo. Un valore di “eta” troppo basso può rallentare la convergenza3 del modello verso la soluzione ottimale, mentre un valore troppo alto può muovere il processo di training lontano dal minimo della funzione di costo.

  • epochs: il numero di iterazioni usate dalla funzione di training. Avendo scelto un valore corretto per eta, più è alto il valore di epochs, migliori saranno le metriche di prestazione del modello, a scapito di un tempo di training più lungo.

import numpy as np

class AdalineGD:
    """ADAptive LInear NEuron classifier"""

    def __init__(self, eta=0.01, epochs=50):
        """Initialize this object."""
        self.losses = None
        self.bias = None
        self.weights = None
        self.eta = eta
        self.epochs = epochs
        self.random_state = random_state

Il Metodo di Training

Il metodo fit4 implementa l’algoritmo di training del modello, usando la discesa del gradiente. Il codice è uguale a quello del metodo train usato dalla regressione lineare multipla. Il nome del metodo è stato cambiato da train a fit per avere un’interfaccia OOP simile a quella dei modelli di classificazione proposti da Scikit-Learn:

    def fit(self, X, y):
        """Fit training data. That is, compute the best values for weights and bias parameters
        that minimize the Loss Function associated with this model."""

        # clone the label data and reshape it as a 2D array
        y = np.copy(y).reshape(-1, 1)

        # initialize weights with a normal distribution, and bias with 0
        rgen = np.random.default_rng(seed=self.random_state)
        self.weights = rgen.normal(loc=0.0, scale=0.01, size=(X.shape[1], 1))
        self.bias = np.float64(0.)
        self.losses = []

        # for each training epoch...
        for i in range(self.epochs):
            # compute the gradient vectors:
            #
            #   ∂L/∂w = 2/m * sum(i=1 to m) { x^(i) * (out^(i) - y^(i)) }
            #         = 2/m * (X.T @ (out - y))
            #
            #   ∂L/∂b = 2/m * sum(i=1 to m) { out^(i) - y^(i) }
            #         = 2/m * (1^T)(out - y)
            #
            z = self.__net_input(X)                    # shape: (n_examples, 1)
            out = self.__activation(z)                 # shape: (n_examples, 1)
            error = out - y                            # shape: (n_examples, 1)
            grad_w = 2.0 * (X.T @ error) / X.shape[0]  # shape: (n_features, 1)
            grad_b = 2.0 * np.mean(error)              # float

            # calculate the steps in the direction of the w-axis and b-axis axis
            # used to reach the flat point on the loss curve for this iteration
            step_w = self.eta * grad_w                 # shape: (n_features, 1)
            step_b = self.eta * grad_b                 # float

            # by convention, the gradient at a certain point is an arrow that points
            # directly uphill from that point. Because we need to go into the opposite direction,
            # we have to subtract "step_w" from the current value of "w", and do the same for "b"
            self.weights -= step_w                     # shape: (n_features, 1)
            self.bias -= step_b                        # bias: float

            # compute the loss for the current iteration
            loss = np.average(error ** 2)
            self.losses.append(loss)

        return self

Confrontando questo codice con quello del modello della regressione lineare multipla, troviamo una differenza nel calcolo dei gradienti e del valore di costo. In Adaline, l’errore per la singola osservazione viene calcolato come la differenza tra l’uscita della funzione di attivazione $\sigma$, e la label $y^{(i)}$, ovvero:

# Adaline:
z = self.__net_input(X)     # weighted sum
out = self.__activation(z)  # activation function
error = out - y             # error

# Linear Regression:
y_hat = predict(X, w, b)    # weighted sum
error = y_hat - y           # error

In pratica, visto che la funzione di attivazione $\sigma$ è una funzione di identità, e quindi $\sigma\left(z^{(i)}\right) = z^{(i)}$, il calcolo dell’errore è equivalente. Il codice completo è indicato di seguito:

    def fit(self, X, y):
        # ...

        # for each training epoch...
        for i in range(self.epochs):
            # compute the gradient vectors:
            #
            #   ∂L/∂w = 2/m * sum(i=1 to m) { x^(i) * (out^(i) - y^(i)) }
            #         = 2/m * (X.T @ (out - y))
            #
            #   ∂L/∂b = 2/m * sum(i=1 to m) { out^(i) - y^(i) }
            #         = 2/m * (1^T)(out - y)
            #
            z = self.__net_input(X)                    # shape: (n_examples, 1)
            out = self.__activation(z)                 # shape: (n_examples, 1)
            error = out - y                            # shape: (n_examples, 1)
            grad_w = 2.0 * (X.T @ error) / X.shape[0]  # shape: (n_features, 1)
            grad_b = 2.0 * np.mean(error)              # float

            # ...

            # compute the loss for the current iteration
            loss = np.average(error ** 2)
            self.losses.append(loss)

        return self

I Metodi di Predizione

Adaline fonisce due metodi di predizione: predict_regression per predirre numeri reali continui, e predict per eseguire predizioni categoriche.

Il metodo predict_regression calcola la somma pesata $z^{(i)}$, che passa in ingresso alla funzione di attivazione $\sigma$, che restituisce il valore di uscita del neurone:

    def predict_regression(self, X):
        """Predict the regression value for the input data."""
        z = self.__net_input(X)
        out = self.__activation(z)
        return out

Il metodo predict calcola il valore di uscita del neurone tramite predict_regression, e poi passa tale valore alla funzione di soglia __threshold, che lo trasforma in un valore categorico: classe “$0$”, o classe “$1$”:

    def predict(self, X):
        """Predict the class label for the input data."""
        out = self.predict_regression(X)
        y_hat = self.__threshold(out)
        return y_hat

I restanti metodi privati sono elencati di seguito. Da notare che, in Adaline, il metodo __activation implementa una funzione d’identità, e quindi non fa nulla di utile. Teniamo comunque la definizione di tale metodo come “placeholder”, in quanto esso verrà reimplementato da altri modelli di neurone, che applicheranno al suo valore di ingresso una data funzione di trasformazione non lineare:

    def __net_input(self, X):
        """Calculate the weighted sum plus the bias."""
        return X @ self.weights + self.bias

    def __activation(self, X):
        """Apply an Identity Function to the input data and return it."""
        return X

    def __threshold(self, out):
        """Transform a regression value into a classification value,
        taking the continuous value returned by the activation function
        and converting it into a categorical value, used as a classification prediction."""
        return np.where(out >= 0.5, 1, 0)

  1. come le reti neurali multi-layer usate nel Deep Learning ↩︎

  2. la funzione di ipotesi determina la relazione tra le variabili di input, o feature, e la label di output associata, cercando di approssimare la funzione target sconosciuta il più possibile. ↩︎

  3. Quando diciamo che un modello ML “converge”, intendiamo che il processo di addestramento ha raggiunto un punto in cui l’ulteriore addestramento non cambia significativamente le metriche di prestazione del modello. ↩︎

  4. Nel contesto ML, il termine “fit” indica il processo di addestramento del modello, mediante un dataset di training. L’addestramento regola i parametri del modello per minimizzare una funzione di perdita specificata e migliorare le previsioni del modello. ↩︎

Caricamento
  • Rendering delle formule LaTeX...
  • Rendering dei grafici Plotly...