Перейти к основному содержимому

Практикум — тональность отзывов на PyTorch

Разработчику Начальный уровень

Задача

Тональность (sentiment) — настроение текста: положительное, отрицательное, нейтральное. В продукте по отзыву ресторана или тикету поддержки модель выдаёт метку класса.

В практикуме MNIST вход — матрица пикселей 28×28. Здесь вход — строка. Сначала её кодируют в числа, затем обучают нейросеть. Полный цикл тот же, что в PyTorch для разработчика: DatasetDataLoader → forward → loss → backward() → сохранение весов.

Перед стартом

Оценка времени — 1,5–3 часа.

Этапы

  1. Baseline в sklearn — TF-IDF и логистическая регрессия
  2. Класс Vocabulary — текст → индексы токенов
  3. Dataset и DataLoader — батчи для PyTorch
  4. ReviewClassifiernn.Embedding и MLP
  5. Обучение с BCEWithLogitsLoss и проверка на val
  6. Инференс на новых строках

Термины практикума

  • Токен — слово после нормализации (нижний регистр, без знаков препинания).
  • Словарь (vocabulary) — таблица "токен → индекс"; строится только по train.
  • PAD — специальный токен заполнения; выравнивает короткие тексты до max_len.
  • UNK — токен для слов, которых не было в словаре при обучении.
  • Embedding — обучаемая матрица: по индексу слова возвращается вектор фиксированной длины.
  • MLP (многослойный перцептрон) — несколько полносвязных слоёв с нелинейностью между ними.
  • Mean pooling — усреднение векторов слов документа в один вектор.
  • Логит — сырой выход нейрона до sigmoid; порог 0 соответствует вероятности 0,5.
  • Val (validation) — отложенная выборка для оценки без подстройки весов.

Этап 0. Данные и baseline в sklearn

Учебный набор — 24 коротких отзыва на английском. Метка 1 — positive, 0 — negative. На проекте загрузите CSV с колонками text и label через pd.read_csv и подставьте вместо списка RAW.

import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline
from sklearn.metrics import accuracy_score, classification_report

RAW = [
("amazing food and friendly staff", 1),
("best pizza in town", 1),
("loved the atmosphere", 1),
("quick service great coffee", 1),
("would definitely come again", 1),
("fresh ingredients tasty soup", 1),
("wonderful experience overall", 1),
("highly recommend this place", 1),
("great value for money", 1),
("excellent dessert", 1),
("slow service cold food", 0),
("rude waiter never again", 0),
("overpriced and bland", 0),
("worst meal ever", 0),
("dirty tables awful smell", 0),
("waited an hour for appetizers", 0),
("food arrived cold", 0),
("terrible experience", 0),
("not worth the money", 0),
("would not recommend", 0),
("disappointing portions", 0),
("no flavor at all", 0),
("long wait bad attitude", 0),
("horrible coffee", 0),
]

df = pd.DataFrame(RAW, columns=["text", "label"])
train_df, val_df = train_test_split(
df, test_size=0.25, random_state=42, stratify=df["label"]
)

sklearn_pipe = Pipeline([
("tfidf", TfidfVectorizer(max_features=500, ngram_range=(1, 2))),
("clf", LogisticRegression(max_iter=1000)),
])
sklearn_pipe.fit(train_df["text"], train_df["label"])
pred = sklearn_pipe.predict(val_df["text"])
print("sklearn accuracy:", accuracy_score(val_df["label"], pred))
print(classification_report(val_df["label"], pred))

Разбор:

  • train_test_split с stratify сохраняет долю классов в train и val — см. разбиение данных.
  • TfidfVectorizer превращает каждый отзыв в разреженный вектор — подробнее в текст как признаки.
  • Pipeline гарантирует, что TF-IDF на val использует словарь, выученный на train.

