«Анализ настроений НЛП» (5) - Анализ настроений CNN

NLP
«Анализ настроений НЛП» (5) - Анализ настроений CNN

4. Сверточный анализ настроений

В этом разделе мы будем использовать сверточные нейронные сети (CNN) для анализа настроений, реализуяConvolutional Neural Networks for Sentence Classificationмодель в .

Примечание: Для получения подробной информации о CNN, пожалуйста, проверьтездесьиздесь.

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

Так зачем использовать сверточные нейронные сети для текста? Точно так же, как фильтр 3x3 может просматривать блоки изображений, фильтр 1x2 может просматривать два последовательных слова или двойные символы в фрагменте текста. В последнем уроке мы рассмотрели модель FastText, которая использует биграммы, явно добавляя их в конец текста, в этой модели CNN мы будем использовать несколько фильтров разных размеров, которые будут просматривать биграммы (фильтр 1x2), триграммы (фильтр 1x3) и/или n-граммы (фильтр 1x) в текстеnnфильтр).

4.1 Предварительная обработка данных

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

import torch
from torchtext.legacy import data
from torchtext.legacy import datasets
import random
import numpy as np

SEED = 1234

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

TEXT = data.Field(tokenize = 'spacy', 
                  tokenizer_language = 'en_core_web_sm',
                  batch_first = True)
LABEL = data.LabelField(dtype = torch.float)

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

train_data, valid_data = train_data.split(random_state = random.seed(SEED))

Создайте словарный запас и загрузите предварительно обученные вложения слов:

MAX_VOCAB_SIZE = 25_000

TEXT.build_vocab(train_data, 
                 max_size = MAX_VOCAB_SIZE, 
                 vectors = "glove.6B.100d", 
                 unk_init = torch.Tensor.normal_)

LABEL.build_vocab(train_data)

Создайте итератор:

BATCH_SIZE = 64

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)

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

Начинайте строить модель!

Первая серьезная проблема заключается в том, как использовать CNN для текста. Изображения обычно двумерные, а текст одномерный. Таким образом, мы можем расширить каждое слово в фрагменте текста по одной оси, а элементы вектора — по другому измерению. Например, рассмотрим встроенное предложение из следующих двух предложений:

image.png

Тогда мы можем использовать[n x emb_dim]фильтр. это полностью покроетnnслова, потому что их ширинаemb_dimразмер. Рассмотрим изображение ниже, наши векторы слов представлены зеленым цветом. Здесь у нас есть 4 слова и 5-мерные вложения, создающие тензор «изображения» [4x5]. Фильтр, охватывающий два слова за раз (т.е. биграммы), будет[2x5]фильтр, показанный желтым цветом, каждый элемент фильтра имеетweight. Выход этого фильтра (показан красным) будет действительным числом, представляющим собой взвешенную сумму всех элементов, покрываемых фильтром.

image.png

Затем отфильтруйте «вниз», чтобы сместить изображение (или по предложению), чтобы покрыть следующую биграмму, и вычислите другой результат (взвешенную сумму).

image.png

Наконец, фильтр снова перемещается вниз и вычисляется конечный результат этого фильтра.

image.png

В общем, ширина фильтра равна ширине «изображения», а на выходе мы получаем вектор с количеством элементов, равным высоте изображения (или длине слова) минус высота фильтр плюс один. В текущем примере42+1=34-2+1=3.

В приведенном выше примере показано, как рассчитать выходные данные фильтра. В нашей модели (и почти во всех CNN) таких фильтров много. Идея состоит в том, что каждый фильтр будет изучать различные функции для извлечения. В приведенном выше примере мы хотим[2 x emb_dim]Каждый из фильтров ищет вхождения различных биграмм.

В нашей модели также есть фильтры разных размеров, высоты 3, 4 и 5, по 100 фильтров в каждом. Мы будем искать различные 3-граммы, 4-граммы и 5-граммы, которые имеют отношение к анализу настроений рецензий на фильмы.

Следующим шагом в нашей модели является использование пула (в частности, максимального пула) на выходе сверточного слоя. Это похоже на модель FastText, разница в том, что в этой модели мы вычисляем его максимальное значение вместо усреднения каждого вектора слова в модели FastText, Следующий пример представляет собой максимальное значение вектора, полученного на выходе слоя свертки (0,9).

image.png

Максимальное значение — это «самая важная» характеристика в анализе тональности текста, соответствующая «самой важной» n-грамме в обзорах. Поскольку наша модель имеет 100 фильтров 3 разных размеров, это означает, что у нас есть 300 различных n-грамм, которые модель считает важными. Мы объединяем их в вектор и пропускаем через линейный слой, чтобы предсказать окончательное настроение. Мы можем взять веса этого линейного слоя в качестве весов для «взвешивания доказательств» и сделать окончательный прогноз, синтезировав 300 n-граммов.

Детали реализации

