NLP Course documentation

Réponse aux questions

Hugging Face's logo
Join the Hugging Face community

and get access to the augmented documentation experience

to get started

Réponse aux questions

Ask a Question Open In Colab Open In Studio Lab

Il est temps de s’intéresser à la réponse aux questions ! Cette tâche peut prendre plusieurs formes mais celle sur laquelle nous allons nous concentrer dans cette section est appelée réponse aux questions extractives. Il s’agit de poser des questions sur un document et d’identifier les réponses sous forme de « d’étendue de texte » dans le document lui-même.

Nous allons finetuner un modèle BERT sur le jeu de données SQuAD, qui consiste en des questions posées par des crowdworkers sur un ensemble d’articles de Wikipedia. Cela nous donnera un modèle capable de calculer des prédictions comme celui-ci :

Il s’agit d’une présentation du modèle qui a été entraîné à l’aide du code présenté dans cette section et qui a ensuité été téléchargé sur le Hub. Vous pouvez le trouver ici

💡 Les modèles basé que sur l’encodeur comme BERT ont tendance à être excellents pour extraire les réponses à des questions factuelles comme « Qui a inventé l’architecture Transformer ? » mais ne sont pas très performants lorsqu’on leur pose des questions ouvertes comme « Pourquoi le ciel est-il bleu ? ». Dans ces cas plus difficiles, les modèles encodeurs-décodeurs comme le T5 et BART sont généralement utilisés pour synthétiser les informations d’une manière assez similaire au résumé de texte. Si vous êtes intéressé par ce type de réponse aux questions génératives, nous vous recommandons de consulter notre démo basée sur le jeu de données ELI5.

Préparation des données

Le jeu de données le plus utilisé comme référence académique pour la réponse extractive aux questions est SQuAD. C’est donc celui que nous utiliserons ici. Il existe également une version plus difficile SQuAD v2, qui comprend des questions sans réponse. Tant que votre propre jeu de données contient une colonne pour les contextes, une colonne pour les questions et une colonne pour les réponses, vous devriez être en mesure d’adapter les étapes ci-dessous.

Le jeu de données SQuAD

Comme d’habitude, nous pouvons télécharger et mettre en cache le jeu de données en une seule étape grâce à load_dataset() :

from datasets import load_dataset

raw_datasets = load_dataset("squad")

Nous pouvons jeter un coup d’œil à cet objet pour en savoir plus sur le jeu de données SQuAD :

raw_datasets
DatasetDict({
    train: Dataset({
        features: ['id', 'title', 'context', 'question', 'answers'],
        num_rows: 87599
    })
    validation: Dataset({
        features: ['id', 'title', 'context', 'question', 'answers'],
        num_rows: 10570
    })
})

On dirait que nous avons tout ce dont nous avons besoin avec les champs context, question et answers. Affichons-les pour le premier élément de notre ensemble d’entraînement :

print("Context: ", raw_datasets["train"][0]["context"])
print("Question: ", raw_datasets["train"][0]["question"])
print("Answer: ", raw_datasets["train"][0]["answers"])
Context: 'Architecturally, the school has a Catholic character. Atop the Main Building\'s gold dome is a golden statue of the Virgin Mary. Immediately in front of the Main Building and facing it, is a copper statue of Christ with arms upraised with the legend "Venite Ad Me Omnes". Next to the Main Building is the Basilica of the Sacred Heart. Immediately behind the basilica is the Grotto, a Marian place of prayer and reflection. It is a replica of the grotto at Lourdes, France where the Virgin Mary reputedly appeared to Saint Bernadette Soubirous in 1858. At the end of the main drive (and in a direct line that connects through 3 statues and the Gold Dome), is a simple, modern stone statue of Mary.'
# Sur le plan architectural, l'école a un caractère catholique. Au sommet du dôme doré du bâtiment principal se trouve une statue dorée de la Vierge Marie. Immédiatement devant le bâtiment principal et face à lui, se trouve une statue en cuivre du Christ, les bras levés, avec la légende "Venite Ad Me Omnes". À côté du bâtiment principal se trouve la basilique du Sacré-Cœur. Immédiatement derrière la basilique se trouve la Grotte, un lieu marial de prière et de réflexion. Il s'agit d'une réplique de la grotte de Lourdes, en France, où la Vierge Marie serait apparue à Sainte Bernadette Soubirous en 1858. Au bout de l'allée principale (et dans une ligne directe qui passe par 3 statues et le Dôme d'or), se trouve une statue de pierre simple et moderne de Marie'.
Question: 'To whom did the Virgin Mary allegedly appear in 1858 in Lourdes France?' 
# A qui la Vierge Marie serait-elle apparue en 1858 à Lourdes, en France ?
Answer: {'text': ['Saint Bernadette Soubirous'], 'answer_start': [515]}

Les champs context et question sont très simples à utiliser. Le champ answers est un peu plus délicat car il compile un dictionnaire avec deux champs qui sont tous deux des listes. C’est le format qui sera attendu par la métrique squad lors de l’évaluation. Si vous utilisez vos propres données, vous n’avez pas nécessairement besoin de vous soucier de mettre les réponses dans le même format. Le champ text est assez évident et le champ answer_start contient l’indice du caractère de départ de chaque réponse dans le contexte.

Pendant l’entraînement, il n’y a qu’une seule réponse possible. Nous pouvons vérifier cela en utilisant la méthode Dataset.filter() :

raw_datasets["train"].filter(lambda x: len(x["answers"]["text"]) != 1)
Dataset({
    features: ['id', 'title', 'context', 'question', 'answers'],
    num_rows: 0
})

Pour l’évaluation, cependant, il existe plusieurs réponses possibles pour chaque échantillon, qui peuvent être identiques ou différentes :

print(raw_datasets["validation"][0]["answers"])
print(raw_datasets["validation"][2]["answers"])
{'text': ['Denver Broncos', 'Denver Broncos', 'Denver Broncos'], 'answer_start': [177, 177, 177]}
{'text': ['Santa Clara, California', "Levi's Stadium", "Levi's Stadium in the San Francisco Bay Area at Santa Clara, California."], 'answer_start': [403, 355, 355]}

Nous ne nous plongerons pas dans le script d’évaluation car tout sera enveloppé pour nous par une métrique de 🤗 Datasets. La version courte est que certaines des questions ont plusieurs réponses possibles, et ce script va comparer une réponse prédite à toutes les réponses acceptables et prendre le meilleur score. Par exemple, si nous regardons l’échantillon de l’indice 2 :

