Pytorch
TensorFlow

#
Ответы на вопросы

Пришло время взглянуть на ответы на вопросы! Эта задача имеет множество разновидностей, но та, на которой мы сосредоточимся в этом разделе, называется экстрактивным ответом на вопросы. Она включает в себя постановку вопросов о документе и определение ответов в виде участков текста в самом документе.

Мы проведем дообучение BERT-модели на датасете SQuAD, состоящем из вопросов, заданных краудворкерами по набору статей Википедии. В результате мы получим модель, способную вычислять прогнозы, подобные этому:

На самом деле это демонстрация модели, которая была обучена и загружена на Hub с помощью кода, показанного в этом разделе. Вы можете найти ее и перепроверить прогнозы здесь.

💡 Модели, основанные только на энкодере, такие как BERT, как правило, отлично справляются с извлечением ответов на фактоидные вопросы типа “Кто изобрел архитектуру трансформера?”, но плохо справляются с открытыми вопросами типа “Почему небо голубое?“. В таких сложных случаях для синтеза информации обычно используются модели энкодеров-декодеров, такие как T5 и BART, что очень похоже на сумризацию текста. Если вам интересен этот тип генеративных ответов на вопросы, рекомендуем ознакомиться с нашим демо основанным на датасете ELI5.

##
Подготовка данных

В качестве академического бенчмарка для экстрактивных ответов на вопросы чаще всего используется датасет SQuAD, поэтому мы будем использовать именно его. Существует также более сложный датасет SQuAD v2, который включает вопросы, не имеющие ответа. Если ваш собственный датасет содержит столбец контекстов, столбец вопросов и столбец ответов, вы сможете адаптировать описанные ниже шаги.

###
Датасет SQuAD

Как обычно, мы можем загрузить и кэшировать датасет всего за один шаг благодаря функции `load_dataset()`:

    from datasets import load_dataset

    raw_datasets = load_dataset("squad")

Мы можем взглянуть на этот объект, чтобы узнать больше о датасете 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
        })
    })

Похоже, у нас есть все необходимое в полях `context`, `question` и `answers`, так что давайте выведем их для первого элемента нашего обучающего набора:

    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.'
    Question: 'To whom did the Virgin Mary allegedly appear in 1858 in Lourdes France?'
    Answer: {'text': ['Saint Bernadette Soubirous'], 'answer_start': [515]}

Поля `context` и `question` очень просты в использовании. С полем `answers` немного сложнее, поскольку оно представляет собой словарь с двумя полями, которые оба являются списками. Именно такой формат будет ожидать метрика `squad` при оценке; если вы используете свои собственные данные, вам не обязательно беспокоиться о том, чтобы привести ответы к такому же формату. Поле `text` довольно очевидно, а поле `answer_start` содержит индекс начального символа каждого ответа в контексте.

Во время обучения существует только один возможный ответ. Мы можем перепроверить это с помощью метода `Dataset.filter()`:

    raw_datasets["train"].filter(lambda x: len(x["answers"]["text"]) != 1)

    Dataset({
        features: ['id', 'title', 'context', 'question', 'answers'],
        num_rows: 0
    })

Для оценки, однако, существует несколько возможных ответов для каждого примера, которые могут быть одинаковыми или разными:

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

Мы не будем углубляться в сценарий оценки, поскольку все это будет завернуто в метрику 🤗 Datasets для нас, но кратко суть в том, что некоторые вопросы имеют несколько возможных ответов, и этот сценарий будет сравнивать спрогнозированный ответ со всеми допустимыми ответами и выбирать лучший результат. Если мы посмотрим, например, на выборку с индексом 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.'
    'Where did Super Bowl 50 take place?'

мы можем увидеть, что ответ действительно может быть одним из трех возможных вариантов, которые мы видели ранее.

###
Подготовка обучающих данных

Начнем с предварительной подготовки обучающих данных. Самое сложное - сгенерировать метки для ответа на вопрос, которые будут представлять собой начальную и конечную позиции токенов, соответствующих ответу в контексте.

Но не будем забегать вперед. Сначала нам нужно преобразовать текст во входных данных в идентификаторы, которые модель сможет понять, используя токенизатор:

    from transformers import AutoTokenizer

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