1. Мы используемnn.Conv2dРеализуйте сверточные слои.in_channelsПараметр — это количество «каналов» в изображении, которые входят в сверточный слой. В реальных изображениях обычно есть 3 канала (по одному для красного, синего и зеленого каналов), но при работе с текстом у нас есть только один канал, сам текст.out_channelsколичество фильтров,kernel_sizeразмер фильтров. Каждый из наших «размеров ядра» будет[n x emb_dim]вnnэто размер n-грамм.

2. После этого мы пропускаем тензоры через сверточный слой и слой объединения и используем функцию активации «ReLU» после сверточный слой. Еще одно приятное свойство объединенных слоев заключается в том, что они могут обрабатывать предложения разной длины. В то время как выходной размер сверточного слоя зависит от размера входных данных, разные пакеты содержат предложения разной длины. Без слоя максимального объединения вход линейного слоя будет зависеть от длины входного предложения, чтобы избежать этого, мы обрезаем/дополняем все предложения до одинаковой длины, но для линейного слоя вход линейного слоя равен всегда фильтровать итог.

Примечание: если длина предложения меньше максимального фильтра, установленного экспериментом, то предложение должно быть дополнено до длины максимального фильтра. Этого нет в данных IMDb, так что нам не о чем беспокоиться.

3. Наконец, мы выполняем операцию исключения выходных данных объединенного фильтра и пропускаем их через линейный слой для прогнозирования.

import torch.nn as nn
import torch.nn.functional as F

class CNN(nn.Module):
    def __init__(self, vocab_size, embedding_dim, n_filters, filter_sizes, output_dim, 
                 dropout, pad_idx):
        
        super().__init__()
        
        self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx = pad_idx)
        
        self.conv_0 = nn.Conv2d(in_channels = 1, 
                                out_channels = n_filters, 
                                kernel_size = (filter_sizes[0], embedding_dim))
        
        self.conv_1 = nn.Conv2d(in_channels = 1, 
                                out_channels = n_filters, 
                                kernel_size = (filter_sizes[1], embedding_dim))
        
        self.conv_2 = nn.Conv2d(in_channels = 1, 
                                out_channels = n_filters, 
                                kernel_size = (filter_sizes[2], embedding_dim))
        
        self.fc = nn.Linear(len(filter_sizes) * n_filters, output_dim)
        
        self.dropout = nn.Dropout(dropout)
        
    def forward(self, text):
                
        #text = [batch size, sent len]
        
        embedded = self.embedding(text)
                
        #embedded = [batch size, sent len, emb dim]
        
        embedded = embedded.unsqueeze(1)
        
        #embedded = [batch size, 1, sent len, emb dim]
        
        conved_0 = F.relu(self.conv_0(embedded).squeeze(3))
        conved_1 = F.relu(self.conv_1(embedded).squeeze(3))
        conved_2 = F.relu(self.conv_2(embedded).squeeze(3))
            
        #conved_n = [batch size, n_filters, sent len - filter_sizes[n] + 1]
        
        pooled_0 = F.max_pool1d(conved_0, conved_0.shape[2]).squeeze(2)
        pooled_1 = F.max_pool1d(conved_1, conved_1.shape[2]).squeeze(2)
        pooled_2 = F.max_pool1d(conved_2, conved_2.shape[2]).squeeze(2)
        
        #pooled_n = [batch size, n_filters]
        
        cat = self.dropout(torch.cat((pooled_0, pooled_1, pooled_2), dim = 1))

        #cat = [batch size, n_filters * len(filter_sizes)]
            
        return self.fc(cat)

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

class CNN(nn.Module):
    def __init__(self, vocab_size, embedding_dim, n_filters, filter_sizes, output_dim, 
                 dropout, pad_idx):
        
        super().__init__()
                
        self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx = pad_idx)
        
        self.convs = nn.ModuleList([
                                    nn.Conv2d(in_channels = 1, 
                                              out_channels = n_filters, 
                                              kernel_size = (fs, embedding_dim)) 
                                    for fs in filter_sizes
                                    ])
        
        self.fc = nn.Linear(len(filter_sizes) * n_filters, output_dim)
        
        self.dropout = nn.Dropout(dropout)
        
    def forward(self, text):
                
        #text = [batch size, sent len]
        
        embedded = self.embedding(text)
                
        #embedded = [batch size, sent len, emb dim]
        
        embedded = embedded.unsqueeze(1)
        
        #embedded = [batch size, 1, sent len, emb dim]
        
        conved = [F.relu(conv(embedded)).squeeze(3) for conv in self.convs]
            
        #conved_n = [batch size, n_filters, sent len - filter_sizes[n] + 1]
                
        pooled = [F.max_pool1d(conv, conv.shape[2]).squeeze(2) for conv in conved]
        
        #pooled_n = [batch size, n_filters]
        
        cat = self.dropout(torch.cat(pooled, dim = 1))

        #cat = [batch size, n_filters * len(filter_sizes)]
            
        return self.fc(cat)

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

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

