Implementare un Encoder Transformer da zero con JAX e Haiku 🤖

Creare da zero un Encoder Transformer con JAX e Haiku 🤖

Comprendere i fondamenti dei bozzetti fondamentali dei Transformers.

Transformers, nello stile di Edward Hopper (generato da Dall.E 3)

Introdotto nel 2017 nel punto di riferimento “Attention is all you need”[0], l’architettura dei Transformer è probabilmente una delle scoperte più significative nella recente storia del Deep Learning, consentendo la nascita di grandi modelli di linguaggio e trovando persino impiego in campi come la computer vision.

Succedendo alle architetture di punta basate sulla ricorrenza come le reti Long Short-Term Memory (LSTM) o le Gated Recurrent Units (GRU), i Transformers introducono il concetto di auto-attenzione, accoppiato con un’architettura codificatore/decodificatore.

In questo articolo, implementeremo la prima metà di un Transformer, l’Encoder, da zero e passo dopo passo. Utilizzeremo JAX come framework principale insieme a Haiku, una delle librerie di apprendimento profondo di DeepMind.

Nel caso in cui non conosci JAX o hai bisogno di un rapido promemoria sulle sue straordinarie funzionalità, ho già trattato l’argomento nel contesto del Reinforcement Learning nel mio articolo precedente:

Vectorizzare e parallelizzare gli ambienti di RL con JAX: Q-learning alla velocità della luce⚡

Impara a vectorizzare un ambiente di GridWorld e ad addestrare 30 agenti di Q-learning in parallelo su una CPU, a 1,8 milioni di step per…

towardsdatascience.com

Esamineremo ciascuno dei blocchi che compongono l’encoder e impareremo a implementarli efficientemente. In particolare, l’outline di questo articolo contiene:

  • Il layer di incorporamento e le codifiche posizionali
  • Attenzione a più teste
  • Connessioni residue e normalizzazione di livello
  • Reti di avanti e indietro posizionali

Nota: questo articolo non ha la pretesa di essere una completa introduzione a questi concetti poiché ci concentreremo prima sull’implementazione. Se necessario, consulta le risorse alla fine di questo post.

Come sempre, il codice completamente commentato per questo articolo oltre a notebook illustrati sono disponibili su GitHub, sentiti libero di aggiungere una stella al repository se hai apprezzato l’articolo!

GitHub – RPegoud/jab: Una collezione di modelli di apprendimento profondo fondamentali implementati in JAX

Una collezione di modelli di apprendimento profondo fondamentali implementati in JAX – GitHub – RPegoud/jab: Una collezione di…

github.com

Parametri principali

Prima di iniziare, dobbiamo definire alcuni parametri che svolgeranno un ruolo cruciale nel blocco dell’encoder:

  • Lunghezza della sequenza (seq_len): Il numero di token o parole in una sequenza.
  • Dimensione dell’incorporamento (embed_dim): La dimensione degli incorporamenti, in altre parole, il numero di valori numerici utilizzati per descrivere un singolo token o parola.
  • Dimensione del batch (batch_size): La dimensione di un batch di input, ovvero il numero di sequenze elaborate contemporaneamente.

Le sequenze di input per il nostro modello di encoder avranno tipicamente una forma (batch_size, seq_len). In questo articolo, useremo batch_size=32 e seq_len=10, il che significa che il nostro encoder elaborerà contemporaneamente 32 sequenze di 10 parole.

Prestare attenzione alla forma dei dati ad ogni passaggio dell’elaborazione ci permetterà di visualizzare e comprendere meglio come i dati fluiscano nel blocco dell’encoder. Ecco una panoramica generale del nostro encoder, inizieremo dal basso con il livello di embedding e positional encodings:

Rappresentazione del blocco dell'encoder del Transformer (realizzata dall'autore)

Livello di Embedding e Positional Encodings

