Pytorch
TensorFlow

#
Les <i> tokenizers </i>

Les tokenizers sont l’un des principaux composants du pipeline de NLP. Ils ont un seul objectif : traduire le texte en données pouvant être traitées par le modèle. Les modèles ne pouvant traiter que des nombres, les tokenizers doivent convertir nos entrées textuelles en données numériques. Dans cette section, nous allons explorer ce qui se passe exactement dans le pipeline de tokénisation.

Dans les tâches de NLP, les données traitées sont généralement du texte brut. Voici un exemple de ce type de texte :

    Jim Henson was a puppeteer # Jim Henson était un marionnettiste

Les modèles ne pouvant traiter que des nombres, nous devons trouver un moyen de convertir le texte brut en nombres. C’est ce que font les tokenizers et il existe de nombreuses façons de procéder. L’objectif est de trouver la représentation la plus significative, c’est-à-dire celle qui a le plus de sens pour le modèle, et si possible qui soit la plus petite.

Voyons quelques exemples d’algorithmes de tokénisation et essayons de répondre à certaines des questions que vous pouvez vous poser à ce sujet.

##
<i> Tokenizer </i> basé sur les mots

Le premier type de tokenizer qui vient à l’esprit est celui basé sur les mots. Il est généralement très facile à utiliser et configurable avec seulement quelques règles. Il donne souvent des résultats décents. Par exemple, dans l’image ci-dessous, l’objectif est de diviser le texte brut en mots et de trouver une représentation numérique pour chacun d’eux :

Il existe différentes façons de diviser le texte. Par exemple, nous pouvons utiliser les espaces pour segmenter le texte en mots en appliquant la fonction `split()` de Python :

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

    ['Jim', 'Henson', 'was', 'a', 'puppeteer'] # ['Jim', 'Henson', était, 'un', 'marionnettiste']

Il existe également des variantes des tokenizers basés sur les mots qui ont des règles supplémentaires pour la ponctuation. Avec ce type de tokenizers nous pouvons nous retrouver avec des « vocabulaires » assez larges, où un vocabulaire est défini par le nombre total de tokens indépendants que nous avons dans notre corpus.

Un identifiant est attribué à chaque mot, en commençant par 0 et en allant jusqu’à la taille du vocabulaire. Le modèle utilise ces identifiants pour identifier chaque mot.

Si nous voulons couvrir complètement une langue avec un tokenizer basé sur les mots, nous devons avoir un identifiant pour chaque mot de la langue que nous traitons, ce qui génère une énorme quantité de tokens. Par exemple, il y a plus de 500 000 mots dans la langue anglaise. Ainsi pour associer chaque mot à un identifiant, nous devrions garder la trace d’autant d’identifiants. De plus, des mots comme « chien » sont représentés différemment de mots comme « chiens ». Le modèle n’a initialement aucun moyen de savoir que « chien » et « chiens » sont similaires : il identifie les deux mots comme non apparentés. Il en va de même pour d’autres mots similaires, comme « maison » et « maisonnette » que le modèle ne considérera pas comme similaires au départ.

Enfin, nous avons besoin d’un token personnalisé pour représenter les mots qui ne font pas partie de notre vocabulaire. C’est ce qu’on appelle le token « inconnu » souvent représenté par « [UNK] » (de l’anglais « unknown ») ou « <unk> ; ». C’est généralement un mauvais signe si vous constatez que le tokenizer produit un nombre important de ce jeton spécial. Cela signifie qu’il n’a pas été en mesure de récupérer une représentation sensée d’un mot et que vous perdez des informations en cours de route. L’objectif de l’élaboration du vocabulaire est de faire en sorte que le tokenizer transforme le moins de mots possible en token inconnu.

Une façon de réduire la quantité de tokens inconnus est d’aller un niveau plus profond, en utilisant un tokenizer basé sur les caractères.

##
<i> Tokenizer </i> basé sur les caractères

Les tokenizers basés sur les caractères divisent le texte en caractères, plutôt qu’en mots. Cela présente deux avantages principaux :

