Pytorch
TensorFlow

#
Tokenizers

I tokenizer sono uno dei componenti fondamentali della pipeline NLP. Servono a uno scopo: tradurre il testo in dati che possono essere elaborati dal modello. I modelli possono elaborare solo numeri, quindi i tokenizer devono convertire i nostri input testuali in dati numerici. In questa sezione analizzeremo cosa succede esattamente nella pipeline di tokenizzazione.

Nelle attività di NLP, i dati che vengono generalmente processati sono testi non elaborati, grezzi. Ecco un esempio di testo grezzo:

    Jim Henson was a puppeteer

Tuttavia, i modelli possono elaborare solo numeri, quindi dobbiamo trovare un modo per convertire il testo non elaborato in numeri. Questo è ciò che fanno i tokenizer, e ci sono molti modi per farlo. L’obiettivo è trovare la rappresentazione più significativa, cioè quella che ha più senso per il modello, e, se possibile, la rappresentazione più piccola.

Vediamo alcuni esempi di algoritmi di tokenizzazione e cerchiamo di rispondere ad alcune domande sulla tokenizzazione.

##
Tokenizer basati sulle parole

Il primo tipo di tokenizzatore che viene in mente è quello basato sulle parole. In genere è molto facile da configurare e utilizzare con poche regole e spesso produce risultati decenti. Ad esempio, nell’immagine qui sotto, l’obiettivo è dividere il testo non elaborato in parole e trovare una rappresentazione numerica per ciascuna di esse:

Esistono diversi modi per dividere il testo. Ad esempio, si possono usare gli spazi bianchi per suddividere il testo in parole, applicando la funzione `split()` di Python:

    tokenized_text = "Jim Henson was a puppeteer".split()
    print(tokenized_text)

    ['Jim', 'Henson', 'was', 'a', 'puppeteer']

Esistono anche varianti di tokenizzatori di parole che prevedono regole aggiuntive per la punteggiatura. Con questo tipo di tokenizer, possiamo ritrovarci con “vocabolari” piuttosto grandi, dove un vocabolario è definito dal numero totale di token indipendenti che abbiamo nel nostro corpus.

A ogni parola viene assegnato un ID, a partire da 0 fino alla dimensione del vocabolario. Il modello utilizza questi ID per identificare ogni parola.

Se vogliamo coprire completamente una lingua con un tokenizzatore basato sulle parole, dovremo avere un identificatore per ogni parola della lingua, il che genererà un’enorme quantità di token. Per esempio, nella lingua inglese ci sono più di 500.000 parole, quindi per costruire una mappa da ogni parola a un ID di input dovremmo tenere traccia di così tanti ID. Inoltre, parole come “cane” sono rappresentate in modo diverso da parole come “cani”, e il modello inizialmente non avrà modo di sapere che “cane” e “cani” sono simili: identificherà le due parole come non correlate. Lo stesso vale per altre parole simili, come “correre” e “correndo”, che il modello non vedrà inizialmente come simili.

Infine, abbiamo bisogno di un token personalizzato per rappresentare le parole che non fanno parte del nostro vocabolario. Questo è noto come token “unknown”, spesso rappresentato come ”[UNK]” o ”<unk>”. Se il tokenizer produce molti token di questo tipo è generalmente un brutto segno, perché non è riuscito a trovare una rappresentazione sensata della parola e si stanno perdendo informazioni. L’obiettivo della creazione del vocabolario è quello di fare in modo che il tokenizzatore inserisca il minor numero possibile di parole nel token sconosciuto.

Un modo per ridurre la quantità di token sconosciuti è quello di andare un livello più in profondità, usando un tokenizer character-based.

##
Character-based

I tokenizer basati sui caratteri dividono il testo in caratteri, anziché in parole. Ciò comporta due vantaggi principali:

- Il vocabolario è molto più ridotto.
- I token fuori vocabolario (sconosciuti) sono molto meno numerosi, poiché ogni parola può essere costruita a partire dai caratteri.

Ma anche in questo caso sorgono alcune questioni relative agli spazi e alla punteggiatura:

Anche questo approccio non è perfetto. Poiché la rappresentazione è ora basata su caratteri anziché su parole, si potrebbe sostenere che, intuitivamente, è meno significativa: ogni carattere non significa molto da solo, mentre è così per le parole. Tuttavia, anche in questo caso il significato varia a seconda della lingua; in cinese, ad esempio, ogni carattere porta con sé più informazioni di un carattere in una lingua latina.

Un’altra cosa da considerare è che ci ritroveremo con una quantità molto elevata di token da elaborare da parte del nostro modello: mentre una parola sarebbe un singolo token con un tokenizzatore basato sulle parole, può facilmente trasformarsi in 10 o più token quando viene convertita in caratteri.

Per ottenere il meglio dei due mondi, possiamo utilizzare una terza tecnica che combina i due approcci: la tokenizzazione delle sottoparole.

##
Tokenizzazione delle sottoparole

Gli algoritmi di tokenizzazione delle sottoparole si basano sul principio che le parole di uso frequente non devono essere suddivise in sottoparole più piccole, ma le parole rare devono essere scomposte in sottoparole significative.

Ad esempio, “fastidiosamente” potrebbe essere considerata una parola rara e potrebbe essere scomposta in “fastidioso” e “mente”. È probabile che queste due parole compaiano più frequentemente come sottoparole a sé stanti, mentre il significato di “fastidiosamente” viene mantenuto dal significato composito di “fastidioso” e “mente”.

Ecco un esempio che mostra come un algoritmo di tokenizzazione delle sottoparole tokenizzerebbe la sequenza “Let’s do tokenization!“:

Queste sottoparole finiscono per fornire un significato semantico: per esempio, nell’esempio precedente “tokenization” è stato diviso in “token” e “ization”, due token che hanno un significato semantico pur essendo efficienti dal punto di vista dello spazio (sono necessari solo due token per rappresentare una parola lunga). Questo ci permette di avere una copertura relativamente buona con vocabolari piccoli e quasi nessun token sconosciuto.

Questo approccio è particolarmente utile nelle lingue agglutinanti come il turco, dove è possibile formare parole complesse (quasi) arbitrariamente lunghe mettendo insieme sottoparole.

###
E non solo!

Non sorprende che esistano molte altre tecniche. Per citarne alcune:

- Byte-level BPE, utilizzato in GPT-2
- WordPiece, utilizzato in BERT
- SentencePiece o Unigram, utilizzato in diversi modelli multilingua.

A questo punto dovresti avere una conoscenza sufficiente di come funzionano i tokenizer per iniziare a usare l’API.

##
Caricamento e salvataggio

Caricare e salvare i tokenizer è semplice come per i modelli. In realtà, si basa sugli stessi due metodi: `from_pretrained()` e `save_pretrained()`. Questi metodi caricano o salvano l’algoritmo usato dal tokenizer (un po’ come l’architettura del modello) ed il suo vocabolario (un po’ come i pesi del modello).

Il caricamento del tokenizer di BERT, addestrato con lo stesso checkpoint di BERT, avviene nello stesso modo in cui si carica il modello, con la differenza che si usa la classe `BertTokenizer`:

    from transformers import BertTokenizer

    tokenizer = BertTokenizer.from_pretrained("bert-base-cased")

In modo simile a `AutoModel`, la classe `AutoTokenizer` prenderà la classe tokenizer appropriata nella libreria in base al nome del checkpoint e può essere usata direttamente con qualsiasi checkpoint:

    from transformers import AutoTokenizer

    tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")

Ora possiamo usare il tokenizer come mostrato nella sezione precedente:

    tokenizer("Using a Transformer network is simple")

    {'input_ids': [101, 7993, 170, 11303, 1200, 2443, 1110, 3014, 102],
     'token_type_ids': [0, 0, 0, 0, 0, 0, 0, 0, 0],
     'attention_mask': [1, 1, 1, 1, 1, 1, 1, 1, 1]}

Salvare un tokenizer è identico a salvare un modello:

    tokenizer.save_pretrained("directory_on_my_computer")