Запишите accuracy sklearn. PyTorch-модель на таком корпусе должна быть сопоставима с этим числом. На больших корпусах TF-IDF + логистическая регрессия часто остаётся сильным baseline до fine-tune трансформера.


Этап 1. Vocabulary и токенизация

Токенизация — разбиение строки на токены. В учебном коде — пробелы и нижний регистр. В продакшене подключают spaCy или токенайзер из Hugging Face.

import re
from collections import Counter

UNK, PAD = "<unk>", "<pad>"

class Vocabulary:
def __init__(self, min_freq: int = 1):
self.min_freq = min_freq
self.token_to_idx = {PAD: 0, UNK: 1}
self.idx_to_token = [PAD, UNK]

@staticmethod
def tokenize(text: str) -> list[str]:
text = text.lower().strip()
text = re.sub(r"[^a-z0-9\s]", "", text)
return [t for t in text.split() if t]

def build(self, texts: list[str]) -> None:
counts = Counter()
for text in texts:
counts.update(self.tokenize(text))
for token, freq in counts.items():
if freq >= self.min_freq and token not in self.token_to_idx:
self.token_to_idx[token] = len(self.idx_to_token)
self.idx_to_token.append(token)

def encode(self, text: str, max_len: int) -> list[int]:
tokens = self.tokenize(text)[:max_len]
ids = [self.token_to_idx.get(t, 1) for t in tokens] # 1 = UNK
while len(ids) < max_len:
ids.append(0) # 0 = PAD
return ids

def __len__(self) -> int:
return len(self.idx_to_token)

vocab = Vocabulary(min_freq=1)
vocab.build(train_df["text"].tolist())
print("vocab size:", len(vocab))

Разбор:

  • Индекс 0 зарезервирован под PAD — дополнение коротких отзывов до фиксированной длины max_len.
  • Индекс 1UNK для слов вне словаря.
  • Метод build вызывают только на train; иначе val "подсказывает" модели редкие слова — утечка признаков.

Этап 2. Dataset и DataLoader

Dataset описывает один элемент выборки. DataLoader собирает элементы в батчи (пачки) для GPU и параллельной загрузки — тот же приём, что в 333.

import torch
from torch.utils.data import Dataset, DataLoader

MAX_LEN = 12

class ReviewDataset(Dataset):
def __init__(self, frame, vocab: Vocabulary, max_len: int):
self.texts = frame["text"].tolist()
self.labels = frame["label"].astype(float).tolist()
self.vocab = vocab
self.max_len = max_len

def __len__(self) -> int:
return len(self.texts)

def __getitem__(self, idx: int):
x = torch.tensor(
self.vocab.encode(self.texts[idx], self.max_len), dtype=torch.long
)
y = torch.tensor(self.labels[idx], dtype=torch.float32)
return x, y

train_loader = DataLoader(
ReviewDataset(train_df, vocab, MAX_LEN), batch_size=8, shuffle=True
)
val_loader = DataLoader(
ReviewDataset(val_df, vocab, MAX_LEN), batch_size=8, shuffle=False
)
  • dtype=torch.long — целые индексы для nn.Embedding.
  • shuffle=True на train перемешивает примеры каждую эпоху.
  • shuffle=False на val — порядок не влияет на метрику.

Этап 3. Модель с Embedding

One-hot кодирование слова — вектор длины |V| с единицей в одной позиции. Умножение one-hot на матрицу весов Linear(|V|, d) выбирает одну строку матрицы. Слой nn.Embedding выполняет тот же выбор по индексу слова — экономнее по памяти на больших словарях. См. также функции потерь и активации и кодирование категорий.

import torch.nn as nn

class ReviewClassifier(nn.Module):
def __init__(self, vocab_size: int, embed_dim: int = 32, hidden: int = 64):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
self.mlp = nn.Sequential(
nn.Linear(embed_dim, hidden),
nn.ReLU(),
nn.Linear(hidden, 1),
)

