Как мы можем легко обучить токенизатор подслов SentencePiece с нуля с помощью Python и использовать его в Tensorflow 2.

В последнее время я занимался разработкой некоторых интересных проектов NLP с помощью TensorFlow (следите за обновлениями, я скоро опубликую их! 😉) и хотел воспользоваться возможностью, чтобы попробовать и включить токенизация подслов. Я решил прибегнуть к SentencePiece [1] (в частности, к его алгоритму unigram) из-за огромного количества положительных функций, которые он предлагает, и его превосходных функций по сравнению со всеми другими доступными в настоящее время стратегиями токенизации. Для любого проекта, от простого классификатора до нейронного машинного переводчика, это должно быть вашим выбором в настоящее время (вы поймете, почему в следующем)!

Тем не менее, в отличие от других более простых токенизаторов, которые включены в готовые библиотеки машинного обучения, SentencePiece требует обучения с нуля, и не всегда просто выяснить, какой способ сделать это быстрее и эффективнее. Поэтому в этом кратком руководстве я хочу поделиться с вами тем, как я это сделал: мы увидим, как мы можем обучить токенизатор с нуля на пользовательском наборе данных с помощью SentencePiece и безукоризненно включить его в любой проект TensorFlow 2, используя tensorflow-text .

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

ПРИМЕЧАНИЕ. Я предполагаю, что вы достаточно знакомы с конвейером NLP и SentencePiece, а также с TensorFlow и tf.keras, поэтому мы не будем вдаваться в подробности о них здесь. Уже есть много хороших ресурсов по этим темам, если вам нужно освежить знания или вы просто хотите углубить свои знания о токенизации подслов и SP. Например, вы можете взглянуть на:

1. Учебное предложение.

Первым обязательным шагом является создание модели токенизации: tensorflow-text не включает (пока, по крайней мере) возможности обучения, поэтому мы прибегнем к библиотеке sentencepiece , обертке кода Google. » для Питона. Вы можете установить его через pip:

pip install sentencepiece

Просто чтобы положиться на практический пример, давайте предположим, что мы хотим обучить нашу модель тексту Алиса в стране чудес 📖 (я подготовил его версию, разделенную на предложения, которую вы можете найти в моем репозитории GitHub вместе со всем кодом). Это чрезвычайно ограниченный корпус (около 1600 предложений), и, как правило, вы хотели бы иметь гораздо больший корпус, чтобы действительно зафиксировать статистику языка для моделирования. Для наших пояснительных целей этого достаточно (и обучается молниеносно!).

Обучение можно проводить из исходного файла или непосредственно из памяти, а полученную модель можно либо сохранить, либо сохранить в памяти (это может быть полезно, если у вас ограниченный доступ к диску, например, в Colab).
Посмотрим, как 😃...

1.1 Обучение из файла

Предположим, что SPLITTED_ALICE_FILE_PATH содержит путь к нашему обучающему корпусу (splitted_alice_in_wonderland.txt в репозитории GitHub), следующий фрагмент кода будет обучать модель с именем sp_alice со словарем 1500 токенов, создающих два файла:

  • sp_alice.model: фактическая модель, используемая для токенизации и детокенизации;
  • sp_alice.vocab: создается только для справки, содержит извлеченные токены, связанные с их соответствующим идентификатором (строкой, в которой они появляются), и оценкой (ее значение зависит от фактического принятого базового алгоритма).

Кстати, реализация SentencePiece от Google автоматически выполняет нормализацию текста (NFKC Unicode), поэтому нам не нужно ничего делать с нашим входным текстом!

import sentencepiece as spm

spm.SentencePieceTrainer.Train(input=SPLITTED_ALICE_FILE_PATH,
                               model_prefix='sp_alice',
                               vocab_size=1500,
                               pad_id=0,                
                               unk_id=1,
                               bos_id=2,
                               eos_id=3
                               )

