Implementare LoRA da zero

Guida dettagliata per l'implementazione di LoRA da zero

Come implementare LoRA da zero e alcuni consigli pratici

Rappresentazione artistica astratta di LoRA, creata da DALLE

In questo post, ti mostrerò come implementare LoRA da zero.

LoRA, acronimo di Low-Rank Adaptation o Low-Rank Adaptors, offre un metodo efficiente e leggero per il fine-tuning di modelli di linguaggio preesistenti. Ciò include modelli di lingua con maschera come BERT e RoBERTa, nonché modelli causali (o chatbot) come GPT, Llama e Mistral.

Uno dei principali vantaggi degli adattatori a rango ridotto è la loro efficienza. Utilizzando meno parametri, LoRA riduce significativamente la complessità computazionale e l’utilizzo di memoria. Ciò ci consente di addestrare modelli di grandi dimensioni su GPU di consumo e di distribuire senza sforzo i nostri LoRA compatti (in termini di megabyte) ad altri.

Inoltre, i LoRA possono migliorare le prestazioni di generalizzazione. Limitando la complessità del modello, contribuiscono a prevenire l’overfitting, specialmente in scenari con dati di addestramento limitati. Ciò si traduce in modelli più resilienti che eccellono con dati nuovi e non visti, o che almeno mantengono le conoscenze dei loro compiti di addestramento iniziali.

Inoltre, gli adattatori a rango ridotto possono essere integrati senza soluzione di continuità nelle architetture di rete neurale esistenti. Questa integrazione consente un fine-tuning e un’adattamento dei modelli preaddestrati con un costo di addestramento aggiuntivo minimo, rendendoli molto adatti per le applicazioni di trasferimento di apprendimento.

Inizieremo approfondendo come funziona LoRA, poi ti mostrerò come svilupparlo da zero per un modello RoBERTa, seguito dalla valutazione della nostra implementazione utilizzando i benchmark GLUE e SQuAD insieme a una discussione su consigli generali e miglioramenti.

Come funziona LoRA

L’idea di base di LoRA è mantenere le matrici preaaddestrate (cioè i parametri del modello originale) congelate (cioè in uno stato fisso) e aggiungere solo una piccola delta alla matrice originale, che ha meno parametri rispetto alla matrice originale.

Ad esempio, consideriamo la matrice W, che potrebbe essere sia i parametri di uno strato completamente connesso che una delle matrici del meccanismo di auto-attenzione di un trasformatore:

Ovviamente, se W-orig avesse dimensioni n×m e inizializzassimo semplicemente una nuova matrice delta con le stesse dimensioni per il fine-tuning, non avremmo ottenuto nulla; anzi avremmo raddoppiato i parametri.

Il trucco è rendere ΔW meno “dimensionale” rispetto alla matrice originale, costruendola mediante una moltiplicazione tra matrici a partire da matrici di dimensione inferiore B e A.

Dove definiamo prima un rango r, che sia significativamente più piccolo delle dimensioni di base della matrice r≪n e r≪m. Quindi la matrice B è n×r e la matrice A è r×m. Moltiplicandole otteniamo una matrice con le stesse dimensioni di W, ma costruita con un numero molto inferiore di parametri.

Ovviamente vogliamo che la nostra delta sia zero all’inizio dell’addestramento, in modo che il fine-tuning inizi proprio come il modello originale. Pertanto, B viene spesso inizializzato come tutto zero e A viene inizializzato con valori casuali (di solito distribuiti normalmente).

Ad esempio, potrebbe apparire così:

Una figura con un esempio di come potrebbe apparire LoRA per una matrice effettiva

Immagina una situazione in cui la nostra dimensione di base è 1024 e abbiamo scelto un rango LoRA di 4 allora:

  • W ha 1024 * 1024 ≈ 1 milione di parametri
  • A & B hanno r * 1024 = 4 * 1024 ≈ 4k parametri ciascuno, per un totale di 8k
  • Quindi dobbiamo allenare solo lo 0.8% dei parametri per aggiornare la nostra matrice con LoRA

A proposito, nel paper di LoRA pesano la matrice delta con un parametro alpha:

Se imposti α sul primo r di cui fai l’esperimento e affini il tasso di apprendimento, in generale puoi modificare successivamente il parametro r senza dover affinare nuovamente il tasso di apprendimento (almeno approssimativamente). Sebbene possiamo trascurare questo dettaglio nella nostra implementazione, è una caratteristica comune in molte altre librerie LoRA, come PEFT di Hugging Face.

Implementare LoRA

Nella nostra implementazione vogliamo rispettare da vicino il paper originale di LoRA. Hanno testato quali matrici di un trasformatore è necessario sostituire. Hanno scoperto che, confrontando diverse strategie su un compito di fine-tuning di GPT-3, era sufficiente adattare solo i vettori di query e valore del meccanismo di auto-attenzione.

Da notare che molte persone ignorano questa valutazione oggi e consentono che ogni matrice venga adattata, indipendentemente dal compito o dal modello (vedi il paper QLoRA).

La nostra implementazione qui verrà fatta in PyTorch, ma dovrebbe essere facilmente adattabile a diversi frameworks.

Per questo articolo, ho semplificato un po’ il codice, in modo che sia più facile da leggere, pur mostrando gli elementi essenziali. Il codice completo e alcuni pesi LoRA addestrati possono essere trovati qui: https://github.com/Montinger/Transformer-Workbench.

Riimplementazione del modello di auto-attenzione

Il modello che desideriamo adattare è il modello RoBERTa di Huggingface. Il modo più semplice è semplicemente riavvolgere il meccanismo di auto-attenzione originale RobertaSelfAttention. La nuova classe LoraRobertaSelfAttention inizializzerà quindi le matrici LoRA. Tutte le matrici B saranno inizializzate con zeri e tutte le matrici A con numeri casuali da una distribuzione normale.

class LoraRobertaSelfAttention(RobertaSelfAttention):    """    Estende RobertaSelfAttention con matrici LoRA (Low-Rank Adaptation).    LoRA migliora l'efficienza aggiornando solo le matrici di query e valore.    Questa classe aggiunge le matrici LoRA e applica la logica LoRA nel metodo forward.    Parametri:    - r (int): Rango per le matrici LoRA.    - config: Configurazione del modello Roberta.    """    def __init__(self, r=8, *args, **kwargs):        super().__init__(*args, **kwargs)        d = self.all_head_size        # Inizializza matrici LoRA per la query e il valore        self.lora_query_matrix_B = nn.Parameter(torch.zeros(d, r))        self.lora_query_matrix_A = nn.Parameter(torch.randn(r, d))        self.lora_value_matrix_B = nn.Parameter(torch.zeros(d, r))        self.lora_value_matrix_A = nn.Parameter(torch.randn(r, d))

Dato queste matrici, ora definiamo i nuovi metodi di classe lora_query e lora_value. Questi calcolano la matrice ΔW, ovvero BA, e la aggiungono alla matrice originale, che viene chiamata dai metodi originali query e value.

class LoraRobertaSelfAttention(RobertaSelfAttention):    # ...    def lora_query(self, x):        """        Applica LoRA alla componente di query. Calcola una query modificata aggiungendo        l'adattamento LoRA all'output di query standard. Richiede il congelamento del        livello lineare regolare prima dell'addestramento.        """        lora_query_weights = torch.matmul(self.lora_query_matrix_B, self.lora_query_matrix_A)        return self.query(x) + F.linear(x, lora_query_weights)    def lora_value(self, x):        """        Applica LoRA al componente di valore. Calcola un output di valore modificato        aggiungendo l'adattamento LoRA all'output di valore standard. Richiede il        congelamento del livello lineare regolare prima dell'addestramento.        """        lora_value_weights = torch.matmul(self.lora_value_matrix_B, self.lora_value_matrix_A)        return self.value(x) + F.linear(x, lora_value_weights)