Come accennato in precedenza, il nostro modello prende in input sequenze di token batched. Generare questi token potrebbe essere semplice come raccogliere un insieme di parole uniche nel nostro dataset e assegnare un indice a ciascuna di esse. Quindi campioneremmo 32 sequenze di 10 parole e sostituiremmo ogni parola con il suo indice nel vocabolario. Questa procedura ci fornirebbe un array di forma (batch_size, seq_len), come atteso.

Siamo ora pronti per iniziare con il nostro Encoder. Il primo passo è creare “positional embeddings” per le nostre sequenze. I positional embeddings sono la somma degli embedding delle parole e dei positional encodings.

Word Embeddings

I word embeddings ci permettono di codificare il significato e le relazioni semantiche tra le parole nel nostro vocabolario. In questo articolo, la dimensione dell’embedding è fissata a 64. Questo significa che ogni parola è rappresentata da un vettore di 64 dimensioni in modo che parole con significati simili abbiano coordinate simili. Inoltre, possiamo manipolare questi vettori per estrarre relazioni tra le parole, come mostrato di seguito.

Esempio di analogie derivate da word embeddings (immagine da developers.google.com)

Utilizzando Haiku, generare degli embeddings apprendibili è semplice come chiamare:

hk.Embed(vocab_size, embed_dim)

Questi embeddings verranno aggiornati insieme ad altri parametri apprendibili durante l’addestramento del modello (ne parleremo tra poco).

Positional Encodings

A differenza delle reti neurali ricorrenti, i Transformers non possono dedurre la posizione di un token dato uno stato nascosto condiviso poiché mancano strutture ricorrenti o convoluzionali. Pertanto, è stata introdotta la positional encodings, vettori che trasmettono la posizione di un token nella sequenza di input.

In sostanza, ad ogni token viene assegnato un vettore posizionale composto da valori sinusoidali e cosinusoidali alternati. Questi vettori corrispondono alla dimensionalità dei word embeddings in modo che entrambi possano essere sommati.

In particolare, il paper originale del Transformer utilizza le seguenti funzioni:

Funzioni di Positional Encoding (riprodotte da “Attention is all you need”, Vaswani et al. 2017)

I seguenti grafici ci consentono di comprendere meglio il funzionamento delle codifiche posizionali. Diamo un’occhiata alla prima riga del grafico superiore, possiamo vedere sequenze alternate di zeri e uno. Infatti, le righe rappresentano la posizione di un token nella sequenza (la variabile pos) mentre le colonne rappresentano la dimensione dell’embedding (la variabile i).

Quindi, quando pos=0, le equazioni precedenti restituiscono sin(0)=0 per le dimensioni di embedding pari e cos(0)=1 per le dimensioni dispari.

Inoltre, vediamo che le righe adiacenti condividono valori simili, mentre la prima e l’ultima riga sono molto diverse. Questa proprietà è utile per il modello per valutare la distanza tra le parole nella sequenza e il loro ordine.

Infine, il terzo grafico rappresenta la somma delle codifiche posizionali e degli embeddings, che è l’output del blocco di embedding.

Rappresentazione dei word embeddings e delle codifiche posizionali, con seq_len=16 e embed_dim=64 (realizzata dall’autore)

Utilizzando Haiku, definiamo il livello di embedding come segue. Come altri framework di deep learning, Haiku ci permette di definire moduli personalizzati (qui hk.Module) per memorizzare i parametri apprendibili e definire il comportamento dei componenti del nostro modello.

Ogni modulo Haiku deve avere una funzione __init__ e __call__. Qui, la funzione di chiamata calcola semplicemente gli embeddings utilizzando la funzione hk.Embed e le codifiche posizionali, prima di sommarli.

La funzione di codifica posizionale utilizza funzionalità JAX come vmap e lax.cond per migliorare le prestazioni. Se non sei familiare con queste funzioni, sentiti libero di consultare il mio articolo precedente in cui vengono presentate in modo più approfondito.

