Programmazione Scientifica++: Lezione 2

Deep Learning — Neurone, MLP e Vettorizzazione

Docente

Argomenti di Oggi

  • Il Neurone Biologico e Matematico
  • Il Problema della Linearità
  • Funzioni di Attivazione: gradino, sigmoide, ReLU
  • Multi-Layer Perceptron (MLP)
  • La Vettorizzazione e le Matrici

Prima del C++: la Matematica

In questa lezione non scriveremo una riga di C++, e non è un rinvio: prima di costruire un software bisogna sapere quali equazioni deve calcolare. Un programma robusto che calcola l’equazione sbagliata è un programma inutile.

Oggi e nella prossima lezione ci occupiamo della teoria matematica alla base del Machine Learning: scopriremo cosa dovrà calcolare ogni Layer del vostro framework, e perché l’algebra lineare vi salverà la vita.

Gli strumenti del C++ — oggetti, puntatori, polimorfismo, template, eccezioni — arrivano dalla Lezione 4 in poi, e li costruirete addosso a queste equazioni.

Il Neurone Biologico

Tutto parte dal cervello umano. Un neurone biologico riceve segnali chimico-elettrici da altri neuroni tramite diramazioni chiamate Dendriti.

Questi segnali non hanno tutti la stessa importanza. Alcuni eccitano il neurone, altri lo inibiscono. Se la somma complessiva dell’eccitazione supera una certa Soglia, il neurone “scatta” e spara a sua volta un impulso elettrico lungo il suo Assone verso i neuroni successivi.

Negli anni ’50, i matematici si chiesero: “Possiamo scriverlo sotto forma di equazione?”.

Il Modello Matematico (Perceptron)

L’unità fondamentale di una Rete Neurale Artificiale si chiama Percettrone — è il singolo neurone artificiale, quello che Rashid nel libro chiama semplicemente neurone o nodo: stessa cosa, nome storico (Rosenblatt, 1958). Prende una serie di input numerici \(x_i\), li moltiplica per dei “pesi” \(w_i\) (che simulano l’importanza del collegamento biologico), e li somma tutti assieme.

L’equazione fondamentale del singolo neurone è:

\[ z = \sum_{i=1}^{n} (w_i \cdot x_i) + b \]

  • \(x_i\): Il segnale in ingresso.
  • \(w_i\): Il Peso (Weight).
  • \(b\): Il Bias, un numero che “sposta” artificialmente la soglia di scatto del neurone.

Cosa Chiediamo a un Neurone

Un neurone deve prendere una decisione: uscita 1 (“sì”) o 0 (“no”). Il banco di prova più piccolo è due ingressi binari \(A, B \in \{0,1\}\) e una porta logica da realizzare.

\(A\) \(B\) AND OR XOR
0 0 0 0 0
0 1 0 1 1
1 0 0 1 1
1 1 1 1 0

Lo XOR (OR esclusivo) vale 1 quando i due ingressi sono diversi.

Geometricamente ogni porta è un problema di classificazione: i quattro ingressi sono i vertici di un quadrato nel piano \((A, B)\), e la porta colora ogni vertice con la sua uscita. Il neurone deve separare i vertici “1” dagli “0” — vediamo con che strumento.

La Combinazione Lineare

Guardate bene l’equazione precedente. Non vi ricorda nulla?

Se prendiamo un neurone con un solo input \(x\), la sua equazione diventa: \[ z = w \cdot x + b \]

Questa è l’equazione di una Retta (\(y = mx + q\)). Il neurone artificiale di base non è nient’altro che una combinazione lineare dei suoi input. Crea un “piano” matematico che taglia lo spazio a metà.

Un mondo troppo semplice (Linearità)

Se un singolo neurone traccia una retta, significa che può risolvere solo problemi linearmente separabili: quelli in cui una retta (in generale un iperpiano) lascia tutti i “sì” da una parte e tutti i “no” dall’altra.

