Pouvoirs spéciaux des <i> tokenizers </i> rapides
Dans cette section, nous allons examiner de plus près les capacités des tokenizers dans 🤗 Transformers.
Jusqu’à présent, nous ne les avons utilisés que pour tokeniser les entrées ou décoder les identifiants pour revenir à du texte. Mais les tokenizers, et surtout ceux soutenus par la bibliothèque 🤗 Tokenizers, peuvent faire beaucoup plus. Pour illustrer ces fonctionnalités supplémentaires, nous allons explorer comment reproduire les résultats des pipelines token-classification
(que nous avons appelé ner
) et question-answering
que nous avons rencontrés pour la première fois dans le chapitre 1.
Dans la discussion qui suit, nous ferons souvent la distinction entre les tokenizers « lents » et les « rapides ». Les tokenizers lents sont ceux écrits en Python à l’intérieur de la bibliothèque 🤗 Transformers, tandis que les rapides sont ceux fournis par 🤗 Tokenizers et sont codés en Rust. Si vous vous souvenez du tableau du chapitre 5 qui indiquait combien de temps il fallait à un tokenizer rapide et à un tokenizer lent pour tokeniser le jeu de données Drug Review, vous devriez avoir une idée de la raison pour laquelle nous les appelons rapides et lents :
Tokenizer rapide | Tokenizer lent | |
---|---|---|
batched=True | 10.8s | 4min41s |
batched=False | 59.2s | 5min3s |
⚠️ Lors de la tokenisation d’une seule phrase, vous ne verrez pas toujours une différence de vitesse entre les versions lente et rapide d’un même tokenizer. En fait, la version rapide peut même être plus lente ! Ce n’est que lorsque vous tokenisez beaucoup de textes en parallèle et en même temps que vous pourrez clairement voir la différence.
L’objet <i> BatchEncoding </i>
La sortie d’un tokenizer n’est pas un simple dictionnaire Python. Ce que nous obtenons est en fait un objet spécial BatchEncoding
. C’est une sous-classe d’un dictionnaire (c’est pourquoi nous avons pu indexer ce résultat sans problème auparavant), mais avec des méthodes supplémentaires qui sont principalement utilisées par les tokenizers rapides.
En plus de leurs capacités de parallélisation, la fonctionnalité clé des tokenizers rapides est qu’ils gardent toujours la trace de l’étendue originale des textes d’où proviennent les tokens finaux, une fonctionnalité que nous appelons mapping offset. Cela permet de débloquer des fonctionnalités telles que le mappage de chaque mot aux tokens qu’il a générés ou le mappage de chaque caractère du texte original au token qu’il contient, et vice versa.
Prenons un exemple :
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")
example = "My name is Sylvain and I work at Hugging Face in Brooklyn."
# Je m'appelle Sylvain et je travaille chez Hugging Face à Brooklyn.
encoding = tokenizer(example)
print(type(encoding))
Comme mentionné précédemment, nous obtenons un objet BatchEncoding
dans la sortie du tokenizer :
<class 'transformers.tokenization_utils_base.BatchEncoding'>
Puisque la classe AutoTokenizer
choisit un tokenizer rapide par défaut, nous pouvons utiliser les méthodes supplémentaires que cet objet BatchEncoding
fournit. Nous avons deux façons de vérifier si notre tokenizer est rapide ou lent. Nous pouvons soit vérifier l’attribut is_fast
du tokenizer comme suit :
tokenizer.is_fast
True
soit vérifier le même attribut mais avec notre encoding
:
encoding.is_fast
True
Voyons ce qu’un tokenizer rapide nous permet de faire. Tout d’abord, nous pouvons accéder aux tokens sans avoir à reconvertir les identifiants en tokens :
encoding.tokens()
['[CLS]', 'My', 'name', 'is', 'S', '##yl', '##va', '##in', 'and', 'I', 'work', 'at', 'Hu', '##gging', 'Face', 'in',
'Brooklyn', '.', '[SEP]']
Dans ce cas, le token à l’index 5 est ##yl
et fait partie du mot « Sylvain » dans la phrase originale. Nous pouvons également utiliser la méthode word_ids()
pour obtenir l’index du mot dont provient chaque token :
encoding.word_ids()
[None, 0, 1, 2, 3, 3, 3, 3, 4, 5, 6, 7, 8, 8, 9, 10, 11, 12, None]
On peut voir que les tokens spéciaux du tokenizer, [CLS]
et [SEP]
, sont mis en correspondance avec None
et que chaque token est mis en correspondance avec le mot dont il provient. Ceci est particulièrement utile pour déterminer si un token est au début d’un mot ou si deux tokens sont dans le même mot. Nous pourrions nous appuyer sur le préfixe ##
pour cela, mais il ne fonctionne que pour les tokenizers de type BERT. Cette méthode fonctionne pour n’importe quel type de tokenizer, du moment qu’il est rapide. Dans le chapitre suivant, nous verrons comment utiliser cette capacité pour appliquer correctement les étiquettes que nous avons pour chaque mot aux tokens dans des tâches comme la reconnaissance d’entités nommées et le POS (Part-of-speech). Nous pouvons également l’utiliser pour masquer tous les tokens provenant du même mot dans la modélisation du langage masqué (une technique appelée whole word masking).
La notion de ce qu’est un mot est compliquée. Par exemple, est-ce que « I’ll » (contraction de « I will ») compte pour un ou deux mots ? Cela dépend en fait du tokenizer et de l’opération de prétokénisation qu’il applique. Certains tokenizer se contentent de séparer les espaces et considèrent donc qu’il s’agit d’un seul mot. D’autres utilisent la ponctuation en plus des espaces et considèrent donc qu’il s’agit de deux mots.
✏️ Essayez ! Créez un tokenizer à partir des checkpoints bert-base-cased
et roberta-base
et tokenisez « 81s » avec. Qu’observez-vous ? Quels sont les identifiants des mots ?
De même, il existe une méthode sentence_ids()
que nous pouvons utiliser pour associer un token à la phrase dont il provient (bien que dans ce cas, le token_type_ids
retourné par le tokenizer peut nous donner la même information).
Enfin, nous pouvons faire correspondre n’importe quel mot ou token aux caractères du texte d’origine (et vice versa) grâce aux méthodes word_to_chars()
ou token_to_chars()
et char_to_word()
ou char_to_token()
. Par exemple, la méthode word_ids()
nous a dit que ##yl
fait partie du mot à l’indice 3, mais de quel mot s’agit-il dans la phrase ? Nous pouvons le découvrir comme ceci :
start, end = encoding.word_to_chars(3)
example[start:end]
Sylvain
Comme nous l’avons mentionné précédemment, tout ceci est rendu possible par le fait que le tokenizer rapide garde la trace de la partie du texte d’où provient chaque token dans une liste d’offsets. Pour illustrer leur utilisation, nous allons maintenant vous montrer comment reproduire manuellement les résultats du pipeline token-classification
.
✏️ Essayez ! Rédigez votre propre texte et voyez si vous pouvez comprendre quels tokens sont associés à l’identifiant du mot et comment extraire les étendues de caractères pour un seul mot. Pour obtenir des points bonus, essayez d’utiliser deux phrases en entrée et voyez si les identifiants ont un sens pour vous.
A l’intérieur du pipeline token-classification
Dans le chapitre 1, nous avons eu un premier aperçu de la NER (où la tâche est d’identifier les parties du texte qui correspondent à des entités telles que des personnes, des lieux ou des organisations) avec la fonction pipeline()
de 🤗 Transformers. Puis, dans le chapitre 2, nous avons vu comment un pipeline regroupe les trois étapes nécessaires pour obtenir les prédictions à partir d’un texte brut : la tokenisation, le passage des entrées dans le modèle et le post-traitement. Les deux premières étapes du pipeline de token-classification
sont les mêmes que dans tout autre pipeline mais le post-traitement est un peu plus complexe. Voyons comment !
Obtenir les résultats de base avec le pipeline
Tout d’abord, prenons un pipeline de classification de tokens afin d’obtenir des résultats à comparer manuellement. Le modèle utilisé par défaut est dbmdz/bert-large-cased-finetuned-conll03-english
. Il effectue une NER sur les phrases :
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}]
Le modèle a correctement identifié chaque token généré par « Sylvain » comme une personne, chaque token généré par « Hugging Face » comme une organisation, et le token « Brooklyn » comme un lieu. Nous pouvons également demander au pipeline de regrouper les tokens qui correspondent à la même entité :
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}]
La propriété aggregation_strategy
choisie va changer les scores calculés pour chaque entité groupée. Avec "simple"
le score est juste la moyenne des scores de chaque token dans l’entité donnée. Par exemple, le score de « Sylvain » est la moyenne des scores que nous avons vu dans l’exemple précédent pour les tokens S
, ##yl
, ##va
, et ##in
. D’autres stratégies sont disponibles :
"first"
, où le score de chaque entité est le score du premier token de cette entité (donc pour « Sylvain » ce serait 0.993828, le score du tokenS
)"max"
, où le score de chaque entité est le score maximal des tokens de cette entité (ainsi, pour « Hugging Face », le score de « Face » serait de 0,98879766)."average"
, où le score de chaque entité est la moyenne des scores des mots qui composent cette entité (ainsi, pour « Sylvain », il n’y aurait pas de différence avec la stratégie"simple"
, mais “Hugging Face” aurait un score de 0,9819, la moyenne des scores de « Hugging », 0,975, et « Face », 0,98879).
Voyons maintenant comment obtenir ces résultats sans utiliser la fonction pipeline()
!
Des entrées aux prédictions
D’abord, nous devons tokeniser notre entrée et la faire passer dans le modèle. Cela se fait exactement comme dans le chapitre 2. Nous instancions le tokenizer et le modèle en utilisant les classes TFAutoXxx
et les utilisons ensuite dans notre exemple :
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)
Puisque nous utilisons AutoModelForTokenClassification
, nous obtenons un ensemble de logits pour chaque token dans la séquence d’entrée :
print(inputs["input_ids"].shape)
print(outputs.logits.shape)
torch.Size([1, 19])
torch.Size([1, 19, 9])
Nous avons un batch avec 1 séquence de 19 tokens et le modèle a 9 étiquettes différentes. Ainsi, la sortie du modèle a une forme de 1 x 19 x 9. Comme pour le pipeline de classification de texte, nous utilisons une fonction softmax pour convertir ces logits en probabilités et nous prenons l’argmax pour obtenir des prédictions (notez que nous pouvons prendre l’argmax sur les logits car la fonction softmax ne change pas l’ordre) :
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]
L’attribut model.config.id2label
contient la correspondance entre les index et les étiquettes que nous pouvons utiliser pour donner un sens aux prédictions :
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'}
Comme nous l’avons vu précédemment, il y a 9 étiquettes : O
est le label pour les tokens qui ne sont dans aucune entité nommée (il signifie outside (en dehors)) et nous avons ensuite deux labels pour chaque type d’entité (divers, personne, organisation et lieu). L’étiquette B-XXX
indique que le token est au début d’une entité XXX
et l’étiquette I-XXX
indique que le token est à l’intérieur de l’entité XXX
. Par exemple, dans l’exemple actuel, nous nous attendons à ce que notre modèle classe le token S
comme B-PER
(début d’une entité personne) et les tokens ##yl
, ##va
et ##in
comme I-PER
(à l’intérieur d’une entité personne).
Vous pourriez penser que le modèle s’est trompé ici car il a attribué l’étiquette I-PER
à ces quatre tokens mais ce n’est pas tout à fait vrai. Il existe en fait deux formats pour ces étiquettes B-
et I-
: IOB1 et IOB2. Le format IOB2 (en rose ci-dessous) est celui que nous avons introduit alors que dans le format IOB1 (en bleu), les étiquettes commençant par B-
ne sont jamais utilisées que pour séparer deux entités adjacentes du même type. Le modèle que nous utilisons a été finetuné sur un jeu de données utilisant ce format, c’est pourquoi il attribue le label I-PER
au token S
.
Nous sommes à présent prêts à reproduire (presque entièrement) les résultats du premier pipeline. Nous pouvons simplement récupérer le score et le label de chaque token qui n’a pas été classé comme 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'}]
C’est très similaire à ce que nous avions avant, à une exception près : le pipeline nous a aussi donné des informations sur le début
et la fin
de chaque entité dans la phrase originale. C’est là que notre offset mapping va entrer en jeu. Pour obtenir les offsets, il suffit de définir return_offsets_mapping=True
lorsque nous appliquons le tokenizer à nos entrées :
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)]
Chaque tuple est l’étendue de texte correspondant à chaque token où (0, 0)
est réservé aux tokens spéciaux. Nous avons vu précédemment que le token à l’index 5 est ##yl
, qui a (12, 14)
comme offsets ici. Si on prend la tranche correspondante dans notre exemple :
example[12:14]
nous obtenons le bon espace de texte sans le ##
:
yl
En utilisant cela, nous pouvons maintenant compléter les résultats précédents :
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}]
C’est la même chose que ce que nous avons obtenu avec le premier pipeline !
Regroupement des entités
L’utilisation des offsets pour déterminer les clés de début et de fin pour chaque entité est pratique mais cette information n’est pas strictement nécessaire. Cependant, lorsque nous voulons regrouper les entités, les offsets nous épargnent un batch de code compliqué. Par exemple, si nous voulions regrouper les tokens Hu
, ##gging
, et Face
, nous pourrions établir des règles spéciales disant que les deux premiers devraient être attachés tout en enlevant le ##
, et le Face
devrait être ajouté avec un espace puisqu’il ne commence pas par ##
mais cela ne fonctionnerait que pour ce type particulier de tokenizer. Il faudrait écrire un autre ensemble de règles pour un tokenizer de type SentencePiece ou Byte-Pair-Encoding (voir plus loin dans ce chapitre).
Avec les offsets, tout ce code personnalisé disparaît : il suffit de prendre l’intervalle du texte original qui commence par le premier token et se termine par le dernier token. Ainsi, dans le cas des tokens Hu
, ##gging
, et Face
, nous devrions commencer au caractère 33 (le début de Hu
) et finir avant le caractère 45 (la fin de Face
) :
example[33:45]
Hugging Face
Pour écrire le code qui post-traite les prédictions tout en regroupant les entités, nous regrouperons les entités qui sont consécutives et étiquetées avec I-XXX
, à l’exception de la première, qui peut être étiquetée comme B-XXX
ou I-XXX
(ainsi, nous arrêtons de regrouper une entité lorsque nous obtenons un O
, un nouveau type d’entité, ou un B-XXX
qui nous indique qu’une entité du même type commence) :
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":
# Enlever le B- ou le I-
label = label[2:]
start, _ = offsets[idx]
# Récupérer tous les tokens étiquetés avec 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
# Le score est la moyenne de tous les scores des tokens dans cette entité groupée
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)
Et nous obtenons les mêmes résultats qu’avec notre deuxième pipeline !
[{'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}]
Un autre exemple de tâche où ces offsets sont extrêmement utiles est la réponse aux questions. Plonger dans ce pipeline, ce que nous ferons dans la section suivante, nous permettra de jeter un coup d’œil à une dernière caractéristique des tokenizers de la bibliothèque 🤗 Transformers : la gestion des tokens qui débordent lorsque nous tronquons une entrée à une longueur donnée.