Семинар · NLP на русском языке

Препроцессинг текста и эмбеддинги на практике

Полный путь от сырого HTML-документа до готовой классификационной модели и векторных представлений слов: очистка, токенизация, лемматизация, признаки TF-IDF, подбор гиперпараметров и эмбеддинги для русскоязычных корпусов.

Сырой HTML→ BeautifulSoup→ razdel / spaCy→ pymorphy3→ TF-IDF / эмбеддинги→ sklearn Pipeline→ модель
gensimcorusrazdel pymorphy3fasttextnavec nltkspacypandas numpyscikit-learnBeautifulSoup
Часть 1 — предобработка и классификация
01
Загрузка данных
coruspandas

corus — коллекция загрузчиков русскоязычных датасетов: новости, Wikipedia, художественная литература, коммерческие тексты. Каждый загрузчик возвращает итерируемый поток объектов с унифицированными полями (text, label, source), что избавляет от написания парсеров под каждый формат вручную.

from corus import load_lenta
# load_lenta читает bz2-архив построчно, не грузит всё в RAM
records = load_lenta('lenta-ru-news.csv.bz2')
import pandas as pd
df = pd.DataFrame(records)
df = df[['title', 'text', 'topic']].dropna()
Для большого корпуса (>1 млн записей) используйте итератор: не вызывайте list() сразу — передавайте поток в chunksize pandas или обрабатывайте по батчам.
02
Парсинг и очистка HTML
BeautifulSoupre

Веб-данные почти всегда содержат HTML-разметку, скрипты, inline-стили и мусорные символы. Порядок очистки важен: сначала парсер удаляет структуру документа, и только потом регулярные выражения зачищают остаточный текстовый шум.

from bs4 import BeautifulSoup
import re

def clean_html(raw: str) -> str:
    soup = BeautifulSoup(raw, 'lxml')
    for tag in soup(['script', 'style', 'nav', 'header', 'footer']):
        tag.decompose()          # удаляем нетекстовые блоки
    text = soup.get_text(' ')   # объединяем через пробел
    text = re.sub(r'\s+', ' ', text)
    text = re.sub(r'[^\w\s\-.,!?]', '', text)
    return text.strip()
Парсер lxml быстрее стандартного html.parser, но требует отдельной установки. Для битого HTML используйте html5lib — он толерантнее к ошибкам разметки.
Что удаляемТеги, CSS, JS, лишние пробелы, управляющие символы, Unicode-мусор
Что сохраняемЗнаки препинания (полезны для сегментации), дефисы в словах, числа
03
Токенизация
razdelnltkspacy

Токенизация — разбивка текста на минимальные единицы (токены). Для русского языка критично: пробельная токенизация не работает из-за клитик, слитных написаний и сложных знаков препинания.

from razdel import tokenize, sentenize

# Сегментация на предложения
sentences = [s.text for s in sentenize(text)]

# Токенизация с позициями (start, stop, text)
tokens = [t.text for t in tokenize(text)]

# spaCy: более тяжёлый, но даёт POS, dep, NER одним проходом
import spacy
nlp = spacy.load('ru_core_news_sm')
doc = nlp(text)
tokens_spacy = [t.text for t in doc if not t.is_space]
razdel
Быстро, только токены + предложения, ~1M токен/с
nltk
Много алгоритмов, MWE-токенизация, язык. универсальность
spaCy
POS, NER, dep за один проход, нужна языковая модель
04
Лемматизация и фильтрация
pymorphy3nltkspacy

Лемма — словарная форма слова («бежит» → «бежать»). Снижает размерность словаря в 5–10 раз. pymorphy3 — морфологический анализатор для русского/украинского, основан на словаре OpenCorpora.

import pymorphy3
from nltk.corpus import stopwords

morph = pymorphy3.MorphAnalyzer()
stop = set(stopwords.words('russian'))

def lemmatize(tokens: list[str]) -> list[str]:
    result = []
    for tok in tokens:
        tok_lower = tok.lower()
        if tok_lower in stop or len(tok_lower) < 3:
            continue
        parsed = morph.parse(tok_lower)[0]  # берём лучший разбор
        lemma = parsed.normal_form
        if parsed.tag.POS in {'NOUN', 'VERB', 'ADJF', 'ADJS'}:
            result.append(lemma)
    return result
pymorphy3 — преемник pymorphy2, совместим по API, поддерживает Python 3.10+. Если встретите код с pymorphy2 — просто замените импорт.
POS-теги OpenCorporaNOUN, VERB, INFN, ADJF (полное прил.), ADJS (краткое), ADVB, NPRO, NUMR, CONJ, PRCL, INTJ
Атрибуты parsed.normal_form — лемма
.tag.POS — часть речи
.tag.case — падеж
.score — вероятность разбора
05
Мешок слов и TF-IDF признаки
scikit-learnnumpy

