4. Сверточный анализ настроений
В этом разделе мы будем использовать сверточные нейронные сети (CNN) для анализа настроений, реализуяConvolutional Neural Networks for Sentence Classificationмодель в .
Примечание: Для получения подробной информации о CNN, пожалуйста, проверьтездесьиздесь.
Сверточные нейронные сети преуспевают в задачах компьютерного зрения благодаря своей способности извлекать признаки из локальных фрагментов входного изображения, модулировать представления и эффективно использовать данные. Точно так же сверточные нейронные сети также можно использовать для обработки данных последовательности, где время можно рассматривать как пространственное измерение, такое как высота и ширина 2D-изображения.
Так зачем использовать сверточные нейронные сети для текста? Точно так же, как фильтр 3x3 может просматривать блоки изображений, фильтр 1x2 может просматривать два последовательных слова или двойные символы в фрагменте текста. В последнем уроке мы рассмотрели модель FastText, которая использует биграммы, явно добавляя их в конец текста, в этой модели CNN мы будем использовать несколько фильтров разных размеров, которые будут просматривать биграммы (фильтр 1x2), триграммы (фильтр 1x3) и/или n-граммы (фильтр 1x) в текстефильтр).
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 для текста. Изображения обычно двумерные, а текст одномерный. Таким образом, мы можем расширить каждое слово в фрагменте текста по одной оси, а элементы вектора — по другому измерению. Например, рассмотрим встроенное предложение из следующих двух предложений:
Тогда мы можем использовать[n x emb_dim]фильтр. это полностью покроетслова, потому что их ширинаemb_dim
размер. Рассмотрим изображение ниже, наши векторы слов представлены зеленым цветом. Здесь у нас есть 4 слова и 5-мерные вложения, создающие тензор «изображения» [4x5]. Фильтр, охватывающий два слова за раз (т.е. биграммы), будет[2x5]фильтр, показанный желтым цветом, каждый элемент фильтра имеетweight. Выход этого фильтра (показан красным) будет действительным числом, представляющим собой взвешенную сумму всех элементов, покрываемых фильтром.
Затем отфильтруйте «вниз», чтобы сместить изображение (или по предложению), чтобы покрыть следующую биграмму, и вычислите другой результат (взвешенную сумму).
Наконец, фильтр снова перемещается вниз и вычисляется конечный результат этого фильтра.
В общем, ширина фильтра равна ширине «изображения», а на выходе мы получаем вектор с количеством элементов, равным высоте изображения (или длине слова) минус высота фильтр плюс один. В текущем примере.
В приведенном выше примере показано, как рассчитать выходные данные фильтра. В нашей модели (и почти во всех CNN) таких фильтров много. Идея состоит в том, что каждый фильтр будет изучать различные функции для извлечения. В приведенном выше примере мы хотим[2 x emb_dim]Каждый из фильтров ищет вхождения различных биграмм.
В нашей модели также есть фильтры разных размеров, высоты 3, 4 и 5, по 100 фильтров в каждом. Мы будем искать различные 3-граммы, 4-граммы и 5-граммы, которые имеют отношение к анализу настроений рецензий на фильмы.
Следующим шагом в нашей модели является использование пула (в частности, максимального пула) на выходе сверточного слоя. Это похоже на модель FastText, разница в том, что в этой модели мы вычисляем его максимальное значение вместо усреднения каждого вектора слова в модели FastText, Следующий пример представляет собой максимальное значение вектора, полученного на выходе слоя свертки (0,9).
Максимальное значение — это «самая важная» характеристика в анализе тональности текста, соответствующая «самой важной» n-грамме в обзорах. Поскольку наша модель имеет 100 фильтров 3 разных размеров, это означает, что у нас есть 300 различных n-грамм, которые модель считает важными. Мы объединяем их в вектор и пропускаем через линейный слой, чтобы предсказать окончательное настроение. Мы можем взять веса этого линейного слоя в качестве весов для «взвешивания доказательств» и сделать окончательный прогноз, синтезировав 300 n-граммов.
Детали реализации
1. Мы используемnn.Conv2d
Реализуйте сверточные слои.in_channels
Параметр — это количество «каналов» в изображении, которые входят в сверточный слой. В реальных изображениях обычно есть 3 канала (по одному для красного, синего и зеленого каналов), но при работе с текстом у нас есть только один канал, сам текст.out_channels
количество фильтров,kernel_size
размер фильтров. Каждый из наших «размеров ядра» будет[n x emb_dim]вэто размер 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}%')
Результаты тестов, которые мы получили, примерно такие же, как у первых двух моделей!
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
резюме
В следующем разделе мы узнаем о многотипном анализе настроений.