print(raw_datasets["validation"][2]["context"])
print(raw_datasets["validation"][2]["question"])
'Super Bowl 50 was an American football game to determine the champion of the National Football League (NFL) for the 2015 season. The American Football Conference (AFC) champion Denver Broncos defeated the National Football Conference (NFC) champion Carolina Panthers 24–10 to earn their third Super Bowl title. The game was played on February 7, 2016, at Levi\'s Stadium in the San Francisco Bay Area at Santa Clara, California. As this was the 50th Super Bowl, the league emphasized the "golden anniversary" with various gold-themed initiatives, as well as temporarily suspending the tradition of naming each Super Bowl game with Roman numerals (under which the game would have been known as "Super Bowl L"), so that the logo could prominently feature the Arabic numerals 50.'
# Le Super Bowl 50 était un match de football américain visant à déterminer le champion de la National Football League (NFL) pour la saison 2015. Les Denver Broncos, champions de la Conférence de football américain (AFC), ont battu les Carolina Panthers, champions de la Conférence nationale de football (NFC), 24 à 10, pour remporter leur troisième titre de Super Bowl. Le match s'est déroulé le 7 février 2016 au Levi\'s Stadium, dans la baie de San Francisco, à Santa Clara, en Californie. Comme il s'agissait du 50e Super Bowl, la ligue a mis l'accent sur l'" anniversaire doré " avec diverses initiatives sur le thème de l'or, ainsi qu'en suspendant temporairement la tradition de nommer chaque match du Super Bowl avec des chiffres romains (en vertu de laquelle le match aurait été appelé " Super Bowl L "), afin que le logo puisse mettre en évidence les chiffres arabes 50.''
'Where did Super Bowl 50 take place?' 
# Où a eu lieu le Super Bowl 50 ?

nous pouvons voir que la réponse peut effectivement être l’une des trois possibilités que nous avons vues précédemment.

Traitement des données d’entraînement

Commençons par le prétraitement des données d’entraînement. La partie la plus difficile est de générer des étiquettes pour la réponse à la question, c’est-à-dire les positions de début et de fin des tokens correspondant à la réponse dans le contexte.

Mais ne nous emballons pas. Tout d’abord, à l’aide d’un tokenizer, nous devons convertir le texte d’entrée en identifiants que le modèle peut comprendre :

from transformers import AutoTokenizer

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

Comme mentionné précédemment, nous allons finetuner un modèle BERT, mais vous pouvez utiliser n’importe quel autre type de modèle tant qu’il a un tokenizer rapide implémenté. Vous pouvez voir toutes les architectures qui sont livrées avec un tokenizer rapide dans ce tableau, et pour vérifier que l’objet tokenizer que vous utilisez est bien soutenu par 🤗 Tokenizers vous pouvez regarder son attribut is_fast :

tokenizer.is_fast
True

Nous pouvons transmettre à notre tokenizer la question et le contexte ensemble. Il insérera correctement les tokens spéciaux pour former une phrase comme celle-ci :

[CLS] question [SEP] context [SEP]

Vérifions à nouveau :

context = raw_datasets["train"][0]["context"]
question = raw_datasets["train"][0]["question"]

inputs = tokenizer(question, context)
tokenizer.decode(inputs["input_ids"])
'[CLS] To whom did the Virgin Mary allegedly appear in 1858 in Lourdes France? [SEP] Architecturally, '
'the school has a Catholic character. Atop the Main Building\'s gold dome is a golden statue of the Virgin '
'Mary. Immediately in front of the Main Building and facing it, is a copper statue of Christ with arms '
'upraised with the legend " Venite Ad Me Omnes ". Next to the Main Building is the Basilica of the Sacred '
'Heart. Immediately behind the basilica is the Grotto, a Marian place of prayer and reflection. It is a '
'replica of the grotto at Lourdes, France where the Virgin Mary reputedly appeared to Saint Bernadette '
'Soubirous in 1858. At the end of the main drive ( and in a direct line that connects through 3 statues '
'and the Gold Dome ), is a simple, modern stone statue of Mary. [SEP]'

'[CLS] A qui la Vierge Marie serait-elle apparue en 1858 à Lourdes en France ? [SEP] Architecturalement, '
'l école a un caractère catholique. Au sommet du dôme doré du bâtiment principal se trouve une statue dorée de la Vierge '
'Marie. Immédiatement devant le bâtiment principal et face à lui, se trouve une statue en cuivre du Christ, les bras '
'levés avec la légende " Venite Ad Me Omnes ". A côté du bâtiment principal se trouve la basilique du Sacré '
'Cœur. Immédiatement derrière la basilique se trouve la Grotte, un lieu marial de prière et de réflexion. Il s'agit d'une '
'réplique de la grotte de Lourdes, en France, où la Vierge Marie serait apparue à Sainte Bernadette '
'Soubirous en 1858. Au bout de l'allée principale ( et en ligne directe qui passe par 3 statues '
'et le Dôme d'or), se trouve une statue de Marie en pierre, simple et moderne. [SEP]'

Les étiquettes sont l’index des tokens de début et de fin de la réponse. Le modèle sera chargé de prédire dans l’entrée un logit de début et de fin par token, les étiquettes théoriques étant les suivantes :

One-hot encoded labels for question answering.

Dans ce cas, le contexte n’est pas trop long, mais certains des exemples du jeu de données ont des contextes très longs qui dépasseront la longueur maximale que nous avons fixée (qui est de 384 dans ce cas). Comme nous l’avons vu dans le chapitre 6 lorsque nous avons exploré le pipeline de question-answering, nous allons traiter les contextes longs en créant plusieurs caractéristiques d’entraînement à partir d’un échantillon de notre jeu de données et avec une fenêtre glissante entre eux.

Pour voir comment cela fonctionne sur notre exemple, nous pouvons limiter la longueur à 100 et utiliser une fenêtre glissante de 50 tokens. Pour rappel, nous utilisons :

  • max_length pour définir la longueur maximale (ici 100)
  • truncation="only_second" pour tronquer le contexte (qui est en deuxième position) quand la question avec son contexte est trop longue
  • stride pour fixer le nombre de tokens se chevauchant entre deux morceaux successifs (ici 50)
  • return_overflowing_tokens=True pour indiquer au tokenizer que l’on veut les tokens qui débordent
inputs = tokenizer(
    question,
    context,
    max_length=100,
    truncation="only_second",
    stride=50,
    return_overflowing_tokens=True,
)

for ids in inputs["input_ids"]:
    print(tokenizer.decode(ids))
'[CLS] To whom did the Virgin Mary allegedly appear in 1858 in Lourdes France? [SEP] Architecturally, the school has a Catholic character. Atop the Main Building\'s gold dome is a golden statue of the Virgin Mary. Immediately in front of the Main Building and facing it, is a copper statue of Christ with arms upraised with the legend " Venite Ad Me Omnes ". Next to the Main Building is the Basilica of the Sacred Heart. Immediately behind the basi [SEP]'
'[CLS] A qui la Vierge Marie serait-elle apparue en 1858 à Lourdes en France ? [SEP] Sur le plan architectural, l école a un caractère catholique. Au sommet du dôme doré du bâtiment principal se trouve une statue dorée de la Vierge Marie. Immédiatement devant le bâtiment principal et face à lui, se trouve une statue en cuivre du Christ, les bras levés, avec la légende " Venite Ad Me Omnes ". À côté du bâtiment principal se trouve la basilique du Sacré-Cœur. Immédiatement derrière la basi [SEP]'

'[CLS] To whom did the Virgin Mary allegedly appear in 1858 in Lourdes France? [SEP] the Main Building and facing it, is a copper statue of Christ with arms upraised with the legend " Venite Ad Me Omnes ". Next to the Main Building is the Basilica of the Sacred Heart. Immediately behind the basilica is the Grotto, a Marian place of prayer and reflection. It is a replica of the grotto at Lourdes, France where the Virgin [SEP]'
'[CLS] A qui la Vierge Marie serait-elle apparue en 1858 à Lourdes en France ? [SEP] le bâtiment principal et face à lui, une statue en cuivre du Christ aux bras levés avec la légende " Venite Ad Me Omnes ". À côté du bâtiment principal se trouve la basilique du Sacré-Cœur. Immédiatement derrière la basilique se trouve la Grotte, un lieu marial de prière et de réflexion. Il s agit d'une réplique de la grotte de Lourdes, en France, où la Vierge [SEP]'