def forward(self, x: torch.Tensor) -> torch.Tensor:
emb = self.embedding(x) # (batch, seq_len, embed_dim)
pooled = emb.mean(dim=1) # mean pooling
return self.mlp(pooled).squeeze(1) # (batch,) логиты

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = ReviewClassifier(len(vocab), embed_dim=32, hidden=64).to(device)

Разбор:

  • padding_idx=0 — вектор PAD не участвует в обновлении весов
  • Mean pooling усредняет эмбеддинги по позициям; порядок слов учитывается слабо (как bag-of-words)
  • Для порядка слов подходят RNN и трансформер
  • На выходе один логит на отзыв — бинарная классификация

Проверка размерностей:

xb, yb = next(iter(train_loader))
xb = xb.to(device)
out = model(xb)
assert out.shape == yb.shape
print("OK", out.shape)

Этап 4. Обучение

import torch.optim as optim

criterion = nn.BCEWithLogitsLoss()
optimizer = optim.Adam(model.parameters(), lr=1e-3)
EPOCHS = 80

def run_epoch(loader, train: bool) -> tuple[float, float]:
model.train(train)
total_loss, correct, total = 0.0, 0, 0
with torch.set_grad_enabled(train):
for xb, yb in loader:
xb, yb = xb.to(device), yb.to(device)
if train:
optimizer.zero_grad()
logits = model(xb)
loss = criterion(logits, yb)
if train:
loss.backward()
optimizer.step()
total_loss += loss.item() * xb.size(0)
preds = (logits > 0).float()
correct += (preds == yb).sum().item()
total += xb.size(0)
return total_loss / max(total, 1), correct / max(total, 1)

for epoch in range(1, EPOCHS + 1):
tr_loss, tr_acc = run_epoch(train_loader, train=True)
va_loss, va_acc = run_epoch(val_loader, train=False)
if epoch % 20 == 0 or epoch == 1:
print(f"epoch {epoch:3d} train loss {tr_loss:.3f} acc {tr_acc:.2f} "
f"val loss {va_loss:.3f} acc {va_acc:.2f}")

Разбор шага обучения:

  • optimizer.zero_grad() обнуляет градиенты прошлого шага
  • loss.backward() считает градиенты по autograd
  • optimizer.step() обновляет веса
  • BCEWithLogitsLoss применяет sigmoid внутри; на выходе модели подают логиты
  • logits > 0 — предсказание класса 1; эквивалент вероятности > 0,5

На маленьком корпусе accuracy на val может колебаться — ориентируйтесь на loss и сравнение со sklearn baseline.

torch.save(model.state_dict(), "review_sentiment.pt")

Формат state_dict — только веса; при загрузке нужна та же архитектура ReviewClassifier — как в сохранении модели.


Этап 5. Инференс

model.eval()
samples = ["great food", "terrible service and rude staff"]

with torch.no_grad():
for text in samples:
ids = torch.tensor(
[vocab.encode(text, MAX_LEN)], dtype=torch.long, device=device
)
logit = model(ids).item()
prob = torch.sigmoid(torch.tensor(logit)).item()
label = "positive" if logit > 0 else "negative"
print(f"{text!r} -> {label} (p={prob:.2f})")
  • model.eval() отключает dropout и фиксирует режим BatchNorm
  • torch.no_grad() экономит память — градиенты на инференсе не нужны
  • Тот же vocab, MAX_LEN и препроцессинг, что при обучении

MNIST и текст — что общего

АспектMNISTЭтот практикум
Входтензор (N, 1, 28, 28)индексы (N, seq_len)
СлоиConv2d, poolingEmbedding, Linear
LossCrossEntropyLoss, метки 0…9BCEWithLogitsLoss, метки 0.0/1.0
Данныеtorchvision.datasets.MNISTсвой Dataset
BaselineTF-IDF + sklearn

Оба практикума тренируют сквозной пайплайн перед переходом к трансформерам.


Дальнейшие шаги


Связанные материалы