Per AND e OR la retta esiste: c’è un solo vertice del quadrato da isolare. Anche fuori dalle porte logiche: pensate alla classificazione di pazienti sani e malati in base al colesterolo. Se basta tirare una riga in un grafico per separarli, il percettrone vince.

Ma se i pazienti sani sono “circondati” dai pazienti malati (es. valori troppo alti o troppo bassi sono letali)? Una singola retta dritta non può chiudersi a cerchio. Il percettrone fallisce.

Il Problema dello XOR

Per lo XOR i vertici da separare sono opposti: \((0,1)\) e \((1,0)\) contro \((0,0)\) e \((1,1)\). Nessuna retta li divide. Nel 1969 Minsky e Papert lo dimostrarono, e la ricerca sulle reti neurali si fermò per anni. La cura è uno strato nascosto: due rette, cioè due neuroni, e una non-linearità fra i due strati. Perché uno strato nascosto basti — non solo per lo XOR, ma per qualunque funzione — lo dice il teorema di approssimazione universale, fra poche slide.

La Funzione di Attivazione

Per permettere al neurone di piegare, flettere e spezzare le rette, dobbiamo forzarlo ad attraversare una funzione matematica “Curva” o “Non-Lineare”.

Chiamiamo questa funzione Funzione di Attivazione \(f(z)\).

La formula del neurone diventa quindi:

\[ a = f(z) = f\left(\sum_{i=1}^{n} (w_i x_i) + b\right) \]

L’output finale è \(a\) (Attivazione), ed è quello che verrà passato al livello successivo.

La “Curvatura” dello Spazio

Warning

IL SEGRETO DEL DEEP LEARNING

Se collegate tra loro 100 layer di neuroni puramente lineari (senza funzione di attivazione), per le leggi dell’algebra lineare l’intera gigantesca rete da milioni di parametri collassa in un solo layer lineare, cioè in una sola matrice: il prodotto delle cento. È tempo sprecato.

Solo l’aggiunta di Funzioni di Attivazione Non-Lineari permette a layer consecutivi di “Piegare lo spazio” in dimensioni superiori, risolvendo problemi complessissimi.

La Step Function (Obsoleta)

Quale funzione \(f(z)\) usare?

Negli anni ’60 usavano la Step Function (Funzione a Gradino). - Se \(z > 0\), l’output è \(1\) (Neurone Acceso). - Se \(z \le 0\), l’output è \(0\) (Neurone Spento).

È concettualmente semplice (come un interruttore), ma ha un difetto mortale: è completamente Piatta ovunque, tranne nel punto zero. Il Machine Learning si basa sul calcolo delle Derivate per correggere gli errori. La derivata di una linea piatta è ZERO. Il neurone non impara nulla.

La Funzione Sigmoide

Per decenni, la regina incontrastata è stata la Sigmoide. Una bellissima funzione a forma di “S” morbida.

\[ f(z) = \frac{1}{1 + e^{-z}} \]

Vantaggi: 1. Schiaccia tutto l’universo infinito dei numeri tra \(0\) e \(1\). Perfetta per esprimere Probabilità (“La probabilità che questa foto sia un Gatto è 0.98”). 2. È derivabile ovunque in modo dolce.

Svantaggio: Se il numero in input è molto alto o molto basso, la curva si “appiattisce”. Il gradiente sparisce (Vanishing Gradient), bloccando l’apprendimento.

L’Era del Deep Learning: ReLU

Oggi la grande maggioranza delle reti neurali usa una funzione apparentemente banale: la ReLU (Rectified Linear Unit).

\[ f(z) = \max(0, z) \]

  • Se il numero è negativo, esce Zero.
  • Se il numero è positivo, esce il numero stesso.

Sembra stupida, vero? Come fa a essere “Non-Lineare”? Il trucco è nello “spigolo” allo zero.

Perché ReLU funziona?

La ReLU è non-lineare grazie al suo taglio brutale sotto lo zero. Ma il suo vero superpotere è che per tutti i numeri positivi, la sua pendenza (derivata) è costante e vale 1.

