LLM Course documentation
Superputerile tokenizerilor rapizi
Superputerile tokenizerilor rapizi
Î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-uluiS
)"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
.
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