Neural Network, Introduzione e Forward Propagation

Parte (1/3)

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’è una Rete Neurale?

Una Rete Neurale è un modello computazionale progettato per risolvere problemi complessi di riconoscimento di pattern, presenti nel dataset di input. Essa è costituita da unità interconnesse, chiamate nodi o neuroni, dove ogni nodo agisce come un’unità computazionale, il cui comportamento è simile a quello di un modello di Regressione Logistica. I nodi sono organizzati in layer: un layer di input, uno o più hidden layer, e un layer di output. In sostanza, ogni nodo è una funzione che elabora i dati in ingresso eseguendo operazioni matematiche, in genere moltiplicando gli input per i pesi associati, aggiungendo bias, e applicando funzioni di attivazione. Questi calcoli permettono alle reti neurali di identificare le strutture sottostanti in grandi insiemi di dati, consentendo di svolgere compiti quali il riconoscimento delle immagini, l’elaborazione del linguaggio naturale, e la modellazione predittiva.

Per fare un confronto, la Regressione Logistica utilizza una funzione di attivazione non-lineare (il sigmoide), ma, per limiti architetturali, tale modello può produrre solo regioni decisionali lineari. Di contro, le Reti Neurali hanno in genere una capacità di modellazione molto più alta, che deriva dalla composizione di più funzioni di attivazione non-lineari, su più layer, dove ogni layer aggiuntivo applica una trasformazione non-lineare all’output del layer precedente. La composizione di queste trasformazioni consente alle Reti Neurali di creare confini decisionali non-lineari complessi, che consentono di classificare dati che non sono linearmente separabili nella loro forma originaria. Con un numero sufficiente di layer e di nodi, le Reti Neurali possono approssimare virtualmente qualsiasi funzione, illustrando in tal modo la loro capacità di gestire problemi sofisticati.

Forward Propagation e Backpropagation

Nelle Reti Neurali, il processo di apprendimento prevede due fasi fondamentali: Forward Propagation e Backpropagation (o “Forward Phase” e “Backward Phase”).

Nella fase di Forward Propagation, i dati in ingresso vengono immessi nella rete, e si spostano in avanti attraverso ogni layer, fino a raggiungere il layer di output. Alla fine, per una data osservazione, la rete ritorna le probabilità di appartenenza alle classi, calcolate sui nodi degli hidden layer, e su quelli dell’output layer. Questi valori possono essere utilizzati per fare previsioni finali, o per ottimizzare ulteriormente i parametri del modello durante l’addestramento della rete (richiamando la Backpropagation).

Nella fase di Backpropagation, la rete calcola la sensibilità dell’errore (loss) alle variazioni dei parametri del layer di output. Quindi, il processo “propaga l’errore all’indietro”, calcolando come la funzione di costo sia influenzata dai parametri di ciascuno dei layer precedenti. L’obiettivo è determinare i gradienti (le derivate parziali) della funzione di costo, rispetto a ogni parametro della rete. Questi valori verranno poi utilizzati per regolare i parametri del modello, e migliorare le sue previsioni.

Forward Propagation

La fase Forward Propagation è il processo con cui i dati si muovono attraverso la Rete Neurale, dal layer di input a quello di output, layer per layer, per calcolare le previsioni di output. Per capire come funziona questo processo, iniziamo a considerare il funzionamento interno di una singola unità del modello di Regressione Logistica. Essa è costituita da un input layer e da un output layer, che compongono una semplice struttura, illustrata da Figura 1:

Diagramma dei Layer di Input e Output del modello di Regressione Logistica
Figura 1: Input e Output Layer

Per una data osservazione in ingresso, $\mathbf{x}^{(i)}$, il layer di input definisce un nodo per ciascuna delle sue $n$ feature, $x^{(i)}_j$. A ogni nodo di tipo feature è associato un proprio peso, $w_j$. Nel layer di output abbiamo un solo nodo, che calcola il valore logit $z^{(i)}$, combinando linearmente le feature in ingresso, $x^{(i)}_j$, con i pesi associati, $w_j$, insieme a un termine di bias, $b$. Il bias ci permette di costruire modelli lineari la cui funzione di ipotesi non è fissata nell’origine dello spazio delle feature. Il valore reale risultante, $z^{(i)}$, viene passato come parametro alla funzione di attivazione sigmoide, $\sigma$, per produrre un valore di output che corrisponde alla previsione del modello, ovvero alla probabilità che l’osservazione corrente appartenga alla label di classe positiva, $p$:

$$ \begin{align} z^{(i)} & = w_1x^{(i)}_1 + w_2x^{(i)}_2 + \dots + w_nx^{(i)}_n + b \label{logit-1}\tag{1}\\[6pt] & = \textbf{w} \cdot \textbf{x}^{(i)} + b \label{logit-2}\tag{2}\\[12pt] p & = \sigma\left(z^{(i)}\right)\\[6pt] & = \frac{1}{1 + e^{(-z^{(i)})}} \label{sigmoid}\tag{3} = \frac{1}{1 + e^{(-\textbf{w} \cdot \textbf{x}^{(i)} + b)}}\\[6pt] \end{align} $$

Ecco un esempio che utilizza solo tre feature, $x_1$, $x_2$ e $x_3$:

Diagramma dei Layer di Input e Output, con tre feature
Figura 2: Input e Output Layer con tre feature

Quando passiamo la combinazione lineare degli input, più il bias $b$, come parametro della funzione sigmoide, $\sigma$, indichiamo questo processo come funzione di attivazione. La funzione sigmoide limita il valore del logit, $z^{(i)}$, nell’intervallo di probabilità $\left[0, 1\right]$.

Classificazione Multi-Classe

Il modello precedente è in grado di effettuare solo una classificazione binaria. Tuttavia, ricordiamo che possiamo eseguire una classificazione multiclasse costruendo un insieme di modelli di Regressione Logistica, dove ogni modello restituisce in uscita la probabilità che l’osservazione di input appartenga ad una specifica label di classe. Estendiamo la nostra Rete Neurale per rappresentare questo aspetto. Consideriamo la seguente immagine, con un layer di output con due nodi, $a_1$ e $a_2$, che rappresentano due funzioni di attivazione distinte (stiamo ancora effettuando una classificazione binaria):