'[CLS] To whom did the Virgin Mary allegedly appear in 1858 in Lourdes France? [SEP] Next to the Main Building is the Basilica of the Sacred Heart. Immediately behind the basilica is the Grotto, a Marian place of prayer and reflection. It is a replica of the grotto at Lourdes, France where the Virgin Mary reputedly appeared to Saint Bernadette Soubirous in 1858. At the end of the main drive ( and in a direct line that connects through 3 [SEP]'
'[CLS] A qui la Vierge Marie serait-elle apparue en 1858 à Lourdes en France ? [SEP] A côté du bâtiment principal se trouve la basilique du Sacré-Cœur. Immédiatement derrière la basilique se trouve la Grotte, un lieu marial de prière et de réflexion. Il s agit d une réplique de la grotte de Lourdes, en France, où la Vierge Marie serait apparue à Sainte Bernadette Soubirous en 1858. Au bout de l allée principale ( et dans une ligne directe qui relie par 3 [SEP]'

'[CLS] To whom did the Virgin Mary allegedly appear in 1858 in Lourdes France? [SEP]. It is a replica of the grotto at Lourdes, France where the Virgin Mary reputedly appeared to Saint Bernadette Soubirous in 1858. At the end of the main drive ( and in a direct line that connects through 3 statues and the Gold Dome ), is a simple, modern stone statue of Mary. [SEP]'
'[CLS] A qui la Vierge Marie est-elle prétendument apparue en 1858 à Lourdes France ? [SEP]. Il s agit d une réplique de la grotte de Lourdes, en France, où la Vierge Marie serait apparue à Sainte Bernadette Soubirous en 1858. Au bout de l allée principale (et dans une ligne directe qui passe par 3 statues et le Dôme d or), se trouve une simple statue de pierre moderne de Marie. [SEP]'

Comme nous pouvons le voir, notre exemple a été divisé en quatre entrées, chacune d’entre elles contenant la question et une partie du contexte. Notez que la réponse à la question (« Bernadette Soubirous ») n’apparaît que dans la troisième et la dernière entrée. Donc en traitant les longs contextes de cette façon, nous allons créer quelques exemples d’entraînement où la réponse n’est pas incluse dans le contexte. Pour ces exemples, les étiquettes seront start_position = end_position = 0 (donc nous prédisons le token [CLS]). Nous définirons également ces étiquettes dans le cas malheureux où la réponse a été tronquée de sorte que nous n’avons que le début (ou la fin) de celle-ci. Pour les exemples où la réponse est entièrement dans le contexte, les étiquettes seront l’index du token où la réponse commence et l’index du token où la réponse se termine.

Le jeu de données nous fournit le caractère de début de la réponse dans le contexte, et en ajoutant la longueur de la réponse, nous pouvons trouver le caractère de fin dans le contexte. Pour faire correspondre ces indices aux tokens, nous devrons utiliser les correspondances offset que nous avons étudiés au chapitre 6. Nous pouvons faire en sorte que notre tokenizer renvoie ces index en passant return_offsets_mapping=True :

inputs = tokenizer(
    question,
    context,
    max_length=100,
    truncation="only_second",
    stride=50,
    return_overflowing_tokens=True,
    return_offsets_mapping=True,
)
inputs.keys()
dict_keys(['input_ids', 'token_type_ids', 'attention_mask', 'offset_mapping', 'overflow_to_sample_mapping'])

Comme nous pouvons le voir, nous récupérons les identifiants d’entrée, les tokens de type identifiant, le masque d’attention, ainsi que la correspondance offset dont nous avions besoin et une clé supplémentaire, overflow_to_sample_mapping. La valeur correspondante nous sera utile lorsque nous tokeniserons plusieurs textes en même temps (ce que nous devrions faire pour bénéficier du fait que notre tokenizer est en Rust). Puisqu’un échantillon peut donner plusieurs caractéristiques, il fait correspondre chaque caractéristique à l’exemple d’où elle provient. Parce qu’ici nous avons seulement tokenisé un exemple, nous obtenons une liste de 0 :

inputs["overflow_to_sample_mapping"]
[0, 0, 0, 0]

Mais si nous tokenisons davantage d’exemples, cela deviendra plus utile :

inputs = tokenizer(
    raw_datasets["train"][2:6]["question"],
    raw_datasets["train"][2:6]["context"],
    max_length=100,
    truncation="only_second",
    stride=50,
    return_overflowing_tokens=True,
    return_offsets_mapping=True,
)

print(f"The 4 examples gave {len(inputs['input_ids'])} features.")
print(f"Here is where each comes from: {inputs['overflow_to_sample_mapping']}.")
'The 4 examples gave 19 features.'
'Here is where each comes from: [0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3].'

Comme nous pouvons le voir, les trois premiers exemples (aux indices 2, 3 et 4 de l’ensemble d’entraînement) ont chacun donné quatre caractéristiques et le dernier exemple (à l’indice 5 de l’ensemble d’entraînement) a donné 7 caractéristiques.

Ces informations seront utiles pour associer chaque caractéristique obtenue à son étiquette correspondante. Comme mentionné précédemment, ces étiquettes sont :

  • (0, 0) si la réponse n’est pas dans l’espace correspondant du contexte.
  • (start_position, end_position) si la réponse est dans l’espace correspondant du contexte, avec start_position étant l’index du token (dans les identifiants d’entrée) au début de la réponse et end_position étant l’index du token (dans les identifiants d’entrée) où la réponse se termine.

Pour déterminer ce qui est le cas et, le cas échéant, les positions des tokens, nous trouvons d’abord les indices qui commencent et finissent le contexte dans les identifiants d’entrée. Nous pourrions utiliser les tokens de type identifiants pour le faire, mais puisque ceux-ci n’existent pas nécessairement pour tous les modèles (DistilBERT ne les requiert pas par exemple), nous allons plutôt utiliser la méthode sequence_ids() du BatchEncoding que notre tokenizer retourne.

Une fois que nous avons ces indices de tokens, nous regardons les offsets correspondants, qui sont des tuples de deux entiers représentant l’étendue des caractères dans le contexte original. Nous pouvons ainsi détecter si le morceau de contexte dans cette fonctionnalité commence après la réponse ou se termine avant que la réponse ne commence (dans ce cas, l’étiquette est (0, 0)). Si ce n’est pas le cas, nous bouclons pour trouver le premier et le dernier token de la réponse :

answers = raw_datasets["train"][2:6]["answers"]
start_positions = []
end_positions = []

