«Анализ настроений НЛП» (7) — Трансформаторный анализ настроений

NLP
«Анализ настроений НЛП» (7) — Трансформаторный анализ настроений

6. Анализ настроений с помощью Transformer

В этом блокноте мы будем использоватьAttention is all you needМодель Transformer впервые представлена ​​в статье. В частности, мы будем использоватьBERT: Pre-training of Deep Bidirectional Transformers for Language UnderstandingМодель BERT в статье.

Модель Transformer намного больше, чем другие модели, описанные в этом уроке. Поэтому мы будем использоватьtransformers libraryчтобы получить предварительно обученные трансформеры и использовать их в качестве слоев для встраивания. Мы исправим (не обучим) преобразователь и будем обучать только остальную часть модели, полученную из представлений, созданных преобразователем. В этом случае мы продолжим извлекать признаки из вложений Берта, используя двунаправленный GRU. Наконец, окончательный результат выводится на слой fc.

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

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

import torch

import random
import numpy as np

SEED = 1234

random.seed(SEED)
np.random.seed(SEED)
torch.manual_seed(SEED)
torch.backends.cudnn.deterministic = True

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

К счастью, в библиотеке трансформаторов есть токенизаторы для каждой предоставленной модели трансформатора. В этом случае мы используем модель BERT без учета регистра (т. е. каждое слово будет строчным). Мы делаем это, загружая предварительно обученный токенизатор «bert-base-uncased».

from transformers import BertTokenizer

tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')

image.png

tokenizerсуществует одинvocabатрибут, который содержит фактический словарь, который мы будем использовать. Мы можем проверить, сколько слов в нем, проверив его длину.

len(tokenizer.vocab)
30522

использоватьtokenizer.tokenizeМетод разбивает строку и объединяет случай.

tokens = tokenizer.tokenize('Hello WORLD how ARE yoU?')

print(tokens)
['hello', 'world', 'how', 'are', 'you', '?']

Мы можем использовать наш глоссарий, чтобы использоватьtokenizer.convert_tokens_to_idsоцифровать знак. Следующие токены представляют собой список после того, как мы выполнили сегментацию слов и унифицированный регистр выше.

indexes = tokenizer.convert_tokens_to_ids(tokens)

print(indexes)
[7592, 2088, 2129, 2024, 2017, 1029]

Трансформеров также обучают специальными жетонами, чтобы отмечать начало и конец предложений.Детали. Точно так же, как мы нормализуем отступы и неизвестные токены, мы также можем получитьtokenizerполучить их.

Уведомление:tokenizerимеет свойства начала и конца последовательности (bos_tokenиeos_token), но мы не устанавливали это, и на этот раз это не сработало с трансформатором, который мы обучали.

init_token = tokenizer.cls_token
eos_token = tokenizer.sep_token
pad_token = tokenizer.pad_token
unk_token = tokenizer.unk_token

print(init_token, eos_token, pad_token, unk_token)
[CLS] [SEP] [PAD] [UNK]

Мы можем получить индекс специальных токенов, перевернув словарь

init_token_idx = tokenizer.convert_tokens_to_ids(init_token)
eos_token_idx = tokenizer.convert_tokens_to_ids(eos_token)
pad_token_idx = tokenizer.convert_tokens_to_ids(pad_token)
unk_token_idx = tokenizer.convert_tokens_to_ids(unk_token)

print(init_token_idx, eos_token_idx, pad_token_idx, unk_token_idx)
101 102 0 100

Или получить их напрямую через метод токенизатора

init_token_idx = tokenizer.cls_token_id
eos_token_idx = tokenizer.sep_token_id
pad_token_idx = tokenizer.pad_token_id
unk_token_idx = tokenizer.unk_token_id

print(init_token_idx, eos_token_idx, pad_token_idx, unk_token_idx)
101 102 0 100

Еще одна вещь, с которой нам нужно иметь дело, это то, что модель обучается на последовательностях с определенной максимальной длиной — она не знает, как обрабатывать последовательности длиннее, чем она была обучена. Мы можем сделать это, проверив версию конвертера, которую мы хотим использовать.max_model_input_sizesчтобы получить максимальную длину этих входных размеров.

max_input_length = tokenizer.max_model_input_sizes['bert-base-uncased']

print(max_input_length)
512