Ora la parte brutta: per utilizzare i metodi dobbiamo sovrascrivere la funzione di inoltro originale di RobertaSelfAttention. Anche se è un po’ cablato (vedi la discussione sulle migliorie in seguito), è piuttosto semplice. Prima di tutto, copiamo il codice di inoltro originale da https://github.com/huggingface/transformers/blob/main/src/transformers/models/roberta/modeling_roberta.py. In secondo luogo, sostituiamo ogni chiamata a query con lora_query e ogni chiamata a value con lora_value. La funzione sarà quindi così:

class LoraRobertaSelfAttention(RobertaSelfAttention):    # ...    def forward(self, hidden_states, *args, **kwargs):        """Copiato da https://github.com/huggingface/transformers/blob/main/src/transformers/models/roberta/modeling_roberta.py        ma con le chiamate a query e value sostituite con chiamate alle       funzioni lora_query e lora_value.        Forniamo solo uno schema di come adattare questo qui.        Cambia ogni chiamata a self.value e self.query nella versione attuale.        """        # codice originale per query:        ## mixed_query_layer = self.query(hidden_states)        # query aggiornata per LoRA:        mixed_query_layer = self.lora_query(hidden_states)        # La chiave non ha LoRA, quindi lasciamo queste chiamate invariate        key_layer = self.transpose_for_scores(self.key(hidden_states))        # codice originale per value:        ## value_layer = self.transpose_for_scores(self.value(hidden_states))        # value aggiornato per LoRA:        value_layer = self.transpose_for_scores(self.lora_value(hidden_states))                # ... (resto del codice di inoltro, invariato)

Ecco, ecco la nostra implementazione della nostra auto-attenzione LoRA. Ora l’unico compito che rimane è sostituire i moduli di attenzione nel modello RoBERTa originale.

Sostituzione dei moduli

Ok, fantastico, abbiamo sostituito l’auto-attenzione con la nostra implementazione; ma come inseriamo questa nuova classe nel vecchio modello RoBERTa? Fondamentalmente dobbiamo iterare su ciascun componente nominato del modello RoBERTa, verificare se è della classe RobertaSelfAttention e, se sì, sostituirlo con LoraRobertaSelfAttention, assicurandoci che le matrici dei pesi originali siano mantenute.

Per ottenere questo scriveremo una nuova funzione wrapper che può fare questa sostituzione. Inoltre, vogliamo anche aggiungere la funzionalità per il fine-tuning del modello RoBERTa su alcuni compiti effettivi in seguito

class LoraWrapperRoberta(nn.Module):    def __init__(self, task_type, num_classes=None, dropout_rate=0.1, model_id="roberta-large",                 lora_rank=8, train_biases=True, train_embedding=False, train_layer_norms=True):        """        Un wrapper per RoBERTa con Low-Rank Adaptation (LoRA) per vari compiti di NLP.        - task_type: Tipo di compito di NLP ('glue', 'squad_v1', 'squad_v2').        - num_classes: Numero di classi per la classificazione (varia con il compito).        - dropout_rate: Tasso di dropout nel modello.        - model_id: ID del modello pre-addestrato RoBERTa.        - lora_rank: Rango per l'adattamento LoRA.        - train_biases, train_embedding, train_layer_norms:             Flag se mantenere certi parametri addestrabili             dopo l'inizializzazione di LoRA.                Esempio:            model = LoraWrapperRoberta(task_type='glue')        """        super().__init__()        # 1. Inizializza il modello di base con i parametri        self.model_id = model_id        self.tokenizer = RobertaTokenizer.from_pretrained(model_id)        self.model = RobertaModel.from_pretrained(model_id)        self.model_config = self.model.config        # 2. Aggiungi il layer per i compiti di benchmark        d_model = self.model_config.hidden_size        self.finetune_head_norm = nn.LayerNorm(d_model)        self.finetune_head_dropout = nn.Dropout(dropout_rate)        self.finetune_head_classifier = nn.Linear(d_model, num_classes)        # 3. Configura il modello LoRA per l'addestramento        self.replace_multihead_attention()        self.freeze_parameters_except_lora_and_bias()