Parleremo meglio dei `token_type_ids` nel Capitolo 3 e spiegheremo la chiave `attention_mask` un po’ più avanti. Per prima cosa, vediamo come vengono generati gli `input_ids`. Per farlo, dobbiamo esaminare i metodi intermedi del tokenizer.

##
Codifica

La traduzione del testo in numeri è nota come codifica. La codifica avviene in due fasi: la tokenizzazione, seguita dalla conversione in input ID.

Come abbiamo visto, il primo passo consiste nel dividere il testo in parole (o parti di parole, simboli di punteggiatura, ecc.), solitamente chiamate token. Ci sono diverse regole che possono governare questo processo, ed è per questo che dobbiamo istanziare il tokenizer usando il nome del modello, per assicurarci di usare le stesse regole che sono state usate quando il modello è stato preaddestrato.

Il secondo passo consiste nel convertire i token in numeri, in modo da poterne costruire un tensore e darlo in pasto al modello. Per fare questo, il tokenizer ha un vocabolario, che è la parte che scarichiamo quando lo istanziamo con il metodo `from_pretrained()`. Anche in questo caso, dobbiamo utilizzare lo stesso vocabolario usato quando il modello è stato preaddestrato.

Per comprendere meglio le due fasi, le esploreremo separatamente. Si noti che utilizzeremo alcuni metodi che eseguono parti della pipeline di tokenizzazione separatamente per mostrare i risultati intermedi di tali passaggi, ma in pratica si dovrebbe chiamare il tokenizzatore direttamente sui propri input (come mostrato nella sezione 2).

###
Processo di tokenizzazione

Il processo di tokenizzazione viene eseguito dal metodo `tokenize()` del tokenizer:

    from transformers import AutoTokenizer

    tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")

    sequence = "Using a Transformer network is simple"
    tokens = tokenizer.tokenize(sequence)

    print(tokens)

L’output di questo metodo è un elenco di stringhe, o token:

    ['Using', 'a', 'transform', '##er', 'network', 'is', 'simple']

Questo tokenizzatore è un tokenizzatore di sottoparole: divide le parole fino a ottenere token che possono essere rappresentati dal suo vocabolario. È il caso di `trasformatore`, che viene diviso in due token: `trasforma` e `##tore`.

###
Dai token agli input IDS

La conversione in ID di input è gestita dal metodo del tokenizer `convert_tokens_to_ids()`:

    ids = tokenizer.convert_tokens_to_ids(tokens)

    print(ids)

    [7993, 170, 11303, 1200, 2443, 1110, 3014]

Questi risultati, una volta convertiti nel tensore quadro appropriato, possono essere successivamente utilizzati come input per un modello, come visto in precedenza in questo capitolo.

✏️ Provaci anche tu! Replica gli ultimi due passaggi (tokenizzazione e conversione in ID di input) sulle frasi di input utilizzate nella sezione 2 (“I’ve been waiting for a HuggingFace course my whole life.” e “I hate this so much!”). Verificate di ottenere gli stessi ID di input che abbiamo ottenuto in precedenza!

##
Decodifica

La decodifica avviene al contrario: dagli indici del vocabolario si vuole ottenere una stringa. Questo può essere fatto con il metodo `decode()` come segue:

    decoded_string = tokenizer.decode([7993, 170, 11303, 1200, 2443, 1110, 3014])
    print(decoded_string)

    'Using a Transformer network is simple'

Si noti che il metodo `decode` non solo converte gli indici in token, ma raggruppa anche i token che fanno parte delle stesse parole per produrre una frase leggibile. Questo comportamento sarà estremamente utile quando utilizzeremo modelli che prevedono un nuovo testo (o un testo generato da un prompt, o per problemi di sequenza-sequenza come la traduzione o il riassunto).

A questo punto si dovrebbero comprendere le operazioni atomiche che un tokenizer può gestire: tokenizzazione, conversione in ID e conversione degli ID in stringhe. Tuttavia, abbiamo solo raschiato la punta dell’iceberg. Nella sezione che segue, vedremo i limiti del nostro approccio e vedremo come superarli.