Мешок слов (BoW) — матрица документ × термин, где значение = количество вхождений. Игнорирует порядок слов. TF-IDF штрафует частые слова во всём корпусе, усиливая редкие и информативные.

from sklearn.feature_extraction.text import CountVectorizer, TfidfTransformer

# Принимает уже лемматизированные строки: ' '.join(tokens)
vectorizer = CountVectorizer(
    max_df=0.95,        # игнорировать термины в >95% документов
    min_df=5,           # игнорировать термины в <5 документах
    max_features=50_000,
    ngram_range=(1, 2)  # униграммы + биграммы
)
X_count = vectorizer.fit_transform(corpus)  # sparse matrix

tfidf = TfidfTransformer(sublinear_tf=True)  # log(1+tf)
X_tfidf = tfidf.fit_transform(X_count)
TfidfVectorizer = CountVectorizer + TfidfTransformer в одном объекте. Разделяйте их, если хотите отдельно контролировать шаги в пайплайне или кешировать BoW-матрицу.
Формула TF-IDFtfidf(t,d) = tf(t,d) × log(N / df(t))
где N — число документов, df(t) — документная частота
sublinear_tf=TrueЗаменяет tf на 1+log(tf). Снижает влияние очень частых слов в длинных документах
06
Классификационная модель — sklearn Pipeline
scikit-learn

Pipeline связывает шаги предобработки и модель в единый объект. Гарантирует, что тестовые данные трансформируются теми же параметрами, что выучены на обучающей выборке — защита от data leakage.

from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.feature_extraction.text import TfidfVectorizer

pipe = Pipeline([
    ('tfidf', TfidfVectorizer(max_features=50_000, sublinear_tf=True)),
    ('clf',  LogisticRegression(C=1.0, max_iter=1000, solver='saga')),
], memory='./cache')  # memory кешир. fitted трансформеры на диск

pipe.fit(X_train, y_train)
print(pipe.score(X_test, y_test))
Параметры шагов обращаются через двойное подчёркивание: tfidf__max_features, clf__C. Именно в таком виде они передаются в GridSearch.
solver='saga'Подходит для больших датасетов и мультиклассификации. Поддерживает l1/l2/elasticnet регуляризацию
memory=Кешируем тяжёлые шаги (TF-IDF fit). При повторном поиске гиперпараметров не пересчитывает с нуля
07
Оптимизация гиперпараметров — HalvingGridSearchCV
scikit-learn

HalvingGridSearchCV — последовательное уполовинивание (Successive Halving): на первом раунде обучает все кандидаты на малой выборке, затем увеличивает объём данных и отбрасывает худших. В 5–10 раз быстрее обычного GridSearch при сопоставимом качестве.

from sklearn.experimental import enable_halving_search_cv  # обязательно!
from sklearn.model_selection import HalvingGridSearchCV

param_grid = {
    'tfidf__max_features': [30_000, 50_000, 100_000],
    'tfidf__ngram_range':   [(1, 1), (1, 2)],
    'clf__C':               [0.1, 1.0, 10.0],
    'clf__solver':          ['saga', 'lbfgs'],
}
search = HalvingGridSearchCV(
    pipe, param_grid,
    factor=3,          # на каждом раунде оставлять 1/3 кандидатов
    resource='n_samples',
    min_resources='smallest',
    cv=5, scoring='f1_macro', n_jobs=-1
)
search.fit(X_train, y_train)
print(search.best_params_, search.best_score_)
ПараметрЗначениеСмысл
factor3На каждом раунде оставляем лучшие 1/factor кандидатов
resourcen_samplesУвеличиваем обучающую выборку от раунда к раунду (можно n_estimators)
min_resources'smallest'Автоматически: минимум данных для запуска всех кандидатов
n_jobs-1Использовать все доступные CPU-ядра
Импорт enable_halving_search_cv обязателен и должен идти до импорта HalvingGridSearchCV — модуль всё ещё находится в sklearn.experimental и по умолчанию скрыт.
Часть 2 — векторные представления слов
08
Word2Vec — обучение своей модели
gensim

Word2Vec обучает нейронную сеть предсказывать слово по контексту (CBOW) или контекст по слову (Skip-gram). Слова с похожим контекстом получают похожие векторы. Gensim — де-факто стандарт для обучения на своём корпусе.

from gensim.models import Word2Vec

# sentences — список списков токенов (уже лемматизированные)
model = Word2Vec(
    sentences=sentences,
    vector_size=300,    # размерность вектора
    window=5,           # размер контекстного окна
    min_count=5,        # игнорировать слова реже 5 раз
    workers=8,          # параллельных потоков
    sg=1,               # 0=CBOW, 1=Skip-gram
    epochs=10
)
model.save('word2vec_ru.model')