for i, offset in enumerate(inputs["offset_mapping"]):
    sample_idx = inputs["overflow_to_sample_mapping"][i]
    answer = answers[sample_idx]
    start_char = answer["answer_start"][0]
    end_char = answer["answer_start"][0] + len(answer["text"][0])
    sequence_ids = inputs.sequence_ids(i)

    # Trouver le début et la fin du contexte
    idx = 0
    while sequence_ids[idx] != 1:
        idx += 1
    context_start = idx
    while sequence_ids[idx] == 1:
        idx += 1
    context_end = idx - 1

    # Si la réponse n'est pas entièrement dans le contexte, l'étiquette est (0, 0)
    if offset[context_start][0] > start_char or offset[context_end][1] < end_char:
        start_positions.append(0)
        end_positions.append(0)
    else:
        # Sinon, ce sont les positions de début et de fin du token
        idx = context_start
        while idx <= context_end and offset[idx][0] <= start_char:
            idx += 1
        start_positions.append(idx - 1)

        idx = context_end
        while idx >= context_start and offset[idx][1] >= end_char:
            idx -= 1
        end_positions.append(idx + 1)

start_positions, end_positions
([83, 51, 19, 0, 0, 64, 27, 0, 34, 0, 0, 0, 67, 34, 0, 0, 0, 0, 0],
 [85, 53, 21, 0, 0, 70, 33, 0, 40, 0, 0, 0, 68, 35, 0, 0, 0, 0, 0])

Jetons un coup d’œil à quelques résultats pour vérifier que notre approche est correcte. Pour la première caractéristique, nous trouvons (83, 85) comme étiquettes. Comparons alors la réponse théorique avec l’étendue décodée des tokens de 83 à 85 (inclus) :

idx = 0
sample_idx = inputs["overflow_to_sample_mapping"][idx]
answer = answers[sample_idx]["text"][0]

start = start_positions[idx]
end = end_positions[idx]
labeled_answer = tokenizer.decode(inputs["input_ids"][idx][start : end + 1])

print(f"Theoretical answer: {answer}, labels give: {labeled_answer}")
'Theoretical answer: the Main Building, labels give: the Main Building'

Cela correspond ! Maintenant vérifions l’index 4, où nous avons mis les étiquettes à (0, 0), signifiant que la réponse n’est pas dans le morceau de contexte de cette caractéristique :

idx = 4
sample_idx = inputs["overflow_to_sample_mapping"][idx]
answer = answers[sample_idx]["text"][0]

decoded_example = tokenizer.decode(inputs["input_ids"][idx])
print(f"Theoretical answer: {answer}, decoded example: {decoded_example}")
'Theoretical answer: a Marian place of prayer and reflection, decoded example: [CLS] What is the Grotto at Notre Dame? [SEP] Architecturally, the school has a Catholic character. Atop the Main Building\'s gold dome is a golden statue of the Virgin Mary. Immediately in front of the Main Building and facing it, is a copper statue of Christ with arms upraised with the legend " Venite Ad Me Omnes ". Next to the Main Building is the Basilica of the Sacred Heart. Immediately behind the basilica is the Grot [SEP]'

En effet, nous ne voyons pas la réponse dans le contexte.

✏️ A votre tour ! En utilisant l’architecture XLNet, le padding est appliqué à gauche et la question et le contexte sont intervertis. Adaptez tout le code que nous venons de voir à l’architecture XLNet (et ajoutez padding=True). Soyez conscient que le token [CLS] peut ne pas être à la position 0 avec le padding appliqué.

Maintenant que nous avons vu étape par étape comment prétraiter nos données d’entraînement, nous pouvons les regrouper dans une fonction que nous appliquerons à l’ensemble des données d’entraînement. Nous allons rembourrer chaque caractéristique à la longueur maximale que nous avons définie, car la plupart des contextes seront longs (et les échantillons correspondants seront divisés en plusieurs caractéristiques). Il n’y a donc pas de réel avantage à appliquer un rembourrage dynamique ici :

max_length = 384
stride = 128


def preprocess_training_examples(examples):
    questions = [q.strip() for q in examples["question"]]
    inputs = tokenizer(
        questions,
        examples["context"],
        max_length=max_length,
        truncation="only_second",
        stride=stride,
        return_overflowing_tokens=True,
        return_offsets_mapping=True,
        padding="max_length",
    )

    offset_mapping = inputs.pop("offset_mapping")
    sample_map = inputs.pop("overflow_to_sample_mapping")
    answers = examples["answers"]
    start_positions = []
    end_positions = []

    for i, offset in enumerate(offset_mapping):
        sample_idx = sample_map[i]
        answer = answers[sample_idx]
        start_char = answer["answer_start"][0]
        end_char = answer["answer_start"][0] + len(answer["text"][0])
        sequence_ids = inputs.sequence_ids(i)

        # Trouver le début et la fin du contexte
        idx = 0
        while sequence_ids[idx] != 1:
            idx += 1
        context_start = idx
        while sequence_ids[idx] == 1:
            idx += 1
        context_end = idx - 1

        # Si la réponse n'est pas entièrement dans le contexte, l'étiquette est (0, 0)
        if offset[context_start][0] > start_char or offset[context_end][1] < end_char:
            start_positions.append(0)
            end_positions.append(0)
        else:
            # Sinon, ce sont les positions de début et de fin du token
            idx = context_start
            while idx <= context_end and offset[idx][0] <= start_char:
                idx += 1
            start_positions.append(idx - 1)

            idx = context_end
            while idx >= context_start and offset[idx][1] >= end_char:
                idx -= 1
            end_positions.append(idx + 1)

    inputs["start_positions"] = start_positions
    inputs["end_positions"] = end_positions
    return inputs

Notez que nous avons défini deux constantes pour déterminer la longueur maximale utilisée ainsi que la longueur de la fenêtre glissante, et que nous avons ajouté un petit nettoyage avant la tokénisation : certaines des questions dans SQuAD ont des espaces supplémentaires au début et à la fin qui n’ajoutent rien (et prennent de la place lors de la tokénisation si vous utilisez un modèle comme RoBERTa), donc nous avons supprimé ces espaces supplémentaires.

Pour appliquer cette fonction à l’ensemble de l’entraînement, nous utilisons la méthode Dataset.map() avec le flag batched=True. C’est nécessaire ici car nous changeons la longueur du jeu de données (puisqu’un exemple peut donner plusieurs caractéristiques d’entraînement) :

train_dataset = raw_datasets["train"].map(
    preprocess_training_examples,
    batched=True,
    remove_columns=raw_datasets["train"].column_names,
)
len(raw_datasets["train"]), len(train_dataset)
(87599, 88729)

Comme nous pouvons le voir, le prétraitement a ajouté environ 1 000 caractéristiques. Notre ensemble d’entraînement est maintenant prêt à être utilisé. Passons au prétraitement de l’ensemble de validation !

Traitement des données de validation

Le prétraitement des données de validation sera légèrement plus facile car nous n’avons pas besoin de générer des étiquettes (sauf si nous voulons calculer une perte de validation, mais elle ne nous aidera pas vraiment à comprendre la qualité du modèle). Le réel plaisir sera d’interpréter les prédictions du modèle dans des étendues du contexte original. Pour cela, il nous suffit de stocker les correspondances d’offset et un moyen de faire correspondre chaque caractéristique créée à l’exemple original dont elle provient. Puisqu’il y a une colonne identifiant dans le jeu de données original, nous l’utiliserons.