Il segnale non si spegne mai. Il gradiente fluisce perfetto attraverso centinaia di layer senza mai svanire. È la funzione che ha permesso alle reti di diventare “Deep” (Profonde).

Inoltre \(\max(0, x)\) è un confronto: un ciclo di clock. Un esponenziale \(e^{-x}\) ne costa alcune decine. Su miliardi di attivazioni la differenza si sente.

Confronto: Step vs Sigmoid vs ReLU

Uniamo i Puntini: Multi-Layer Perceptron

Ora che abbiamo un singolo neurone intelligente (con ReLU), ne prendiamo migliaia e li impiliamo in “Strati” (Layer).

Nasce il Multi-Layer Perceptron (MLP) o Rete Feedforward Fully Connected.

  • Input Layer: I dati puri (es. i pixel dell’immagine).
  • Hidden Layers: I livelli intermedi “nascosti”. Ogni neurone è connesso a tutti i neuroni del layer precedente.
  • Output Layer: La risposta finale (es. 10 neuroni per le 10 cifre da 0 a 9).

Architettura di Base

Ecco la rappresentazione visiva dei flussi (Edges) in un MLP.

graph LR
    subgraph Input
    x1((x₁))
    x2((x₂))
    end
    
    subgraph Hidden
    a1((a₁))
    a2((a₂))
    a3((a₃))
    end
    
    subgraph Output
    y1((y₁))
    end

    x1 --> a1; x1 --> a2; x1 --> a3
    x2 --> a1; x2 --> a2; x2 --> a3
    
    a1 --> y1; a2 --> y1; a3 --> y1

Il groviglio di frecce è letteralmente l’insieme dei Pesi (Weights) della rete. Un livello denso si chiama Fully Connected perché non manca nemmeno una freccia.

La Nostra Architettura: MNIST

Per MNIST: 784 → 200 → 10, un solo strato nascosto, sigmoide su entrambi. Ingressi in \([0.01,\,0.99]\), la cifra è il neurone più acceso. Basta a superare il 90%; ReLU e softmax sono varianti da progetto.

Forward Propagation (L’onda in avanti)

Il processo con cui i dati entrano dall’Input Layer, vengono moltiplicati per i pesi, sommati, passati nelle funzioni di attivazione livello dopo livello fino ad uscire come risultato finale, si chiama Forward Propagation.

L’onda procede inesorabile solo in avanti (Feed-forward).

La domanda sorge spontanea: Costruire una rete del genere serve davvero a qualcosa o è solo un esercizio di stile informatico?

Tutto è un Vettore

Prima del teorema, un cambio di prospettiva che vale per tutto il corso.

Un’immagine MNIST è \(28\times 28\) pixel: 784 numeri, un vettore \(\mathbf{x}\in\mathbb{R}^{784}\). Un suono è una sequenza di campioni. Un testo diventa numeri (codici, o embedding). Un video è una pila di immagini. Qualunque oggetto, una volta digitalizzato, è un vettore.

E allora classificare — “questa immagine è un 7” — non è altro che una funzione \(f:\mathbb{R}^{784}\to\mathbb{R}^{10}\), che al vettore dei pixel associa le dieci uscite, una per cifra. Tradurre una frase, prevedere la parola successiva, diagnosticare da una radiografia: sempre una funzione che manda vettori in vettori. “Ragionare”, in questo senso, è calcolare \(f\).

Le domande diventano due, e la seconda è tutto il corso:

  1. Esiste una rete neurale che calcola \(f\)?
  2. Come si trovano i suoi pesi?

Il Teorema di Approssimazione Universale

Alla prima domanda risponde uno dei teoremi più belli della matematica applicata (Cybenko 1989; Hornik 1991; Leshno et al. 1993).

