Come generare testo utilizzando diversi metodi di decodifica per la generazione del linguaggio con i Transformers

Generazione di testo con diversi metodi di decodifica nei Transformers

Introduzione

Negli ultimi anni c’è stato un crescente interesse per la generazione di linguaggio a termine aperto grazie all’avvento di modelli di linguaggio basati su grandi trasformatori addestrati su milioni di pagine web, come il famoso modello GPT2 di OpenAI. I risultati sulla generazione di linguaggio a termine aperto condizionato sono impressionanti, ad esempio GPT2 su unicorni, XLNet, Controlled language con CTRL. Oltre all’architettura migliorata del trasformatore e ai massicci dati di addestramento non supervisionato, metodi di decodifica migliori hanno svolto un ruolo importante.

Questo post del blog fornisce una breve panoramica delle diverse strategie di decodifica e, cosa più importante, mostra come è possibile implementarle con un minimo sforzo utilizzando la popolare libreria transformers!

Tutte le seguenti funzionalità possono essere utilizzate per la generazione di linguaggio auto-regressiva (qui un ripasso). In breve, la generazione di linguaggio auto-regressiva si basa sull’assunzione che la distribuzione di probabilità di una sequenza di parole possa essere scomposta nel prodotto di distribuzioni condizionali della parola successiva:

P(w1:T∣W0)=∏t=1TP(wt∣w1:t−1,W0) ,con w1:0=∅, P(w_{1:T} | W_0 ) = \prod_{t=1}^T P(w_{t} | w_{1: t-1}, W_0) \text{ ,con } w_{1: 0} = \emptyset, P(w1:T​∣W0​)=t=1∏T​P(wt​∣w1:t−1​,W0​) ,con w1:0​=∅,

e W0W_0W0​ è la sequenza di parole di contesto iniziale. La lunghezza TTT della sequenza di parole è di solito determinata al volo e corrisponde all’istante di tempo t=Tt=Tt=T in cui viene generato il token EOS da P(wt∣w1:t−1,W0)P(w_{t} | w_{1: t-1}, W_{0})P(wt​∣w1:t−1​,W0​).

La generazione di linguaggio auto-regressiva è ora disponibile per GPT2, XLNet, OpenAi-GPT, CTRL, TransfoXL, XLM, Bart, T5 sia in PyTorch che in Tensorflow >= 2.0!

Daremo un’occhiata ai metodi di decodifica attualmente più importanti, principalmente Greedy search, Beam search, Top-K sampling e Top-p sampling.

Installiamo rapidamente transformers e carichiamo il modello. Utilizzeremo GPT2 in Tensorflow 2.1 per dimostrazione, ma l’API è la stessa per PyTorch.

!pip install -q git+https://github.com/huggingface/transformers.git
!pip install -q tensorflow==2.1

import tensorflow as tf
from transformers import TFGPT2LMHeadModel, GPT2Tokenizer


tokenizer = GPT2Tokenizer.from_pretrained("gpt2")

# aggiungi il token EOS come token PAD per evitare avvisi
model = TFGPT2LMHeadModel.from_pretrained("gpt2", pad_token_id=tokenizer.eos_token_id)

La ricerca greedy semplicemente seleziona la parola con la probabilità più alta come prossima parola: wt=argmaxwP(w∣w1:t−1)w_t = argmax_{w}P(w | w_{1:t-1})wt​=argmaxw​P(w∣w1:t−1​) in ogni istante di tempo ttt. Lo sketch seguente mostra la ricerca greedy.

Partendo dalla parola “The”,\text{“The”},”The”, l’algoritmo sceglie avidamente la parola successiva con la probabilità più alta “nice”\text{“nice”}”nice” e così via, in modo che la sequenza di parole generata finale sia (“The”,”nice”,”woman”)(\text{“The”}, \text{“nice”}, \text{“woman”})(“The”,”nice”,”woman”) con una probabilità complessiva di 0.5×0.4=0.20.5 \times 0.4 = 0.20.5×0.4=0.2 .