Как упоминалось ранее, мы будем проводить дообучение модели BERT, но вы можете использовать любой другой тип модели, если в ней реализован быстрый токенизатор. Вы можете увидеть все архитектуры с быстрой версией в этой большой таблице, а чтобы проверить, что используемый вами объект `tokenizer` действительно поддерживается 🤗 Tokenizers, вы можете посмотреть на его атрибут `is_fast`:

    tokenizer.is_fast

    True

Мы можем передать нашему токенизатору вопрос и контекст вместе, и он правильно вставит специальные токены, чтобы сформировать предложение, подобное этому:

    [CLS] question [SEP] context [SEP]

Давайте перепроверим:

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

В качестве меток будут использоваться индексы токенов, начинающих и заканчивающих ответ, а задача модели - предсказать один начальный и конечный логит для каждого токена на входе, при этом теоретические метки будут выглядеть следующим образом:

В данном случае контекст не слишком длинный, но некоторые примеры в датасете имеют очень длинные контексты, которые превысят установленную нами максимальную длину (которая в данном случае равна 384). Как мы видели в Главе 6, когда изучали внутреннее устройство конвейера `question-answering`, мы будем работать с длинными контекстами, создавая несколько обучающих признаков из одной выборки нашего датасета, со скользящим окном между ними.

Чтобы увидеть, как это работает на текущем примере, мы можем ограничить длину до 100 и использовать скользящее окно из 50 токенов. В качестве напоминания мы используем:

- `max_length` для установки максимальной длины (здесь 100)
- `truncation="only_second"` для усечения контекста (который находится во второй позиции), когда вопрос с его контекстом слишком длинный
- `stride` для задания количества перекрывающихся токенов между двумя последовательными фрагментами (здесь 50)
- `return_overflowing_tokens=True`, чтобы сообщить токенизатору, что нам нужны переполненные токены (overflowing tokens)

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

Как мы можем видеть, наш пример был разбит на четыре входа, каждый из которых содержит вопрос и часть контекста. Обратите внимание, что ответ на вопрос (“Bernadette Soubirous”) появляется только в третьем и последнем входе, поэтому, работая с длинными контекстами таким образом, мы создадим несколько обучающих примеров, в которых ответ не будет включен в контекст. Для этих примеров метками будут `start_position = end_position = 0` (таким образом мы предсказываем токен `[CLS]`). Мы также зададим эти метки в неудачном случае, когда ответ был усечен, так что у нас есть только его начало (или конец). Для примеров, где ответ полностью находится в контексте, метками будут индекс токена, с которого начинается ответ, и индекс токена, на котором ответ заканчивается.

