Caricamento, 0%

RLHF — Instruction tuning e chat template

In questo post ci immergiamo un po’ più nel dettaglio in ciò che riguarda l’Instruction Fine Tuning — detto anche Instruction Tuning. Questo è il metodo di base per adattare i modelli di linguaggio ad una determinata distribuzione di task. Rappresenta il punto di partenza per RLHF preparando il modello ad un formato di istruzioni conosciuto come “question-answering”.

Questo è un metodo “supervised”, il che significa che necessita di esempi che contengono la risposta desiderata, le “etichette”: scritte da persone o, come vedremo, generate da altri modelli. Di seguito vedremo più nel dettaglio come questo metodo di training viene gestito e come i dati di training sono formattati e strutturati.

Un modello che completa sequenze

Partiamo dall’inizio. Come abbiamo visto, un modello linguistico di base è addestrato a eseguire una sola operazione: data una sequenza di token, stimare quale token dovrebbe venire dopo.

P(tn+1∣t1,t2,…,tn)(1)P(t_{n+1} \mid t_1, t_2, \dots, t_n) \tag{1}

Il modello infatti non nasce come chatbot e non possiede inizialmente il concetto di conversazione.

Se il modello riceve una frase incompleta come “The capital of the United States is” il modello potrebbe continuare generando “Washington”. Dal suo punto di vista, però, non ha risposto ad una domanda. Ha soltanto completato una sequenza sulla base delle regolarità apprese durante il pre-training. In termini probabilistici, dato un insieme di token precedenti, il modello calcola la probabilità dei possibili token successivi: è proprio la (1).

Probabilmente dirò qualcosa di scontato, ma quando ci riferiamo al “token” indichiamo l’unità elementare elaborata dal modello. Prima di essere ricevuto dal Transformer, il testo viene analizzato da un tokenizer, un modulo ad hoc che lo suddivide in frammenti e assegna a ciascuno un identificatore numerico. Ogni frase viene quindi tradotta in una sequenza di token che il modello elabora. Esistono inoltre dei token con funzioni speciali: <bos_token>, ad esempio, può indicare l’inizio di una sequenza, mentre <eos_token> può segnalarne la conclusione. Il nome concreto cambia da modello a modello: <s> e </s>, per esempio, oppure <|begin_of_text|> e <|end_of_text|>.

La frase The capital of the United States is entra nel tokenizer, che la divide in sette token, ciascuno con un identificatore numerico. La sequenza di token entra nel Transformer, che restituisce una probabilità per ogni possibile token successivo: Washington in cima con 0,62, poi a, located e the con valori molto più bassi, e decine di migliaia di altri token.
Dal testo ai token, e dai token alla probabilità del token successivo: è tutto ciò che un modello di base sa fare. Identificatori e probabilità sono indicativi.

In quest’ottica, interagire con un modello significa fornirgli un testo da completare, ad esempio:

<bos_token>The capital of the United States is

Il modello continua a generare token finché non produce un token di fine sequenza, raggiunge un limite imposto dall’applicazione oppure esaurisce la propria finestra di contesto.

Il chat template

Come si trasforma quindi un modello del genere in un assistente? Le applicazioni rappresentano una conversazione attraverso strutture dati, ma il modello, come abbiamo appena visto, continua ad accettare soltanto sequenze di token.

Un’applicazione potrebbe ad esempio rappresentare una conversazione in questo modo:

messages = [
    {
        "role": "system",
        "content": "Sei un assistente esperto di reti."
    },
    {
        "role": "user",
        "content": "Che cos'è il protocollo TCP?"
    }
]

Come vediamo, sono presenti una lista, due dizionari, dei campi e una separazione esplicita tra ruoli e contenuti. È necessaria una conversione da oggetti Python a una singola sequenza testuale che sia successivamente tokenizzata.

Introduciamo quindi il Chat Template.

Il Chat Template rappresenta una regola di serializzazione. Stabilisce in che ordine disporre i messaggi, come indicare i ruoli, quali delimitatori inserire e come indicare al modello che deve iniziare a generare la risposta dell’assistente. Un esempio di template è ChatML: seguendo la sua struttura, la conversazione riportata nell’ultimo esempio diventa:

<|im_start|>system
Sei un assistente esperto di reti.<|im_end|>
<|im_start|>user
Che cos'è il protocollo TCP?<|im_end|>
<|im_start|>assistant

I marcatori <|im_start|> e <|im_end|> delimitano i singoli messaggi: sono token speciali, ciascuno con un proprio identificatore, non testo qualunque. Le parole system, user e assistant identificano invece il ruolo associato a ciascun contenuto.

Il ruolo system contiene normalmente istruzioni generali sul comportamento desiderato. Può chiedere al modello di rispondere in una determinata lingua, adottare uno stile, rispettare alcuni vincoli o interpretare uno specifico ruolo.