Diagramma dei Layer di Input e Output, con due nodi di output
Figura 3: Input e Output Layer con due nodi di output

Generalizzando, un layer di output con $n_o$ nodi $a_1, a_2, \cdots, a_{n_o}$ permette di avere un classificatore multiclasse per $n_o$ classi distinte. Ognuno di questi nodi ha un proprio set di pesi, e un singolo bias. Indichiamo con $w_{t,s}$ il peso associato alla connessione che va dal nodo di destinazione $t$ nel layer corrente, al nodo sorgente $s$ nel layer precedente. Considerando un’osservazione $\mathbf{x}$, con $n$ feature di input (non usando la notazione “superscript”, per migliorare la leggibilità delle formule), abbiamo:

$$ \begin{align} z_1 & = w_{1,1}x_1 + w_{1,2}x_2 + \cdots + w_{1,n}x_n + b_1\\[6pt] z_2 & = w_{2,1}x_1 + w_{2,2}x_2 + \cdots + w_{2,n}x_n + b_2\\[6pt] \cdots\\[6pt] z_{n_o} & = w_{k,1}x_1 + w_{k,2}x_2 + \cdots + w_{k,n}x_n + b_k \label{logit-k}\tag{4}\\[6pt] a_1 & = \sigma\left(z_1\right)\\[6pt] a_2 & = \sigma\left(z_2\right)\\[6pt] \cdots\\[6pt] a_{n_o} & = \sigma\left(z_k\right) \label{sigma-k}\tag{5}\\[6pt] \end{align} $$

In relazione all’immagine precedente, la notazione $w_{2,3}$ indica il peso della connessione che va dal secondo nodo del layer di output, $a_2$, al terzo nodo del layer di input, $x_3$. Per questa semplice rete, abbiamo:

$$ \begin{align} z_1 & = w_{1,1}x_1 + w_{1,2}x_2 + w_{1,3}x_3 + b_1\\[6pt] z_2 & = w_{2,1}x_1 + w_{2,2}x_2 + w_{2,3}x_3 + b_2\\[6pt] a_1 & = \sigma\left(z_1\right)\\[6pt] a_2 & = \sigma\left(z_2\right)\\[6pt] \end{align} $$

dove $a_1$ è la probabilità che l’esempio in ingresso, $\mathbf{x}$, appartenga all’etichetta di classe “$0$”, e $a_2$ è la probabilità che $\mathbf{x}$ appartenga all’etichetta di classe “$1$”. Possiamo codificare queste operazioni utilizzando la seguente notazione matriciale:

$$ \begin{align} \mathbf{a} & = \sigma \left(\mathbf{W} \mathbf{x} + \mathbf{b}\right) \label{sigma-matrix-1}\tag{6}\\[6pt] & = \sigma\left\{ \begin{bmatrix} w_{1,1} & w_{1,2} & \dots & w_{1,n} \\[6pt] w_{2,1} & w_{2,2} & \dots & w_{2,n} \\[6pt] \vdots & \vdots & \vdots & \vdots \\[6pt] w_{n_o,1} & w_{n_o,2} & \dots & w_{n_o,n}\\[6pt] \end{bmatrix} \begin{bmatrix} x_1\\[6pt] x_2\\[6pt] \vdots\\[6pt] x_n\\[6pt] \end{bmatrix} + \begin{bmatrix} b_1\\[6pt]b_2\\[6pt]\vdots\\[6pt]b_{n_o}\\[6pt] \end{bmatrix} \right\} \label{sigma-matrix-2}\tag{7}\\[6pt] & = \sigma\left\{ \begin{bmatrix} w_{1,1}x_1 + w_{1,2}x_2 + \dots + w_{1,n}x_n + b_1\\[6pt] w_{2,1}x_1 + w_{2,2}x_2 + \dots + w_{2,n}x_n + b_2\\[6pt] \vdots \\[6pt] w_{n_o,1}x_1 + w_{n_o,2}x_2 + \dots + w_{n_o,n}x_n + b_{n_o}\\[6pt] \end{bmatrix} \right\} = \begin{bmatrix} a_1\\[6pt] a_2\\[6pt] \vdots\\ a_{n_o}\\[6pt] \end{bmatrix} \label{sigma-matrix-3}\tag{8}\\[6pt] \end{align} $$

dove $\mathbf{W}$ è una matrice di dimensione $n_o \times n$ che, per ogni $j$-ma riga, definisce il set di pesi associato al $j$-mo nodo dell’output layer. L’uso delle matrici per codificare i calcoli delle reti neurali fornisce un modo efficiente e scalabile per rappresentare ed eseguire operazioni matematiche complesse. Le operazioni matriciali possono sfruttare l’hardware di elaborazione dati in parallelo (come le GPU), e questo accelera notevolmente l’addestramento e l’inferenza del modello. Inoltre, le rappresentazioni matriciali sono facilmente scalabili per reti di grandi dimensioni: aggiungere altri nodi o layer significa semplicemente espandere le matrici, anziché creare numerose equazioni individuali.

L’equazione della matrice precedente, $\eqref{sigma-matrix-1}$, mostra il caso della classificazione multiclasse applicata su un singolo esempio, $\mathbf{x}$. Possiamo estendere la nostra matrice di input per classificare più osservazioni in un colpo solo. Reintroducendo la notazione “superscript”, abbiamo:

$$ \begin{align} \mathbf{A} & = \sigma \left(\mathbf{X} \mathbf{W}^{\intercal} + \mathbf{B}\right) \label{sigma-matrix-4}\tag{9}\\[6pt] & = \sigma\left\{ \begin{bmatrix} x^{(1)}_1 & x^{(1)}_2 & \dots & x^{(1)}_n\\[6pt] x^{(2)}_1 & x^{(2)}_2 & \dots & x^{(2)}_n\\[6pt] \vdots & \vdots & \vdots & \vdots \\[6pt] x^{(m)}_1 & x^{(m)}_2 & \dots & x^{(m)}_n\\[6pt] \end{bmatrix} \begin{bmatrix} w_{1,1} & w_{2,1} & \dots & w_{n_o,1}\\[6pt] w_{1,2} & w_{2,2} & \dots & w_{n_o,2}\\[6pt] \vdots & \vdots & \vdots & \vdots \\[6pt] w_{1,n} & w_{2,n} & \dots & w_{n_o,n}\\[6pt] \end{bmatrix} + \begin{bmatrix} b_1 & b_2 & \dots & b_{n_o}\\[6pt] b_1 & b_2 & \dots & b_{n_o}\\[6pt] \vdots & \vdots & \vdots & \vdots \\[6pt] b_1 & b_2 & \dots & b_{n_o}\\[6pt] \end{bmatrix} \right\} \label{sigma-matrix-5}\tag{10}\\[6pt] & = \sigma\left\{ \begin{bmatrix} \mathbf{w}_1\mathbf{x}^{(1)}+b_1 & \mathbf{w}_2\mathbf{x}^{(1)}+b_2 & \dots & \mathbf{w}_{n_o}\mathbf{x}^{(1)}+b_{n_o}\\[6pt] \mathbf{w}_1\mathbf{x}^{(2)}+b_1 & \mathbf{w}_2\mathbf{x}^{(2)}+b_2 & \dots & \mathbf{w}_{n_o}\mathbf{x}^{(2)}+b_{n_o}\\[6pt] \vdots & \vdots & \vdots & \vdots \\[6pt] \mathbf{w}_1\mathbf{x}^{(m)}+b_1 & \mathbf{w}_2\mathbf{x}^{(m)}+b_2 & \dots & \mathbf{w}_{n_o}\mathbf{x}^{(m)}+b_{n_o}\\[6pt] \end{bmatrix} \right\} \label{sigma-matrix-6}\tag{11}\\[6pt] & = \begin{bmatrix} a^{(1)}_1 & a^{(1)}_2 & \dots & a^{(1)}_{n_o}\\[6pt] a^{(2)}_1 & a^{(2)}_2 & \dots & a^{(2)}_{n_o}\\[6pt] \vdots & \vdots & \vdots & \vdots \\[6pt] a^{(m)}_1 & a^{(m)}_2 & \dots & a^{(m)}_{n_o}\\[6pt] \end{bmatrix} \label{sigma-matrix-7}\tag{12}\\[6pt] \end{align} $$

La matrice risultante, $\mathbf{A}$, ha $m$ righe e $n_o$ colonne. Ogni $i$-ma riga definisce i $n_o$ valori di probabilità che l’osservazione $\mathbf{x}^{(i)}$ appartenga a una di queste $n_o$ classi. Ad esempio, $a^{(1)}_1$ indica la probabilità che $\mathbf{x}^{(1)}$ appartenga alla classe “$0$”, $a^{(1)}_2$ indica la probabilità che $\mathbf{x}^{(1)}$ appartenga alla classe “$1$”, e così via.

Uso degli Hidden Layer

Finora ci siamo occupati esclusivamente di reti a due strati; inseriamo le informazioni nel layer di input, e osserviamo i risultati nel layer di output. La vera potenza delle Reti Neurali emerge quando si aggiungono altri layer alla rete. Ogni layer che si trova tra il layer di input e quello di output è noto come “hidden layer”. L’esempio seguente presenta una semplice Rete Neurale con uno layer di input, un hidden layer, e un layer output:

Diagramma dei Layer Input, Hidden e Output
Figura 4: Input, Hidden e Output Layer

Per semplicità, il diagramma precedente contiene due nodi per ogni layer, ma layer diversi possono contenere un numero diverso di nodi. Indicando con $n$ il numero di nodi feature dell’input layer, possiamo calcolare l’output del $j$-esimo nodo di attivazione nell’hidden layer come:

$$ \begin{align} z^h_j & = w^h_{j,1}x_1 + w^h_{j,2}x_2 + \cdots + w^h_{j,n}x_n + b^h_j \label{z^h_j-1}\tag{13}\\[6pt] & = \mathbf{w}^{h\intercal}_j\mathbf{x} + b^h_j \label{z^h_j-2}\tag{14}\\[6pt] a^h_j & = \sigma\left(z^h_j\right) \label{a^h_j}\tag{15}\\[6pt] \end{align} $$

Ad esempio, considerando il diagramma precedente, per calcolare l’output del primo nodo di attivazione dell’hidden layer, $a^h_1$, abbiamo:

$$ \begin{align} z^h_1 & = w^h_{1,1}x_1 + w^h_{1,2}x_2 + b^h_1\\[6pt] & = \mathbf{w}^{h\intercal}_1\mathbf{x} + b^h_1\\[6pt] a^h_1 & = \sigma\left(z^h_1\right)\\[6pt] \end{align} $$

Indicando con $n_h$ il numero di nodi dell’hidden layer, possiamo calcolare l’output del $j$-esimo nodo di attivazione dell’output layer come:

$$ \begin{align} z^o_j & = w^o_{j,1}a^h_1 + w^o_{j,2}a^h_2 + \cdots + w^o_{j,n_h}a^h_{n_h} + b^o_j \label{z^o_j-1}\tag{16}\\[6pt] & = \mathbf{w}^{o\intercal}_j\mathbf{a}^h + b^o_j \label{z^o_j-2}\tag{17}\\[6pt] a^o_j & = \sigma\left(z^o_j\right) \label{a^o_j}\tag{18}\\[6pt] \end{align} $$

Ad esempio, per calcolare l’output del secondo nodo di attivazione dell’output layer, $a^o_2$, abbiamo:

$$ \begin{align} z^o_2 & = w^o_{2,1}a^h_1 + w^o_{2,2}a^h_2 + b^o_2\\[6pt] & = \mathbf{w}^{o\intercal}_2\mathbf{a}^h + b^o_2\\[6pt] a^o_2 & = \sigma\left(z^o_2\right)\\[6pt] \end{align} $$

