Фаза 05 · урок 02
Мешок слов, TF-IDF и представление текста
Цель урока: Каждый NLP-конвейер должен ответить на один и тот же вопрос. Как превратить поток токенов переменной длины в вектор фиксированного размера, который может принять классификатор? Первый ответ, к которому пришла область, был самым простым…
Текущий релиз AlexBred.com: первые 100 уроков русскоязычной программы.
Содержание урока
- Проблема
- Концепция
- Собираем сами
- Шаг 1: строим словарь
- Шаг 2: мешок слов
- Шаг 3: частота термина и документная частота
- Шаг 4: TF-IDF
- Шаг 5: L2-нормализация строк
- Используем готовое
- Когда TF-IDF всё ещё выигрывает (по состоянию на 2026 год)
- Когда TF-IDF не работает
- Гибрид: эмбеддинги, взвешенные TF-IDF
- Готовый результат
- Упражнения
- Ключевые термины
- Дополнительное чтение
Сначала считайте, потом думайте. В 2026 году TF-IDF по-прежнему превосходит эмбеддинги на чётко определённых задачах.
Тип: Практика Языки: Python Предварительные требования: Фаза 5 · 01 (Обработка текста), Фаза 2 · 02 (Линейная регрессия с нуля) Время: около 75 минут
Проблема
Модели нужны числа. У вас есть строки.
Каждый NLP-конвейер должен ответить на один и тот же вопрос. Как превратить поток токенов переменной длины в вектор фиксированного размера, который может принять классификатор? Первый ответ, к которому пришла область, был самым простым из тех, что работают. Посчитать слова. Сделать вектор.
Этот вектор использовался в промышленном NLP больше, чем любая модель эмбеддингов. Спам-фильтры, классификаторы тем, обнаружение аномалий в логах, ранжирование поиска (до BM25), первая волна анализа тональности, первое десятилетие академических NLP-бенчмарков. Практики 2026 года по-прежнему сначала берутся за него в узких задачах классификации. Он быстрый, интерпретируемый и часто неотличим от модели эмбеддингов с 400 млн параметров в задачах, где важно присутствие слов.
В этом уроке мы построим мешок слов, затем TF-IDF с нуля. Потом покажем, как scikit-learn делает то же самое в трёх строках. Затем назовём режим отказа, из-за которого вы обратитесь к эмбеддингам.
Концепция
Мешок слов (Bag of Words, BoW) отбрасывает порядок. Для каждого документа посчитайте, сколько раз встречается каждое слово словаря. Длина вектора равна размеру словаря. Позиция i — это число вхождений слова i.
TF-IDF перевзвешивает BoW. Слово, которое встречается в каждом документе, неинформативно, поэтому его вес уменьшается. Слово, редкое по корпусу, но частое в одном документе, является сигналом, поэтому его вес увеличивается.
TF-IDF(w, d) = TF(w, d) * IDF(w)
= count(w in d) / |d| * log(N / df(w))
Здесь TF — частота термина в документе, df — документная частота (во скольких документах содержится слово), N — общее число документов. log удерживает вес в пределах для повсеместно встречающихся слов.
Ключевое свойство: оба метода создают разреженные векторы с интерпретируемыми осями. Можно посмотреть на веса обученного классификатора и прочитать, какие слова сдвигают документ в сторону каждого класса. С 768-мерным BERT-эмбеддингом так сделать нельзя.
bow-tfidf
Собираем сами
Шаг 1: строим словарь
def build_vocab(docs):
vocab = {}
for doc in docs:
for token in doc:
if token not in vocab:
vocab[token] = len(vocab)
return vocab
Входные данные: список токенизированных документов (подойдёт любой токенизатор на уровне слов; code/main.py в этом уроке использует упрощённый вариант с приведением к нижнему регистру). Выход: словарь {word: index}. Стабильный порядок вставки означает, что индекс слова 0 получает первое слово, встреченное в первом документе. Соглашения различаются; scikit-learn сортирует по алфавиту.
Шаг 2: мешок слов
def bag_of_words(docs, vocab):
matrix = [[0] * len(vocab) for _ in docs]
for i, doc in enumerate(docs):
for token in doc:
if token in vocab:
matrix[i][vocab[token]] += 1
return matrix
>>> docs = [["cat", "sat", "on", "mat"], ["cat", "cat", "ran"]]
>>> vocab = build_vocab(docs)
>>> bag_of_words(docs, vocab)
[[1, 1, 1, 1, 0], [2, 0, 0, 0, 1]]
Строки — это документы. Столбцы — индексы словаря. Запись [i][j] означает «сколько раз слово j встречается в документе i». В документе 1 слово cat встречается дважды, потому что так и есть. В документе 0 слово ran встречается ноль раз, потому что его там нет.
Шаг 3: частота термина и документная частота
import math
def term_frequency(doc_bow, doc_length):
return [c / doc_length if doc_length else 0 for c in doc_bow]
def document_frequency(bow_matrix):
df = [0] * len(bow_matrix[0])
for row in bow_matrix:
for j, count in enumerate(row):
if count > 0:
df[j] += 1
return df
def inverse_document_frequency(df, n_docs):
return [math.log((n_docs + 1) / (d + 1)) + 1 for d in df]
Стоит назвать два приёма сглаживания. (n+1)/(d+1) предотвращает log(x/0). Завершающее +1 гарантирует, что слово из каждого документа всё ещё получает IDF 1 (а не 0), что соответствует настройке scikit-learn по умолчанию. В других реализациях используется необработанный log(N/df). Работают оба варианта; сглаженный дружелюбнее.
Шаг 4: TF-IDF
def tfidf(bow_matrix):
n_docs = len(bow_matrix)
df = document_frequency(bow_matrix)
idf = inverse_document_frequency(df, n_docs)
out = []
for row in bow_matrix:
length = sum(row)
tf = term_frequency(row, length)
out.append([tf_j * idf_j for tf_j, idf_j in zip(tf, idf)])
return out
>>> docs = [
... ["the", "cat", "sat"],
... ["the", "dog", "sat"],
... ["the", "cat", "ran"],
... ]
>>> vocab = build_vocab(docs)
>>> bow = bag_of_words(docs, vocab)
>>> tfidf(bow)
Три документа, пять слов словаря (the, cat, sat, dog, ran). the встречается во всех трёх, поэтому его IDF низок. dog встречается в одном, поэтому его IDF высок. Векторы разрежены (большинство элементов малы), а различающие слова выделяются.
Шаг 5: L2-нормализация строк
def l2_normalize(matrix):
out = []
for row in matrix:
norm = math.sqrt(sum(x * x for x in row))
out.append([x / norm if norm else 0 for x in row])
return out
Без нормализации более длинный документ получает более крупный вектор и доминирует в оценках похожести. L2-нормализация помещает каждый документ на единичную гиперсферу. Косинусное сходство между строками теперь является просто скалярным произведением.
Используем готовое
scikit-learn поставляет готовую промышленную версию.
from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer
docs = ["the cat sat on the mat", "the dog sat on the mat", "the cat ran"]
bow_vectorizer = CountVectorizer()
bow = bow_vectorizer.fit_transform(docs)
print(bow_vectorizer.get_feature_names_out())
print(bow.toarray())
tfidf_vectorizer = TfidfVectorizer()
tfidf = tfidf_vectorizer.fit_transform(docs)
print(tfidf.toarray().round(3))
CountVectorizer выполняет токенизацию, строит словарь и BoW за один вызов. TfidfVectorizer добавляет IDF-взвешивание и L2-нормализацию. Оба возвращают разреженные матрицы. Для 100 тыс. документов плотная версия не помещается в память; сохраняйте разреженность, пока классификатор не потребует плотное представление.
Параметры, которые меняют всё:
| Аргумент | Эффект |
|---|---|
ngram_range=(1, 2) |
Включить биграммы. Обычно повышает качество классификации. |
min_df=2 |
Отбросить слова менее чем из 2 документов. Сокращает словарь на шумных данных. |
max_df=0.95 |
Отбросить слова более чем из 95% документов. Приближает удаление стоп-слов без жёстко заданного списка. |
stop_words="english" |
Встроенный список стоп-слов scikit-learn. Зависит от задачи — в анализе тональности не следует удалять отрицания. |
sublinear_tf=True |
Использовать 1 + log(tf) вместо необработанного tf. Помогает, когда термин много раз повторяется в одном документе. |
Когда TF-IDF всё ещё выигрывает (по состоянию на 2026 год)
- Обнаружение спама, маркировка тем, выявление аномалий в логах. Важно присутствие слов, а не семантические нюансы.
- Режимы с малым объёмом данных (сотни размеченных примеров). У TF-IDF плюс логистическая регрессия нет затрат на предобучение.
- Любые случаи, где важна задержка. TF-IDF плюс линейная модель отвечает за микросекунды. Получение эмбеддинга документа через трансформер занимает 10–100 мс.
- Системы, которые должны объяснять свои предсказания. Изучите коэффициенты классификатора. Верхние положительные слова — причина.
Когда TF-IDF не работает
Семантическая слепота. Рассмотрим эти два документа:
- «Фильм был совсем не хорошим».
- «Фильм был превосходным».
Один — отрицательный отзыв. Другой — положительный. Их пересечение TF-IDF в точности равно {the, movie, was}. Классификатор на основе мешка слов должен запомнить, что слово not рядом с good меняет метку. На достаточном объёме данных он может этому научиться, но никогда не сделает это так же изящно, как модель, понимающая синтаксис.
Другой режим отказа: слова вне словаря при инференсе. Модель BoW, обученная на отзывах IMDb, не знает, что делать с Zoomer-approved, если этот токен никогда не появлялся в обучении. Субсловные эмбеддинги (урок 04) справляются с этим. TF-IDF — нет.
Гибрид: эмбеддинги, взвешенные TF-IDF
Практический вариант по умолчанию в 2026 году для классификации на средних объёмах данных: используйте веса TF-IDF как внимание к эмбеддингам слов.
def tfidf_weighted_embedding(doc, tfidf_scores, embedding_table, dim):
vec = [0.0] * dim
total_weight = 0.0
for token in doc:
if token not in embedding_table or token not in tfidf_scores:
continue
weight = tfidf_scores[token]
emb = embedding_table[token]
for i in range(dim):
vec[i] += weight * emb[i]
total_weight += weight
if total_weight == 0:
return vec
return [v / total_weight for v in vec]
Вы получаете семантические возможности от эмбеддингов и акцент на редких словах от TF-IDF. Классификатор обучается на объединённом векторе. Это превосходит каждый метод по отдельности для классификации тональности, тем и намерений при менее чем примерно 50 тыс. размеченных примеров.
Готовый результат
Сохраните как outputs/prompt-vectorization-picker.md:
---
name: vectorization-picker
description: Given a text-classification task, recommend BoW, TF-IDF, embeddings, or a hybrid.
phase: 5
lesson: 02
---
You recommend a text-vectorization strategy. Given a task description, output:
1. Representation (BoW, TF-IDF, transformer embeddings, or a hybrid). Explain why in one sentence.
2. Specific vectorizer configuration. Name the library. Quote the arguments (`ngram_range`, `min_df`, `max_df`, `sublinear_tf`, `stop_words`).
3. One failure mode to test before shipping.
Refuse to recommend embeddings when the user has under 500 labeled examples unless they show evidence of semantic failure in a TF-IDF baseline. Refuse to remove stopwords for sentiment analysis (negations carry signal). Flag class imbalance as needing more than a vectorizer change.
Example input: "Classifying 30k customer support tickets into 12 categories. Most tickets are 2-3 sentences. English only. Need explainability for audit logs."
Example output:
- Representation: TF-IDF. 30k examples is not small; explainability requirement rules out dense embeddings.
- Config: `TfidfVectorizer(ngram_range=(1, 2), min_df=3, max_df=0.95, sublinear_tf=True, stop_words=None)`. Keep stopwords because category keywords sometimes are stopwords ("not working" vs "working").
- Failure to test: verify `min_df=3` does not drop rare category keywords. Run `get_feature_names_out` filtered by class and eyeball.
Упражнения
- Легко. Реализуйте
cosine_similarity(doc_vec_a, doc_vec_b)для L2-нормализованного выхода TF-IDF. Убедитесь, что идентичные документы получают оценку 1.0, а документы с непересекающимся словарём — 0.0. - Средне. Добавьте поддержку
n-gramвbag_of_words. Параметрnсоздаёт подсчёты поn-граммам. Проверьте, чтоn=2для["the", "cat", "sat"]создаёт подсчёты биграмм для["the cat", "cat sat"]. - Сложно. Постройте описанный выше гибрид TF-IDF со взвешенными эмбеддингами, используя векторы GloVe 100d (скачайте один раз, кэшируйте). Сравните точность классификации с обычным TF-IDF и простыми эмбеддингами со средним объединением на наборе данных 20 Newsgroups. Сообщите, что выигрывает и где.
Ключевые термины
| Термин | Как это называют | Что это на самом деле означает |
|---|---|---|
| BoW | Вектор частот слов | Подсчёты слов словаря в одном документе. Отбрасывает порядок. |
| TF | Частота термина | Число вхождений слова в документе, опционально нормированное на длину документа. |
| DF | Документная частота | Число документов, содержащих слово хотя бы один раз. |
| IDF | Обратная документная частота | Сглаженный log(N / df). Уменьшает вес слов, встречающихся везде. |
| Разреженный вектор | В основном нули | Словарь обычно содержит 10–100 тыс. слов; большинство отсутствует в любом конкретном документе. |
| Косинусное сходство | Угол между векторами | Скалярное произведение L2-нормализованных векторов. 1 — идентичны, 0 — ортогональны. |
Дополнительное чтение
- scikit-learn — извлечение признаков из текста — каноническая справка по API и заметки о каждом параметре.
- Salton, G., & Buckley, C. (1988). Term-weighting approaches in automatic text retrieval — статья, которая на десятилетие сделала TF-IDF вариантом по умолчанию.
- «Why TF-IDF Still Beats Embeddings» — Ashfaque Thonikkadavan (Medium) — взгляд 2026 года на случаи, когда старый метод выигрывает и почему.
Источник: Bag of Words, TF-IDF, and Text Representation — оригинал Навигация: Фаза 5 — NLP: от основ к продвинутым темам · Полный каталог · ранее: 05.01 — Обработка текста · далее: 05.03 — Встраивания слов и Word2Vec.