Лекция 8: Классификация статей по темам
Зачем классифицировать тексты по темам?
Заголовок раздела «Зачем классифицировать тексты по темам?»В реальных проектах часто нужно автоматически разбивать большие коллекции текстов на смысловые группы: новости по рубрикам, отзывы по продуктам, вакансии по специальностям.
Есть два основных подхода:
| Подход | Суть | Когда использовать |
|---|---|---|
| Тематическое моделирование | Находим темы сами, без разметки | Нет готовых категорий |
| Классификация с учителем | Обучаем модель на размеченных данных | Есть готовые категории |
В этой лекции разберём тематическое моделирование — подход без учителя.
Как компьютер «видит» текст?
Заголовок раздела «Как компьютер «видит» текст?»Прежде чем применять алгоритмы, текст нужно превратить в числа.
Мешок слов (Bag of Words)
Заголовок раздела «Мешок слов (Bag of Words)»Самый простой способ: считаем, сколько раз каждое слово встречается в документе.
Документ: "кошка ест рыбу, рыба вкусная"
Словарь: [кошка, ест, рыбу, рыба, вкусная]Вектор: [ 1, 1, 1, 1, 1 ]Порядок слов теряется, но для тематики это часто не важно.
Проблема: слова «и», «в», «на» встречаются в каждом документе и засоряют вектор. Нужен способ снизить их вес.
TF-IDF (Term Frequency — Inverse Document Frequency) взвешивает слова так, что частые во всей коллекции слова получают меньший вес, а редкие и характерные — больший.
Итоговая формула:
TF — частота термина в документе
Заголовок раздела «TF — частота термина в документе»Насколько часто слово встречается в документе :
где — количество вхождений слова в документ , знаменатель — общее число слов в документе.
Пример. Документ: «банк повысил ставку банк» (4 слова)
IDF — обратная частота документа
Заголовок раздела «IDF — обратная частота документа»Насколько редко слово встречается в коллекции из документов:
где — число документов, в которых встречается слово .
Пример. Коллекция из 1000 новостей:
- Слово «банк» встречается в 200 документах:
- Слово «ставка» встречается в 50 документах:
- Слово «и» встречается в 999 документах:
Чем реже слово в коллекции — тем выше IDF, тем важнее оно для конкретного документа. Выбор основания логарифма в формуле не имеет значения, поскольку изменение основания приводит к изменению веса каждого слова на постоянный множитель, что не влияет на соотношение весов.
Формула sklearn
Заголовок раздела «Формула sklearn»На практике sklearn использует сглаженную версию (во избежание деления на ноль и логарифма нуля):
После умножения TF × IDF вектор документа нормируется до единичной длины (L2-норма / Евклидова норма):
from sklearn.feature_extraction.text import TfidfVectorizer
docs = [ "Центральный банк повысил ключевую ставку", "Сборная России выиграла чемпионат", "Инфляция снизилась по данным Росстата", "Футболисты готовятся к матчу",]
vectorizer = TfidfVectorizer(max_features=100)X = vectorizer.fit_transform(docs)
print(X.shape) # (4, 100) — 4 документа, до 100 признаков
# Смотрим веса слов для первого документаfeature_names = vectorizer.get_feature_names_out()scores = X[0].toarray()[0]top = sorted(zip(feature_names, scores), key=lambda x: -x[1])[:5]for word, score in top: print(f"{word}: {score:.3f}")N-граммы
Заголовок раздела «N-граммы»Bag of Words и TF-IDF по умолчанию работают с отдельными словами (униграммами). Но фраза «центральный банк» несёт другой смысл, чем слова «центральный» и «банк» по отдельности.
N-грамма — последовательность из соседних слов (или символов).
| Тип | Пример для «центральный банк повысил ставку» | |
|---|---|---|
| Униграмма | 1 | «центральный», «банк», «повысил», «ставку» |
| Биграмма | 2 | «центральный банк», «банк повысил», «повысил ставку» |
| Триграмма | 3 | «центральный банк повысил», «банк повысил ставку» |
Зачем нужны n-граммы?
Заголовок раздела «Зачем нужны n-граммы?»- Устойчивые выражения: «центральный банк», «процентная ставка», «чемпионат мира» — каждая фраза несёт целостный смысл
- Отрицания: «не понравилось» vs «понравилось» — биграмма сохраняет отрицание
- Именованные сущности: «Санкт Петербург», «Реал Мадрид»
Формальное определение
Заголовок раздела «Формальное определение»Для документа множество -грамм:
Пример для , документ «банк повысил ставку»:
N-граммы в sklearn
Заголовок раздела «N-граммы в sklearn»Параметр ngram_range=(min_n, max_n) задаёт диапазон. При ngram_range=(1, 2) векторизатор добавит и унiграммы, и биграммы:
from sklearn.feature_extraction.text import TfidfVectorizer
docs = [ "Центральный банк повысил ключевую ставку", "Процентная ставка выросла до рекорда", "Сборная выиграла чемпионат мира по хоккею",]
# Только биграммыvectorizer_bigram = TfidfVectorizer(ngram_range=(2, 2), max_features=20)X = vectorizer_bigram.fit_transform(docs)print(vectorizer_bigram.get_feature_names_out())# ['банк повысил', 'выиграла чемпионат', 'выросла до', ...]
# Уни- и биграммы вместеvectorizer_mixed = TfidfVectorizer(ngram_range=(1, 2), max_features=50)X = vectorizer_mixed.fit_transform(docs)print(f"Признаков: {X.shape[1]}")Компромисс
Заголовок раздела «Компромисс»| Только уни-граммы | + биграммы | + триграммы | |
|---|---|---|---|
| Размер словаря | Маленький | Средний | Большой |
| Контекст | Нет | Частичный | Лучше |
| Риск разреженности | Низкий | Средний | Высокий |
| Скорость | Быстро | Умеренно | Медленно |
Для тематического моделирования обычно достаточно ngram_range=(1, 2).
Символьные n-граммы
Заголовок раздела «Символьные n-граммы»Помимо словесных, существуют символьные n-граммы — последовательности символов. Они устойчивы к опечаткам и помогают с морфологически богатыми языками (например, русским).
# Символьные биграммыchar_vectorizer = TfidfVectorizer(analyzer='char_wb', ngram_range=(3, 4))X = char_vectorizer.fit_transform(docs)# "банк" → " ба", "бан", "анк", "нк "Предобработка текста
Заголовок раздела «Предобработка текста»Прежде чем строить матрицу TF-IDF, текст нужно привести к единообразному виду. Два ключевых инструмента — стоп-слова и лемматизация.
Стоп-слова
Заголовок раздела «Стоп-слова»Стоп-слова — слова, которые встречаются почти в каждом документе и не несут информации о теме: предлоги, союзы, местоимения, вводные слова.
Примеры для русского языка:
| Тип | Примеры |
|---|---|
| Предлоги | в, на, с, по, из, за, к, от, до, при, над, под |
| Союзы | и, а, но, или, что, как, если, когда |
| Местоимения | он, она, они, мы, вы, это, то |
| Вводные | также, кроме, однако, впрочем |
Если их не убрать, эти слова получат высокий TF (часто встречаются в документе), но низкий IDF (встречаются везде) — и в итоге TF-IDF всё равно будет близок к нулю. Однако они раздувают размер словаря и замедляют обработку, поэтому их принято удалять явно.
STOP_WORDS = { "и", "в", "на", "с", "по", "из", "за", "к", "от", "до", "что", "как", "но", "а", "это", "все", "он", "она", "они", "для", "не", "при", "об", "со", "во", "под", "над", "или"}
words = ["центральный", "банк", "повысил", "ставку", "в", "стране"]filtered = [w for w in words if w not in STOP_WORDS]# ["центральный", "банк", "повысил", "ставку", "стране"]Лемматизация
Заголовок раздела «Лемматизация»Лемма — начальная, словарная форма слова. Все словоформы одного слова отображаются на одну лемму:
Без лемматизации «банк» и «банке» будут двумя разными признаками, хотя означают одно. В русском языке это особенно важно из-за богатой морфологии: у существительных 6 падежей, у глаголов — несколько форм времени, числа, рода.
Лемматизация vs стемминг. Близкий по идее метод — стемминг: отрезаем окончание по правилам, не обращаясь к словарю. Стемминг быстрее, но дает нечитаемые «корни»:
| Лемматизация | Стемминг | |
|---|---|---|
| «повышает» | повышать | повыш |
| «читателей» | читатель | читател |
| Требует словаря | да | нет |
| Качество | выше | ниже |
Для русского языка используют библиотеку pymorphy2:
# pip install pymorphy2import pymorphy2
morph = pymorphy2.MorphAnalyzer()
def lemmatize(word): """Возвращает лемму слова.""" return morph.parse(word)[0].normal_form
print(lemmatize("банке")) # банкprint(lemmatize("повысил")) # повышатьprint(lemmatize("ставкой")) # ставкаПолная предобработка
Заголовок раздела «Полная предобработка»import reimport pymorphy2
morph = pymorphy2.MorphAnalyzer()
STOP_WORDS = { "и", "в", "на", "с", "по", "из", "за", "к", "от", "до", "что", "как", "но", "а", "это", "все", "он", "она", "они", "для", "не", "при", "об", "со", "во", "под", "над", "или"}
def preprocess(text): text = text.lower() text = re.sub(r'[^а-яёa-z\s]', ' ', text) words = text.split() words = [morph.parse(w)[0].normal_form for w in words if w not in STOP_WORDS and len(w) > 2] return ' '.join(words)
print(preprocess("Центральный банк повысил ключевую ставку"))# "центральный банк повышать ключевой ставка"Тематическое моделирование с NMF
Заголовок раздела «Тематическое моделирование с NMF»NMF (Non-negative Matrix Factorization) — метод разложения матрицы на два множителя с неотрицательными значениями.
Пусть у нас есть коллекция из документов, описанных словами. Все документы образуют матрицу , где каждая строка — TF-IDF вектор одного документа.
NMF ищет приближённое разложение:
где — заданное число тем (обычно ).
- («документы × темы») — насколько каждая тема представлена в каждом документе
- («темы × слова») — насколько каждое слово характерно для каждой темы
слова → ┌─────────────┐ ┌────────┐ ┌─────────────┐документы│ X │ ≈ │ W │ × │ H │ └─────────────┘ └────────┘ └─────────────┘ n × m n × k k × mАлгоритм сам находит темы — мы только задаём их количество .
Целевая функция
Заголовок раздела «Целевая функция»NMF минимизирует ошибку реконструкции — расстояние Фробениуса между исходной матрицей и её приближением:
с ограничением на неотрицательность: , .
Благодаря этому ограничению матрицы интерпретируются как части целого: каждый документ — взвешенная сумма тем, каждая тема — взвешенная сумма слов. Отрицательные «вычитающие» компоненты, как в PCA, здесь невозможны.
Алгоритм: мультипликативные обновления
Заголовок раздела «Алгоритм: мультипликативные обновления»Задача не является выпуклой, но на практике хорошо работают мультипликативные правила обновления Ли и Сеунга (Lee & Seung, 2001). На каждой итерации:
где — поэлементное умножение, деление тоже поэлементное. Эти обновления гарантируют, что и остаются неотрицательными и ошибка монотонно убывает.
На практике sklearn использует более быстрый метод — Coordinate Descent или Multiplicative Update, выбираемый через параметр solver.
Пример на новостях
Заголовок раздела «Пример на новостях»from sklearn.feature_extraction.text import TfidfVectorizerfrom sklearn.decomposition import NMF
# Набор новостейarticles = [ "Центральный банк России повысил ключевую ставку до 16 процентов", "Инфляция в России замедлилась по данным Росстата", "Бюджет страны на следующий год одобрен парламентом", "Сборная России по футболу одержала победу в матче", "Хоккейная команда выиграла чемпионат мира", "Тренер объявил состав на следующую игру", "Новый смартфон получил улучшенную камеру и батарею", "Компания выпустила обновление операционной системы", "Разработчики представили искусственный интеллект для написания кода",]
# Шаг 1: Преобразуем тексты в TF-IDF матрицуvectorizer = TfidfVectorizer( max_features=50, stop_words=None # для русского языка нужен отдельный список стоп-слов)X = vectorizer.fit_transform(articles)
# Шаг 2: Применяем NMF, ищем 3 темыnmf = NMF(n_components=3, random_state=42)W = nmf.fit_transform(X) # документы × темыH = nmf.components_ # темы × слова
# Шаг 3: Смотрим на топ-слова каждой темыfeature_names = vectorizer.get_feature_names_out()
for topic_idx, topic_weights in enumerate(H): top_words_idx = topic_weights.argsort()[-5:][::-1] top_words = [feature_names[i] for i in top_words_idx] print(f"Тема {topic_idx + 1}: {', '.join(top_words)}")Вывод (примерный):
Тема 1: ставку, банк, инфляция, бюджет, росстатТема 2: матче, команда, чемпионат, тренер, футболуТема 3: смартфон, обновление, разработчики, система, интеллектОпределяем тему каждого документа
Заголовок раздела «Определяем тему каждого документа»import numpy as np
topic_labels = {0: "Экономика", 1: "Спорт", 2: "Технологии"}
for i, article in enumerate(articles): dominant_topic = np.argmax(W[i]) confidence = W[i][dominant_topic] print(f"[{topic_labels[dominant_topic]}] {article[:50]}...")Выбор количества тем
Заголовок раздела «Выбор количества тем»Ошибка реконструкции
Заголовок раздела «Ошибка реконструкции»Reconstruction error — это значение целевой функции NMF после обучения, норма Фробениуса:
При одна тема не может описать разнообразие текстов — ошибка большая. При (число тем = число документов) каждый документ — своя тема, ошибка близка к нулю.
Метод локтя: строим график и ищем точку «перегиба» — после неё добавление новых тем почти не снижает ошибку.
Ошибка │ │ ● │ ● │ ● │ ● ← локоть │ ●───●───● └───────────────────── k (число тем)import matplotlib.pyplot as pltfrom sklearn.decomposition import NMF
errors = []n_topics_range = range(2, 15)
for k in n_topics_range: model = NMF(n_components=k, random_state=42, max_iter=300) model.fit(X) errors.append(model.reconstruction_err_)
plt.figure(figsize=(8, 4))plt.plot(list(n_topics_range), errors, marker='o')plt.xlabel('Количество тем (k)')plt.ylabel('Ошибка реконструкции ||X - WH||')plt.title('Метод локтя для выбора числа тем')plt.grid(True)plt.show()Относительное уменьшение ошибки
Заголовок раздела «Относительное уменьшение ошибки»Более формальный способ найти локоть — смотреть на относительное снижение ошибки при увеличении на 1:
deltas = []for i in range(1, len(errors)): delta = (errors[i-1] - errors[i]) / errors[i-1] deltas.append(delta)
# Оптимальное k — где дельта резко падаетfor k, d in zip(list(n_topics_range)[1:], deltas): print(f"k={k}: улучшение {d*100:.1f}%")Когда улучшение падает ниже, например, 5% — дальше увеличивать нет смысла.
LDA — вероятностный подход
Заголовок раздела «LDA — вероятностный подход»Простыми словами
Заголовок раздела «Простыми словами»Представьте, что вы редактор новостного агентства. Когда журналист пишет статью про «санкции против российских банков после чемпионата», он мешает в одном тексте экономику и спорт. Это нормально — реальные статьи редко бывают про одну чёткую тему.
NMF находит одну доминирующую тему для каждого документа. LDA делает иначе: он считает, что каждый документ — это смесь нескольких тем в разных пропорциях.
Например, для нашей коллекции новостей LDA может сказать:
«Центральный банк повысил ставку» → Экономика: 95%, Спорт: 2%, Технологии: 3%
«Банк ВТБ стал спонсором хоккейного клуба» → Экономика: 55%, Спорт: 40%, Технологии: 5%
«ИИ-стартап привлёк инвестиции» → Экономика: 30%, Спорт: 5%, Технологии: 65%Такое «мягкое» отнесение к темам часто ближе к реальности, чем жёсткое «одна статья — одна тема».
Как LDA устроен внутри
Заголовок раздела «Как LDA устроен внутри»LDA (Latent Dirichlet Allocation) — вероятностная порождающая модель. Она предполагает, что текст «создавался» следующим образом:
- Для документа выбирается распределение по темам:
- Для каждой темы выбирается распределение по словам:
- Каждое слово в документе генерируется так:
- сначала выбирается тема:
- затем слово из этой темы:
По наблюдаемым словам алгоритм делает обратный вывод — восстанавливает скрытые темы и тематические смеси документов .
Распределение Дирихле
Заголовок раздела «Распределение Дирихле»Распределение Дирихле — распределение вероятностных распределений. Оно задаёт, насколько «размазаны» веса по категориям:
- При малом — распределение концентрируется у вершин симплекса: документ, скорее всего, относится к одной теме
- При большом — распределение равномерно: документ смешивает много тем
На практике и — гиперпараметры модели.
LDA на примере наших новостей
Заголовок раздела «LDA на примере наших новостей»LDA работает с матрицей счётчиков слов (CountVectorizer), а не с TF-IDF — это принципиальное отличие. Вероятностная модель предполагает, что слова «выбирались» случайно из темы, а не взвешивались.
from sklearn.feature_extraction.text import CountVectorizerfrom sklearn.decomposition import LatentDirichletAllocationimport numpy as np
articles = [ "Центральный банк России повысил ключевую ставку до 16 процентов", "Инфляция в России замедлилась по данным Росстата", "Бюджет страны на следующий год одобрен парламентом", "Сборная России по футболу одержала победу в матче", "Хоккейная команда выиграла чемпионат мира", "Тренер объявил состав на следующую игру", "Новый смартфон получил улучшенную камеру и батарею", "Компания выпустила обновление операционной системы", "Разработчики представили искусственный интеллект для написания кода",]
# Шаг 1: CountVectorizer — не TF-IDF, просто счётчики словcount_vec = CountVectorizer(max_features=100)X_counts = count_vec.fit_transform(articles)
# Шаг 2: Обучаем LDA на 3 темыlda = LatentDirichletAllocation( n_components=3, max_iter=50, random_state=42)W_lda = lda.fit_transform(X_counts) # матрица n×k: доли тем в каждом документе
# Шаг 3: Смотрим топ-слова темfeature_names = count_vec.get_feature_names_out()topic_labels = ["Тема A", "Тема B", "Тема C"]
for i, topic in enumerate(lda.components_): top_idx = topic.argsort()[-5:][::-1] top_words = [feature_names[j] for j in top_idx] print(f"{topic_labels[i]}: {', '.join(top_words)}")Вывод (примерный):
Тема A: банк, ставку, инфляция, бюджет, процентов ← экономикаТема B: матче, команда, чемпионат, тренер, игру ← спортТема C: смартфон, разработчики, систем, код, камеру ← технологииШаг 4: Читаем тематические смеси документов.
W_lda[i] — вектор длины , сумма элементов равна 1. Это и есть вероятностное распределение тем для документа :
# Смотрим, как каждая статья смешивает темыfor i, article in enumerate(articles): weights = W_lda[i] # Форматируем в виде процентов mix = ", ".join( f"{topic_labels[k]}: {w*100:.0f}%" for k, w in enumerate(weights) if w > 0.05 # показываем только значимые доли ) print(f"{article[:40]:40s} | {mix}")Вывод (примерный):
Центральный банк России повысил клю... | Тема A: 92%, Тема C: 5%Сборная России по футболу одержала... | Тема B: 88%, Тема A: 8%Новый смартфон получил улучшенную ... | Тема C: 91%Именно здесь LDA отличается от NMF: он явно показывает «смешанные» статьи вместо того чтобы навязать одну тему.
Шаг 5: Нормировка весов слов.
lda.components_ — ненормированные счётчики. Нормируем, чтобы получить вероятности слов внутри темы:
phi = lda.components_ / lda.components_.sum(axis=1, keepdims=True)# Теперь для каждой темы k: sum(phi[k]) == 1.0print(f"P('банк' | Тема A) = {phi[0, count_vec.vocabulary_.get('банк', 0)]:.4f}")NMF vs LDA
Заголовок раздела «NMF vs LDA»| Критерий | NMF | LDA |
|---|---|---|
| Математическая основа | Матричное разложение | Вероятностная порождающая модель |
| Целевая функция | Максимизация правдоподобия | |
| Вход | TF-IDF или счётчики | Только счётчики слов |
| Веса | Неотрицательные числа | Вероятности () |
| Пересечение тем | Нет явного ограничения | Заложено в модель |
| Интерпретация | Части целого | Вероятностное смешение |
| Скорость | Быстрее | Медленнее |
| В sklearn | NMF | LatentDirichletAllocation |
Когда выбирать NMF: быстрый разведочный анализ, работа с TF-IDF, нужна высокая скорость.
Когда выбирать LDA: важна вероятностная интерпретация, документы явно смешивают несколько тем, нужна оценка неопределённости.
Полный пример: классификация новостей
Заголовок раздела «Полный пример: классификация новостей»from sklearn.feature_extraction.text import TfidfVectorizerfrom sklearn.decomposition import NMFimport numpy as npimport re
STOP_WORDS = { "и", "в", "на", "с", "по", "из", "за", "к", "от", "до", "что", "как", "но", "а", "это", "все", "он", "она", "они", "для", "не", "при", "об", "со", "во", "под", "над", "или"}
def preprocess(text): text = text.lower() text = re.sub(r'[^а-яёa-z\s]', ' ', text) words = [w for w in text.split() if w not in STOP_WORDS and len(w) > 2] return ' '.join(words)
class TopicClassifier: def __init__(self, n_topics=5, n_top_words=10): self.n_topics = n_topics self.n_top_words = n_top_words self.vectorizer = TfidfVectorizer(max_features=500) self.model = NMF(n_components=n_topics, random_state=42)
def fit(self, texts): cleaned = [preprocess(t) for t in texts] X = self.vectorizer.fit_transform(cleaned) self.W = self.model.fit_transform(X) return self
def get_topics(self): """Возвращает топ-слова для каждой темы.""" feature_names = self.vectorizer.get_feature_names_out() topics = {} for i, topic in enumerate(self.model.components_): top_idx = topic.argsort()[-self.n_top_words:][::-1] topics[i] = [feature_names[j] for j in top_idx] return topics
def predict(self, texts): """Определяет тему для каждого текста.""" cleaned = [preprocess(t) for t in texts] X = self.vectorizer.transform(cleaned) W = self.model.transform(X) return np.argmax(W, axis=1)
# Использованиеarticles = [ "Центральный банк повысил ключевую ставку", "Инфляция снизилась по данным Росстата", "Сборная России выиграла чемпионат", "Футболисты готовятся к матчу", "Новый смартфон получил улучшенную камеру", "Разработчики выпустили обновление системы",]
classifier = TopicClassifier(n_topics=3)classifier.fit(articles)
# Смотрим темыtopics = classifier.get_topics()for topic_id, words in topics.items(): print(f"Тема {topic_id}: {', '.join(words[:5])}")
# Классифицируем новые статьиnew_articles = ["Банк снизил процентную ставку по кредитам"]predictions = classifier.predict(new_articles)print(f"Тема: {predictions[0]}")