Датасет предоставляет нам начальный символ ответа в контексте, а прибавив к нему длину ответа, мы можем найти конечный символ в контексте. Чтобы сопоставить их с индексами токенов, нам нужно использовать сопоставление смещений, которое мы изучали в Главе 6. Мы можем настроить наш токенизатор на их возврат, передав `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'])

Как мы видим, нам возвращаются обычные идентификаторы входа, идентификаторы типов токенов и маска внимания, а также необходимое нам сопоставление смещений и дополнительный ключ `overflow_to_sample_mapping`. Соответствующее значение мы будем использовать при токенизации нескольких текстов одновременно (что мы и должны делать, чтобы извлечь выгоду из того, что наш токенизатор основан на Rust). Поскольку один образец может давать несколько признаков, он сопоставляет каждый признак с примером, из которого он произошел. Поскольку здесь мы токенизировали только один пример, мы получим список `0`:

    inputs["overflow_to_sample_mapping"]

    [0, 0, 0, 0]

Но если мы проведем токенизацию большего количества примеров, это станет более полезным:

    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].'

Как мы видим, первые три примера (с индексами 2, 3 и 4 в обучающем наборе) дали по четыре признака, а последний пример (с индексом 5 в обучающем наборе) - 7 признаков.

Эта информация будет полезна для сопоставления каждого полученного признака с соответствующей меткой. Как уже упоминалось ранее, этими метками являются:

- `(0, 0)`, если ответ не находится в соответствующей области контекста
- `(start_position, end_position)`, если ответ находится в соответствующей области контекста, причем `start_position` - это индекс токена (во входных идентификаторах) в начале ответа, а `end_position` - индекс токена (во входных идентификаторах) в конце ответа

Чтобы определить, какой из этих случаев имеет место, и, если нужно, позиции токенов, мы сначала находим индексы, с которых начинается и заканчивается контекст во входных идентификаторах. Для этого мы могли бы использовать идентификаторы типов токенов, но поскольку они не обязательно существуют для всех моделей (например, DistilBERT их не требует), вместо этого мы воспользуемся методом `sequence_ids()` из `BatchEncoding`, который возвращает наш токенизатор.

Получив индексы токенов, мы смотрим на соответствующие смещения, которые представляют собой кортежи из двух целых чисел, обозначающих промежуток символов внутри исходного контекста. Таким образом, мы можем определить, начинается ли фрагмент контекста в этом признаке после ответа или заканчивается до начала ответа (в этом случае метка будет `(0, 0)`). Если это не так, мы зацикливаемся, чтобы найти первый и последний токен ответа:

    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)

        # Найдём начало и конец контекста
        idx = 0
        while sequence_ids[idx] != 1:
            idx += 1
        context_start = idx
        while sequence_ids[idx] == 1:
            idx += 1
        context_end = idx - 1

        # Если ответ не полностью находится внутри контекста, меткой будет (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:
            # В противном случае это начальная и конечная позиции токенов
            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])

Давайте посмотрим на несколько результатов, чтобы убедиться в правильности нашего подхода. Для первого признака мы находим `(83, 85)` в качестве меток, поэтому давайте сравним теоретический ответ с декодированным диапазоном лексем с 83 по 85 (включительно):

    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'

Итак, это совпадение! Теперь проверим индекс 4, где мы установили метки на `(0, 0)`, что означает, что ответ не находится в фрагменте контекста этого признака:

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

Действительно, мы не видим ответа в контексте.

✏️ Ваша очередь! При использовании архитектуры XLNet дополнение применяется слева, а вопрос и контекст меняются местами. Адаптируйте весь код, который мы только что рассмотрели, к архитектуре XLNet (и добавьте `padding=True`). Имейте в виду, что токен `[CLS]` может не находиться в позиции 0 при использовании дополнения.

Теперь, когда мы шаг за шагом разобрались с предварительной обработкой обучающих данных, мы можем сгруппировать их в функцию, которую будем применять ко всему датасету. Мы дополним каждый признак до максимальной длины, которую мы задали, поскольку большинство контекстов будут длинными (и соответствующие образцы будут разбиты на несколько признаков), поэтому применение динамического дополнения здесь не имеет реальной пользы:

    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)

            # Найдём начало и конец контекста
            idx = 0
            while sequence_ids[idx] != 1:
                idx += 1
            context_start = idx
            while sequence_ids[idx] == 1:
                idx += 1
            context_end = idx - 1

            # Если ответ не полностью находится внутри контекста, меткой будет (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:
                # В противном случае это начальная и конечная позиции токенов
                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

Обратите внимание, что мы определили две константы для определения максимальной длины и длины скользящего окна, а также добавили немного очистки перед токенизацией: некоторые вопросы в датасете SQuAD имеют лишние пробелы в начале и конце, которые ничего не добавляют (и занимают место при токенизации, если вы используете модель вроде RoBERTa), поэтому мы удалили эти лишние пробелы.

Чтобы применить эту функцию ко всему обучающему набору, мы используем метод `Dataset.map()` с флагом `batched=True`. Это необходимо, так как мы изменяем длину датасета (поскольку один пример может давать несколько обучающих признаков):

    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)

Как мы видим, предварительная обработка добавила около 1 000 признаков. Теперь наш обучающий набор готов к использованию - давайте займемся предварительной обработкой валидационного набора!

###
Подготовка валидационных данных

Предварительная обработка валидационных данных будет немного проще, поскольку нам не нужно генерировать метки (если только мы не хотим вычислять потери на валидации, но это число не поможет нам понять, насколько хороша модель). Настоящей радостью будет интерпретация прогнозов модели в диапазонах исходного контекста. Для этого нам нужно хранить как сопоставления смещений, так и способ сопоставления каждого созданного признака с оригинальным примером, из которого он взят. Поскольку в исходном датасете есть столбец ID, мы будем использовать этот ID.

Единственное, что мы добавим сюда, - это немного почистим сопоставления смещений. Они будут содержать смещения для вопроса и контекста, но на этапе постобработки у нас не будет возможности узнать, какая часть входных идентификаторов соответствует контексту, а какая - вопросу (метод `sequence_ids()`, который мы использовали, доступен только для выхода токенизатора). Поэтому мы установим смещения, соответствующие вопросу, в `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

