Pytorch
TensorFlow

#
Особые возможности быстрых токенизаторов

В этом разделе мы подробно рассмотрим возможности токенизаторов в 🤗 Transformers. До сих пор мы использовали их только для токенизации входных данных или декодирования идентификаторов обратно в текст, но токенизаторы — особенно те, которые поддерживаются библиотекой 🤗 Tokenizers - могут делать гораздо больше. Чтобы проиллюстрировать эти дополнительные возможности, мы рассмотрим, как воспроизвести результаты конвейеров `token-classification` (которые мы назвали `ner`) и `question-answering`, с которыми мы впервые столкнулись в Главе 1.

В дальнейшем обсуждении мы будем часто проводить различие между “медленными” и “быстрыми” токенизаторами. Медленные токенизаторы - это те, что написаны на Python в библиотеке 🤗 Transformers, а быстрые версии - это те, что предоставляются в 🤗 Tokenizers, которые написаны на Rust. Если вы помните таблицу из Главы 5, в которой приводилось, сколько времени потребовалось быстрому и медленному токенизаторам для токенизации датасета Drug Review Dataset, вы должны иметь представление о том, почему мы называем их быстрыми и медленными:

| Быстрый токенизатор | Медленный токенизатор
--- | --- | ---
`batched=True` | 10.8s | 4min41s
`batched=False` | 59.2s | 5min3s

⚠️ Когда вы токенизируете одно предложение, вы не всегда увидите разницу в скорости между медленной и быстрой версиями одного и того же токенизатора. Более того, быстрая версия может быть даже медленнее! Только при параллельной токенизации большого количества текстов вы сможете увидеть разницу.

##
Batch encoding

Результат работы токенизатора - это не простой словарь Python; то, что мы получаем, - это специальный объект `BatchEncoding`. Это подкласс словаря (именно поэтому мы раньше могли без проблем индексировать результат), но с дополнительными методами, которые в основном используются быстрыми токенизаторами.

Помимо возможностей распараллеливания, ключевой функцией быстрых токенизаторов является то, что они всегда отслеживают исходный диапазон текстов, из которых взяты конечные токены, - эту функцию мы называем сопоставление смещений (offset mapping). Это, в свою очередь, открывает такие возможности, как сопоставление каждого слова с порожденными им токенами или сопоставление каждого символа исходного текста с токеном, в котором он находится, и наоборот.

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

    from transformers import AutoTokenizer

    tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")
    example = "My name is Sylvain and I work at Hugging Face in Brooklyn."
    encoding = tokenizer(example)
    print(type(encoding))

Как уже говорилось, на выходе токенизатора мы получаем объект `BatchEncoding`:

    <class 'transformers.tokenization_utils_base.BatchEncoding'>

Поскольку класс `AutoTokenizer` по умолчанию выбирает быстрый токенизатор, мы можем использовать дополнительные методы, которые предоставляет объект `BatchEncoding`. У нас есть два способа проверить, является ли наш токенизатор быстрым или медленным. Мы можем проверить атрибут `is_fast` у `tokenizer`:

    tokenizer.is_fast

    True

или проверьте тот же атрибут нашего `encoding`:

    encoding.is_fast

    True

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

    encoding.tokens()

    ['[CLS]', 'My', 'name', 'is', 'S', '##yl', '##va', '##in', 'and', 'I', 'work', 'at', 'Hu', '##gging', 'Face', 'in',
     'Brooklyn', '.', '[SEP]']

В данном случае токен с индексом 5 - это `##yl`, который является частью слова “Sylvain” в исходном предложении. Мы также можем использовать метод `word_ids()`, чтобы получить индекс слова, из которого происходит каждый токен:

    encoding.word_ids()

    [None, 0, 1, 2, 3, 3, 3, 3, 4, 5, 6, 7, 8, 8, 9, 10, 11, 12, None]

