Практикум — тональность отзывов на PyTorch
Задача
Тональность (sentiment) — настроение текста: положительное, отрицательное, нейтральное. В продукте по отзыву ресторана или тикету поддержки модель выдаёт метку класса.
В практикуме MNIST вход — матрица пикселей 28×28. Здесь вход — строка. Сначала её кодируют в числа, затем обучают нейросеть. Полный цикл тот же, что в PyTorch для разработчика: Dataset → DataLoader → forward → loss → backward() → сохранение весов.
Перед стартом
- Python 3.10+, пакеты
torch,scikit-learn,pandas - Текст как признаки — TF-IDF и словарь
- PyTorch для разработчика — тензоры, autograd, функции потерь
- GPU необязателен; учебный корпус маленький
Оценка времени — 1,5–3 часа.
Этапы
- Baseline в sklearn — TF-IDF и логистическая регрессия
- Класс
Vocabulary— текст → индексы токенов DatasetиDataLoader— батчи для PyTorchReviewClassifier—nn.Embeddingи MLP- Обучение с
BCEWithLogitsLossи проверка на val - Инференс на новых строках
Термины практикума
- Токен — слово после нормализации (нижний регистр, без знаков препинания).
- Словарь (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. - Индекс
1— UNK для слов вне словаря. - Метод
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()считает градиенты по autogradoptimizer.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 и фиксирует режим BatchNormtorch.no_grad()экономит память — градиенты на инференсе не нужны- Тот же
vocab,MAX_LENи препроцессинг, что при обучении
MNIST и текст — что общего
| Аспект | MNIST | Этот практикум |
|---|---|---|
| Вход | тензор (N, 1, 28, 28) | индексы (N, seq_len) |
| Слои | Conv2d, pooling | Embedding, Linear |
| Loss | CrossEntropyLoss, метки 0…9 | BCEWithLogitsLoss, метки 0.0/1.0 |
| Данные | torchvision.datasets.MNIST | свой Dataset |
| Baseline | — | TF-IDF + sklearn |
Оба практикума тренируют сквозной пайплайн перед переходом к трансформерам.
Дальнейшие шаги
- Расширить корпус (Yelp, RuSentiment) и сравнить метрики с TF-IDF
- Добавить 1D-CNN или BiLSTM — обзор архитектур в 6-03
- Fine-tune BERT для русского — практика с transformers
- Распознавание текста в продуктах
Связанные материалы
- PyTorch для разработчика
- Python для ML — мост к PyTorch
- Классическое машинное обучение
- Подготовка данных для ML
- Модели обучения для текста