Apprendimento dei Transformers Parte 1 – La configurazione

'Apprendimento dei Transformers Parte 1 - Configurazione'

Una Esplorazione in 4 Parti dei Transformers Utilizzando nanoGPT come Punto di Partenza

Foto di Josh Riemer su Unsplash

Non so voi, ma a volte è più facile guardare il codice che leggere degli articoli. Quando stavo lavorando su AdventureGPT, ho iniziato leggendo il codice sorgente di BabyAGI, un’implementazione del paper ReAct di circa 600 righe di Python.

Recentemente, ho scoperto un paper chiamato TinyStories attraverso l’episodio 33 dell’eccellente podcast Cognitive Revolution. TinyStories cerca di dimostrare che i modelli addestrati su milioni (non miliardi) di parametri possono essere efficaci con dati di qualità sufficientemente alta. Nel caso dei ricercatori di Microsoft nel paper, hanno utilizzato dati sintetici generati da GPT-3.5 e GPT-4 che avrebbero richiesto circa $10k per essere generati al dettaglio. Il dataset e i modelli sono disponibili nel repository HuggingFace dell’autore.

Ero affascinato dal fatto che un modello potesse essere addestrato su 30M o meno di parametri. Per riferimento, sto eseguendo tutto il mio addestramento e inferenza dei modelli su un laptop Lenovo Legion 5 con una GTX 1660 Ti. Anche solo per l’inferenza, la maggior parte dei modelli con oltre 3 miliardi di parametri è troppo grande per essere eseguita sulla mia macchina. So che ci sono risorse di calcolo cloud disponibili a pagamento, ma sto imparando tutto ciò nel mio tempo libero e posso permettermi solo la modesta fattura OpenAI che accumulo tramite le chiamate API. Pertanto, l’idea che ci fossero modelli che potevo addestrare sul mio modesto hardware mi ha entusiasmato all’istante.

Ho iniziato a leggere il paper TinyStories e presto mi sono reso conto che hanno utilizzato il modello GPT Neo, ormai defunto, nel loro addestramento del modello. Ho iniziato ad esaminare il codice per cercare di capirlo e mi sono reso conto che avevo bisogno di qualcosa di ancora più piccolo per iniziare. Per contestualizzare, sono principalmente un ingegnere software di backend con una sufficiente esperienza di machine learning per non perdermi completamente quando sento parlare di reti neurali. Non sono nemmeno lontanamente un vero ingegnere di ML e questo mi ha spinto a digitare “gpt da zero” nel mio motore di ricerca preferito per trovare un’introduzione più semplice. Ho trovato il video qui sotto e tutto è cambiato.

Questo era quello che stavo cercando. Oltre al repository di base collegato nel video, c’è una versione più elaborata chiamata nanoGPT che è ancora in fase di sviluppo attivo. Inoltre, il codice di addestramento e il codice del modello sono circa 300 righe di Python ciascuno. Per me, questo era ancora più eccitante del video. Ho chiuso il video e ho iniziato a esaminare il codice sorgente. nanoGPT utilizza PyTorch, che non ho mai usato prima. Presenta anche abbastanza matematica e gergo di machine learning da farmi sentire un po’ ansioso come principiante. Questo sarebbe stato un compito più impegnativo di quanto mi aspettassi.

Uno dei modi migliori per capire qualcosa è scriverne. Pertanto, ho intenzione di analizzare il codice nel repository di nanoGPT, leggere il famoso paper “Attention is All You Need” e imparare i transformers in modo pratico, partendo dalle basi. Tutto quello che imparerò lungo il percorso spero di scriverlo in questa serie. Se vuoi seguirmi, clona il repository di nanoGPT sul tuo computer (il modello può anche essere addestrato sulla CPU, quindi non ci sono scuse di hardware) e seguimi.

La prima cosa che ho fatto dopo aver clonato il repository è seguire le istruzioni del README per addestrare il modello più semplice, il modello di generazione a livello di carattere utilizzando il dataset tiny_shakespeare. C’è uno script per preparare il dataset per l’addestramento, uno script per eseguire l’addestramento effettivo e uno script di campionamento per generare del testo. Con alcuni comandi nel terminale e più di un’ora di addestramento, ho ottenuto un modello semplice per generare del testo che sembra shakespeariano.

Seguire le istruzioni è tutto bello e buono, ma non capisco realmente qualcosa finché non la modifico per adattarla al mio caso d’uso. Il mio obiettivo qui era addestrare un modello simile a livello di carattere utilizzando il dataset TinyStories. Questo richiedeva la creazione di uno script di preparazione dei dati personalizzato per preparare il dataset all’addestramento. Approfondiamolo ulteriormente.

