Фаза 05 · урок 02

Мешок слов, TF-IDF и представление текста

Цель урока: Каждый NLP-конвейер должен ответить на один и тот же вопрос. Как превратить поток токенов переменной длины в вектор фиксированного размера, который может принять классификатор? Первый ответ, к которому пришла область, был самым простым…

Текущий релиз AlexBred.com: первые 100 уроков русскоязычной программы.

Курс
AI Engineering from Scratch
Фаза
NLP: от основ к продвинутым темам
Чтение
11 мин.
Проверено
Содержание урока
  1. Проблема
  2. Концепция
  3. Собираем сами
  4. Шаг 1: строим словарь
  5. Шаг 2: мешок слов
  6. Шаг 3: частота термина и документная частота
  7. Шаг 4: TF-IDF
  8. Шаг 5: L2-нормализация строк
  9. Используем готовое
  10. Когда TF-IDF всё ещё выигрывает (по состоянию на 2026 год)
  11. Когда TF-IDF не работает
  12. Гибрид: эмбеддинги, взвешенные TF-IDF
  13. Готовый результат
  14. Упражнения
  15. Ключевые термины
  16. Дополнительное чтение

Сначала считайте, потом думайте. В 2026 году TF-IDF по-прежнему превосходит эмбеддинги на чётко определённых задачах.

Тип: Практика Языки: Python Предварительные требования: Фаза 5 · 01 (Обработка текста), Фаза 2 · 02 (Линейная регрессия с нуля) Время: около 75 минут

Схема к уроку «Мешок слов, TF-IDF и представление текста»

Проблема

Модели нужны числа. У вас есть строки.

Каждый 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.

Упражнения

  1. Легко. Реализуйте cosine_similarity(doc_vec_a, doc_vec_b) для L2-нормализованного выхода TF-IDF. Убедитесь, что идентичные документы получают оценку 1.0, а документы с непересекающимся словарём — 0.0.
  2. Средне. Добавьте поддержку n-gram в bag_of_words. Параметр n создаёт подсчёты по n-граммам. Проверьте, что n=2 для ["the", "cat", "sat"] создаёт подсчёты биграмм для ["the cat", "cat sat"].
  3. Сложно. Постройте описанный выше гибрид TF-IDF со взвешенными эмбеддингами, используя векторы GloVe 100d (скачайте один раз, кэшируйте). Сравните точность классификации с обычным TF-IDF и простыми эмбеддингами со средним объединением на наборе данных 20 Newsgroups. Сообщите, что выигрывает и где.

Ключевые термины

Термин Как это называют Что это на самом деле означает
BoW Вектор частот слов Подсчёты слов словаря в одном документе. Отбрасывает порядок.
TF Частота термина Число вхождений слова в документе, опционально нормированное на длину документа.
DF Документная частота Число документов, содержащих слово хотя бы один раз.
IDF Обратная документная частота Сглаженный log(N / df). Уменьшает вес слов, встречающихся везде.
Разреженный вектор В основном нули Словарь обычно содержит 10–100 тыс. слов; большинство отсутствует в любом конкретном документе.
Косинусное сходство Угол между векторами Скалярное произведение L2-нормализованных векторов. 1 — идентичны, 0 — ортогональны.

Дополнительное чтение


Источник: Bag of Words, TF-IDF, and Text Representation — оригинал Навигация: Фаза 5 — NLP: от основ к продвинутым темам · Полный каталог · ранее: 05.01 — Обработка текста · далее: 05.03 — Встраивания слов и Word2Vec.