прежде чем мы использовалиspaCytokenizer для маркировки наших примеров. Однако теперь нам нужно определить функцию, которую мы передадим нашемуTEXTполе, которое будет обрабатывать всю токенизацию для нас. Это также уменьшит количество токенов до максимальной длины. Обратите внимание, что наша максимальная длина на 2 меньше фактической максимальной длины. Это потому, что нам нужно добавить два маркера к каждой последовательности, один в начале и один в конце.

def tokenize_and_cut(sentence):
    tokens = tokenizer.tokenize(sentence) 
    tokens = tokens[:max_input_length-2]
    return tokens

Теперь мы начинаем определять наши поля, преобразователь ожидает поместить пакетное измерение в первое измерение, поэтому мы устанавливаемbatch_first = True. Теперь, когда у нас есть лексические данные для текста, предоставленные преобразователем, мы устанавливаемuse_vocab = Falseчтобы сообщить torchtext, что нет необходимости разделять данные. мы будемtokenize_and_cutФункции передаются как токенизаторы.preprocessingПараметр — это функция, здесь мы конвертируем токен в его индекс. Наконец, мы определяем специальные токены — обратите внимание, что мы определили их как их значения индекса, а не их строковые значения, то есть «100» вместо «[UNK]». Это связано с тем, что последовательности были преобразованы в индексы.

Мы определяем поле метки, как и раньше.

from torchtext.legacy import data

TEXT = data.Field(batch_first = True,
                  use_vocab = False,
                  tokenize = tokenize_and_cut,
                  preprocessing = tokenizer.convert_tokens_to_ids,
                  init_token = init_token_idx,
                  eos_token = eos_token_idx,
                  pad_token = pad_token_idx,
                  unk_token = unk_token_idx)

LABEL = data.LabelField(dtype = torch.float)

Загрузить данные, разделенные на набор для обучения и набор для проверки

from torchtext.legacy import datasets

train_data, test_data = datasets.IMDB.splits(TEXT, LABEL)

train_data, valid_data = train_data.split(random_state = random.seed(SEED))
print(f"Number of training examples: {len(train_data)}")
print(f"Number of validation examples: {len(valid_data)}")
print(f"Number of testing examples: {len(test_data)}")
Number of training examples: 17500
Number of validation examples: 7500
Number of testing examples: 25000

Просто посмотрите на пример, посмотрите, как это работает, и выведите горячий вектор одного из предложений.

print(vars(train_data.examples[6]))
{'text': [1042, 4140, 1996, 2087, 2112, 1010, 2023, 3185, 5683, 2066, 1037, 1000, 2081, 1011, 2005, 1011, 2694, 1000, 3947, 1012, 1996, 3257, 2003, 10654, 1011, 28273, 1010, 1996, 3772, 1006, 2007, 1996, 6453, 1997, 5965, 1043, 11761, 2638, 1007, 2003, 2058, 13088, 10593, 2102, 1998, 7815, 2100, 1012, 15339, 14282, 1010, 3391, 1010, 18058, 2014, 3210, 2066, 2016, 1005, 1055, 3147, 3752, 2068, 2125, 1037, 16091, 4003, 1012, 2069, 2028, 2518, 3084, 2023, 2143, 4276, 3666, 1010, 1998, 2008, 2003, 2320, 10012, 3310, 2067, 2013, 1996, 1000, 7367, 11368, 5649, 1012, 1000, 2045, 2003, 2242, 14888, 2055, 3666, 1037, 2235, 2775, 4028, 2619, 1010, 1998, 2023, 3185, 2453, 2022, 2062, 2084, 2070, 2064, 5047, 2074, 2005, 2008, 3114, 1012, 2009, 2003, 7078, 5923, 1011, 27017, 1012, 2023, 2143, 2069, 2515, 2028, 2518, 2157, 1010, 2021, 2009, 21145, 2008, 2028, 2518, 2157, 2041, 1997, 1996, 2380, 1012, 4276, 3773, 2074, 2005, 1996, 2197, 2184, 2781, 2030, 2061, 1012], 'label': 'neg'}

мы можем использоватьconvert_ids_to_tokensПреобразуйте эти индексы обратно в читаемые токены.

tokens = tokenizer.convert_ids_to_tokens(vars(train_data.examples[6])['text'])

