Создание токенизатора, блок за блоком
Как мы уже видели в предыдущих разделах, токенизация состоит из нескольких этапов:
- Нормализация (любая необходимая очистка текста, например, удаление пробелов или подчеркиваний, нормализация Unicode и т. д.)
- Предварительная токенизация (разделение входного текста на слова).
- Прогон входных данных через модель (использование предварительно токенизированных слов для создания последовательности токенов)
- Постобработка (добавление специальных токенов токенизатора, генерация маски внимания и идентификаторов типов токенов)
В качестве напоминания вот еще один взгляд на общий процесс:
Библиотека 🤗 Tokenizers была создана для того, чтобы предоставить несколько вариантов каждого из этих шагов, которые вы можете смешивать и сочетать между собой. В этом разделе мы рассмотрим, как можно создать токенизатор с нуля, а не обучать новый токенизатор на основе старого, как мы делали в разделе 2. После этого вы сможете создать любой токенизатор, который только сможете придумать!
Точнее, библиотека построена вокруг центрального класса Tokenizer
, а строительные блоки сгруппированы в подмодули:
normalizers
содержит все возможные типы нормализаторов текстаNormalizer
, которые вы можете использовать (полный список здесь).pre_tokenizers
содержит все возможные типы предварительных токенизаторовPreTokenizer
, которые вы можете использовать (полный список здесь).models
содержит различные типы моделейModel
, которые вы можете использовать, такие какBPE
,WordPiece
иUnigram
(полный список здесь).trainers
содержит все различные типыTrainer
, которые вы можете использовать для обучения модели на корпусе (по одному на каждый тип модели; полный список здесь).post_processors
содержит различные типы постпроцессоровPostProcessor
, которые вы можете использовать (полный список здесь).decoders
содержит различные типы декодеровDecoder
, которые вы можете использовать для декодирования результатов токенизации (полный список здесь).
Весь список блоков вы можете найти здесь.
Получение корпуса текста
Для обучения нашего нового токенизатора мы будем использовать небольшой корпус текстов (чтобы примеры выполнялись быстро). Шаги по сбору корпуса аналогичны тем, что мы делали в начале этой главы, но на этот раз мы будем использовать набор данных WikiText-2:
from datasets import load_dataset
dataset = load_dataset("wikitext", name="wikitext-2-raw-v1", split="train")
def get_training_corpus():
for i in range(0, len(dataset), 1000):
yield dataset[i : i + 1000]["text"]
Функция get_training_corpus()
- это генератор, который выдает батч из 1000 текстов, которые мы будем использовать для обучения токенизатора.
🤗 Токенизаторы также можно обучать непосредственно на текстовых файлах. Вот как мы можем сгенерировать текстовый файл, содержащий все тексты/входы из WikiText-2, который мы можем использовать локально:
with open("wikitext-2.txt", "w", encoding="utf-8") as f:
for i in range(len(dataset)):
f.write(dataset[i]["text"] + "\n")
Далее мы покажем вам, как блок за блоком построить собственные токенизаторы BERT, GPT-2 и XLNet. Это даст нам пример каждого из трех основных алгоритмов токенизации: WordPiece, BPE и Unigram. Начнем с BERT!
Создание токенизатора WordPiece с нуля
Чтобы создать токенизатор с помощью библиотеки 🤗 Tokenizers, мы начнем с инстанцирования объектов Tokenizer
и model
, затем установим для их атрибутов normalizer
, pre_tokenizer
, post_processor
и decoder
нужные нам значения.
Для этого примера мы создадим Tokenizer
с моделью WordPiece:
from tokenizers import (
decoders,
models,
normalizers,
pre_tokenizers,
processors,
trainers,
Tokenizer,
)
tokenizer = Tokenizer(models.WordPiece(unk_token="[UNK]"))
Мы должны указать unk_token
, чтобы модель знала, что возвращать, когда она встречает символы, которых раньше не видела. Другие аргументы, которые мы можем задать здесь, включают vocab
нашей модели (мы собираемся обучать модель, поэтому нам не нужно его задавать) и max_input_chars_per_word
, который определяет максимальную длину для каждого слова (слова длиннее переданного значения будут разбиты на части).
Первым шагом токенизации является нормализация, поэтому начнем с нее. Поскольку BERT широко используется, существует BertNormalizer
с классическими параметрами, которые мы можем установить для BERT: lowercase
и strip_accents
, которые не требуют пояснений; clean_text
для удаления всех управляющих символов и замены повторяющихся пробелов на один; и handle_chinese_chars
, который расставляет пробелы вокруг китайских символов. Чтобы повторить токенизатор bert-base-uncased
, мы можем просто установить этот нормализатор:
tokenizer.normalizer = normalizers.BertNormalizer(lowercase=True)
Однако, как правило, при создании нового токенизатора у вас не будет доступа к такому удобному нормализатору, уже реализованному в библиотеке 🤗 Tokenizers, поэтому давайте посмотрим, как создать нормализатор BERT вручную. Библиотека предоставляет нормализатор Lowercase
и нормализатор StripAccents
, и вы можете комбинировать несколько нормализаторов с помощью Sequence
:
tokenizer.normalizer = normalizers.Sequence(
[normalizers.NFD(), normalizers.Lowercase(), normalizers.StripAccents()]
)
Мы также используем нормализатор Unicode NFD
, поскольку в противном случае нормализатор StripAccents
не сможет правильно распознать акцентированные символы и, следовательно, не удалит их.
Как мы уже видели ранее, мы можем использовать метод normalize_str()
нормализатора, чтобы проверить, как он влияет на данный текст:
print(tokenizer.normalizer.normalize_str("Héllò hôw are ü?"))
hello how are u?
Далее если вы протестируете две версии предыдущих нормализаторов на строке, содержащей символ Unicode u"\u0085"
, то наверняка заметите, что эти два нормализатора не совсем эквивалентны.
Чтобы не усложнять версию с normalizers.Sequence
, мы не включили в нее Regex-замены, которые требует BertNormalizer
, когда аргумент clean_text
установлен в True
, что является поведением по умолчанию. Но не волнуйтесь: можно получить точно такую же нормализацию без использования удобного BertNormalizer
, добавив два normalizers.Replace
в последовательность нормализаторов.
Далее следует этап предварительной токенизации. Опять же, есть готовый BertPreTokenizer
, который мы можем использовать:
tokenizer.pre_tokenizer = pre_tokenizers.BertPreTokenizer()
Или мы можем создать его с нуля:
tokenizer.pre_tokenizer = pre_tokenizers.Whitespace()
Обратите внимание, что токенизатор Whitespace
разделяет пробельные символы и все символы, которые не являются буквами, цифрами или символом подчеркивания, поэтому технически он разделяет пробельные символы и знаки пунктуации:
tokenizer.pre_tokenizer.pre_tokenize_str("Let's test my pre-tokenizer.")
[('Let', (0, 3)), ("'", (3, 4)), ('s', (4, 5)), ('test', (6, 10)), ('my', (11, 13)), ('pre', (14, 17)),
('-', (17, 18)), ('tokenizer', (18, 27)), ('.', (27, 28))]
Если вы хотите выполнять разделение только по пробельным символам, то вместо этого следует использовать предварительный токенизатор WhitespaceSplit
:
pre_tokenizer = pre_tokenizers.WhitespaceSplit()
pre_tokenizer.pre_tokenize_str("Let's test my pre-tokenizer.")
[("Let's", (0, 5)), ('test', (6, 10)), ('my', (11, 13)), ('pre-tokenizer.', (14, 28))]
Как и в случае с нормализаторами, вы можете использовать Sequence
для комбинирования нескольких предварительных токенизаторов:
pre_tokenizer = pre_tokenizers.Sequence(
[pre_tokenizers.WhitespaceSplit(), pre_tokenizers.Punctuation()]
)
pre_tokenizer.pre_tokenize_str("Let's test my pre-tokenizer.")
[('Let', (0, 3)), ("'", (3, 4)), ('s', (4, 5)), ('test', (6, 10)), ('my', (11, 13)), ('pre', (14, 17)),
('-', (17, 18)), ('tokenizer', (18, 27)), ('.', (27, 28))]
Следующий шаг в конвейере токенизации - обработка входных данных с помощью модели. Мы уже указали нашу модель в инициализации, но нам все еще нужно обучить ее, для чего потребуется WordPieceTrainer
. Главное, что нужно помнить при инстанцировании тренера в 🤗 Tokenizers, это то, что вам нужно передать ему все специальные токены, которые вы собираетесь использовать - иначе он не добавит их в словарь, поскольку их нет в обучающем корпусе:
special_tokens = ["[UNK]", "[PAD]", "[CLS]", "[SEP]", "[MASK]"]
trainer = trainers.WordPieceTrainer(vocab_size=25000, special_tokens=special_tokens)
Помимо указания vocab_size
и special_tokens
, мы можем задать min_frequency
(количество раз, которое должен встретиться токен, чтобы быть включенным в словарь) или изменить continuing_subword_prefix
(если мы хотим использовать что-то отличное от ##
).
Чтобы обучить нашу модель с помощью итератора, который мы определили ранее, достаточно выполнить эту команду:
tokenizer.train_from_iterator(get_training_corpus(), trainer=trainer)
Мы также можем использовать текстовые файлы для обучения нашего токенизатора, что будет выглядеть следующим образом (предварительно мы повторно инициализируем модель с пустым WordPiece
):
tokenizer.model = models.WordPiece(unk_token="[UNK]")
tokenizer.train(["wikitext-2.txt"], trainer=trainer)
В обоих случаях мы можем проверить работу токенизатора на тексте, вызвав метод encode()
:
encoding = tokenizer.encode("Let's test this tokenizer.")
print(encoding.tokens)
['let', "'", 's', 'test', 'this', 'tok', '##eni', '##zer', '.']
Полученное encoding
представляет собой Encoding
, которое содержит все необходимые результаты работы токенизатора в разных атрибутах: ids
, type_ids
, tokens
, offsets
, attention_mask
, special_tokens_mask
и overflowing
.
Последний шаг в конвейере токенизации - постобработка. Нам нужно добавить токен [CLS]
в начале и токен [SEP]
в конце (или после каждого предложения, если у нас есть пара предложений). Для этого мы будем использовать TemplateProcessor
, но сначала нам нужно узнать идентификаторы токенов [CLS]
и [SEP]
в словаре:
cls_token_id = tokenizer.token_to_id("[CLS]")
sep_token_id = tokenizer.token_to_id("[SEP]")
print(cls_token_id, sep_token_id)
(2, 3)
Чтобы написать шаблон для TemplateProcessor
, мы должны указать, как обрабатывать одно предложение и пару предложений. Для обоих случаев мы указываем специальные токены, которые мы хотим использовать; первое (или одиночное) предложение представлено $A
, а второе предложение (если кодируется пара) представлено $B
. Для каждого из них (специальных токенов и предложений) мы также указываем соответствующий идентификатор типа токена (token type ID) после двоеточия.
Таким образом, классический шаблон BERT определяется следующим образом:
tokenizer.post_processor = processors.TemplateProcessing(
single=f"[CLS]:0 $A:0 [SEP]:0",
pair=f"[CLS]:0 $A:0 [SEP]:0 $B:1 [SEP]:1",
special_tokens=[("[CLS]", cls_token_id), ("[SEP]", sep_token_id)],
)
Обратите внимание, что нам нужно передать идентификаторы специальных токенов, чтобы токенизатор мог правильно преобразовать их в их идентификаторы.
Как только это будет добавлено, вернемся к нашему предыдущему примеру:
encoding = tokenizer.encode("Let's test this tokenizer.")
print(encoding.tokens)
['[CLS]', 'let', "'", 's', 'test', 'this', 'tok', '##eni', '##zer', '.', '[SEP]']
И на паре предложений мы получаем правильный результат:
encoding = tokenizer.encode("Let's test this tokenizer...", "on a pair of sentences.")
print(encoding.tokens)
print(encoding.type_ids)
['[CLS]', 'let', "'", 's', 'test', 'this', 'tok', '##eni', '##zer', '...', '[SEP]', 'on', 'a', 'pair', 'of', 'sentences', '.', '[SEP]']
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1]
Мы почти закончили создание этого токенизатора с нуля - остался последний шаг - добавить декодер:
tokenizer.decoder = decoders.WordPiece(prefix="##")
Давайте проверим его на нашем предыдущем encoding
:
tokenizer.decode(encoding.ids)
"let's test this tokenizer... on a pair of sentences."
Отлично! Мы можем сохранить наш токенизатор в единственном JSON-файле следующим образом:
tokenizer.save("tokenizer.json")
Затем мы можем загрузить этот файл в объект Tokenizer
с помощью метода from_file()
:
new_tokenizer = Tokenizer.from_file("tokenizer.json")
Чтобы использовать этот токенизатор в 🤗 Transformers, мы должны обернуть его в PreTrainedTokenizerFast
. Мы можем использовать либо общий класс, либо, если наш токенизатор соответствует существующей модели, использовать этот класс (здесь BertTokenizerFast
). Если вы используете этот урок для создания нового токенизатора, вам придется использовать первый вариант.
Чтобы обернуть токенизатор в PreTrainedTokenizerFast
, мы можем либо передать собранный нами токенизатор как tokenizer_object
, либо передать сохраненный файл токенизатора как tokenizer_file
. Главное помнить, что нам придется вручную задавать все специальные токены, поскольку класс не может определить из объекта tokenizer
, какой токен является токеном маски, токеном [CLS]
и т. д.:
from transformers import PreTrainedTokenizerFast
wrapped_tokenizer = PreTrainedTokenizerFast(
tokenizer_object=tokenizer,
# tokenizer_file="tokenizer.json", # В качестве альтернативы можно загрузить из файла токенизатора.
unk_token="[UNK]",
pad_token="[PAD]",
cls_token="[CLS]",
sep_token="[SEP]",
mask_token="[MASK]",
)
Если вы используете определенный класс токенизатора (например, BertTokenizerFast
), вам нужно будет указать только специальные токены, которые отличаются от токенов по умолчанию (здесь их нет):
from transformers import BertTokenizerFast
wrapped_tokenizer = BertTokenizerFast(tokenizer_object=tokenizer)
Затем вы можете использовать этот токенизатор, как и любой другой токенизатор 🤗 Transformers. Вы можете сохранить его с помощью метода save_pretrained()
или загрузить на хаб с помощью метода push_to_hub()
.
Теперь, когда мы рассмотрели, как создать токенизатор WordPiece, давайте сделаем то же самое для токенизатора BPE. Мы будем двигаться немного быстрее, поскольку вы знаете все шаги, и подчеркнем только различия.
Создание токенизатора BPE с нуля
Теперь давайте создадим токенизатор GPT-2. Как и в случае с токенизатором BERT, мы начнем с инициализации Tokenizer
с моделью BPE:
tokenizer = Tokenizer(models.BPE())
Также, как и в случае с BERT, мы могли бы инициализировать эту модель словарем, если бы он у нас был (в этом случае нам нужно было бы передать vocab
и merges
), но поскольку мы будем обучать с нуля, нам не нужно этого делать. Нам также не нужно указывать unk_token
, потому что GPT-2 использует byte-level BPE, который не требует этого.
GPT-2 не использует нормализатор, поэтому мы пропускаем этот шаг и переходим непосредственно к предварительной токенизации:
tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)
Опция, которую мы добавили к ByteLevel
, заключается в том, чтобы не добавлять пробел в начале предложения (в противном случае это происходит по умолчанию). Мы можем посмотреть на предварительную токенизацию примера текста, как было показано ранее:
tokenizer.pre_tokenizer.pre_tokenize_str("Let's test pre-tokenization!")
[('Let', (0, 3)), ("'s", (3, 5)), ('Ġtest', (5, 10)), ('Ġpre', (10, 14)), ('-', (14, 15)),
('tokenization', (15, 27)), ('!', (27, 28))]
Далее следует модель, которую нужно обучить. Для GPT-2 единственным специальным токеном является токен конца текста:
trainer = trainers.BpeTrainer(vocab_size=25000, special_tokens=["<|endoftext|>"])
tokenizer.train_from_iterator(get_training_corpus(), trainer=trainer)
Как и в случае с WordPieceTrainer
, а также vocab_size
и special_tokens
, мы можем указать min_frequency
, если хотим, или если у нас есть суффикс конца слова (например, </w>
), мы можем задать его с помощью end_of_word_suffix
.
Этот токенизатор также может быть обучен на текстовых файлах:
tokenizer.model = models.BPE()
tokenizer.train(["wikitext-2.txt"], trainer=trainer)
Давайте посмотрим на пример токенизации текста:
encoding = tokenizer.encode("Let's test this tokenizer.")
print(encoding.tokens)
['L', 'et', "'", 's', 'Ġtest', 'Ġthis', 'Ġto', 'ken', 'izer', '.']
Мы применяем постобработку на уровне байтов для токенизатора GPT-2 следующим образом:
tokenizer.post_processor = processors.ByteLevel(trim_offsets=False)
Опция trim_offsets = False
указывает постпроцессору, что мы должны оставить смещения токенов, начинающихся с ‘Ġ’, как есть: таким образом, начало смещения будет указывать на пробел перед словом, а не на первый символ слова (поскольку пробел технически является частью токена). Давайте посмотрим на результат с текстом, который мы только что закодировали, где 'Ġtest'
- это токен с индексом 4:
sentence = "Let's test this tokenizer."
encoding = tokenizer.encode(sentence)
start, end = encoding.offsets[4]
sentence[start:end]
' test'
Наконец, мы добавляем декодер на уровне байтов:
tokenizer.decoder = decoders.ByteLevel()
и мы сможем перепроверить, правильно ли он работает:
tokenizer.decode(encoding.ids)
"Let's test this tokenizer."
Отлично! Теперь, когда мы закончили, мы можем сохранить токенизатор, как раньше, и обернуть его в PreTrainedTokenizerFast
или GPT2TokenizerFast
, если мы хотим использовать его в 🤗 Transformers:
from transformers import PreTrainedTokenizerFast
wrapped_tokenizer = PreTrainedTokenizerFast(
tokenizer_object=tokenizer,
bos_token="<|endoftext|>",
eos_token="<|endoftext|>",
)
или:
from transformers import GPT2TokenizerFast
wrapped_tokenizer = GPT2TokenizerFast(tokenizer_object=tokenizer)
В качестве последнего примера мы покажем вам, как создать токенизатор Unigram с нуля.
Создание токенизатора Unigram с нуля
Теперь давайте построим токенизатор XLNet. Как и в предыдущих токенизаторах, мы начнем с инициализации Tokenizer
с моделью Unigram:
tokenizer = Tokenizer(models.Unigram())
Опять же, мы могли бы инициализировать эту модель словарем, если бы он у нас был.
Для нормализации XLNet использует несколько замен (которые пришли из SentencePiece):
from tokenizers import Regex
tokenizer.normalizer = normalizers.Sequence(
[
normalizers.Replace("``", '"'),
normalizers.Replace("''", '"'),
normalizers.NFKD(),
normalizers.StripAccents(),
normalizers.Replace(Regex(" {2,}"), " "),
]
)
Он заменяет “
и ”
with ”
и любую последовательность из двух или более пробелов на один пробел, а также удаляет ударения в токенезируемых текстах.
Предварительный токенизатор, который должен использоваться для любого токенизатора SentencePiece, - это Metaspace
:
tokenizer.pre_tokenizer = pre_tokenizers.Metaspace()
Мы можем посмотреть на предварительную токенизацию примера текста, как было показано ранее:
tokenizer.pre_tokenizer.pre_tokenize_str("Let's test the pre-tokenizer!")
[("▁Let's", (0, 5)), ('▁test', (5, 10)), ('▁the', (10, 14)), ('▁pre-tokenizer!', (14, 29))]
Далее следует модель, которую нужно обучить. В XLNet довольно много специальных токенов:
special_tokens = ["<cls>", "<sep>", "<unk>", "<pad>", "<mask>", "<s>", "</s>"]
trainer = trainers.UnigramTrainer(
vocab_size=25000, special_tokens=special_tokens, unk_token="<unk>"
)
tokenizer.train_from_iterator(get_training_corpus(), trainer=trainer)
Очень важный аргумент, который не стоит забывать для UnigramTrainer
- это unk_token
. Мы также можем передавать другие аргументы, специфичные для алгоритма Unigram, такие как shrinking_factor
для каждого шага удаления токенов (по умолчанию 0.75) или max_piece_length
для указания максимальной длины данного токена (по умолчанию 16).
Этот токенизатор также может быть обучен на текстовых файлах:
tokenizer.model = models.Unigram()
tokenizer.train(["wikitext-2.txt"], trainer=trainer)
Давайте посмотрим на токенизацию примера текста:
encoding = tokenizer.encode("Let's test this tokenizer.")
print(encoding.tokens)
['▁Let', "'", 's', '▁test', '▁this', '▁to', 'ken', 'izer', '.']
Особенностью XLNet является то, что он помещает токен <cls>
в конец предложения с идентификатором типа 2 (чтобы отличить его от других токенов). В результате он помещается слева. Мы можем разобраться со всеми специальными токенами и идентификаторами типов токенов с помощью шаблона, как в BERT, но сначала нам нужно получить идентификаторы токенов <cls>
и <sep>
:
cls_token_id = tokenizer.token_to_id("<cls>")
sep_token_id = tokenizer.token_to_id("<sep>")
print(cls_token_id, sep_token_id)
0 1
Шаблон выглядит следующим образом:
tokenizer.post_processor = processors.TemplateProcessing(
single="$A:0 <sep>:0 <cls>:2",
pair="$A:0 <sep>:0 $B:1 <sep>:1 <cls>:2",
special_tokens=[("<sep>", sep_token_id), ("<cls>", cls_token_id)],
)
И мы можем проверить, как это работает, закодировав пару предложений:
encoding = tokenizer.encode("Let's test this tokenizer...", "on a pair of sentences!")
print(encoding.tokens)
print(encoding.type_ids)
['▁Let', "'", 's', '▁test', '▁this', '▁to', 'ken', 'izer', '.', '.', '.', '<sep>', '▁', 'on', '▁', 'a', '▁pair',
'▁of', '▁sentence', 's', '!', '<sep>', '<cls>']
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2]
Наконец, мы добавляем декодер Metaspace
:
tokenizer.decoder = decoders.Metaspace()
и мы закончили работу с этим токенизатором! Мы можем сохранить токенизатор, как и раньше, и обернуть его в PreTrainedTokenizerFast
или XLNetTokenizerFast
, если мы хотим использовать его в 🤗 Transformers. При использовании PreTrainedTokenizerFast
следует обратить внимание на то, что помимо специальных токенов, нам нужно указать библиотеке 🤗 Transformers на то, чтобы они располагались слева:
from transformers import PreTrainedTokenizerFast
wrapped_tokenizer = PreTrainedTokenizerFast(
tokenizer_object=tokenizer,
bos_token="<s>",
eos_token="</s>",
unk_token="<unk>",
pad_token="<pad>",
cls_token="<cls>",
sep_token="<sep>",
mask_token="<mask>",
padding_side="left",
)
Или альтернативно:
from transformers import XLNetTokenizerFast
wrapped_tokenizer = XLNetTokenizerFast(tokenizer_object=tokenizer)
Теперь, когда вы увидели, как различные блоки используются для создания существующих токенизаторов, вы должны быть в состоянии написать любой токенизатор, который вы хотите, с помощью библиотеки 🤗 Tokenizers и использовать его в 🤗 Transformers.