Processare i dati
Continuando l’esempio del capitolo precedente, ecco come addestrare un classificatore di sequenze su un’unica batch in PyTorch:
import torch
from transformers import AdamW, AutoTokenizer, AutoModelForSequenceClassification
# Same as before
checkpoint = "bert-base-uncased"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
model = AutoModelForSequenceClassification.from_pretrained(checkpoint)
sequences = [
"I've been waiting for a HuggingFace course my whole life.",
"This course is amazing!",
]
batch = tokenizer(sequences, padding=True, truncation=True, return_tensors="pt")
# This is new
batch["labels"] = torch.tensor([1, 1])
optimizer = AdamW(model.parameters())
loss = model(**batch).loss
loss.backward()
optimizer.step()
Ovviamente, addestrare il modello su due frasi non porterà a dei risultati molto buoni. Per ottenere risultati migliori, si deve preparare un dataset più grande.
In questa sezione verrà usato come esempio il dataset MRPC (Microsoft Research Paraphrase Corpus), presentato nell’articolo di William B. Dolan e Chris Brockett. Il dataset contiene 5801 coppie di frasi, con una label che indica se l’una è una parafrasi dell’altra (i.e. se hanno lo stesso significato). È stato selezionato per questo capitolo perché è un dataset piccolo, con cui è facile sperimentare durante l’addestramento.
Caricare un dataset dall’Hub
L’Hub non contiene solo modelli; contiene anche molti dataset in tante lingue diverse. I dataset possono essere esplorati qui, ed è consigliato tentare di caricare e processare un nuovo dataset dopo aver completato questa sezione (cfr. la documentazione. Per ora, focalizziamoci sul dataset MRPC! Questo è uno dei 10 dataset che fanno parte del GLUE benchmark, che è un benchmark accademico usato per misurare la performance di modelli ML su 10 compiti di classificazione del testo.
La libreria 🤗 Datasets fornisce un comando molto semplice per scaricare e mettere nella cache un dataset sull’Hub. Il dataset MRPC può essere scaricato così:
from datasets import load_dataset
raw_datasets = load_dataset("glue", "mrpc")
raw_datasets
DatasetDict({
train: Dataset({
features: ['sentence1', 'sentence2', 'label', 'idx'],
num_rows: 3668
})
validation: Dataset({
features: ['sentence1', 'sentence2', 'label', 'idx'],
num_rows: 408
})
test: Dataset({
features: ['sentence1', 'sentence2', 'label', 'idx'],
num_rows: 1725
})
})
Il risultato è un oggetto di tipo DatasetDict
che contiene il training set, il validation set, e il test set. Ciascuno di questi contiene svariate colonne, (sentence1
, sentence2
, label
, e idx
) a un numero variabile di righe, corrispondenti al numero di elementi in ogni set (quindi, vi sono 3668 coppie di frasi nel training set, 408 nel validation set, e 1725 nel test set).
Questo comando scarica il dataset e lo mette in cache, in ~/.cache/huggingface/dataset secondo l’impostazione predefinita. Nel Capitolo 2 è stato spiegato come personalizzare la cartella di cache impostando la variabile d’ambiente HF_HOME
.
Ogni coppia di frasi nell’oggetto raw_datasets
può essere ottenuta tramite il suo indice, come in un dizionario:
raw_train_dataset = raw_datasets["train"]
raw_train_dataset[0]
{'idx': 0,
'label': 1,
'sentence1': 'Amrozi accused his brother , whom he called " the witness " , of deliberately distorting his evidence .',
'sentence2': 'Referring to him as only " the witness " , Amrozi accused his brother of deliberately distorting his evidence .'}
Le label sono già numeri interi, quindi non è necessario alcun preprocessing. Per sapere a quale numero corrisponde quale tipo di label, si possono analizzare le features
del raw_train_dataset
. Ciò permette di capire la tipologia di ogni colonna:
raw_train_dataset.features
{'sentence1': Value(dtype='string', id=None),
'sentence2': Value(dtype='string', id=None),
'label': ClassLabel(num_classes=2, names=['not_equivalent', 'equivalent'], names_file=None, id=None),
'idx': Value(dtype='int32', id=None)}
Dietro le quinte, label
è del tipo ClassLabel
, e la corrispondenza tra i numeri e i nomi delle label è contenuta nella cartella names. 0
corrisponde a not_equivalent
(significato diverso), e 1
corrisponde a equivalent
(stesso significato).
✏️ Prova tu! Quali sono le label dell’elemento 15 del training set, e 87 del validation set?
Preprocessing del dataset
Per preprocessare il dataset, è necessario convertire il testo in numeri comprensibili al modello. Come dimostrato nel capitolo precedente, ciò viene fatto con un tokenizer (tokenizzatore). Il tokenizer prende come input sia una frase sia una lista di frasi, quindi è possibile effettuare la tokenizzazione di tutte le prime e seconde frasi di ogni coppia in questo modo:
from transformers import AutoTokenizer
checkpoint = "bert-base-uncased"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
tokenized_sentences_1 = tokenizer(raw_datasets["train"]["sentence1"])
tokenized_sentences_2 = tokenizer(raw_datasets["train"]["sentence2"])
Tuttavia, non si possono semplicemente passare al modello due frasi e sperare di predire se l’una è una parafrasi dell’altra o no. Bisogna gestire le due frasi come una coppia, e applicare il preprocessing necessario. Fortunatamente, il tokenizer può anche prendere come input una coppia di frasi e prepararla nel formato atteso dal modello BERT:
inputs = tokenizer("This is the first sentence.", "This is the second one.")
inputs
{
'input_ids': [101, 2023, 2003, 1996, 2034, 6251, 1012, 102, 2023, 2003, 1996, 2117, 2028, 1012, 102],
'token_type_ids': [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1],
'attention_mask': [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
}
Sono state già discusse nel Capitolo 2 le chiavi input_ids
e attention_mask
, ma il discorso su token_type_ids
era stato rimandato. In questo esempio, ciò può essere usato per indicare al modello quale parte dell’input è la prima frase, e quale la seconda.
✏️ Prova tu! Prendere l’element 15 del training set e tokenizzare le due frasi sia separatamente, sia come coppia. Qual è la differenza tra i due risultati?
Decodificando gli ID in input_ids
per ritrasformarli in parole:
tokenizer.convert_ids_to_tokens(inputs["input_ids"])
si ottiene:
['[CLS]', 'this', 'is', 'the', 'first', 'sentence', '.', '[SEP]', 'this', 'is', 'the', 'second', 'one', '.', '[SEP]']
Perciò è chiaro che il modello si aspetta gli input nella forma [CLS] frase1 [SEP] frase2 [SEP]
quando vi sono due frasi. Allineando con token_type_ids
si ottiene:
['[CLS]', 'this', 'is', 'the', 'first', 'sentence', '.', '[SEP]', 'this', 'is', 'the', 'second', 'one', '.', '[SEP]']
[ 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1]
Le parti dell’input corrispondenti a [CLS] frase1 [SEP]
hanno tutte un token type ID di 0
, mentre le altre parti, corrispondenti quindi a frase2 [SEP]
, hanno tutte un token type ID di 1
.
Da notare che se viene selezionato un altro checkpoint, gli input tokenizzati non conterranno necessariamente i token_type_ids
(ad esempio, non si ottengono usando un modello DistilBERT). I token_type_ids
si ottengono solo quando il modello saprebbe che farne, avendole già viste in fase di pre-addestramento.
In questo caso, BERT è stato pre-addestrato con i token type IDs, e in aggiunta all’obiettivo di masked language modeling di cui si era parlato nel Capitolo 1, vi è un altro obiettivo che si chiama next sentence prediction (predire la prossima frase). Lo scopo di questo task è modellizzare la relazione tra coppie di frasi.
Durante un task di next sentence prediction, il modello riceve una coppia di frasi (con token mascherati in maniera aleatoria) e deve predire se la seconda segue la prima. Per rendere il task meno banale, la metà delle volte le frasi si susseguono nel documento da cui erano state estratte originariamente, l’altra metà delle volte le frasi provengono da due documenti diversi.
In generale, non bisogna preoccuparsi se i token_type_ids
sono presenti o no negli input tokenizzati: finché viene usato lo stesso checkpoint per il tokenizer e il modello, tutto andrà bene poiché il tokenizer sa cosa fornire al modello.
Ora che abbiamo visto come il tokenizer può gestire una coppia di frasi, possiamo usarlo per tokenizzare l’intero dataset: come nel capitolo precedente, si può fornire al tokenizer una lista di coppie di frasi dando prima la lista delle prime frasi, e poi la lista delle seconde frasi. Questo approcchio è anche compatibile le opzioni di padding e truncation già viste nel Capitolo 2. Perciò, un modo per preprocessare il dataset di addestramento è:
tokenized_dataset = tokenizer(
raw_datasets["train"]["sentence1"],
raw_datasets["train"]["sentence2"],
padding=True,
truncation=True,
)
Questo metodo funziona, ma ha lo svantaggio di restituire un dizionario (avente input_ids
, attention_mask
, e token_type_ids
come chiavi, e delle liste di liste come valori). Oltretutto, questo metodo funziona solo se si ha a disposizione RAM sufficiente per contenere l’intero dataset durante la tokenizzazione (mentre i dataset dalla libreria 🤗 Datasets sono file Apache Arrow archiviati su disco, perciò in memoria vengono caricati solo i campioni richiesti).
Per tenere i dati come dataset, utilizzare il metodo Dataset.map()
. Ciò permette anche della flessibilità extra, qualora fosse necessario del preprocessing aggiuntivo oltre alla tokenizzazione. Il metodo map()
applica una funziona ad ogni elemento del dataset, perciò bisogna definire una funzione che tokenizzi gli input:
def tokenize_function(example):
return tokenizer(example["sentence1"], example["sentence2"], truncation=True)
Questa funzione riceve un dizionario (come gli elementi del nostro dataset) e restituisce un nuovo dizionario con input_ids,
attention_mask, e
token_type_idscome chiavi. Funziona anche se il dizionario
examplecontiene svariati campioni (ad una chiave corrisponde una lista di frasi) poiché il
tokenizerfunziona con liste di coppie di frasi, come già visto. Ciò permette di usare l'opzione
batched=Truenella chiamata a
map(), che accelererà di molto la tokenizzazione. Il
tokenizer` si appoggia ad un tokenizer scritto in Rust della libreria 🤗 Tokenizers. Questo tokenizer può essere molto veloce, ma solo se gli vengono forniti molti input insieme.
Per ora non ci siamo preoccupati del parametro padding
nella nostra funzione di tokenizzazione. Questo perché il padding di tutti i campioni fino a lunghezza massima non è efficiente: è meglio fare il padding dei campioni quando stiamo assemblando una batch, poiché in quel momento è necessario il padding solo fino alla lunghezza massima nel batch, non la lunghezza massima nell’intero dataset. Ciò permette di risparmiare molto tempo e potenza di calcolo nel caso in cui gli input abbiano lunghezze molto varie!
Ecco come si applica la funzione di tokenizzazione sull’intero dataset. Bisogna usare batched=True
nella chiamata a map
in modo tale che la funzione venga applicata a vari elementi del dataset insieme, e non ad ogni elemento separatamente. Ciò permette un preprocessing più rapido.
tokenized_datasets = raw_datasets.map(tokenize_function, batched=True)
tokenized_datasets
La libreria 🤗 Datasets aggiunge nuovi campi ai dataset, uno per ogni chiave nel dizionario restituito dalla funzione di preprocessing:
DatasetDict({
train: Dataset({
features: ['attention_mask', 'idx', 'input_ids', 'label', 'sentence1', 'sentence2', 'token_type_ids'],
num_rows: 3668
})
validation: Dataset({
features: ['attention_mask', 'idx', 'input_ids', 'label', 'sentence1', 'sentence2', 'token_type_ids'],
num_rows: 408
})
test: Dataset({
features: ['attention_mask', 'idx', 'input_ids', 'label', 'sentence1', 'sentence2', 'token_type_ids'],
num_rows: 1725
})
})
Si può anche applicare il multiprocessing durante il preprocessing con la funzione map()
utilizzando il parametro num_proc
. Ciò non è stato dimostrato qui perché la libreria 🤗 Tokenizers già utilizza vari thread per tokenizzare i campioni più rapidamente, ma nel caso in cui non venga usato un tokenizer rapido di questa libreria, ciò potrebbe velocizzare il preprocessing.
La funzione tokenize_function
restituisce un dizionario con input_ids
, attention_mask
, e token_type_ids
come chiavi, quindi quei tre campi vengono aggiunti a tutti gli split (le parti) del dataset. Si possono anche cambiare i campi esistenti nel caso in cui la funzione di preprocessing restituisca un nuovo valore per una chiave già esistente nel dataset a cui viene applicato map()
.
L’ultima cosa da fare è il padding di tutti i campioni alla lunghezza dell’elemento più lungo quando sono inseriti in una batch — una tecnica che si chiama dynamic padding.
Dynamic padding
La funzione responsabile dell’assembramento dei campioni in una batch si chiama collate function (funzione di raccolta). È uno dei parametri che si possono passare quando si costruisce un DataLoader
, e il default è la funzione che converte semplicemente i campioni in tensori PyTorch e li concatena (ricorsivamente nel caso in cui gli elementi siano liste, tuple o dizionari). Ciò non sarà possibile nel nostro caso poiché gli input non hanno tutti la stessa lunghezza. Abbiamo rimandato il padding apposta, per poterlo applicare secondo necessità ad ogni batch, evitando quindi input troppo lunghi con molto padding. Ciò accelererà l’addestramento di un bel po’, ma può causare problemi se l’addestramento avviene su TPU — le TPU preferiscono dimensioni fisse, anche se ciò richiede del padding in più.
In pratica, bisogna definire una collate function che applichi la giusta quantità di padding agli elementi del dataset in una stessa batch. Fortunatamente, la libreria 🤗 Transformers fornisce questa funziona tramite DataCollatorWithPadding
. Essa prende in input un tokenizer quando viene istanziata (per individuare quale token da usare per il padding, e se il modello si aspetta padding a sinistra o a destra dell’input) e farà tutto il necessario:
from transformers import DataCollatorWithPadding
data_collator = DataCollatorWithPadding(tokenizer=tokenizer)
Per testare questo nuovo gioco, analizziamo alcuni campioni dal set di addestramento da raggruppare in un batch. Adesso togliamo le colonne idx
, sentence1
, e sentence2
poiché non saranno necessarie e contengono stringhe (e non si possono creare tensori con stringhe), e controlliamo le lunghezze di ogni elemento nel batch:
samples = tokenized_datasets["train"][:8]
samples = {k: v for k, v in samples.items() if k not in ["idx", "sentence1", "sentence2"]}
[len(x) for x in samples["input_ids"]]
[50, 59, 47, 67, 59, 50, 62, 32]
Nulla di sorprendente, i campioni hanno lunghezza variabile da 32 a 67. Il padding dinamico significa che i campioni in questo batch dovrebbero tutti ricevere un padding fino alla lunghezza di 67, il massimo nel batch. Senza padding dinamico, tutti i campioni dovrebbero ricevere un padding fino alla lunghezza massima nell’intero dataset, o la lunghezza massima processabile dal modello. Bisogna controllare che il data_collator
stia applicando un padding dinamico al batch in maniera corretta:
batch = data_collator(samples)
{k: v.shape for k, v in batch.items()}
{'attention_mask': torch.Size([8, 67]),
'input_ids': torch.Size([8, 67]),
'token_type_ids': torch.Size([8, 67]),
'labels': torch.Size([8])}
Ottimo! Adesso che siamo passati dal testo grezzo a dei batch che il modello è in grado di trattare, siamo pronti per affinarlo!
✏️ Prova tu! Replicare il preprocessing sul dataset GLUE SST-2. È leggermente diverso poiche è composto da frasi singole e non da coppie di frasi, ma il resto della procedura dovrebbe essere simile. Per una sfida più complessa, provare a scrivere una funzione di preprocessing che funzioni per qualsiasi dei compiti in GLUE.