Мы можем видеть, что специальные токены токенизатора `[CLS]` и `[SEP]` сопоставляются с `None`, а затем каждый токен сопоставляется со словом, от которого он происходит. Это особенно полезно для определения того, находится ли токен в начале слова или два токена в одном и том же слове. Для этого мы могли бы использовать префикс `##`, но он работает только для токенизаторов типа BERT; этот метод работает для любого типа токенизаторов, лишь бы он был быстрым. В следующей главе мы увидим, как можно использовать эту возможность для применения меток, которые мы имеем для каждого слова, к токенам в таких задачах, как распознавание именованных сущностей (NER) и тегирование частей речи (part-of-speech - POS). Мы также можем использовать ее для маскирования всех токенов, происходящих от одного и того же слова, при моделировании языка по маске (masked language modeling) (эта техника называется маскированием всего слова (whole word masking)).

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

✏️ Попробуйте! Создайте токенизатор из контрольных точек `bert-base-cased` и `roberta-base` и токенизируйте с их помощью ”81s”. Что вы заметили? Каковы идентификаторы слов?

Аналогично, существует метод `sentence_ids()`, который мы можем использовать для сопоставления токена с предложением, из которого оно взято (хотя в этом случае ту же информацию может дать и `token_type_ids`, возвращаемый токенизатором).

Наконец, с помощью методов `word_to_chars()` или `token_to_chars()` и `char_to_word()` или `char_to_token()` мы можем сопоставить любое слово или токен с символами в оригинальном тексте и наоборот. Например, метод `word_ids()` сообщил нам, что `##yl` является частью слова с индексом 3, но какое это слово в предложении? Мы можем выяснить это следующим образом:

    start, end = encoding.word_to_chars(3)
    example[start:end]

    Sylvain

Как мы уже говорили, все это происходит благодаря тому, что быстрый токенизатор отслеживает, из какого участка текста происходит каждый токен, в списке смещений (offsets). Чтобы проиллюстрировать их использование, далее мы покажем, как воспроизвести результаты конвейера `token-classification` вручную.

✏️ Попробуйте! Создайте свой собственный пример текста и посмотрите, сможете ли вы понять, какие токены связаны с идентификаторами слов, а также как извлечь диапазоны символов для одного слова. Чтобы получить бонусные очки, попробуйте использовать два предложения в качестве входных данных и посмотрите, будут ли идентификаторы предложений иметь для вас смысл.

##
Внутри конвейера token-classification

В Главе 1 мы впервые попробовали применить NER - когда задача состоит в том, чтобы определить, какие части текста соответствуют сущностям, таким как люди, места или организации - с помощью функции 🤗 Transformers `pipeline()`. Затем, в Главе 2, мы увидели, как конвейер объединяет три этапа, необходимые для получения прогнозов из необработанного текста: токенизацию, прохождение входных данных через модель и постобработку. Первые два шага в конвейере `token-classification` такие же, как и в любом другом конвейере, но постобработка немного сложнее - давайте посмотрим, как это сделать!

###
Получение базовых результатов с помощью конвейера

Для начала возьмем конвейер token classification, чтобы получить результаты для сравнения вручную. По умолчанию используется модель `dbmdz/bert-large-cased-finetuned-conll03-english`; она выполняет NER на предложениях:

    from transformers import pipeline

    token_classifier = pipeline("token-classification")
    token_classifier("My name is Sylvain and I work at Hugging Face in Brooklyn.")

    [{'entity': 'I-PER', 'score': 0.9993828, 'index': 4, 'word': 'S', 'start': 11, 'end': 12},
     {'entity': 'I-PER', 'score': 0.99815476, 'index': 5, 'word': '##yl', 'start': 12, 'end': 14},
     {'entity': 'I-PER', 'score': 0.99590725, 'index': 6, 'word': '##va', 'start': 14, 'end': 16},
     {'entity': 'I-PER', 'score': 0.9992327, 'index': 7, 'word': '##in', 'start': 16, 'end': 18},
     {'entity': 'I-ORG', 'score': 0.97389334, 'index': 12, 'word': 'Hu', 'start': 33, 'end': 35},
     {'entity': 'I-ORG', 'score': 0.976115, 'index': 13, 'word': '##gging', 'start': 35, 'end': 40},
     {'entity': 'I-ORG', 'score': 0.98879766, 'index': 14, 'word': 'Face', 'start': 41, 'end': 45},
     {'entity': 'I-LOC', 'score': 0.99321055, 'index': 16, 'word': 'Brooklyn', 'start': 49, 'end': 57}]