nanoGPT ha due tipi di script di preparazione dei dati: uno per i modelli in stile GPT-2 e uno per i modelli a livello di carattere. Ho preso parte del codice dai modelli GPT-2 per il download dai repository di HuggingFace e ho preso tutto il resto dallo script di livello di carattere di tiny_shakespeare. Un punto importante qui, tiny_shakespeare ha poco più di 1MB e contiene solo 40k righe di Shakespeare. TinyStories occupa oltre 3GB compressi e contiene 39,7M di storie. I metodi per tokenizzare e suddividere tiny_shakespeare non erano direttamente trasferibili, almeno non con i 32GB di RAM che ha il mio laptop. Ho fatto crashare la mia macchina più volte cercando metodi pythonici e di facile lettura per preparare TinyStories. Lo script finale utilizza alcuni trucchi che dettaglierò di seguito.

Prima di tutto, la mia soluzione preferita per elaborare elenchi di dati è la list comprehension, una sintassi per generare nuovi elenchi da elenchi esistenti con modifiche. Il problema con la list comprehension in questo caso è che i 3 GB di testo compresso diventano circa 10 GB in RAM. Ora, la list comprehension richiede copie multiple dell’elenco in RAM. Non è un problema per dati piccoli, ma non fattibile per TinyStories.

Le uscite degli script di preparazione dei dati sono un array compresso di NumPy di codifica a livello di carattere per i dati di addestramento e di convalida più un metadata pickle che include l’elenco completo dei caratteri unici e le mappe di codifica/decodifica per convertire questi caratteri in numeri. Utilizzando questo come riferimento, non abbiamo bisogno di altro oltre all’array finale codificato di numeri una volta che i caratteri unici sono stati trovati e mappati in numeri. Il modo migliore per farlo in modo efficiente in termini di memoria è iterare attraverso i dati con un semplice ciclo for mentre si costruiscono queste uscite pezzo per pezzo. Per fare ciò, si inizializza una variabile iniziale prima del ciclo che viene poi aggiornata ad ogni iterazione. Questo evita che vengano mantenute in RAM versioni multiple dell’insieme di dati e restituisce solo ciò di cui abbiamo bisogno. Il codice finale per la generazione del vocabolario è il seguente:

chars_dataset = set([])len_dataset = 0# ottenere tutti i caratteri unici che si verificano in questo testo, nonché la lunghezza totale per i dati di addestramentodesc = "Enumerate characters in training set"for story in tqdm(dataset['train']['text'], desc):    chars = list(set(story))    for char in chars:        chars_dataset.add(char)        len_dataset += len(story)

Detto questo, un array di 30,7 milioni di storie (oltre 4 miliardi di caratteri) codificato come numeri occupa comunque una quantità non trascurabile di RAM perché Python memorizza gli interi in modo dinamico. Entra in gioco NumPy, che ha una modalità di archiviazione degli array molto più efficiente in cui è possibile specificare la dimensione esatta degli interi. Oltre alla memorizzazione efficiente, NumPy dispone anche di una concatenazione di array efficiente in termini di memoria che può essere utilizzata per costruire l’array codificato finale in modo iterativo anziché tutto in una volta.

Il mio tocco finale allo script è stato aggiungere una barra di avanzamento utilizzando tqdm per ogni passaggio e finalmente ero pronto per eseguire lo script. Quindi, l’ho eseguito durante la notte e sono tornato la mattina. Quando sono tornato, lo script era ancora in esecuzione, con oltre 100 ore di tempo di calcolo stimato rimanente.

È stato in quel momento che mi è davvero colpito: 30,7 milioni di storie sono poche per un modello linguistico, ma non sono affatto un dataset giocattolo da elaborare su un singolo thread. Era il momento di chiamare i rinforzi: la parallelizzazione. La parallelizzazione porta con sé molta complessità e overhead, ma i guadagni in termini di prestazioni valevano lo scambio. Fortunatamente, ci sono diversi modi per parallelizzare il codice Python. Molte di queste soluzioni richiedono grandi modifiche a uno script eseguito in modo seriale o astrazioni complicate. Dopo un po’ di ricerca, ho trovato qualcosa che mi ha permesso di mantenere gran parte del mio script uguale, ma di eseguire comunque più processi per sfruttare tutti i miei thread.

Ray è una libreria per parallelizzare facilmente metodi in Python e può essere eseguita facilmente in locale o come un cluster. Si occupa di eseguire attività in una coda e di avviare processi worker per svolgere tali attività. C’è una guida eccellente a ray di seguito se questo ha stimolato la tua curiosità.

Modern Parallel and Distributed Python: A Quick Tutorial on Ray

