10 min read

Анализ тональности текста (Sentiment Analysis)

Анализ тональности текста (Sentiment Analysis)
Фото: verdian chua / Unsplash

Анализ тональности текста (анализ настроений, анализ мнений) — это классическая задача Машинного обучения (ML), позволяющая идентифицировать, извлекать и количественно оценивать текстовые данные для облегчения Классификации (Classification) и работы с ними. К примеру, если речь идет о Текстовом блоке (Corpus) комментариев к продуктам, Sentiment Analysis способен, среди прочих, с помощью определения эмоциональной окраски:

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

Это измеримый способ понять и проанализировать общественное восприятие различных идей и концепций или недавно запущенного продукта.

Мы используем различные инструменты Обработки естественного языка (NLP) и анализа текста, чтобы выяснить, что может быть субъективной информацией. Нам нужно идентифицировать, извлечь и количественно оценить такие детали из текста для облегчения классификации и работы с данными.

Понимание эмоций и анализ настроений играют огромную роль в системах рекомендаций на основе совместной фильтрации. Группировка людей, имеющих сходную реакцию на определенный продукт, и показ им похожих продуктов. Например, рекомендовать фильмы людям, группируя их с другими людьми, имеющими схожие взгляды на определенное шоу или фильм.

Как работает анализ тональности?

Обработка естественного языка — это основная концепция, на которой строится анализ настроений. Это ветвь Искусственного интеллекта (AI), работающая с текстами, дающая машинам возможность "понимать" предложения и использовать эту способность.

В зависимости от объема текстового блока Sentiment Analysis можно разделить на анализ на уровне документа, предложения и фразы.

Выделяют два основных метода анализа мнений:

  • Анализ тональности на основе словарей эмоций: существует предопределенный список слов для каждого настроения; текст или документ сопоставляют с такими списками. Затем алгоритм определяет, какой тип слов или какая Полярность (Polarity) преобладает в нем. Этот тип анализа настроений на основе правил прост в реализации, но ему не хватает гибкости и он не учитывает контекст.
  • Автоматический анализ тональности в основном основан на контролируемых алгоритмах Машинного обучения и на самом деле очень полезен для понимания сложных текстов. Алгоритмы в этой категории включают Метод опорных векторов (SVM), Линейную регрессию (Linear Regression), Рекуррентную нейросеть (RNN) и ее подвиды. В этой статье мы будем использовать рекуррентную нейронную сеть.

Система анализа настроений

Рекуррентные нейронные сети – это еще и способ превратить текстовые данные в последовательные, то есть такие, что каждая последующая запись зависит от предыдущей. Рекуррентные нейронные сети имеют собственную память и запоминают входные данные, которые были переданы каждому узлу. В Нейронных сетях прямого распространения (Feed Forward Neural Network) информация – входные данные, перемещается вперед и никогда не перемещаются назад ни в каких узлах. Поскольку у таких сетей нет памяти, они не запоминают предыдущие вводы:

RNN же учитывает, какие вводные данные она получила, также как и предыдущие итерации ввода. Рекуррентная нейронная сеть создает копию выходных данных и зацикливает ее отправку обратно.

Например, когда предложение передается через нейросеть прямого распределения, она принимает слово за словом, пока не достигнет последнего. Она не помнит, что выдавали до этого. Но РНН также знает предыдущие входные данные и, таким образом, может предсказать, что может произойти дальше. Идеально для последовательных данных!

Рекуррентные нейронные сети не новы, они были впервые представлены в 1980-х годах, но стали очень популярными с ростом Глубокого обучения (Deep Learning) и его использования в последовательных данных. Тем не менее, у Рекуррентных нейросетей есть свой собственный набор проблем, основной из которых является Проблема исчезающего градиента (Vanishing Gradient Problem).

Ответом на этот вопрос является Долгая краткосрочная память (LSTM). Это особый тип модели RNN, который может изучать долгосрочные зависимости. Она сделана, чтобы "помнить" долгосрочные данные. Что делает LSTM особенной, так это дополнительная ячейка памяти, которая является повторяющимся состоянием, и каждая из них имеет несколько Гейтов (Gate), которые контролируют поток информации в ячейку памяти и из нее:

Входной, выходной гейты и гейт "забывания"

Входные ворота (Input Gate) используются для обновления состояния ячейки, Гейт забывания (Forget Gate) решает, какую информацию следует сохранить, а какую следует отбросить. Выходной гейт (Output Gate) определяет значения для следующих скрытых гейтов.

Анализ настроений: PyTorch

Теперь, когда у нас есть общее представление о концепции, попробуем реализовать такую модель с помощью PyTorch. Мы создадим простой классификатор тональности, который будет классифицировать, были ли отзывы пользователей о фильме на IMDB положительными, отрицательными или нейтральными.