Più in generale, possiamo calcolare l’output del $j$-esimo nodo di attivazione, nel layer $l$, come:

$$ \begin{align} z^l_j & = w^l_{j,1}a^{(l-1)}_1 + w^l_{j,2}a^{(l-1)}_2 + \cdots + w^l_{j,n_l}a^{(l-1)}_{n_{l-1}} + b^l_j \label{z^l_j-1}\tag{19}\\[6pt] & = \mathbf{w}^{l\intercal}_j\mathbf{a}^{(l-1)} + b^l_j \label{z^l_j-2}\tag{20}\\[6pt] a^l_j & = \sigma\left(z^l_j\right) \label{a^l_j}\tag{21}\\[6pt] \end{align} $$

dove:

  • $w^l_{t,s}$ è il peso associato alla connessione che va dal nodo di destinazione $t$, nel layer corrente $l$, al nodo sorgente $s$ nel layer precedente $(l-1)$.
  • $b^l_t$ è il bias associato al nodo $t$ nel layer corrente, $l$.
  • $a^l_q$ è il $q$-esimo nodo di attivazione, nel layer corrente, $l$.
  • il pedice $n_l$ indica il numero di nodi del layer $l$.

Per accelerare i calcoli, possiamo usare le operazioni matriciali, considerando che:

Analisi Dimensionale
  • la matrice dei pesi, $\mathbf{W}^l$, ha un numero di righe uguale al numero di nodi del layer corrente, $n_l$, e un numero di colonne uguale al numero di nodi del layer precedente, $n_{l-1}$.
  • il vettore di attivazione, $\mathbf{a}^l$, e quello di bias, $\mathbf{b}^l$, hanno un numero di elementi uguale al numero di nodi del layer corrente, $n_l$.
  • il vettore di attivazione $\mathbf{a}^{l-1}$ ha un numero di elementi uguale al numero di nodi del layer precedente, $n_{l-1}$.

Avendo un’unica osservazioni di input, $\mathbf{x}$, possiamo calcolare $\mathbf{a}^{l}$ come:

$$ \begin{align} \mathbf{a}^l & = \sigma \left(\mathbf{W}^{l} \mathbf{a}^{l-1} + \mathbf{b}^l\right) \label{a^l}\tag{22}\\[6pt] & = \sigma\left\{ \begin{bmatrix} w^l_{1,1} & w^l_{1,2} & \dots & w^l_{1,n_{l-1}} \\[6pt] w^l_{2,1} & w^l_{2,2} & \dots & w^l_{2,n_{l-1}} \\[6pt] \vdots & \vdots & \vdots & \vdots \\[6pt] w^l_{{n_l},1} & w^l_{{n_l},2} & \dots & w^l_{{n_l},n_{l-1}}\\[6pt] \end{bmatrix} \begin{bmatrix} a^{l-1}_1 \\[6pt] a^{l-1}_2 \\[6pt] \vdots \\[6pt] a^{l-1}_{n_{l-1}}\\[6pt] \end{bmatrix} + \begin{bmatrix} b^l_1 \\[6pt] b^l_2 \\[6pt] \vdots \\[6pt] b^l_{n_l}\\[6pt] \end{bmatrix} \right\}\\[6pt] & = \sigma\left\{ \begin{bmatrix} w^l_{1,1}a^{l-1}_1 + w^l_{1,2}a^{l-1}_2 + \dots + w^l_{1,n_{l-1}}a^{l-1}_{n_{l-1}} + b^l_1 \\[6pt] w^l_{2,1}a^{l-1}_1 + w^l_{2,2}a^{l-1}_2 + \dots + w^l_{2,n_{l-1}}a^{l-1}_{n_{l-1}} + b^l_2 \\[6pt] \vdots \\[6pt] w^l_{{n_l},1}a^{l-1}_1 + w^l_{{n_l},2}a^{l-1}_2 + \dots + w^l_{{n_l},n_{l-1}}a^{l-1}_{n_{l-1}} + b^l_{n_l}\\[6pt] \end{bmatrix} \right\} = \begin{bmatrix} a^l_1 \\[6pt] a^l_2 \\[6pt] \vdots \\ a^l_{n_l}\\[6pt] \end{bmatrix} \end{align} $$

Avendo $m$ osservazioni di input, $\mathbf{x}^{(i)}$, possiamo calcolare la matrice $\mathbf{A}^l$, di dimensioni $m \times n_l$, dove ogni $i$-ma riga definisce gli $n_l$ nodi di attivazione $a^{l(i)}_j$, calcolati per l’osservazione corrente. In questo caso, consideriamo che:

Analisi Dimensionale
  • la matrice di bias, $\mathbf{B}^l$, e quella di attivazione, $\mathbf{A}^l$, hanno entrambe un numero di righe uguale al numero di esempi $\mathbf{x}^{(i)}$ passati alla rete, $m$, e un numero di colonne uguale al numero di nodi del layer corrente, $n_l$.
  • la matrice di attivazione $\mathbf{A}^{l-1}$ ha un numero di righe uguale al numero di esempi passati alla rete, $m$, e un numero di colonne uguale al numero di nodi del layer precedente, $n_{l-1}$.