Teorema. Sia \(f:K\to\mathbb{R}^M\) continua su un compatto \(K\subset\mathbb{R}^N\), e sia \(\varphi\) una funzione continua non polinomiale (sigmoide, tanh, ReLU…). Per ogni \(\varepsilon>0\) esistono un numero \(H\) di neuroni nascosti e pesi \(\mathbf{W}^{(1)}, \mathbf{b}^{(1)}, \mathbf{W}^{(2)}\) tali che \[ F(\mathbf{x}) = \mathbf{W}^{(2)}\,\varphi\!\left(\mathbf{W}^{(1)}\mathbf{x}+\mathbf{b}^{(1)}\right) \qquad\text{soddisfi}\qquad \max_{\mathbf{x}\in K}\|F(\mathbf{x})-f(\mathbf{x})\| < \varepsilon . \]

Cioè: un solo strato nascosto, abbastanza largo, approssima qualunque funzione continua con la precisione che si vuole. La funzione che classifica le cifre, quella che traduce, quella che diagnostica: esistono tutte, dentro questa forma.

L’Ipotesi che Conta: la Non-Linearità

Nel teorema c’è un’ipotesi che non si può togliere: \(\varphi\) non polinomiale, in particolare non lineare.

Provate a toglierla. Se \(\varphi\) fosse lineare, \(\varphi(\mathbf{z}) = \mathbf{z}\), la rete a due strati sarebbe

\[ F(\mathbf{x}) = \mathbf{W}^{(2)}\left(\mathbf{W}^{(1)}\mathbf{x} + \mathbf{b}^{(1)}\right) = \underbrace{\mathbf{W}^{(2)}\mathbf{W}^{(1)}}_{\text{una sola matrice}}\,\mathbf{x} + \mathbf{W}^{(2)}\mathbf{b}^{(1)} , \]

cioè un percettrone a singolo strato: la composizione di due mappe lineari è lineare, e con mille strati lineari si otterrebbe sempre e solo una matrice. Saremmo di nuovo fermi allo XOR.

È la non-linearità fra gli strati a dare alla composizione più potere dei suoi pezzi, ed è per questo che nel Lab 07 ogni DenseLayer sarà seguito da un layer di attivazione. Le \(\varphi\) viste poche slide fa — sigmoide, tanh, ReLU — non erano un dettaglio: erano la condizione perché tutto questo funzioni.

Cosa il Teorema Non Dice

Traduciamo dal matematichese. Se avete un problema governato da regole — riconoscere una voce, guidare un’auto, leggere una radiografia — esiste una configurazione di pesi che lo risolve con la precisione voluta. La rete neurale, come famiglia di funzioni, non ha limiti di espressività.

Ma il teorema è di esistenza, non di costruzione, e tace su tre cose:

  • quanti neuroni nascosti servono: \(H\) può essere enorme, e in pratica conviene più strati stretti che uno solo larghissimo (è il “deep” di Deep Learning);
  • come trovare i pesi: il teorema non dà nessun algoritmo. Questo è il lavoro della Backpropagation, la prossima lezione;
  • come si comporta la rete sui dati che non ha visto: approssimare \(f\) sui dati di addestramento non garantisce nulla altrove. È il problema della generalizzazione, che incontrerete nel Lab 14 misurando la performance su un test set separato.

Il Problema Computazionale (I Cicli For)

Fermiamoci un momento e pensiamo come un programmatore al primo anno di Informatica scriverebbe la Forward Propagation.

Dovrebbe calcolare \(z = \sum w_i x_i + b\) per ogni singolo neurone. In C++, scriverebbe:

// Vogliamo calcolare le attivazioni del Layer L
for (int i = 0; i < num_neuroni_layer_L; i++) {
    double z = bias[i];
    for (int j = 0; j < num_neuroni_layer_L_meno_1; j++) {
        z += peso[i][j] * input[j];
    }
    attivazione[i] = relu(z);
}

Vi sembra un buon codice?

Il Codice Naif (Lento e Mortale)

Se fate così, fallirete. Una rete vera (es. GPT o ResNet) ha migliaia di neuroni per layer.

Un layer da 4000 neuroni connesso a un layer da 4000 neuroni significa: \(4000 \times 4000 = 16.000.000\) di moltiplicazioni all’interno di un doppio ciclo for. Immaginate di ripeterlo per 100 layer.

