Pytorch
TensorFlow

#
Préparer les données

En continuant avec l’exemple du chapitre précédent, voici comment entraîner un classifieur de séquences sur un batch avec PyTorch :

    import torch
    from transformers import AdamW, AutoTokenizer, AutoModelForSequenceClassification

    # Même chose que précédemment
    checkpoint = "bert-base-uncased"
    tokenizer = AutoTokenizer.from_pretrained(checkpoint)
    model = AutoModelForSequenceClassification.from_pretrained(checkpoint)
    sequences = [
        "I've been waiting for a HuggingFace course my whole life.",
        # J'ai attendu un cours de HuggingFace toute ma vie.
        "This course is amazing!",  # Ce cours est incroyable !
    ]
    batch = tokenizer(sequences, padding=True, truncation=True, return_tensors="pt")

    # Ceci est nouveau
    batch["labels"] = torch.tensor([1, 1])

    optimizer = AdamW(model.parameters())
    loss = model(**batch).loss
    loss.backward()
    optimizer.step()

Evidemment, entraîner un modèle avec seulement deux phrases ne va pas donner de bons résultats. Pour obtenir de meilleurs résultats, vous allez avoir à préparer un plus grand jeu de données.

Dans cette section, nous allons utiliser comme exemple le jeu de données MRPC (Microsoft Research Paraphrase Corpus) présenté dans un papier par William B. Dolan et Chris Brockett. Ce jeu de données contient 5801 paires de phrases avec un label indiquant si ces paires sont des paraphrases ou non (i.e. si elles ont la même signification). Nous l’avons choisi pour ce chapitre parce que c’est un petit jeu de données et cela rend donc simples les expériences d’entraînement sur ce jeu de données.

###
Charger un jeu de données depuis le Hub

Le Hub ne contient pas seulement des modèles mais aussi plusieurs jeux de données dans un tas de langues différentes. Vous pouvez explorer les jeux de données ici et nous vous conseillons d’essayer de charger un nouveau jeu de données une fois que vous avez étudié cette section (voir la documentation générale ici). Mais pour l’instant, concentrons-nous sur le jeu de données MRPC ! Il s’agit de l’un des 10 jeux de données qui constituent le benchmark GLUE qui est un benchmark académique utilisé pour mesurer les performances des modèles d’apprentissage automatique sur 10 différentes tâches de classification de textes.

La bibliothèque 🤗 Datasets propose une commande très simple pour télécharger et mettre en cache un jeu de données à partir du Hub. On peut télécharger le jeu de données MRPC comme ceci :

    from datasets import load_dataset

    raw_datasets = load_dataset("glue", "mrpc")
    raw_datasets

    DatasetDict({
        train: Dataset({
            features: ['sentence1', 'sentence2', 'label', 'idx'],
            num_rows: 3668
        })
        validation: Dataset({
            features: ['sentence1', 'sentence2', 'label', 'idx'],
            num_rows: 408
        })
        test: Dataset({
            features: ['sentence1', 'sentence2', 'label', 'idx'],
            num_rows: 1725
        })
    })

Comme vous le voyez, on obtient un objet de type `DatasetDict` qui contient le jeu de données d’entraînement, celui de validation et celui de test. Chacun d’eux contient plusieurs colonnes (`sentence1`, `sentence2`, `label` et `idx`) et une variable nombre de lignes qui contient le nombre d’éléments dans chaque jeu de données (il y a donc 3.668 paires de phrases dans le jeu d’entraînement, 408 dans celui de validation et 1.725 dans celui de test).

Cette commande télécharge et met en cache le jeu de données dans ~/.cache/huggingface/dataset. Rappelez-vous que comme vu au chapitre 2, vous pouvez personnaliser votre dossier cache en modifiant la variable d’environnement `HF_HOME`.

Nous pouvons accéder à chaque paire de phrase de notre objet `raw_datasets` par les indices, comme avec un dictionnaire :

    raw_train_dataset = raw_datasets["train"]
    raw_train_dataset[0]

    {'idx': 0,
     'label': 1,
     'sentence1': 'Amrozi accused his brother , whom he called " the witness " , of deliberately distorting his evidence .',
     # Amrozi a accusé son frère, qu'il a appelé « le témoin », de déformer délibérément son témoignage.
     'sentence2': 'Referring to him as only " the witness " , Amrozi accused his brother of deliberately distorting his evidence .'}
     # Se référant à lui uniquement comme « le témoin », Amrozi a accusé son frère de déformer délibérément son témoignage.