La parte finale della sequenza rimane incompleta intenzionalmente: il modello la tratta come il punto di partenza da cui generare il token successivo, cioè l’inizio della risposta dell’assistente.

Più turni, una sola sequenza

È importante tenere in considerazione questa struttura soprattutto nei contesti in cui la conversazione contiene più turni. Immaginiamo il seguente dialogo:

Utente: Cos'è TCP?
Assistente: È un protocollo di trasporto affidabile.
Utente: Cosa significa affidabile?

Per rispondere all’ultima domanda, il modello deve conoscere il contenuto degli scambi precedenti. Viene dunque ricostruita l’intera cronologia rilevante:

<|im_start|>user
Cos'è TCP?<|im_end|>
<|im_start|>assistant
È un protocollo di trasporto affidabile.<|im_end|>
<|im_start|>user
Cosa significa affidabile?<|im_end|>
<|im_start|>assistant

Vediamo in questo esempio semplificato che la memoria conversazionale non è quindi una memoria permanente conservata internamente dal modello. È la cronologia, ripresentata ogni volta all’interno della sequenza di input. Questo comporta un limite naturale. Ogni modello dispone di una finestra di contesto finita. Quando la conversazione diventa troppo lunga, non è più possibile reinserire indefinitamente tutti i messaggi precedenti. L’applicazione deve allora scegliere quali eliminare, quali riassumere e quali recuperare perché rilevanti per la richiesta corrente.

Quattro righe, una per turno, dentro un riquadro tratteggiato di lunghezza fissa, la finestra di contesto. Al turno 1 la sequenza contiene il messaggio di sistema e il primo messaggio dell'utente; al turno 2 anche la prima risposta e il secondo messaggio; al turno 3 la sequenza riempie la finestra e non resta spazio per la risposta. Al turno 8 i primi sei turni sono sostituiti da un riassunto, seguito dagli ultimi messaggi.
A ogni turno la conversazione viene rimandata per intero; quando la finestra si riempie, l’applicazione elimina, riassume o recupera i messaggi vecchi.

L’addestramento

Abbiamo visto quindi la struttura che viene utilizzata sui dati di training di questa fase. Quasi “tutto qua”. Il processo di training è lo stesso usato nella fase di pre-training, attraverso la predizione del token successivo. In molti sistemi, tuttavia, la funzione di errore viene calcolata principalmente o esclusivamente sui token della risposta dell’assistente. Il contesto formato dalle istruzioni e dalla domanda serve a condizionare la previsione, mentre il modello viene corretto in base a quanta probabilità assegna, token dopo token, alla risposta di riferimento.

In maniera semplificata, la funzione di errore da minimizzare può essere espressa come:

L(ϕ)=−∑t∈rispostalog⁡Pϕ(yt∣x,y<t)(2)\mathcal{L}(\phi) = -\sum_{t \in \text{risposta}} \log P_\phi(y_t \mid x, y_{<t}) \tag{2}

Qui xx rappresenta il prompt formattato, yty_t è il token corretto della risposta, y<ty_{<t} contiene i token della risposta già osservati e ϕ\phi rappresenta i parametri del modello, con la stessa lettera dei post precedenti.

Una conversazione in formato ChatML divisa in token. La prima parte, con la domanda dell'utente e l'apertura del turno dell'assistente, è il contesto x: condiziona la previsione ma non contribuisce alla loss. La seconda parte, la risposta dell'assistente fino al marcatore di fine messaggio compreso, è la risposta y: su ognuno di questi token si calcola un termine meno logaritmo della probabilità.
La loss dell’instruction tuning: il contesto condiziona la previsione, ma si paga solo sui token della risposta, marcatore di fine compreso.

Ripetendo il processo su numerosi esempi, il modello impara progressivamente che il contenuto associato al ruolo system fornisce istruzioni generali, che il contenuto user rappresenta una richiesta e che, dopo l’apertura di un turno assistant, dovrebbe produrre una risposta coerente. Impara inoltre che <|im_end|> segnala normalmente la conclusione del messaggio.

Le fasi successive del post-training, come il preference tuning o il reinforcement learning basato sul feedback umano, possono modificare la qualità e lo stile delle risposte, ma continuano generalmente a utilizzare una struttura conversazionale compatibile.

Il template come protocollo

Il template utilizzato durante l’inferenza dovrebbe corrispondere a quello usato durante il post-training. Se un modello è stato addestrato con delimitatori come:

<|im_start|>user
...
<|im_end|>

ma durante l’utilizzo riceve un formato completamente diverso:

### Human:
...
### Bot:

potrebbe ancora riuscire a rispondere, perché riconosce il contenuto linguistico, ma potrebbe farlo in modo meno stabile o con prestazioni inferiori. Il formato funziona come una sorta di protocollo applicativo: il significato generale può essere simile, ma il modello è stato ottimizzato per riconoscere una specifica codifica.

Esistono diversi template, che variano più nella sintassi che nel principio.

Una volta stabilito questo meccanismo, è possibile estenderlo oltre la semplice conversazione. Lo stesso formato può rappresentare, per esempio, l’utilizzo di strumenti esterni. Se l’utente domanda quale sia il tempo a Roma, il modello può generare una struttura che esprime la volontà di chiamare un servizio meteorologico (i delimitatori sono illustrativi: ogni famiglia di modelli definisce i propri):

<|tool_call|>
{"name":"weather","arguments":{"city":"Rome"}}
<|tool_end|>

Il modello, normalmente, non esegue direttamente il servizio. È l’applicazione (il software che si trova tra l’utente e il modello linguistico) che intercetta la richiesta, invoca lo strumento reale e reinserisce il risultato nella conversazione:

<|tool_result|>
{"temperature":22,"condition":"sereno"}
<|tool_end|>
Diagramma di sequenza fra utente, applicazione, modello e servizio meteo. L'utente chiede che tempo fa a Roma; l'applicazione passa al modello, nel chat template, la conversazione e l'elenco degli strumenti disponibili; il modello genera una chiamata allo strumento; l'applicazione chiama davvero il servizio meteo, che risponde con 22 gradi e cielo sereno; l'applicazione reinserisce il risultato nella conversazione; il modello genera la risposta, che l'applicazione mostra all'utente.
Il modello non esegue nulla: scrive una richiesta in un formato concordato, e l’applicazione la esegue e gli restituisce il risultato.

Vediamo dunque che non è cambiato il modo in cui il modello funziona, quanto la struttura con cui l’informazione è rappresentata.

Il flusso completo può essere riassunto così: l’applicazione raccoglie una conversazione strutturata, il chat template la trasforma in una stringa contenente ruoli e delimitatori, il tokenizer converte quella stringa in identificatori numerici, il Transformer prevede i token successivi e l’applicazione interpreta i token generati come una risposta dell’assistente.

Il percorso di una conversazione. Nel mondo dell'applicazione, la lista di messaggi con ruoli e contenuti passa dal chat template e diventa una stringa con delimitatori come im_start e im_end. Il tokenizer la trasforma in una sequenza di identificatori numerici, che entra nel Transformer, nel mondo del modello. Il Transformer produce i token successivi uno alla volta; l'applicazione li decodifica in testo e li legge come la risposta dell'assistente.
Il chat template è il ponte fra il mondo dell’applicazione, fatto di messaggi e ruoli, e quello del modello, fatto solo di token.

Il chat template è quindi il ponte tra due rappresentazioni profondamente diverse. Da un lato esiste il mondo dell’applicazione, nel quale si parla di utenti, assistenti, messaggi, cronologia e strumenti. Dall’altro esiste il mondo matematico del modello, nel quale esistono soltanto sequenze di token e probabilità condizionate. Il chat template rende possibile il passaggio dal primo al secondo mondo e, attraverso il post-training, permette a un semplice completatore di testo di comportarsi come un assistente conversazionale.

I dati

Abbiamo visto dunque il formato che i dati devono avere per rendere una sequenza comprensibile dal modello. Ora, la capacità del modello di rispondere in maniera utile, dunque di generare una serie di token nel formato appena visto e con un valore nella risposta data, deriva dall’Instruction Tuning, cioè la fase di addestramento supervisionato che abbiamo anticipato all’inizio.

In questa fase, il modello riceve il prompt iniziale, che utilizza come contesto per prevedere, token dopo token, la risposta di riferimento, sulla quale viene poi calcolata la loss. Dunque vediamo che è soprattutto la qualità delle risposte di addestramento a determinare il risultato finale.

Grazie a metodi come LoRA (Low-Rank Adaptation), che mantiene invariati i pesi originali del modello e addestra solo piccole matrici aggiuntive (gli adapter), e alla quantizzazione, che riduce la memoria occupata dai pesi e che QLoRA combina con LoRA, l’Instruction Tuning diventa accessibile anche senza l’infrastruttura necessaria per riaddestrare integralmente un grande modello.

A questo punto, quanti dati servono per ottenere un buon risultato?

La risposta dipende dalla distanza tra le capacità già presenti nel modello e il comportamento che si desidera insegnare. Se l’obiettivo è circoscritto, un dataset piccolo ma accuratamente selezionato può produrre risultati molto buoni. Un modello pre-addestrato possiede già una grande quantità di conoscenze linguistiche e fattuali; l’instruction tuning non deve necessariamente insegnargli tutto da zero. Può limitarsi a mostrargli come utilizzare ciò che già conosce nel formato e nello stile desiderati.