Nel seguente genereremo sequenze di parole utilizzando GPT2 sul contesto (“I”,”enjoy”,”walking”,”with”,”my”,”cute”,”dog”)(\text{“I”}, \text{“enjoy”}, \text{“walking”}, \text{“with”}, \text{“my”}, \text{“cute”}, \text{“dog”})(“I”,”enjoy”,”walking”,”with”,”my”,”cute”,”dog”). Vediamo come la ricerca greedy può essere utilizzata in transformers:

# codifica del contesto su cui la generazione è condizionata
input_ids = tokenizer.encode('I enjoy walking with my cute dog', return_tensors='tf')

# genera testo finché la lunghezza dell'output (che include la lunghezza del contesto) raggiunge 50
greedy_output = model.generate(input_ids, max_length=50)

print("Output:\n" + 100 * '-')
print(tokenizer.decode(greedy_output[0], skip_special_tokens=True))

Perfetto! Abbiamo generato il nostro primo breve testo con GPT2 😊. Le parole generate a seguito del contesto sono ragionevoli, ma il modello inizia rapidamente a ripetersi! Questo è un problema molto comune nella generazione di linguaggio in generale e sembra esserlo ancora di più nella ricerca greedy e nella ricerca a fascio – dai un’occhiata a Vijayakumar et al., 2016 e Shao et al., 2017.

Il principale svantaggio della ricerca greedy è che non considera parole ad alta probabilità nascoste dietro una parola a bassa probabilità, come si può vedere nel nostro esempio sopra:

La parola “has”\text{“has”}”has” con la sua alta probabilità condizionale di 0.90.90.9 è nascosta dietro la parola “dog”\text{“dog”}”dog”, che ha solo la seconda probabilità condizionale più alta, quindi la ricerca greedy non considera la sequenza di parole “The”,”dog”,”has”\text{“The”}, \text{“dog”}, \text{“has”}”The”,”dog”,”has”.

Fortunatamente, abbiamo la ricerca a fascio per alleviare questo problema!

La ricerca a fascio riduce il rischio di perdere sequenze di parole nascoste ad alta probabilità mantenendo le num_beams ipotesi più probabili ad ogni passo temporale e alla fine scegliendo l’ipotesi con la probabilità complessiva più alta. Illustreremo con num_beams=2:

Al passo temporale 1, oltre all’ipotesi più probabile (“The”,”nice”)(\text{“The”}, \text{“nice”})(“The”,”nice”), la ricerca a fascio tiene traccia anche della seconda ipotesi più probabile (“The”,”dog”)(\text{“The”}, \text{“dog”})(“The”,”dog”). Al passo temporale 2, la ricerca a fascio trova che la sequenza di parole (“The”,”dog”,”has”)(\text{“The”}, \text{“dog”}, \text{“has”})(“The”,”dog”,”has”), ha con 0.360.360.36 una probabilità più alta rispetto a (“The”,”nice”,”woman”)(\text{“The”}, \text{“nice”}, \text{“woman”})(“The”,”nice”,”woman”), che ha 0.20.20.2. Fantastico, ha trovato la sequenza di parole più probabile nel nostro esempio di esempio!

La ricerca a fascio troverà sempre una sequenza di output con una probabilità più alta rispetto alla ricerca greedy, ma non è garantito che troverà l’output più probabile.

Vediamo come la ricerca a fascio può essere utilizzata in transformers. Impostiamo num_beams > 1 e early_stopping=True in modo che la generazione si interrompa quando tutte le ipotesi del fascio raggiungono il token EOS.

# attiva la ricerca a fascio e early_stopping
beam_output = model.generate(
    input_ids, 
    max_length=50, 
    num_beams=5, 
    early_stopping=True
)

print("Output:\n" + 100 * '-')
print(tokenizer.decode(beam_output[0], skip_special_tokens=True))

Anche se il risultato è probabilmente più fluido, l’output include comunque ripetizioni delle stesse sequenze di parole. Un rimedio semplice è quello di introdurre penalità per gli n-grammi (a.k.a sequenze di parole di n parole) come introdotto da Paulus et al. (2017) e Klein et al. (2017). La penalità per gli n-grammi più comuni assicura che nessun n-gramma appaia due volte impostando manualmente la probabilità delle parole successive che potrebbero creare un n-gramma già visto a 0.

Proviamolo impostando no_repeat_ngram_size=2 in modo che nessun 2-gramma appaia due volte:

# impostare no_repeat_ngram_size a 2
beam_output = model.generate(
    input_ids, 
    max_length=50, 
    num_beams=5, 
    no_repeat_ngram_size=2, 
    early_stopping=True
)

print("Output:\n" + 100 * '-')
print(tokenizer.decode(beam_output[0], skip_special_tokens=True))

Bene, ora sembra molto meglio! Possiamo vedere che la ripetizione non appare più. Tuttavia, le penalità degli n-grammi devono essere usate con attenzione. Un articolo generato sulla città di New York non dovrebbe utilizzare una penalità di 2-grammi, altrimenti il nome della città apparirebbe solo una volta in tutto il testo!

Un’altra caratteristica importante della ricerca a fascio è che possiamo confrontare i migliori fasci dopo la generazione e scegliere il fascio generato che meglio si adatta al nostro scopo.

In transformers, impostiamo semplicemente il parametro num_return_sequences al numero dei fasci con il punteggio più alto che devono essere restituiti. Assicurati però che num_return_sequences <= num_beams!

# impostare return_num_sequences > 1
beam_outputs = model.generate(
    input_ids, 
    max_length=50, 
    num_beams=5, 
    no_repeat_ngram_size=2, 
    num_return_sequences=5, 
    early_stopping=True
)

# ora abbiamo 3 sequenze di output
print("Output:\n" + 100 * '-')
for i, beam_output in enumerate(beam_outputs):
  print("{}: {}".format(i, tokenizer.decode(beam_output, skip_special_tokens=True)))

Come si può vedere, le cinque ipotesi di fascio sono solo marginalmente diverse l’una dall’altra – il che non dovrebbe essere troppo sorprendente quando si utilizzano solo 5 fasci.

Nella generazione a termine aperto, di recente sono state avanzate alcune ragioni per cui la ricerca a fascio potrebbe non essere la migliore opzione possibile:

  • La ricerca a fascio può funzionare molto bene in compiti in cui la lunghezza della generazione desiderata è più o meno prevedibile, come nella traduzione automatica o nella sintesi – vedi Murray et al. (2018) e Yang et al. (2018). Ma questo non è il caso della generazione a termine aperto in cui la lunghezza dell’output desiderato può variare molto, ad esempio nella generazione di dialoghi e storie.

  • Abbiamo visto che la ricerca a fascio soffre molto della generazione ripetitiva. Questo è particolarmente difficile da controllare con penalità di n-grammi o altre penalità nella generazione di storie, poiché trovare un buon compromesso tra “nessuna ripetizione” forzata e cicli ripetitivi di n-grammi identici richiede molta messa a punto.

  • Come sostenuto da Ari Holtzman et al. (2019), il linguaggio umano di alta qualità non segue una distribuzione delle parole successive di alta probabilità. In altre parole, come esseri umani, vogliamo che il testo generato ci sorprenda e non sia noioso/prevedibile. Gli autori lo dimostrano in modo convincente rappresentando graficamente la probabilità che un modello darebbe al testo umano rispetto a ciò che fa la ricerca a fascio.

Quindi smettiamo di essere noiosi e introduciamo un po’ di casualità 🤪.

Campionamento

<p+Nella sua forma più basilare, il campionamento significa scegliere casualmente la parola successiva wtw_twt​ in base alla sua distribuzione di probabilità condizionata:

wt∼P(w∣w1:t−1) w_t \sim P(w|w_{1:t-1}) wt​∼P(w∣w1:t−1​)

<p+Prendendo l'esempio sopra, la seguente immagine visualizza la generazione del linguaggio quando si usa il campionamento.

Diventa ovvio che la generazione del linguaggio utilizzando il campionamento non è più deterministica. La parola (“car”)(\text{“car”})(“car”) viene campionata dalla distribuzione di probabilità condizionata P(w∣”The”)P(w | \text{“The”})P(w∣”The”), seguita dal campionamento di (“drives”)(\text{“drives”})(“drives”) da P(w∣”The”,”car”)P(w | \text{“The”}, \text{“car”})P(w∣”The”,”car”) .

In transformers, impostiamo do_sample=True e disattiviamo il campionamento Top-K (ne parleremo più avanti) tramite top_k=0. Di seguito, fissiamo random_seed=0 per scopi illustrativi. Sentiti libero di cambiare il random_seed per sperimentare con il modello.