La seule chose que nous allons ajouter ici est un petit nettoyage des correspondances d’offset. Elles contiendront les offsets pour la question et le contexte, mais une fois que nous serons à la phase de post-traitement, nous n’aurons aucun moyen de savoir quelle partie des identifiants d’entrée correspondait au contexte et quelle partie était la question (la méthode sequence_ids() que nous avons utilisée n’est disponible que pour la sortie du tokenizer). Donc, nous allons mettre les offsets correspondant à la question à None :

def preprocess_validation_examples(examples):
    questions = [q.strip() for q in examples["question"]]
    inputs = tokenizer(
        questions,
        examples["context"],
        max_length=max_length,
        truncation="only_second",
        stride=stride,
        return_overflowing_tokens=True,
        return_offsets_mapping=True,
        padding="max_length",
    )

    sample_map = inputs.pop("overflow_to_sample_mapping")
    example_ids = []

    for i in range(len(inputs["input_ids"])):
        sample_idx = sample_map[i]
        example_ids.append(examples["id"][sample_idx])

        sequence_ids = inputs.sequence_ids(i)
        offset = inputs["offset_mapping"][i]
        inputs["offset_mapping"][i] = [
            o if sequence_ids[k] == 1 else None for k, o in enumerate(offset)
        ]

    inputs["example_id"] = example_ids
    return inputs

Nous pouvons appliquer cette fonction sur l’ensemble de validation comme précédemment :

validation_dataset = raw_datasets["validation"].map(
    preprocess_validation_examples,
    batched=True,
    remove_columns=raw_datasets["validation"].column_names,
)
len(raw_datasets["validation"]), len(validation_dataset)
(10570, 10822)

Dans ce cas, nous n’avons ajouté que quelques centaines d’échantillons, il semble donc que les contextes dans l’ensemble de validation soient un peu plus courts.

Maintenant que nous avons prétraité toutes les données, nous pouvons passer à l’entraînement.

<i> Finetuner </i> le modèle avec l’API Trainer

Le code d’entraînement pour cet exemple ressemblera beaucoup au code des sections précédentes mais le calcul de la métrique avec la fonction compute_metrics() sera un défi unique. Puisque nous avons rembourré tous les échantillons à la longueur maximale que nous avons définie, il n’y a pas d’assembleur de données à définir. Ainsi le calcul de la métrique est vraiment la seule chose dont nous devons nous soucier. La partie la plus difficile sera de post-traiter les prédictions du modèle en étendues de texte dans les exemples originaux. Une fois que nous aurons fait cela, la métrique de la bibliothèque 🤗 Datasets fera le gros du travail pour nous.

Post-traitement

Le modèle produira des logits pour les positions de début et de fin de la réponse dans les identifiants d’entrée, comme nous l’avons vu lors de notre exploration du pipeline de question-answering au chapitre 6. L’étape de post-traitement sera similaire à ce que nous avons fait à ce chapitre là. Voici un rapide rappel des actions que nous avons prises :

  • nous avons masqué les logits de début et de fin correspondant aux tokens en dehors du contexte,
  • nous avons ensuite converti les logits de début et de fin en probabilités en utilisant une fonction SoftMax,
  • nous avons attribué un score à chaque paire (start_token, end_token) en prenant le produit des deux probabilités correspondantes,
  • nous avons cherché la paire avec le score maximum qui donnait une réponse valide (par exemple, un start_token inférieur au end_token).

Ici, nous allons modifier légèrement ce processus car nous n’avons pas besoin de calculer les scores réels (juste la réponse prédite). Cela signifie que nous pouvons sauter l’étape de la SoftMax. Pour aller plus vite, nous ne donnerons pas non plus un score à toutes les paires (start_token, end_token) possibles, mais seulement celles correspondant aux n_best logits les plus élevés (avec n_best=20). Puisque nous sautons la SoftMax, les scores seront des scores logi, et seront obtenus en prenant la somme des logits de début et de fin (au lieu du produit, à cause de la règlelog(ab)=log(a)+log(b)\log(ab) = \log(a) + \log(b)).

Pour démontrer tout cela, nous aurons besoin d’un certain type de prédictions. Puisque nous n’avons pas encore entraîné notre modèle, nous allons utiliser le modèle par défaut du pipeline de question-answering pour générer quelques prédictions sur une petite partie de l’ensemble de validation. Nous pouvons utiliser la même fonction de traitement que précédemment car elle repose sur la constante globale tokenizer, nous devons juste changer cet objet par le tokenizer du modèle que nous voulons utiliser temporairement :

small_eval_set = raw_datasets["validation"].select(range(100))
trained_checkpoint = "distilbert-base-cased-distilled-squad"

tokenizer = AutoTokenizer.from_pretrained(trained_checkpoint)
eval_set = small_eval_set.map(
    preprocess_validation_examples,
    batched=True,
    remove_columns=raw_datasets["validation"].column_names,
)

Maintenant que le prétraitement est terminé, nous changeons le tokenizer pour celui que nous avons choisi à l’origine :

tokenizer = AutoTokenizer.from_pretrained(model_checkpoint)

Nous supprimons ensuite les colonnes de notre eval_set qui ne sont pas attendues par le modèle. Nous construisons un batch avec tout de ce petit ensemble de validation et le passons au modèle. Si un GPU est disponible, nous l’utilisons pour aller plus vite :

import torch
from transformers import AutoModelForQuestionAnswering

eval_set_for_model = eval_set.remove_columns(["example_id", "offset_mapping"])
eval_set_for_model.set_format("torch")

device = torch.device("cuda") if torch.cuda.is_available() else torch.device("cpu")
batch = {k: eval_set_for_model[k].to(device) for k in eval_set_for_model.column_names}
trained_model = AutoModelForQuestionAnswering.from_pretrained(trained_checkpoint).to(
    device
)

with torch.no_grad():
    outputs = trained_model(**batch)

Puisque Trainer nous donne les prédictions sous forme de tableaux NumPy, nous récupérons les logits de début et de fin et les convertissons dans ce format :

start_logits = outputs.start_logits.cpu().numpy()
end_logits = outputs.end_logits.cpu().numpy()

Maintenant, nous devons trouver la réponse prédite pour chaque exemple dans notre small_eval_set. Un exemple peut avoir été divisé en plusieurs caractéristiques dans eval_set, donc la première étape est de faire correspondre chaque exemple dans small_eval_set aux caractéristiques correspondantes dans eval_set :

import collections

example_to_features = collections.defaultdict(list)
for idx, feature in enumerate(eval_set):
    example_to_features[feature["example_id"]].append(idx)

Avec cela, nous pouvons vraiment nous mettre au travail en bouclant tous les exemples et, pour chaque exemple, toutes les caractéristiques associées. Comme nous l’avons dit précédemment, nous allons regarder les scores logit pour les n_best logits de début et logits de fin, en excluant les positions qui donnent :

  • une réponse qui ne serait pas dans le contexte
  • une réponse avec une longueur négative
  • une réponse qui est trop longue (nous limitons les possibilités à max_answer_length=30)

Une fois que nous avons toutes les réponses possibles notées pour un exemple, nous choisissons simplement celle qui a le meilleur score logit :

import numpy as np

n_best = 20
max_answer_length = 30
predicted_answers = []