Обратите внимание, что нам нужно переопределить идентификаторы специальных токенов, чтобы легко интегрировать модель в TensorFlow: фактически токен ‹pad› должен быть связан с идентификатором 0, если мы хотим напрямую использовать функции автоматического маскирования. tf.keras, чтонесомненно хорошо и пригодится позже.

Мы можем указать множество дополнительных параметров для настройки обучения (см. здесь), но конфигурация по умолчанию уже должна работать очень хорошо, если у вас нет особых потребностей. Например, model_type позволяет выбирать между unigram[2](по умолчанию) или кодировкой пар байтов*, а user_defined_symbols позволяет указать токены, которые никогда не будут разделены (полезно, если мы хотим чтобы присвоить им особое значение, смотрите здесь, если интересно).

*Возможно, вы по-прежнему захотите использовать unigram поверх BPE, см. [3].

1.2 Обучение по памяти/итератору

Обучение модели из памяти более или менее похоже на обучение из внешнего файла, за исключением того, что для этой функции требуется итератор. Предположим, что corpus — это список предложений, следующий код прекрасно справится с этой задачей.

spm.SentencePieceTrainer.Train(sentence_iterator=iter(corpus),
                               model_prefix='sp_alice',
                               vocab_size=1500,
                               pad_id=0,                
                               unk_id=1,
                               bos_id=2,
                               eos_id=3
                               )

Конечно, библиотека также предлагает функции токенизации и детокенизации, поэтому давайте посмотрим, как наша обученная модель справляется с токенизацией предложения. Мы загрузим файл sp_alice.model (предположительно, его путь указан в MODEL_PATH) и попробуем токенизировать предложение

-- Да, вот так, -- со вздохом сказал Шляпник, -- всегда чаепитие […]

sp = spm.SentencePieceProcessor(model_file=str(MODEL_PATH))

encoded_input = sp.Encode("'Yes, that's it,' said the Hatter with a sigh: 'it's always tea-time'")
print(encoded_input)

tokenized_input = [sp.IdToPiece(id) for id in encoded_input]
print(*tokenized_input)

что дает нам в результате закодированный ввод (выраженный в виде идентификаторов токена) и токенизированный ввод строки:

[8, 7, 485, 4, 28, 7, 12, 14, 25, 18, 5, 122, 43, 13, 466, 32, 8, 7, 130, 7, 12, 13, 453, 12, 295, 50, 1236, 7]
▁ ' Yes , ▁that ' s ▁it ,' ▁said ▁the ▁Hatter ▁with ▁a ▁sigh : ▁ ' it ' s ▁a lway s ▁tea - time '

Обратите внимание, что символ представляет собой пустой пробел в SentencePiece и имеет смысл в том смысле, что является частью токенов (кроме того, в начале предложения всегда добавляется пробел). Интересно, что мы можем видеть, как «всегда» разбивается на вложенные токены — «всегда-с». Это связано с тем, что слово встречается во всем тексте только 13 раз (например, "Hatter" и "tea" встречается более 40 раз каждое), а модель решил не представлять его с помощью определенного токена.

Очень интересной особенностью SentencePiece является регуляризация подслов (выборка подслов [2], если в качестве алгоритма обучения используется unigram, или BPE). -dropout[4] в случаеиз BPE):поскольку может быть много способов составления каждого слова, начиная с суб- токенов, мы можем использовать эту функцию, чтобы внести больше разнообразия в обучение и сделать наши модели более надежными за счет своего рода естественного увеличения данных. Чтобы показать это, мы сгенерируем 3 токенизации одного и того же предложения, используя выборку (alpha — параметр сглаживания, чем ниже, тем более разнообразны результаты, которые мы получим, и n_best_size — это количество групп токенов с наивысшим рангом, из которых будет производиться выборка в каждый момент времени. где -1 означает все возможности):

for _ in range(3):
    encoded_input = sp.Encode("'Yes, that's it,' said the Hatter with a sigh: 'it's always tea-time'",
                              enable_sampling=True,
                              alpha=0.1,
                              nbest_size=-1
                              )

    tokenized_input = [sp.IdToPiece(id) for id in encoded_input]
    print(*tokenized_input)

