Физтех.Статистика
Скачать ipynb
Введение в анализ данных¶
Обработка естественного языка. Рекуррентные нейронные сети.¶
В данном ноутбуке мы научимся строить рекуррентные нейронные сети и решим с помощью них 2 задачи: посимвольная генерация имен и анализ тональности текста. В генерации рассмотрим 2 варианта работы с выходами RNN. Продолжаем работать с библиотекой torch
, но добавятся и новые — основные библиотеки обработки текстов: torchtext
и nltk
. А в следующем ноутбуке мы посмотрим пример генерации текста с помощью модели LLAMA.
!pip install -q 'portalocker>=2.0.0' > null
!python -m spacy download en > null
!python -m spacy download de > null
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import time
import random
from random import sample
import torch
import torch.nn as nn
from torch import optim
import torch.nn.functional as F
from torch.utils.data import DataLoader, Dataset
from torch.nn.utils.rnn import pad_sequence
from sklearn.model_selection import train_test_split
from collections import Counter, OrderedDict
from IPython.display import clear_output
import torchtext
from torchtext import data, datasets
from torchtext.data.utils import get_tokenizer
from torchtext.data.functional import to_map_style_dataset
import nltk
from nltk.corpus import names as names_data
from string import punctuation
sns.set(palette="Set2")
sns.set_style("whitegrid")
# Загрузим необходимые датасеты nltk
nltk.download("names")
nltk.download("stopwords")
print(f"torchtext version: {torchtext.version}")
device = (
"mps"
if torch.backends.mps.is_available()
else "cuda" if torch.cuda.is_available() else "cpu"
)
torchtext version: <module 'torchtext.version' from '/usr/local/lib/python3.10/dist-packages/torchtext/version.py'>
[nltk_data] Downloading package names to /root/nltk_data... [nltk_data] Unzipping corpora/names.zip. [nltk_data] Downloading package stopwords to /root/nltk_data... [nltk_data] Unzipping corpora/stopwords.zip.
Классическая RNN (Vanilla RNN)¶
Принимает на вход очередной токен $x_t$ и предыдущее скрытое состояние $h_{t-1}$ и выдаёт новое скрытое состояние $h_t$. Преобразование происходит по формуле:
$$h_t = \sigma(U_hx_t + V_hh_{t-1} + b_h),$$
где
$x_{t}$ — токен:
[emb_dim, 1]
;$h_t$ — скрытое состояние:
[hid_dim, 1]
;$b_h$ — обучаемый вектор сдвига:
[hid_dim, 1]
;$U_h$ — обучаемая матрица для преобразования входов:
[emb_dim, hid_dim]
;$V_h$ — обучаемая матрица для преобразования скрытого состояния:
[hid_dim, hid_dim]
;$\sigma$ — нелинейная функция, по умолчанию
tanh
.
$U_h, V_h, b_h, W_y, b_y$ — обучаемые параметры RNN-клетки, а hid_dim
, emb_dim
— гиперпараметры.
Если же мы хотим решать задачу классификации, то мы можем применить линейный слой с функций softmax к скрытому состоянию и получить предсказание вероятности: $$o_t = \sigma(W_o h_t + b_o).$$
В модуле torch.nn
клетка Vanilla RNN представлена классом torch.nn.RNNCell
. Его можноинициализировать следующим образом:
torch.nn.RNNCell(input_size: int, hidden_size: int, bias: bool = True, nonlinearity: str = 'tanh')
.
Класс RNNCell
возвращает только следующее скрытое состояние $h_t$.
Самый простой пример применения клетки RNN:
seq_len = 6
rnn = nn.RNNCell(input_size=10, hidden_size=20)
input = torch.randn(seq_len, 3, 10)
hx = torch.randn(3, 20)
for i in range(seq_len):
hx = rnn(input[i], hx)
print(hx)
print(hx.shape)
tensor([[-0.7739, -0.2172, -0.2360, -0.0677, 0.5620, 0.5704, 0.3726, -0.3988, -0.4905, -0.4825, -0.6892, -0.4839, -0.8644, 0.3372, 0.0149, -0.3897, -0.7587, -0.2573, -0.6434, 0.4679], [ 0.2881, -0.0329, -0.7278, 0.0389, -0.5569, 0.4840, 0.0175, 0.4286, 0.4792, 0.3227, -0.1440, 0.1783, -0.0999, 0.7176, -0.2140, -0.0252, -0.5685, -0.2077, 0.7632, -0.4826], [-0.5209, 0.1492, -0.0113, 0.1825, 0.8625, -0.3570, -0.6518, -0.2265, -0.5820, 0.2177, -0.6730, 0.1080, -0.7529, 0.2462, -0.2631, 0.1284, -0.8476, -0.6578, -0.2318, 0.4345]], grad_fn=<TanhBackward0>) torch.Size([3, 20])
Но для того, чтобы прогнать все токены через RNNCell
, нужно писать цикл, что совсем неудобно. А если мы хотим использовать ещё многослойные RNN, то неудобство возрастает. Для удобства существует класс torch.nn.RNN
. Его параметры:
input_size
— размер эмбеддинга;hidden_size
— размер скрытого состояния;num_layers
— число рекуррентных слоёв;nonlinearity
— функция активации —'tanh'
или'relu'
, по умолчанию:'tanh'
;bias
— если установлен вFalse
, то $b_h$ устанавливаются равными 0 и не обучается, по умолчанию:True
;batch_first
— еслиTrue
, то входные и выходные тензоры имеют размерность(batch, seq_len, feature)
, иначе(seq_len, batch, feature)
, по умолчанию:False
;dropout
— вероятность отключения каждого нейрона при dropout, по умолчанию:0
;bidirectional
— использовать ли двунаправленную сеть.
RNN
возвращает h_n
и output
.
h_n
— скрытые состояния на последний момент времени со всех слоев и со всех направлений (forward и backward). В случае, если слой один и RNN однонаправленная, то это просто последнее скрытое состояние. Размерностьh_n
:(batch, num_layers * num_directions, hidden_size)
.output
— скрытые состояния последнего слоя для всех моментов времени $t$. В случае,bidirectional=True
, то то же самое и для обратного прохода. Размерностьoutput
:(batch, seq_len,num_directions * hidden_size)
.
Инициализируем RNN
и применим её к последовательности случайных чисел.
seq_len = 5
batch = 3
input_size = 10
layers_num = 2
hidden_size = 20
rnn = nn.RNN(input_size=10, hidden_size=20, num_layers=2)
input = torch.randn(seq_len, batch, input_size)
h0 = torch.randn(layers_num, batch, hidden_size)
output, hn = rnn(input, h0)
print(output.shape, hn.shape)
torch.Size([5, 3, 20]) torch.Size([2, 3, 20])
2. Простая языковая модель с использованием рекуррентных нейронных сетей¶
На лекции вы познакомились с устройством языковых моделей и основными их применениями. В данном обучающем ноутбуке построим простую языковую модель на основе рекуррентной нейронной сети и решим с её помощью простую задачу — побуквенную генерацию имён.
Загрузим датасет имён из модуля nltk.corpus
для обучения сети.
names = names_data.words(fileids=["male.txt"]) + names_data.words(
fileids=["female.txt"]
)
Описание данных¶
Файл names
содержит около 8000 имён из самых разных стран, записанных латинскими буквами.
print("Всего имён:", len(names))
print("Примеры имён:")
for name in names[:10]:
print(name)
Всего имён: 7944 Примеры имён: Aamir Aaron Abbey Abbie Abbot Abbott Abby Abdel Abdul Abdulkarim
При генерации слов или текстов необходимо задать стоп-условие, по выполнении которого генерация будет остановлена. Один из вариантов такого условия — ограничение генерируемого текста по длине. Посмотрим на распределение длин имён.
MAX_LENGTH = max(map(len, names))
print("Максимальная длина:", MAX_LENGTH)
plt.figure(figsize=(7, 4))
plt.title("Распределение длин имён")
plt.hist(list(map(len, names)), bins=15, range=(0, 15))
plt.xlabel("Длина слова")
plt.show();
Максимальная длина: 15
# запишем в tokens все возможные токены
tokens = set("".join(names))
tokens = list(tokens)
num_tokens = len(tokens)
print("Число токенов:", num_tokens)
Число токенов: 55
print(tokens)
['u', 'q', 'a', 'l', 'A', 'P', 'K', 'S', 'y', 'T', 'G', 'z', 'b', 'N', "'", 'v', 'L', ' ', 'I', 'p', 'x', 'X', 'B', '-', 'Z', 'M', 'Q', 'J', 't', 'f', 'm', 'w', 'Y', 'i', 'U', 'd', 'H', 'C', 'c', 'r', 'h', 'j', 'k', 'D', 'F', 'O', 'n', 'o', 'e', 'g', 's', 'V', 'R', 'E', 'W']
Перевод символов в целочисленный формат¶
Сопоставим каждому токену из tokens
число, чтобы с ним могла работать нейронная сеть.
token_to_id = {token: idx for idx, token in enumerate(tokens)}
print(token_to_id)
{'u': 0, 'q': 1, 'a': 2, 'l': 3, 'A': 4, 'P': 5, 'K': 6, 'S': 7, 'y': 8, 'T': 9, 'G': 10, 'z': 11, 'b': 12, 'N': 13, "'": 14, 'v': 15, 'L': 16, ' ': 17, 'I': 18, 'p': 19, 'x': 20, 'X': 21, 'B': 22, '-': 23, 'Z': 24, 'M': 25, 'Q': 26, 'J': 27, 't': 28, 'f': 29, 'm': 30, 'w': 31, 'Y': 32, 'i': 33, 'U': 34, 'd': 35, 'H': 36, 'C': 37, 'c': 38, 'r': 39, 'h': 40, 'j': 41, 'k': 42, 'D': 43, 'F': 44, 'O': 45, 'n': 46, 'o': 47, 'e': 48, 'g': 49, 's': 50, 'V': 51, 'R': 52, 'E': 53, 'W': 54}
def to_matrix(
lines, max_len=None, pad=token_to_id[" "], dtype="int32", batch_first=True
):
"""
Переведём список имён в целочисленную матрицу,
чтобы с ней могла работать нейросеть
lines: массив текстов (имён)
return: матрица, в которой каждая i-ая строка соответствует кодированию i-ого текста.
Каждому токену соответствует уникальный id.
Если длина текста меньше максимальной, то кодирование дополняется id токена <pad>,
если больше максимальной, то кодирование обрезается.
"""
max_len = max_len or max(map(len, lines))
lines_ix = np.zeros([len(lines), max_len], dtype) + pad
for i in range(len(lines)):
line_ix = [token_to_id[c] for c in lines[i][:max_len]]
lines_ix[i, : len(line_ix)] = line_ix
if not batch_first:
# переведём размерность из [batch, time] в [time, batch]
lines_ix = np.transpose(lines_ix)
return lines_ix
Проверим работу функции.
print("\n".join(names[::2000]))
print(to_matrix(names[::2000]))
Aamir Pincas Concettina Lizette [[ 4 2 30 33 39 17 17 17 17 17] [ 5 33 46 38 2 50 17 17 17 17] [37 47 46 38 48 28 28 33 46 2] [16 33 11 48 28 28 48 17 17 17]]
Рекуррентная архитектура для языковой модели¶
Мы реализуем наиболее простую архитектуру, состоящую из 3 частей:
- эмбеддинг-слой —
nn.Embedding
; - рекуррентный слой — здесь может быть любая рекуррентная сеть (например, Vanilla RNN, LSTM, GRU), в данном случае
nn.LSTM
; - линейный слой для логитов —
nn.Linear
.
class CharRNNLoop(nn.Module):
"""Класс модели для генерации имён на основе LSTM"""
def __init__(self, num_tokens=num_tokens, emb_size=16, rnn_num_units=64):
super(self.__class__, self).__init__()
self.emb = nn.Embedding(num_tokens, emb_size)
self.rnn = nn.LSTM(emb_size, rnn_num_units, batch_first=True)
self.hid_to_logits = nn.Linear(rnn_num_units, num_tokens)
def forward(self, x):
# x.shape = (batch_size, max_name_len)
assert isinstance(x.data, torch.LongTensor)
# Получим эмбеддинги для входов
# (batch_size, max_name_len, emb_size)
emb = self.emb(x)
# Прогоним через RNN-сеть и получим логиты
# (batch_size, max_name_len, rnn_num_units)
h_seq, _ = self.rnn(emb)
# Прогоним через полносвязный слой и получим логиты для каждого токена
# (batch_size, max_name_len, num_tokens)
next_logits = self.hid_to_logits(h_seq)
# Применим log(softmax()) и получим логарифмы вероятностей для каждого токена
# (batch_size, max_name_len, num_tokens)
next_logp = F.log_softmax(next_logits, dim=-1)
return next_logp
Заметьте, что здесь мы работаем именно с логарифмами вероятностей токенов. То есть преобразуем логиты в $\log p$ с помощью
log_softmax
изtorch.nn.functional
и соответствующий критерийnn.NLLLoss
(Negative Log-Likelihood Loss), который принимает их на вход. В задаче классификации картинок на прошлом семинаре мы работали с логитами и критериемnn.CrossEntropyLoss
.
model = CharRNNLoop()
criterion = nn.NLLLoss()
opt = torch.optim.Adam(model.parameters())
history = []
Для примера посчитаем лосс модели на одном батче.
# Преобразуем текст в матричный вид
batch_ix = to_matrix(sample(names, 32), max_len=MAX_LENGTH)
batch_ix = torch.LongTensor(batch_ix)
# Получим log вероятностей для токенов
logp_seq = model(batch_ix)
# Посчитаем лосс
loss = criterion(
logp_seq[:, :-1].contiguous().view(-1, num_tokens),
batch_ix[:, 1:].contiguous().view(-1),
)
print(loss.item())
loss.backward()
4.051820755004883
Обучим модель
MAX_LENGTH = 16
for i in range(3000):
batch_ix = to_matrix(sample(names, 32), max_len=MAX_LENGTH)
batch_ix = torch.tensor(batch_ix, dtype=torch.int64)
logp_seq = model(batch_ix)
# Считаем функцию потерь
predictions_logp = logp_seq[:, :-1]
actual_next_tokens = batch_ix[:, 1:]
loss = criterion(
predictions_logp.contiguous().view(-1, num_tokens),
actual_next_tokens.contiguous().view(-1),
)
# Обратный проход
loss.backward()
opt.step()
history.append(loss.data.numpy())
if (i + 1) % 100 == 0:
clear_output(True)
plt.figure(figsize=(8, 5))
plt.plot(history)
plt.title("Лосс модели при обучении")
plt.xlabel("Номер батча")
plt.show()
def generate_sample(
rnn_model, seed_phrase=" ", max_length=MAX_LENGTH, temperature=1.0
):
"""
Функция для генерации имён
Параметры.
1) rnn_model — модель, для генерации имён,
2) seed_phrase — начало имени,
3) max_length — ограничение на длину слова,
4) temperature — температура в softmax,
позволяет сделать распределение вероятностей более сглаженным и наоборот.
"""
# Переведем символы в их id
x_sequence = [token_to_id[token] for token in seed_phrase]
x_sequence = torch.tensor([x_sequence], dtype=torch.int64)
# Цикл генерации по 1 токену
for _ in range(max_length - len(seed_phrase)):
# Делаем предсказание моделью, на вход даем всю последовательность
logp_next = rnn_model(x_sequence)
p_next = F.softmax(logp_next / temperature, dim=-1).data.numpy()[0, -1]
# Cэмплируем следующий токен, используя полученные вероятности
next_ix = np.random.choice(num_tokens, p=p_next)
next_ix = torch.tensor([[next_ix]], dtype=torch.int64)
x_sequence = torch.cat([x_sequence, next_ix], dim=1)
return "".join([tokens[ix] for ix in x_sequence.data.numpy()[0]])
Попробуем сгенерировать какие-нибудь имена.
for _ in range(10):
print(generate_sample(model, "Vas", max_length=5))
Vasa Vasax Vaske Vasta Vastt Vas Vase Vaseh Vaste Vas
for _ in range(10):
print(generate_sample(model, "Mipt"))
Miptsabssie Miptis Miptia Mipt Mipt Mipta Miptaheritie Mipt Mipt Mipta
Проблема. Заметим, что для того, чтобы сгенерировать следующий токен в модель каждый раз подается вся последовательность до текущего токена.
Попробуем написать более умную функцию forward
, которая принимает не всю последовательность, а только последний выход и скрытое состояние модели.
Вход:
emb
— эмбеддинги очередных входных токенов;(h0, c0)
— предыдущее скрытое состояние.
Выход:
output
— текущий выход из LSTM слоя;(h, c)
— текущее скрытое состояние.
В случае, если скрытое состояние не подается на вход
forward
, будем обрабатывать последовательность $x$ как ранее.
class SmartCharRNNLoop(nn.Module):
"""Класс модели для генерации имён на основе LSTM,
которая принимает на вход предыдущее скрытое состояние"""
def __init__(self, num_tokens=num_tokens, emb_size=16, rnn_num_units=64):
super(self.__class__, self).__init__()
self.emb = nn.Embedding(num_tokens, emb_size)
self.rnn = nn.LSTM(emb_size, rnn_num_units, batch_first=True)
self.hid_to_logits = nn.Linear(rnn_num_units, num_tokens)
def forward(self, x, h0=None, c0=None):
# x.shape = (batch_size, max_name_len)
assert isinstance(x.data, torch.LongTensor)
# Получим эмбеддинги для входов
# (batch_size, max_name_len, emb_size)
emb = self.emb(x)
# Прогоним через RNN сеть, получим логиты и (h, c)
# output.shape = (batch_size, max_name_len, rnn_num_units)
if h0 is not None:
output, (h, c) = self.rnn(emb, (h0, c0))
else:
output, (h, c) = self.rnn(emb)
# Прогоним через полносвязный слой и получим логиты для каждого токена
# (batch_size, max_name_len, num_tokens)
next_logits = self.hid_to_logits(output)
# Применим log(softmax()) и получим логарифмы вероятностей для каждого токена
# (batch_size, max_name_len, num_tokens)
next_logp = F.log_softmax(next_logits, dim=-1)
return next_logp, (h, c)
Инициализируем модель и оптимизатор.
model = SmartCharRNNLoop()
criterion = nn.NLLLoss()
opt = torch.optim.Adam(model.parameters())
history = []
Обучим улучшенную модель.
MAX_LENGTH = 16
for i in range(3000):
batch_ix = to_matrix(sample(names, 32), max_len=MAX_LENGTH)
batch_ix = torch.tensor(batch_ix, dtype=torch.int64)
logp_seq, _ = model(batch_ix)
# Считаем функцию потерь
predictions_logp = logp_seq[:, :-1]
actual_next_tokens = batch_ix[:, 1:]
loss = criterion(
predictions_logp.contiguous().view(-1, num_tokens),
actual_next_tokens.contiguous().view(-1),
)
# Обратный проход
loss.backward()
opt.step()
history.append(loss.data.numpy())
if (i + 1) % 100 == 0:
clear_output(True)
plt.figure(figsize=(8, 5))
plt.plot(history)
plt.title("Лосс модели при обучении")
plt.xlabel("Номер батча")
plt.show()
Напишем более умный метод генерации. На каждом этапе он берет (h, c)
с предыдущего этапа, а также сгенерированный на предыдущем этапе токен и предсказывает новый токен по полученным из LSTM вероятностям.
def smart_generate_sample(
rnn_model, seed_phrase=" ", max_length=MAX_LENGTH, temperature=1.0
):
"""
Функция для генерации имён
Параметры.
1) rnn_model — модель для генерации имён
2) seed_phrase — начало имени
3) max_length — ограничение на длину слова
4) temperature — температура в softmax,
позволяет сделать распределение вероятностей более сглаженным и наоборот.
"""
# Переведем символы в их id
x_sequence = [token_to_id[token] for token in seed_phrase]
x_sequence = torch.tensor([x_sequence], dtype=torch.int64)
# Цикл генерации по 1 токену
for token_id in range(max_length - len(seed_phrase)):
if token_id == 0:
logp_next, (h, c) = rnn_model(x_sequence)
else:
logp_next, (h, c) = rnn_model(x_sequence[-1:], h, c)
p_next = F.softmax(
logp_next[:, -1, :] / temperature, dim=-1
).data.numpy()[0, :]
# Cэмплируем следующий токен, используя полученные вероятности
next_ix = np.random.choice(num_tokens, p=p_next)
next_ix = torch.tensor([[next_ix]], dtype=torch.int64)
x_sequence = torch.cat([x_sequence, next_ix], dim=1)
return "".join([tokens[ix] for ix in x_sequence.data.numpy()[0]])
for _ in range(10):
print(smart_generate_sample(model, "Vas", max_length=5))
Vass Vask Vasos Vass Vasdd Vasde Vasge Vas Vass Vasga
for _ in range(10):
print(smart_generate_sample(model, "Mipt", max_length=8))
Mipte Mipte Miptore Mipted Mipta Mipte Mipta Miptore Miptor Miptere
3. Задача классификации с использованием рекуррентных нейронных сетей¶
Посмотрим на то, как можно с помощью рекуррентной нейронной сети решить задачу sentiment analysis.
Sentiment Analysis (анализ тональности) — это задача определения эмоциональной окраски текста. Обычно целью является определение тональности текста как положительной, отрицательной или нейтральной.
Пусть
- $X$ — набор текстовых данных, где каждый текст представлен в виде последовательности токенов;
- $Y$ — метки классов, где каждая метка обозначает эмоциональную окраску текста (например,
1
для положительного,-1
для отрицательного,0
для нейтрального).
Пусть у нас есть $m$ примеров в наборе данных. Каждый текст представлен в виде последовательности токенов фиксированной длины $n$. Тогда:
- Входные данные $X$: Тензор размерности
(m, n)
, где $m$ — количество примеров, $n$ — размерность пространства токенов. - Метки классов $Y$: Вектор размерности
(m,)
, где каждый элемент $i$ принимает значения из множества $\{-1, 0, 1\}$.
Цель: построить модель $f(X)$, которая по входным текстам $X$ будет предсказывать метки классов $Y$.
Сейчас для простоты демонстрации будем решать задачу классификации на 2 класса:
0
— негативный отзыв;1
— позитивный отзыв.
Скачаем датасет отзывов к фильмам с сайта IMDB
train_iter, test_iter = datasets.IMDB()
train_dataset = to_map_style_dataset(train_iter)
test_dataset = to_map_style_dataset(test_iter)
Зададим токенизатор, списки стоп-слов и пунктуации.
tokenizer = get_tokenizer("spacy", language="en_core_web_sm")
stop_words = nltk.corpus.stopwords.words("english") # стоп-слова
punc = list(punctuation) # пунктуация
Создадим словарь используемых слов и отфильтруем редко используемые слова.
# зададим счетчики
counter = Counter()
# посчитаем количество вхождений каждого токена
for _, text in train_iter:
counter.update(
token
for token in tokenizer(text)
if token not in stop_words and token not in punc
)
ordered_dict = OrderedDict(counter)
MAX_VOCAB_SIZE = 25000 # ограничение на словарь
ordered_dict = OrderedDict(counter.most_common(MAX_VOCAB_SIZE))
Создадим словарь, добавив специальные токены: padding и unknown.
# зададим словарь
vocab = torchtext.vocab.vocab(ordered_dict)
# объявим специальные токены
unk_token = "<unk>"
pad_token = "<pad>"
def add_special_tokens(vocab):
"""Функция для добавления специальных токенов в словарь."""
for special_token in [unk_token, pad_token]:
vocab.append_token(special_token)
vocab.set_default_index(vocab[unk_token])
UNK_IDX = vocab[unk_token]
PAD_IDX = vocab[pad_token]
return vocab, UNK_IDX, PAD_IDX
vocab, UNK_IDX_EN, PAD_IDX_EN = add_special_tokens(vocab)
Немного обсудим возможные стратегии к паддингу последовательностей до одинаковый длины для того, чтобы сформировать входной тензор.
Самый простой подход заключается в том, чтобы найти текст с максимальной длиной и дополнить все остальные последовательности до него.
Чуть более грамотно сначала сэмплировать батч и уже после этого дополнять паддингом последовательности до максимальной длины в батче.
Наиболее продвинутым же является подход, при котором сэмплы приблизительно одинаковый длины группируют в отдельный бакет, а батч примеров сэмплируется из одного бакета.
Подумайте над преимуществами и недостатками каждого подхода. Вторая стратегия проста в реализации и достаточно эффективна, поэтому применяем ее.
class SentimentAnalysisDataset(Dataset):
"""Класс датасета анализа тональности"""
def __init__(self, texts, labels, smart_pad_collate=False):
"""
Параметры.
1) texts (list) — корпус токенизированных текстов, на котором будет
происходить обучение
2) labels (list) — истинные метки текстов
"""
self.texts = texts
self.labels = labels
if smart_pad_collate:
self.texts.sort(key=len)
def __len__(self):
return len(self.labels)
def __getitem__(self, idx):
return self.texts[idx], self.labels[idx]
В умном батчинге надо отсортировать тексты, затем разбить их на бакеты. Таким образом в одном бакете тексты будут приблизительно одинаковый длины. Далее при сэмплировании надо сначала выбрать бакет, откуда будем брать, а затем уже индексы семплов внутри бакета. То, что выше — это обычный паддинг до максимума в батче.
def pad_collate(batch):
"""
Формирует тензоры из токенизированных текстов и меток, а также
дополняет последовательности токенов до максимальной длины в батче с UNK_IDX
"""
texts, labels = zip(*batch)
# сформируем тензоры
texts_tensors = [torch.LongTensor(t) for t in texts]
labels = torch.LongTensor(labels)
# дополним до макс. длины в батче
texts_tensors = pad_sequence(
texts_tensors, padding_value=PAD_IDX_EN, batch_first=True
)
return texts_tensors, labels
Токенизируем текст
train_tokens = [vocab(tokenizer(text)) for _, text in train_dataset]
train_labels = [int(label == 1) for label, _ in train_dataset]
# Разделим все токены и метки на train и test
train_tokens, valid_tokens, train_labels, valid_labels = train_test_split(
train_tokens, train_labels, stratify=train_labels
)
train_data = SentimentAnalysisDataset(train_tokens, train_labels)
valid_data = SentimentAnalysisDataset(valid_tokens, valid_labels)
Размер получившегося словаря:
num_tokens = len(vocab)
num_tokens
25002
Посмотрим на распределение количества токенов в тексте.
lens = [len(row) for row in train_tokens]
print("Максимальная длина:", max(lens))
plt.figure(figsize=(7, 4))
plt.title("Распределение длин текстов в токенах")
plt.xlabel("Количество токенов")
plt.hist(lens, bins=15, range=(0, 1100))
plt.show();
Максимальная длина: 2789
BATCH_SIZE = 64 # размер батча
# сформируем даталоадеры
train_loader = DataLoader(
train_data, batch_size=BATCH_SIZE, shuffle=True, collate_fn=pad_collate
)
valid_loader = DataLoader(
valid_data, batch_size=BATCH_SIZE, shuffle=False, collate_fn=pad_collate
)
Архитектура рекуррентной нейронной сети для задачи sentiment analysis¶
Мы реализуем наиболее простую архитектуру, состоящую из 3 частей:
- эмбеддинг-слой;
- рекуррентный слой — здесь может быть любая рекуррентная сеть (например, Vanilla RNN, LSTM, GRU);
- линейный слой для предсказания класса.
В отличие от прошлой модели сейчас мы делаем предсказание класса для всей последовательности, используя последнее скрытое состояние $h_T$.
class SimpleLSTMClassifier(nn.Module):
"""Модель для классификации последовательностей на основе LSTM"""
def __init__(
self, num_tokens, emb_size=512, rnn_num_units=64, num_classes=2
):
super(self.__class__, self).__init__()
self.emb = nn.Embedding(num_tokens, emb_size, padding_idx=PAD_IDX_EN)
self.rnn = nn.LSTM(emb_size, rnn_num_units, batch_first=True)
self.classifier = nn.Linear(rnn_num_units, num_classes)
def forward(self, x):
# x.shape = (batch_size, max_pad_len)
# Получим эмбеддинги для входов
# (batch_size, max_pad_len, emb_size)
emb = self.emb(x)
# Прогоним через RNN-сеть и получим скрытое состояние,
# в котором хранится нужная информация о последовательности
# (batch_size, rnn_num_units)
_, (h_state, _) = self.rnn(emb)
# Прогоним через полносвязный слой и получим логиты для каждого токена
# (batch_size, num_tokens)
logits = self.classifier(h_state.squeeze(0))
return logits
Напишем вспомогательные функции для подсчета точности (binary_accuray
), обучения (train
) и тестирования (evaluate
) модели на 1 эпохе. Функция epoch_time
позволит измерять время прохождения 1 эпохи.
def binary_accuracy(preds, y):
"""
Возвращает точность модели.
Параметры.
1) preds — предсказания модели,
2) y — истинные метки классов.
"""
# округляет предсказания до ближайшего integer
rounded_preds = torch.round(torch.sigmoid(preds))
correct = (rounded_preds == y).float()
acc = correct.sum() / len(correct)
return acc
def train(model, iterator, optimizer, criterion, loss_history):
"""
Функция 1 эпохи обучения модели и подсчёта её точности.
Параметры.
1) model — модель,
2) iterator — итератор обучающего датасета,
3) optimizer — класс метода оптимизации,
4) criterion — функция потерь.
"""
epoch_loss = 0
epoch_acc = 0
model.train()
for batch_id, batch in enumerate(iterator):
# чтобы градиент не накапливался, его нужно обнулить
optimizer.zero_grad()
# получим предсказания модели
texts, labels = batch
texts, labels = texts.to(device), labels.to(device)
predictions = model(texts).squeeze(1)
loss = criterion(predictions, labels.float())
acc = binary_accuracy(predictions, labels)
# сделаем back-propagation для подсчёта градиентов
loss.backward()
# выполним шаг оптимизатора
optimizer.step()
# обновим метрики
epoch_loss += loss.item()
epoch_acc += acc.item()
loss_history.append(loss.item())
return epoch_loss / len(iterator), epoch_acc / len(iterator)
def evaluate(model, iterator, criterion, loss_history=None):
"""
Функция 1 эпохи тестирования модели и подсчёта её точности.
Параметры.
1) model — модель,
2) iterator — итератор датасета,
3) criterion — функция потерь.
"""
epoch_loss = 0
epoch_acc = 0
model.eval()
with torch.no_grad(): # отключим подсчёт градиентов на валидации
for batch_id, batch in enumerate(iterator):
texts, labels = batch
texts, labels = texts.to(device), labels.to(device)
# получим предсказания
predictions = model(texts).squeeze(1)
# посчитаем метрики
loss = criterion(predictions, labels.float())
acc = binary_accuracy(predictions, labels)
# обновим метрики
epoch_loss += loss.item()
epoch_acc += acc.item()
if loss_history is not None:
loss_history.append(loss.item())
return epoch_loss / len(iterator), epoch_acc / len(iterator)
def epoch_time(start_time, end_time):
"""
Функция для подсчёта времени работы одной эпохи.
Параметры.
1) start_time — время начала запуска,
2) 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
Объединим эти функции в единый цикл обучения:
def training_loop(model_instance, n_epochs=10):
"""
Функция для обучения нейронной сети.
Параметры.
1) model_instance — обучаемая модель,
2) n_epochs — количество эпох.
"""
best_valid_loss = float("inf")
loss_history = []
val_loss_history = []
acc_history = []
val_acc_history = []
for epoch in range(n_epochs):
start_time = time.time()
# Обучим одну эпоху на обучающем датасете
train_loss, train_acc = train(
model_instance, train_loader, optimizer, criterion, loss_history
)
acc_history.append(train_acc)
# Оценим точность модели на тестовом датасете
valid_loss, valid_acc = evaluate(
model_instance, valid_loader, criterion, val_loss_history
)
val_acc_history.append(valid_acc)
# Посчитаем время работы одной эпохи
end_time = time.time()
epoch_mins, epoch_secs = epoch_time(start_time, end_time)
# Отобразим графики loss'ов
clear_output(True)
plt.figure(figsize=(18, 6))
plt.subplot(1, 3, 1)
plt.plot(loss_history)
plt.title("Train Loss")
plt.xlabel("Номер батча")
plt.subplot(1, 3, 2)
plt.plot(val_loss_history, color="tab:orange")
plt.title("Valid Loss")
plt.xlabel("Номер батча")
# Если значение функции потерь улучшилось, сохраним параметры модели
if valid_loss < best_valid_loss:
best_valid_loss = valid_loss
torch.save(model_instance.state_dict(), "model_checkpoint.pt")
# Отрисуем значение точности от эпохи
plt.subplot(1, 3, 3)
plt.plot(acc_history, label="Train")
plt.plot(val_acc_history, label="Valid")
plt.legend()
plt.title("Accuracy")
plt.xlabel("Номер эпохи")
plt.show()
print(
f"Номер эпохи: {epoch+1:02} | Время обучения эпохи: {epoch_mins}m {epoch_secs}s"
)
Теперь можем инициализировать модель и обучить ее.
model = SimpleLSTMClassifier(
num_tokens=num_tokens,
emb_size=100,
rnn_num_units=256,
num_classes=1,
).to(device)
# У нас задача бинарной классификации, будем использовать BCEWithLogitsLoss
criterion = nn.BCEWithLogitsLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.005)
history = []
training_loop(model)
Номер эпохи: 10 | Время обучения эпохи: 0m 25s
Посмотрим, что выдает наша модель на конкретных отзывах. Для этого возьмем 1 батч из тестовой выборки.
data_batch, labels_batch = next(iter(valid_loader))
# Применяем модель, получаем предсказания
with torch.no_grad():
preds = torch.round(torch.sigmoid(model(data_batch.to(device))))
При генерации батчей мы убирали пунктуацию, поэтому сейчас не сможем посмотреть на изначальный текст. Но суть уловить все еще можно:
label_mapping = ["negative", "positive"]
# Выведем предсказания для 5 случайных примера из тестового батча
indices = torch.randint(low=0, high=len(data_batch), size=(5,))
for idx in indices:
print("=" * 20)
print("Text:")
text = " ".join(
[
token
for token in vocab.lookup_tokens(data_batch[idx].tolist())
if token not in ["<unk>", "<pad>"]
]
)
print(text)
print(f"Predicted label: {label_mapping[int(preds[idx].item())]}")
print(f"True label: {label_mapping[labels_batch[idx].item()]}")
print()
==================== Text: As kid I loved computer animation although EXTREMELY limited tools almost nonexistent This movie I sat awe watched amazing images almost hypnotic music shaped desire create moving things computer This whole package deal music video really packs one two punch If know child wants get involved computer animation MUST HAVE br /><br />I still almost 20 years later rate movie one top 3 favorites The originality I think still unsurpassed today 's Hollywood spits I currently wanting see I make imitation form movie deserves imitation =) Predicted label: negative True label: negative ==================== Text: This far away worst movie 've ever seen entire life It slow boring scary funny dramatic entertaining.<br /><br Michelle Gellar old empty expressions fright shock She could n't sell character could anyone else picture.<br /><br />For thought Grudge kind alright n't go see unless get enjoyment wasting time life.<br /><br />I saw movie free way I n't want come across rant guy lost 8 bucks terrible movie It free still sucked I hated it.<br /><br />Avoid Predicted label: positive True label: positive ==================== Text: My kids picked video store ... 's great hear Liza Dorothy cause sounds like mom But many bad songs animation pretty crude compared cartoons time Predicted label: positive True label: positive ==================== Text: This one brilliant movies I seen recent times Goes way even international movie I really surprised received recognition deserved Kulkarni winning National Award perhaps fact simply amazes speaks volumes eyes There scenes stand When Gauri comes back city Krishna 's wedding Krishna meet first time many years Krishna notices change Gauri single line dialogue said The entire gamut emotions conveyed subtle mannerisms eyes There 's another towards end Krishna pleads Abhay Kulkarni marry Gauri instead If moved scene n't heart.<br /><br />Watch movie sheer movie making brilliance acting capabilities Predicted label: negative True label: negative ==================== Text: My room mate ordered one web back I finally got around watching It gross It cheezy It pretty dumb ... also lot fun I mean fun watching movie like since City Of The Walking Dead ages ago It like old Drive In Theater You could tell guy made movie liked horrible dubbed zombie movies This one cliches tricks films rolled one 's neat like The factor high gore flows laughs roll The effects go sloppy good one guy gets torn half one guy gets heart shoved chest excellent The acting goes terrible actually pretty good There much plot lots lots gore This one patterned zombie movies Italy Spain I think linger gross scenes forever like movie If like Troma movies cheezy B grade stuff wrong watching one A nice way waste Friday night Predicted label: positive True label: negative
Вывод: В этом ноутбуке мы научились представлять тексты в численном виде, обучили несколько моделей на основе nn.LSTM
, генерирующих имена по данному началу. Также научились решать задачу анализа тональности отзывов к фильмам, которая по своей сути является частным случаем задачи классификации текста.
Далее мы посмотрим пример генерации текста с помощью модели LLAMA.