В этом посте я хочу показать, как применить BERT к простой проблеме классификации текста. Я предполагаю, что вы более или менее знакомы с тем, что такое BERT на высоком уровне, и больше сосредотачиваетесь на практической стороне, показывая вам, как использовать его в своей работе. Грубо говоря, BERT - это модель, которая умеет представлять текст. Вы даете ему некоторую последовательность в качестве ввода, затем он несколько раз просматривает влево и вправо и создает векторное представление для каждого слова в качестве вывода. В своей статье авторы описывают два способа работы с BERT, один как с механизмом извлечения признаков. То есть мы используем окончательный результат BERT в качестве входных данных для другой модели. Таким образом, мы извлекаем особенности из текста с помощью BERT, а затем используем их в отдельной модели для реальной задачи. Другой способ - тонкая настройка BERT. То есть мы добавляем дополнительный слой / слои поверх BERT, а затем тренируем все вместе. Таким образом, мы обучаем наш дополнительный слой / слои, а также изменяем (точно настраиваем) веса BERT. Здесь я хочу показать второй метод и представить пошаговое решение очень простой и популярной задачи классификации текста - классификации настроений IMDB Movie. Эта задача может быть не самой сложной задачей для решения, и применение BERT к ней может быть немного излишним, но большинство шагов, показанных здесь, одинаковы почти для каждой задачи, независимо от того, насколько она сложна.

Прежде чем погрузиться в реальный код, давайте разберемся с общей структурой BERT и тем, что нам нужно сделать, чтобы использовать ее в задаче классификации. Как упоминалось ранее, обычно вход BERT - это последовательность слов, а выход - последовательность векторов. BERT позволяет нам выполнять различные задачи в зависимости от его результатов. Итак, для другого типа задач нам нужно немного изменить ввод и / или вывод. На рисунке ниже вы можете увидеть 4 разных типа задач, для каждого типа задачи мы можем видеть, какими должны быть входные и выходные данные модели.

Вы можете видеть, что для ввода всегда есть специальный токен [CLS] (обозначает классификацию) в начале каждой последовательности и специальный токен [SEP], который разделяет две части ввода.

Для вывода, если нас интересует классификация, нам нужно использовать вывод первого токена (токен [CLS]). Для более сложных выходов мы можем использовать все остальные выходные токены.

Нас интересует «Классификация одиночных предложений» (вверху справа), поэтому мы добавим специальный токен [CLS] и будем использовать его выходные данные в качестве входных данных для линейного слоя с последующей активацией sigmoid, которая выполняет фактическую классификацию.

Теперь давайте разберемся с задачей: по обзору фильма предсказать, будет ли он положительным или отрицательным. Набор данных, который мы используем, составляет 50 000 обзоров IMDB (25 КБ для поездов и 25 КБ для тестов) из библиотеки PyTorch-NLP. Каждый отзыв помечен тегами pos или neg. Как в тренировочных, так и в тестовых наборах 50% положительных и 50% отрицательных отзывов.

Вы можете найти весь код в этой записной книжке.

1. Подготовка данных

Загружаем данные с помощью библиотеки pytorch-nlp:

train_data, test_data = imdb_dataset(train=True, test=True)

Каждый экземпляр в этом наборе данных представляет собой словарь с двумя полями: text и sentimet.

{
    'sentiment': 'pos',  
    'text': 'Having enjoyed Joyces complex nove...'
}

Мы создаем две переменные для каждого набора, одну для текстов и одну для этикеток:

train_texts, train_labels = list(zip(*map(lambda d: (d['text'], d['sentiment']), train_data)))
test_texts, test_labels = list(zip(*map(lambda d: (d['text'], d['sentiment']), test_data)))

Далее нам нужно токенизировать наши тексты. BERT был обучен с использованием токенизации WordPiece. Это означает, что слово можно разбить на несколько подслов. Например, если я токенизирую предложение «Привет, меня зовут Дима», я получу:

tokenizer.tokenize('Hi my name is Dima')
# OUTPUT
['hi', 'my', 'name', 'is', 'dim', '##a']

Такой вид токенизации полезен при работе со словами вне словарного запаса и может помочь лучше представить сложные слова. Подслова строятся во время обучения и зависят от корпуса, на котором обучалась модель. Конечно, мы могли бы использовать любую другую технику токенизации, но мы получим наилучшие результаты, если будем использовать тот же токенизатор, на котором была обучена модель BERT. Библиотека PyTorch-Pretrained-BERT предоставляет нам токенизатор для каждой из моделей BERTS. Здесь мы используем базовую bert-base-uncased модель, есть несколько других моделей, в том числе гораздо более крупные модели. Максимальный размер последовательности для BERT - 512, поэтому мы будем усекать любой обзор, который длиннее этого.

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

tokenizer = BertTokenizer.from_pretrained('bert-base-uncased', do_lower_case=True)
train_tokens = list(map(lambda t: ['[CLS]'] + tokenizer.tokenize(t)[:511], train_texts))
test_tokens = list(map(lambda t: ['[CLS]'] + tokenizer.tokenize(t)[:511], test_texts))

Затем нам нужно преобразовать каждый токен в каждом обзоре в id, который присутствует в словаре токенизатора. Если есть токен, которого нет в словаре, токенизатор будет использовать специальный токен [UNK] и его идентификатор:

train_tokens_ids = list(map(tokenizer.convert_tokens_to_ids, train_tokens))
test_tokens_ids = list(map(tokenizer.convert_tokens_to_ids, train_tokens_ids))

Наконец, нам нужно дополнить наш ввод таким образом, чтобы он имел тот же размер, равный 512. Это означает, что для любого обзора, длина которого меньше 512 токенов, мы будем добавлять нули, чтобы получить 512 токенов:

train_tokens_ids = pad_sequences(train_tokens_ids, maxlen=512, truncating="post", padding="post", dtype="int")
test_tokens_ids = pad_sequences(test_tokens_ids, maxlen=512, truncating="post", padding="post", dtype="int")

Наша целевая переменная в настоящее время представляет собой список строк neg и pos. Преобразуем его в numpy массивы логических значений:

train_y = np.array(train_labels) == 'pos'
test_y = np.array(test_labels) == 'pos'

2. Построение модели

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

Прежде чем создавать нашу модель, давайте посмотрим, как мы можем использовать модель BERT, реализованную в библиотеке PyTorch-Pretrained-BERT:

bert = BertModel.from_pretrained('bert-base-uncased')
x = torch.tensor(train_tokens_ids[:3])
y, pooled = bert(x, output_all_encoded_layers=False)
print('x shape:', x.shape)
print('y shape:', y.shape)
print('pooled shape:', pooled.shape)
# OUTPUT
x shape :(3, 512)
y shape: (3, 512, 768)
pooled shape: (3, 768)

Сначала мы создаем модель BERT, затем создаем тензор PyTorch с первыми тремя отзывами из нашего обучающего набора и передаем его ему. На выходе две переменные. Давайте разберемся во всех формах: x имеет размер (3, 512), мы взяли всего 3 отзыва по 512 токенов в каждом. y имеет размер (3, 512, 768), это результат конечного уровня BERT для каждого токена. Мы могли бы использовать output_all_encoded_layer=True, чтобы получить результат всех 12 слоев. Каждый токен в каждом обзоре представлен с использованием вектора размера 768.pooled имеет размер (3, 768) это результат нашего [CLS] токена, первого токена в нашей последовательности.

Наша цель - получить объединенный вывод BERT, применить линейный слой и sigmoid активацию. Вот как выглядит наша модель:

class BertBinaryClassifier(nn.Module):
    def __init__(self, dropout=0.1):
        super(BertBinaryClassifier, self).__init__()
        self.bert = BertModel.from_pretrained('bert-base-uncased')
        self.linear = nn.Linear(768, 1)
        self.sigmoid = nn.Sigmoid()
    
    def forward(self, tokens):
        _, pooled_output = self.bert(tokens, utput_all=False)
        linear_output = self.linear(dropout_output)
        proba = self.sigmoid(linear_output)
        return proba

Каждая модель в PyTorch - это nn.Module объект. Это означает, что каждая построенная нами модель должна предоставлять 2 метода. __init__ method объявляет все различные части, которые модель будет использовать. В нашем случае мы создаем модель BERT, которую будем настраивать, линейный слой и активацию сигмоида. Метод forward - это фактический код, который выполняется во время прямого прохода (например, метод predict в sklearn или keras). Здесь мы берем вход tokens и передаем его модели BERT. Результатом BERT являются 2 переменные, как мы видели ранее, мы используем только вторую (имя _ используется, чтобы подчеркнуть, что эта переменная не используется). Мы берем объединенный вывод и передаем его линейному слою. Наконец, мы используем сигмовидную активацию, чтобы получить фактическую вероятность.

3. Обучение / настройка

Обучение довольно стандартное. Сначала подготавливаем наши тензоры и загрузчики данных:

train_tokens_tensor = torch.tensor(train_tokens_ids)
train_y_tensor = torch.tensor(train_y.reshape(-1, 1)).float()
test_tokens_tensor = torch.tensor(test_tokens_ids)
test_y_tensor = torch.tensor(test_y.reshape(-1, 1)).float()
train_dataset = TensorDataset(train_tokens_tensor, train_y_tensor)
train_sampler = RandomSampler(train_dataset)
train_dataloader = DataLoader(train_dataset, sampler=train_sampler, batch_size=BATCH_SIZE)
test_dataset = TensorDataset(test_tokens_tensor, test_y_tensor)
test_sampler = SequentialSampler(test_dataset)
test_dataloader = DataLoader(test_dataset, sampler=test_sampler, batch_size=BATCH_SIZE)

Мы воспользуемся оптимизаторомAdam и функцией потерь BinaryCrossEntropy (BCELoss) и обучим модель 10 эпохам:

bert_clf = BertBinaryClassifier()
bert_clf = bert_clf.cuda()
optimizer = Adam(bert_clf.parameters(), lr=3e-6)
bert_clf.train()
for epoch_num in range(EPOCHS):
    for step_num, batch_data in enumerate(train_dataloader):
        token_ids, labels = tuple(t.to(device) for t in batch_data)
        probas = bert_clf(token_ids)
        loss_func = nn.BCELoss()
        batch_loss = loss_func(probas, labels)
        bert_clf.zero_grad()
        batch_loss.backward()
        optimizer.step()

Для тех, кто не знаком с PyTorch, давайте рассмотрим код шаг за шагом.

Сначала мы создаем BertBinaryClassifier, как мы определили выше. Мы переносим его на GPU, применяя bert_clf.cuda(). Мы создаем оптимизатор Adam с параметрами нашей модели (которые оптимизатор обновит) и скоростью обучения, которая, как я считаю, работает хорошо.

Для каждого шага в каждую эпоху мы делаем следующее:

  1. Перенесите наши тензоры в GPU, применив .to(device)
  2. bert_clf(token_ids) дает нам вероятности (прямой проход)
  3. Рассчитайте убыток с loss_func(probas, labels)
  4. Обнулите градиенты из предыдущего шага
  5. Вычислить и распространить новые градиенты на batch_loss.backward()
  6. Обновите параметры модели относительно градиентов на optimizer.step()

После 10 эпох я получил неплохие результаты.

Заключение

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