LLM Course documentation

Superputerile tokenizerilor rapizi

Hugging Face's logo
Join the Hugging Face community

and get access to the augmented documentation experience

to get started

Superputerile tokenizerilor rapizi

Ask a Question Open In Colab Open In Studio Lab

În această secțiune, vom analiza mai atent capacitățile tokenizerilor din 🤗 Transformers. Până acum, i-am folosit doar pentru tokenizarea inputurilor sau decodificarea ID-urilor înapoi în text, dar tokenizerii - în special cei susținuți de biblioteca 🤗 Tokenizers - pot face mult mai multe. Pentru a ilustra aceste funcții suplimentare, vom explora modul de reproducere a rezultatelor pipeline-urilor token-classification (pe care le-am numit ner) și question-answering pe care le-am întâlnit pentru prima dată în Capitolul 1.

În discuția următoare, vom face adesea distincția între tokenizatori “lenți” și “rapizi”. Tokenizerii lenți sunt cei scriși în Python în interiorul bibliotecii 🤗 Transformers, în timp ce versiunile rapide sunt cele furnizate de 🤗 Tokenizers, care sunt scrise în Rust. Dacă vă amintiți de tabelul din Capitolul 5 care a raportat cât timp a durat un tokenizer rapid și unul lent pentru a tokeniza datasetul Drug Review, ar trebui să aveți o idee despre de ce îi numim lenți și rapizi:

Fast tokenizer Slow tokenizer
batched=True 10,8s 4min41s
batched=False 59,2s 5min3s

⚠️ Atunci când tokenizați o singură propoziție, nu veți vedea întotdeauna o diferență de viteză între versiunea lentă și rapidă ale aceluiași tokenizer. De fapt, versiunea rapidă poate fi chiar mai lentă! Abia atunci când tokenizați multe texte în paralel, în același timp, veți putea observa clar diferența.

Batch encoding

Rezultatul unui tokenizer nu este un simplu dicționar Python; ceea ce obținem este de fapt un obiect special BatchEncoding. Este o subclasă a unui dicționar (de aceea am putut indexa acel rezultat fără nici o problemă mai devreme), dar cu metode suplimentare care sunt utilizate în principal de către tokenizerii rapizi.

Pe lângă capacitățile lor de paralelizare, funcționalitatea principală a tokenizerilor rapizi este aceea că aceștia țin întotdeauna evidența intervalului original de texte din care provin tokenii finali - o funcționalitate pe care o numim offset mapping. Acest lucru deblochează funcții precum mappingul fiecărui cuvânt la tokens pe care îi generează sau mappingul fiecărui caracter al textului original la tokenul în care se află și invers.

Acum hai să aruncăm o privire la un exemplu:

from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")
example = "My name is Sylvain and I work at Hugging Face in Brooklyn."
encoding = tokenizer(example)
print(type(encoding))

Așa cum s-a menționat anterior, în outputul tokenizerului obținem un obiect BatchEncoding:

<class 'transformers.tokenization_utils_base.BatchEncoding'>

Deoarece clasa AutoTokenizer selectează un tokenizer rapid în mod implicit, putem utiliza metodele suplimentare pe care acest obiect BatchEncoding le oferă. Avem două metode de verificare dacă tokenizerul nostru este unul lent sau rapid. Putem verifica fie atributul is_fast al tokenizer-ului:

tokenizer.is_fast
True

sau același atribut al encoding:

encoding.is_fast
True

Hai să vedem ce ne permite un tokenizer rapid să facem. În primul rând, putem accesa tokenul fără a fi nevoie să facem convert ID-urile înapoi în tokens:

encoding.tokens()
['[CLS]', 'My', 'name', 'is', 'S', '##yl', '##va', '##in', 'and', 'I', 'work', 'at', 'Hu', '##gging', 'Face', 'in',
 'Brooklyn', '.', '[SEP]']

În acest caz tokenul la indexul 5 este ##yl, ceea ce este o parte a cuvântului “Sylvain” în propoziția originală. În același timp putem folosi metoda word_ids() pentru a obține indexul cuvântului din care provine fiecare token:

encoding.word_ids()
[None, 0, 1, 2, 3, 3, 3, 3, 4, 5, 6, 7, 8, 8, 9, 10, 11, 12, None]

