Si lo so, post vacanze è dura per tutti. Ma qualcuno deve pur farlo il lavoro sporco no? Quindi riprendiamo, dopo qualche settimana di pausa, il nostro bel discorsetto sulle Neural ODE o NODE. Discorsetto che potete recuperare leggendo le prime tre parti linkate a fine articolo. Per farla breve abbiamo parlato di calcolo differenziale e calcolo integrale, e metodi analitici e numerici per risolvere equazioni differenziali. Non ci resta, finalmente aggiungerei, che vedere come mettere tutto insieme per arrivare alla formulazione di queste benedette NODE. Quindi iniziamo...
Dal Discreto Al Continuo
Esistono tante architetture di reti neurali. Troppe. Tra le varie architetture ne esiste una in particolare, nota come ResNet o Rete Residuale. Le ResNet sono state un punto di svolta nel mondo del Deep Learning in quanto hanno permesso di superare delle problematiche di addestramento. Quali, vi starete chiedendo. Vedetela così, per essere più performante una rete neurale deve essere più profonda, ma più è profonda più le performance peggiorano per via di Gradient Vanishing/Explosion che non staremo qui a trattare ma fidatevi, sono brutte bestie. La particolarità di queste reti è l'utilizzo di blocchi residuali. Per farla semplice, una ResNet è una serie di questi blocchetti, ognuno dei quali è strutturato così:
Figura 1. Blocco Residuale
Da qui dovrebbe essere chiaro il perchè è residuale. L'uscita infatti è
da cui
Dove:
- \(x\) è l'input
- \(y\) è l'output
- \(F(x)\) è la trasformazione fatta dai layer (o residuo).
Per cui quello che imparano i singoli layers non è una mappatura assoluta della \(y\), bensì sono residui.
Ma cosa c'entra questo con le NODE? Calma calma. Ora ci arriviamo. Iniziamo supponendo di avere una ResNet ad un livello:
Figura 2. ResNet Un Livello
L'uscita è la seguente:
Dove:
- \(x_0\) è l'ingresso della ResNet
- \(f_1\) è il layer di trasformazione
- \(\theta_1\) sono i pesi del relativo layer
- \(x_1\) è l'output
Prendiamone ora, una a due livelli:
Figura 3. ResNet Due Livelli
Ora l'uscita è:
Ancora. Prendiamone una a tre livelli:
Figura 4. ResNet Tre Livelli
l'uscita è
Avete capito l'antifona...? Quindi perché non pensare alla profondità della rete come una sequenza temporale, dove ogni layer \(f_i\) rappresenta un passo discreto nel tempo? E perché non supponiamo di avere infiniti layers e quindi infinite \(f_i\), una per ogni step temporale...? Ma si, facciamolo! E andiamo a generalizzare:
Ma arrivati a questo punto, perché non immaginare di avere un’unica funzione \(f\) che varia in funzione del tempo, invece che infinite \(f_i\)? E allora scriviamolo così:
Questa formulazione vi ricorda qualcosa? No!? Forse potrebbe se aggiungessi un fattore moltiplicativo \(h=1\):
Così spero che vada meglio. Dovreste avere familiarità ormai con la formula di Eulero. Se non l'avete, recuperatela qui.
Da questa formulazione possiamo scrivere:
Anche questa formulazione dovreste riconoscere, è quella del rapporto incrementale. Quindi, supponendo di poter agire sul valore \(h\), possiamo tranquillamente scrivere:
Formula 1. Formulazione Differenziale della Neural ODE
Se quindi riuscissimo a trovare un modo per considerare piccole variazioni della nostra \(f\) introducendo un \(h\) piccolo a piacere, il nostro problema passerebbe dall'essere una sequenza di passi grossi, ossia layer discreti con \(h=1\), a infiniti passetti piccolini.
Ed ecco che, almeno concettualmente, siamo passati da un problema discreto con tante funzioni \(f_t(x, \theta_t)\), ad uno continuo che evolve con fluidità nel tempo, guidato da un’unica \(f(x(t), t, \theta)\). Schematicamente abbiamo questo comportamento:
Figura 5. Dal Discreto al Continuo
A questo punto il passo successivo è naturale. Se abbiamo una derivata che descrive come cambia il nostro stato, possiamo chiederci
quanto cambia nel tempo?
La risposta è, come sempre: integriamo.
Integra Che Ti Passa
Abbiamo appena ottenuto una bella equazione differenziale che ci dice come cambia lo stato nel tempo. Bene, ma a noi interessa sapere quanto cambia, cioè:
Se parto da \(x_0\) nel tempo \(t_0\), dove sarò nel tempo \(t_N\)?
La risposta, se avete letto anche solo una riga degli articoli precedenti, la conoscete già: integriamo.
Formula 2. Formulazione Integrale della Neural ODE
Questa, signore e signori, è il cuore di una NODE. Non c'è più una lista di layer, ma una dinamica continua che evolve nel tempo, guidata da una funzione \(f\) che impara come far cambiare lo stato. E questa \(f\), sorpresa sorpresa, altro non è che una rete neurale.
Ora viene il bello. Come integro analiticamente una \(f\) che, ricordiamolo, è una rete neurale bella piena di ReLU, layer non lineari e pesi sparsi?
La risposta veloce? Non puoi.
E' per questo che vi ho scritto un intero articolo sui metodi numerici. Non sono mica uno sprovveduto.
E ricordate nei solver, quale era il parametro che controllava il passo di integrazione? Esatto... era proprio \(h\).
Quindi quell’idea introdotta nel paragrafo precedente, cioè introdurre un \(h\) piccolo a piacere, non solo è sensata, ma è esattamente
quello che si va a fare.
Houston, Abbiamo Un Problema... Di Addestramento
Sappiamo che non possiamo risolvere il nostro integrale analiticamente, ma numericamente chi ci ferma? Quindi per il momento, limitiamoci a scrivere questo:
Formula 3. Formulazione Numerica della Neural ODE
dove:
- \(f\) non è il classico problema. Non è un Lotka Volterra o simili, ma è una rete neurale con i suoi strati, non linearità e attivazioni.
- \(\theta\) sono i pesi della rete neurale e quindi i parametri da ottimizzare.
- \(x_0\) è lo stato iniziale (l'input della rete neurale), ed è noto.
- \(t_0\) è il tempo di partenza.
- \(t_N\) è il tempo di arrivo, cioè il momento in cui osserviamo l’output.
Se avete letto gli articoli sul neurone, sapete che addestrare una rete neurale significa ottimizzare i pesi.
Questo lo si fa calcolando la Loss che è l’errore tra l'output previsto e quello desiderato, e propagandola all’indietro tramite backpropagation
modificando i pesi in base a quanto hanno inciso sull'errore stesso. Tutto facile nel discreto, vero? Layer per layer, peso per peso. E ora?
Ora è ogni istante del tempo di integrazione a contribuire, un po’ per volta, alla perdita finale.
Come calcoliamo l’errore di qualcosa che evolve in modo continuo nel tempo?
La rete non è più fatta di layer distinti. È nascosta dentro un integratore. Non c’è più una sequenza di layer ben definita, come la seguente
dove per arrivare all'output \(y\), ci basta un forward della rete, bensì abbiamo
Non possiamo più vedere né controllare l'effetto diretto di ogni peso sull’errore, perché l'output non è prodotto direttamente da una rete neurale, ma da un ODESolver che integra il sistema dinamico definito dalla rete stessa. Come abbiamo visto, un solver chiama ripetutamente, in base al valore \(h\), la dinamica \(f\). Quindi ora, ogni chiamata a \(f\) è un forward della rete. Il risultato viene utilizzato per calcolare il valore allo step successivo, fino ad arrivare all'output \(y\) al tempo \(t_N\), ossia dopo \(n\) forward. Le differenze tra il forward di una NODE e quello di una rete neurale classica, sono riportate nelle immagini che seguono:
Figura 5. Forward Rete Neurale
Figura 6. Forward Neural ODE
La Loss quindi, non è più il semplice errore calcolato in funzione di output vero e output predetto ma qualcosa di molto più complesso che è funzione di tutti gli istanti temporali, come nell'immagine che segue.
Figura 7. Loss Della Neural ODE
Potete vedere ogni pallino nero come un forward che genera un output per lo step successivo contribuendo alla Loss, a partire dall'istante iniziale \(t_0\) fino ad arrivare a quello finale \(t_N\) che produce la predizione \(y=x(t_N)\).
Facendo un recap, gli input iniziali sono dati da \(x_0\) e l'obiettivo è minimizzare il valore di
dove:
- \(L\) è la funzione di Loss
- \(y\) è l'output vero
- \(x(t_N)\) è l'output dell'ODESolver, ottenuto dopo decine o centinaia di forward della stessa rete neurale \(f\).
A questo punto però il quesito resta. Come addestriamo la rete per ottimizzare \(\theta\)? Cioè come facciamo backpropagation dell'errore sapendo che la Loss calcolata non è il risultato di un forward della rete neurale, ma il risultato di un ODESolver cioè di \(n\) forward della rete neurale?
Adjoint Un Posto A Tavola
Se arrivati a questo punto ci avete capito qualcosa, ottimo! Potete spiegarlo anche a me! Ma bando agli scherzi, riprendiamo. Dalla serie di articoli sul neurone abbiamo imparato che minimizzare la Loss vuol dire derivare l'errore rispetto i pesi, cioè eseguire
Nel paragrafo precedente però, abbiamo visto che in una NODE, l'errore è il risultato di tanti contributi infinitesimi generati dagli stati \(x(t_i)\), ognuno dei quali utilizza lo stesso identico insieme di pesi \(\theta\). Quindi per la regola della catena possiamo scrivere:
Formula 4. Gradiente della Loss
dove:
- \(\frac{\partial L}{\partial x(t_i)}\) è il contributo all'errore dello stato \(i-esimo\)
- \(\frac{dx}{d\theta}\) è il contributo che i pesi danno allo stato \(x(t_i)\)
Poniamo attenzione alla grandezza
Questo valore è anche chiamato sensibilità della Loss rispetto lo stato al tempo \(t\), e rappresenta il gradiente istantaneo che evolve nel tempo, cioè come ogni singolo stato impatta sull'errore. Risponde alla domanda:
Se cambiassi il valore dello stato, quanto cambierebbe il valore della Loss?
E come quantifichiamo quanto, istantaneamente, una grandezza incide su un'altra? Esatto, attraverso la derivata. Quindi possiamo scrivere
Per il teorema di Schwarz possiamo modificarla come segue
Ora abbiamo la Loss in funzione del tempo, ma come ben sappiamo la Loss è in funzione degli stati e sono gli stati ad essere in funzione del tempo per cui possiamo applicare la regola della catena e scrivere
per cui facendo una semplice sostituzione abbiamo:
In mezzo a questa ginepraio di derivate, probabilmente non avete notato che una parte di questa formula l'abbiamo già definita qui. Per cui, sostituendo la notazione relativa ad un \(i-esimo\) stato in favore di una generica, possiamo scrivere
Se ben ricordate però abbiamo anche scritto che
quindi facciamo un'altra sostituzione e scriviamo:
Arrivati a questo punto manca un ultimo pezzettino. Abbiamo visto che per arrivare allo stato finale, abbiamo utilizzato un ODESolver che evolve in avanti nel tempo: partiamo dallo stato iniziale \(x_0\) (l'input) e arriviamo allo stato finale \(x_{t_N}\) (l'output). E' Proprio sullo stato finale che possiamo calcolare la Loss, ma vi ricordo che non abbiamo informazione sugli stati intermedi, quindi niente backpropagation. Per cui, per come ogni singolo stato ha impattato sull'errore, dobbiamo tornare indietro nel tempo a partire dallo stato \(x(t_N)\) per arrivare allo stato \(x_0\). Ma questo ha delle implicazioni. Lasciate che ve le spieghi con un esempio: supponete di stare camminando in salita e di fare un passo in avanti. Vi siete spostati di mezzo metro in avanti e vi trovate più in alto, diciamo del \(5\%\). Ora fate lo stesso identico passo, ma all'indietro. Vi spostate sempre di mezzo metro, sempre con un dislivello del \(5\%\) ma questa volta verso il basso, siete cioè scesi del \(5\%\), il che vuol dire che siete a \(-5\%\) rispetto a prima. Matematicamente quello che si sta facendo è far sì che la derivata (ossia la pendenza) rimanga coerente, cioè che indichi correttamente non solo quanto ci siamo spostati in pendenza, ma anche se si sta salendo o scendendo. Quindi, per poter tornare indietro nel tempo a partire dallo stato finale, ma mantenendo la coerenza del gradiente, ci serve un bel segno meno. Aggiungiamolo:
Formula 5. Formulazione Differenziale della Sensibilità
Si lo so, io ve l'ho raccontata e quasi un po' imposta, ma in realtà dovreste ringraziarmi per avervi evitato lo sbattimento di altre formule. Se in ogni caso siete curiosi potete vedere la dimostrazione del perché ci va il segno meno, qui. Tornando a noi, dal teorema fondamentale del calcolo integrale possiamo scrivere:
Formula 6. Formulazione Integrale della Sensibilità
Quello che abbiamo appena introdotto si chiama Metodo Adjoint e il perché non solo è utile, ma è indispensabile, lo vedremo tra pochissimo. Graficamente quello che il metodo adjoint fa è riportato nella figura che segue
Figura 7. Metodo Adjoint
Partiamo dall'istante finale, del quale abbiamo tutte le informazioni necessarie in quanto è l'output, e torniamo indietro nel tempo calcolando per ogni istante temporale la sensibilità dell'errore rispetto allo stato corrente. Se c'è una cosa che abbiamo imparato è che ci fanno schifo gli integrali analitici. Quindi come risolviamo il nostro equazione della sensibilità...? E perché proprio con un ODESolver?
Formula 7. Formulazione Numerica della Sensibilità
dove:
- \(-a(t) \cdot \frac{df(x(t), t, \theta)}{dx}\) è la dinamica.
- \(\frac{dL}{dx(t_N)} = a(t_N)\) è la sensibilità allo stato finale ed è calcolabile, quindi nota.
- \(t_N\) è l'istante finale, dal quale iniziamo l'integrazione.
- \(t_0\) è l'istante iniziale, nel quale finiamo l'integrazione.
Un Adjoint Per Domarlo
Se siete arrivati a questo punto complimenti, ma immagino sarete più confusi di prima. Perché di fatto il metodo adjoint ora come ora non serve a nulla. Quindi, per evitare linciaggi, diamo un senso a tutto questo. Ripartiamo proprio dalla formulazione integrale della neural ODE e deriviamola rispetto ai pesi. Quello che otteniamo è:
Dato che \(x(t_0)\) è il solito e noto istante iniziale che non cambia mai nel tempo, abbiamo che il suo valore è costante per cui (come dovreste sapere dai precedenti articoli), la sua derivata è nulla. Quindi abbiamo:
Riprendiamo anche la formulazione del gradiente dell'errore. Dalla formula precedente abbiamo \(\frac{dx(t_N)}{d\theta}\), e dal paragrafo precedente sappiamo che \(\frac{dL}{dx(t)} = a(t)\) per cui possiamo scrivere:
Formula 8. Formulazione Integrale del Gradiente
Siamo quindi arrivati ad una nuova formulazione del gradiente di errore che permette la backpropagation. E sapete cosa ha di speciale questa formulazione? Che non dipende dagli stati interni. E se ricordate il nostro problema era proprio quello. Il fatto che essendo la rete neurale nascosta in un solver, non abbiamo accesso agli stati interni. E seppure potremmo trovare un modo per salvarli, non converrebbe dal punto di vista della occupazione di memoria perché vi ricordo che abbiamo centinaia di forward, e quindi centinaia stati interni. Solita storia, abbiamo un integrale. Quindi scomodiamo, per la terza volta, i solver e scriviamo:
Formula 9. Formulazione Numerica del Gradiente
Per questo ODESolver la condizione iniziale, che è al tempo \(t_N\) dato che andiamo indietro nel tempo, è \(0\). Questo perché \(\frac{dL}{d\theta}\) è un gradiente che accumula il contributo dei pesi alla Loss. In fase iniziale questo contenitore di contributi è vuoto, e man mano che si torna indietro nel tempo possiamo quantificarli e aggiungerli.
Quindi, la ODESolver sulla funzione sensibilità, ci permette di calcolare la sensibilità istantanea che viene utilizzata dalla ODESolver per il calcolo della funzione gradiente. Manca un ultimo pezzo. Quali sono gli input della nostra \(f(x(t), t, \theta)\)? Sappiamo che sono gli stati \(x(t)\) ma chi ci fornisce questi stati? Li avevamo in fase di forward perché sono proprio quelli che calcoliamo con questa ODESolver ma come già detto e ridetto, ce ne sono a migliaia e quindi l'occupazione spaziale sarebbe eccessiva se li volessimo memorizzare. Quindi come si procede? Beh tanto per cambiare... usiamo un'altra ODE che risolve il nostro problema all'indietro. Quindi se con questo ODESolver abbiamo fatto il forward, con il seguente:
Formula 10. Formulazione Numerica dello Stato in Backward
ripercorriamo i nostri stati all'indietro. Quindi usiamo nuovamente la rete neurale per predire lo stato ma piuttosto che procedere con
procediamo al contrario
dove \(x_N\) lo abbiamo perché è l'output dell'ODESolver della fase di forward. Quindi è vero che computazionalmente dobbiamo risolvere un'altra ODE facendo \(n\) forward della rete neurale, ma in questo modo istantaneamente abbiamo lo stato \(x_i\) da utilizzare per risolvere la ODE della sensibilità e, di conseguenza, quella del gradiente. Ed essendo che questo risultato ci serve solo per un istante temporale, una volta utilizzato per fare uno step, possiamo buttarlo facendo si che l'uso di memoria rimanga limitato.
Dio Benedica I Recap
Ci tengo ancora a farvi i miei complimenti se state leggendo queste righe, non perché siete il milionesimo visitatore (anzi probabilmente siete il secondo, il primo sono io), ma perché vi ho buttato in mezzo le Menadi e ne siete usciti Orfeo. Non che questo sia di buon auspicio. In ogni caso, ora che abbiamo tutti gli strumenti, possiamo fare un recap.
Iniziamo guardando il seguente disegno che ho così alacremente disegnato per voi
Figura 8. Quadro di Insieme
Così come per le reti neurali classiche, l'addestramento di una NODE si divide in due fasi: forward e backward.
Forward
Si utilizza un ODESolver (Eulero, RK4, DOPRI5, ecc), dal tempo \(t_0\) al tempo \(t_N\) scelto, su una dinamica definita da una rete neurale \(f\) utilizzando, come input, \(x_0\)
Arrivati all'istante \(t_N\), abbiamo il nostro output (o predizione), dato dallo stato \(x(t_N)\). Usiamo lo stato \(x(t_N)\) e l'output vero \(y\), per calcolare la Loss \(L\) e la sua derivata rispetto allo stato, ossia la sensibilità all'istante \(t_N\):
Sia \(x(t_N)\) che \(a(t_N)\), che hanno dimensionalità pari alla dimensione dell'output della rete neurale, vengono usati per gli step della backward.
Backward
Si utilizza un sistema di ben tre ODESolver, dello stesso tipo di quello usato per il forward. Tutte e tre risolvono la dinamica a partire all'indietro, cioè si parte da \(t_N\) per arrivare a \(t_0\).
dove:
- La prima prende in ingresso lo stato \(x(t_N)\) predetto nella fase di forward e produce uno stato all'istante \(x(t_{N-1})\).
- La seconda prende in ingresso la sensibilità \(a(t_N)\) calcolato in fase di forward, e lo stato corrente \(x(t_{N-1})\) calcolato dal primo ODESolver del sistema e produce in output la sensibilità \(a(t_{N-1})\) all'istante \(t_{N-1}\).
- La terza prende in ingresso la sensibilità corrente \(a(t_{N-1})\) prodotta dal secondo ODESolver del sistema, e lo stato corrente \(x(t_{N-1})\) calcolato dal primo ODESolver del sistema. L'output di questa ODESolver è una matrice gradiente delle dimensioni della rete neurale (o meglio dei suoi parametri).
Una volta arrivati all'istante \(t_0\), possiamo usare la matrice gradiente prodotta dal terzo ODESolver, che conterrà quindi le derivate parziali della Loss rispetto i pesi, per aggiornare i pesi della rete stessa.
Ultimissima nota prima di passare alle conclusioni. Se avete dei dubbi su come si calcolano le derivate
Non è nulla più nulla meno di quello che vi ho già mostrato sulla serie di articoli relativi al neurone. Un buon motivo quindi per recuperarla.
Conclusioni
Siamo arrivati alla fine di questo (ultimo, per ora) capitolo teorico sulle NODE. È stato un viaggio lungo, iniziato dalle derivate e dagli integrali, che ci ha portati a formulare un modello dinamico continuo capace di apprendere trasformazioni complesse nel tempo.
Come abbiamo visto, le NODE sono computazionalmente più costose rispetto alle reti classiche, ma presentano vantaggi unici:
- Non hanno bisogno di salvare tutte le attivazioni intermedie, risparmiando memoria.
- Possono operare con reti più piccole. Grazie alla natura continua dell’integrazione riescono a generalizzare meglio e con reti meno complesse.
Quindi, quando ha senso usare una NODE?
Come spesso accade nel machine learning, la risposta è: dipende.
Se il tuo problema coinvolge dinamiche temporali continue, dati irregolari o sistemi fisici modellabili tramite ODE, allora una NODE può fare la differenza.
In tutti gli altri casi potrebbe solo complicarti la vita.
Nel prossimo (e ultimo) articolo della serie, passeremo finalmente alla pratica: vedremo un'applicazione concreta delle NODE e discuteremo di alcuni casi d’uso reali.
Alla Prossima.