# impostare il seed per riprodurre i risultati. Sentiti libero di cambiarlo per ottenere risultati diversi
tf.random.set_seed(0)

# attivare il campionamento e disattivare il top_k impostando il campionamento top_k a 0
sample_output = model.generate(
    input_ids, 
    do_sample=True, 
    max_length=50, 
    top_k=0
)

print("Output:\n" + 100 * '-')
print(tokenizer.decode(sample_output[0], skip_special_tokens=True))

Interessante! Il testo sembra corretto, ma osservandolo più da vicino, non è molto coerente. Le trigrammi “new hand sense” e “local batte harness” sono molto strani e non sembrano essere stati scritti da un umano. Questo è il grande problema quando si generano sequenze di parole campionando: i modelli spesso generano delle sciocchezze incoerenti, cf. Ari Holtzman et al. (2019).

Un trucco è rendere la distribuzione P(w∣w1:t−1)P(w|w_{1:t-1})P(w∣w1:t−1​) più accentuata (aumentando la probabilità delle parole con probabilità elevate e diminuendo la probabilità delle parole con probabilità basse) riducendo la cosiddetta temperatura del softmax.

Un’illustrazione dell’applicazione della temperatura nel nostro esempio precedente potrebbe essere la seguente.

La distribuzione condizionale della parola successiva al passo t=1t=1t=1 diventa molto più accentuata, lasciando quasi nessuna possibilità per la parola (“car”)(\text{“car”})(“car”) di essere selezionata.

Vediamo come possiamo ridurre la distribuzione nella libreria impostando temperature=0.7:

# impostare il seed per riprodurre i risultati. Sentiti libero di cambiarlo per ottenere risultati diversi
tf.random.set_seed(0)

# utilizzare la temperatura per ridurre la sensibilità ai candidati con probabilità basse
sample_output = model.generate(
    input_ids, 
    do_sample=True, 
    max_length=50, 
    top_k=0, 
    temperature=0.7
)

print("Output:\n" + 100 * '-')
print(tokenizer.decode(sample_output[0], skip_special_tokens=True))

OK. Ci sono meno trigrammi strani e l’output è un po’ più coerente ora! Anche se l’applicazione della temperatura può rendere una distribuzione meno casuale, nel suo limite, quando si imposta temperature →0\to 0→0, il campionamento con temperatura diventa uguale alla decodifica avida e soffrirà degli stessi problemi di prima.

Campionamento Top-K

Fan et al. (2018) hanno introdotto uno schema di campionamento semplice ma molto potente, chiamato campionamento Top-K. Nel campionamento Top-K, le K parole successive più probabili vengono filtrate e la massa di probabilità viene ridistribuita solo tra quelle K parole successive. GPT2 ha adottato questo schema di campionamento, che è stato uno dei motivi del suo successo nella generazione di storie.

Aumentiamo il numero di parole utilizzate per entrambi i passaggi di campionamento nell’esempio precedente da 3 parole a 10 parole per illustrare meglio il campionamento Top-K.

Avendo impostato K=6K = 6K=6, in entrambi i passaggi di campionamento limitiamo il nostro pool di campionamento a 6 parole. Mentre le 6 parole più probabili, definite come Vtop-KV_{\text{top-K}}Vtop-K​, includono solo circa due terzi della massa di probabilità nell’ultimo passaggio, includono quasi tutta la massa di probabilità nel secondo passaggio. Tuttavia, vediamo che elimina con successo i candidati piuttosto strani (“not”, “the”, “small”, “told”)(\text{“not”}, \text{“the”}, \text{“small”}, \text{“told”})(“not”,”the”,“small”,”told”) nel secondo passaggio di campionamento.

Vediamo come è possibile utilizzare il Top-K nella libreria impostando top_k=50:

# impostare il seed per riprodurre i risultati. Sentiti libero di cambiarlo per ottenere risultati diversi
tf.random.set_seed(0)

# impostare top_k a 50
sample_output = model.generate(
    input_ids, 
    do_sample=True, 
    max_length=50, 
    top_k=50
)