- le vocabulaire est beaucoup plus petit
- il y a beaucoup moins de tokens hors vocabulaire (inconnus) puisque chaque mot peut être construit à partir de caractères.

Mais là aussi, des questions se posent concernant les espaces et la ponctuation :

Cette approche n’est pas non plus parfaite. Puisque la représentation est maintenant basée sur des caractères plutôt que sur des mots, on pourrait dire intuitivement qu’elle est moins significative : chaque caractère ne signifie pas grand-chose en soi, alors que c’est le cas pour les mots. Toutefois, là encore, cela diffère selon la langue. En chinois, par exemple, chaque caractère est porteur de plus d’informations qu’un caractère dans une langue latine.

Un autre élément à prendre en compte est que nous nous retrouverons avec une très grande quantité de tokens à traiter par notre modèle. Alors qu’avec un tokenizer basé sur les mots, pour un mot donné on aurait qu’un seul token, avec un tokenizer basé sur les caractères, cela peut facilement se transformer en 10 tokens voire plus.

Pour obtenir le meilleur des deux mondes, nous pouvons utiliser une troisième technique qui combine les deux approches : la tokénisation en sous-mots.

##
Tokénisation en sous-mots

Les algorithmes de tokenisation en sous-mots reposent sur le principe selon lequel les mots fréquemment utilisés ne doivent pas être divisés en sous-mots plus petits, mais les mots rares doivent être décomposés en sous-mots significatifs.

Par exemple, le mot « maisonnette » peut être considéré comme un mot rare et peut être décomposé en « maison » et « ette ». Ces deux mots sont susceptibles d’apparaître plus fréquemment en tant que sous-mots autonomes, alors qu’en même temps le sens de « maison » est conservé par le sens composite de « maison » et « ette ».

Voici un exemple montrant comment un algorithme de tokenisation en sous-mots tokeniserait la séquence « Let’s do tokenization ! » :

Ces sous-mots finissent par fournir beaucoup de sens sémantique. Par exemple, ci-dessus, « tokenization » a été divisé en « token » et « ization » : deux tokens qui ont un sens sémantique tout en étant peu encombrants (seuls deux tokens sont nécessaires pour représenter un long mot). Cela nous permet d’avoir une couverture relativement bonne avec de petits vocabulaires et presque aucun token inconnu.

Cette approche est particulièrement utile dans les langues agglutinantes comme le turc, où l’on peut former des mots complexes (presque) arbitrairement longs en enchaînant des sous-mots.

###
Et plus encore !

Il existe de nombreuses autres techniques. Pour n’en citer que quelques-unes :

- le Byte-level BPE utilisé par exemple dans le GPT-2
- le WordPiece utilisé par exemple dans BERT
- SentencePiece ou Unigram, utilisés dans plusieurs modèles multilingues.

Vous devriez maintenant avoir une connaissance suffisante du fonctionnement des tokenizers pour commencer à utiliser l’API.

##
Chargement et sauvegarde

Le chargement et la sauvegarde des tokenizers est aussi simple que pour les modèles. En fait, c’est basé sur les deux mêmes méthodes : `from_pretrained()` et `save_pretrained()`. Ces méthodes vont charger ou sauvegarder l’algorithme utilisé par le tokenizer (un peu comme l’architecture du modèle) ainsi que son vocabulaire (un peu comme les poids du modèle).

Le chargement du tokenizer de BERT entraîné avec le même checkpoint que BERT se fait de la même manière que le chargement du modèle, sauf que nous utilisons la classe `BertTokenizer` :

    from transformers import BertTokenizer

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

Similaire à `AutoModel`, la classe `AutoTokenizer` récupère la classe de tokenizer appropriée dans la bibliothèque basée sur le nom du checkpoint. Elle peut être utilisée directement avec n’importe quel checkpoint :

    from transformers import AutoTokenizer

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

Nous pouvons à présent utiliser le tokenizer comme indiqué dans la section précédente :

    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]}

La sauvegarde d’un tokenizer est identique à celle d’un modèle :

    tokenizer.save_pretrained("directory_on_my_computer")