Модель правильно идентифицировала каждый токен, сгенерировав “Sylvain”, как человека, каждый токен, сгенерированный “Hugging Face”, как организацию, а токен “Brooklyn” - как местоположение. Мы также можем попросить конвейер сгруппировать токены, которые соответствуют одной и той же сущности:

    from transformers import pipeline

    token_classifier = pipeline("token-classification", aggregation_strategy="simple")
    token_classifier("My name is Sylvain and I work at Hugging Face in Brooklyn.")

    [{'entity_group': 'PER', 'score': 0.9981694, 'word': 'Sylvain', 'start': 11, 'end': 18},
     {'entity_group': 'ORG', 'score': 0.97960204, 'word': 'Hugging Face', 'start': 33, 'end': 45},
     {'entity_group': 'LOC', 'score': 0.99321055, 'word': 'Brooklyn', 'start': 49, 'end': 57}]

Выбранная `aggregation_strategy` изменит оценки, вычисляемые для каждой сгруппированной сущности. При использовании значения `"simple"` оценка является средним значением оценок каждого токена данной сущности: например, оценка “Sylvain” является средним значением оценок, которые мы видели в предыдущем примере для токенов `S`, `##yl`, `##va` и `##in`. Другие доступные стратегии:

- `"first"`, где оценка каждой сущности - это оценка первого токена этой сущности (так, для “Sylvain” это будет 0,993828, оценки токена `S`)
- `"max"`, где оценка каждой сущности - это максимальная оценка токенов в этой сущности (так, для ""Hugging Face"" это будет 0.98879766, оценки “Face”).
- `"average"`, где оценка каждой сущности - это средняя оценка слов, составляющих эту сущность (таким образом, для слова ""Sylvain"" не будет никаких отличий от стратегии `"simple"`, но “Hugging Face” будет иметь оценку 0.9819, среднюю оценку для “Hugging”, 0.975, и “Face”, 0.98879)

Теперь давайте посмотрим, как получить эти результаты без использования функции `pipeline()`!

###
От входных данных к прогнозам

Сначала нам нужно токенизировать наш ввод и пропустить его через модель. Это делается точно так же, как в Главе 2; мы инстанцируем токенизатор и модель с помощью классов `AutoXxx`, а затем используем их в нашем примере:

    from transformers import AutoTokenizer, AutoModelForTokenClassification

    model_checkpoint = "dbmdz/bert-large-cased-finetuned-conll03-english"
    tokenizer = AutoTokenizer.from_pretrained(model_checkpoint)
    model = AutoModelForTokenClassification.from_pretrained(model_checkpoint)

    example = "My name is Sylvain and I work at Hugging Face in Brooklyn."
    inputs = tokenizer(example, return_tensors="pt")
    outputs = model(**inputs)

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

    print(inputs["input_ids"].shape)
    print(outputs.logits.shape)

    torch.Size([1, 19])
    torch.Size([1, 19, 9])

У нас есть батч с 1 последовательностью из 19 токенов, и модель имеет 9 различных меток, поэтому выход модели имеет форму 1 x 19 x 9. Как и для конвейера классификации текста, мы используем функцию softmax для преобразования этих логитов в вероятности и берем argmax для получения прогнозов (обратите внимание, что мы можем взять argmax для логитов, потому что softmax не меняет порядок):

    import torch

    probabilities = torch.nn.functional.softmax(outputs.logits, dim=-1)[0].tolist()
    predictions = outputs.logits.argmax(dim=-1)[0].tolist()
    print(predictions)

    [0, 0, 0, 0, 4, 4, 4, 4, 0, 0, 0, 0, 6, 6, 6, 0, 8, 0, 0]

Атрибут `model.config.id2label` содержит отображение индексов в метки, которые мы можем использовать для осмысления прогнозов:

    model.config.id2label

    {0: 'O',
     1: 'B-MISC',
     2: 'I-MISC',
     3: 'B-PER',
     4: 'I-PER',
     5: 'B-ORG',
     6: 'I-ORG',
     7: 'B-LOC',
     8: 'I-LOC'}