print("Output:\n" + 100 * '-')
print(tokenizer.decode(sample_output[0], skip_special_tokens=True))

Non male affatto! Il testo è probabilmente il testo più simile a quello umano fino ad ora. Un’osservazione da fare però sul campionamento Top-K è che non adatta dinamicamente il numero di parole che vengono filtrate dalla distribuzione di probabilità della parola successiva P(w∣w1:t−1)P(w|w_{1:t-1})P(w∣w1:t−1​). Questo può essere problematico poiché alcune parole potrebbero essere campionate da una distribuzione molto accentuata (distribuzione a destra nel grafico sopra), mentre altre da una distribuzione molto più piatta (distribuzione a sinistra nel grafico sopra).

Nel passo t=1, Top-K elimina la possibilità di campionare (“people”,”big”,”house”,”cat”)(“people”, “big”, “house”, “cat”), che sembrano candidati ragionevoli. D’altra parte, nel passo t=2, il metodo include le parole argomentabilmente non adatte (“down”,”a”)(“down”, “a”) nel pool di parole campione. Pertanto, limitare il pool di campioni a una dimensione fissa K potrebbe mettere a rischio il modello nel produrre parole senza senso per distribuzioni affilate e limitare la creatività del modello per distribuzioni piatte. Questa intuizione ha portato Ari Holtzman et al. (2019) a creare il campionamento Top-p– o campionamento nucleo.

Campionamento Top-p (nucleo)

Invece di campionare solo dalle K parole più probabili, nel campionamento Top-p si sceglie il più piccolo insieme possibile di parole la cui probabilità cumulativa supera la probabilità p. La massa di probabilità viene quindi ridistribuita tra questo insieme di parole. In questo modo, la dimensione dell’insieme di parole (anche chiamato numero di parole nell’insieme) può aumentare e diminuire dinamicamente in base alla distribuzione di probabilità della parola successiva. Ok, è stato molto verboso, vediamo una visualizzazione.

Avendo impostato p=0.92, il campionamento Top-p seleziona il numero minimo di parole che superano insieme p=92% della massa di probabilità, definito come Vtop-p. Nell’esempio precedente, questo includeva le 9 parole più probabili, mentre nel secondo esempio è sufficiente selezionare le prime 3 parole per superare il 92%. Piuttosto semplice in realtà! Si può notare che mantiene una vasta gamma di parole quando la parola successiva è argomentabilmente meno prevedibile, ad esempio P(w∣”The”), e solo poche parole quando la parola successiva sembra più prevedibile, ad esempio P(w∣”The”,”car”).

Ok, ora è il momento di provarlo in transformers! Attiviamo il campionamento Top-p impostando 0 < top_p < 1:

# impostiamo il seed per riprodurre i risultati. Sentiti libero di cambiarlo per ottenere risultati diversi
tf.random.set_seed(0)

# disattiviamo il campionamento Top-K e campioniamo solo dalle parole più probabili al 92%
sample_output = model.generate(
    input_ids, 
    do_sample=True, 
    max_length=50, 
    top_p=0.92, 
    top_k=0
)

print("Output:\n" + 100 * '-')
print(tokenizer.decode(sample_output[0], skip_special_tokens=True))

Output:
----------------------------------------------------------------------------------------------------
Mi piace passeggiare con il mio cane carino. Non sarà mai lo stesso. Lo guardo giocare.


Ragazzi, il mio cane ha bisogno di un nome. Specialmente se viene trovato con le ali.


Cosa era? Avevo molto o

Fantastico, sembra che potrebbe essere stato scritto da un umano. Beh, forse non proprio ancora.

Mentre in teoria Top-p sembra più elegante di Top-K, entrambi i metodi funzionano bene nella pratica. Top-p può anche essere utilizzato in combinazione con Top-K, il che può evitare parole con un rango molto basso consentendo al contempo una selezione dinamica.

Infine, per ottenere più output campionati in modo indipendente, possiamo impostare nuovamente il parametro num_return_sequences > 1:

# impostiamo il seed per riprodurre i risultati. Sentiti libero di cambiarlo per ottenere risultati diversi
tf.random.set_seed(0)