Мы можем применить эту функцию ко всему валидационому датасету, как и раньше:

    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)

В данном случае мы добавили всего пару сотен примеров, поэтому контексты в валидационном датасете немного короче.

Теперь, когда мы предварительно обработали все данные, можно приступать к обучению.

##
Дообучение модели с API Trainer

Код обучения для этого примера будет очень похож на код из предыдущих разделов — самым сложным будет написание функции `compute_metrics()`. Поскольку мы дополнили все примеры до максимальной длины, которую мы задали, нет никакого коллатора данных, поэтому вычисление метрики - это единственное, о чем нам нужно беспокоиться. Самым сложным будет постобработка прогнозов модели в отрезки текста оригинальных примеров; как только мы это сделаем, метрика из библиотеки 🤗 Datasets сделает за нас большую часть работы.

###
Постобработка

Модель выведет логиты для начальной и конечной позиций ответа во входных идентификаторах, как мы видели при изучении конвейера `question-answering`. Шаг постобработки будет аналогичен тому, что мы делали там, так что вот краткое напоминание о том, что мы делали:

- Мы маскировали начальные и конечные логиты, соответствующие токенам вне контекста.
- Затем мы преобразовали начальные и конечные логиты в вероятности с помощью softmax.
- Каждой паре `(start_token, end_token)` мы присваивали оценку, взяв произведение двух соответствующих вероятностей.
- Мы искали пару с максимальной оценкой, которая давала правильный ответ (например, `start_token` меньше `end_token`).

В данном случае мы немного изменим этот процесс, поскольку нам не нужно вычислять фактические оценки (только спрогнозированный ответ). Это означает, что мы можем пропустить шаг softmax. Чтобы ускорить процесс, мы также не будем оценивать все возможные пары `(start_token, end_token)`, а только те, которые соответствуют наибольшим `n_best` логитам (при `n_best=20`). Так как мы пропустим softmax, эти оценки будут оценками логитов, и будут получены путем взятия суммы начального и конечного логитов (вместо произведения, по правилуlog⁡(a b)=log⁡(a)+log⁡(b)\log(ab) = \log(a) + \log(b)log(ab)=log(a)+log(b)).

Чтобы продемонстрировать все это, нам понадобятся некоторые прогнозы. Поскольку мы еще не обучили нашу модель, мы будем использовать модель по умолчанию для конвейера QA, чтобы сгенерировать несколько прогнозов на небольшой части набора для валидации. Мы можем использовать ту же функцию обработки, что и раньше; поскольку она опирается на глобальную константу `tokenizer`, нам просто нужно изменить этот объект на токенизатор модели, которую мы хотим временно использовать:

    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,
    )

Теперь, когда препроцессинг завершен, мы меняем токенизатор обратно на тот, который был выбран изначально:

    tokenizer = AutoTokenizer.from_pretrained(model_checkpoint)

Затем мы удаляем из нашего `eval_set` столбцы, которые не ожидает модель, создаем батч со всей этой небольшой валидацией и пропускаем его через модель. Если доступен GPU, мы используем его для ускорения работы:

    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)

Поскольку `Trainer` будет возвращать нам прогнозы в виде массивов NumPy, мы берем начальный и конечный логиты и конвертируем их в этот формат:

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