$$ \begin{align} \mathbf{A}^l & = \sigma \left(\mathbf{A}^{l-1} \mathbf{W}^{l\intercal} + \mathbf{B}^l\right) \label{A^l}\tag{23}\\[6pt] & = \sigma\left\{ \begin{bmatrix} a^{l-1,(1)}_1 & a^{l-1,(1)}_2 & \dots & a^{l-1,(1)}_{n_{l-1}}\\[6pt] a^{l-1,(2)}_1 & a^{l-1,(2)}_2 & \dots & a^{l-1,(2)}_{n_{l-1}}\\[6pt] \vdots & \vdots & \vdots & \vdots \\[6pt] a^{l-1,(m)}_1 & a^{l-1,(m)}_2 & \dots & a^{l-1,(m)}_{n_{l-1}}\\[6pt] \end{bmatrix} \begin{bmatrix} w^l_{1,1} & w^l_{2,1} & \dots & w^l_{n_l,1} \\[6pt] w^l_{1,2} & w^l_{2,2} & \dots & w^l_{n_l,2} \\[6pt] \vdots & \vdots & \vdots & \vdots \\[6pt] w^l_{1,n_{l-1}} & w^l_{2,n_{l-1}} & \dots & w^l_{n_l,n_{l-1}}\\[6pt] \end{bmatrix} + \begin{bmatrix} b^l_1 & b^l_2 & \dots & b^l_{n_l}\\[6pt] b^l_1 & b^l_2 & \dots & b^l_{n_l}\\[6pt] \vdots & \vdots & \vdots & \vdots \\[6pt] b^l_1 & b^l_2 & \dots & b^l_{n_l}\\[6pt] \end{bmatrix} \right\} \\[6pt] & = \sigma\left\{ \begin{bmatrix} \mathbf{w}^l_1\mathbf{a}^{l-1,(1)}+b_1 & \mathbf{w}^l_2\mathbf{a}^{l-1,(1)}+b_2 & \dots & \mathbf{w}^l_{n_l}\mathbf{a}^{l-1,(1)}+b^l_{n_l}\\[6pt] \mathbf{w}^l_1\mathbf{a}^{l-1,(2)}+b_1 & \mathbf{w}^l_2\mathbf{a}^{l-1,(2)}+b_2 & \dots & \mathbf{w}^l_{n_l}\mathbf{a}^{l-1,(2)}+b^l_{n_l}\\[6pt] \vdots & \vdots & \vdots & \vdots \\[6pt] \mathbf{w}^l_1\mathbf{a}^{l-1,(m)}+b_1 & \mathbf{w}^l_2\mathbf{a}^{l-1,(m)}+b_2 & \dots & \mathbf{w}^l_{n_l}\mathbf{a}^{l-1,(m)}+b^l_{n_l}\\[6pt] \end{bmatrix} \right\} \\[6pt] & = \sigma\left\{ \begin{bmatrix} z^{l(1)}_1 & z^{l(1)}_2 & \dots & z^{l(1)}_{n_l}\\[6pt] z^{l(2)}_1 & z^{l(2)}_2 & \dots & z^{l(2)}_{n_l}\\[6pt] \vdots & \vdots & \vdots & \vdots \\[6pt] z^{l(m)}_1 & z^{l(m)}_2 & \dots & z^{l(m)}_{n_l}\\[6pt] \end{bmatrix} \right\} = \begin{bmatrix} a^{l(1)}_1 & a^{l(1)}_2 & \dots & a^{l(1)}_{n_l}\\[6pt] a^{l(2)}_1 & a^{l(2)}_2 & \dots & a^{l(2)}_{n_l}\\[6pt] \vdots & \vdots & \vdots & \vdots \\[6pt] a^{l(m)}_1 & a^{l(m)}_2 & \dots & a^{l(m)}_{n_l}\\[6pt] \end{bmatrix} \end{align} $$

dove $a^{l(i)}_j$ indica l’output del $j$-mo nodo di attivazione del layer $l$, per l’$i$-ma osservazione di input.

Implementazione di Forward Propagation

La classe MLPClassifier

Dopo aver definito le equazioni vettoriali che ci permettono di calcolare l’output dei nodi di attivazione in un dato layer, implementiamo un semplice modello di Rete Neurale con un singolo hidden layer. Visto che per ora non sappiamo come eseguire il training del modello, usiamo valori dei parametri già ottimizzati, che carichiamo da alcuni files.

Nel costruttore della classe, utilizziamo i parametri num_features, num_hidden, num_classes per inizializzare la matrice dei pesi, e il vettore bias per l’hidden layer e per l’output layer. Per un dato layer $l$, la matrice $\mathbf{W}^l$ ha un numero di righe uguale al numero di nodi del layer corrente, e un numero di colonne uguale al numero di nodi del layer precedente. Inoltre, il vettore $\mathbf{b}^l$ ha un numero di elementi uguale al numero di nodi del layer corrente. Per l’hidden layer:

  • la matrice dei pesi, self.weight_h, ha dimensione (num_hidden, num_features)
  • il vettore bias, self.bias_h ha dimensione (num_hidden, )

Per l’output layer:

  • self.weight_out ha dimensione (num_classes, num_hidden).
  • self.bias_out ha dimensione (num_classes, ).

Quando inizializziamo una matrice dei pesi, dobbiamo evitare di usare un valore costante, come $0$, perché questo può interrompere il processo di apprendimento della rete. Approfondiremo questo problema dopo aver appreso il funzionamento del processo di Backpropagation. Per ora ci basti sapere che, se tutti i nodi di un layer vengono configurati con gli stessi pesi, per gli stessi valori di input, essi calcoleranno lo stesso valore di output, e durante l’addestramento, si aggiorneranno allo stesso modo. Di conseguenza, rimarranno sempre identici, senza riuscire ad apprendere le diverse feature del dataset. Una strategia per risolvere questo problema è quella di usare il metodo Generator.normal per inizializzare entrambe le matrici con una distribuzione normale, con media $0$, e deviazione standard di $0.1$:

import numpy as np

class MLPClassifier:
    """A Multi Layer Perceptron (MLP) with a single Hidden Layer."""
    def __init__(self, num_features, num_hidden, num_classes, random_state=123):
        """
        Initialize the weight matrices and bias vectors for the hidden and output layers.

        Parameters
        ----------
        num_features: int
            the number of dataset features.
        num_hidden : int
            the number of nodes for the hidden layer.
        num_classes : int
            the number of nodes for the output layer.
        """
        self.num_classes = num_classes
        self.rng = np.random.default_rng(random_state)

        # initialize hidden layer
        self.weight_h = self.rng.normal(loc=0.0, scale=0.1, size=(num_hidden, num_features))
        self.bias_h = np.zeros(num_hidden).reshape(1, -1)

        # initialize output layer
        self.weight_o = self.rng.normal(loc=0.0, scale=0.1, size=(num_classes, num_hidden))
        self.bias_o = np.zeros(num_classes).reshape(1, -1)