In sostanza, vmap ci consente di definire una funzione per un singolo campione e vettorizzarla in modo da poterla applicare a insiemi di dati. Il parametro in_axes viene utilizzato per specificare che vogliamo iterare sull’asse principale dell’input dim, che rappresenta la dimensione di embedding. D’altra parte, lax.cond è una versione compatibile con XLA di un’istruzione if/else in Python.

Self-attention e MultiHead-Attention

L’attenzione mira a calcolare l’importanza di ogni parola in una sequenza, relativamente a una parola di input. Ad esempio, nella frase:

“Il gatto nero è saltato sul divano, si è coricato e si è addormentato perché era stanco”.

La parola “it” potrebbe essere piuttosto ambigua per il modello, poiché tecnicamente potrebbe riferirsi sia a “gatto” che a “divano“. Un modello di attenzione ben addestrato sarebbe in grado di comprendere che “it” si riferisce a “gatto” e quindi assegnare i valori di attenzione al resto della frase di conseguenza.

Essenzialmente, i valori di attenzione potrebbero essere considerati come pesi che descrivono l’importanza di una certa parola dato il contesto dell’input. Ad esempio, il vettore di attenzione per la parola “saltato” avrebbe valori elevati per parole come “gatto” (cosa è saltato?), “sul“, e “divano” (dove è saltato?) poiché queste parole sono rilevanti per il suo contesto.