Теперь нам нужно найти спрогнозированный ответ для каждого примера в `small_eval_set`. Один пример может быть разбит на несколько признаков в `eval_set`, поэтому первым шагом будет сопоставление каждого примера в `small_eval_set` с соответствующими признаками в `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)

Имея это на руках, мы можем приступить к работе, итерируясь по всем примерам и, для каждого примера, по всем ассоциированным с ним признакам. Как мы уже говорили, мы рассмотрим оценки логитов для `n_best` начальных логитов и конечных логитов, исключая позиции, которые дают:

- Ответ, который не вписывается в контекст
- Ответ с отрицательной длиной
- Слишком длинный ответ (мы ограничиваем возможности по `max_answer_length=30`).

После того как мы получили все возможные ответы для одного примера, мы просто выбираем тот, который имеет лучшую оценку логита:

    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:
                    # Пропускаем ответы, которые не полностью соответствуют контексту
                    if offsets[start_index] is None or offsets[end_index] is None:
                        continue
                    # Пропускайте ответы, длина которых либо < 0, либо > 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"]})

Окончательный формат спрогнозированных ответов - это тот, который ожидает метрика, которую мы будем использовать. Как обычно, мы можем загрузить ее с помощью библиотеки 🤗 Evaluate:

    import evaluate

    metric = evaluate.load("squad")

Эта метрика ожидает прогнозируемые ответы в формате, который мы видели выше (список словарей с одним ключом для идентификатора примера и одним ключом для прогнозируемого текста), и теоретические ответы в формате ниже (список словарей с одним ключом для идентификатора примера и одним ключом для возможных ответов):

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

Теперь мы можем убедиться, что получаем разумные результаты, посмотрев на первый элемент обоих списков:

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

Не так уж плохо! Теперь давайте посмотрим на оценку, которую дает нам метрика:

    metric.compute(predictions=predicted_answers, references=theoretical_answers)

    {'exact_match': 83.0, 'f1': 88.25}

Опять же, это довольно хорошо, если учесть, что согласно его статье DistilBERT с дообучением на SQuAD получает 79,1 и 86,9 для этих оценок на всем датасете.

Теперь давайте поместим все, что мы только что сделали, в функцию `compute_metrics()`, которую мы будем использовать в `Trainer`. Обычно функция `compute_metrics()` получает только кортеж `eval_preds` с логитами и метками. Здесь нам понадобится немного больше, так как мы должны искать в датасете признаков смещения и в датасете примеров исходные контексты, поэтому мы не сможем использовать эту функцию для получения обычных результатов оценки во время обучения. Мы будем использовать ее только в конце обучения для проверки результатов.

Функция `compute_metrics()` группирует те же шаги, что и до этого, только добавляется небольшая проверка на случай, если мы не найдем ни одного правильного ответа (в этом случае мы прогнозируем пустую строку).

    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 = []

            # Итерируемся по всем признакам, ассоциированным с этим примером
            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:
                        # Пропускаем ответы, которые не полностью соответствуют контексту
                        if offsets[start_index] is None or offsets[end_index] is None:
                            continue
                        # Пропускайте ответы, длина которых либо < 0, либо > 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)

            # Выбираем ответ с лучшей оценкой
            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)

Мы можем проверить ее работу на наших прогнозах:

    compute_metrics(start_logits, end_logits, eval_set, small_eval_set)

    {'exact_match': 83.0, 'f1': 88.25}

Выглядит отлично! Теперь давайте используем это для дообучения нашей модели.

###
Дообучение модели

Теперь мы готовы к обучению нашей модели. Давайте сначала создадим ее, используя класс `AutoModelForQuestionAnswering`, как и раньше:

    model = AutoModelForQuestionAnswering.from_pretrained(model_checkpoint)

Как обычно, мы получаем предупреждение о том, что некоторые веса не используются (веса головы предварительного обучения), а другие инициализируются случайным образом (веса головы ответов на вопросы). Вы уже должны были привыкнуть к этому, но это означает, что модель еще не готова к использованию и нуждается в дообучении - хорошо, что мы сейчас этим займемся!

Чтобы отправить нашу модель на Hub, нам нужно войти в Hugging Face. Если вы выполняете этот код в блокноте, вы можете сделать это с помощью следующей служебной функции, которая отображает виджет, где вы можете ввести свои учетные данные:

    from huggingface_hub import notebook_login

    notebook_login()

Если вы работаете не в блокноте, просто введите следующую строку в терминале:

    huggingface-cli login

Когда это сделано, мы можем определить наши `TrainingArguments`. Как мы уже говорили, когда определяли нашу функцию для вычисления метрики, мы не сможем сделать обычный цикл оценки из-за сигнатуры функции `compute_metrics()`. Мы могли бы написать собственный подкласс `Trainer` для этого (такой подход можно найти в примере скрипта ответа на вопросы), но это слишком длинно для данного раздела. Вместо этого мы будем оценивать модель только в конце обучения, а как проводить регулярную оценку, покажем ниже в разделе “Пользовательский цикл обучения”.

Именно здесь API `Trainer` показывает свои ограничения, а библиотека 🤗 Accelerate блистает: настройка класса под конкретный случай использования может быть болезненной, но настройка полностью открытого цикла обучения - это просто.

Let’s take a look at our `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,
    )