Questo principio è particolarmente evidente nei compiti di chat alignment. Se si vuole insegnare al modello a rispondere in modo disponibile, coerente e conversazionale, senza introdurre capacità complesse completamente nuove, poche migliaia di esempi di alta qualità possono essere molto efficaci.

Dataset come LIMA, circa mille esempi, hanno mostrato che una raccolta relativamente piccola e accuratamente curata può modificare sensibilmente il comportamento conversazionale di un modello. Circa un anno dopo la diffusione di ChatGPT, dataset umani come No Robots, costituiti da circa diecimila esempi, rappresentavano un riferimento importante per l’addestramento di assistenti open source.

Quando però si cercano obiettivi più ambiziosi (non solo una conversazione gradevole, ma anche la capacità di risolvere problemi matematici, scrivere codice, seguire istruzioni complesse, utilizzare strumenti, produrre output strutturati e operare in domini specialistici) sono necessari dataset molto più grandi, composti da centinaia di migliaia o milioni di esempi.

Il collo di bottiglia diventa quindi la costruzione di questi dataset. Una soluzione la si trova ricorrendo all’utilizzo di modelli linguistici più “capaci” per generare un dataset sintetico (filtrando e verificando in maniera opportuna).

Nella progettazione di un dataset occorre inoltre considerare la distribuzione dei prompt. Se il modello verrà utilizzato principalmente per assistere programmatori, dovrebbe vedere richieste simili a quelle che incontrerà in produzione: debugging, spiegazione di errori, generazione di test, modifica del codice e analisi di architetture software.

Addestrarlo prevalentemente su conversazioni generiche potrebbe migliorarne la capacità di dialogo, ma non prepararlo adeguatamente al lavoro sul codice. Allo stesso modo, un dataset quasi interamente matematico non è la scelta migliore per costruire un assistente destinato alla scrittura creativa.

Il principio può essere espresso, in modo informale, come una corrispondenza tra distribuzioni:

Ptraining(x)≈Puso(x)(3)P_{\text{training}}(x) \approx P_{\text{uso}}(x) \tag{3}

dove Ptraining(x)P_{\text{training}}(x) rappresenta la distribuzione delle richieste presenti nel dataset e Puso(x)P_{\text{uso}}(x) quella delle richieste che il modello riceverà realmente. Quanto più queste distribuzioni sono simili, tanto più il comportamento appreso durante il training risulterà pertinente nell’utilizzo successivo.

Due istogrammi per un assistente destinato ai programmatori, con quattro tipi di richiesta: codice, chat, matematica e scrittura. Nel primo le richieste del dataset sono soprattutto chat, 70 per cento, mentre quelle reali sono soprattutto codice, 70 per cento: il modello impara a conversare, non a lavorare sul codice. Nel secondo le due distribuzioni quasi coincidono, con il codice al 65 per cento nel dataset e al 70 nell'uso.
La (3) in pratica, per un assistente destinato ai programmatori: conta che le richieste del dataset somiglino a quelle che riceverà davvero. Valori illustrativi.

Una fase fra le altre

Ricordiamoci però che il post-training non è generalmente composto da un’unica fase isolata. Dopo l’instruction tuning possono seguire il preference tuning, RLHF, metodi di ottimizzazione diretta delle preferenze, training specifici per il ragionamento o ulteriori adattamenti di dominio.

Se alcuni esempi sono imperfetti all’interno del dataset di instruction tuning, le fasi successive possono correggere parte dei comportamenti indesiderati, purché il segnale complessivo rimanga valido.

Ciò non significa che la qualità dei dati possa essere ignorata. Significa piuttosto che l’obiettivo non è rendere ogni singola fase perfetta indipendentemente dalle altre. Bisogna progettare l’intero processo di ottimizzazione affinché le diverse fasi collaborino verso il comportamento finale desiderato.

L’instruction tuning costituisce così il primo passaggio nel quale un modello pre-addestrato impara sistematicamente a interpretare il testo come un’istruzione e a produrre una risposta appropriata. Il chat template gli fornisce la grammatica della conversazione; il dataset gli mostra il comportamento da adottare; la funzione di loss trasforma la distanza tra ciò che il modello si aspettava e la risposta desiderata in aggiornamenti dei parametri; le fasi successive perfezionano infine quel comportamento sulla base delle preferenze e degli obiettivi del sistema.

Il risultato non è un modello che ha cessato di prevedere token. È un modello per il quale, dopo una richiesta formulata nel corretto contesto conversazionale, le continuazioni più probabili sono diventate risposte utili, pertinenti e strutturate.