Come puoi vedere, chiamiamo due metodi di supporto nell’inizializzazione:

  1. self.replace_multihead_attention: Questo metodo sostituisce l’attenzione di tutte le parti della rete neurale con la nostra LoraRobertaSelfAttention precedentemente scritta.
  2. self.freeze_parameters_except_lora_and_bias: Questo metodo congela tutti i parametri principali per l’addestramento, in modo che i gradienti e i passaggi di ottimizzazione vengano applicati solo ai parametri LoRA e agli altri parametri di bias e layer norm che vogliamo mantenere addestrabili.
class LoraWrapperRoberta(nn.Module):    # ...    def replace_multihead_attention_recursion(self, model):        """        Sostituisce RobertaSelfAttention con LoraRobertaSelfAttention nel modello.        Questo metodo applica la sostituzione in modo ricorsivo a tutti i sottocomponenti.        Parametri        ----------        model : nn.Module            Il modulo o modello PyTorch da modificare.        """        for name, module in model.named_children():            if isinstance(module, RobertaSelfAttention):                # Sostituisci RobertaSelfAttention con LoraRobertaSelfAttention                new_layer = LoraRobertaSelfAttention(r=self.lora_rank, config=self.model_config)                new_layer.load_state_dict(module.state_dict(), strict=False)                setattr(model, name, new_layer)            else:                # Chiamata ricorsiva per i moduli figli                self.replace_multihead_attention_recursion(module)

Dobbiamo scorrere in modo ricorsivo tutte le parti del modello, poiché in PyTorch le parti della rete possono (e infatti sono per RoBERTa) essere raggruppate in un modulo PyTorch separato.

Ora dobbiamo congelare tutti i parametri che non vogliamo più addestrare:

class LoraWrapperRoberta(nn.Module):    # ...    def freeze_parameters_except_lora_and_bias(self):        """        Congela tutti i parametri del modello tranne specifici strati e tipi in base alla configurazione.        I parametri negli strati LoRA, nell'head di fine-tuning, nei parametri di bias, negli embedding e nei layer norm         possono essere impostati come addestrabili in base alle impostazioni della classe.        """        for name, param in self.model.named_parameters():            is_trainable = (                "lora_" in name or                "finetune_head_" in name or                (self.train_biases and "bias" in name) or                (self.train_embeddings and "embeddings" in name) or                (self.train_layer_norms and "LayerNorm" in name)            )            param.requires_grad = is_trainable

Inoltre, dobbiamo implementare i metodi forward per tener conto dei compiti su cui faremo il fine-tuning, nonché due metodi per salvare e caricare i pesi LoRA, in modo da poter caricare gli adattatori di un modello addestrato in precedenza.

Cliffhanger: C’è un modo che avrebbe reso il codice molto più bello e facile da generalizzare ad altre architetture di rete (poiché il nostro è molto implicato nel modello RoBERTa). Puoi pensare a cosa potrebbe essere? Hai tempo per riflettere su questa domanda fino a quando non lo discuteremo nella sezione Migliorie Possibili di seguito. Ma finché ciò non accade: testiamo alcuni benchmark per verificare se la nostra implementazione funziona effettivamente.

Test dei risultati con GLUE e SQuAD

La nostra implementazione è ora pronta per essere valutata utilizzando i benchmark GLUE (General Language Understanding Evaluation) e SQuAD (Stanford Question Answering Dataset).

Il benchmark GLUE, una suite di otto diverse attività di NLP, valuta le abilità comprensive di un modello linguistico. Include sfide come l’analisi del sentiment, l’inferenza testuale e la similarità delle frasi, offrendo una misura robusta della capacità di adattamento e competenza linguistiche di un modello.

SQuAD, d’altra parte, si concentra sulla valutazione dei modelli di domanda-risposta. Coinvolge l’estrazione di risposte da passaggi di Wikipedia, dove il modello identifica l’intervallo di testo rilevante. SQuAD v2, una versione più avanzata, introduce domande senza risposta, aggiungendo complessità e riflettendo situazioni reali in cui i modelli devono riconoscere quando il testo non ha una risposta.

Si noti che per il seguente benchmark, non ho ottimizzato alcun iperparametro, non ho eseguito più esecuzioni (in particolare i dataset più piccoli di GLUE sono sensibili al rumore stocastico), non ho utilizzato alcun early stopping e non sono partito da un fine-tuning su un compito GLUE precedente (come spesso viene fatto per diminuire la variabilità del rumore del dataset piccolo e prevenire l’overfitting).