Датасет (Dataset), который мы будем использовать, представляет собой 50 000 отзывов на фильмы. Это набор данных для Двоичной классификации (Binary Classification), в котором каждый обзор классифицируется как положительный или отрицательный.

Установим библиотеку torchtext, из которой и возьмем датасет:

!pip install torchtext==0.10.0

Импортируем необходимые библиотеки:

import random
import spacy
import time

import torch
import torch.nn as nn
import torch.optim as optim
import torchtext
from torchtext.legacy import data
from torchtext.legacy import datasets

Мы собираемся использовать метод data.field, чтобы решить, как данные должны быть предварительно обработаны. Параметры, которые мы туда передаем, будут определять предварительную обработку:

seed = 42

torch.manual_seed(seed)
torch.backends.cudnn.deterministic = True
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

txt = data.Field(tokenize = 'spacy',
                  tokenizer_language = 'en_core_web_sm',
                  include_lengths = True)

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

Первый параметр, наш tokenizer_language (который определяет, как предложения будут разбиты на токены) — это модуль en_core_web_sm для английского языка библиотеки spacy. По умолчанию он просто разделит текст с помощью пробелов.

Мы также устанавливаем случайное начальное число seed, которое может принимать любое значение, но мы упоминаем его только для целей воспроизводимости (чтобы и при верстке статьи, и при самостоятельном запуске кода читателем результаты были идентичны). Вы можете изменить его или даже опустить без какого-либо существенного эффекта.

Мы также будем использовать cuda, а также доступный нам Центральный процессор (CPU).

Загрузим набор данных и разделим его на Тренировочные (Train Data) и Тестовые данные (Test Data). Набор уже разделен.

train_data, test_data = datasets.IMDB.splits(txt, labels)

Далее мы выделим из тренировочных данных еще и Валидационные данные (Validation Data):

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

Мы дополнительно ограничим количество слов, которые освоит модель, до 25000:

num_words = 25_000

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

labels.build_vocab(train_data)

Это позволит выбрать наиболее часто используемые 25000 слов из набора данных и использовать их для обучения. Так мы значительно сократим работу модели без реальной потери точности.

Воспользуемся преимуществами Пакетов (Batch), то есть разделим данные на части по 64 записи. Если этого не сделать, памяти не хватит:

btch_size = 64

train_itr, valid_itr, test_itr = data.BucketIterator.splits(
    (train_data, valid_data, test_data), 
    batch_size = btch_size,
    sort_within_batch = True,
    device = device)

Теперь подготовим модель и определим ее архитектуру. Мы используем многослойный двунаправленный LSTM RNN для нашей задачи. Это означает, что будет несколько слоев нейросети, наложенных друг на друга.

Двунаправленная RNN имеют то преимущество, что охватывает больше контекста, чем однонаправленная сеть. Например, если модель должна угадать следующее слово в предложении, она сделает это на основе предыдущих знаний. Но в двунаправленной сети он также будет знать, что будет дальше, благодаря двум сетям, текущим как бы в противоположных направлениях и наложенных друг на друга. Предложение «я люблю машинное обучение» будет в первой сети выглядеть как "я", "люблю", "машинное", "обучение" а во второй — как "обучение", "машинное", "люблю", "я". Это обеспечивает более полный контекст, хоть и воспринимается как некое мошенничество.

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

class RNN(nn.Module):
    def __init__(self, word_limit, dimension_embedding, dimension_hidden, dimension_output, num_layers, 
                 bidirectional, dropout, pad_idx):
        
        super().__init__()
        
        self.embedding = nn.Embedding(word_limit, dimension_embedding, padding_idx=pad_idx)
        
        self.rnn = nn.LSTM(dimension_embedding, 
                           dimension_hidden, 
                           num_layers=num_layers, 
                           bidirectional=bidirectional, 
                           dropout=dropout)
        
        self.fc = nn.Linear(dimension_hidden * 2, dimension_output)
        
        self.dropout = nn.Dropout(dropout)
        

    def forward(self, text, len_txt):
        
        embedded = self.dropout(self.embedding(text))
               

        packed_embedded = nn.utils.rnn.pack_padded_sequence(embedded, len_txt.to('cpu'))
        
        packed_output, (hidden, cell) = self.rnn(packed_embedded)
        
        output, output_lengths = nn.utils.rnn.pad_packed_sequence(packed_output)

        
        hidden = self.dropout(torch.cat((hidden[-2,:,:], hidden[-1,:,:]), dim = 1))
                            
        return self.fc(hidden)

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

Теперь мы зададим явно некоторые настройки нашей модели:

dimension_input = len(txt.vocab)
dimension_embedding = 100
dimension_hddn = 256
dimension_out = 1
layers = 2
bidirectional = True
droupout = 0.5
idx_pad = txt.vocab.stoi[txt.pad_token]

model = RNN(dimension_input, 
            dimension_embedding, 
            dimension_hddn, 
            dimension_out, 
            layers, 
            bidirectional, 
            droupout, 
            idx_pad)

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

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

print(f'Модель обладает {count_parameters(model):,} тренируемыми параметрами')
>>> Модель обладает 4,810,857 тренируемыми параметрами

Предварительно обученные встраивающие веса размещаются вместо исходных:

pretrained_embeddings = txt.vocab.vectors

print(pretrained_embeddings.shape)
>>> torch.Size([25002, 100])
model.embedding.weight.data.copy_(pretrained_embeddings)
>>> tensor([[ 1.9269,  1.4873,  0.9007,  ...,  0.1233,  0.3499,  0.6173],
        [ 0.7262,  0.0912, -0.3891,  ...,  0.0821,  0.4440, -0.7240],
        [-0.0382, -0.2449,  0.7281,  ..., -0.1459,  0.8278,  0.2706],
        ...,
        [ 0.2735, -0.1130,  0.2871,  ..., -0.8155, -0.0639,  0.9330],
        [-1.1777, -0.1115, -0.1409,  ...,  0.8815,  0.1093,  1.1222],
        [-0.8087,  0.4473,  0.0443,  ..., -1.2134,  0.4822,  0.0481]])
unique_id = txt.vocab.stoi[txt.unk_token]

model.embedding.weight.data[unique_id] = torch.zeros(dimension_embedding)
model.embedding.weight.data[idx_pad] = torch.zeros(dimension_embedding)

print(model.embedding.weight.data)
>>> tensor([[ 0.0000,  0.0000,  0.0000,  ...,  0.0000,  0.0000,  0.0000],
        [ 0.0000,  0.0000,  0.0000,  ...,  0.0000,  0.0000,  0.0000],
        [-0.0382, -0.2449,  0.7281,  ..., -0.1459,  0.8278,  0.2706],
        ...,
        [ 0.2735, -0.1130,  0.2871,  ..., -0.8155, -0.0639,  0.9330],
        [-1.1777, -0.1115, -0.1409,  ...,  0.8815,  0.1093,  1.1222],
        [-0.8087,  0.4473,  0.0443,  ..., -1.2134,  0.4822,  0.0481]])

Теперь мы определяем некоторые параметры модели, то есть Оптимизатор (Optimizer), который мы собираемся использовать, и критерий Функции потери (Loss Function), который нам подходит. Мы выбрали Адаптивную оценку момента (Adam) для быстрой Сходимости (Convergence) модели. Размещаем модель и критерий на Графическом процессоре (GPU):

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

Теперь мы приступаем к необходимым функциям для обучения и оценки модели анализа настроений.

criterion = nn.BCEWithLogitsLoss()

model = model.to(device)
criterion = criterion.to(device)
def bin_acc(preds, y):
   
    predictions = torch.round(torch.sigmoid(preds))
    correct = (predictions == y).float() 
    acc = correct.sum() / len(correct)
    return acc

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

def train(model, itr, optimizer, criterion):
    
    epoch_loss = 0
    epoch_acc = 0
    
    model.train()
    
    for i in itr:
        
        optimizer.zero_grad()
        
        text, len_txt = i.text
        
        predictions = model(text, len_txt).squeeze(1)
        
        loss = criterion(predictions, i.label)
        
        acc = bin_acc(predictions, i.label)
        
        loss.backward()
        
        optimizer.step()
        
        epoch_loss += loss.item()
        epoch_acc += acc.item()
        
    return epoch_loss / len(itr), epoch_acc / len(itr)

Определим функцию train для обучения и оценки моделей. Мы начинаем с циклического перебора количества Эпох (Epoch). Число итераций в каждой эпохе зависит от размера пакета, который мы задали равным 64. Мы передаем текст в модель, получаем от нее прогнозы, вычисляем потери для каждой итерации, а затем используем Метод обратного распространения ошибки (Backpropagation).

Единственное существенное изменение в функции оценки по сравнению с функцией обучения заключается в том, что мы не распространяем потери в обратном направлении по модели и используем torch.nograd, что в основном означает отсутствие Градиентного спуска (Gradient Descent) при оценке.

def evaluate(model, itr, criterion):
    
    epoch_loss = 0
    epoch_acc = 0
    
    model.eval()
    
    with torch.no_grad():
    
        for i in itr:

            text, len_txt = i.text
            
            predictions = model(text, len_txt).squeeze(1)
            
            loss = criterion(predictions, i.label)
            
            acc = bin_acc(predictions, i.label)

            epoch_loss += loss.item()
            epoch_acc += acc.item()
        
    return epoch_loss / len(itr), epoch_acc / len(itr)