Большинство из них мы уже видели: мы задаем некоторые гиперпараметры (например, скорость обучения, количество эпох обучения и затухание веса) и указываем, что хотим сохранять модель в конце каждой эпохи, пропускать оценку и загружать результаты в Model Hub. Мы также включаем обучение со смешанной точностью с `fp16=True`, так как это может значительно ускорить обучение на современных GPU.

По умолчанию используемый розиторий будет находиться в вашем пространстве имен и называться в соответствии с заданным выходным каталогом, так что в нашем случае он будет находиться в `"sgugger/bert-finetuned-squad"`. Мы можем переопределить это, передав `hub_model_id`; например, чтобы отправить модель в организацию `huggingface_course`, мы использовали `hub_model_id="huggingface_course/bert-finetuned-squad"` (это модель, на которую мы ссылались в начале этого раздела).

💡 Если используемый вами выходной каталог существует, он должен быть локальным клоном того розитория, в который вы хотите отправлять данные (поэтому задайте новое имя, если вы получите ошибку при определении `Trainer`).

Наконец, мы просто передаем все в класс `Trainer` и запускаем обучение:

    from transformers import Trainer

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

Обратите внимание, что во время обучения каждый раз, когда модель сохраняется (здесь - каждую эпоху), она загружается на Hub в фоновом режиме. Таким образом, при необходимости вы сможете возобновить обучение на другой машине. Все обучение занимает некоторое время (чуть больше часа на Titan RTX), поэтому во время его проведения вы можете выпить кофе или перечитать те части курса, которые показались вам более сложными. Также обратите внимание, что как только закончится первая эпоха, вы увидите, что некоторые веса загружены в Hub, и сможете начать играть с вашей моделью на ее странице.

Когда обучение завершено, мы можем наконец оценить нашу модель (и помолиться, что не потратили все это время вычислений впустую). Метод `predict()` функции `Trainer` вернет кортеж, где первыми элементами будут предсказания модели (здесь пара с начальным и конечным логитами). Мы отправляем его в нашу функцию `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}

Отлично! Для сравнения, базовые показатели, указанные в статье BERT для этой модели, составляют 80,8 и 88,5, так что мы как раз там, где должны быть.

Наконец, мы используем метод `push_to_hub()`, чтобы убедиться, что мы загрузили последнюю версию модели:

    trainer.push_to_hub(commit_message="Training complete")

Это возвращает URL только что выполненного коммита, если вы хотите его просмотреть:

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

`Trainer` также создает черновик карточки модели со всеми результатами оценки и загружает ее.

На этом этапе вы можете использовать виджет инференса на Model Hub, чтобы протестировать модель и поделиться ею с друзьями, семьей и любимыми питомцами. Вы успешно провели дообучение модели для задачи ответа на вопрос - поздравляем!

✏️ Ваша очередь! Попробуйте другую архитектуру модели, чтобы узнать, лучше ли она справляется с этой задачей!

Если вы хотите более глубоко погрузиться в тренировочный цикл, мы покажем вам, как сделать то же самое с помощью 🤗 Accelerate.

##
Пользовательский цикл обучения

Теперь давайте посмотрим на полный цикл обучения, чтобы вы могли легко настроить нужные вам части. Он будет очень похож на цикл обучения в Главе 3, за исключением цикла оценки. Мы сможем регулярно оценивать модель, поскольку больше не ограничены классом `Trainer`.

###
Подготовим все для обучения

Сначала нам нужно создать `DataLoader`ы из наших датасетов. Мы установим формат этих датасетов в `"torch"` и удалим столбцы в наборе валидации, которые не используются моделью. Затем мы можем использовать `default_data_collator`, предоставляемый Transformers, в качестве `collate_fn` и перемешаем обучающий набор, но не набор для валидации:

    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
    )

Затем мы реинстанцируем нашу модель, чтобы убедиться, что мы не продолжаем дообучение, а снова начинаем с предварительно обученной модели BERT:

    model = AutoModelForQuestionAnswering.from_pretrained(model_checkpoint)

Тогда нам понадобится оптимизатор. Как обычно, мы используем классический `AdamW`, который похож на Adam, но с исправлением в способе применения затухания веса:

    from torch.optim import AdamW

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

Когда у нас есть все эти объекты, мы можем отправить их в метод `accelerator.prepare()`. Помните, что если вы хотите обучать на TPU в блокноте Colab, вам нужно будет перенести весь этот код в функцию обучения, которая не должна выполнять ни одну ячейку, инстанцирующую `Accelerator`. Мы можем принудительно обучать со смешанной точностью, передав `fp16=True` в `Accelerator` (или, если вы выполняете код в виде скрипта, просто убедитесь, что заполнили 🤗 Accelerate `config` соответствующим образом).

    from accelerate import Accelerator

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

Как вы уже поняли из предыдущих разделов, мы можем использовать длину `train_dataloader` для вычисления количества шагов обучения только после того, как она пройдет через метод `accelerator.prepare()`. Мы используем тот же линейный график, что и в предыдущих разделах:

    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,
    )

Чтобы отправить нашу модель на Hub, нам нужно создать объект `Repository` в рабочей папке. Сначала войдите в Hub Hugging Face, если вы еще не вошли в него. Мы определим имя розитория по идентификатору модели, который мы хотим присвоить нашей модели (не стесняйтесь заменить `repo_name` на свое усмотрение; оно просто должно содержать ваше имя пользователя, что и делает функция `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'