Rappresentazione visiva di un vettore di attenzione (realizzata dall'autore)

Nel paper Transformer, l’attenzione viene calcolata utilizzando l’Attenzione deI Prodotto Scalato. Che viene riassunta dalla formula:

Attenzione deI Prodotto Scalato (riprodotto da “Attention is all you need”, Vaswani et al. 2017)

Qui, Q, K e V indicano le Query, Chiavi e Valori. Queste matrici vengono ottenute moltiplicando i vettori dei pesi appresi WQ, WK e WV con gli embedding posizionali.

Questi nomi sono principalmente astrazioni utilizzate per aiutare a comprendere come le informazioni vengono elaborate e pesate nel blocco di attenzione. Sono un’allusione al vocabolario dei sistemi di recupero[2] (ad esempio cercare un video su YouTube per esempio).

Ecco una spiegazione intuitiva:

  • Query: possono essere interpretate come un “insieme di domande” su tutte le posizioni in una sequenza. Ad esempio, interrogare il contesto di una parola e cercare di identificare le parti più rilevanti della sequenza.
  • Chiavi: possono essere viste come detentrici di informazioni con le quali le query interagiscono, la compatibilità tra una query e una chiave determina quanto attenzione la query dovrebbe prestare al valore corrispondente.
  • Valori: abbinare chiavi e query ci consente di decidere quali chiavi sono rilevanti, i valori sono il contenuto effettivo abbinato alle chiavi.

Nella figura seguente, la query è una ricerca su YouTube, le chiavi sono le descrizioni e i metadati dei video, mentre i valori sono i video associati.

Rappresentazione intuitiva del concetto di Query, Chiavi, Valori (realizzata dall'autore)

Nel nostro caso, le query, le chiavi e i valori provengono dalla stessa fonte (poiché derivano dalle sequenze di input), da qui il nome di auto-attenzione.

Il calcolo dei punteggi di attenzione viene di solito eseguito più volte in parallelo, ogni volta con una frazione degli embedding. Questo meccanismo viene chiamato “Multi-Head Attention” e consente ad ogni testa di apprendere diverse rappresentazioni dei dati in parallelo, portando ad un modello più robusto.

Una singola testa di attenzione generalmente elabora array con forma (batch_size, seq_len, d_k) dove d_k può essere impostato come il rapporto tra il numero di teste e la dimensione degli embedding (d_k = n_heads/embed_dim). In questo modo, concatenando le uscite di ogni testa si ottiene comodamente un array con forma (batch_size, seq_len, embed_dim), come input.

Il calcolo delle matrici di attenzione può essere suddiviso in diversi passaggi:

  • Innanzitutto, definiamo i vettori dei pesi appresi WQ, WK e WV. Questi vettori hanno forme (n_heads, embed_dim, d_k).
  • In parallelo, moltiplichiamo gli embedding posizionali con i vettori dei pesi. Otteniamo le matrici Q, K e V con forme (batch_size, seq_len, d_k).
  • Poi scaliamo il prodotto-scalare tra Q e K (trasposta). Questo scaglionamento comporta la divisione del risultato del prodotto-scalare per la radice quadrata di d_k e l’applicazione della funzione softmax sulle righe delle matrici. Pertanto, i punteggi di attenzione per un token di input (ovvero una riga) si sommano a uno, ciò aiuta a evitare che i valori diventino troppo grandi e rallentino il calcolo. L’output ha forma (batch_size, seq_len, seq_len)
  • Infine, moltiplichiamo il risultato dell’operazione precedente con V, rendendo la forma dell’output (batch_size, seq_len, d_k).
Rappresentazione visiva delle operazioni di matrice all'interno di un blocco di attenzione (realizzata dall'autore)
  • Le uscite di ogni testa di attenzione possono quindi essere concatenate per formare una matrice con forma (batch_size, seq_len, embed_dim). Il documento Transformer aggiunge anche uno strato lineare alla fine del modulo di attenzione multi-testa, per aggregare e combinare le rappresentazioni apprese da tutte le teste di attenzione.
Concatenazione di matrici di attenzione multi-testa e strato lineare (realizzata dall'autore)

In Haiku, il modulo di attenzione multi-testa può essere implementato come segue. La funzione __call__ segue la stessa logica del grafico sopra, mentre i metodi di classe sfruttano le utilità JAX come vmap (per vettorizzare le operazioni su diverse teste di attenzione e matrici) e tree_map (per mappare i prodotti scalari tra matrici e vettori di peso).

Connessioni Residuali e Normalizzazione delle Layer

Come potresti aver notato nel grafico del Transformer, il blocco di attenzione multi-testa e la rete di feed-forward sono seguiti da connessioni residuali e normalizzazione delle layer.

Connessioni Residuali o Skip

Le connessioni residuali sono una soluzione standard per risolvere il problema del gradiente che svanisce, che si verifica quando i gradienti diventano troppo piccoli per aggiornare efficacemente i parametri del modello.

Dato che questo problema si presenta naturalmente nelle architetture particolarmente profonde, le connessioni residuali vengono utilizzate in una varietà di modelli complessi come ResNet (Kaiming et al, 2015) nella computer vision, AlphaZero (Silver et al, 2017) nel reinforcement learning, e naturalmente, i Transformers.

Nella pratica, le connessioni residuali semplicemente inoltrano l’output di uno specifico layer a uno successivo, saltando uno o più layer lungo il percorso. Ad esempio, la connessione residuale attorno all’attenzione multi-testa è equivalente alla somma dell’output dell’attenzione multi-testa con gli embedding posizionali.

Ciò consente ai gradienti di fluire più efficientemente attraverso l’architettura durante la retropropagazione e di solito può portare a una convergenza più veloce e ad un addestramento più stabile.

Rappresentazione delle connessioni residuali nei Transformers (realizzata dall'autore)

Normalizzazione delle Layer

La normalizzazione delle layer aiuta a garantire che i valori propagati attraverso il modello non si “espandano” (tendano all’infinito), cosa che potrebbe facilmente accadere nei blocchi di attenzione, dove diverse matrici vengono moltiplicate durante ogni passaggio in avanti.

A differenza della normalizzazione batch, che normalizza lungo la dimensione del batch ipotizzando una distribuzione uniforme, la normalizzazione delle layer opera sulle feature. Questo approccio è adatto per batch di frasi in cui ciascuna può avere distribuzioni uniche a causa di significati variabili e vocabolari diversi.

Normalizzando le feature, come gli embedding o i valori di attenzione, la normalizzazione delle layer standardizza i dati su una scala coerente senza confondere caratteristiche distinte delle frasi, mantenendo la distribuzione unica di ciascuna.

Rappresentazione della Normalizzazione dei Livelli nel contesto dei Transformers (realizzato dall'autore)

L’implementazione della normalizzazione dei livelli è piuttosto semplice: inizializziamo i parametri apprendibili alpha e beta e normalizziamo lungo l’asse delle caratteristiche desiderato.

Rete Feed-Forward a Larghezza Localizzata

Le ultime componenti dell’encoder che dobbiamo considerare sono la rete feed-forward a larghezza localizzata. Questa rete completamente connessa prende in input le uscite normalizzate del blocco di attenzione ed è utilizzata per introdurre non linearità e aumentare la capacità del modello di apprendere funzioni complesse.

È composta da due strati densi separati da una funzione di attivazione GELU:

Dopo questo blocco, abbiamo un’altra connessione residua e normalizzazione dei livelli completare l’encoder.

Conclusione

Ecco fatto! Ora dovresti essere familiare con i concetti principali dell’encoder Transformer. Ecco la classe completa dell’encoder, nota che in Haiku, assegniamo un nome a ciascuno degli strati in modo che i parametri apprendibili siano separati e facili da raggiungere. La funzione __call__ fornisce un buon riepilogo dei diversi passaggi del nostro encoder:

Per utilizzare questo modulo su dati effettivi, dobbiamo applicare hk.transform a una funzione che incapsula la classe dell’encoder. Infatti, potresti ricordare che JAX abbraccia il paradigma della programmazione funzionale, quindi Haiku segue gli stessi principi.

Definiamo una funzione che contiene un’istanza della classe dell’encoder e restituisce l’output di un passaggio in avanti. Applicando hk.transform otteniamo un oggetto trasformato che ha accesso a due funzioni: init e apply.

La prima ci consente di inizializzare il modulo con una chiave casuale e alcuni dati falsi (nota che qui stiamo passando un array di zeri con forma batch_size, seq_len), mentre la seconda ci consente di elaborare dati reali.

# Nota: le due seguenti sintassi sono equivalenti# 1: Usare transform come un decorator di [email protected] encoder(x):  ...  return model(x)  encoder.init(...)encoder.apply(...)# 2: Applicare transform separatamentedef encoder(x):  ...  return model(x)encoder_fn = hk.transform(encoder)encoder_fn.init(...)encoder_fn.apply(...)

Nel prossimo articolo, completeremo l’architettura del transformer aggiungendo un decodificatore, che riutilizza la maggior parte dei blocchi introdotti finora, e impareremo come allenare un modello su un compito specifico utilizzando Optax!

Grazie per aver letto fino a questo punto, se sei interessato a sperimentare con il codice, puoi trovarlo completamente commentato su GitHub, insieme a ulteriori dettagli e a un tutorial che utilizza un dataset di esempio.

GitHub – RPegoud/jab: Una raccolta di modelli di apprendimento profondo fondamentali implementati in JAX

Una raccolta di modelli di apprendimento profondo fondamentali implementati in JAX – GitHub – RPegoud/jab: Una…

github.com

Se desideri approfondire i Transformers, la sezione seguente contiene alcuni articoli che mi hanno aiutato nella stesura di questo articolo.

Arrivederci alla prossima volta 👋

Riferimenti e Approfondimenti:

[1] Attention is all you need (2017), Vaswani et al, Google

[2] Cosa sono esattamente le chiavi, le query e i valori nei meccanismi di attenzione? (2019) Stack Exchange

[3] L’Illustrated Transformer (2018), Jay Alammar

[4] Un’introduzione delicata alla codifica posizionale nei modelli di trasformatori (2023), Mehreen Saeed, Machine Learning Mastery

Crediti immagine