Занятие 3
Лабораторная 2¶
*Сегодня будем решать следующую задачу:**
Дан текстовый корпус
$$
\mathcal{D} = \{ s_1, s_2, \dots, s_N \},
$$
где каждое $ s_i $ — последовательность слов из словаря $ V $.
Требуется:
Построить word-level языковую модель на основе RNN-архитектуры, которая аппроксимирует совместное распределение слов в предложении:
$$ P(w_1, w_2, \dots, w_T) = \prod_{t=1}^{T} P(w_t \mid w_1, \dots, w_{t-1}) $$Реализовать несколько вариантов модели, отличающихся:
- типом рекуррентного блока (RNN, GRU, LSTM),
- глубиной (
num_layers∈ {1, 2}), - размером скрытого состояния,
- наличием регуляризации (dropout),
- алгоритмом оптимизации (SGD, Adam).
Для каждой конфигурации вычислить перплексию на тестовом подмножестве корпуса:
$$ \text{Perplexity} = \exp\left( -\frac{1}{N} \sum_{i=1}^{N} \log P(w_i \mid w_1, \dots, w_{i-1}) \right) $$
1. Библиотеки¶
from datasets import load_dataset
from collections import Counter
import torch
import torch.nn as nn
import torch.optim as optim
from torch.nn.utils.rnn import pad_sequence
from torch.utils.data import DataLoader
import random
import math
import pandas as pd
from tabulate import tabulate
import re
import time
from tqdm import tqdm # для отображения прогресс-бара
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f"Используется устройство: {device}")
2. Подготовка датасета¶
Сегодня будем работать с датасетом IMDB
Датасет состоит из 50 000 отзывов (25k обучающих + 25k тестовых: 50% положительных, 50% отрицательных).
# Загружаем IMDB
dataset = load_dataset('imdb')
# Простая токенизация без NLTK
def tokenize(text):
# Оставляем только буквы и апострофы (для "don't" и т.п.)
return re.findall(r"\b\w+\b", text.lower())
# Собираем все слова из обучающей выборки
all_words = []
for example in dataset['train']:
all_words.extend(tokenize(example['text']))
# обучающий пример
print(dataset['train'][3])
# Зададим, сколько самых частых слов из корпуса мы хотим включить в словарь.
# Это гиперпараметр: можно уменьшить (для быстрого обучения) или увеличить (для лучшего покрытия).
vocab_size = 10000
# Counter подсчитывает, сколько раз каждое слово встречается в обучающем корпусе.
counter = Counter(all_words)
# Служебные токены
#<pad> - Заполнение коротких последовательностей до одинаковой длины (для батчей)
# <unk> Замена редких/неизвестных слов (out-of-vocabulary)
# <bos> Beginning of sentence — маркер начала предложения
# <eos> End of sentence — маркер конца предложения
special_tokens = ['<pad>', '<unk>', '<bos>', '<eos>']
# Берём 10 000 самых частых слов из корпуса (без учёта служебных).
# Редкие слова автоматически будут заменяться на <unk>.
most_common = [word for word, _ in counter.most_common(vocab_size)]
# Формируем итоговый словарь: сначала идут служебные токены, затем частые слова.
vocab = special_tokens + most_common
word2idx = {word: idx for idx, word in enumerate(vocab)}
idx2word = {idx: word for word, idx in word2idx.items()}
print(f"Размер словаря: {len(vocab)}")
Задача 1¶
Найдите самое частое слово в словаре.
"""Данная функция преобразует текстовую строку в последовательность числовых индексов,
пригодную для подачи в нейронную сеть (например, RNN).
"""
def text_to_indices(text):
tokens = tokenize(text)
# добавление начала предложения
indices = [word2idx['<bos>']]
for token in tokens:
indices.append(word2idx.get(token, word2idx['<unk>']))
# добавление конца предложения
indices.append(word2idx['<eos>'])
return torch.tensor(indices, dtype=torch.long)
"""Данная функция подготавливает подмножество данных (раздел датасета)
для обучения или тестирования языковой модели."""
def prepare_split(split_name, max_samples=5000):
data = []
for i, example in enumerate(dataset[split_name]):
if i >= max_samples:
break
data.append(text_to_indices(example['text']))
return data
train_data = prepare_split('train', max_samples=8000)
test_data = prepare_split('test', max_samples=2000)
train_data[0]
Задача 2¶
train_data[0] - это набор цифр. Что он означает?
3. Обучение модели¶
class RNNLanguageModel(nn.Module):
def __init__(self, vocab_size, embed_dim=200, hidden_dim=200,
num_layers=1, rnn_type='LSTM', dropout=0.0):
super().__init__()
# Эмбеддинг-слой: преобразует индексы слов в плотные векторы
self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=word2idx['<pad>'])
# Dropout после эмбеддингов для регуляризации
self.dropout_emb = nn.Dropout(dropout)
# Выбор типа рекуррентного слоя
rnn_types = {
'RNN': nn.RNN,
'GRU': nn.GRU,
'LSTM': nn.LSTM
}
# Инициализация RNN-блока
self.rnn = rnn_types[rnn_type](
input_size=embed_dim, # размер входного эмбеддинга
hidden_size=hidden_dim, # размер скрытого состояния
num_layers=num_layers, # количество слоёв
dropout=dropout if num_layers > 1 else 0, # dropout между слоями (только если их >1)
batch_first=False # ожидаем форму (seq_len, batch, ...)
)
# Dropout на выходе RNN
self.dropout_out = nn.Dropout(dropout)
# Линейный слой: преобразует скрытое состояние в логиты над словарём
self.fc = nn.Linear(hidden_dim, vocab_size)
# Сохраняем тип RNN для возможного использования в forward
self.rnn_type = rnn_type
def forward(self, x, hidden=None):
# x: тензор формы (seq_len, batch_size) с индексами слов
emb = self.embedding(x) # (seq_len, batch, embed_dim)
emb = self.dropout_emb(emb) # применяем dropout к эмбеддингам
# Пропускаем через RNN
output, hidden = self.rnn(emb, hidden) # output: (seq_len, batch, hidden_dim)
output = self.dropout_out(output) # dropout на выходе RNN
logits = self.fc(output) # (seq_len, batch, vocab_size)
return logits, hidden
# Преобразуем список последовательностей разной длины в единый тензор фиксированной формы, который
#можно подать в нейронную сеть.
def collate_batch(batch):
batch = [seq[:200] for seq in batch if len(seq) > 1] # ограничиваем длину и фильтруем короткие
padded = pad_sequence(batch, batch_first=False, padding_value=word2idx['<pad>'])
return padded.to(device)
# Оценка модели на тестовых данных: вычисляет среднюю кросс-энтропию (loss)
def evaluate(model, data_loader, criterion):
model.eval()
total_loss = 0.0
total_tokens = 0
with torch.no_grad(): # отключаем градиенты для ускорения
for batch in data_loader:
if batch.size(0) <= 1:
continue
x = batch[:-1, :] # вход: все слова, кроме последнего
y = batch[1:, :].reshape(-1) # цель: все слова, кроме первого
logits, _ = model(x)
loss = criterion(logits.reshape(-1, len(vocab)), y)
total_loss += loss.item() * y.numel()
total_tokens += y.numel()
return total_loss / total_tokens # средний loss на токен
# Обучение модели
def train_model(model, train_loader, epochs=3, lr=0.002, clip=0.5):
# Переносим модель на GPU (если доступен) или CPU
model.to(device)
# Функция потерь: кросс-энтропия, игнорирующая паддинг-токены при подсчёте
criterion = nn.CrossEntropyLoss(ignore_index=word2idx['<pad>'])
# Используем только Adam — адаптивный оптимизатор, устойчивый к выбору lr
optimizer = torch.optim.Adam(model.parameters(), lr=lr)
# Планировщик: уменьшает learning rate на 5% после каждой эпохи для стабильной сходимости
scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=1, gamma=0.95)
for epoch in range(epochs):
model.train() # включаем режим обучения (включает dropout и т.п.)
total_loss = 0
start_time = time.time()
# Прогресс-бар для отслеживания обучения внутри эпохи
progress_bar = tqdm(train_loader, desc=f"Эпоха {epoch+1}/{epochs}", leave=False)
for batch in progress_bar:
# Пропускаем слишком короткие последовательности (нельзя предсказать следующее слово)
if batch.size(0) <= 1:
continue
# x — вход: все слова, кроме последнего (<eos>)
# y — цель: все слова, кроме первого (<bos>)
x = batch[:-1, :]
y = batch[1:, :].reshape(-1)
optimizer.zero_grad() # обнуляем градиенты с прошлой итерации
logits, _ = model(x) # прямой проход: получаем логиты
loss = criterion(logits.reshape(-1, len(vocab)), y) # вычисляем потерю
loss.backward() # обратное распространение ошибки
torch.nn.utils.clip_grad_norm_(model.parameters(), clip) # обрезка градиентов против взрыва
optimizer.step() # обновление весов
total_loss += loss.item()
progress_bar.set_postfix({'loss': f"{loss.item():.3f}"}) # отображаем текущий loss
# Средний loss за эпоху и затраченное время
avg_loss = total_loss / len(train_loader)
elapsed = time.time() - start_time
print(f"Эпоха {epoch+1} завершена. Средний loss: {avg_loss:.3f}. Время: {elapsed:.1f} сек.")
# Уменьшаем learning rate
scheduler.step()
return model
4. Эксперименты¶
experiments = []
# Подготовка данных
train_loader = DataLoader(train_data, batch_size=32, shuffle=True, collate_fn=collate_batch)
test_loader = DataLoader(test_data, batch_size=32, shuffle=False, collate_fn=collate_batch)
print("\n🚀 Эксперимент 3: Размер скрытого состояния")
for hidden_dim in [50, 100, 200]:
print(f" Обучение LSTM, hidden_dim={hidden_dim}...")
model = RNNLanguageModel(
vocab_size=len(vocab),
embed_dim=hidden_dim, # обычно embed_dim = hidden_dim для простоты
hidden_dim=hidden_dim,
num_layers=1,
rnn_type='LSTM',
dropout=0.2
)
model = train_model(model, train_loader, test_loader, epochs=3, lr=0.002)
criterion = nn.CrossEntropyLoss(ignore_index=word2idx['<pad>'])
test_loss = evaluate(model, test_loader, criterion)
ppl = math.exp(test_loss)
experiments.append({
'Модель': f'LSTM (hidden={hidden_dim})',
'Скрытый размер': hidden_dim,
'Слои': 1,
'Dropout': 0.2,
'Оптимизатор': 'Adam',
'Перплексия': round(ppl, 1)
})
df = pd.DataFrame(experiments)
print("\n=== Результаты экспериментов ===")
print(tabulate(df, headers='keys', tablefmt='grid', showindex=False))
5. Генерация текста¶
def generate_text(model, seed_text, max_len=40, temperature=1.0, method='greedy'):
"""
Генерирует текст с помощью обученной RNN-модели.
Аргументы:
model: обученная RNNLanguageModel
seed_text (str): начальный текст (например, "I love")
max_len (int): максимальная длина генерируемого текста (в словах)
temperature (float): температура для sampling'а (1.0 = стандартная)
method (str): 'greedy' или 'sample'
"""
model.eval()
tokens = tokenize(seed_text)
indices = [word2idx['<bos>']] + [word2idx.get(t, word2idx['<unk>']) for t in tokens]
generated = tokens[:]
with torch.no_grad():
for _ in range(max_len):
# Подготавливаем входной тензор
x = torch.tensor(indices, dtype=torch.long).unsqueeze(1).to(device) # (seq_len, 1)
# Прямой проход
logits, _ = model(x)
# Берём логиты последнего слова
next_logits = logits[-1, 0, :] / temperature
probs = torch.softmax(next_logits, dim=-1)
if method == 'greedy':
next_idx = torch.argmax(probs).item()
else: # sampling
next_idx = torch.multinomial(probs, 1).item()
# Остановка при <eos>
if next_idx == word2idx['<eos>']:
break
# Добавляем слово
word = idx2word.get(next_idx, '<unk>')
generated.append(word)
indices.append(next_idx)
return ' '.join(generated)
# После обучения модели (например, lstm_model)
print("=== Примеры генерации ===")
seeds = ["i", "i think", "i think this", "i think this film"]
for seed in seeds:
greedy = generate_text(model, seed, max_len=25, method='greedy')
sampled = generate_text(model, seed, max_len=25, method='sample', temperature=0.8)
print(f"Начало: '{seed}'")
print(f" Жадно: {greedy}")
print(f" Sampling: {sampled}\n")
Задача 3¶
Проведите эксперименты с vocab_sizes = [5000, 10000, 20000]. Как размер vocab_sizes влияет на перплексию?
Задача 4¶
Проведите эксперименты с hidden_dims = [50, 100, 200, 400]. Как размер hidden_dims влияет на перплексию?
Задача 5¶
Проведите эксперименты с RNN, GRU и LSTM. Сделайте выводы.
Задача 6¶
Проведите эксперименты с num_layers_list = [1, 2, 3]. Как размер num_layers_list влияет на перплексию?
Задача 7¶
Придумайте ещё какие-нибудь улучшения и достигните перплексии меньше 100 на тестовом датасете.