Затем мы можем клонировать этот розиторий в локальную папку. Если она уже существует, эта локальная папка должна быть клоном того розитория, с которым мы работаем:

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

Теперь мы можем загрузить все, что сохранили в `output_dir`, вызвав метод `repo.push_to_hub()`. Это поможет нам загружать промежуточные модели в конце каждой эпохи.

###
Цикл обучения

Теперь мы готовы написать полный цикл обучения. После определения прогресс-бара, чтобы следить за ходом обучения, цикл состоит из трех частей:

- Собственно обучение, которое представляет собой классическую итерацию по `train_dataloader`, прямой проход по модели, затем обратный проход и шаг оптимизатора.
- Оценка, в которой мы собираем все значения для `start_logits` и `end_logits` перед преобразованием их в массивы NumPy. После завершения цикла оценки мы объединяем все результаты. Обратите внимание, что нам нужно произвести усечение, потому что `Accelerator` может добавить несколько примеров в конце, чтобы убедиться, что у нас одинаковое количество примеров в каждом процессе.
- Сохранение и загрузка, где мы сначала сохраняем модель и токенизатор, а затем вызываем `repo.push_to_hub()`. Как и раньше, мы используем аргумент `blocking=False`, чтобы указать библиотеке 🤗 Hub на асинхронный процесс push. Таким образом, обучение продолжается нормально, а эта (длинная) инструкция выполняется в фоновом режиме.

Вот полный код цикла обучения:

    from tqdm.auto import tqdm
    import torch

    progress_bar = tqdm(range(num_training_steps))

    for epoch in range(num_train_epochs):
        # Обучение
        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)

        # Оценка
        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)

        # Сохранение и загрузка
        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
            )

Если вы впервые видите модель, сохраненную с помощью 🤗 Accelerate, давайте посмотрим на три строки кода, которые с этим связаны:

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

Первая строка не требует пояснений: она предписывает всем процессам подождать, пока все не окажутся на этой стадии, прежде чем продолжить работу. Это нужно для того, чтобы убедиться, что у нас одна и та же модель в каждом процессе перед сохранением. Затем мы захватываем `unwrapped_model`, которая является базовой моделью, которую мы определили. Метод `accelerator.prepare()` изменяет модель для работы в распределенном обучении, поэтому у нее больше не будет метода `save_pretrained()`; метод `accelerator.unwrap_model()` отменяет этот шаг. Наконец, мы вызываем `save_pretrained()`, но указываем этому методу использовать `accelerator.save()` вместо `torch.save()`.

После этого у вас должна получиться модель, которая дает результаты, очень похожие на модель, обученную с помощью `Trainer`. Вы можете проверить модель, которую мы обучили с помощью этого кода, на huggingface-course/bert-finetuned-squad-accelerate. А если вы хотите протестировать какие-либо изменения в цикле обучения, вы можете напрямую реализовать их, отредактировав код, показанный выше!

##
Использование дообученной модели

Мы уже показали вам, как можно использовать модель, дообучение которой мы проводили на Model Hub, с помощью виджета инференса. Чтобы использовать ее локально в `pipeline`, нужно просто указать идентификатор модели:

    from transformers import pipeline

    # Замените здесь на выбранную вами контрольную точку
    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'}

Отлично! Наша модель работает так же хорошо, как и модель по умолчанию для этого конвейера!