Nous parlerons plus en détail des `token_type_ids` au chapitre 3 et nous expliquerons la clé `attention_mask` un peu plus tard. Tout d’abord, voyons comment les `input_ids` sont générés. Pour ce faire, nous devons examiner les méthodes intermédiaires du tokenizer.

##
Encodage

La traduction d’un texte en chiffres est connue sous le nom d’encodage. L’encodage se fait en deux étapes : la tokenisation, suivie de la conversion en identifiants d’entrée.

Comme nous l’avons vu, la première étape consiste à diviser le texte en mots (ou parties de mots, symboles de ponctuation, etc.), généralement appelés tokens. De nombreuses règles peuvent régir ce processus. C’est pourquoi nous devons instancier le tokenizer en utilisant le nom du modèle afin de nous assurer que nous utilisons les mêmes règles que celles utilisées lors du pré-entraînement du modèle.

La deuxième étape consiste à convertir ces tokens en nombres afin de construire un tenseur à partir de ceux-ci ainsi que de les transmettre au modèle. Pour ce faire, le tokenizer possède un vocabulaire, qui est la partie que nous téléchargeons lorsque nous l’instancions avec la méthode `from_pretrained()`. Encore une fois, nous devons utiliser le même vocabulaire que celui utilisé lors du pré-entraînement du modèle.

Pour mieux comprendre les deux étapes, nous allons les explorer séparément. A noter que nous utilisons des méthodes effectuant séparément des parties du pipeline de tokenisation afin de montrer les résultats intermédiaires de ces étapes. Néanmoins, en pratique, il faut appeler le tokenizer directement sur vos entrées (comme indiqué dans la section 2).

###
Tokenisation

Le processus de tokenisation est effectué par la méthode `tokenize()` du 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)

La sortie de cette méthode est une liste de chaînes de caractères ou de tokens :

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

Ce tokenizer est un tokenizer de sous-mots : il découpe les mots jusqu’à obtenir des tokens qui peuvent être représentés par son vocabulaire. C’est le cas ici avec `transformer` qui est divisé en deux tokens : `transform` et `##er`.

###
De <i> tokens </i> aux identifiants d’entrée

La conversion en identifiants d’entrée est gérée par la méthode `convert_tokens_to_ids()` du tokenizer :

    ids = tokenizer.convert_tokens_to_ids(tokens)

    print(ids)

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

Une fois converties en tenseur dans le framework approprié, ces sorties peuvent ensuite être utilisées comme entrées d’un modèle, comme nous l’avons vu précédemment dans ce chapitre.

✏️ Essayez ! Reproduisez les deux dernières étapes (tokénisation et conversion en identifiants d’entrée) sur les phrases des entrées que nous avons utilisées dans la section 2 (« I’ve been waiting for a HuggingFace course my whole life. » et « I hate this so much! »). Vérifiez que vous obtenez les mêmes identifiants d’entrée que nous avons obtenus précédemment !

##
Décodage

Le décodage va dans l’autre sens : à partir d’indices du vocabulaire nous voulons obtenir une chaîne de caractères. Cela peut être fait avec la méthode `decode()` comme suit :

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

    'Using a Transformer network is simple'

Notez que la méthode `decode` non seulement reconvertit les indices en tokens mais regroupe également les tokens faisant partie des mêmes mots. Le but étant de produire une phrase lisible. Ce comportement sera extrêmement utile lorsque dans la suite du cours nous utiliserons des modèles pouvant produire du nouveau texte (soit du texte généré à partir d’un prompt, soit pour des problèmes de séquence à séquence comme la traduction ou le résumé de texte).

Vous devriez maintenant comprendre les opérations atomiques qu’un tokenizer peut gérer : tokenisation, conversion en identifiants, et reconversion des identifiants en chaîne de caractères. Cependant, nous n’avons fait qu’effleurer la partie émergée de l’iceberg. Dans la section suivante, nous allons pousser notre approche jusqu’à ses limites et voir comment les surmonter.