Come addestrare un nuovo modello di lingua da zero utilizzando Transformers e Tokenizers

Addestramento modello di lingua da zero con Transformers e Tokenizers

Negli ultimi mesi abbiamo apportato diverse migliorie alle nostre librerie transformers e tokenizers, con l’obiettivo di rendere più facile che mai addestrare un nuovo modello di linguaggio da zero.

In questo post mostreremo come addestrare un “piccolo” modello (84 M parametri = 6 layer, dimensione nascosta 768, 12 testate di attenzione) – lo stesso numero di layer e testate di DistilBERT – su Esperanto. Successivamente affineremo il modello su un compito di part-of-speech tagging.

Esperanto è una lingua artificiale con l’obiettivo di essere facile da imparare. L’abbiamo scelta per questa dimostrazione per diversi motivi:

  • è una lingua a risorse relativamente basse (anche se è parlata da ~2 milioni di persone) quindi questa dimostrazione è meno noiosa che addestrare un altro modello in inglese 😁
  • la sua grammatica è altamente regolare (ad esempio, tutti i sostantivi comuni terminano in -o, tutti gli aggettivi in -a) quindi otterremo risultati linguistici interessanti anche su un piccolo dataset.
  • infine, l’obiettivo generale alla base della lingua è avvicinare le persone (promuovendo la pace nel mondo e la comprensione internazionale) che si potrebbe sostenere sia allineato con l’obiettivo della comunità NLP 💚

N.B. Non sarà necessario capire l’Esperanto per capire questo post, ma se desideri impararlo, Duolingo ha un bel corso con 280.000 studenti attivi.

Il nostro modello si chiamerà… aspetta un attimo… EsperBERTo 😂

1. Trova un dataset

Prima di tutto, troviamo un corpus di testo in Esperanto. Qui useremo la porzione Esperanto del corpus OSCAR dell’INRIA. OSCAR è un enorme corpus multilingue ottenuto dalla classificazione linguistica e filtraggio dei dump di Common Crawl del Web.

La porzione Esperanto del dataset ha una dimensione di soli 299M, quindi la concateniamo con il sottocorpus Esperanto della Leipzig Corpora Collection, che è composto da testi provenienti da fonti diverse come notizie, letteratura e Wikipedia.

Il corpus di addestramento finale ha una dimensione di 3 GB, che è ancora piccola: per ottenere risultati migliori per il tuo modello, più dati puoi ottenere per il preaddestramento.

2. Addestra un tokenizer

Scegliamo di addestrare un tokenizer di codifica byte-level Byte-pair (lo stesso di GPT-2), con gli stessi token speciali di RoBERTa. Scegliamo arbitrariamente la sua dimensione a 52.000.

Raccomandiamo di addestrare un BPE di livello byte (piuttosto che, ad esempio, un tokenizer WordPiece come BERT) perché inizierà a costruire il suo vocabolario da un alfabeto di singoli byte, quindi tutte le parole saranno scomponibili in token (non più token <unk>!).

#! pip install tokenizers

from pathlib import Path

from tokenizers import ByteLevelBPETokenizer

paths = [str(x) for x in Path("./eo_data/").glob("**/*.txt")]

# Inizializza un tokenizer
tokenizer = ByteLevelBPETokenizer()

# Personalizza l'addestramento
tokenizer.train(files=paths, vocab_size=52_000, min_frequency=2, special_tokens=[
    "<s>",
    "<pad>",
    "</s>",
    "<unk>",
    "<mask>",
])

# Salva i file su disco
tokenizer.save_model(".", "esperberto")

Ecco un’anteprima accelerata dell’output:

Sul nostro dataset, l’addestramento ha richiesto circa ~5 minuti.

🔥🔥 Wow, è stato veloce! ⚡️🔥

Ora abbiamo sia un vocab.json, che è un elenco dei token più frequenti ordinati per frequenza, sia un elenco di fusioni merges.txt.

{
    "<s>": 0,
    "<pad>": 1,
    "</s>": 2,
    "<unk>": 3,
    "<mask>": 4,
    "!": 5,
    "\"": 6,
    "#": 7,
    "$": 8,
    "%": 9,
    "&": 10,
    "'": 11,
    "(": 12,
    ")": 13,
    # ...
}

# merges.txt
l a
Ġ k
o n
Ġ la
t a
Ġ e
Ġ d
Ġ p
# ...

Ciò che è fantastico è che il nostro tokenizer è ottimizzato per l’Esperanto. Rispetto a un tokenizer generico addestrato per l’inglese, più parole native sono rappresentate da un singolo token non diviso. Le lettere accentate, cioè i caratteri accentati utilizzati in Esperanto – ĉ, ĝ, ĥ, ĵ, ŝ e ŭ – sono codificati in modo nativo. Rappresentiamo anche sequenze in modo più efficiente. Qui su questo corpus, la lunghezza media delle sequenze codificate è ~30% più piccola rispetto all’utilizzo del tokenizer GPT-2 preaddestrato.

Ecco come puoi usarlo in tokenizers, inclusa la gestione dei token speciali RoBERTa – naturalmente, sarai anche in grado di usarlo direttamente da transformers.

