LLM Course documentation
Tokenizerii rapizi în pipeline-ul de QA
Tokenizerii rapizi în pipeline-ul de QA
Acum ne vom aprofunda în pipelineul question-answering
și să vedem cum putem valorifica offesturile pentru a primi răspunzuri la întrebări la îndemână din context, asemănător cum am făcut cu entitățile grupate în secțiunea precedentă. Pe urmă vom vedea cum vom face față contextelor foarte lungi care ajung truncate. Puteți trece peste această secțiune dacă nu sunteți interesat în întrebarea care răspunde la sarcina aceasta.
Folosind question-answering pipeline
Cum am văzut în Capitolul 1, noi putem folosi pipelineul question-answering
ca acesta pentru a răspunde la o întrebare:
from transformers import pipeline
question_answerer = pipeline("question-answering")
context = """
🤗 Transformers is backed by the three most popular deep learning libraries — Jax, PyTorch, and TensorFlow — with a seamless integration
between them. It's straightforward to train your models with one before loading them for inference with the other.
"""
question = "Which deep learning libraries back 🤗 Transformers?"
question_answerer(question=question, context=context)
{'score': 0.97773,
'start': 78,
'end': 105,
'answer': 'Jax, PyTorch and TensorFlow'}
Spre deosebire de alte pipelineuri, care nu put trunca și face split la text care este mai lung decât lungimea maxim acceptată de model(și, prin urmare, pot pierde informații la sfârșitul unui document), accest pipeline poate face față contextelor foarte lungi și va returna răspunsul la întrebare chiar dacă aceasta se află la sfârșit:
long_context = """
🤗 Transformers: State of the Art NLP
🤗 Transformers provides thousands of pretrained models to perform tasks on texts such as classification, information extraction,
question answering, summarization, translation, text generation and more in over 100 languages.
Its aim is to make cutting-edge NLP easier to use for everyone.
🤗 Transformers provides APIs to quickly download and use those pretrained models on a given text, fine-tune them on your own datasets and
then share them with the community on our model hub. At the same time, each python module defining an architecture is fully standalone and
can be modified to enable quick research experiments.
Why should I use transformers?
1. Easy-to-use state-of-the-art models:
- High performance on NLU and NLG tasks.
- Low barrier to entry for educators and practitioners.
- Few user-facing abstractions with just three classes to learn.
- A unified API for using all our pretrained models.
- Lower compute costs, smaller carbon footprint:
2. Researchers can share trained models instead of always retraining.
- Practitioners can reduce compute time and production costs.
- Dozens of architectures with over 10,000 pretrained models, some in more than 100 languages.
3. Choose the right framework for every part of a model's lifetime:
- Train state-of-the-art models in 3 lines of code.
- Move a single model between TF2.0/PyTorch frameworks at will.
- Seamlessly pick the right framework for training, evaluation and production.
4. Easily customize a model or an example to your needs:
- We provide examples for each architecture to reproduce the results published by its original authors.
- Model internals are exposed as consistently as possible.
- Model files can be used independently of the library for quick experiments.
🤗 Transformers is backed by the three most popular deep learning libraries — Jax, PyTorch and TensorFlow — with a seamless integration
between them. It's straightforward to train your models with one before loading them for inference with the other.
"""
question_answerer(question=question, context=long_context)
{'score': 0.97149,
'start': 1892,
'end': 1919,
'answer': 'Jax, PyTorch and TensorFlow'}
Hai să vedem cum el face toate astea!
Folosind un model pentru răspunderea la întrebări
Ca în cazul oricărui altui pipeline, începem prin tokenizarea datelor de intrare și apoi le trimitem prin model. Checkpointul utilizat în mod implicit pentru pipelineul question-answering
este distilbert-base-cased-distilled-squad
(“squad” din nume provine de la datasetul pe care modelul a fost ajustat; vom vorbi mai multe despre datasetul SQuAD în Capitolul 7):
from transformers import AutoTokenizer, AutoModelForQuestionAnswering
model_checkpoint = "distilbert-base-cased-distilled-squad"
tokenizer = AutoTokenizer.from_pretrained(model_checkpoint)
model = AutoModelForQuestionAnswering.from_pretrained(model_checkpoint)
inputs = tokenizer(question, context, return_tensors="pt")
outputs = model(**inputs)
Observați că noi tokenizăm întrebrea și contextul ca o perecehe, cu întrebarea prima.
Modelele create pentru răspunderea la întrebări funcționează puțin diferit de modelele pe care le-am văzut până acum. Folosind imaginea de mai sus ca exemplu, modelul a fost antrenat pentru a prezice indicele tokenului cu care începe răspunsului (aici 21) și indicele simbolului la care se termină răspunsul (aici 24). Acesta este motivul pentru care modelele respective nu returnează un singur tensor de logits, ci două: unul pentru logits-ul corespunzători tokenului cu care începe răspunsului și unul pentru logits-ul corespunzător tokenului de sfârșit al răspunsului. Deoarece în acest caz avem un singur input care conține 66 de token-uri, obținem:
start_logits = outputs.start_logits
end_logits = outputs.end_logits
print(start_logits.shape, end_logits.shape)
torch.Size([1, 66]) torch.Size([1, 66])
Pentru a converti acești logits în probabilități, vom aplica o funcție softmax - dar înainte de aceasta, trebuie să ne asigurăm că mascăm indicii care nu fac parte din context. Inputul nostru este [CLS] întrebare [SEP] context [SEP]
, deci trebuie să mascăm token-urile întrebării, precum și tokenul [SEP]
. Cu toate acestea, vom păstra simbolul [CLS]
, deoarece unele modele îl folosesc pentru a indica faptul că răspunsul nu se află în context.
Deoarece vom aplica ulterior un softmax, trebuie doar să înlocuim logiturile pe care dorim să le mascăm cu un număr negativ mare. Aici, folosim -10000
:
import torch
sequence_ids = inputs.sequence_ids()
# Mask everything apart from the tokens of the context
mask = [i != 1 for i in sequence_ids]
# Unmask the [CLS] token
mask[0] = False
mask = torch.tensor(mask)[None]
start_logits[mask] = -10000
end_logits[mask] = -10000
Acum că am mascat în mod corespunzător logiturile corespunzătoare pozițiilor pe care nu dorim să le prezicem, putem aplica softmax:
start_probabilities = torch.nn.functional.softmax(start_logits, dim=-1)[0]
end_probabilities = torch.nn.functional.softmax(end_logits, dim=-1)[0]
La acest stadiu, am putea lua argmax al probabilităților de început și de sfârșit - dar am putea ajunge la un indice de început care este mai mare decât indicele de sfârșit, deci trebuie să luăm câteva precauții suplimentare. Vom calcula probabilitățile fiecărui start_index
și end_index
posibil în cazul în care start_index <= end_index
, apoi vom lua un tuple (start_index, end_index)
cu cea mai mare probabilitate.
Presupunând că evenimentele “The answer starts at start_index
” și “The answer ends at end_index
” sunt independente, probabilitatea ca răspunsul să înceapă la start_index
și să se termine la end_index
este:
Deci, pentru a calcula toate scorurile, trebuie doar să calculăm toate produsele unde start_index <= end_index
.
Mai întâi hai să calculăm toate produsele posibile:
scores = start_probabilities[:, None] * end_probabilities[None, :]
Apoi vom masca valorile în care start_index > end_index
prin stabilirea lor la 0
(celelalte probabilități sunt toate numere pozitive). Funcția torch.triu()
returnează partea triunghiulară superioară a tensorului 2D trecut ca argument, deci va face această mascare pentru noi:
scores = torch.triu(score)
Acum trebuie doar să obținem indicele maximului. Deoarece PyTorch va returna indicele în tensorul aplatizat, trebuie să folosim operațiile floor division //
și modulusul %
pentru a obține start_index
și end_index
:
max_index = scores.argmax().item()
start_index = max_index // scores.shape[1]
end_index = max_index % scores.shape[1]
print(scores[start_index, end_index])
Nu am terminat încă, dar cel puțin avem deja scorul corect pentru răspuns (puteți verifica acest lucru comparându-l cu primul rezultat din secțiunea anterioară):
0.97773
✏️ Încercați! Calculați indicii de început și de sfârșit pentru cele mai probabile cinci răspunsuri.
Avem start_index
și end_index
ale răspunsului în termeni de tokens, deci acum trebuie doar să convertim în character indices în context. Acesta este momentul în care offseturile vor fi foarte utile. Putem să le luăm și să le folosim așa cum am făcut în sarcina de clasificare a tokenurilor:
inputs_with_offsets = tokenizer(question, context, return_offsets_mapping=True)
offsets = inputs_with_offsets["offset_mapping"]
start_char, _ = offsets[start_index]
_, end_char = offsets[end_index]
answer = context[start_char:end_char]
Acum trebuie doar să formatăm totul pentru a obține rezultatul nostru:
result = {
"answer": answer,
"start": start_char,
"end": end_char,
"score": scores[start_index, end_index],
}
print(result)
{'answer': 'Jax, PyTorch and TensorFlow',
'start': 78,
'end': 105,
'score': 0.97773}
Grozav! Este la fel ca în primul nostru exemplu!
✏️ Încercați! Utilizați cele mai bune scoruri pe care le-ați calculat anterior pentru a afișa cele mai probabile cinci răspunsuri. Pentru a vă verifica rezultatele, întoarceți-vă la primul pipeline și introduceți top_k=5
atunci când îl apelați.
Gestionarea contextelor lungi
Dacă încercăm să tokenizăm întrebarea și contextul lung pe care le-am folosit ca un exemplu anterior, vom obține un număr de tokenuri mai mare decât lungimea maximă utilizată în pipelineul question-answering
(care este 384):
inputs = tokenizer(question, long_context)
print(len(inputs["input_ids"]))
461
Prin urmare, va trebui să trunchiem inputurile la lungimea maximă. Există mai multe modalități prin care putem face acest lucru, dar nu dorim să trunchiem întrebarea, ci doar contextul. Deoarece contextul este a doua propoziție, vom utiliza strategia de trunchiere "only_second"
. Problema care apare atunci este că răspunsul la întrebare poate să nu fie în contextul trunchiat. Aici, de exemplu, am ales o întrebare la care răspunsul se află spre sfârșitul contextului, iar atunci când îl trunchiem, răspunsul nu este prezent:
inputs = tokenizer(question, long_context, max_length=384, truncation="only_second")
print(tokenizer.decode(inputs["input_ids"]))
"""
[CLS] Which deep learning libraries back [UNK] Transformers? [SEP] [UNK] Transformers : State of the Art NLP
[UNK] Transformers provides thousands of pretrained models to perform tasks on texts such as classification, information extraction,
question answering, summarization, translation, text generation and more in over 100 languages.
Its aim is to make cutting-edge NLP easier to use for everyone.
[UNK] Transformers provides APIs to quickly download and use those pretrained models on a given text, fine-tune them on your own datasets and
then share them with the community on our model hub. At the same time, each python module defining an architecture is fully standalone and
can be modified to enable quick research experiments.
Why should I use transformers?
1. Easy-to-use state-of-the-art models:
- High performance on NLU and NLG tasks.
- Low barrier to entry for educators and practitioners.
- Few user-facing abstractions with just three classes to learn.
- A unified API for using all our pretrained models.
- Lower compute costs, smaller carbon footprint:
2. Researchers can share trained models instead of always retraining.
- Practitioners can reduce compute time and production costs.
- Dozens of architectures with over 10,000 pretrained models, some in more than 100 languages.
3. Choose the right framework for every part of a model's lifetime:
- Train state-of-the-art models in 3 lines of code.
- Move a single model between TF2.0/PyTorch frameworks at will.
- Seamlessly pick the right framework for training, evaluation and production.
4. Easily customize a model or an example to your needs:
- We provide examples for each architecture to reproduce the results published by its original authors.
- Model internal [SEP]
"""
Aceasta înseamnă că modelul va avea dificultăți în a alege răspunsul corect. Pentru a rezolva acest lucru, pipelineul question-answering
ne permite să împărțim contextul în bucăți mai mici, specificând lungimea maximă. Pentru a ne asigura că nu împărțim contextul exact în locul nepotrivit pentru a face posibilă găsirea răspunsului, aceasta include și o anumită suprapunere între bucăți.
Putem cere tokenizerului (rapid sau lent) să facă acest lucru pentru noi adăugând return_overflowing_tokens=True
, și putem specifica suprapunerea dorită cu argumentul stride
. Iată un exemplu, folosind o propoziție mai mică:
sentence = "This sentence is not too long but we are going to split it anyway."
inputs = tokenizer(
sentence, truncation=True, return_overflowing_tokens=True, max_length=6, stride=2
)
for ids in inputs["input_ids"]:
print(tokenizer.decode(ids))
'[CLS] This sentence is not [SEP]'
'[CLS] is not too long [SEP]'
'[CLS] too long but we [SEP]'
'[CLS] but we are going [SEP]'
'[CLS] are going to split [SEP]'
'[CLS] to split it anyway [SEP]'
'[CLS] it anyway. [SEP]'
După cum putem vedea, propoziția a fost împărțită în bucăți astfel încât fiecare intrare din inputs["input_ids"]
să aibă cel mult 6 token-uri (aici ar trebui să adăugăm padding pentru ca ultima intrare să aibă aceeași dimensiune ca celelalte) și există o suprapunere de 2 tokenuri între fiecare intrare.
Să aruncăm o privire mai atentă la rezultatul tokenizării:
print(inputs.keys())
dict_keys(['input_ids', 'attention_mask', 'overflow_to_sample_mapping'])
Așa cum era de așteptat, obținem ID-uri de intrare și un attention mask. Ultima cheie, overflow_to_sample_mapping
, este o hartă care ne spune cărei propoziții îi corespunde fiecare dintre rezultate - aici avem 7 rezultate care provin toate din (singura) propoziție pe care am transmis-o tokenizerului:
print(inputs["overflow_to_sample_mapping"])
[0, 0, 0, 0, 0, 0, 0]
Acest lucru este mai util atunci când tokenizăm mai multe propoziții împreună. De exemplu, aceasta:
sentences = [
"This sentence is not too long but we are going to split it anyway.",
"This sentence is shorter but will still get split.",
]
inputs = tokenizer(
sentences, truncation=True, return_overflowing_tokens=True, max_length=6, stride=2
)
print(inputs["overflow_to_sample_mapping"])
gets us:
[0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1]
ceea ce înseamnă că prima propoziție este împărțită în 7 fragmente ca înainte, iar următoarele 4 fragmente provin din a doua propoziție.
Acum să ne întoarcem la contextul nostru lung. În mod implicit, pipelineul question-answering
utilizează o lungime maximă de 384, așa cum am menționat mai devreme, și un stride de 128, care corespund modului în care modelul a fost fine-tuned (puteți ajusta acești parametri prin trecerea argumentelor max_seq_len
și stride
atunci când apelați pipelineul). Astfel, vom utiliza acești parametri la tokenizare. Vom adăuga, de asemenea, padding (pentru a avea sampleuri de aceeași lungime, astfel încât să putem construi tensori), precum și pentru a solicita offsets:
inputs = tokenizer(
question,
long_context,
stride=128,
max_length=384,
padding="longest",
truncation="only_second",
return_overflowing_tokens=True,
return_offsets_mapping=True,
)
Aceste “inputuri” vor conține ID-urile de input și attention maskurile așteptate de model, precum și offseturile și “overflow_to_sample_mapping” despre care tocmai am vorbit. Deoarece cei doi nu sunt parametri utilizați de model, îi vom scoate din inputs
(și nu vom stoca harta, deoarece nu este utilă aici) înainte de a-l converti într-un tensor:
_ = inputs.pop("overflow_to_sample_mapping")
offsets = inputs.pop("offset_mapping")
inputs = inputs.convert_to_tensors("pt")
print(inputs["input_ids"].shape)
torch.Size([2, 384])
Contextul nostru lung a fost împărțit în două, ceea ce înseamnă că, după ce trece prin modelul nostru, vom avea două seturi de logits de început și de sfârșit:
outputs = model(**inputs)
start_logits = outputs.start_logits
end_logits = outputs.end_logits
print(start_logits.shape, end_logits.shape)
torch.Size([2, 384]) torch.Size([2, 384])
Ca și înainte, mai întâi mascăm tokenii care nu fac parte din context înainte de a lua softmax. De asemenea, mascăm toți padding tokens(marcate de attention mask):
sequence_ids = inputs.sequence_ids()
# Mask everything apart from the tokens of the context
mask = [i != 1 for i in sequence_ids]
# Unmask the [CLS] token
mask[0] = False
# Mask all the [PAD] tokens
mask = torch.logical_or(torch.tensor(mask)[None], (inputs["attention_mask"] == 0))
start_logits[mask] = -10000
end_logits[mask] = -10000
Then we can use the softmax to convert our logits to probabilities:
start_probabilities = torch.nn.functional.softmax(start_logits, dim=-1)
end_probabilities = torch.nn.functional.softmax(end_logits, dim=-1)
Următorul pas este similar cu ceea ce am făcut pentru contextul mic, dar îl repetăm pentru fiecare dintre cele două chunkuri. Atribuim un scor tuturor intervalelor posibile de răspuns, apoi luăm intervalul cu cel mai bun scor:
candidates = []
for start_probs, end_probs in zip(start_probabilities, end_probabilities):
scores = start_probs[:, None] * end_probs[None, :]
idx = torch.triu(scores).argmax().item()
start_idx = idx // scores.shape[1]
end_idx = idx % scores.shape[1]
score = scores[start_idx, end_idx].item()
candidates.append((start_idx, end_idx, score))
print(candidates)
[(0, 18, 0.33867), (173, 184, 0.97149)]
Cei doi candidați corespund celor mai bune răspunsuri pe care modelul le-a putut găsi în fiecare parte. Modelul este mult mai încrezător că răspunsul corect se află în a doua parte (ceea ce este un semn bun!). Acum trebuie doar să facem map celor două intervale de tokenuri cu intervalele de caractere din context (trebuie să o punem în corespondență doar pe a doua pentru a avea răspunsul nostru, dar este interesant să vedem ce a ales modelul în prima parte).
✏️ Încercați! Adaptați codul de mai sus pentru a returna scorurile și spanurile intervalele pentru cele mai probabile cinci răspunsuri (în total, nu pe chunk).
offsets
-urile pe care le-am luat mai devreme este de fapt o listă de offsets, cu o listă pentru fiecare chunk de text:
for candidate, offset in zip(candidates, offsets):
start_token, end_token, score = candidate
start_char, _ = offset[start_token]
_, end_char = offset[end_token]
answer = long_context[start_char:end_char]
result = {"answer": answer, "start": start_char, "end": end_char, "score": score}
print(result)
{'answer': '\n🤗 Transformers: State of the Art NLP', 'start': 0, 'end': 37, 'score': 0.33867}
{'answer': 'Jax, PyTorch and TensorFlow', 'start': 1892, 'end': 1919, 'score': 0.97149}
Dacă ignorăm primul rezultat, obținem același rezultat ca și pipelineul noastru pentru acest context lung - yay!
✏️ Încercați! Utilizați cele mai bune scoruri pe care le-ați calculat înainte pentru a afișa cele mai probabile cinci răspunsuri (pentru întregul context, nu pentru fiecare chunk). Pentru a vă verifica rezultatele, întoarceți-vă la primul pipeline și introduceți top_k=5
atunci când îl apelați.
Aici se încheie scufundarea noastră în capacitățile tokenizerului. Vom pune toate acestea din nou în practică în capitolul următor, când vă vom arăta cum să ajustați un model pentru o serie de sarcini NLP comune.
< > Update on GitHub