Tutti i test sono stati eseguiti:

  • A partire da una iniezione LoRA appena inizializzata con rango 8 nel modello RoBERTa-base
  • La formazione è stata effettuata per esattamente 6 epoche per ciascun compito, senza alcun early stopping.
  • Nelle prime 2 epoche, il learning rate è stato aumentato linearmente fino al valore massimo, per poi decadere linearmente verso zero nelle restanti 4 epoche.
  • Il learning rate massimo per tutti i compiti era 5e-4.
  • La dimensione del batch per tutti i compiti era 16

Il modello RoBERTa-base ha 124,6 milioni di parametri. Con i parametri LoRA, i bias e le norme di livello, abbiamo solo 420mila parametri non congelati da addestrare. Questo significa che ci alleniamo essenzialmente solo su lo 0,34% dei parametri originali.

Il numero di parametri introdotti da LoRA per questi specifici compiti è notevolmente ridotto, ammontando a soli 1,7 MB di dimensione effettiva su disco. Puoi trovare i LoRA addestrati nel repository Git nella cartella Output.

Dopo l’addestramento, abbiamo ricaricato i parametri LoRA, li abbiamo riapplicati e testato le prestazioni su ciascun set di convalida dei singoli compiti. Di seguito sono riportati i risultati:

Prestazioni sui benchmark GLUE utilizzando LoRA
Prestazioni sui set di dati SQuAD utilizzando LoRA

Probabilmente questi risultati potrebbero essere notevolmente migliorati con un miglioramento dei parametri iperparametrici. Tuttavia, dimostra chiaramente che la nostra implementazione di LoRA funziona e le nostre matrici a basso rango iniettate stanno imparando.

Possibili miglioramenti

Riflettendo sulla nostra implementazione, si potrebbe chiedere: “Potrebbe esserci un approccio più efficiente, generalizzabile (cioè trasferibile ad altre architetture di rete) rispetto al ricodificare la classe di auto-attenzione e effettuare sostituzioni complesse?”

Infatti, avremmo potuto semplicemente implementare una funzione che racchiude la classe pytorch nn.Linear e essere più specifici su quali livelli vogliamo sostituire con essa, attraverso la verifica dei loro nomi. Allo stesso modo, si potrebbero scrivere wrapper intorno alla maggior parte dei livelli di base di pytorch e essere in grado di adattare rapidamente LoRA a nuove architetture di rete. Per dare uno schizzo rapido di come ciò potrebbe essere fatto:

class LoraLinear(nn.Linear):    """    Estende il livello lineare PyTorch con Low-Rank Adaptation (LoRA).    LoRA aggiunge due matrici al livello, consentendo l'addestramento efficiente di modelli grandi.    """    def __init__(self, in_features, out_features, r=8, *args, **kwargs):        super().__init__(in_features, out_features, *args, **kwargs)        # Inizializza le matrici LoRA        self.lora_matrix_B = nn.Parameter(torch.zeros(out_features, r))        self.lora_matrix_A = nn.Parameter(torch.randn(r, in_features))                # Congela la matrice dei pesi originale        self.weight.requires_grad = False    def forward(self, x: Tensor) -> Tensor:        # Calcola l'aggiustamento dei pesi LoRA        lora_weights = torch.matmul(self.lora_matrix_B, self.lora_matrix_A)        # Applica le trasformazioni lineari originali e LoRA-adattate        return super().forward(x) + F.linear(x, lora_weights)

Questo è effettivamente (quasi) il modo in cui la libreria PEFT (Parameter-Efficient Fine-Tuning) di huggingface implementa LoRA. Per qualsiasi applicazione pratica, in cui non stai cercando di imparare, raccomando vivamente di utilizzarla invece di codificare la propria.

È diventata anche una pratica piuttosto comune iniettare LoRA in tutti i livelli lineari (ovvero tutte le matrici dell’auto-attenzione e i due livelli lineari per la rete diretta completamente connessa). Di solito è una buona idea mantenere i bias e le norme di livello addestrabili, oltre ai parametri LoRA. Poiché sono già piccoli, non avrai bisogno di un’iniezione a basso rango per essi.