# impostiamo top_k = 50, top_p = 0.95 e num_return_sequences = 3
sample_outputs = model.generate(
    input_ids,
    do_sample=True, 
    max_length=50, 
    top_k=50, 
    top_p=0.95, 
    num_return_sequences=3
)

print("Output:\n" + 100 * '-')
for i, sample_output in enumerate(sample_outputs):
  print("{}: {}".format(i, tokenizer.decode(sample_output, skip_special_tokens=True)))

Output:
----------------------------------------------------------------------------------------------------
0: Mi piace passeggiare con il mio cane carino. È così bello avere l'opportunità di passeggiare con un cane. Ma ho questo problema con il cane e con il modo in cui ci guarda e cerca sempre di farmi capire che posso fare qualcosa
1: Mi piace passeggiare con il mio cane carino, ama fare gite in posti diversi del pianeta, anche nel deserto! Il mondo non è abbastanza grande per viaggiare in autobus con il nostro amato cucciolo, ma è lì che trovo il mio amore
2: Mi piace passeggiare con il mio cane carino e giocare con i nostri bambini," ha detto David J. Smith, direttore della Humane Society degli Stati Uniti.

"Quindi, di conseguenza, ho più lavoro nel mio tempo", ha detto.

Grande, ora dovresti avere tutti gli strumenti per permettere al tuo modello di scrivere le tue storie con transformers!

Conclusione

Come metodi di decodifica ad hoc, il campionamento top-p e top-K sembrano produrre testo più fluido rispetto alla tradizionale ricerca greedy e beam search nella generazione del linguaggio a termine aperto. Recentemente, tuttavia, ci sono state prove che le presunte carenze della ricerca greedy e beam – principalmente la generazione di sequenze di parole ripetitive – sono causate dal modello (soprattutto dal modo in cui viene addestrato), piuttosto che dal metodo di decodifica, vedi Welleck et al. (2019). Inoltre, come dimostrato in Welleck et al. (2020), sembra che il campionamento top-K e top-p soffra anche di generare sequenze di parole ripetitive.

In Welleck et al. (2019), gli autori mostrano che secondo le valutazioni umane, la ricerca beam può generare testo più fluido rispetto al campionamento Top-p, quando si adatta l’obiettivo di addestramento del modello.

La generazione del linguaggio a termine aperto è un campo di ricerca in rapida evoluzione e, come spesso accade, non esiste un metodo universale che funzioni per tutti i casi, quindi bisogna vedere qual è il migliore per il proprio caso specifico.

È bello che tu possa provare tutti i diversi metodi di decodifica in transformers 🤗.

Questa è stata una breve introduzione su come utilizzare diversi metodi di decodifica in transformers e le tendenze recenti nella generazione del linguaggio a termine aperto.

Feedback e domande sono molto benvenuti nel repository di Github.

Per divertirti a generare storie, dai un’occhiata a Writing with Transformers

Grazie a tutti coloro che hanno contribuito all’articolo del blog: Alexander Rush, Julien Chaumand, Thomas Wolf, Victor Sanh, Sam Shleifer, Clément Delangue, Yacine Jernite, Oliver Åstrand e John de Wasseige.

Appendice

Ci sono un paio di parametri aggiuntivi per il metodo generate che non sono stati menzionati in precedenza. Li spiegheremo brevemente qui!

  • min_length può essere utilizzato per obbligare il modello a non produrre un token EOS (= non finire la frase) prima di raggiungere min_length. Questo viene utilizzato abbastanza frequentemente nella sintesi, ma può essere utile in generale se l’utente desidera avere output più lunghi.

  • repetition_penalty può essere utilizzato per penalizzare le parole che sono già state generate o appartengono al contesto. È stato introdotto per la prima volta da Keskar et al. (2019) e viene utilizzato anche nell’obiettivo di addestramento in Welleck et al. (2019). Può essere molto efficace nel prevenire le ripetizioni, ma sembra essere molto sensibile a modelli e casi d’uso diversi, ad esempio vedere questa discussione su Github.

  • attention_mask può essere utilizzato per mascherare i token di padding

  • pad_token_id, bos_token_id, eos_token_id: se il modello non ha quei token di default, l’utente può scegliere manualmente altri id di token per rappresentarli.

Per ulteriori informazioni, consultare anche la descrizione della funzione generate.