from tokenizers.implementations import ByteLevelBPETokenizer
from tokenizers.processors import BertProcessing


tokenizer = ByteLevelBPETokenizer(
    "./models/EsperBERTo-small/vocab.json",
    "./models/EsperBERTo-small/merges.txt",
)
tokenizer._tokenizer.post_processor = BertProcessing(
    ("</s>", tokenizer.token_to_id("</s>")),
    ("<s>", tokenizer.token_to_id("<s>")),
)
tokenizer.enable_truncation(max_length=512)

print(
    tokenizer.encode("Mi estas Julien.")
)
# Encoding(num_tokens=7, ...)
# tokens: ['<s>', 'Mi', 'Ġestas', 'ĠJuli', 'en', '.', '</s>']

3. Allenare un modello di linguaggio da zero

Aggiornamento: Il notebook Colab associato utilizza direttamente il nostro nuovo Trainer, invece di utilizzare uno script. Sentiti libero di scegliere l’approccio che preferisci.

Ora addestreremo il nostro modello di linguaggio utilizzando lo script run_language_modeling.py di transformers (recentemente rinominato da run_lm_finetuning.py in quanto ora supporta l’addestramento da zero in modo più fluido). Ricorda solo di lasciare --model_name_or_path a None per addestrare da zero rispetto a un modello o checkpoint esistente.

Addestreremo un modello simile a RoBERTa, che è simile a BERT con un paio di modifiche (consulta la documentazione per maggiori dettagli).

Dato che il modello è simile a BERT, lo addestreremo su un compito di modello di linguaggio mascherato, ovvero prevederemo come riempire i token arbitrari che mascheriamo casualmente nel dataset. Questo è gestito dallo script di esempio.

Dobbiamo solo fare due cose:

  • implementare una semplice sottoclasse di Dataset che carichi i dati dai nostri file di testo
    • A seconda del tuo caso d’uso, potresti anche non avere bisogno di scrivere la tua sottoclasse personalizzata di Dataset, se uno degli esempi forniti (TextDataset e LineByLineTextDataset) funziona: ma ci sono molte personalizzazioni che potresti voler aggiungere in base a come è strutturato il tuo corpus.
  • Scegliere e sperimentare con diversi set di iperparametri.

Ecco una versione semplice del nostro EsperantoDataset.

from torch.utils.data import Dataset

class EsperantoDataset(Dataset):
    def __init__(self, evaluate: bool = False):
        tokenizer = ByteLevelBPETokenizer(
            "./models/EsperBERTo-small/vocab.json",
            "./models/EsperBERTo-small/merges.txt",
        )
        tokenizer._tokenizer.post_processor = BertProcessing(
            ("</s>", tokenizer.token_to_id("</s>")),
            ("<s>", tokenizer.token_to_id("<s>")),
        )
        tokenizer.enable_truncation(max_length=512)
        # o utilizzare direttamente il RobertaTokenizer di `transformers`.

        self.examples = []

        src_files = Path("./data/").glob("*-eval.txt") if evaluate else Path("./data/").glob("*-train.txt")
        for src_file in src_files:
            print("🔥", src_file)
            lines = src_file.read_text(encoding="utf-8").splitlines()
            self.examples += [x.ids for x in tokenizer.encode_batch(lines)]

    def __len__(self):
        return len(self.examples)

    def __getitem__(self, i):
        # Effettueremo il padding a livello di batch.
        return torch.tensor(self.examples[i])

Se il tuo dataset è molto grande, puoi scegliere di caricare e tokenizzare gli esempi al volo, anziché come passaggio di pre-elaborazione.

Ecco un insieme specifico di iperparametri e argomenti che passiamo allo script:

    --output_dir ./models/EsperBERTo-small-v1
    --model_type roberta
    --mlm
    --config_name ./models/EsperBERTo-small
    --tokenizer_name ./models/EsperBERTo-small
    --do_train
    --do_eval
    --learning_rate 1e-4
    --num_train_epochs 5
    --save_total_limit 2
    --save_steps 2000
    --per_gpu_train_batch_size 16
    --evaluate_during_training
    --seed 42

Come al solito, scegli la dimensione del batch più grande che riesci a inserire nella tua/e GPU.

🔥🔥🔥 Cominciamo l’addestramento!! 🔥🔥🔥

Qui puoi controllare il nostro Tensorboard per un insieme specifico di iperparametri:

I nostri script di esempio si registrano nel formato Tensorboard per impostazione predefinita, nella cartella runs/. Poi, per visualizzare la tua board, esegui semplicemente tensorboard dev upload --logdir runs – questo configurerà tensorboard.dev, una versione ospitata gestita da Google che ti permette di condividere il tuo esperimento di ML con chiunque.

4. Verifica che il LM sia effettivamente addestrato

Oltre a osservare la riduzione delle perdite di addestramento e valutazione, il modo più semplice per verificare se il nostro modello di linguaggio sta apprendendo qualcosa di interessante è tramite la FillMaskPipeline.