Anche la quantizzazione dei pesi delle matrici originali per conservare la VRAM della GPU è consigliabile, facilitando l’addestramento di modelli più grandi su una determinata GPU. Ciò può essere fatto in modo efficiente utilizzando la libreria bits-and-bytes, ora completamente integrata con Hugging Face (vedi riferimenti).

In sintesi, ecco i Cinque Comandamenti dell’Adattamento a Basso Rango in un contesto serio:

I Cinque Comandamenti dell'Adattamento a Basso Rango

Se trovi difficile leggere la tavoletta di pietra incisa, eccole di nuovo in testo normale:

I Cinque Comandamenti dell’Adattamento a Basso Rango

1. Utilizza LoRA per ottimizzare il raffinamento del modello, focalizzandoti nel mantenere le dimensioni dei parametri minime.2. Utilizza la libreria PEFT per l’implementazione di LoRA, evitando la necessità di codifica complessa.3. Estendi le adattamenti di LoRA a tutti i livelli lineari, migliorando le capacità complessive del modello.4. Mantieni i bias e le normalizzazioni dei livelli addestrabili, poiché sono essenziali per l’adattabilità del modello e non richiedono adattamenti a basso rango.5. Applica Quantized-LoRA – QLoRA – per preservare la VRAM della GPU e addestrare il tuo modello, consentendo l’addestramento di modelli più grandi.

Ricorda, l’addestramento con QLoRA potrebbe essere leggermente più lento rispetto a LoRA, poiché comporta la dequantizzazione delle matrici durante ogni moltiplicazione. Ad esempio, quando si raffina qualcosa di massiccio come Llama-7B, QLoRA richiede circa il 75% in meno di VRAM ma è approssimativamente il 40% più lento rispetto a LoRA standard. Per ulteriori approfondimenti, consulta i blogpost che ho collegato nelle referenze.

Una Guida Passo-Passo all’Implementazione PEFT

Guardiamo come effettivamente rispettare i nostri comandamenti e implementare una versione migliore tramite PEFT.

Innanzitutto, carichiamo il nostro modello in modo quantizzato. Grazie all’integrazione di bitsandbytes con la libreria Huggingface transformers (introdotto nel maggio 2023), questo è un gioco da ragazzi.

Dobbiamo specificare un file di configurazione e quindi caricare direttamente il modello da huggingface con questa quantizzazione. In generale, è meglio utilizzare gli oggetti AutoModel di transformers. È difficile caricare un modello quantizzato come un sottomodulo di un oggetto nn.module più grande, appena definito. In generale, dovresti lavorare con i modelli grezzi di huggingface e quindi importare direttamente un AutoModelForSequenceClassification per i compiti GLUE e AutoModelForQuestionAnswering per i benchmark SQuAD. Nella configurazione possiamo anche specificare quali parametri non quantizzare: qui dobbiamo registrare le classificazioni o le uscite di qa, poiché vogliamo addestrare queste a pieno regime, ovvero senza LoRA, poiché queste sono state inizializzate di recente per il raffinamento e non hanno mai fatto parte del modello base pre-addestrato.

import bitsandbytes as bnbfrom transformers import AutoModel, AutoModelForSequenceClassification, BitsAndBytesConfig# Configurazione per caricare un modello quantizzatobnb_config = BitsAndBytesConfig(    load_in_4bit=True,  # Abilita il caricamento in 4-bit    bnb_4bit_quant_type="nf4",    bnb_4bit_compute_dtype=torch.bfloat16,    llm_int8_skip_modules=['classifier', 'qa_outputs'],  # Salta questi per la quantizzazione)# Carica il modello da Huggingface con quantizzazione model = AutoModelForSequenceClassification.from_pretrained('roberta-base',          torch_dtype="auto", quantization_config=bnb_config)

Puoi verificare il caricamento in 4-bit ispezionando i moduli del modello e i tipi di dati dei parametri:

# Verifica il caricamento in 4-bitprint("Verifica degli elementi a 4 bit (Linear4bit) nel livello di attenzione:")print(model.roberta.encoder.layer[4].attention)print("Verifica del tipo di dati uint8:")print(model.roberta.encoder.layer[4].attention.self.query.weight.dtype)

Ora passiamo all’iniezione dei parametri LoRA con PEFT. Nota che la libreria PEFT è molto più flessibile, anche quando si lavora con modelli personalizzati o altre strutture complesse, purché tu stia facendo solo LoRA invece di QLoRA (la quantizzazione è di solito la parte complicata).

La libreria PEFT mira ai moduli da sostituire tramite i loro nomi; quindi dobbiamo dare un’occhiata ai model.named_parameters(). Ecco come appare per il modello roberta-base non quantizzato.

Modulo                                                        Parametri---------------------------------------------------------  ------------roberta.embeddings.word_embeddings.weight                     38_603_520roberta.embeddings.position_embeddings.weight                    394_752roberta.embeddings.token_type_embeddings.weight                      768roberta.embeddings.LayerNorm.weight                                  768roberta.embeddings.LayerNorm.bias                                    768roberta.encoder.layer.0.attention.self.query.weight              589_824roberta.encoder.layer.0.attention.self.query.bias                    768roberta.encoder.layer.0.attention.self.key.weight                589_824roberta.encoder.layer.0.attention.self.key.bias                      768roberta.encoder.layer.0.attention.self.value.weight              589_824roberta.encoder.layer.0.attention.self.value.bias                    768roberta.encoder.layer.0.attention.output.dense.weight            589_824roberta.encoder.layer.0.attention.output.dense.bias                  768roberta.encoder.layer.0.attention.output.LayerNorm.weight            768roberta.encoder.layer.0.attention.output.LayerNorm.bias              768roberta.encoder.layer.0.intermediate.dense.weight              2_359_296roberta.encoder.layer.0.intermediate.dense.bias                    3_072roberta.encoder.layer.0.output.dense.weight                    2_359_296roberta.encoder.layer.0.output.dense.bias                            768roberta.encoder.layer.0.output.LayerNorm.weight                      768roberta.encoder.layer.0.output.LayerNorm.bias                        768roberta.encoder.layer.1.attention.self.query.weight              589_824...roberta.encoder.layer.11.output.LayerNorm.bias                       768classifier.dense.weight                                          589_824classifier.dense.bias                                                768classifier.out_proj.weight                                         1_536classifier.out_proj.bias                                               2---------------------------------------------------------  ------------TOTALE                                                        124_647_170

Poi possiamo specificare gli obiettivi di LoRA da selezionare per queste stringhe. Il controllo è se contiene la sottostringa specificata nel suo nome completo. Pertanto scrivere query e value è equivalente alla nostra implementazione da zero di cui sopra. Per gli strati densi dobbiamo essere un po ‘più attenti poiché il classificatore ha anche un output denso. Se desideriamo aggiustare anche gli altri strati densi, dobbiamo essere più specifici tramite intermediate.dense e output.dense.

Tutti i parametri che non sono stati iniettati con i parametri di LoRA vengono automaticamente congelati, cioè non riceveranno aggiornamenti del gradiente. Se ci sono strati che vogliamo allenare nella loro forma originale, possiamo specificarli passando una lista ai parametri modules_to_save del Lora-Config. Nel nostro caso, vogliamo aggiungere il LayerNorm qui e le teste di messa a punto per GLUE e SQuAD. Notare che non è necessario che ogni elemento delle liste corrisponda a qualcosa. Possiamo semplicemente aggiungere il classifier e qa_outputs a questa lista e poi avere un singolo file di configurazione che funzionerà correttamente per entrambi i compiti.

Per i parametri del bias è possibile utilizzare il comodo parametro di configurazione bias. È possibile specificare tutti per riesercitare tutti i bias di tutti i moduli, lora_only per allenare solo quelli iniettati o none per mantenere tutti i bias costanti durante l’allenamento.

Il seguente esempio injetta un LoRA con rank 2. Specifichiamo i parametri alfa con l’8 sopra, poiché questo era il rank che abbiamo provato per primo e dovrebbe consentirci di mantenere il tasso di apprendimento originale dal nostro esempio da zero.

