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

Лекция 8: Классификация статей по темам

В реальных проектах часто нужно автоматически разбивать большие коллекции текстов на смысловые группы: новости по рубрикам, отзывы по продуктам, вакансии по специальностям.

Есть два основных подхода:

ПодходСутьКогда использовать
Тематическое моделированиеНаходим темы сами, без разметкиНет готовых категорий
Классификация с учителемОбучаем модель на размеченных данныхЕсть готовые категории

В этой лекции разберём тематическое моделирование — подход без учителя.

Прежде чем применять алгоритмы, текст нужно превратить в числа.

Самый простой способ: считаем, сколько раз каждое слово встречается в документе.

Документ: "кошка ест рыбу, рыба вкусная"
Словарь: [кошка, ест, рыбу, рыба, вкусная]
Вектор: [ 1, 1, 1, 1, 1 ]

Порядок слов теряется, но для тематики это часто не важно.

Проблема: слова «и», «в», «на» встречаются в каждом документе и засоряют вектор. Нужен способ снизить их вес.

TF-IDF (Term Frequency — Inverse Document Frequency) взвешивает слова так, что частые во всей коллекции слова получают меньший вес, а редкие и характерные — больший.

Итоговая формула:

TF-IDF(t,d,D)=TF(t,d)×IDF(t,D)\text{TF-IDF}(t, d, D) = \text{TF}(t, d) \times \text{IDF}(t, D)

Насколько часто слово tt встречается в документе dd:

TF(t,d)=ft,dtdft,d\text{TF}(t, d) = \frac{f_{t,d}}{\sum_{t' \in d} f_{t',d}}

где ft,df_{t,d} — количество вхождений слова tt в документ dd, знаменатель — общее число слов в документе.

Пример. Документ: «банк повысил ставку банк» (4 слова)

TF(банк,d)=24=0.5\text{TF}(\text{банк}, d) = \frac{2}{4} = 0.5

TF(ставку,d)=14=0.25\text{TF}(\text{ставку}, d) = \frac{1}{4} = 0.25

Насколько редко слово tt встречается в коллекции DD из NN документов:

IDF(t,D)=logN{dD:td}\text{IDF}(t, D) = \log \frac{N}{|\{d \in D : t \in d\}|}

где {dD:td}|\{d \in D : t \in d\}| — число документов, в которых встречается слово tt.

Пример. Коллекция из 1000 новостей:

  • Слово «банк» встречается в 200 документах: IDF=log1000200=log5\text{IDF} = \log\frac{1000}{200} = \log 5
  • Слово «ставка» встречается в 50 документах: IDF=log100050=log20\text{IDF} = \log\frac{1000}{50} = \log 20
  • Слово «и» встречается в 999 документах: IDF=log1000999\text{IDF} = \log\frac{1000}{999}

Чем реже слово в коллекции — тем выше IDF, тем важнее оно для конкретного документа. Выбор основания логарифма в формуле не имеет значения, поскольку изменение основания приводит к изменению веса каждого слова на постоянный множитель, что не влияет на соотношение весов.

На практике sklearn использует сглаженную версию (во избежание деления на ноль и логарифма нуля):

IDF(t,D)=log1+N1+{dD:td}+1\text{IDF}(t, D) = \log \frac{1 + N}{1 + |\{d \in D : t \in d\}|} + 1

После умножения TF × IDF вектор документа нормируется до единичной длины (L2-норма / Евклидова норма):

vnorm=vv2\vec{v}_{norm} = \frac{\vec{v}}{||\vec{v}||_2}

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}")

Bag of Words и TF-IDF по умолчанию работают с отдельными словами (униграммами). Но фраза «центральный банк» несёт другой смысл, чем слова «центральный» и «банк» по отдельности.

N-грамма — последовательность из nn соседних слов (или символов).

ТипnnПример для «центральный банк повысил ставку»
Униграмма1«центральный», «банк», «повысил», «ставку»
Биграмма2«центральный банк», «банк повысил», «повысил ставку»
Триграмма3«центральный банк повысил», «банк повысил ставку»
  • Устойчивые выражения: «центральный банк», «процентная ставка», «чемпионат мира» — каждая фраза несёт целостный смысл
  • Отрицания: «не понравилось» vs «понравилось» — биграмма сохраняет отрицание
  • Именованные сущности: «Санкт Петербург», «Реал Мадрид»

Для документа d=(w1,w2,,wm)d = (w_1, w_2, \ldots, w_m) множество nn-грамм:

Gn(d)={(wi,wi+1,,wi+n1)1imn+1}G_n(d) = \{(w_i, w_{i+1}, \ldots, w_{i+n-1}) \mid 1 \le i \le m - n + 1\}

Пример для n=2n=2, документ «банк повысил ставку»:

G2={(банк, повысил), (повысил, ставку)}G_2 = \{(\text{банк, повысил}),\ (\text{повысил, ставку})\}