Как мы видели ранее, существует 9 меток: `O` - это метка для токенов, которые не входят ни в одну именованную сущность (она означает “вне”), а затем у нас есть две метки для каждого типа сущности (miscellaneous, person, organization и location). Метка `B-XXX` указывает на то, что токен находится в начале сущности `XXX`, а метка `I-XXX` указывает на то, что токен находится внутри сущности `XXX`. Таким образом, в данном примере мы ожидаем, что наша модель классифицирует токен `S` как `B-PER` (начало сущности person), а токены `##yl`, `##va` и `##in` как `I-PER` (внутри сущности person).

Вы можете подумать, что модель в данном случае ошиблась, поскольку присвоила всем четырем токенам метку `I-PER`, но это не совсем так. На самом деле существует два формата для меток `B-` и `I-`: IOB1 и IOB2. Формат IOB2 (розовый цвет ниже) - это тот, который мы представили, в то время как в формате IOB1 (синий цвет) метки, начинающиеся с `B-`, используются только для разделения двух соседних сущностей одного типа. Используемая нами модель была дообучена на наборе данных, использующем этот формат, поэтому она присваивает токену `S` метку `I-PER`.

С помощью этой карты мы можем воспроизвести (почти полностью) результаты первого конвейера - мы можем просто получить оценку и метку каждого токена, который не был классифицирован как `O`:

    results = []
    tokens = inputs.tokens()

    for idx, pred in enumerate(predictions):
        label = model.config.id2label[pred]
        if label != "O":
            results.append(
                {"entity": label, "score": probabilities[idx][pred], "word": tokens[idx]}
            )

    print(results)

    [{'entity': 'I-PER', 'score': 0.9993828, 'index': 4, 'word': 'S'},
     {'entity': 'I-PER', 'score': 0.99815476, 'index': 5, 'word': '##yl'},
     {'entity': 'I-PER', 'score': 0.99590725, 'index': 6, 'word': '##va'},
     {'entity': 'I-PER', 'score': 0.9992327, 'index': 7, 'word': '##in'},
     {'entity': 'I-ORG', 'score': 0.97389334, 'index': 12, 'word': 'Hu'},
     {'entity': 'I-ORG', 'score': 0.976115, 'index': 13, 'word': '##gging'},
     {'entity': 'I-ORG', 'score': 0.98879766, 'index': 14, 'word': 'Face'},
     {'entity': 'I-LOC', 'score': 0.99321055, 'index': 16, 'word': 'Brooklyn'}]

Это очень похоже на то, что у нас было раньше, за одним исключением: конвейер также предоставил нам информацию о `start` и `end` каждой сущности в исходном предложении. Вот тут-то и пригодится наше сопоставление смещений. Чтобы получить смещения, нам нужно просто установить `return_offsets_mapping=True`, когда мы применяем токенизатор к нашим входным данным:

    inputs_with_offsets = tokenizer(example, return_offsets_mapping=True)
    inputs_with_offsets["offset_mapping"]

    [(0, 0), (0, 2), (3, 7), (8, 10), (11, 12), (12, 14), (14, 16), (16, 18), (19, 22), (23, 24), (25, 29), (30, 32),
     (33, 35), (35, 40), (41, 45), (46, 48), (49, 57), (57, 58), (0, 0)]

Каждый кортеж - это участок текста, соответствующий каждому токену, где `(0, 0)` зарезервировано для специальных токенов. Мы уже видели, что токен с индексом 5 - это `##yl`, который имеет `(12, 14)` в качестве смещения. Если мы возьмем соответствующий фрагмент в нашем примере:

    example[12:14]

мы получим нужный участок текста без использования `##`:

    yl

Используя это, мы можем дополнить предыдущие результаты:

    results = []
    inputs_with_offsets = tokenizer(example, return_offsets_mapping=True)
    tokens = inputs_with_offsets.tokens()
    offsets = inputs_with_offsets["offset_mapping"]

    for idx, pred in enumerate(predictions):
        label = model.config.id2label[pred]
        if label != "O":
            start, end = offsets[idx]
            results.append(
                {
                    "entity": label,
                    "score": probabilities[idx][pred],
                    "word": tokens[idx],
                    "start": start,
                    "end": end,
                }
            )

    print(results)

    [{'entity': 'I-PER', 'score': 0.9993828, 'index': 4, 'word': 'S', 'start': 11, 'end': 12},
     {'entity': 'I-PER', 'score': 0.99815476, 'index': 5, 'word': '##yl', 'start': 12, 'end': 14},
     {'entity': 'I-PER', 'score': 0.99590725, 'index': 6, 'word': '##va', 'start': 14, 'end': 16},
     {'entity': 'I-PER', 'score': 0.9992327, 'index': 7, 'word': '##in', 'start': 16, 'end': 18},
     {'entity': 'I-ORG', 'score': 0.97389334, 'index': 12, 'word': 'Hu', 'start': 33, 'end': 35},
     {'entity': 'I-ORG', 'score': 0.976115, 'index': 13, 'word': '##gging', 'start': 35, 'end': 40},
     {'entity': 'I-ORG', 'score': 0.98879766, 'index': 14, 'word': 'Face', 'start': 41, 'end': 45},
     {'entity': 'I-LOC', 'score': 0.99321055, 'index': 16, 'word': 'Brooklyn', 'start': 49, 'end': 57}]