Nous pouvons voir que les étiquettes sont déjà des entiers, donc nous n’aurons pas à faire de prétraitement ici. Pour savoir quel entier correspond à quel label, nous pouvons inspecter les `features` de notre `raw_train_dataset`. Cela nous indiquera le type de chaque colonne :

    raw_train_dataset.features

    {'sentence1': Value(dtype='string', id=None),
     'sentence2': Value(dtype='string', id=None),
     'label': ClassLabel(num_classes=2, names=['not_equivalent', 'equivalent'], names_file=None, id=None),
     'idx': Value(dtype='int32', id=None)}

En réalité, `label` est de type `ClassLabel` et la correspondance des entiers aux noms des labels est enregistrée le dossier names. `0` correspond à `not_equivalent` et `1` correspond à `equivalent`.

✏️ Essayez ! Regardez l’élément 15 de l’ensemble d’entraînement et l’élément 87 de l’ensemble de validation. Quelles sont leurs étiquettes ?

###
Prétraitement d'un jeu de données

Pour prétraiter le jeu de données, nous devons convertir le texte en chiffres compréhensibles par le modèle. Comme vous l’avez vu dans le chapitre précédent, cette conversion est effectuée par un tokenizer. Nous pouvons fournir au tokenizer une phrase ou une liste de phrases, de sorte que nous pouvons directement tokeniser toutes les premières phrases et toutes les secondes phrases de chaque paire comme ceci :

    from transformers import AutoTokenizer

    checkpoint = "bert-base-uncased"
    tokenizer = AutoTokenizer.from_pretrained(checkpoint)
    tokenized_sentences_1 = tokenizer(raw_datasets["train"]["sentence1"])
    tokenized_sentences_2 = tokenizer(raw_datasets["train"]["sentence2"])