Параметр 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-граммы — последовательности символов. Они устойчивы к опечаткам и помогают с морфологически богатыми языками (например, русским).

# Символьные биграммы
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]
# ["центральный", "банк", "повысил", "ставку", "стране"]

Лемма — начальная, словарная форма слова. Все словоформы одного слова отображаются на одну лемму:

банк, банке, банку, банкомлемматизациябанк\text{банк},\ \text{банке},\ \text{банку},\ \text{банком} \xrightarrow{\text{лемматизация}} \text{банк}

повысил, повысила, повышает, повышенлемматизацияповышать\text{повысил},\ \text{повысила},\ \text{повышает},\ \text{повышен} \xrightarrow{\text{лемматизация}} \text{повышать}

Без лемматизации «банк» и «банке» будут двумя разными признаками, хотя означают одно. В русском языке это особенно важно из-за богатой морфологии: у существительных 6 падежей, у глаголов — несколько форм времени, числа, рода.

Лемматизация vs стемминг. Близкий по идее метод — стемминг: отрезаем окончание по правилам, не обращаясь к словарю. Стемминг быстрее, но дает нечитаемые «корни»:

ЛемматизацияСтемминг
«повышает»повышатьповыш
«читателей»читательчитател
Требует словаряданет
Качествовышениже

Для русского языка используют библиотеку pymorphy2:

# pip install pymorphy2
import pymorphy2
morph = pymorphy2.MorphAnalyzer()
def lemmatize(word):
"""Возвращает лемму слова."""
return morph.parse(word)[0].normal_form
print(lemmatize("банке")) # банк
print(lemmatize("повысил")) # повышать
print(lemmatize("ставкой")) # ставка
import re
import 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 (Non-negative Matrix Factorization) — метод разложения матрицы на два множителя с неотрицательными значениями.

Пусть у нас есть коллекция из nn документов, описанных mm словами. Все документы образуют матрицу XR0n×mX \in \mathbb{R}^{n \times m}_{\ge 0}, где каждая строка — TF-IDF вектор одного документа.

NMF ищет приближённое разложение:

XWH,WR0n×k,HR0k×mX \approx W \cdot H, \quad W \in \mathbb{R}^{n \times k}_{\ge 0},\quad H \in \mathbb{R}^{k \times m}_{\ge 0}

где kk — заданное число тем (обычно kmk \ll m).

  • WW («документы × темы») — насколько каждая тема представлена в каждом документе
  • HH («темы × слова») — насколько каждое слово характерно для каждой темы
слова →
┌─────────────┐ ┌────────┐ ┌─────────────┐
документы│ X │ ≈ │ W │ × │ H │
└─────────────┘ └────────┘ └─────────────┘
n × m n × k k × m

Алгоритм сам находит темы — мы только задаём их количество kk.

NMF минимизирует ошибку реконструкции — расстояние Фробениуса между исходной матрицей и её приближением:

L(W,H)=XWHF2=i=1nj=1m(Xij(WH)ij)2\mathcal{L}(W, H) = \| X - W \cdot H \|_F^2 = \sum_{i=1}^{n} \sum_{j=1}^{m} \left(X_{ij} - (WH)_{ij}\right)^2

с ограничением на неотрицательность: W0W \ge 0, H0H \ge 0.

Благодаря этому ограничению матрицы интерпретируются как части целого: каждый документ — взвешенная сумма тем, каждая тема — взвешенная сумма слов. Отрицательные «вычитающие» компоненты, как в PCA, здесь невозможны.

Задача не является выпуклой, но на практике хорошо работают мультипликативные правила обновления Ли и Сеунга (Lee & Seung, 2001). На каждой итерации:

HHWXWWHH \leftarrow H \odot \frac{W^\top X}{W^\top W H}

WWXHWHHW \leftarrow W \odot \frac{X H^\top}{W H H^\top}

где \odot — поэлементное умножение, деление тоже поэлементное. Эти обновления гарантируют, что WW и HH остаются неотрицательными и ошибка монотонно убывает.

На практике sklearn использует более быстрый метод — Coordinate Descent или Multiplicative Update, выбираемый через параметр solver.

from sklearn.feature_extraction.text import TfidfVectorizer
from 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 после обучения, норма Фробениуса:

E(k)=XWkHkF=i,j(Xij(WkHk)ij)2\mathcal{E}(k) = \| X - W_k \cdot H_k \|_F = \sqrt{\sum_{i,j} \left(X_{ij} - (W_k H_k)_{ij}\right)^2}

При k=1k=1 одна тема не может описать разнообразие текстов — ошибка большая. При k=nk=n (число тем = число документов) каждый документ — своя тема, ошибка близка к нулю.

Метод локтя: строим график E(k)\mathcal{E}(k) и ищем точку «перегиба» — после неё добавление новых тем почти не снижает ошибку.