Putem vedea că tokenizerul are tokeni speciali `[CLS]` și `[SEP]` care sunt mapped la `None`, iar apoi fiecare token este mapped la cuvântul din care provine. Acest lucru este deosebit de util pentru a determina dacă un token este la începutul unui cuvânt sau dacă două tokenuri sunt în același cuvânt. Ne-am putea baza pe prefixul `##` pentru aceasta, dar funcționează doar pentru tokenizeri de tip BERT; această metodă funcționează pentru orice tip de tokenizator, atâta timp cât este unul rapid. În capitolul următor, vom vedea cum putem utiliza această capabilitate pentru a aplica labeluri pe care le avem pentru fiecare cuvânt în mod corespunzător tokenurilor în sarcini precum named entity recognition (NER) și part-of-speech (POS). De asemenea, îl putem utiliza pentru a face mask tuturor tokenurilor care provin din același cuvânt în masked language modeling(o tehnică numită _whole word masking_).

<Tip>

Noțiunea de ceea ce este un cuvânt este complicată. De exemplu, "I'll" (o prescurtare a "I will") contează ca unul sau două cuvinte? Acest lucru depinde de tokenizer și de operațiunea de pre-tokenizare pe care o aplică. Unii tokenizeri se divid doar pe spații, așa că vor considera acest lucru ca un singur cuvânt. Alții folosesc punctuația pe lângă spații, deci vor considera două cuvinte.

✏️ **Încercați!** Creați un tokenizer din checkpointurile `bert-base-cased` și `roberta-base` și tokenizați "81s" cu ele. Ce observați? Care sunt ID-urile cuvintelor?

</Tip>

În mod similar, există o metodă `sentence_ids()` pe care o putem utiliza pentru a face map unui token la propoziția din care provine (deși, în acest caz, `token_type_ids` returnate de tokenizer ne pot oferi aceeași informație).

În cele din urmă, putem face map oricărui cuvânt sau token la caracterele din textul original și invers, prin intermediul metodelor `word_to_chars()` sau `token_to_chars()` și `char_to_word()` sau `char_to_token()`. De exemplu, metoda `word_ids()` ne-a spus că `##yl` face parte din cuvântul cu indicele 3, dar ce cuvânt este în propoziție? Putem afla astfel:

```py
start, end = encoding.word_to_chars(3)
example[start:end]
Sylvain

Așa cum am menționat anterior, toate acestea sunt posibile datorită faptului că tokenizerul rapid ține evidența spanului de text de la care provine fiecare token într-o listă de offseturi. Pentru a ilustra modul în care se utilizează acestea, în continuare vă vom arăta cum să replicați rezultatele pipelineului token-classification manual.

✏️ Încercați! Creați propriul exemplu de text și încercați să înțelegeți care tokenuri sunt asociate cu ID-ul cuvântului și, de asemenea, cum să extrageți spanurile pentru singur cuvânt. Pentru puncte bonus, încercați să utilizați două propoziții ca inputuri și să vedeți dacă ID-urile propozițiilor au sens pentru voi.

În interiorul pipelineului token-classification

În Capitolul 1 am avut primul nostru contact aplicând NER - unde sarcina constă în identificarea părților textului care corespund entităților precum persoane, locații sau organizații - cu funcția pipeline() din 🤗 Transformers. Apoi, în Capitolul 2, am văzut cum un pipeline grupează cele trei etape necesare pentru a obține predicțiile de la un text “raw”: tokenizare, trecerea inputurilor prin model și post-procesare. Primele două etape din pipelineul token-classification sunt la fel ca în orice alt pipeline, dar post-procesarea este puțin mai complexă - hai să vedem cum!

Obținerea rezultatelor de bază cu pipelineul

În primul rând, trebuie să luăm un token classification pipeline pentru a obține câteva rezultate ca să le putem compara manual. Modelul utilizat în mod implicit este dbmdz/bert-large-cased-finetuned-conll03-english; acesta aplică NER pe propoziții:

from transformers import pipeline

token_classifier = pipeline("token-classification")
token_classifier("My name is Sylvain and I work at Hugging Face in Brooklyn.")
[{'entity': 'I-PER', 'score': 0.9993828, 'index': 4, 'word': 'S', 'start': 11, 'end': 12},
 {'entity': 'I-PER', 'score': 0.99815476, 'index': 5, 'word': '##yl', 'start': 12, 'end': 14},
 {'entity': 'I-PER', 'score': 0.99590725, 'index': 6, 'word': '##va', 'start': 14, 'end': 16},
 {'entity': 'I-PER', 'score': 0.9992327, 'index': 7, 'word': '##in', 'start': 16, 'end': 18},
 {'entity': 'I-ORG', 'score': 0.97389334, 'index': 12, 'word': 'Hu', 'start': 33, 'end': 35},
 {'entity': 'I-ORG', 'score': 0.976115, 'index': 13, 'word': '##gging', 'start': 35, 'end': 40},
 {'entity': 'I-ORG', 'score': 0.98879766, 'index': 14, 'word': 'Face', 'start': 41, 'end': 45},
 {'entity': 'I-LOC', 'score': 0.99321055, 'index': 16, 'word': 'Brooklyn', 'start': 49, 'end': 57}]

Modelul a identificat fiecare token generdat de “Sylvain” ca o persoană, fiecare token generat de “Hugging Face” ca o organizație, și fiecare token “Brooklin” ca o locație”. În același timp putem întreba pipelineul să grupeze împreună tokenurile care corespund cu aceeași entitate.

from transformers import pipeline

token_classifier = pipeline("token-classification", aggregation_strategy="simple")
token_classifier("My name is Sylvain and I work at Hugging Face in Brooklyn.")
[{'entity_group': 'PER', 'score': 0.9981694, 'word': 'Sylvain', 'start': 11, 'end': 18},
 {'entity_group': 'ORG', 'score': 0.97960204, 'word': 'Hugging Face', 'start': 33, 'end': 45},
 {'entity_group': 'LOC', 'score': 0.99321055, 'word': 'Brooklyn', 'start': 49, 'end': 57}]

Aggregation_strategy aleasă va modifica scorurile calculate pentru fiecare entitate grupată. Cu "simple", scorul este doar media scorurilor pentru fiecare token din entitatea dată: de exemplu, scorul pentru “Sylvain” este media scorurilor pe care le-am văzut în exemplele anterioare pentru token-urile S, ##yl, ##va și ##in. Alte strategii disponibile sunt:

  • "first", unde scorul fiecărei entități este scorul primului token al acelei entități (astfel, pentru “Sylvain” ar fi 0.993828, scorul token-ului S)
  • "max", unde scorul fiecărei entități este scorul maxim al tokenilor din acea entitate (astfel, pentru “Hugging Face” ar fi 0.98879766, scorul “Face”)
  • "average", unde scorul fiecărei entități este media scorurilor cuvintelor care compun acea entitate (astfel, pentru “Sylvain” nu ar exista nicio diferență față de strategia "simple", dar “Hugging Face” ar avea un scor de 0.9819, media scorurilor pentru “Hugging”, 0.975 și “Face”, 0.98879)

Acum să vedem cum putem obține aceste rezultate fără a folosi funcția pipeline()!

De la inputuri la predicții

În primul rând trebuie să tokenizăm inputurile și sp le trecem prin model. Acest lucru este făcut excat ca în Capitolul 2; noi am inițializat tokenizerul și modelul folosind clasa AutoXxx și apoi am folosit-o pe exemplul nostru:

from transformers import AutoTokenizer, AutoModelForTokenClassification

model_checkpoint = "dbmdz/bert-large-cased-finetuned-conll03-english"
tokenizer = AutoTokenizer.from_pretrained(model_checkpoint)
model = AutoModelForTokenClassification.from_pretrained(model_checkpoint)

example = "My name is Sylvain and I work at Hugging Face in Brooklyn."
inputs = tokenizer(example, return_tensors="pt")
outputs = model(**inputs)

Deoarece aici folosim AutoModelForTokenClassification , noi primim un set de logits pentru fiecare token în input sequence:

print(inputs["input_ids"].shape)
print(outputs.logits.shape)
torch.Size([1, 19])
torch.Size([1, 19, 9])

Noi avem un batch cu 1 secvență din 19 tokenuri și modelul are 9 labeluri diferite, deci outputul modelului are un shape de 1 x 19 x 9. Ca și pentru text classification pipeline, noi folosim o funcție softmax pentru a face convert logiturilor în probabilități și luăm argmax pentru a obține predicții(atrage atenția asupra fatpului că putem lua argmax pe logituri deoarece softmax nu schimbă ordinea):

import torch

probabilities = torch.nn.functional.softmax(outputs.logits, dim=-1)[0].tolist()
predictions = outputs.logits.argmax(dim=-1)[0].tolist()
print(predictions)
[0, 0, 0, 0, 4, 4, 4, 4, 0, 0, 0, 0, 6, 6, 6, 0, 8, 0, 0]

Atributul model.config.id2label con;ine mappingul indexilor la labeluri pe care le putem folosi pentru a înțelege predicțiile:

model.config.id2label
{0: 'O',
 1: 'B-MISC',
 2: 'I-MISC',
 3: 'B-PER',
 4: 'I-PER',
 5: 'B-ORG',
 6: 'I-ORG',
 7: 'B-LOC',
 8: 'I-LOC'}

Cum am văzut mai devreme, există 9 labeluri: O este labelul pentru tokenurile care nu se află în nicio entitate numită(aceasta reprezintă “exteriorul”), și avem apoi două labeluri pentru fiecare tip de entitate (divers, persoană, organizație și locație). Eticheta B-XXX indică faptul că tokenul se află la începutul entității XXX și eticheta I-XXX indică faptul că tokenul se află în interiorul entității XXX. De exemplu, în exemplul curent ne-am aștepta ca modelul nostru să clasifice tokenul S ca B-PER (începutul unei entități de-tip persoană) și tokenurile ##yl, ##va și ##in ca I-PER (în interiorul unei entități de tip persoană).

S-ar putea să credeți că modelul a greșit în acest caz, deoarece a atribuit eticheta I-PER tuturor acestor patru tokeni, dar acesta nu este în întregime adevărat. Există, de fapt, două formate pentru labelurile B- și I-: IOB1 și IOB2. Formatul IOB2 (în roz mai jos), este cel pe care l-am introdus, în timp ce formatul IOB1 (în albastru), utilizează labelurile care încep cu B- doar pentru a separa două entități adiacente de același tip. Modelul pe care îl utilizăm a fost fine-tuned pe un dataset care utilizează acel format, ceea ce explică de ce atribuie labelul I-PER tokenului S.

IOB1 vs IOB2 format

Cu maparea aceasta, suntem gat a să reproducem(aproape în total) rezultat primului pipeline — noi putem lua scorul și labelul fiecărui token care nu a fost clasificat ca O:

results = []
tokens = inputs.tokens()

for idx, pred in enumerate(predictions):
    label = model.config.id2label[pred]
    if label != "O":
        results.append(
            {"entity": label, "score": probabilities[idx][pred], "word": tokens[idx]}
        )

print(results)
[{'entity': 'I-PER', 'score': 0.9993828, 'index': 4, 'word': 'S'},
 {'entity': 'I-PER', 'score': 0.99815476, 'index': 5, 'word': '##yl'},
 {'entity': 'I-PER', 'score': 0.99590725, 'index': 6, 'word': '##va'},
 {'entity': 'I-PER', 'score': 0.9992327, 'index': 7, 'word': '##in'},
 {'entity': 'I-ORG', 'score': 0.97389334, 'index': 12, 'word': 'Hu'},
 {'entity': 'I-ORG', 'score': 0.976115, 'index': 13, 'word': '##gging'},
 {'entity': 'I-ORG', 'score': 0.98879766, 'index': 14, 'word': 'Face'},
 {'entity': 'I-LOC', 'score': 0.99321055, 'index': 16, 'word': 'Brooklyn'}]

Acest lucru este foarte similar cu ce am avut mai devreme, cu o excepție: pipelineul de asemenea ne-a oferit informație despre start și end al fiecărei entități în propoziția originală. Acum e momentul când offset mappingul nostru ne va ajuta. Pentru a obține offseturile, noi trebuie să setăm return_offsets_mapping=True când aplicăm tokenizerul pe inputurile noastre:

inputs_with_offsets = tokenizer(example, return_offsets_mapping=True)
inputs_with_offsets["offset_mapping"]
[(0, 0), (0, 2), (3, 7), (8, 10), (11, 12), (12, 14), (14, 16), (16, 18), (19, 22), (23, 24), (25, 29), (30, 32),
 (33, 35), (35, 40), (41, 45), (46, 48), (49, 57), (57, 58), (0, 0)]

Fiecare tuple este spanul de text care corespunde fiecărui token, unde (0, 0) este rezervat pentru tokenii speciali. Noi am văzut înainte că tokenul la indexul 5 este ##yl, care are aici (12, 14) ca offsets. Dacă luăm sliceul corespunzător în exemplul nostru:

example[12:14]

noi obținem spanul propriu de text fără ##:

yl

Folosind aceasta, putem acum completa rezultatele anterioare:

results = []
inputs_with_offsets = tokenizer(example, return_offsets_mapping=True)
tokens = inputs_with_offsets.tokens()
offsets = inputs_with_offsets["offset_mapping"]

for idx, pred in enumerate(predictions):
    label = model.config.id2label[pred]
    if label != "O":
        start, end = offsets[idx]
        results.append(
            {
                "entity": label,
                "score": probabilities[idx][pred],
                "word": tokens[idx],
                "start": start,
                "end": end,
            }
        )

print(results)
[{'entity': 'I-PER', 'score': 0.9993828, 'index': 4, 'word': 'S', 'start': 11, 'end': 12},
 {'entity': 'I-PER', 'score': 0.99815476, 'index': 5, 'word': '##yl', 'start': 12, 'end': 14},
 {'entity': 'I-PER', 'score': 0.99590725, 'index': 6, 'word': '##va', 'start': 14, 'end': 16},
 {'entity': 'I-PER', 'score': 0.9992327, 'index': 7, 'word': '##in', 'start': 16, 'end': 18},
 {'entity': 'I-ORG', 'score': 0.97389334, 'index': 12, 'word': 'Hu', 'start': 33, 'end': 35},
 {'entity': 'I-ORG', 'score': 0.976115, 'index': 13, 'word': '##gging', 'start': 35, 'end': 40},
 {'entity': 'I-ORG', 'score': 0.98879766, 'index': 14, 'word': 'Face', 'start': 41, 'end': 45},
 {'entity': 'I-LOC', 'score': 0.99321055, 'index': 16, 'word': 'Brooklyn', 'start': 49, 'end': 57}]

Acest răspuns e același răspuns pe care l-am primit de la primul pipeline:

Gruparea entităților

Utilizarea offseturilor pentru a determina cheile de start și de sfârșit pentru fiecare entitate este util, dar această informație nu este strict necesară. Când dorim să grupăm entitățile împreună, totuși, offseturile ne vor salva o mulțime de messy code. De exemplu, dacă am dori să grupăm împreună tokenii Hu, ##gging și Face, am putea crea reguli speciale care să spună că primele două ar trebui să fie atașate și să înlăturăm ##, iar Face ar trebui adăugat cu un spațiu, deoarece nu începe cu ## — dar acest lucru ar funcționa doar pentru acest tip particular de tokenizer. Ar trebui să scriem un alt set de reguli pentru un tokenizer SentencePiece sau unul Byte-Pair-Encoding (discutat mai târziu în acest capitol).

Cu offseturile, tot acel cod custom dispare: pur și simplu putem lua spanul din textul original care începe cu primul token și se termină cu ultimul token. Deci, în cazul tokenurilor Hu, ##gging și Face, ar trebui să începem la caracterul 33 (începutul lui Hu) și să ne oprim înainte de caracterul 45 (sfârșitul lui Face):

example[33:45]
Hugging Face

Pentru a scrie codul care post-procesează predicțiile în timp ce grupăm entitățile, vom grupa entitățile care sunt consecutive și labeled cu I-XXX, cu excepția primeia, care poate fi labeled ca B-XXX sau I-XXX (decidem să oprim gruparea unei entități atunci când întâlnim un O, un nou tip de entitate, sau un B-XXX care ne spune că o entitate de același tip începe):

import numpy as np

results = []
inputs_with_offsets = tokenizer(example, return_offsets_mapping=True)
tokens = inputs_with_offsets.tokens()
offsets = inputs_with_offsets["offset_mapping"]

idx = 0
while idx < len(predictions):
    pred = predictions[idx]
    label = model.config.id2label[pred]
    if label != "O":
        # Remove the B- or I-
        label = label[2:]
        start, _ = offsets[idx]

        # Grab all the tokens labeled with I-label
        all_scores = []
        while (
            idx < len(predictions)
            and model.config.id2label[predictions[idx]] == f"I-{label}"
        ):
            all_scores.append(probabilities[idx][pred])
            _, end = offsets[idx]
            idx += 1

        # The score is the mean of all the scores of the tokens in that grouped entity
        score = np.mean(all_scores).item()
        word = example[start:end]
        results.append(
            {
                "entity_group": label,
                "score": score,
                "word": word,
                "start": start,
                "end": end,
            }
        )
    idx += 1

print(results)

Și obținem aceleași răspuns ca de la pipelineul secundar!

[{'entity_group': 'PER', 'score': 0.9981694, 'word': 'Sylvain', 'start': 11, 'end': 18},
 {'entity_group': 'ORG', 'score': 0.97960204, 'word': 'Hugging Face', 'start': 33, 'end': 45},
 {'entity_group': 'LOC', 'score': 0.99321055, 'word': 'Brooklyn', 'start': 49, 'end': 57}]

Alt exemplu de sarcină unde offseturile sunt extrem de useful pentru răspunderea la întrebări. Scufundându-ne în pipelineuri, un lucru pe care îl vom face în următoarea secțiune, ne vom premite să ne uităm peste o caracteristică a tokenizerului în librăria 🤗 Transformers: vom avea de-a face cu overflowing tokens când truncăm un input de o anumită lungime.

< > Update on GitHub