# Аналогии: «король» - «мужчина» + «женщина» ≈ «королева»
model.wv.most_similar(
    positive=['король', 'женщина'],
    negative=['мужчина'], topn=5
)
CBOW (sg=0)
Быстрее, лучше для частых слов, меньше памяти
Skip-gram (sg=1)
Медленнее, лучше для редких слов и малых корпусов
vector_size
100 — быстро/компактно; 300 — стандарт; 600 — для больших корпусов
Для классификации документов вектор документа = среднее векторов его слов. Взвешенное среднее по TF-IDF весам даёт заметно лучший результат.
09
FastText — субсловные эмбеддинги
fasttextgensim

FastText расширяет Word2Vec: каждое слово = сумма векторов символьных n-грамм. Это позволяет строить векторы для незнакомых слов («неологизм», опечатки, редкие формы) — ключевое преимущество для морфологически богатых языков, включая русский.

# Вариант 1: обучение через fasttext (C-библиотека, быстрее)
import fasttext
model_ft = fasttext.train_unsupervised(
    'corpus.txt',   # файл: одна строка = один документ
    model='skipgram',
    dim=300, epoch=10, minCount=5
)
vec = model_ft.get_word_vector('токенизация')  # работает для OOV!

# Вариант 2: загрузка предобученных векторов через gensim
from gensim.models.fasttext import load_facebook_vectors
ft = load_facebook_vectors('cc.ru.300.bin')  # Common Crawl русский
ft.most_similar('машинное')
Символьные n-граммыПараметр minn=3, maxn=6: слово «текст» → «тек», «екс», «кст», «текс», «екст», «текст»
Предобученные моделиfasttext.cc.ai/crawl-vectors — 157 языков, включая русский (300d, 2M слов, Common Crawl + Wikipedia)
Библиотека fasttext (Facebook) требует компилятора C++. На Windows используйте WSL или пакет fasttext-wheel.
10
Navec — компактные русскоязычные эмбеддинги
navec

Navec — предобученные эмбеддинги от Natasha Project, оптимизированные для русского. Особенность: квантизация с помощью Product Quantization сжимает 500k × 300 векторов до ~50 МБ при незначительной потере качества. Спроектирован для работы вместе с Natasha/Yargy.

from navec import Navec

navec = Navec.load('navec_hudlit_v1_12B_500K_300d_100q.tar')
# hudlit — художественная литература, 12B токенов обучения

vec = navec['кот']            # numpy array shape (300,)
print('кот' in navec)         # True
print('qwerty' in navec)      # False

# Вектор документа (обработка OOV через пропуск токена)
import numpy as np
def doc_vector(tokens, model):
    vecs = [model[t] for t in tokens if t in model]
    return np.mean(vecs, axis=0) if vecs else np.zeros(300)
hudlit
12B токенов, худлит. Хорошо для общей лексики, литературного текста
news
Новостные тексты. Лучше для тематической классификации новостей
wiki
Wikipedia. Хорошо для технических и энциклопедических текстов
11
Сравнение подходов к векторизации
все методы
МетодOOVРазмерКогда использовать
BoW / TF-IDF—Sparse, ~50–500k dimsЛинейные модели, интерпретируемость, быстрый baseline
Word2Vec (своя)НетDense 100–300dЕсть большой доменный корпус (>50M токенов)
FastText (CC)ДаDense 300dОбщая лексика + OOV, новые/редкие слова
NavecНетDense 300d, 50 МБКомпактность, русский язык, Natasha-стек
Для продакшн-классификатора: сначала проверьте TF-IDF + LogReg как baseline. Эмбеддинги дают прирост главным образом при дефиците разметки или большой вариативности словаря.

Итоги: как всё это собирается вместе

Части 1 и 2 — не два разных пайплайна, а два способа получить признаки на выходе одного и того же предобработанного текста. После шагов 01–04 (загрузка → очистка HTML → токенизация → лемматизация) корпус готов к векторизации: TfidfVectorizer даёт разреженный, интерпретируемый и быстрый baseline внутри sklearn.Pipeline, а Word2Vec / FastText / Navec — плотные векторы, которые усредняются в вектор документа и подаются в ту же классификационную модель. HalvingGridSearchCV одинаково хорошо подбирает гиперпараметры в обоих случаях, если эмбеддинг обёрнут в собственный трансформер с методами fit/transform.

Практическое правило: начинайте с TF-IDF + LogisticRegression — это baseline, с которым сравнивают всё остальное. Переходите на эмбеддинги, если словарь корпуса нестабилен (много OOV, опечаток, неологизмов) или разметки мало и нужна генерализация за счёт предобученных векторов.

pip install corusзагрузчики данных
pip install beautifulsoup4 lxmlпарсинг HTML
pip install razdelтокенизация RU
pip install pymorphy3лемматизация RU
pip install nltkстоп-слова, корпуса
pip install spacyPOS / NER / dep
pip install scikit-learnTF-IDF, Pipeline, поиск
pip install gensimWord2Vec, FastText
pip install fasttext-wheelFastText (C++ backend)
pip install navecкомпактные эмбеддинги RU
pip install pandas numpyтаблицы, векторы