for example in small_eval_set:
    example_id = example["id"]
    context = example["context"]
    answers = []

    for feature_index in example_to_features[example_id]:
        start_logit = start_logits[feature_index]
        end_logit = end_logits[feature_index]
        offsets = eval_set["offset_mapping"][feature_index]

        start_indexes = np.argsort(start_logit)[-1 : -n_best - 1 : -1].tolist()
        end_indexes = np.argsort(end_logit)[-1 : -n_best - 1 : -1].tolist()
        for start_index in start_indexes:
            for end_index in end_indexes:
                # Ignore les réponses qui ne sont pas entièrement dans le contexte
                if offsets[start_index] is None or offsets[end_index] is None:
                    continue
                # Ignore les réponses dont la longueur est soit < 0 soit > max_answer_length
                if (
                    end_index < start_index
                    or end_index - start_index + 1 > max_answer_length
                ):
                    continue

                answers.append(
                    {
                        "text": context[offsets[start_index][0] : offsets[end_index][1]],
                        "logit_score": start_logit[start_index] + end_logit[end_index],
                    }
                )

    best_answer = max(answers, key=lambda x: x["logit_score"])
    predicted_answers.append({"id": example_id, "prediction_text": best_answer["text"]})

Le format final des réponses prédites est celui qui sera attendu par la métrique que nous allons utiliser. Comme d’habitude, nous pouvons la charger à l’aide de la bibliothèque 🤗 Evaluate :

import evaluate

metric = evaluate.load("squad")

Cette métrique attend les réponses prédites dans le format que nous avons vu ci-dessus (une liste de dictionnaires avec une clé pour l’identifiant de l’exemple et une clé pour le texte prédit) et les réponses théoriques dans le format ci-dessous (une liste de dictionnaires avec une clé pour l’identifiant de l’exemple et une clé pour les réponses possibles) :

theoretical_answers = [
    {"id": ex["id"], "answers": ex["answers"]} for ex in small_eval_set
]

Nous pouvons maintenant vérifier que nous obtenons des résultats raisonnables en examinant le premier élément des deux listes :

print(predicted_answers[0])
print(theoretical_answers[0])
{'id': '56be4db0acb8001400a502ec', 'prediction_text': 'Denver Broncos'}
{'id': '56be4db0acb8001400a502ec', 'answers': {'text': ['Denver Broncos', 'Denver Broncos', 'Denver Broncos'], 'answer_start': [177, 177, 177]}}

Pas trop mal ! Voyons maintenant le score que la métrique nous donne :

metric.compute(predictions=predicted_answers, references=theoretical_answers)
{'exact_match': 83.0, 'f1': 88.25}

Encore une fois, c’est plutôt bon si l’on considère que, d’après le papier de DistilBERT, finetuné sur SQuAD, ce modèle obtient 79,1 et 86,9 pour ces scores sur l’ensemble du jeu de données.

Maintenant, mettons tout ce que nous venons de faire dans une fonction compute_metrics() que nous utiliserons dans le Trainer. Normalement, cette fonction compute_metrics() reçoit seulement un tuple eval_preds avec les logits et les étiquettes. Ici, nous aurons besoin d’un peu plus, car nous devons chercher dans le jeu de données des caractéristiques pour le décalage et dans le jeu de données des exemples pour les contextes originaux. Ainsi nous ne serons pas en mesure d’utiliser cette fonction pour obtenir des résultats d’évaluation standards pendant l’entraînement. Nous ne l’utiliserons qu’à la fin de l’entraînement pour vérifier les résultats.

La fonction compute_metrics() regroupe les mêmes étapes que précédemment. Nous ajoutons juste une petite vérification au cas où nous ne trouverions aucune réponse valide (dans ce cas nous prédisons une chaîne vide).

from tqdm.auto import tqdm


def compute_metrics(start_logits, end_logits, features, examples):
    example_to_features = collections.defaultdict(list)
    for idx, feature in enumerate(features):
        example_to_features[feature["example_id"]].append(idx)

    predicted_answers = []
    for example in tqdm(examples):
        example_id = example["id"]
        context = example["context"]
        answers = []

        # Parcourir en boucle toutes les fonctionnalités associées à cet exemple
        for feature_index in example_to_features[example_id]:
            start_logit = start_logits[feature_index]
            end_logit = end_logits[feature_index]
            offsets = features[feature_index]["offset_mapping"]

            start_indexes = np.argsort(start_logit)[-1 : -n_best - 1 : -1].tolist()
            end_indexes = np.argsort(end_logit)[-1 : -n_best - 1 : -1].tolist()
            for start_index in start_indexes:
                for end_index in end_indexes:
                    # Ignore les réponses qui ne sont pas entièrement dans le contexte
                    if offsets[start_index] is None or offsets[end_index] is None:
                        continue
                    # Ignore les réponses dont la longueur est soit < 0, soit > max_answer_length
                    if (
                        end_index < start_index
                        or end_index - start_index + 1 > max_answer_length
                    ):
                        continue

                    answer = {
                        "text": context[offsets[start_index][0] : offsets[end_index][1]],
                        "logit_score": start_logit[start_index] + end_logit[end_index],
                    }
                    answers.append(answer)

        # Sélectionne la réponse avec le meilleur score
        if len(answers) > 0:
            best_answer = max(answers, key=lambda x: x["logit_score"])
            predicted_answers.append(
                {"id": example_id, "prediction_text": best_answer["text"]}
            )
        else:
            predicted_answers.append({"id": example_id, "prediction_text": ""})

    theoretical_answers = [{"id": ex["id"], "answers": ex["answers"]} for ex in examples]
    return metric.compute(predictions=predicted_answers, references=theoretical_answers)

Nous pouvons vérifier que cela fonctionne sur nos prédictions :

compute_metrics(start_logits, end_logits, eval_set, small_eval_set)
{'exact_match': 83.0, 'f1': 88.25}

C’est bien ! Maintenant, utilisons ceci pour finetuner notre modèle.

<i> Finetuning </i> du modèle

Nous sommes maintenant prêts à entraîner notre modèle. Créons-le en utilisant la classe AutoModelForQuestionAnswering comme précédemment :

model = AutoModelForQuestionAnswering.from_pretrained(model_checkpoint)

Comme d’habitude, nous recevons un avertissement indiquant que certains poids ne sont pas utilisés (ceux de la tête de pré-entraînement) et que d’autres sont initialisés de manière aléatoire (ceux de la tête de réponse aux questions). Vous devriez être habitué à cela maintenant, mais cela signifie que ce modèle n’est pas encore prêt à être utilisé et qu’il a besoin d’être finetuné. Une bonne chose que nous soyons sur le point de le faire !

Pour pouvoir pousser notre modèle vers le Hub, nous devons nous connecter à Hugging Face. Si vous exécutez ce code dans un notebook, vous pouvez le faire avec la fonction utilitaire suivante, qui affiche un widget où vous pouvez entrer vos identifiants de connexion :

from huggingface_hub import notebook_login

notebook_login()

Si vous ne travaillez pas dans un notebook, tapez simplement la ligne suivante dans votre terminal :

huggingface-cli login