import peft# Configura l'iniezione LoRA tramite PEFT peft_config = peft.LoraConfig (r = 2, # dimensione del rank delle matrici iniettate di LoRA lora_alpha = 8, # parametro per la scalatura, qui viene utilizzato l'8 per renderlo comparabile con la nostra implementazione modules_to_save = ['query', 'key', 'value', 'intermediate.dense', 'output.dense'], # essere precisi sul denso perché il classificatore ha anche un output denso ["LayerNorm", "classifier", "qa_outputs"], # perfine il layer norm; il classificatore è la testa di messa a punto; qa_outputs è per SQuAD lora_dropout = 0,1, # probabilità di dropout per gli strati bias = "tutti", # nessuno, tutto o solo LoRA) modello = peft.get_peft_model (modello, peft_config)

Ricorda, specificare più moduli per le iniezioni di LoRA potrebbe aumentare i requisiti di VRAM. Se incontri limitazioni di VRAM, considera di ridurre il numero di moduli target o il rank di LoRA.

Per l’allenamento, specialmente con QLoRA, scegli un ottimizzatore compatibile con le matrici quantizzate. Sostituisci il tuo ottimizzatore torch standard con una variante di bitsandbytes in questo modo:

import torchimport bitsandbytes as bnb# sostituisci questoottimizzatore  = torch.optim.AdamW(argomenti qui)# con questoottimizzatore  = bnb.optim.AdamW8bit(argomenti stessi qui)

Puoi quindi addestrare questo modello come prima, senza doverti preoccupare esplicitamente di QLoRA durante l’addestramento.

Una volta completato l’addestramento, il processo di salvataggio e ricarica del modello è semplice. Utilizza model.save_pretrained per salvare il tuo modello, specificando il nome desiderato. La libreria PEFT creerà automaticamente una directory in questa posizione, dove memorizzerà i pesi del modello e un file di configurazione. Questo file include dettagli essenziali come il modello di base e i parametri di configurazione di LoRA.

Per ricaricare il modello, utilizza peft.AutoPeftModel.from_pretrained, passando il percorso della directory come argomento. Un punto fondamentale da ricordare è che la configurazione di LoRA attualmente non mantiene il numero di classi per cui è stata inizializzata AutoModelForSequenceClassification. Quando si utilizza from_pretrained, è necessario inserire manualmente questo numero di classi come parametro aggiuntivo. Se non lo si fa, si verificherà un errore.

Il modello ricaricato comprenderà il modello di base originale con gli adattatori LoRA applicati. Se si decide di integrare permanentemente gli adattatori LoRA nelle matrici del modello di base, esegui semplicemente model.merge_and_unload().

Per una comprensione più pratica e istruzioni dettagliate, consulta il repository GitHub. Lì troverai due notebook intitolati Train-QLoRA-with-PEFT.ipynb e Load-LoRA-Weights-PEFT.ipynb, che forniscono un esempio passo-passo per l’addestramento e il caricamento di modelli con PEFT.

Conclusion

“Non cesseremo mai dall’esplorare, e la fine di tutte le nostre esplorazioni sarà arrivare dove siamo partiti e conoscere il luogo per la prima volta.”

— tratto da “Little Gidding” di T.S. Eliot

Questo viaggio ci ha portato da un’implementazione LoRA diretta, seppur codificata duramente, ad una comprensione più profonda degli adattatori a basso rango, della loro implementazione pratica e del testing del benchmark.

Abbiamo esplorato una strategia di implementazione alternativa e più efficiente e ci siamo immerse nell’eleganza di librerie esistenti come PEFT per l’integrazione di LoRA.

La nostra avventura si conclude con linee guida pratiche per l’utilizzo di LoRA, racchiuse nei ‘Cinque Comandamenti’, garantendo un uso efficiente ed efficace di questa tecnica nelle applicazioni del mondo reale e una guida passo-passo su come implementarli nella pratica.

Riferimenti

Tutte le immagini, salvo diversa indicazione, sono dell’autore.