Apprendimento dei trasformatori Codice Prima Parte 2 – GPT da vicino e personalmente
Apprendimento dei trasformatori Codice Parte 2 - GPT in dettaglio
Scavare nei Transformer pre-addestrati generativi tramite nanoGPT
Benvenuti alla seconda parte del mio progetto, dove mi immergo nelle complessità dei modelli basati su Transformer e GPT utilizzando il dataset TinyStories e nanoGPT, tutti addestrati su un vecchio laptop per il gaming. Nella prima parte, ho preparato il dataset per l’input in un modello generativo a livello di carattere. Puoi trovare il link alla prima parte qui sotto.
Imparare il Codice dei Transformer – Prima Parte 1
Parte 1 di una nuova serie in cui mi impegno a imparare il codice dei Transformer usando nanoGPT
towardsdatascience.com
In questo articolo, mi propongo di analizzare il modello GPT, i suoi componenti e la sua implementazione in nanoGPT. Ho scelto nanoGPT per la sua semplice implementazione in Python di un modello GPT, che ha circa 300 righe di codice, e per il suo script di addestramento altrettanto comprensibile. Con le conoscenze di base necessarie, si potrebbe rapidamente comprendere i modelli GPT semplicemente leggendo il codice sorgente. Ad essere sincero, io non avevo questa comprensione quando ho esaminato per la prima volta il codice. Alcune parti del materiale mi sfuggono ancora. Tuttavia, spero che con tutto ciò che ho imparato, questa spiegazione possa fornire un punto di partenza per coloro che desiderano acquisire una comprensione intuitiva di come i modelli GPT funzionano internamente.
In preparazione per questo articolo, ho letto vari paper. Inizialmente, pensavo che leggere semplicemente il lavoro fondamentale “Attention is All You Need” sarebbe stato sufficiente per mettere la mia comprensione al passo. Questa era un’assunzione ingenua. Mentre è vero che questo paper ha introdotto il modello Transformer, sono stati successivi paper che lo hanno adattato per compiti più avanzati come la generazione di testo. “AIAYN” era solo un’introduzione a un argomento più ampio. Non scoraggiato, mi sono ricordato di un articolo su HackerNews che forniva una lista di lettura per comprendere appieno gli LLM. Dopo una rapida ricerca, ho trovato l’articolo qui. Non ho letto tutto in sequenza, ma intendo riprendere questa lista di lettura per continuare il mio percorso di apprendimento dopo aver completato questa serie.
- Come risolvere i problemi di dipendenza di Python con Anaconda su Windows
- Fornisci modelli di linguaggio di grandi dimensioni dal tuo computer con l’Inferenza di Generazione di Testo
- Le decisioni che preparano i team di dati per il successo
Detto questo, immergiamoci. Per comprendere i modelli GPT in dettaglio, dobbiamo iniziare con il Transformer. Il Transformer utilizza un meccanismo di self-attention noto come attenzione a punti prodotto-scalato. La seguente spiegazione è tratta da questo articolo illuminante sull’attenzione a punti prodotto-scalato, che consiglio per una comprensione più approfondita. Fondamentalmente, per ogni elemento di una sequenza di input (l’elemento i-esimo), vogliamo moltiplicare la sequenza di input per una media ponderata di tutti gli elementi nella sequenza con l’elemento i-esimo. Questi pesi vengono calcolati moltiplicando il prodotto scalare del vettore all’elemento i-esimo con l’intero vettore di input e poi applicando un softmax in modo che i pesi siano valori compresi tra 0 e 1. Nel paper originale “Attention is All You Need”, questi input sono chiamati query (l’intera sequenza), key (il vettore all’elemento i-esimo) e value (anche l’intera sequenza). I pesi passati al meccanismo di attenzione vengono inizializzati con valori casuali e appresi man mano che avvengono più passaggi all’interno di una rete neurale.
nanoGPT implementa l’attenzione a punti prodotto-scalato e la estende all’attenzione multi-head, ovvero più operazioni di attenzione che avvengono contemporaneamente. Lo implementa anche come un torch.nn.Module
, che gli consente di essere composto con altri strati di rete
import torchimport torch.nn as nnfrom torch.nn import functional as Fclass CausalSelfAttention(nn.Module): def __init__(self, config): super().__init__() assert config.n_embd % config.n_head == 0 # proiezioni key, query, value per tutte le heads, ma in un batch self.c_attn = nn.Linear(config.n_embd, 3 * config.n_embd, bias=config.bias) # proiezione di output self.c_proj = nn.Linear(config.n_embd, config.n_embd, bias=config.bias) # regolarizzazione self.attn_dropout = nn.Dropout(config.dropout) self.resid_dropout = nn.Dropout(config.dropout) self.n_head = config.n_head self.n_embd = config.n_embd self.dropout = config.dropout # Flash Attention fa andare il GPU brrrrr ma il supporto è solo in PyTorch >= 2.0 self.flash = hasattr(torch.nn.functional, 'scaled_dot_product_attention') if not self.flash: print("ATTENZIONE: utilizzo di attenzione lenta. Flash Attention richiede PyTorch >= 2.0") # maschera causale per garantire che l'attenzione venga applicata solo a sinistra nella sequenza di input self.register_buffer("bias", torch.tril(torch.ones(config.block_size, config.block_size)) .view(1, 1, config.block_size, config.block_size)) def forward(self, x): B, T, C = x.size() # dimensione del batch, lunghezza della sequenza, dimensionalità dell'embedding (n_embd) # calcola key, query, values per tutte le heads nel batch e sposta la head in avanti per essere la dimensione del batch q, k, v = self.c_attn(x).split(self.n_embd, dim=2) k = k.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs) q = q.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs) v = v.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs) # self-attention causale; Self-attend: (B, nh, T, hs) x (B, nh, hs, T) -> (B, nh, T, T) if self.flash: # attenzione efficiente utilizzando i kernel CUDA di Flash Attention y = torch.nn.functional.scaled_dot_product_attention(q, k, v, attn_mask=None, dropout_p=self.dropout if self.training else 0, is_causal=True) else: # implementazione manuale dell'attenzione att = (q @ k.transpose(-2, -1)) * (1.0 / math.sqrt(k.size(-1))) att = att.masked_fill(self.bias[:,:,:T,:T] == 0, float('-inf')) att = F.softmax(att, dim=-1) att = self.attn_dropout(att) y = att @ v # (B, nh, T, T) x (B, nh, T, hs) -> (B, nh, T, hs) y = y.transpose(1, 2).contiguous().view(B, T, C) # ri-assembla tutti gli output delle heads uno accanto all'altro # proiezione di output y = self.resid_dropout(self.c_proj(y)) return y
Analizziamo ulteriormente questo codice, partendo dal costruttore. Innanzitutto, verifichiamo che il numero di head di attenzione (n_heads
) divida uniformemente la dimensionalità dell’embedding (n_embed
). Questo è cruciale perché quando l’embedding viene diviso in sezioni per ogni head, vogliamo coprire tutto lo spazio dell’embedding senza lacune. Successivamente, inizializziamo due layer Lineari, c_att
e c_proj
: c_att
è il layer che contiene tutto il nostro spazio di lavoro per le matrici che compongono il calcolo dell’attenzione dot-product scalata, mentre c_proj
memorizza il risultato finale dei calcoli. La dimensione dell’embedding viene triplicata in c_att
perché dobbiamo includere spazio per i tre componenti principali dell’attenzione: query, key e value.
Abbiamo anche due dropout layer, attn_dropout
e resid_dropout
. I dropout layer annullano casualmente gli elementi della matrice di input in base a una data probabilità. Secondo la documentazione di PyTorch, ciò serve a ridurre l’overfitting del modello. Il valore in config.dropout
è la probabilità che un dato campione venga eliminato durante un dropout layer.
Concludiamo il costruttore verificando se l’utente ha accesso a PyTorch 2.0, che vanta una versione ottimizzata dell’attenzione dot-product scalata. Se disponibile, la classe la utilizza; altrimenti, impostiamo una maschera di bias. Questa maschera è un componente della funzionalità di mascheramento opzionale del meccanismo di attenzione. Il metodo torch.tril restituisce una matrice con la sua sezione triangolare superiore convertita in zeri. Quando combinata con il metodo torch.ones, genera efficacemente una maschera di 1 e 0 che il meccanismo di attenzione utilizza per produrre output previsti per un determinato input campionato.
Successivamente, ci immergiamo nel metodo forward
della classe, dove viene applicato l’algoritmo di attenzione. Inizialmente, determiniamo le dimensioni della nostra matrice di input e la dividiamo in tre dimensioni: dimensione del batch, dimensione del tempo (o numero di campioni), dimensione del corpus (o dimensione dell’embedding). nanoGPT utilizza un processo di apprendimento batched, che esploreremo in maggior dettaglio quando esamineremo il modello transformer che utilizza questo layer di attenzione. Per ora, è sufficiente capire che stiamo lavorando con i dati a batch. Successivamente, alimentiamo l’input x
nel layer di trasformazione lineare c_attn
che espande la dimensionalità da n_embed
a tre volte n_embed
. L’output di tale trasformazione viene diviso nelle nostre variabili q
, k
, v
che sono i nostri input per l’algoritmo di attenzione. Successivamente, viene utilizzato il metodo view
per riorganizzare i dati in ciascuna di queste variabili nel formato previsto dalla funzione scaled_dot_product_attention
di PyTorch.
Quando la funzione ottimizzata non è disponibile, il codice si basa su una implementazione manuale dell’attenzione dot-product scalata. Inizia prendendo il prodotto scalare delle matrici q
e k
, con k
trasposto per adattarsi alla funzione di prodotto scalare, e il risultato viene scalato per la radice quadrata della dimensione di k
. Quindi mascheriamo l’output scalato utilizzando il buffer di bias creato in precedenza, sostituendo gli 0 con l’infinito negativo. Successivamente, viene applicata una funzione softmax alla matrice att
, convertendo gli infiniti negativi di nuovo in 0 e garantendo che tutti gli altri valori siano scalati tra 0 e 1. Applichiamo quindi un dropout layer per evitare l’overfitting prima di ottenere il prodotto scalare della matrice att
e v
.
Indipendentemente dall’implementazione del prodotto scalare utilizzata, l’output multi-head viene riorganizzato affiancato prima di passare attraverso un ultimo dropout layer e quindi restituire il risultato. Questa è l’implementazione completa del layer di attenzione in meno di 50 righe di codice Python/PyTorch. Se non comprendi completamente il codice sopra, ti consiglio di dedicare del tempo a rivederlo prima di procedere con il resto dell’articolo.
Prima di immergerci nel modulo GPT, che integra tutto, sono necessari altri due blocchi di costruzione. Il primo è un semplice perceptron multi-livello (MLP) – chiamato nella pubblicazione “Attention is All You Need” come rete feed-forward – e il blocco di attenzione, che combina il layer di attenzione con un MLP per completare l’architettura di base del transformer rappresentata nella pubblicazione. Entrambi sono implementati nel seguente frammento di codice di nanoGPT.
class MLP(nn.Module): """ Perceptron Multi-Livello """ def __init__(self, config): super().__init__() self.c_fc = nn.Linear(config.n_embd, 4 * config.n_embd, bias=config.bias) self.gelu = nn.GELU() self.c_proj = nn.Linear(4 * config.n_embd, config.n_embd, bias=config.bias) self.dropout = nn.Dropout(config.dropout) def forward(self, x): x = self.c_fc(x) x = self.gelu(x) x = self.c_proj(x) x = self.dropout(x) return xclass Blocco(nn.Module): def __init__(self, config): super().__init__() self.ln_1 = LayerNorm(config.n_embd, bias=config.bias) self.attn = CausalSelfAttention(config) self.ln_2 = LayerNorm(config.n_embd, bias=config.bias) self.mlp = MLP(config) def forward(self, x): x = x + self.attn(self.ln_1(x)) x = x + self.mlp(self.ln_2(x)) return x
Il livello MLP, nonostante la sua apparente semplicità in termini di linee di codice, aggiunge un ulteriore livello di complessità al modello. Fondamentalmente, i livelli lineari collegano ciascun livello di input con ciascun elemento del livello di output, utilizzando una trasformazione lineare per trasferire i valori tra di essi. Nel codice sopra citato, partiamo con la dimensione dell’embedding, n_embed
, come numero di parametri prima di quadruplicarlo nell’output. La quadruplicazione è arbitraria; lo scopo del modulo MLP è quello di migliorare il calcolo della rete aggiungendo più nodi. A patto che l’aumento della dimensionalità all’inizio dell’MLP e la diminuzione alla fine dell’MLP siano equivalenti, producendo la stessa dimensione iniziale dell’input/fine dell’output, il numero di scalatura è semplicemente un altro iperparametro. Un altro elemento cruciale da considerare è la funzione di attivazione. Questa implementazione MLP consiste di due livelli lineari collegati alla funzione di attivazione GELU. Il documento originale utilizza la funzione ReLU, ma nanoGPT utilizza GELU per garantire la compatibilità con i checkpoint del modello GPT2.
Successivamente, esaminiamo il modulo Blocco. Questo modulo finalizza il nostro blocco trasformatore come descritto nel paper “Attention”. Fondamentalmente, instrada l’input attraverso un livello di normalizzazione prima di passarlo al livello di attenzione, quindi aggiunge il risultato all’input. L’output di questa addizione viene normalizzato ancora una volta prima di essere trasferito all’MLP e quindi aggiunto a se stesso. Questo processo implementa il lato decoder del trasformatore come descritto nel paper “Attention”. Per la generazione di testo, è comune utilizzare solo un decoder, poiché non è necessario condizionare l’output del decoder su nulla oltre alla sequenza di input. Il trasformatore è stato inizialmente progettato per la traduzione automatica, che deve tener conto sia dell’encoding del token di input che dell’encoding del token di output. Tuttavia, con la generazione di testo, viene utilizzato solo un singolo encoding del token, eliminando la necessità di una cross-attenzione tramite un encoder. Andrej Karpathy, l’autore di nanoGPT, fornisce una spiegazione esaustiva di questo nel suo video collegato al primo articolo di questa serie.
Infine, raggiungiamo il componente principale: il modello GPT. La maggior parte del file di circa 300 righe è dedicata al modulo GPT. Gestisce funzionalità utili come il fine-tuning del modello e utilità progettate per l’addestramento del modello (argomento del prossimo articolo di questa serie). Pertanto, presento una versione semplificata di ciò che è disponibile nel repository nanoGPT di seguito.
class GPT(nn.Module): def __init__(self, config): super().__init__() assert config.vocab_size is not None assert config.block_size is not None self.config = config self.transformer = nn.ModuleDict(dict( wte = nn.Embedding(config.vocab_size, config.n_embd), wpe = nn.Embedding(config.block_size, config.n_embd), drop = nn.Dropout(config.dropout), h = nn.ModuleList([Blocco(config) for _ in range(config.n_layer)]), ln_f = LayerNorm(config.n_embd, bias=config.bias), )) self.lm_head = nn.Linear(config.n_embd, config.vocab_size, bias=False) # con il weight tying, quando si utilizza torch.compile(), vengono generati alcuni avvisi: # "UserWarning: functional_call was passed multiple values for tied weights. # This behavior is deprecated and will be an error in future versions" # non sono sicuro al 100% di cosa sia, finora sembra innocuo. TODO indagare self.transformer.wte.weight = self.lm_head.weight # https://paperswithcode.com/method/weight-tying # inizializza tutti i pesi self.apply(self._init_weights) # applica l'inizializzazione speciale scalata alle proiezioni residue, secondo il paper GPT-2 for pn, p in self.named_parameters(): if pn.endswith('c_proj.weight'): torch.nn.init.normal_(p, mean=0.0, std=0.02/math.sqrt(2 * config.n_layer)) def _init_weights(self, module): if isinstance(module, nn.Linear): torch.nn.init.normal_(module.weight, mean=0.0, std=0.02) if module.bias is not None: torch.nn.init.zeros_(module.bias) elif isinstance(module, nn.Embedding): torch.nn.init.normal_(module.weight, mean=0.0, std=0.02) def forward(self, idx, targets=None): device = idx.device b, t = idx.size() assert t <= self.config.block_size, f"Impossibile instradare sequenze di lunghezza {t}, la dimensione del blocco è solo {self.config.block_size}" pos = torch.arange(0, t, dtype=torch.long, device=device) # forma (t) # inoltra il modello GPT stesso tok_emb = self.transformer.wte(idx) # embedding dei token di forma (b, t, n_embd) pos_emb = self.transformer.wpe(pos) # embedding di posizione di forma (t, n_embd) x = self.transformer.drop(tok_emb + pos_emb) for blocco in self.transformer.h: x = blocco(x) x = self.transformer.ln_f(x) if targets is not None: # se ci sono dei target desiderati, calcola anche la perdita logits = self.lm_head(x) loss = F.cross_entropy(logits.view(-1, logits.size(-1)), targets.view(-1), ignore_index=-1) else: # mini-ottimizzazione durante l'inferenza: instrada solo lm_head sull'ultimo passaggio logits = self.lm_head(x[:, [-1], :]) # nota: utilizzo la lista [-1] per preservare la dimensione temporale loss = None return logits, loss @torch.no_grad() def generate(self, idx, max_new_tokens, temperature=1.0, top_k=None): """ Prende una sequenza di indici idx (LongTensor di forma (b,t)) e completa la sequenza max_new_tokens volte, alimentando le previsioni nel modello ogni volta. Probabilmente vorrai assicurarti di essere in modalità model.eval() per questo. """ for _ in range(max_new_tokens): # se il contesto della sequenza sta diventando troppo lungo, dobbiamo ridurlo a block_size idx_cond = idx if idx.size(1) <= self.config.block_size else idx[:, -self.config.block_size:] # inoltra il modello per ottenere i logit per l'indice nella sequenza logits, _ = self(idx_cond) # seleziona i logit all'ultimo passaggio e scala con la temperatura desiderata logits = logits[:, -1, :] / temperature # opzionalmente riduci i logit solo alle migliori k opzioni if top_k is not None: v, _ = torch.topk(logits, min(top_k, logits.size(-1))) logits[logits < v[:, [-1]]] = -float('Inf') # applica softmax per convertire i logit in probabilità (normalizzate) probs = F.softmax(logits, dim=-1) # campiona dalla distribuzione idx_next = torch.multinomial(probs, num_samples=1) # aggiungi l'indice campionato alla sequenza in esecuzione e continua idx = torch.cat((idx, idx_next), dim=1) return idx
Iniziamo con il costruttore della classe. I diversi strati sono assemblati in un PyTorch ModuleDict, che fornisce una certa struttura. Partiamo con due strati di Embedding: uno per l’embedding del token e uno per l’embedding posizionale. Il modulo nn.Embedding
è progettato per essere sparsamente popolato di valori, ottimizzando le sue capacità di archiviazione rispetto ad altri moduli di strato. Successivamente, abbiamo uno strato di dropout, seguito da n_layer
moduli di blocco che formano i nostri strati di attenzione, e quindi un altro singolo strato di dropout. Lo strato lineare lm_head
prende l’output dai blocchi di attenzione e lo riduce alla dimensione del vocabolario, agendo come nostro principale output per il GPT, a parte il valore di perdita.
Una volta definiti gli strati, è necessaria una configurazione aggiuntiva prima di poter iniziare ad addestrare il modulo. Qui, Andrej collega i pesi dell’encoding posizionale a quelli dello strato di output. Secondo il paper collegato nei commenti del codice, ciò viene fatto per ridurre i parametri finali del modello migliorandone anche le prestazioni. Il costruttore inizializza anche i pesi del modello. Poiché questi pesi verranno appresi durante l’addestramento, vengono inizializzati con una distribuzione gaussiana di numeri casuali e i bias del modulo vengono impostati a 0. Infine, viene utilizzata una modifica del paper GPT-2 in cui i pesi di eventuali strati residui vengono scalati per la radice quadrata del numero di strati.
Quando si alimenta in avanti la rete, la dimensione del batch e il numero di campioni (qui t
) vengono estratti dalla dimensione dell’input. Successivamente, viene creata una memoria sul dispositivo di addestramento per ciò che diventerà l’embedding posizionale. Successivamente, i token di input vengono incorporati in un layer di embedding del token wte
. Successivamente, l’embedding posizionale viene calcolato sul layer wpe
. Questi embedding vengono sommati insieme prima di essere passati attraverso uno strato di dropout. Il risultato viene quindi passato attraverso ciascuno dei blocchi n_layer
e normalizzato. Il risultato finale viene passato allo strato lineare lm_head
che riduce i pesi incorporati in un punteggio di probabilità per ogni token in un vocabolario.
Quando viene calcolata una perdita (ad esempio, durante l’addestramento), calcoliamo la differenza tra il token previsto e il token effettivo utilizzando la cross-entropy. Se non è così, la perdita è None
. Sia la perdita che le probabilità dei token vengono restituite come parte della funzione di avanzamento del feed.
A differenza dei moduli precedenti, il modulo GPT ha metodi aggiuntivi. Il più rilevante per noi è la funzione di generazione, che sarà familiare a chiunque abbia già utilizzato un modello generativo. Dato un insieme di token di input idx
, un numero di max_new_tokens
e una temperature
, genera max_new_tokens
molti token. Approfondiamo come ciò viene realizzato. Innanzitutto, i token di input vengono ridotti per adattarsi alla block_size
(altri chiamano questa lunghezza del contesto), se necessario, campionando dalla fine dell’input per primi. Successivamente, i token vengono alimentati alla rete e l’output viene scalato per la temperature
inserita. Più alta è la temperatura, più creativo e incline all’allucinazione è il modello. Temperature più alte comportano anche un output meno prevedibile. Successivamente, viene applicato un softmax per convertire i pesi di output del modello in probabilità comprese tra 0 e 1. Viene utilizzata una funzione di campionamento per selezionare il prossimo token dalle probabilità, e quel token viene aggiunto al vettore di input che viene alimentato nuovamente nel modello GPT per il carattere successivo.
Grazie per la pazienza nel leggere questo articolo esaustivo. Mentre esaminare il codice sorgente annotato è un metodo prezioso per comprendere la funzione di un segmento di codice, non c’è sostituto per manipolare personalmente diverse parti e parametri del codice. In linea con questo, sto fornendo un link al codice sorgente completo model.py
del repository nanoGPT
nanoGPT/model.py at master · karpathy/nanoGPT
Il repository più semplice e veloce per addestrare/affinare GPT di dimensioni VoAGI. – nanoGPT/model.py at master ·…
github.com
Nell’articolo successivo, esploreremo lo script train.py
di nanoGPT e addestreremo un modello a livello di carattere sul dataset TinyStories. Seguimi su VoAGI per assicurarti di non perderti nulla!
Ho utilizzato una vasta gamma di risorse per creare questo articolo, molte delle quali sono già state linkate in questo e nel precedente articolo. Tuttavia, sarei negligente se non condividessi queste risorse con voi per ulteriori approfondimenti su qualsiasi argomento o per spiegazioni alternative dei concetti.
- Creiamo GPT: da zero, in codice, spiegato a parole — YouTube
- Elenco di lettura LLM — Blog
- “Attention is All You Need” — Articolo
- “Language Models are Unsupervised Multitask Learners” — Articolo su GPT-2
- Perceptron a più strati spiegato e illustrato — VoAGI
- Collegamento dei pesi — Papers With Code
- Guida illustrata alla rete neurale dei trasformatori: una spiegazione passo passo — YouTube
Modificato utilizzando GPT-4 e uno script personalizzato di LangChain.