Результат подчеркивает то, что мы только что обсудили:

▁ ' Yes , ▁that ' s ▁it ,' ▁said ▁the ▁Hatter ▁with ▁a ▁sigh : ▁ ' it ' s ▁a lway s ▁tea - time '
▁ ' Yes , ▁that ' s ▁ it , ' ▁said ▁ t he ▁Hatter ▁with ▁a ▁sigh : ▁ ' i t ' s ▁ a lway s ▁ t e a - time '
▁ ' Y es , ▁ that ' s ▁it , ' ▁s a i d ▁ the ▁ H a t t er ▁with ▁a ▁s i g h : ▁ ' it ' s ▁al way s ▁ te a - time '

1.3 Сохранение модели в памяти

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

import io
model = io.BytesIO()
spm.SentencePieceTrainer.Train(sentence_iterator=iter(corpus),
                               model_writer=model,
                               vocab_size=1500,
                               pad_id=0,                
                               unk_id=1,
                               bos_id=2,
                               eos_id=3
                               )
# Optionally, save it
with open(CURRENT_PATH.joinpath('sp_alice.model'), 'wb') as outfile:
    outfile.write(model.getvalue())
sp = spm.SentencePieceProcessor(model_proto=model.getvalue())

Разобравшись с основами работы SentencePiece в коде, давайте воспользуемся обученными моделями и разработанным кодом в TensorFlow.

2. Использование text.SentencePieceTokenizer()

Прежде всего, нам нужно установить библиотеку tensorflow-text (предполагается, что версия библиотеки всегда должна совпадать с текущей установленной версией TensorFlow).

pip install tensorflow-text==[version of tensorflow]

Токенизатор SentencePiece, реализованный в TensorFlow, также предлагает кодирование/декодирование и выборку, которые, конечно же, можно использовать для обучения наших моделей глубокого обучения. Прежде всего, нам нужно создать его экземпляр с моделью (я покажу вам, как это сделать только из файла; если он уже находится в памяти, просто передайте model.getvalue(), как указано выше). Опять же, предполагая, что переменная MODEL_PATH содержит путь к файлу .model, следующий код создает токенизацию (лучшую, детерминированно, если только мы не установим параметры alpha и n_best_size ) входного предложения.

import tensorflow as tf
from tensorflow.python.platform import gfile
from tensorflow_text import SentencepieceTokenizer, pad_model_inputs
model = gfile.GFile(MODEL_PATH, 'rb').read()
tf_sp = SentencepieceTokenizer(
    model=model,
    #alpha=0.1,
    #nbest_size=-1,
    #add_bos=True,
    #add_eos=True,
    #reverse=False
)
input_ = tf.constant(["'Yes, that's it,' said the Hatter with a sigh: 'it's always tea-time'"])
encoded_input = tf_sp.tokenize(input_)

Здесь у нас есть дополнительные параметры: add_bos и add_eos, которые добавляют маркеры начала предложения и конца предложения после сегментации, и reverse, который переворачивает предложение перед сегментацией. Они могут быть особенно полезны в задачах машинного перевода и суммирования.

А как насчет партий предложений? Что ж, токенизатор создаст RaggedTensor,поскольку каждая токенизация в принципе отличается по длине. Нам нужно заполнить его самостоятельно: давайте сделаем это динамически с длиной самого длинного предложения, с помощью функции, которая позволит нам выполнить всю предварительную обработку за один раз.

def tokenize(text_batch: tf.Tensor,
             tokenizer: Tokenizer,
             make_lower: bool = True,
             max_sequence_length: int = 512,
             fixed_length: bool = False
             ) -> tf.Tensor:
    # Possibly make lowercase
    if make_lower:
        text_batch = tf.strings.lower(text_batch)
    # Tokenize
    tokenized_batch = tokenizer.tokenize(text_batch)
    # If we need to pad/truncate to max length
    if fixed_length:
        seq_length = max_sequence_length
    else:
        tokenized_batch_max_length = tf.cast(tokenized_batch.bounding_shape(axis=1), dtype=tf.int32)
        seq_length = tf.minimum(max_sequence_length, tokenized_batch_max_length)

    tokenized_batch, _ = pad_model_inputs(tokenized_batch,
                                          max_seq_length=seq_length,
                                          pad_value=0
                                          )

    return tokenized_batch