Le Pipeline sono semplici wrapper attorno ai tokenizzatori e ai modelli, e quella ‘fill-mask’ ti permetterà di inserire una sequenza contenente un token mascherato (qui, <mask>) e restituire un elenco delle sequenze più probabili riempite, con le loro probabilità.

from transformers import pipeline

fill_mask = pipeline(
    "fill-mask",
    model="./models/EsperBERTo-small",
    tokenizer="./models/EsperBERTo-small"
)

# La suno <mask>.
# =>

result = fill_mask("La suno <mask>.")

# {'score': 0.2526160776615143, 'sequence': '<s> La suno brilis.</s>', 'token': 10820}
# {'score': 0.0999930202960968, 'sequence': '<s> La suno lumis.</s>', 'token': 23833}
# {'score': 0.04382849484682083, 'sequence': '<s> La suno brilas.</s>', 'token': 15006}
# {'score': 0.026011141017079353, 'sequence': '<s> La suno falas.</s>', 'token': 7392}
# {'score': 0.016859788447618484, 'sequence': '<s> La suno pasis.</s>', 'token': 4552}

Ottimo, funziona la sintassi/grammatica semplice. Proviamo un prompt leggermente più interessante:

fill_mask("Jen la komenco de bela <mask>.")

# Questo è l'inizio di un <mask> bello.
# =>

# {
#     'score':0.06502299010753632
#     'sequence':'<s> Jen la komenco de bela vivo.</s>'
#     'token':1099
# }
# {
#     'score':0.0421181358397007
#     'sequence':'<s> Jen la komenco de bela vespero.</s>'
#     'token':5100
# }
# {
#     'score':0.024884626269340515
#     'sequence':'<s> Jen la komenco de bela laboro.</s>'
#     'token':1570
# }
# {
#     'score':0.02324388362467289
#     'sequence':'<s> Jen la komenco de bela tago.</s>'
#     'token':1688
# }
# {
#     'score':0.020378097891807556
#     'sequence':'<s> Jen la komenco de bela festo.</s>'
#     'token':4580
# }

Solo l’inizio di una bella giornata”, davvero!

Con prompt più complessi, puoi verificare se il tuo modello di linguaggio ha catturato più conoscenze semantiche o addirittura una sorta di ragionamento (statistico) del senso comune.

5. Affina il tuo modello di linguaggio in un compito secondario

Ora possiamo affinare il nostro nuovo modello di linguaggio Esperanto in un compito secondario di Part-of-speech tagging.

Come accennato in precedenza, l’Esperanto è una lingua altamente regolare in cui le desinenze delle parole condizionano tipicamente la parte grammaticale del discorso. Utilizzando un dataset di etichette POS annotate in Esperanto formattate nel formato CoNLL-2003 (vedi esempio di seguito), possiamo utilizzare lo script run_ner.py da transformers.

L’POS tagging è un compito di classificazione dei token proprio come NER, quindi possiamo usare lo stesso script esatto.

Ecco ancora una volta il Tensorboard ospitato per questo affinamento. Alleniamo per 3 epoche utilizzando una dimensione di batch di 64 per GPU.

Le perdite di allenamento e di valutazione convergono a valori residui piccoli poiché il compito è piuttosto facile (la lingua è regolare) – è comunque divertente poterlo allenare end-to-end 😃.

Questa volta, utilizziamo una TokenClassificationPipeline:

from transformers import TokenClassificationPipeline, pipeline


MODEL_PATH = "./models/EsperBERTo-small-pos/"

nlp = pipeline(
    "ner",
    model=MODEL_PATH,
    tokenizer=MODEL_PATH,
)
# o istanzia direttamente una TokenClassificationPipeline.

nlp("Mi estas viro kej estas tago varma.")

# {'entity': 'PRON', 'score': 0.9979867339134216, 'word': ' Mi'}
# {'entity': 'VERB', 'score': 0.9683094620704651, 'word': ' estas'}
# {'entity': 'VERB', 'score': 0.9797462821006775, 'word': ' estas'}
# {'entity': 'NOUN', 'score': 0.8509314060211182, 'word': ' tago'}
# {'entity': 'ADJ', 'score': 0.9996201395988464, 'word': ' varma'}

Sembra che abbia funzionato! 🔥

Per un dataset più sfidante per NER, @stefan-it ha suggerito che potremmo allenarci sul dataset silver standard da WikiANN

6. Condividi il tuo modello 🎉

Infine, quando hai un bel modello, pensa di condividerlo con la comunità:

  • carica il tuo modello utilizzando la CLI: transformers-cli upload
  • scrivi una README.md model card e aggiungila al repository in model_cards/. La tua model card dovrebbe includere idealmente:
    • una descrizione del modello,
    • parametri di allenamento (dataset, preprocessing, iperparametri),
    • risultati di valutazione,
    • usi previsti e limitazioni
    • qualsiasi altra cosa utile! 🤓

TADA!

➡️ Il tuo modello ha una pagina su https://huggingface.co/models e tutti possono caricarlo utilizzando AutoModel.from_pretrained("username/model_name").

Se vuoi dare un’occhiata ai modelli in diverse lingue, controlla https://huggingface.co/models

Grazie!