print(tokens)
['f', '##ot', 'the', 'most', 'part', ',', 'this', 'movie', 'feels', 'like', 'a', '"', 'made', '-', 'for', '-', 'tv', '"', 'effort', '.', 'the', 'direction', 'is', 'ham', '-', 'fisted', ',', 'the', 'acting', '(', 'with', 'the', 'exception', 'of', 'fred', 'g', '##wyn', '##ne', ')', 'is', 'over', '##wr', '##ough', '##t', 'and', 'soap', '##y', '.', 'denise', 'crosby', ',', 'particularly', ',', 'delivers', 'her', 'lines', 'like', 'she', "'", 's', 'cold', 'reading', 'them', 'off', 'a', 'cue', 'card', '.', 'only', 'one', 'thing', 'makes', 'this', 'film', 'worth', 'watching', ',', 'and', 'that', 'is', 'once', 'gage', 'comes', 'back', 'from', 'the', '"', 'se', '##met', '##ary', '.', '"', 'there', 'is', 'something', 'disturbing', 'about', 'watching', 'a', 'small', 'child', 'murder', 'someone', ',', 'and', 'this', 'movie', 'might', 'be', 'more', 'than', 'some', 'can', 'handle', 'just', 'for', 'that', 'reason', '.', 'it', 'is', 'absolutely', 'bone', '-', 'chilling', '.', 'this', 'film', 'only', 'does', 'one', 'thing', 'right', ',', 'but', 'it', 'knocks', 'that', 'one', 'thing', 'right', 'out', 'of', 'the', 'park', '.', 'worth', 'seeing', 'just', 'for', 'the', 'last', '10', 'minutes', 'or', 'so', '.']

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

LABEL.build_vocab(train_data)
print(LABEL.vocab.stoi)
defaultdict(None, {'neg': 0, 'pos': 1})

Как и раньше, мы создаем итератор. Основываясь на прошлом опыте, использование наибольшего размера партии может обеспечить наилучшие результаты преобразователя, конечно, вы также можете попробовать использовать другие размеры партии, если ваша видеокарта лучше.

BATCH_SIZE = 128

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

train_iterator, valid_iterator, test_iterator = data.BucketIterator.splits(
    (train_data, valid_data, test_data), 
    batch_size = BATCH_SIZE, 
    device = device)

6.2 Построение модели

Далее мы импортируем предварительно обученную модель.

from transformers import BertTokenizer, BertModel

bert = BertModel.from_pretrained('bert-base-uncased')

image.png

Далее мы определим нашу фактическую модель.

Вместо использования слоя встраивания мы будем использовать предварительно обученную модель Transformer для получения встраивания текста. Затем эти вложения передаются в ГРУ для генерации прогнозов тональности входного предложения. Мы получаем размер встраивания от преобразователя через его свойство конфигурации (называемоеhidden_size). В остальном инициализация стандартная.

На прямом проходе мы заворачиваем трансформатор вno_grad, чтобы в этой части модели не вычислялись градиенты. Преобразователь фактически возвращает всю последовательность вложений иpooledвывод.Документация модели БертаОтметив, что объединенный вывод «обычно не является хорошим обобщением семантического содержания ввода, обычно лучше усреднять или объединять последовательность скрытых состояний во входной последовательности», поэтому мы не будем его использовать. Остальная часть прямого прохода — это стандартная реализация рекуррентных моделей, где мы берем скрытое состояние на последнем временном шаге и передаем его линейному слою, чтобы получить наши прогнозы.

import torch.nn as nn

class BERTGRUSentiment(nn.Module):
    def __init__(self,
                 bert,
                 hidden_dim,
                 output_dim,
                 n_layers,
                 bidirectional,
                 dropout):
        
        super().__init__()
        
        self.bert = bert
        
        embedding_dim = bert.config.to_dict()['hidden_size']
        
        self.rnn = nn.GRU(embedding_dim,
                          hidden_dim,
                          num_layers = n_layers,
                          bidirectional = bidirectional,
                          batch_first = True,
                          dropout = 0 if n_layers < 2 else dropout)
        
        self.out = nn.Linear(hidden_dim * 2 if bidirectional else hidden_dim, output_dim)
        
        self.dropout = nn.Dropout(dropout)
        
    def forward(self, text):
        
        #text = [batch size, sent len]
                
        with torch.no_grad():
            embedded = self.bert(text)[0]
                
        #embedded = [batch size, sent len, emb dim]
        
        _, hidden = self.rnn(embedded)
        
        #hidden = [n layers * n directions, batch size, emb dim]
        
        if self.rnn.bidirectional:
            hidden = self.dropout(torch.cat((hidden[-2,:,:], hidden[-1,:,:]), dim = 1))
        else:
            hidden = self.dropout(hidden[-1,:,:])
                
        #hidden = [batch size, hid dim]
        
        output = self.out(hidden)
        
        #output = [batch size, out dim]
        
        return output