Функция принимает на вход пакет предложений и токенизатор и применяет некоторые шаги предварительной обработки: make_lower указывает, хотим ли мы преобразовать входной текст в нижний регистр, max_sequence_length — максимальное количество токенов на выборку, а fixed_length заставляет вывод быть всегда max_sequence_length -long, усекая или дополняя результат соответственно.

Вызов с параметрами по умолчанию

input_ = tf.constant(
    ["'Yes, that's it,' said the Hatter with a sigh: 'it's always tea-time'",
     "But if I'm not the same, the next question is, Who in the world am I?"]
)

tokenized_input = tokenize(input_, tf_sp)
print(tokenized_input)

дает

tf.Tensor(
[[   6   64  256    4   28    7   12   14   25   18    5    8 1044   17
    17   90   43   13  465   32    6  130    7   12   13  453   12  294
    50 1237    7]
 [  60   98    8  141    7   48   53    5  269    4    5  230  287   63
     4  107   19    5  766  258    8  141   36    0    0    0    0    0
     0    0    0]], shape=(2, 31), dtype=int32)

это то, что мы ожидаем (второе предложение короче, поэтому оно дополняется, чтобы соответствовать первому по количеству токенов). Заполнение 0 позволяет совместимым слоям keras автоматически маскировать ввод при необходимости (см. здесь).

Приведенный выше код можно использовать в нашем проекте TensorFlow (например, для .map() tf.data.Dataset), и он естественным образом поддерживает трассировку для повышения производительности. Делая шаг вперед, я хотел бы предложить вам реализацию в виде настраиваемого слоя tf.keras, который поддерживает сериализацию/сохранение, а также автоматически включает и выключает регуляризацию подслов для обучения и вывода, если это необходимо. Более того, следуя описанию выше, его можно запустить из файла внешней модели или обучить из памяти или файловых данных.

Код не требует пояснений и следует основным правилам создания подклассов keras.Layer. Представленная ранее функция включена сюда как _tokenize(), а трассируемая версия представлена ​​как _tf_tokenize(), которая выполняется при вызове в случае, если для повышения производительности активирован флаг use_tf_function.

В заключение мы попробуем слой в очень простом скрипте, который обучает сеть LSTM задаче бинарной классификации IMDb, следуя учебнику TensorFlow, но заменяя слой предварительной обработки нашим.

Epoch 1/2
391/391 [==============================] - 131s 320ms/step - loss: 0.5643 - accuracy: 0.6740 - val_loss: 0.3124 - val_accuracy: 0.8672
Epoch 2/2
391/391 [==============================] - 123s 313ms/step - loss: 0.3101 - accuracy: 0.8745 - val_loss: 0.3041 - val_accuracy: 0.8693
391/391 [==============================] - 46s 118ms/step - loss: 0.3054 - accuracy: 0.8672
Test Loss: 0.31
Test Accuracy: 0.87
The movie was cool. The animation and the graphics were out of this world. I would recommend this movie.
[[0.77931494]]

И это все! Не стесняйтесь использовать код и играть с ним, возможно, включая другие интересные функции, и дайте мне знать в комментариях, если у вас есть какие-либо вопросы :).

Использованная литература:

[1] Таку Кудо и Джон Ричардсон, SentencePiece: простой и независимый от языка токенизатор и детокенизатор подслов для нейронной обработки текста»
[2] Таку Кудо, “ Регуляризация подслов: улучшение моделей перевода нейронной сети с несколькими кандидатами подслов»
[3] Кай Бостром и Грег Дарретт, Кодирование пар байтов неоптимально для предварительного обучения языковых моделей»
[4] Иван Провилков, Дмитрий Емельяненко и Елена Войта, «BPE-Dropout: простая и эффективная регуляризация подслов»