Il Metodo Fit

Per ora, non sapendo come eseguire l’addestramento del modello, limitiamoci a caricare i valori ottimizzati di tutti i parametri da alcuni file:

    def fit(self, X, y):
        """
        Fit the data, computing the optimal values for the model parameters.
        For now, just read the fitted parameters from some files.
        """
        self.__load_params()

    def __load_params(self, data_dir="data"):
        self.weight_h = np.load(data_dir + "/weight_h.npy")
        self.weight_o = np.load(data_dir + "/weight_out.npy")
        self.bias_h   = np.load(data_dir + "/bias_h.npy")
        self.bias_o   = np.load(data_dir + "/bias_out.npy")

Il Metodo Predict

Il metodo predict chiama __forward per calcolare i valori di output della rete, che sono one-hot encoded, e li converte in formato “umanamente leggibile”:

    def predict(self, X: np.ndarray) -> np.ndarray:
        """
        Classify the input examples.

        Parameters
        ----------
        X : ndarray with shape (n_examples, n_features)
            the examples to classify.

        Returns
        -------
        ndarray with shape (n_examples, )
            an array containing the target class label for each example.
        """
        # convert the one-hot encoded labels to human-readable values
        _, out = self.__forward(X)       # shape: (n_examples, n_classes)
        y_hat = np.argmax(out, axis=1)   # shape: (n_examples, )
        return y_hat

Il Metodo di Forward Propagation

Il metodo __forward definisce la fase di Forward Propagation, implementando l’equazione $\eqref{A^l}$ per calcolare la matrice di attivazione per l’hidden layer, A_h, e quella per l’output layer, A_out. Ricordiamoci che, per ogni osservazione $\mathbf{x}^{(i)}$ passata alla rete, la matrice di attivazione associata ad un dato layer, contiene la probabilità di classe restituita da ogni nodo di tale layer. Per l’hidden layer, la matrice A_h ha dimensione (n_examples, n_hidden), mentre per l’output layer, la matrice A_out ha dimensione (n_examples, n_out), dove n_hidden e n_out sono, rispettivamente, il numero di nodi dell’hidden e dell’output layer:

    def __forward(self, X):
        """
        Take in one or more training examples, and for each one of them,
        get the class-membership probabilities returned from the hidden and output layers.

        Parameters
        ----------
        X : ndarray with shape (n_examples, n_features)
            the examples to classify.

        Returns
        -------
        A_h : ndarray with shape (n_examples, n_hidden)
            hidden layer's activation matrix.
            For each example, it contains the class probability returned by every node of the layer.
        A_o : ndarray with shape (n_examples, n_classes)
            output layer's activation matrix.
            For each example, it contains the class probability returned by every node of the layer.
        """
        # Hidden Layer:
        #   Z^(h) = X @ W^(h)^t + b^(h)
        #   A^(h) = σ(Z^(h))
        Z_h = (X @ self.weight_h.T) + self.bias_h    # shape: (n_examples, n_hidden)
        A_h = self.__sigmoid(Z_h)                    # shape: (n_examples, n_hidden)

        # Output Layer:
        #   Z^(o) = A^(h) @ W^(o)^t + b^(o)
        #   A^(o) = σ(Z^(o))
        Z_o = (A_h @ self.weight_o.T) + self.bias_o  # shape: (n_examples, n_classes)
        A_o = self.__sigmoid(Z_o)                    # shape: (n_examples, n_classes)

        return A_h, A_o

    def __sigmoid(self, z):
        return 1. / (1. + np.exp(-z))

Esempi di Classificazione

Proviamo a confrontare i risultati di classificazione ottenuti da un Regressore Logistico, con la nostra implementazione MLPClassifier, utilizzando un hidden layer con $6$ nodi:

from sklearn.datasets import make_moons
from sklearn.linear_model import LogisticRegression

# config
num_hidden = 6

# get data from a non-linear dataset
X, y = make_moons(n_samples=100, noise=0.05, random_state=42)

# instance and train the models
log_reg = LogisticRegression(random_state=42)
log_reg.fit(X, y)
mlpc = MLPClassifier(num_features=2, num_hidden=num_hidden, num_classes=2)
mlpc.fit(X, y)

# plot the decision regions and the models' losses
classifiers = [log_reg, mlpc]
classifiers_name = ["Logistic Regression", "Multi-Layer Perceptron"]
plot_decision_regions_2D(X, y, classifiers, classifiers_name)
Confronto tra Regione Decisionale di una Rete Neurale e di un modello di Regressione Logistica
Figura 5: Rete Neurale e Regressione Logistica a Confronto

Dal grafico precedente possiamo vedere come MLPClassifier, con un singolo hidden layer, sia in grado di adattarsi molto bene a un dataset non-lineare . Vediamo ora cosa accade se ripetiamo lo stesso processo, con sei diverse istanze, ognuna delle quali utilizza un hidden layer con un numero crescente di nodi:

Confronto tra Regione Decisionale di una Rete Neurale, in funzione del numero di nodi dell'Hidden Layer
Figura 6: Regioni Decisionali in funzione del numero di nodi dell’Hidden Layer

Possiamo notare come, con un hidden layer con un singolo neurone, otteniamo lo stesso risultato precedentemente avuto con un Regressore Logistico. Con $3$ nodi cominciamo a vedere come il confine della regione decisionale del classificatore cominci a seguire il perimetro delle due “mezze lune” formate dalle osservazioni del dataset. Con $5$ nodi, il classificatore si adatta alla distribuzione dei dati, e l’errore di classificazione scende a $0$.

La Rete Neurale come Approssimatore Universale di Funzioni

L’Hidden Layer

Consideriamo una rete con un hidden layer con un singolo nodo. Il suo output è calcolato come:

Diagramma di un Percettrone con un Hidden Layer con un singolo nodo
Figura 7: Hidden Layer con 1 Nodo
$$ \begin{align} a^h_1 & = \sigma\left(w^h_{1,1}x_1 + w^h_{1,2}x_2 + b^h_1\right) \label{a^h_1}\tag{24}\\[6pt] a^o_1 & = \sigma\left(w^o_{1,1}a^h_1 + b^o_1\right) \label{hlo-1}\tag{25}\\[6pt] & = \sigma\biggl[w^o_{1,1}\cdot{\sigma\left(w^h_{1,1}x_1 + w^h_{1,2}x_2 + b^h_1\right)} + b^o_1\biggr]\\[6pt] \end{align} $$

La stessa rete, con un hidden layer con due nodi, ritorna:

Diagramma di un Percettrone con un Hidden Layer con due nodi
Figura 8: Hidden Layer con 2 Nodi
$$ \begin{align} a^h_1 & = \sigma\left(w^h_{1,1}x_1 + w^h_{1,2}x_2 + b^h_1\right)\\[6pt] a^h_2 & = \sigma\left(w^h_{2,1}x_1 + w^h_{2,2}x_2 + b^h_2\right) \label{a^h_2}\tag{26}\\[6pt] a^o_1 & = \sigma\left(w^o_{1,1}a^h_1 + w^o_{1,2}a^h_2 + b^o_1\right) \label{hlo-2}\tag{27}\\[6pt] & = \sigma\biggl[w^o_{1,1}\cdot{\sigma\left(w^h_{1,1}x_1 + w^h_{1,2}x_2 + b^h_1\right)}\\ & \hspace{12pt} + w^o_{1,2}\cdot{\sigma\left(w^h_{2,1}x_1 + w^h_{2,2}x_2 + b^h_2\right)} + b^o_1\biggr]\\ \end{align} $$

Se invece abbiamo un hidden layer con tre nodi:

Diagramma di un Percettrone con un Hidden Layer con tre nodi
Figura 9: Hidden Layer con 3 Nodi
$$ \begin{align} a^h_1 & = \sigma\left(w^h_{1,1}x_1 + w^h_{1,2}x_2 + b^h_1\right)\\[6pt] a^h_2 & = \sigma\left(w^h_{2,1}x_1 + w^h_{2,2}x_2 + b^h_2\right)\\[6pt] a^h_3 & = \sigma\left(w^h_{3,1}x_1 + w^h_{3,2}x_2 + b^h_3\right) \label{a^h_3}\tag{28}\\[6pt] a^o_1 & = \sigma\left(w^o_{1,1}a^h_1 + w^o_{1,2}a^h_2 + w^o_{1,3}a^h_3 + b^o_1\right) \label{hlo-3}\tag{29}\\[6pt] & = \sigma\biggl[w^o_{1,1}\cdot{\sigma\left(w^h_{1,1}x_1 + w^h_{1,2}x_2 + b^h_1\right)}\\ & \hspace{12pt} + w^o_{1,2}\cdot{\sigma\left(w^h_{2,1}x_1 + w^h_{2,2}x_2 + b^h_2\right)}\\ & \hspace{12pt} + w^o_{1,3}\cdot{\sigma\left(w^h_{3,1}x_1 + w^h_{3,2}x_2 + b^h_3\right)} + b^o_1\biggr]\\ \end{align} $$

Possiamo generalizzare le equazioni $\eqref{hlo-1}$, $\eqref{hlo-2}$ e $\eqref{hlo-3}$ come:

$$ \begin{align} a^o_1 & = \sigma\left[\sum_{k=1}^{n_h}\left(w^o_{1,k}a^h_k\right) + b^o_1 \right] \label{hlo-gen}\tag{30}\\ \end{align} $$

dove $n_h$ è il numero di nodi dell’hidden layer.

Le Funzioni Base

Riguardando il grafico di Figura 6, notiamo come al crescere dei nodi $a^h_j$, la rete neurale aumenti la propria capacità di rappresentare funzioni complesse. Ognuno dei nodi $a^h_j$ calcola come suo valore di output, una trasformazione non-lineare dei suoi input, con i propri pesi e bias. Queste trasformazioni possono essere viste come funzioni base $\phi_j$ (phi), ovvero elementi che possono essere linearmente combinati assieme per approssimare o definire altre funzioni più complesse.

Questa idea deriva dall’algebra lineare, dove i vettori base sono un insieme di vettori linearmente indipendenti che attraversano lo spazio vettoriale, e qualsiasi punto in tale spazio può sempre essere rappresentato come una combinazione lineare dei suoi vettori base, con pesi scalari. In uno spazio bidimensionale, possiamo definire i vettori base $\mathbf{e}_1 = (1, 0)$, $\mathbf{e}_2 = (0, 1)$, ed ogni vettore $\mathbf{v} = (x, y)$ può essere rappresentato come $\mathbf{v} = x\mathbf{e}_1 + y\mathbf{e}_2$. Ad esempio, possiamo visualizzare $\mathbf{v} = 2\mathbf{e}_1 + 1.5\mathbf{e}_2$ come:

Grafico della combinazione lineare di due vettori base di uno spazio 2D
Figura 10: Combinazione Lineare dei vettori base di uno spazio 2D

Negli spazi di funzioni, facciamo la stessa cosa, ma invece di usare vettori come $(1, 0)$ o $(0, 1)$, usiamo delle funzioni base. Ad esempio, possiamo usare delle funzioni base polinomiali come:

$$ \begin{align} \phi_1(x) & = x\\[6pt] \phi_2(x) & = x^2\\[6pt] \phi_3(x) & = x^3\\[6pt] \end{align} $$

Rimanendo in uno spazio 2D, possiamo definire una curva tramite la combinazione lineare delle funzioni base $\phi_1$ e $\phi_2$, come $f(x) = 3\phi_1(x) - 2\phi_2(x)$:

Grafico della combinazione lineare di funzioni base di uno spazio 2D
Figura 11: Combinazione Lineare di funzioni base di uno spazio 2D