Мы создаем экземпляр модели, используя стандартные гиперпараметры.

HIDDEN_DIM = 256
OUTPUT_DIM = 1
N_LAYERS = 2
BIDIRECTIONAL = True
DROPOUT = 0.25

model = BERTGRUSentiment(bert,
                         HIDDEN_DIM,
                         OUTPUT_DIM,
                         N_LAYERS,
                         BIDIRECTIONAL,
                         DROPOUT)

Мы можем проверить, сколько параметров у модели, наша стандартная модель имеет менее 5 млн параметров, а эта модель имеет 112 млн. К счастью, 110 млн из этих параметров поступают от преобразователя, нам больше не нужно их обучать.

def count_parameters(model):
    return sum(p.numel() for p in model.parameters() if p.requires_grad)

print(f'The model has {count_parameters(model):,} trainable parameters')
The model has 112,241,409 trainable parameters

Чтобы исправить параметры (без их обучения), нам нужно преобразовать ихrequires_gradсвойство установлено наFalse. Для этого мы просто перебираем всеnamed_parameters, если ониbertВ рамках модели преобразователя задаемrequires_grad = False, такие как тонкая настройка, она должна бытьrequires_gradУстановить какTrue

for name, param in model.named_parameters():                
    if name.startswith('bert'):
        param.requires_grad = False

Теперь мы можем видеть, что наша модель имеет менее 3 миллионов обучаемых параметров, что делает ее почти такой же хорошей, какFastTextмодель сравнима. Однако текст по-прежнему должен передаваться через преобразователь, из-за чего обучение занимает больше времени.

def count_parameters(model):
    return sum(p.numel() for p in model.parameters() if p.requires_grad)

print(f'The model has {count_parameters(model):,} trainable parameters')
The model has 2,759,169 trainable parameters

Мы можем перепроверить имена обучаемых параметров, чтобы убедиться, что они имеют смысл. Мы видим, что они оба ГРУ (rnn) и линейный слой (out) параметры.

for name, param in model.named_parameters():                
    if param.requires_grad:
        print(name)
rnn.weight_ih_l0
rnn.weight_hh_l0
rnn.bias_ih_l0
rnn.bias_hh_l0
rnn.weight_ih_l0_reverse
rnn.weight_hh_l0_reverse
rnn.bias_ih_l0_reverse
rnn.bias_hh_l0_reverse
rnn.weight_ih_l1
rnn.weight_hh_l1
rnn.bias_ih_l1
rnn.bias_hh_l1
rnn.weight_ih_l1_reverse
rnn.weight_hh_l1_reverse
rnn.bias_ih_l1_reverse
rnn.bias_hh_l1_reverse
out.weight
out.bias

6.3 Обучение модели

По соглашению мы строим собственные критерии оценки модели (функцию потерь), которая по-прежнему является бинарной классификацией.

import torch.optim as optim

optimizer = optim.Adam(model.parameters())
criterion = nn.BCEWithLogitsLoss()

Поместите модель и критерии оценки (функцию потерь) на GPU, если он у вас есть

model = model.to(device)
criterion = criterion.to(device)

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

def binary_accuracy(preds, y):
    """
    Returns accuracy per batch, i.e. if you get 8/10 right, this returns 0.8, NOT 8
    """

    #round predictions to the closest integer
    rounded_preds = torch.round(torch.sigmoid(preds))
    correct = (rounded_preds == y).float() #convert into float for division 
    acc = correct.sum() / len(correct)
    return acc