Une fois ceci fait, nous pouvons définir nos TrainingArguments. Comme nous l’avons dit lorsque nous avons défini notre fonction pour calculer la métrique, nous ne serons pas en mesure d’avoir une boucle d’évaluation standard à cause de la signature de la fonction compute_metrics(). Nous pourrions écrire notre propre sous-classe de Trainer pour faire cela (une approche que vous pouvez trouver dans le script d’exemple de réponse aux questions), mais c’est un peu trop long pour cette section. A la place, nous n’évaluerons le modèle qu’à la fin de l’entraînement et nous vous montrerons comment faire une évaluation cela dans le paragraphe « Une boucle d’entraînement personnalisée » ci-dessous.

C’est là que l’API Trainer montre ses limites et que la bibliothèque 🤗 Accelerate brille : personnaliser la classe pour un cas d’utilisation spécifique peut être pénible, mais modifier une boucle d’entraînement est facile.

Jetons un coup d’œil à notre TrainingArguments :

from transformers import TrainingArguments

args = TrainingArguments(
    "bert-finetuned-squad",
    evaluation_strategy="no",
    save_strategy="epoch",
    learning_rate=2e-5,
    num_train_epochs=3,
    weight_decay=0.01,
    fp16=True,
    push_to_hub=True,
)

Nous avons déjà vu la plupart d’entre eux. Nous définissons quelques hyperparamètres (comme le taux d’apprentissage, le nombre d’époques d’entraînement, un taux de décroissance des poids) et nous indiquons que nous voulons sauvegarder le modèle à la fin de chaque époque, sauter l’évaluation, et télécharger nos résultats vers le Hub. Nous activons également l’entraînement en précision mixte avec fp16=True, car cela peut accélérer l’entraînement sur un GPU récent.

Par défaut, le dépôt utilisé sera dans votre espace et nommé après le répertoire de sortie que vous avez défini. Donc dans notre cas il sera dans "sgugger/bert-finetuned-squad". Nous pouvons passer outre en passant un hub_model_id, par exemple, pour pousser le modèle dans l’organisation huggingface_course nous avons utilisé hub_model_id= "huggingface_course/bert-finetuned-squad" (qui est le modèle que nous avons lié au début de cette section).

💡 Si le répertoire de sortie que vous utilisez existe, il doit être un clone local du dépôt vers lequel vous voulez pousser (donc définissez un nouveau nom si vous obtenez une erreur lors de la définition de votre Trainer).

Enfin, nous passons tout à la classe Trainer et lançons l’entraînement :

from transformers import Trainer

trainer = Trainer(
    model=model,
    args=args,
    train_dataset=train_dataset,
    eval_dataset=validation_dataset,
    tokenizer=tokenizer,
)
trainer.train()

Notez que pendant l’entraînement, chaque fois que le modèle est sauvegardé (ici, à chaque époque), il est téléchargé sur le Hub en arrière-plan. Ainsi, vous pourrez reprendre votre entraînement sur une autre machine si nécessaire. L’ensemble de l’entraînement prend un certain temps (un peu plus d’une heure sur une Titan RTX), vous pouvez donc prendre un café ou relire les parties du cours qui vous ont semblé plus difficiles pendant qu’il se déroule. Notez également que dès que la première époque est terminée, vous verrez des poids téléchargés sur le Hub et vous pourrez commencer à jouer avec votre modèle sur sa page.

Une fois l’entraînement terminé, nous pouvons enfin évaluer notre modèle (et prier pour ne pas avoir dépensé tout ce temps de calcul pour rien). La méthode predict() du Trainer retournera un tuple où les premiers éléments seront les prédictions du modèle (ici une paire avec les logits de début et de fin). Nous envoyons ceci à notre fonction compute_metrics() :

predictions, _ = trainer.predict(validation_dataset)
start_logits, end_logits = predictions
compute_metrics(start_logits, end_logits, validation_dataset, raw_datasets["validation"])
{'exact_match': 81.18259224219489, 'f1': 88.67381321905516}

Super ! À titre de comparaison, les scores indiqués dans l’article de BERT pour ce tâche sont de 80,8 et 88,5. Donc nous sommes exactement là où nous devrions être.

Enfin, nous utilisons la méthode push_to_hub() pour nous assurer que nous téléchargeons la dernière version du modèle :

trainer.push_to_hub(commit_message="Training complete")

Cela renvoie l’URL du commit qu’il vient de faire, si vous voulez l’inspecter :

'https://huggingface.co/sgugger/bert-finetuned-squad/commit/9dcee1fbc25946a6ed4bb32efb1bd71d5fa90b68'

Le Trainer rédige également une carte de modèle avec tous les résultats de l’évaluation et la télécharge.

À ce stade, vous pouvez utiliser le widget d’inférence sur le Hub du modèle pour tester le modèle et le partager avec vos amis, votre famille et vos animaux préférés. Vous avez réussi à finetuner un modèle sur une tâche de réponse à une question. Félicitations !

✏️ A votre tour Essayez un autre modèle pour voir s’il est plus performant pour cette tâche !

Si vous voulez plonger un peu plus profondément dans la boucle d’entraînement, nous allons maintenant vous montrer comment faire la même chose en utilisant 🤗 Accelerate.

Une boucle d’entraînement personnalisée

Jetons maintenant un coup d’œil à la boucle d’entraînement complète, afin que vous puissiez facilement personnaliser les parties dont vous avez besoin. Elle ressemblera beaucoup à la boucle d’entraînement du chapitre 3, à l’exception de la boucle d’évaluation. Nous serons en mesure d’évaluer le modèle régulièrement puisque nous ne sommes plus contraints par la classe Trainer.

Préparer tout pour l’entraînement

Tout d’abord, nous devons construire le DataLoaders à partir de nos jeux de données. Nous définissons le format de ces jeux de données à "torch" et supprimons les colonnes dans le jeu de validation qui ne sont pas utilisées par le modèle. Ensuite, nous pouvons utiliser le default_data_collator fourni par 🤗 Transformers comme collate_fn et mélanger l’ensemble d’entraînement mais pas celui de validation :

from torch.utils.data import DataLoader
from transformers import default_data_collator

train_dataset.set_format("torch")
validation_set = validation_dataset.remove_columns(["example_id", "offset_mapping"])
validation_set.set_format("torch")

train_dataloader = DataLoader(
    train_dataset,
    shuffle=True,
    collate_fn=default_data_collator,
    batch_size=8,
)
eval_dataloader = DataLoader(
    validation_set, collate_fn=default_data_collator, batch_size=8
)

Ensuite, nous réinstantifions notre modèle afin de nous assurer que nous ne poursuivons pas le finetuning précédent et que nous repartons du modèle BERT pré-entraîné :

model = AutoModelForQuestionAnswering.from_pretrained(model_checkpoint)

Ensuite, nous aurons besoin d’un optimiseur. Comme d’habitude, nous utilisons le classique AdamW, qui est comme Adam mais avec une correction dans la façon dont le taux de décroissance des poids est appliqué :

from torch.optim import AdamW

optimizer = AdamW(model.parameters(), lr=2e-5)

Une fois que nous avons tous ces objets, nous pouvons les envoyer à la méthode accelerator.prepare(). Rappelez-vous que si vous voulez entraîner sur des TPUs dans un notebook Colab, vous devrez déplacer tout ce code dans une fonction d’entraînement, et qui ne devrait pas exécuter une cellule qui instancie un Accelerator. Nous pouvons forcer l’entraînement en précision mixte en passant l’argument fp16=True à Accelerator (ou, si vous exécutez le code comme un script, assurez-vous de remplir la 🤗 Accelerate config de manière appropriée).