Ray è un progetto open source per il parallelismo e la distribuzione di Python.

towardsdatascience.com

Quando si è trattato di scegliere cosa parallelizzare, la funzione di codifica sembrava un buon candidato. Ha input e output chiari, nessun effetto collaterale su questi input ed era facilmente una delle parti più grandi del tempo di calcolo. Adattare il codice esistente per lavorare con ray non avrebbe potuto essere più semplice: la funzione diventa accessibile a ray tramite un decoratore, la chiamata funzionale cambia leggermente per aggiungere un attributo remoto e c’è una funzione per avviare l’esecuzione di tutti i dati. Di seguito è riportato un esempio di come appariva inizialmente nel mio codice sorgente:

import rayray.init()…# dato tutti i caratteri unici all'interno di un insieme di dati, # creare una mappatura unica di caratteri in interistoi = { ch:i for i,ch in enumerate(chars_dataset) }@ray.remotedef encode(s):    return [stoi[c] for c in s]…encoded_stories = []for story in dataset[‘train’][‘text’]:    encoded_stories.append(encode.remote(story))ray.get(encoded_stories)…

Armato di tutta la potenza della mia CPU, ho proseguito solo per bloccare immediatamente il mio laptop. Con la pila di chiamate distribuite localmente utilizzata da ray, l’intero dataset era in memoria diverse volte. L’inserimento semplicemente dell’intero dataset in coda causava un errore di memoria esaurita. Annoiato, ho usato questa scusa per comprare più RAM (64 GB eccoci qua!), ma ho continuato a modificare il codice mentre la RAM veniva spedita.

Il passo successivo è stato quello di raggruppare le richieste gestite da Ray in qualcosa che potesse stare all’interno di una quantità ragionevole di memoria. L’aggiunta della logica di raggruppamento è stata abbastanza semplice ed è presente nel codice finale che linkerò alla fine dell’articolo. Quello che è diventato interessante è stato sperimentare con la dimensione del raggruppamento. Inizialmente, ho scelto una dimensione del raggruppamento casuale (5000) ed è iniziato bene, ma è diventato evidente che una quantità significativa di tempo veniva speso nel codice single-threaded durante ogni raggruppamento.

Essenzialmente, guardando il mio monitor di sistema preferito, ho visto che un singolo core rimaneva impegnato per diversi minuti prima che infine tutti i core del mio laptop si accendessero per qualche secondo prima di tornare a utilizzare solo un singolo core. Questo mi ha spinto a giocare un po’ con la dimensione del raggruppamento, sperando di alimentare più velocemente i core della CPU affamati e mantenerli impegnati più a lungo. Ridurre la dimensione del raggruppamento non ha aiutato perché c’era così tanto codice sincrono in ciascun raggruppamento utilizzato per suddividere e preparare un raggruppamento dal set di dati completo. Quel codice non poteva essere parallelizzato, quindi significava che ogni raggruppamento aveva un costo di avvio elevato in termini di tempo per generare il chunk. Ciò mi ha spinto a provare l’opposto, aumentando la dimensione del chunk per mantenere i core più impegnati per un periodo più lungo. Questo ha funzionato, poiché la generazione del chunk richiedeva lo stesso tempo indipendentemente dalla dimensione del chunk, ma ogni chunk elaborava più dati. Combinando ciò con lo spostamento del mio post-processing di codifica nelle funzioni di Ray, sono riuscito ad elaborare il 30% del set di dati di training in poche ore, tutto su un singolo laptop.

Infine, dopo qualche ora in più, ho avuto un dataset completamente preparato e personalizzato da alimentare al modello a livello di carattere. Ero contento di non dover ricorrere all’utilizzo di costose risorse di calcolo cloud per elaborare il set di training, che era la mia prossima mossa se l’aumento della RAM non avesse funzionato. Inoltre, ho imparato intimamente cosa significa creare/elaborare un dataset per un modello a livello di carattere.

Nell’articolo successivo di questa serie, esaminerò il codice del modello effettivo, spiegando nel modo migliore possibile e fornendo link a numerose risorse esterne per fornire informazioni aggiuntive dove le mie conoscenze sono limitate. Una volta scritto l’articolo, tornerò indietro e fornirò un link qui. Nel frattempo, ho linkato la versione finale del mio script di preparazione del dataset di seguito in modo da poterlo seguire e vedere cosa serve per elaborare un dataset piuttosto grande su una piattaforma di calcolo limitata.

nanoGPT/data/tinystories_char/prepare.py at master · oaguy1/nanoGPT

Il repository più semplice e veloce per l’addestramento/raffinamento di GPT di dimensioni VoAGI. – nanoGPT/data/tinystories_char/prepare.py…

github.com