def train(model, iterator, optimizer, criterion):
    
    epoch_loss = 0
    epoch_acc = 0
    
    model.train()
    
    for batch in iterator:
        
        optimizer.zero_grad()
        
        predictions = model(batch.text).squeeze(1)
        
        loss = criterion(predictions, batch.label)
        
        acc = binary_accuracy(predictions, batch.label)
        
        loss.backward()
        
        optimizer.step()
        
        epoch_loss += loss.item()
        epoch_acc += acc.item()
        
    return epoch_loss / len(iterator), epoch_acc / len(iterator)
def evaluate(model, iterator, criterion):
    
    epoch_loss = 0
    epoch_acc = 0
    
    model.eval()
    
    with torch.no_grad():
    
        for batch in iterator:

            predictions = model(batch.text).squeeze(1)
            
            loss = criterion(predictions, batch.label)
            
            acc = binary_accuracy(predictions, batch.label)

            epoch_loss += loss.item()
            epoch_acc += acc.item()
        
    return epoch_loss / len(iterator), epoch_acc / len(iterator)
import time

def epoch_time(start_time, end_time):
    elapsed_time = end_time - start_time
    elapsed_mins = int(elapsed_time / 60)
    elapsed_secs = int(elapsed_time - (elapsed_mins * 60))
    return elapsed_mins, elapsed_secs

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

N_EPOCHS = 5

best_valid_loss = float('inf')

for epoch in range(N_EPOCHS):
    
    start_time = time.time()
    
    train_loss, train_acc = train(model, train_iterator, optimizer, criterion)
    valid_loss, valid_acc = evaluate(model, valid_iterator, criterion)
        
    end_time = time.time()
        
    epoch_mins, epoch_secs = epoch_time(start_time, end_time)
        
    if valid_loss < best_valid_loss:
        best_valid_loss = valid_loss
        torch.save(model.state_dict(), 'tut6-model.pt')
    
    print(f'Epoch: {epoch+1:02} | Epoch Time: {epoch_mins}m {epoch_secs}s')
    print(f'\tTrain Loss: {train_loss:.3f} | Train Acc: {train_acc*100:.2f}%')
    print(f'\t Val. Loss: {valid_loss:.3f} |  Val. Acc: {valid_acc*100:.2f}%')
Epoch: 01 | Epoch Time: 7m 13s
	Train Loss: 0.502 | Train Acc: 74.41%
	 Val. Loss: 0.270 |  Val. Acc: 89.15%
Epoch: 02 | Epoch Time: 7m 7s
	Train Loss: 0.281 | Train Acc: 88.49%
	 Val. Loss: 0.224 |  Val. Acc: 91.32%
Epoch: 03 | Epoch Time: 7m 17s
	Train Loss: 0.239 | Train Acc: 90.67%
	 Val. Loss: 0.211 |  Val. Acc: 91.91%
Epoch: 04 | Epoch Time: 7m 14s
	Train Loss: 0.206 | Train Acc: 91.81%
	 Val. Loss: 0.206 |  Val. Acc: 92.01%
Epoch: 05 | Epoch Time: 7m 15s
	Train Loss: 0.188 | Train Acc: 92.63%
	 Val. Loss: 0.211 |  Val. Acc: 91.92%

Мы загрузим параметры, которые дают нам наилучшие значения потерь на проверочном наборе, и применим эти параметры к тестовому набору — и достигнем оптимальных результатов на тестовом наборе.

model.load_state_dict(torch.load('tut6-model.pt'))

test_loss, test_acc = evaluate(model, test_iterator, criterion)

print(f'Test Loss: {test_loss:.3f} | Test Acc: {test_acc*100:.2f}%')
Test Loss: 0.209 | Test Acc: 91.58%

6.4 Проверка модели

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

def predict_sentiment(model, tokenizer, sentence):
    model.eval()
    tokens = tokenizer.tokenize(sentence)
    tokens = tokens[:max_input_length-2]
    indexed = [init_token_idx] + tokenizer.convert_tokens_to_ids(tokens) + [eos_token_idx]
    tensor = torch.LongTensor(indexed).to(device)
    tensor = tensor.unsqueeze(0)
    prediction = torch.sigmoid(model(tensor))
    return prediction.item()
predict_sentiment(model, tokenizer, "This film is terrible")
0.03391794115304947
predict_sentiment(model, tokenizer, "This film is great")
0.8869886994361877