def epoch_time(start_time, end_time):
    used_time = end_time - start_time
    used_mins = int(used_time / 60)
    used_secs = int(used_time - (used_mins * 60))
    return used_mins, used_secs

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

num_epochs = 5

best_valid_loss = float('inf')

for epoch in range(num_epochs):

    start_time = time.time()
    
    train_loss, train_acc = train(model, train_itr, optimizer, criterion)
    valid_loss, valid_acc = evaluate(model, valid_itr, 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(), 'tut2-model.pt')
    
    print(f'Эпоха: {epoch+1:02} | Время на эпоху: {epoch_mins}m {epoch_secs}s')
    print(f'\tТренировочные потери: {train_loss:.3f} | Тренировочная точность: {train_acc*100:.2f}%')
    print(f'\t Валидационные потери: {valid_loss:.3f} |  Валидационная точность: {valid_acc*100:.2f}%')

Мы устанавливаем количество эпох равным 5, а затем начинаем наше обучение. Отобразим потери при обучении и проверке на каждом этапе, если нам нужно понять или построить кривую обучения на более позднем этапе. Мы сохраняем ту модель, которая имеет наименьшие потери при Валидации (Validation).

>>>
Эпоха: 01 | Время на эпоху: 0m 35s
	Тренировочные потери: 0.649 | Тренировочная точность: 61.34%
	 Валидационные потери: 0.583 |  Валидационная точность: 72.39%
Эпоха: 02 | Время на эпоху: 0m 36s
	Тренировочные потери: 0.561 | Тренировочная точность: 71.81%
	 Валидационные потери: 0.460 |  Валидационная точность: 79.26%
Эпоха: 03 | Время на эпоху: 0m 38s
	Тренировочные потери: 0.549 | Тренировочная точность: 71.97%
	 Валидационные потери: 0.358 |  Валидационная точность: 84.73%
Эпоха: 04 | Время на эпоху: 0m 38s
	Тренировочные потери: 0.432 | Тренировочная точность: 80.96%
	 Валидационные потери: 0.353 |  Валидационная точность: 85.52%
Эпоха: 05 | Время на эпоху: 0m 38s
	Тренировочные потери: 0.313 | Тренировочная точность: 87.03%
	 Валидационные потери: 0.299 |  Валидационная точность: 87.52%

Загружаем сохраненную контрольную точку модели и тестируем ее на созданном ранее тестовом наборе:

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

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

print(f'Тестовые потери: {test_loss:.3f} | Тестовая точность: {test_acc*100:.2f}%')

Во время пробного запуска модели анализа настроений Python мы достигли приличной оценки точности 87.42%:

>>> Тестовые потери: 0.301 | Тестовая точность: 87.42%

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

nlp = spacy.load('en_core_web_sm')

def pred(model, sentence):
    model.eval()
    tokenized = [tok.text for tok in nlp.tokenizer(sentence)]
    indexed = [txt.vocab.stoi[t] for t in tokenized]
    length = [len(indexed)]
    tensor = torch.LongTensor(indexed).to(device)
    tensor = tensor.unsqueeze(1)
    length_tensor = torch.LongTensor(length)
    prediction = torch.sigmoid(model(tensor, length_tensor))
    return prediction.item()

Мы загружаем англоязычный модуль spacy для токенизации данных, которые нам нужно передать модели. Вначале мы использовали встроенный torch.text, но здесь мы не используем пакеты, и предварительная обработка, которую нам нужно сделать, может быть выполнена библиотекой spacy. Для этого мы определяем функцию pred прогнозирования тональности. После предварительной обработки мы конвертируем данные в Тензоры (Tensor) и готовим их к передаче в модель.

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

sent = ["Положительный", "Нейтральный", "Отрицательный"]
def print_sent(x):
  if (x < 0.3): print(sent[0])
  elif (x > 0.3 and x < 0.7): print(sent[1])
  else: print(sent[2])

Теперь мы просто передаем любые данные и проверяем, что об этом думает модель:

print_sent(pred(model, "This film was average"))
print_sent(pred(model, "This film is horrible"))
print_sent(pred(model, "This film was great"))
print_sent(pred(model, "This was the best movie i have seen in a while. The cast was great and the script was awesome, and the direction just blew my mind"))
>>>
Нейтральный
Отрицательный
Положительный
Положительный

Мы успешно разработали модель анализа тональности Python, основанную на LSTM, которая является довольно надежной и очень точной.

Ноутбук, не требующий дополнительной настройки на момент написания статьи, можно скачать здесь.

Автор оригинальной статьи: data-flair.training