from accelerate import Accelerator

accelerator = Accelerator(fp16=True)
model, optimizer, train_dataloader, eval_dataloader = accelerator.prepare(
    model, optimizer, train_dataloader, eval_dataloader
)

Comme vous devez le savoir depuis les sections précédentes, nous ne pouvons utiliser la longueur de train_dataloader pour calculer le nombre d’étapes d’entraînement qu’après qu’il soit passé par la méthode accelerator.prepare(). Nous utilisons le même programme linéaire que dans les sections précédentes :

from transformers import get_scheduler

num_train_epochs = 3
num_update_steps_per_epoch = len(train_dataloader)
num_training_steps = num_train_epochs * num_update_steps_per_epoch

lr_scheduler = get_scheduler(
    "linear",
    optimizer=optimizer,
    num_warmup_steps=0,
    num_training_steps=num_training_steps,
)

Pour pousser notre modèle vers le Hub, nous aurons besoin de créer un objet Repository dans un dossier de travail. Tout d’abord, connectez-vous au Hub, si vous n’êtes pas déjà connecté. Nous déterminerons le nom du dépôt à partir de l’identifiant du modèle que nous voulons donner à notre modèle (n’hésitez pas à remplacer le repo_name par votre propre choix. Il doit juste contenir votre nom d’utilisateur, ce que fait la fonction get_full_repo_name()) :

from huggingface_hub import Repository, get_full_repo_name

model_name = "bert-finetuned-squad-accelerate"
repo_name = get_full_repo_name(model_name)
repo_name
'sgugger/bert-finetuned-squad-accelerate'

Ensuite, nous pouvons cloner ce dépôt dans un dossier local. S’il existe déjà, ce dossier local doit être un clone du dépôt avec lequel nous travaillons :

output_dir = "bert-finetuned-squad-accelerate"
repo = Repository(output_dir, clone_from=repo_name)

Nous pouvons maintenant télécharger tout ce que nous sauvegardons dans output_dir en appelant la méthode repo.push_to_hub(). Cela nous aidera à télécharger les modèles intermédiaires à la fin de chaque époque.

Boucle d’entraînement

Nous sommes maintenant prêts à écrire la boucle d’entraînement complète. Après avoir défini une barre de progression pour suivre l’évolution de l’entraînement, la boucle comporte trois parties :

  • l’entraînement à proprement dit, qui est l’itération classique sur le train_dataloader, passage en avant du modèle, puis passage en arrière et étape d’optimisation.
  • l’évaluation, dans laquelle nous rassemblons toutes les valeurs pour start_logits et end_logits avant de les convertir en tableaux NumPy. Une fois la boucle d’évaluation terminée, nous concaténons tous les résultats. Notez que nous devons tronquer car Accelerator peut avoir ajouté quelques échantillons à la fin pour s’assurer que nous avons le même nombre d’exemples dans chaque processus.
  • sauvegarde et téléchargement, où nous sauvegardons d’abord le modèle et le tokenizer, puis appelons repo.push_to_hub(). Comme nous l’avons fait auparavant, nous utilisons l’argument blocking=False pour dire à la bibliothèque 🤗 Hub de pousser dans un processus asynchrone. De cette façon, l’entraînement continue normalement et cette (longue) instruction est exécutée en arrière-plan.

Voici le code complet de la boucle d’entraînement :

from tqdm.auto import tqdm
import torch

progress_bar = tqdm(range(num_training_steps))

for epoch in range(num_train_epochs):
    # Entraînement
    model.train()
    for step, batch in enumerate(train_dataloader):
        outputs = model(**batch)
        loss = outputs.loss
        accelerator.backward(loss)

        optimizer.step()
        lr_scheduler.step()
        optimizer.zero_grad()
        progress_bar.update(1)

    # Evaluation
    model.eval()
    start_logits = []
    end_logits = []
    accelerator.print("Evaluation!")
    for batch in tqdm(eval_dataloader):
        with torch.no_grad():
            outputs = model(**batch)

        start_logits.append(accelerator.gather(outputs.start_logits).cpu().numpy())
        end_logits.append(accelerator.gather(outputs.end_logits).cpu().numpy())

    start_logits = np.concatenate(start_logits)
    end_logits = np.concatenate(end_logits)
    start_logits = start_logits[: len(validation_dataset)]
    end_logits = end_logits[: len(validation_dataset)]

    metrics = compute_metrics(
        start_logits, end_logits, validation_dataset, raw_datasets["validation"]
    )
    print(f"epoch {epoch}:", metrics)

    # Sauvegarder et télécharger
    accelerator.wait_for_everyone()
    unwrapped_model = accelerator.unwrap_model(model)
    unwrapped_model.save_pretrained(output_dir, save_function=accelerator.save)
    if accelerator.is_main_process:
        tokenizer.save_pretrained(output_dir)
        repo.push_to_hub(
            commit_message=f"Training in progress epoch {epoch}", blocking=False
        )

Au cas où ce serait la première fois que vous verriez un modèle enregistré avec 🤗 Accelerate, prenons un moment pour inspecter les trois lignes de code qui l’accompagnent :

accelerator.wait_for_everyone()
unwrapped_model = accelerator.unwrap_model(model)
unwrapped_model.save_pretrained(output_dir, save_function=accelerator.save)

La première ligne est explicite : elle indique à tous les processus d’attendre que tout le monde soit à ce stade avant de continuer. C’est pour s’assurer que nous avons le même modèle dans chaque processus avant de sauvegarder. Ensuite, nous prenons le unwrapped_model, qui est le modèle de base que nous avons défini. La méthode accelerator.prepare() modifie le modèle pour qu’il fonctionne dans l’entraînement distribué. Donc il n’aura plus la méthode save_pretrained() car la méthode accelerator.unwrap_model() annule cette étape. Enfin, nous appelons save_pretrained() mais nous disons à cette méthode d’utiliser accelerator.save() au lieu de torch.save().

Une fois ceci fait, vous devriez avoir un modèle qui produit des résultats assez similaires à celui entraîné avec Trainer. Vous pouvez vérifier le modèle que nous avons entraîné en utilisant ce code à huggingface-course/bert-finetuned-squad-accelerate. Et si vous voulez tester des modifications de la boucle d’entraînement, vous pouvez les implémenter directement en modifiant le code ci-dessus !

Utilisation du modèle <i> finetuné </i>

Nous vous avons déjà montré comment vous pouvez utiliser le modèle que nous avons finetuné sur le Hub avec le widget d’inférence. Pour l’utiliser localement dans un pipeline, il suffit de spécifier l’identifiant du modèle :

from transformers import pipeline

# Remplacez par votre propre checkpoint
model_checkpoint = "huggingface-course/bert-finetuned-squad"
question_answerer = pipeline("question-answering", model=model_checkpoint)

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.9979003071784973,
 'start': 78,
 'end': 105,
 'answer': 'Jax, PyTorch and TensorFlow'}

Super ! Notre modèle fonctionne aussi bien que le modèle par défaut pour ce pipeline !