Ошибка
│ ●
│ ●
│ ●
│ ● ← локоть
│ ●───●───●
└───────────────────── k (число тем)
import matplotlib.pyplot as plt
from 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()

Более формальный способ найти локоть — смотреть на относительное снижение ошибки при увеличении kk на 1:

Δ(k)=E(k1)E(k)E(k1)\Delta(k) = \frac{\mathcal{E}(k-1) - \mathcal{E}(k)}{\mathcal{E}(k-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% — дальше увеличивать kk нет смысла.

Представьте, что вы редактор новостного агентства. Когда журналист пишет статью про «санкции против российских банков после чемпионата», он мешает в одном тексте экономику и спорт. Это нормально — реальные статьи редко бывают про одну чёткую тему.

NMF находит одну доминирующую тему для каждого документа. LDA делает иначе: он считает, что каждый документ — это смесь нескольких тем в разных пропорциях.

Например, для нашей коллекции новостей LDA может сказать:

«Центральный банк повысил ставку»
→ Экономика: 95%, Спорт: 2%, Технологии: 3%
«Банк ВТБ стал спонсором хоккейного клуба»
→ Экономика: 55%, Спорт: 40%, Технологии: 5%
«ИИ-стартап привлёк инвестиции»
→ Экономика: 30%, Спорт: 5%, Технологии: 65%

Такое «мягкое» отнесение к темам часто ближе к реальности, чем жёсткое «одна статья — одна тема».

LDA (Latent Dirichlet Allocation) — вероятностная порождающая модель. Она предполагает, что текст «создавался» следующим образом:

  1. Для документа dd выбирается распределение по темам: θdDir(α)\theta_d \sim \text{Dir}(\alpha)
  2. Для каждой темы kk выбирается распределение по словам: ϕkDir(β)\phi_k \sim \text{Dir}(\beta)
  3. Каждое слово в документе генерируется так:
    • сначала выбирается тема: zCategorical(θd)z \sim \text{Categorical}(\theta_d)
    • затем слово из этой темы: wCategorical(ϕz)w \sim \text{Categorical}(\phi_z)

По наблюдаемым словам алгоритм делает обратный вывод — восстанавливает скрытые темы ϕk\phi_k и тематические смеси документов θd\theta_d.

Распределение Дирихле Dir(α)\text{Dir}(\alpha) — распределение вероятностных распределений. Оно задаёт, насколько «размазаны» веса по категориям:

p(θα)=1B(α)k=1Kθkαk1,kθk=1,θk0p(\theta \mid \alpha) = \frac{1}{B(\alpha)} \prod_{k=1}^{K} \theta_k^{\alpha_k - 1}, \quad \sum_k \theta_k = 1,\quad \theta_k \ge 0

  • При малом α<1\alpha < 1 — распределение концентрируется у вершин симплекса: документ, скорее всего, относится к одной теме
  • При большом α>1\alpha > 1 — распределение равномерно: документ смешивает много тем

На практике α\alpha и β\beta — гиперпараметры модели.

LDA работает с матрицей счётчиков слов (CountVectorizer), а не с TF-IDF — это принципиальное отличие. Вероятностная модель предполагает, что слова «выбирались» случайно из темы, а не взвешивались.

from sklearn.feature_extraction.text import CountVectorizer
from sklearn.decomposition import LatentDirichletAllocation
import 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] — вектор длины kk, сумма элементов равна 1. Это и есть вероятностное распределение тем для документа ii:

P(темадокументi)=Wlda[i]P(\text{тема} \mid \text{документ}_i) = W_{lda}[i]

# Смотрим, как каждая статья смешивает темы
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_ — ненормированные счётчики. Нормируем, чтобы получить вероятности слов внутри темы:

P(wk)=ϕkwwϕkwP(w \mid k) = \frac{\phi_{kw}}{\sum_{w'} \phi_{kw'}}

phi = lda.components_ / lda.components_.sum(axis=1, keepdims=True)
# Теперь для каждой темы k: sum(phi[k]) == 1.0
print(f"P('банк' | Тема A) = {phi[0, count_vec.vocabulary_.get('банк', 0)]:.4f}")
КритерийNMFLDA
Математическая основаМатричное разложениеВероятностная порождающая модель
Целевая функцияXWHF2\|X - WH\|_F^2Максимизация правдоподобия
ВходTF-IDF или счётчикиТолько счётчики слов
Веса WWНеотрицательные числаВероятности (k=1\sum_k = 1)
Пересечение темНет явного ограниченияЗаложено в модель
ИнтерпретацияЧасти целогоВероятностное смешение
СкоростьБыстрееМедленнее
В sklearnNMFLatentDirichletAllocation

Когда выбирать NMF: быстрый разведочный анализ, работа с TF-IDF, нужна высокая скорость.

Когда выбирать LDA: важна вероятностная интерпретация, документы явно смешивают несколько тем, нужна оценка неопределённости.

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.decomposition import NMF
import numpy as np
import 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]}")