Dal grafico precedente, possiamo notare come queste semplici curve possano essere combinate — ciascuna scalata dal proprio peso — per formare una forma più complessa. È esattamente lo stesso concetto che le reti neurali utilizzano quando combinano funzioni base non-lineari, come le attivazioni sigmoidi, per costruire modelli espressivi. Ritornando alla nostra rete neurale, possiamo usare $\eqref{a^h_1}$, $\eqref{a^h_2}$, e $\eqref{a^h_3}$ per definire le seguenti funzioni base:

$$ \begin{align} \phi_1(\mathbf{x}) & = a^h_1(\mathbf{x}) = \sigma\left(w^h_{1,1}x_1 + w^h_{1,2}x_2 + b^h_1\right)\\[6pt] \phi_2(\mathbf{x}) & = a^h_2(\mathbf{x}) = \sigma\left(w^h_{2,1}x_1 + w^h_{2,2}x_2 + b^h_2\right)\\[6pt] \phi_3(\mathbf{x}) & = a^h_3(\mathbf{x}) = \sigma\left(w^h_{3,1}x_1 + w^h_{3,2}x_2 + b^h_3\right)\\[6pt] \end{align} $$

e riscrivere $\eqref{hlo-3}$ come una combinazione lineare delle stesse, dove ognuna è scalata dal suo peso $w^o_{1,j}$:

$$ \begin{align} a^o_1 & = \sigma\left(w^o_{1,1}\phi_1 + w^o_{1,2}\phi_2 + w^o_{1,3}\phi_3 + b^o_1 \right) \label{a^o_1-base}\tag{31}\\[6pt] \end{align} $$

Generalizzando:

$$ \begin{align} a^o_1 & = \sigma\left[\sum_{k=1}^{n_h}\left(w^o_{1,k}\phi_k\right) + b^o_1 \right] \label{a^o_1-gen-base}\tag{32}\\[6pt] & = \sigma\left(w^o_{1,1}\phi_1 + w^o_{1,2}\phi_2 + \cdots + w^o_{1,n_h}\phi_{n_h} + b^o_1 \right)\\[6pt] \end{align} $$

L’Output Layer

In una rete neurale con un singolo hidden layer, l’output di ciascun nodo nascosto $a^h_j$ può essere interpretato come una funzione base non-lineare delle feature di input. L’output layer apprende quindi a combinare queste funzioni base per approssimare la funzione target, a noi sconosciuta.

Un aspetto fondamentale è che la rete non si limita a imparare i coefficienti di combinazione, ma apprende sia la forma delle funzioni base (tramite i pesi e i bias dell’hidden layer), sia il modo in cui esse vengono combinate (tramite i parametri dell’output layer), il tutto direttamente dai dati di addestramento.

Dopo la trasformazione non-lineare operata dall’hidden layer, le osservazioni vengono rappresentate in un nuovo spazio delle feature latenti1, $\pmb{\phi} = (\phi_1, \dots, \phi_{n_h})$. In questo spazio, il $j$-esimo nodo dell’output layer calcola la combinazione lineare:

$$ \begin{align} z^o_j & = \sum_{k=1}^{n_h}\left(w^o_{1,k}\,\phi_k\right) + b^o_1 \label{z^o_1-gen-base}\tag{33}\\[6pt] \end{align} $$

L’equazione $z^o_j = 0$ definisce un iperpiano nello spazio delle feature latenti, che separa le osservazioni in due regioni: quelle per cui $z^o_j > 0$ e quelle per cui $z^o_j < 0$. L’applicazione della funzione di attivazione sigmoide, $\sigma(z^o_j)$, introduce una non-linearità finale, comprimendo l’output nell’intervallo $[0, 1]$. Il valore ottenuto può essere interpretato come la probabilità che l’osservazione di input appartenga alla classe “$j$”.

Di conseguenza, sebbene la separazione sia lineare nello spazio delle feature latenti, il confine decisionale risultante nello spazio di input originale è non-lineare.

Riprendiamo l’esempio già visto nel contesto delle feature polinomiali, in cui consideriamo un dataset monodimensionale (1D) che non è linearmente separabile:

Scatterplot di un dataset non linearmente separabile
Figura 12: Dataset non linearmente separabile

In questo caso, la trasformazione operata dalle funzioni base genera un nuovo spazio delle feature bidimensionale (2D), nel quale le osservazioni diventano linearmente separabili:

Scatterplot di un dataset linearmente separabile nel nuovo spazio delle feature
Figura 13: Nuovo spazio delle feature

Aggiungendo neuroni all’hidden layer, la rete costruisce un nuovo spazio delle feature latenti, di dimensionalità superiore, e ottenuto tramite trasformazioni non-lineari, che “piega e deforma” lo spazio delle feature originale. In questo spazio trasformato, osservazioni che non erano separabili linearmente possono diventarlo.

La non-linearità è introdotta dall’applicazione della funzione di attivazione sigmoide alla combinazione lineare degli input scalati, $\eqref{z^h_j-1}$. In assenza di funzioni di attivazione non-lineari, la rete collasserebbe in un modello lineare a singolo layer, incapace di apprendere pattern complessi.

Ne consegue che una rete neurale con un singolo hidden layer è, in teoria, un approssimatore universale di funzioni: dato un numero sufficiente di neuroni, essa può approssimare arbitrariamente bene qualsiasi funzione continua e quindi qualsiasi confine decisionale. All’aumentare del numero di neuroni nell’hidden layer, aumenta anche il numero di parametri apprendibili dalla rete, accrescendone la capacità espressiva. Questo consente al modello di catturare strutture sempre più complesse nei dati, e di adattare il confine decisionale alla loro distribuzione.


  1. In una rete neurale con hidden layer, le feature latenti sono le rappresentazioni interne dei dati apprese automaticamente dal modello (feature trasformate), non osservabili direttamente nei dati originali, e non definite a priori. ↩︎

Caricamento
  • Rendering delle formule LaTeX...