class CNN1d(nn.Module):
    def __init__(self, vocab_size, embedding_dim, n_filters, filter_sizes, output_dim, 
                 dropout, pad_idx):
        
        super().__init__()
        
        self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx = pad_idx)
        
        self.convs = nn.ModuleList([
                                    nn.Conv1d(in_channels = embedding_dim, 
                                              out_channels = n_filters, 
                                              kernel_size = fs)
                                    for fs in filter_sizes
                                    ])
        
        self.fc = nn.Linear(len(filter_sizes) * n_filters, output_dim)
        
        self.dropout = nn.Dropout(dropout)
        
    def forward(self, text):
        
        #text = [batch size, sent len]
        
        embedded = self.embedding(text)
                
        #embedded = [batch size, sent len, emb dim]
        
        embedded = embedded.permute(0, 2, 1)
        
        #embedded = [batch size, emb dim, sent len]
        
        conved = [F.relu(conv(embedded)) for conv in self.convs]
            
        #conved_n = [batch size, n_filters, sent len - filter_sizes[n] + 1]
        
        pooled = [F.max_pool1d(conv, conv.shape[2]).squeeze(2) for conv in conved]
        
        #pooled_n = [batch size, n_filters]
        
        cat = self.dropout(torch.cat(pooled, dim = 1))
        
        #cat = [batch size, n_filters * len(filter_sizes)]
            
        return self.fc(cat)

созданныйCNNЭкземпляр класса.

Если мы хотим запустить одномерную сверточную модель, мы можем поставитьCNNизменить наCNN1d, обратите внимание, что обе модели дают почти одинаковые результаты.

INPUT_DIM = len(TEXT.vocab)
EMBEDDING_DIM = 100
N_FILTERS = 100
FILTER_SIZES = [3,4,5]
OUTPUT_DIM = 1
DROPOUT = 0.5
PAD_IDX = TEXT.vocab.stoi[TEXT.pad_token]

model = CNN(INPUT_DIM, EMBEDDING_DIM, N_FILTERS, FILTER_SIZES, OUTPUT_DIM, DROPOUT, PAD_IDX)

Проверяя количество параметров в нашей модели, мы видим, что оно примерно такое же, как и в модели FastText.

Модели «CNN» и «CNN1d» имеют одинаковое количество параметров.

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,620,801 trainable parameters

Затем загрузите предварительно обученные вложения слов.

pretrained_embeddings = TEXT.vocab.vectors

model.embedding.weight.data.copy_(pretrained_embeddings)
tensor([[-0.1117, -0.4966,  0.1631,  ...,  1.2647, -0.2753, -0.1325],
        [-0.8555, -0.7208,  1.3755,  ...,  0.0825, -1.1314,  0.3997],
        [-0.0382, -0.2449,  0.7281,  ..., -0.1459,  0.8278,  0.2706],
        ...,
        [ 0.6783,  0.0488,  0.5860,  ...,  0.2680, -0.0086,  0.5758],
        [-0.6208, -0.0480, -0.1046,  ...,  0.3718,  0.1225,  0.1061],
        [-0.6553, -0.6292,  0.9967,  ...,  0.2278, -0.1975,  0.0857]])

Затем начальные веса неизвестных маркеров и маркеров заполнения обнуляются.

UNK_IDX = TEXT.vocab.stoi[TEXT.unk_token]

model.embedding.weight.data[UNK_IDX] = torch.zeros(EMBEDDING_DIM)
model.embedding.weight.data[PAD_IDX] = torch.zeros(EMBEDDING_DIM)

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

Обучение такое же, как и предыдущее задание, мы инициализируем оптимизатор, функцию потерь (эталон) и размещаем модель и эталон на графическом процессоре.

import torch.optim as optim

optimizer = optim.Adam(model.parameters())

criterion = nn.BCEWithLogitsLoss()

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

Определена функция для обучения нашей модели:

Уведомление: Поскольку отсев используется снова, мы должны помнить об использованииmodel.train()чтобы гарантировать, что отсев может быть использован во время обучения.

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)

Определена функция для проверки нашей модели:

Уведомление: Опять же, поскольку мы используем отсев, мы должны помнить об использованииmodel.eval()чтобы гарантировать, что отсев может быть отключен при оценке.

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(), 'tut4-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}%')

image.png

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

model.load_state_dict(torch.load('tut4-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.343 | Test Acc: 85.31%

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

import spacy
nlp = spacy.load('en_core_web_sm')

def predict_sentiment(model, sentence, min_len = 5):
    model.eval()
    tokenized = [tok.text for tok in nlp.tokenizer(sentence)]
    if len(tokenized) < min_len:
        tokenized += ['<pad>'] * (min_len - len(tokenized))
    indexed = [TEXT.vocab.stoi[t] for t in tokenized]
    tensor = torch.LongTensor(indexed).to(device)
    tensor = tensor.unsqueeze(0)
    prediction = torch.sigmoid(model(tensor))
    return prediction.item()

Примеры негативных отзывов:

predict_sentiment(model, "This film is terrible")
0.09913548082113266

Примеры положительных отзывов:

predict_sentiment(model, "This film is great")
0.9769725799560547

резюме

В следующем разделе мы узнаем о многотипном анализе настроений.