Cependant, nous ne pouvons pas simplement passer deux séquences au modèle et obtenir une prédiction pour savoir si les deux phrases sont des paraphrases ou non. Nous devons traiter les deux séquences comme une paire, et appliquer le prétraitement approprié. Heureusement, le tokenizer peut également prendre une paire de séquences et la préparer de la manière attendue par notre modèle BERT :

    inputs = tokenizer(
        "This is the first sentence.", "This is the second one."
    )  # "C'est la première phrase.", "C'est la deuxième."
    inputs

    {
      'input_ids': [101, 2023, 2003, 1996, 2034, 6251, 1012, 102, 2023, 2003, 1996, 2117, 2028, 1012, 102],
      'token_type_ids': [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1],
      'attention_mask': [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
    }

Nous avons discuté des clés `input_ids` et `attention_mask` dans le chapitre 2, mais nous avons laissé de côté les `token_type_ids`. Dans cet exemple, c’est ce qui indique au modèle quelle partie de l’entrée est la première phrase et quelle partie est la deuxième phrase.

✏️ Essayez ! Prenez l’élément 15 de l’ensemble d’entraînement et tokenisez les deux phrases séparément et par paire. Quelle est la différence entre les deux résultats ?

Si on décode les IDs dans `input_ids` en mots :

    tokenizer.convert_ids_to_tokens(inputs["input_ids"])

nous aurons :

    ['[CLS]', 'this', 'is', 'the', 'first', 'sentence', '.', '[SEP]', 'this', 'is', 'the', 'second', 'one', '.', '[SEP]']

Nous voyons donc que le modèle s’attend à ce que les entrées soient de la forme `[CLS] phrase1 [SEP] phrase2 [SEP]` lorsqu’il y a deux phrases. En alignant cela avec les `token_type_ids`, on obtient :

    ['[CLS]', 'this', 'is', 'the', 'first', 'sentence', '.', '[SEP]', 'this', 'is', 'the', 'second', 'one', '.', '[SEP]']
    [      0,      0,    0,     0,       0,          0,   0,       0,      1,    1,     1,        1,     1,   1,       1]

Comme vous pouvez le voir, les parties de l’entrée correspondant à `[CLS] sentence1 [SEP]` ont toutes un token de type ID de `0`, tandis que les autres parties, correspondant à `sentence2 [SEP]`, ont toutes un token de type ID de `1`.

Notez que si vous choisissez un autre checkpoint, vous n’aurez pas nécessairement les `token_type_ids` dans vos entrées tokenisées (par exemple, ils ne sont pas retournés si vous utilisez un modèle DistilBERT). Ils ne sont retournés que lorsque le modèle sait quoi faire avec eux, parce qu’il les a vus pendant son pré-entraînement.

Ici, BERT est pré-entraîné avec les tokens de type ID et en plus de l’objectif de modélisation du langage masqué dont nous avons abordé dans chapitre 1, il a un objectif supplémentaire appelé prédiction de la phrase suivante. Le but de cette tâche est de modéliser la relation entre des paires de phrases.

Avec la prédiction de la phrase suivante, on fournit au modèle des paires de phrases (avec des tokens masqués de manière aléatoire) et on lui demande de prédire si la deuxième phrase suit la première. Pour rendre la tâche non triviale, la moitié du temps, les phrases se suivent dans le document d’origine dont elles ont été extraites, et l’autre moitié du temps, les deux phrases proviennent de deux documents différents.

En général, vous n’avez pas besoin de vous inquiéter de savoir s’il y a ou non des `token_type_ids` dans vos entrées tokenisées : tant que vous utilisez le même checkpoint pour le tokenizer et le modèle, tout ira bien puisque le tokenizer sait quoi fournir à son modèle.

Maintenant que nous avons vu comment notre tokenizer peut traiter une paire de phrases, nous pouvons l’utiliser pour tokeniser l’ensemble de notre jeu de données : comme dans le chapitre précédent, nous pouvons fournir au tokenizer une liste de paires de phrases en lui donnant la liste des premières phrases, puis la liste des secondes phrases. Ceci est également compatible avec les options de remplissage et de troncature que nous avons vues dans le chapitre 2. Voici donc une façon de prétraiter le jeu de données d’entraînement :

    tokenized_dataset = tokenizer(
        raw_datasets["train"]["sentence1"],
        raw_datasets["train"]["sentence2"],
        padding=True,
        truncation=True,
    )

Cela fonctionne bien, mais a l’inconvénient de retourner un dictionnaire (avec nos clés, `input_ids`, `attention_mask`, et `token_type_ids`, et des valeurs qui sont des listes de listes). Cela ne fonctionnera également que si vous avez assez de RAM pour stocker l’ensemble de votre jeu de données pendant la tokenisation (alors que les jeux de données de la bibliothèque 🤗 Datasets sont des fichiers Apache Arrow stockés sur le disque, vous ne gardez donc en mémoire que les échantillons que vous demandez).

Pour conserver les données sous forme de jeu de données, nous utiliserons la méthode `Dataset.map()`. Cela nous permet également une certaine flexibilité, si nous avons besoin d’un prétraitement plus poussé que la simple tokenisation. La méthode `map()` fonctionne en appliquant une fonction sur chaque élément de l’ensemble de données, donc définissons une fonction qui tokenise nos entrées :

    def tokenize_function(example):
        return tokenizer(example["sentence1"], example["sentence2"], truncation=True)

Cette fonction prend un dictionnaire (comme les éléments de notre jeu de données) et retourne un nouveau dictionnaire avec les clés `input_ids`, `attention_mask`, et `token_type_ids`. Notez que cela fonctionne également si le dictionnaire `example` contient plusieurs échantillons (chaque clé étant une liste de phrases) puisque le `tokenizer` travaille sur des listes de paires de phrases, comme vu précédemment. Cela nous permettra d’utiliser l’option `batched=True` dans notre appel à `map()`, ce qui accélérera grandement la tokénisation. Le `tokenizer` est soutenu par un tokenizer écrit en Rust à partir de la bibliothèque 🤗 Tokenizers. Ce tokenizer peut être très rapide, mais seulement si on lui donne beaucoup d’entrées en même temps.

Notez que nous avons laissé l’argument `padding` hors de notre fonction de tokenizer pour le moment. C’est parce que le padding de tous les échantillons à la longueur maximale n’est pas efficace : il est préférable de remplir les échantillons lorsque nous construisons un batch, car alors nous avons seulement besoin de remplir à la longueur maximale dans ce batch, et non la longueur maximale dans l’ensemble des données. Cela peut permettre de gagner beaucoup de temps et de puissance de traitement lorsque les entrées ont des longueurs très variables !

Voici comment nous appliquons la fonction de tokenization sur tous nos jeux de données en même temps. Nous utilisons `batched=True` dans notre appel à `map` pour que la fonction soit appliquée à plusieurs éléments de notre jeu de données en une fois, et non à chaque élément séparément. Cela permet un prétraitement plus rapide.

    tokenized_datasets = raw_datasets.map(tokenize_function, batched=True)
    tokenized_datasets

La façon dont la bibliothèque 🤗 Datasets applique ce traitement consiste à ajouter de nouveaux champs aux jeux de données, un pour chaque clé du dictionnaire renvoyé par la fonction de prétraitement :

    DatasetDict({
        train: Dataset({
            features: ['attention_mask', 'idx', 'input_ids', 'label', 'sentence1', 'sentence2', 'token_type_ids'],
            num_rows: 3668
        })
        validation: Dataset({
            features: ['attention_mask', 'idx', 'input_ids', 'label', 'sentence1', 'sentence2', 'token_type_ids'],
            num_rows: 408
        })
        test: Dataset({
            features: ['attention_mask', 'idx', 'input_ids', 'label', 'sentence1', 'sentence2', 'token_type_ids'],
            num_rows: 1725
        })
    })

Vous pouvez même utiliser le multitraitement lorsque vous appliquez votre fonction de prétraitement avec `map()` en passant un argument `num_proc`. Nous ne l’avons pas fait ici parce que la bibliothèque 🤗 Tokenizers utilise déjà plusieurs threads pour tokeniser nos échantillons plus rapidement, mais si vous n’utilisez pas un tokenizer rapide soutenu par cette bibliothèque, cela pourrait accélérer votre prétraitement.

Notre `tokenize_function` retourne un dictionnaire avec les clés `input_ids`, `attention_mask`, et `token_type_ids`, donc ces trois champs sont ajoutés à toutes les divisions de notre jeu de données. Notez que nous aurions également pu modifier des champs existants si notre fonction de prétraitement avait retourné une nouvelle valeur pour une clé existante dans l’ensemble de données auquel nous avons appliqué `map()`.

La dernière chose que nous devrons faire est de remplir tous les exemples à la longueur de l’élément le plus long lorsque nous regroupons les éléments, une technique que nous appelons le padding dynamique.

###
Padding dynamique

La fonction qui est responsable de l’assemblage des échantillons dans un batch est appelée fonction d’assemblement. C’est un argument que vous pouvez passer quand vous construisez un `DataLoader`, la valeur par défaut étant une fonction qui va juste convertir vos échantillons en tenseurs PyTorch et les concaténer (récursivement si vos éléments sont des listes, des tuples ou des dictionnaires). Cela ne sera pas possible dans notre cas puisque les entrées que nous avons ne seront pas toutes de la même taille. Nous avons délibérément reporté le padding, pour ne l’appliquer que si nécessaire sur chaque batch et éviter d’avoir des entrées trop longues avec beaucoup de remplissage. Cela accélère considérablement l’entraînement, mais notez que si vous vous entraînez sur un TPU, cela peut poser des problèmes. En effet, les TPU préfèrent les formes fixes, même si cela nécessite un padding supplémentaire.

Pour faire cela en pratique, nous devons définir une fonction d’assemblement qui appliquera la bonne quantité de padding aux éléments du jeu de données que nous voulons regrouper. Heureusement, la bibliothèque 🤗 Transformers nous fournit une telle fonction via `DataCollatorWithPadding`. Elle prend un tokenizer lorsque vous l’instanciez (pour savoir quel token de padding utiliser et si le modèle s’attend à ce que le padding soit à gauche ou à droite des entrées) et fera tout ce dont vous avez besoin :

    from transformers import DataCollatorWithPadding

    data_collator = DataCollatorWithPadding(tokenizer=tokenizer)

Pour tester notre nouveau jouet, prenons quelques éléments de notre jeu d’entraînement avec lesquels nous allons former un batch. Ici, on supprime les colonnes `idx`, `sentence1` et `sentence2` puisque nous n’en aurons pas besoin et qu’elles contiennent des strings (et nous ne pouvons pas créer des tenseurs avec des strings) et on regarde la longueur de chaque entrée du batch :

    samples = tokenized_datasets["train"][:8]
    samples = {k: v for k, v in samples.items() if k not in ["idx", "sentence1", "sentence2"]}
    [len(x) for x in samples["input_ids"]]

    [50, 59, 47, 67, 59, 50, 62, 32]

Sans surprise, nous obtenons des échantillons de longueur variable, de 32 à 67. Le padding dynamique signifie que les échantillons de ce batch doivent tous être rembourrés à une longueur de 67, la longueur maximale dans le batch. Sans le padding dynamique, tous les échantillons devraient être rembourrés à la longueur maximale du jeu de données entier, ou à la longueur maximale que le modèle peut accepter. Vérifions à nouveau que notre `data_collator` rembourre dynamiquement le batch correctement :

    batch = data_collator(samples)
    {k: v.shape for k, v in batch.items()}

    {'attention_mask': torch.Size([8, 67]),
     'input_ids': torch.Size([8, 67]),
     'token_type_ids': torch.Size([8, 67]),
     'labels': torch.Size([8])}

C’est beau ! Maintenant que nous sommes passés du texte brut à des batchs que notre modèle peut traiter, nous sommes prêts à le finetuner !

✏️ Essayez ! Reproduisez le prétraitement sur le jeu de données GLUE SST-2. C’est un peu différent puisqu’il est composé de phrases simples au lieu de paires, mais le reste de ce que nous avons fait devrait être identique. Pour un défi plus difficile, essayez d’écrire une fonction de prétraitement qui fonctionne sur toutes les tâches GLUE.