I cicli for in C++ sono puramente SEQUENZIALI. Il processore fa la prima moltiplicazione, aspetta che finisca, fa la seconda, aspetta, fa la terza. Ci vorranno mesi di esecuzione.

L’Algebra Lineare in Soccorso

Per abbattere i tempi di calcolo, la matematica ci offre una scappatoia monumentale: smettere di guardare ai neuroni come singole entità, e guardarli come Vettori e Matrici.

Invece di calcolare l’output del “Neurone 1” e poi del “Neurone 2”, calcoleremo l’output di tutto il Layer contemporaneamente.

Questo processo di astrazione matematica si chiama Vettorizzazione.

La Vettorizzazione (Concetto)

Immaginate che il Layer precedente abbia emesso tre output (attivazioni). Li impiliamo in un Vettore Colonna (una matrice di dimensione \(3 \times 1\)) che chiameremo \(\mathbf{x}\).

\[ \mathbf{x} = \begin{bmatrix} 1.2 \\ -0.5 \\ 0.8 \end{bmatrix} \]

Immaginiamo che il nostro Layer attuale abbia 2 neuroni, e che ognuno abbia il suo Bias. Li mettiamo nel Vettore Colonna \(\mathbf{b}\) (dimensione \(2 \times 1\)).

\[ \mathbf{b} = \begin{bmatrix} 0.1 \\ -0.2 \end{bmatrix} \]

Lo Schema della Rete

Lo stesso esempio di prima — tre ingressi, due neuroni — visto come circuito: sei pesi, uno per freccia. \(\omega_{i,j}\) collega il nodo \(i\) del layer #1 al nodo \(j\) del layer #2: primo indice la partenza, secondo l’arrivo, come nel libro di Rashid. E appartengono al layer #2, quello che li riceve: nel codice sarà così anche per DenseLayer.

La Matrice dei Pesi (W)

Ora il pezzo grosso: I Pesi. Ogni neurone del nostro layer ha 3 frecce in entrata (provenienti da \(\mathbf{x}\)). Dato che abbiamo 2 neuroni, in totale ci sono 6 pesi.

Li organizziamo in una formidabile Matrice bidimensionale \(\mathbf{W}\) (dimensione \(2 \times 3\)).

\[ \mathbf{W} = \begin{bmatrix} w_{11} & w_{12} & w_{13} \\ w_{21} & w_{22} & w_{23} \end{bmatrix} \] Ogni riga di questa matrice rappresenta tutti i pesi appartenenti a un singolo neurone del nostro strato: \(w_{jk}\) è il peso con cui il neurone \(j\) legge l’ingresso \(k\).

Note

Attenzione all’ordine degli indici. Nello schema di prima (e nel libro di Rashid) \(\omega_{i,j}\) va dalla partenza \(i\) all’arrivo \(j\); qui invece la riga è il neurone d’arrivo, quindi questa \(\mathbf{W}\) è la trasposta di quella tabella. È lei che serve per moltiplicare, ed è per questo che nel codice si chiamerà Wt.

L’Equazione Sacra del Machine Learning

Unendo i pezzi con le spietate leggi della Moltiplicazione tra Matrici (Riga per Colonna), i doppi cicli for spariscono nel nulla, sostituiti da una singola, elegante equazione:

\[ \mathbf{z} = \mathbf{W} \cdot \mathbf{x} + \mathbf{b} \]

Questa equazione produce un Vettore Colonna \(\mathbf{z}\) di dimensione \(2 \times 1\), contenente simultaneamente lo stato di eccitazione dei nostri 2 neuroni!

\[ \begin{bmatrix} z_1 \\ z_2 \end{bmatrix} = \begin{bmatrix} w_{11} & w_{12} & w_{13} \\ w_{21} & w_{22} & w_{23} \end{bmatrix} \cdot \begin{bmatrix} x_1 \\ x_2 \\ x_3 \end{bmatrix} + \begin{bmatrix} b_1 \\ b_2 \end{bmatrix} \]

