Вы когда-нибудь задумывались, как Netflix понимает, что вы ненавидите романтические комедии ещё до того, как начнёте их смотреть? Или как Amazon может предсказать, будет ли отзыв о продукте восторженным письмом или цифровым возмущением? Добро пожаловать в увлекательный мир анализа настроений, где машины учатся читать между строк человеческих эмоций, по одному слову за раз.

Сегодня мы создадим собственную систему анализа настроений, используя BERT (Bidirectional Encoder Representations from Transformers) и PyTorch. Не волнуйтесь, если это звучит как беспорядочная аббревиатура прямо сейчас — к концу этого пути вы будете достаточно хорошо разбираться в трансформерах, чтобы произвести впечатление на своего кота (или по крайней мере на коллег).

Понимание BERT: швейцарский армейский нож обработки естественного языка

BERT — это не просто очередная аббревиатура в постоянно растущей куче жаргона искусственного интеллекта. Представьте его как гения эпохи Возрождения в области обработки естественного языка — он читает текст одновременно вперёд и назад, что делает его невероятно хорошим в понимании контекста. В отличие от своих предшественников, которые читали текст как книгу (слева направо), BERT больше похож на того друга, который портит фильмы, читая концовку первым, а затем возвращается, чтобы понять, как мы к этому пришли.

Часть «Bidirectional» означает, что BERT рассматривает всё предложение сразу, учитывая как слова, которые идут до каждого токена, так и после него. Это революционно, потому что контекст — это всё в языке. Слово «банк» означает совершенно разные вещи, когда вы говорите о деньгах или реке, и BERT это понимает.

Настройка нашей среды разработки

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

import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
from transformers import BertTokenizer, BertModel, BertForSequenceClassification
from transformers import AdamW, get_linear_schedule_with_warmup
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score, classification_report
import matplotlib.pyplot as plt
import seaborn as sns
from tqdm import tqdm
import warnings
warnings.filterwarnings('ignore')

Если у вас отсутствуют какие-либо из этих пакетов, pip — ваш друг:

pip install torch transformers pandas numpy scikit-learn matplotlib seaborn tqdm

Данные: наши цифровые эмоции

Для этого учебника мы будем работать с отзывами о фильмах — потому что ничто так не говорит о «человеческих эмоциях», как чьи-то страстные чувства о том, оправдал ли фильм про супергероев ожидания. Мы будем использовать знаменитый набор данных IMDB, но прелесть того, что мы создаём, заключается в том, что он может работать с любыми текстовыми данными, где вам нужно определить настроение.

# Создадим некоторые примерные данные для демонстрации
# На практике вы бы загрузили это из CSV-файла или базы данных
sample_reviews = [
    ("This movie was absolutely fantastic! The acting was superb.", 1),
    ("Worst film I've ever seen. Complete waste of time.", 0),
    ("Pretty decent movie, nothing special but enjoyable.", 1),
    ("I fell asleep halfway through. Boring and predictable.", 0),
    ("Masterpiece! Every scene was perfectly crafted.", 1),
    ("The plot made no sense and the dialogue was terrible.", 0),
]
# Преобразуем в DataFrame для более удобной обработки
df = pd.DataFrame(sample_reviews, columns=['text', 'sentiment'])
print(f"Dataset shape: {df.shape}")
print(f"Sentiment distribution:\n{df['sentiment'].value_counts()}")

Создание нашего пользовательского класса набора данных

PyTorch любит, чтобы его наборы данных были правильно отформатированы (в этом смысле он немного перфекционист). Нам нужно создать пользовательский класс набора данных, который будет обрабатывать предобработку текста и токенизацию. Именно здесь BERT становится немного придирчивым — ему нужны специальные токены, маски внимания и правильное заполнение.

class SentimentDataset(Dataset):
    def __init__(self, texts, targets, tokenizer, max_len=128):
        """
        Инициализация набора данных
        Args:
            texts: Список текстовых образцов
            targets: Список меток настроений (0 для отрицательного, 1 для положительного)
            tokenizer: Токенизатор BERT
            max_len: Максимальная длина последовательности
        """
        self.texts = texts
        self.targets = targets
        self.tokenizer = tokenizer
        self.max_len = max_len
    def __len__(self):
        return len(self.texts)
    def __getitem__(self, idx):
        text = str(self.texts[idx])
        target = self.targets[idx]
        # Токенизация текста
        encoding = self.tokenizer.encode_plus(
            text,
            add_special_tokens=True,        # Добавить токены [CLS] и [SEP]
            max_length=self.max_len,        # Дополнить или обрезать до max_len
            return_token_type_ids=False,    # Нам не нужны идентификаторы типа токена для анализа настроений
            padding='max_length',           # Дополнить до max_length
            truncation=True,                # Обрезать, если длиннее max_length
            return_attention_mask=True,     # Вернуть маски внимания
            return_tensors='pt',            # Вернуть тензоры PyTorch
        )
        return {
            'text': text,
            'input_ids': encoding['input_ids'].flatten(),
            'attention_mask': encoding['attention_mask'].flatten(),
            'targets': torch.tensor(target, dtype=torch.long)
        }

Токенизатор выступает в роли переводчика для BERT — он преобразует наш текст, понятный человеку, в числа, которые BERT может понять. Маска внимания сообщает BERT, какие токены являются реальными словами, а какие просто заполнением (потому что не все предложения равны по длине).

Создание нашего классификатора настроений

Теперь самое интересное — создание нашего классификатора настроений! Мы будем строить на основе предварительно обученного модели BERT, потому что, давайте признаем, у нас нет вычислительных ресурсов Google для обучения BERT с нуля (и ни у кого нет такого счёта за электроэнергию).

class BertSentimentClassifier(nn.Module):
    def __init__(self, n_classes=2, model_name='bert-base-uncased'):
        super(BertSentimentClassifier, self).__init__()
        self.bert = BertModel.from_pretrained(model_name)
        self.dropout = nn.Dropout(p=0.3)  # Предотвращение переобучения
        self.classifier = nn.Linear(self.bert.config.hidden_size, n_classes)
    def forward(self, input_ids, attention_mask):
        # Получить вывод BERT
        outputs = self.bert(
            input_ids=input_ids,
            attention_mask=attention_mask
        )
        # Использовать представление токена [CLS] для классификации
        pooled_output = outputs.pooler_output
        # Применитьdropout для регуляризации
        output = self.dropout(pooled_output)
        # Окончательный классификационный слой
        return self.classifier(output)

Магия происходит в pooler_output — это понимание BERT всего предложения, сжатое в один вектор. Затем мы передаём это через простой слой нейронной сети для двоичной классификации (положительное или отрицательное).

Конвейер обучения

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

def train_model(model, data_loader, loss_fn, optimizer, device, scheduler, n_examples):
    model = model.train()
    losses = []
    correct_predictions = 0
    progress_bar = tqdm(data_loader, desc="Training", leave=False)
    for batch_idx, data in enumerate(progress_bar):
        input_ids = data["input_ids"].to(device)
        attention_mask = data["attention_mask"].to(device)
        targets = data["targets"].to(device)
        # Сброс градиентов
        optimizer.zero_grad()
        # Прямой проход
        outputs = model(input_ids=input_ids, attention_mask=attention_mask)
        # Вычисление потерь
        loss = loss_fn(outputs, targets)
        # Получение прогнозов
        _, preds = torch.max(outputs, dim=1)
        correct_predictions += torch.sum(preds == targets)
        losses.append(loss.item())