Это то же самое, что мы получили от первого конвейера!

###
Группировка сущностей

Использование смещений для определения начального и конечного ключей для каждой сущности удобно, но эта информация не является строго необходимой. Однако когда мы захотим сгруппировать сущности вместе, смещения избавят нас от большого количества беспорядочного кода. Например, если бы мы хотели сгруппировать токены `Hu`, `##gging` и `Face`, мы могли бы создать специальные правила, согласно которым первые два должны быть присоединены, удалив `##`, а `Face` должен быть добавлен через пробел, поскольку он не начинается с `##` - но это будет работать только для данного конкретного типа токенизатора. Для токенизатора SentencePiece или Byte-Pair-Encoding нам придется написать другой набор правил (о них мы поговорим позже в этой главе).

С помощью смещений весь этот пользовательский код отпадает: мы просто можем взять в исходном тексте промежуток, который начинается с первого токена и заканчивается последним. Так, в случае с токенами `Hu`, `##gging` и `Face` мы должны начать с символа 33 (начало `Hu`) и закончить символом 45 (конец `Face`):

    example[33:45]

    Hugging Face

Чтобы написать код для постобработки прогнозов при группировке сущностей, мы будем группировать сущности, которые идут подряд и помечены `I-XXX`, за исключением первой, которая может быть помечена как `B-XXX` или `I-XXX` (таким образом, мы прекращаем группировать сущность, когда получаем `O`, новый тип сущности, или `B-XXX`, который говорит нам, что начинается сущность того же типа):

    import numpy as np

    results = []
    inputs_with_offsets = tokenizer(example, return_offsets_mapping=True)
    tokens = inputs_with_offsets.tokens()
    offsets = inputs_with_offsets["offset_mapping"]

    idx = 0
    while idx < len(predictions):
        pred = predictions[idx]
        label = model.config.id2label[pred]
        if label != "O":
            # Удалим B- или I-
            label = label[2:]
            start, _ = offsets[idx]

            # Соберём все токены, помеченные I-меткой
            all_scores = []
            while (
                idx < len(predictions)
                and model.config.id2label[predictions[idx]] == f"I-{label}"
            ):
                all_scores.append(probabilities[idx][pred])
                _, end = offsets[idx]
                idx += 1

            # Оценка является средним значением всех оценок токенов в этой сгруппированной сущности
            score = np.mean(all_scores).item()
            word = example[start:end]
            results.append(
                {
                    "entity_group": label,
                    "score": score,
                    "word": word,
                    "start": start,
                    "end": end,
                }
            )
        idx += 1

    print(results)

И мы получаем те же результаты, что и со вторым конвейером!

    [{'entity_group': 'PER', 'score': 0.9981694, 'word': 'Sylvain', 'start': 11, 'end': 18},
     {'entity_group': 'ORG', 'score': 0.97960204, 'word': 'Hugging Face', 'start': 33, 'end': 45},
     {'entity_group': 'LOC', 'score': 0.99321055, 'word': 'Brooklyn', 'start': 49, 'end': 57}]

Еще один пример задачи, в которой эти смещения чрезвычайно полезны, - question answering. Погружение в этот конвейер, которое мы сделаем в следующем разделе, также позволит нам взглянуть на последнюю особенность токенизаторов в библиотеке 🤗 Transformers: работа с переполненными токенами (overflowing tokens), когда мы усекаем входные данные до заданной длины.

< > Update on GitHub