Il Vantaggio Hardware (SIMD & GPU)

Perché scrivere \(\mathbf{W} \cdot \mathbf{x}\) è infinitamente più veloce dei cicli for?

Perché la moltiplicazione Riga \(\times\) Colonna è un’operazione brutalmente Parallela. Il calcolo del Neurone 1 non dipende minimamente dal calcolo del Neurone 2.

I processori Intel moderni posseggono istruzioni speciali (SIMD - Single Instruction Multiple Data) che eseguono 8 moltiplicazioni contemporaneamente nello stesso nanosecondo. Le GPU (Schede Video Nvidia) ne posseggono migliaia, processando enormi matrici istantaneamente.

C++ e Librerie Ottimizzate (Eigen / BLAS)

In C++, non dovrete (e non dovreste mai) scrivervi a mano la moltiplicazione tra matrici.

Nel vostro progetto, userete una libreria di algebra lineare di livello industriale come Eigen (o BLAS/LAPACK). Basterà definire due oggetti Eigen::Matrix e moltiplicarli con il banale operatore *.

// Eigen usa istruzioni Assembly SIMD sotto il cofano!
Eigen::VectorXd z = W * x + b; 

Il vostro codice risulterà 100 volte più pulito da leggere e 1000 volte più veloce in esecuzione.

L’Equazione Ricorsiva Finale

L’ultimo passo per ottenere i valori da inviare al livello successivo è applicare la funzione di attivazione a tutto il Vettore Colonna.

\[ \mathbf{a}^{(L)} = f\left( \mathbf{W}^{(L)} \mathbf{a}^{(L-1)} + \mathbf{b}^{(L)} \right) \]

Questa equazione racchiude l’intera logica di inferenza (Forward Pass) del Deep Learning moderno.

  • Prendi l’input (\(\mathbf{a}^{(L-1)}\)), moltiplica per i pesi (\(\mathbf{W}\)), somma il bias (\(\mathbf{b}\)).
  • Schiaccia con la funzione di attivazione (\(f\) — per noi la sigmoide).
  • Invia al prossimo layer, e ripeti.

Quesiti finali

1. Dieci strati per niente. Immaginate una rete di dieci strati densi, ciascuno con la sua matrice di pesi, e nessuna funzione di attivazione fra uno e l’altro. Quanti strati servono per calcolare esattamente la stessa funzione?

2. (aperta) Perchè “deep” e non “wide”? Il teorema di approssimazione universale dice che uno strato nascosto basta per qualunque funzione. E allora perchè il campo si chiama deep learning? Provate a rendere quantitativo l’argomento della slide “Cosa il Teorema Non Dice”: confrontate 784 → 200 → 10 con 784 → 100 → 100 → 10, contate i pesi dell’una e dell’altra, e chiedetevi quante “pieghe” sa fare ciascuna.

Quesiti finali: le risposte

1. Uno. \(\mathbf{W}_{10}(\mathbf{W}_9(\cdots \mathbf{W}_1 \mathbf{x})) = (\mathbf{W}_{10} \cdots \mathbf{W}_1)\,\mathbf{x} = \mathbf{W}\mathbf{x}\): il prodotto di dieci matrici è una matrice. Dieci strati lineari non sono più espressivi di uno — sono lo stesso identico modello, scritto peggio e calcolato dieci volte più lentamente. La non-linearità non è un abbellimento: senza, la profondità non esiste proprio.

2. Traccia: \(784 \times 200 + 200 \times 10 = 158.800\) pesi contro \(784 \times 100 + 100 \times 100 + 100 \times 10 = 89.400\). La rete profonda costa poco più della metà ed è quella che in pratica funziona meglio: ogni strato ripiega lo spazio già ripiegato dal precedente, e le regioni che sa distinguere crescono come un prodotto, non come una somma. Il teorema garantisce che la rete larga esiste; non dice che sia quella conveniente, né che la troviate.