- BrainTools - https://www.braintools.ru -

AI для PHP-разработчиков. Часть 6: Bag of Words и TF–IDF – как компьютер превращает текст в математику

Как компьютер превращает текст в числа и почему TF–IDF десятилетиями оставался основой поисковых систем. Разбираем Bag of Words, TF–IDF и поиск похожих документов на чистом PHP.

Это шестая часть проекта.

Часть 5: От массивов к GPU: как PHP-экосистема приходит к настоящему ML [1]
Часть 4: Практическое использование TransformersPHP [2]
Часть 3: Практика без Python и data science [3]
Часть 2: Собираем простейшую RAG-систему на PHP с Neuron AI за вечер [4]
Часть 1: Как я пытался подружить PHP с NER – драма в 5 актах [5]

Когда мы говорим, что нейросети “понимают текст”, легко забыть одну важную вещь: компьютер изначально вообще не умеет понимать слова.

Для машины текст – это просто последовательность символов. Чтобы алгоритмы могли работать с языком, текст нужно превратить в числа.

Именно здесь появляются Bag of Words и TF–IDF – два фундаментальных подхода, с которых исторически начиналось NLP и поиск по тексту.

Несмотря на возраст, эти методы до сих пор используются:

  • в поисковых системах;

  • в FAQ и helpdesk;

  • в корпоративных поисковиках;

  • в рекомендациях документов;

  • в классификации текстов.

И главное – они помогают понять, как вообще текст становится математикой [6]


Историческая справка

Исторически эти подходы появились в разные годы и развивались постепенно.

Bag of Words [7] начал формироваться ещё в 1950-х годах как простой способ представления текста через набор слов. Активно развиваться этот подход стал в 1960-х вместе с работами Жерара Салтона и появлением vector space model. ​

TF–IDF [8] появился позже – в начале 1970-х. Идею IDF предложила Карен Спэрк Джонс в 1972 году, а затем TF–IDF стал популярным благодаря исследованиям Жерара Салтона в области информационного поиска.

Bag of Words: “мешок слов”

BOW – Bag of Words (мешок слов) – это способ представить текст без учёта порядка слов. Нас интересует только то, какие слова встретились и сколько раз.

Представим два предложения:

  • ​”Кот ест рыбу”

  • “Рыбу ест кот”

Для человека они почти одинаковы. Для Bag of Words – абсолютно одинаковы.

Мы как бы высыпаем слова из текста в мешок, перемешиваем, забывая об их порядке и считаем количество каждого слова.

Как строится словарь

Первый шаг – построить словарь. Это просто список всех уникальных слов во всех документах.

Пусть у нас есть три документа:

D1: кот ест рыбу
D2: кот любит рыбу
D3: собака ест мясо

Сначала строится словарь всех уникальных слов:

[кот, ест, рыбу, любит, собака, мясо]

После этого каждому слову назначается индекс:

кот → 0
ест → 1
рыбу → 2
любит → 3
собака → 4
мясо → 5

Превращаем текст в вектор

Теперь каждый документ можно представить как числовой вектор длины |V|, где |V| – размер словаря.

Для документа: кот ест рыбу получаем:

[1, 1, 1, 0, 0, 0]

Для: кот любит рыбу:

[1, 0, 1, 1, 0, 0]

А для: собака ест мясо:

[0, 1, 0, 0, 1, 1]

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

BOW - Мешок слов: векторы

BOW – Мешок слов: векторы

Немного математики

Формально Bag of Words можно описать так:

Пусть словарь:

V={w_1, w_2, dots, w_n}

Тогда документ представляется как вектор:

x(d)=(c_1, c_2, dots, c_n)

где:

  • cᵢ– количество вхождений слова wᵢ

  • n – размер словаря.

На этом этапе документы уже становятся объектами линейной алгебры.

Это обычный вектор в  mathbb{R}^n (для чистого Bag of Words – формально в mathbb{N}^n, но можно рассматривать как вектор в  mathbb{R}^n.​

И уже на этом этапе мы можем:

  • ​сравнивать документы

  • обучать классификаторы

  • искать похожие тексты

Но есть одна проблема.

Главная проблема Bag of Words

У подхода есть серьёзный недостаток, который называется проблемой частот.

Все слова считаются одинаково важными.

Например: слово “кот” слово “и”.

Слово “и” будет встречаться почти в каждом документе. Его частота большая, но смысловая ценность почти нулевая.​

Bag of Words не различает:

  • важные слова

  • служебные слова

  • редкие, но информативные термины

Именно поэтому на сцене появился TF–IDF.

TF–IDF: идея в одной фразе

TF–IDF расшифровывается как: Term Frequency – Inverse Document Frequency

​Идея очень простая:

  • ​слово важно, если оно часто встречается в документе

  • но оно теряет ценность, если встречается почти во всех документах

TF – “насколько часто слово встречается в данном документе”

​IDF – “насколько слово редкое в корпусе”

Итоговый вес – их произведение.

TF (Term Frequency) – насколько слово важно внутри документа

Самая простая формула TF:

mathrm{TF}(w,d)=count(w,d)

Но чаще используют нормализацию:

mathrm{TF}(w,d)=frac{count(w,d)}{|d|}

где:

  • count(w,d) – количество слова

  • |d| – длина документа

Интерпретация проста:

  • 0 → слова нет

  • чем больше значение, тем важнее слово в рамках данного документа

IDF (Inverse Document Frequency) – насколько слово редкое

IDF показывает, насколько слово редкое.

а насколько это слово уникально для всего корпуса?

Для этого используется IDF:

mathrm{IDF}(w)=lnleft(frac{N}{df(w)}right)

где:

  • ln – натуральный логарифм (его же и используем далее)

  • N – количество документов

  • df(w) – число документов, содержащих слово

Иногда ещё добавляют сглаживание:

mathrm{IDF}(w)=lnleft(frac{N + 1}{df(w) + 1}right) + 1

Как это интерпретировать:

  • редкое слово → высокий IDF

  • частое слово → низкий IDF

Например: “SMTP” может встречаться редко, в тоже время “как” – почти везде.

Следовательно:

  • “SMTP” будет иметь высокий вес

  • “как” – почти нулевой

Пример вычисления

Допустим, что у нас есть:

  • всего 3 документа

  • слово “кот” встречается в двух документах

Тогда:

mathrm{IDF}(text{кот})=lnleft(frac{3}{2}right)approx0.405

А слово “собака” встречается только один раз:

mathrm{IDF}(text{собака})=lnleft(frac{3}{1}right)approx1.099

Даже если в документе они встречаются по одному разу, “собака” будет весить значительно больше.

Финальная формула TF–IDF

Теперь объединяем TF и IDF:

mathrm{TFtext{-}IDF}(w,d)=mathrm{TF}(w,d)timesmathrm{IDF}(w)

Таким образом:

  • частое слово внутри документа → вес растёт;

  • частое слово во всём корпусе → вес падает.

Тепловая карта, отображающая значения TF-IDF

Тепловая карта, отображающая значения TF-IDF

​Вектор TF–IDF

Как и Bag of Words, TF–IDF – это вектор.

Отличие только в том, что вместо целых чисел мы получаем вещественные веса.

x(d)=(mathrm{tfidf}_1, mathrm{tfidf}_2, dots, mathrm{tfidf}_n)

Этот вектор:

  • обычно хранится в разреженном виде (только ненулевые значения)

  • высокоразмерный

  • хорошо отражает смысл документа на базовом уровне

Сравнение документов

TF–IDF часто используют вместе с косинусным сходством (cosine similarity [9]).

Почему? Потому что:

  • длины документов разные

  • важна не сумма весов, а направление вектора

​Косинусное сходство измеряет угол между векторами, а не расстояние между точками.

Косинусное сходство документов

Косинусное сходство документов

Почему TF–IDF стал стандартом поиска

TF–IDF долгое время был основой поисковых систем, и даже сегодня похожие идеи используются внутри Elasticsearch, Lucene, корпоративных поисковиков и систем рекомендаций.

Причина проста: TF–IDF хорошо работает в задачах, где тексты относительно короткие, важна терминология и нужны быстрые, понятные вычисления. Модель легко интерпретировать, а результаты – объяснить.

Ограничения Bag of Words и TF–IDF

​При этом важно понимать границы этих моделей. Они не учитывают порядок слов, не понимают контекст и не знают семантики. Для них выражения вроде river bank и bank account могут выглядеть почти одинаково (или для русского языка: заплетённая коса и нашла коса на камень).

​Но несмотря на простоту, такие подходы до сих пор остаются полезными. Они быстрые, хорошо работают на небольших данных и часто используются как сильный baseline перед более сложными ML-моделями.

Почему это всё ещё важно

Bag of Words и TF–IDF – это фундамент NLP.

Если вы понимаете, как текст превращается в вектор, почему слова получают разные веса и как редкость влияет на значимость термина, то embeddings [10], attention и transformer-модели [11] становятся гораздо понятнее.

Потому что современные модели делают концептуально то же самое – представляют текст в виде чисел и ищут зависимости между ними, – только значительно сложнее и умнее.

Именно поэтому мы начали объяснения с мешка слов.

Простой пример TF–IDF на PHP (без библиотек)

Поиск похожих документов на PHP

В этой статье мы сознательно не будем использовать готовые библиотеки и реализуем всё на чистом PHP – исключительно в образовательных целях, чтобы лучше понять, как работают Bag of Words и TF–IDF “под капотом”.

Рассмотрим простой пример. Допустим, у нас есть база знаний:

$documents = [
    1 => 'Как сбросить пароль пользователя',
    2 => 'Ошибка подключения к базе данных',
    3 => 'Настройка SMTP для отправки почты',
    4 => 'Восстановление доступа к аккаунту пользователя',
];

Пользователь вводит запрос:

не могу восстановить пароль пользователя

Задача системы – найти наиболее похожие документы.

Архитектура поиска

Pipeline будет выглядеть так:

Документы
   ↓
Токенизация
   ↓
TF–IDF векторы
   ↓
Вектор запроса
   ↓
Cosine Similarity
   ↓
Сортировка результатов
Конвейер поиска (pipeline) документов

Конвейер поиска (pipeline) документов

Шаг 1. Подготавливаем документы

$documents = [
    1 => 'Как сбросить пароль пользователя',
    2 => 'Ошибка подключения к базе данных',
    3 => 'Настройка SMTP для отправки почты',
    4 => 'Восстановление доступа к аккаунту пользователя',
];

$query = 'не могу восстановить пароль пользователя'; 

Шаг 2. Токенизация

Для простоты здесь используется очень примитивная токенизация – мы просто разбиваем строку по пробелам. В production-системах обычно дополнительно:

  • удаляют пунктуацию

  • нормализуют пробелы

  • убирают stop-words

  • приводят слова к нормальной форме

function tokenize(string $text): array {
    $text = mb_strtolower($text);

    return explode(' ', $text);
}

Преобразуем документы:

$tokenizedDocs = array_map('tokenize', $documents);
$queryTokens = tokenize($query);

Шаг 3. TF (Term Frequency)

При помощи этой функции мы рассчитаем нормализованную частоту встречаемости термина в одном документе.

function termFrequency(array $tokens): array {
    $tf = [];
    $count = count($tokens);

    foreach ($tokens as $token) {
        $tf[$token] = ($tf[$token] ?? 0) + 1;
    }

    foreach ($tf as $word => $value) {
        $tf[$word] = $value / $count;
    }

    return $tf;
}

Шаг 4. IDF (Inverse Document Frequency)

Теперь считаем, насколько слово редкое во всём корпусе. Вычисляем обратную частоту встречаемости термина во всём корпусе документов.

function inverseDocumentFrequency(array $documents): array {
    $df = [];
    $N = count($documents);

    foreach ($documents as $doc) {
        foreach (array_unique($doc) as $word) {
            $df[$word] = ($df[$word] ?? 0) + 1;
        }
    }

    $idf = [];

    foreach ($df as $word => $freq) {
        $idf[$word] = log($N / $freq);
        // Такой вариант формулы использует smoothing и помогает избежать 
        // ситуаций, когда очень частые слова получают вес ровно 0
        // $idf[$word] = log(($N + 1) / ($freq + 1)) + 1;
    }

    return $idf;
}

Шаг 5. TF–IDF вектор

Создаём TF-IDF вектор для одного документа/запроса.

function tfidf(array $tf, array $idf): array {
    $vector = [];

    foreach ($tf as $word => $value) {
        $vector[$word] = $value * ($idf[$word] ?? 0);
    }

    return $vector;
}

Строим векторы документов:

$idf = inverseDocumentFrequency($tokenizedDocs);

$documentVectors = [];
foreach ($tokenizedDocs as $id => $tokens) {
    $tf = termFrequency($tokens);
    $documentVectors[$id] = tfidf($tf, $idf);
}

Шаг 6. Вектор запроса

$queryTf = termFrequency($queryTokens);
$queryVector = tfidf($queryTf, $idf);

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

Это очень важный момент.

После TF–IDF документы и запрос представлены в одном взвешенном векторном пространстве терминов.

Шаг 7. Cosine Similarity

Теперь нужно измерить близость между векторами.

Используем cosine similarity:

mathrm{cosine_sim}(A, B)=frac{A cdot B}{|A||B|}

Интуитивно:

  • чем ближе cosine similarity к 1 → тем ближе направления векторов

  • чем ближе значение к 0 → тем менее похожи документы

Реализация cosine similarity

(см. ниже в полном примере кода).

Шаг 8. Поиск похожих документов

$results = [];

foreach ($documentVectors as $id => $vector) {
    $results[$id] = cosineSimilarity(
        $queryVector,
        $vector
    );
}

arsort($results);

print_r($results);

Полный пример кода на чистом PHP

Скрытый текст
// Исходные документы для поиска сходства.
$documents = [
    1 => 'Как сбросить пароль пользователя',
    2 => 'Ошибка подключения к базе данных',
    3 => 'Настройка SMTP для отправки почты',
    4 => 'Восстановление доступа к аккаунту пользователя',
];

// Converts text to lowercase and splits by spaces.
function tokenize(string $text): array {
    $text = mb_strtolower($text);

    return explode(' ', $text);
}

// Вычисляет нормализованную частоту встречаемости терминов в одном документе.
function termFrequency(array $tokens): array {
    $tf = [];
    $count = count($tokens);

    foreach ($tokens as $token) {
        $tf[$token] = ($tf[$token] ?? 0) + 1;
    }

    foreach ($tf as $word => $value) {
        $tf[$word] = $value / $count;
    }

    return $tf;
}

// Вычисляет обратную частоту встречаемости документа по всем документам.
function inverseDocumentFrequency(array $documents): array {
    $df = [];
    $N = count($documents);

    foreach ($documents as $doc) {
        foreach (array_unique($doc) as $word) {
            $df[$word] = ($df[$word] ?? 0) + 1;
        }
    }

    $idf = [];

    foreach ($df as $word => $freq) {
        $idf[$word] = log($N / $freq);
        // Такой вариант формулы использует smoothing и помогает избежать ситуаций, 
        // когда очень частые слова получают вес ровно 0
        // $idf[$word] = log(($N + 1) / ($freq + 1)) + 1;
    }

    return $idf;
}

// Создает TF-IDF вектор для одного документа/запроса.
function tfidf(array $tf, array $idf): array {
    $vector = [];

    foreach ($tf as $word => $value) {
        $vector[$word] = $value * ($idf[$word] ?? 0);
    }

    return $vector;
}

// Измеряет сходство между двумя разреженными векторами.
function cosineSimilarity(array $a, array $b): float {
    $dot = 0;
    $normA = 0;
    $normB = 0;

    $words = array_unique(array_merge(
        array_keys($a),
        array_keys($b)
    ));

    foreach ($words as $word) {
        $va = $a[$word] ?? 0;
        $vb = $b[$word] ?? 0;

        $dot += $va * $vb;

        $normA += $va * $va;
        $normB += $vb * $vb;
    }

    if ($normA == 0 || $normB == 0) {
        return 0;
    }

    return $dot / (sqrt($normA) * sqrt($normB));
}

// Предварительно вычислить токенизированные документы, 
// IDF-коды и векторы TF-IDF для документов.
$tokenizedDocs = array_map('tokenize', $documents);
$idf = inverseDocumentFrequency($tokenizedDocs);

$documentVectors = [];
foreach ($tokenizedDocs as $id => $tokens) {
    $tf = termFrequency($tokens);
    $documentVectors[$id] = tfidf($tf, $idf);
}

$query = 'не могу восстановить пароль пользователя';

$queryTokens = tokenize($query);
$queryTf = termFrequency($queryTokens);
$queryVector = tfidf($queryTf, $idf);

$results = [];

foreach ($documentVectors as $id => $vector) {
    $results[$id] = cosineSimilarity(
        $queryVector,
        $vector
    );
}

arsort($results);

echo 'Results:' . "n";
foreach ($results as $id => $score) {
    echo 'Document ' . $id . ': ' . round($score, 2) . ' (' . $documents[$id] . ')' . "n";
}
echo "n" . "n";


echo 'Document vectors:' . "n";
foreach ($documentVectors as $id => $vector) {
    echo 'Document ' . $id . ': ' . "n";
    print_r($vector);
    echo "n";
}
echo "n";

echo 'IDF:' . "n";
print_r($idf);

Результат

Пример вывода:

Array (
    [1] => 0.62017367294604
    [4] => 0.11952286093344
    [2] => 0
    [3] => 0
)

Чтобы самостоятельно протестировать этот код,
воспользуйтесь онлайн-демонстрацией [12] для его запуска.

Интерпретация результатов

Система считает наиболее похожими:

  1. “Как сбросить пароль пользователя”

  2. “Восстановление доступа к аккаунту пользователя”

И это уже выглядит вполне разумно.

Интересно, что:

  • SMTP не имеет ничего общего с запросом

  • ошибка [13] базы данных тоже нерелевантна

  • документ про восстановление доступа получил ненулевое сходство в основном благодаря совпадению слова “пользователя”

При этом система всё ещё не понимает, что:

  • “восстановить” и “восстановление” связаны

  • “пароль” и “доступ” могут быть близкими по смыслу

Без стемминга (stemming) [14] или лемматизации (lemmatization) [15] такие слова считаются разными токенами.

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

Подведение итогов

Таким образом, хотя мы и убедились на довольно простом примере, что система работает, у неё есть ограничения. Она не понимает смысл текста по-настоящему: не знает синонимов, плохо работает с разными формами слов и не учитывает контекст. По сути, поиск строится в основном на совпадении терминов.

Например, для текущей реализации слова:

  • “восстановить”

  • “восстановление”

считаются разными токенами.

То же самое касается:

  • “доступ”

  • “пароль”

Система просто не знает, что эти слова могут быть связаны по смыслу.

Чтобы решить это, обычно добавляют:

Но фундамент остаётся тем же: текст всё равно превращается в вектор. И этот кейс показывает очень важную идею всей области NLP.

Даже простая статистика слов уже позволяет строить полезные поисковые системы.

Без нейросетей. Без GPU/TPU. Без LLM.

Только: слова, веса, векторы и немного линейной алгебры.

Именно с таких систем исторически начинался поиск по тексту – и именно они до сих пор лежат внутри многих production-систем как быстрый и надёжный базовый уровень.

Если вам интересна тема AI в PHP, можно глубже погрузиться в неё в моей бесплатной книге: “AI для PHP-разработчиков: интуитивно и на практике [16]“.

А чтобы лучше понять, как всё работает, – попробуйте интерактивные онлайн-примеры [17] и поэкспериментируйте с кодом самостоятельно.

Автор: samako

Источник [18]


Сайт-источник BrainTools: https://www.braintools.ru

Путь до страницы источника: https://www.braintools.ru/article/30210

URLs in this post:

[1] От массивов к GPU: как PHP-экосистема приходит к настоящему ML: https://habr.com/ru/articles/1019250/

[2] Практическое использование TransformersPHP: https://habr.com/ru/articles/993966/

[3] Практика без Python и data science: https://habr.com/ru/articles/984042/

[4] Собираем простейшую RAG-систему на PHP с Neuron AI за вечер: https://habr.com/ru/articles/966792/

[5] Как я пытался подружить PHP с NER – драма в 5 актах: https://habr.com/ru/articles/948014/

[6] математикой: http://www.braintools.ru/article/7620

[7] Bag of Words: https://ru.wikipedia.org/wiki/%D0%9C%D0%B5%D1%88%D0%BE%D0%BA_%D1%81%D0%BB%D0%BE%D0%B2

[8] TF–IDF: https://ru.wikipedia.org/wiki/TF-IDF

[9] cosine similarity: https://en.wikipedia.org/wiki/Cosine_similarity

[10] embeddings: https://ru.wikipedia.org/wiki/%D0%92%D0%B5%D0%BA%D1%82%D0%BE%D1%80%D0%BD%D0%BE%D0%B5_%D0%BF%D1%80%D0%B5%D0%B4%D1%81%D1%82%D0%B0%D0%B2%D0%BB%D0%B5%D0%BD%D0%B8%D0%B5_%D1%81%D0%BB%D0%BE%D0%B2

[11] transformer-модели: https://ru.wikipedia.org/wiki/%D0%A2%D1%80%D0%B0%D0%BD%D1%81%D1%84%D0%BE%D1%80%D0%BC%D0%B5%D1%80_(%D0%BC%D0%BE%D0%B4%D0%B5%D0%BB%D1%8C_%D0%BC%D0%B0%D1%88%D0%B8%D0%BD%D0%BD%D0%BE%D0%B3%D0%BE_%D0%BE%D0%B1%D1%83%D1%87%D0%B5%D0%BD%D0%B8%D1%8F)

[12] онлайн-демонстрацией: https://aiwithphp.org/books/ai-for-php-developers/examples/part-5/bag-of-words-and-tf-idf

[13] ошибка: http://www.braintools.ru/article/4192

[14] стемминга (stemming): https://ru.wikipedia.org/wiki/%D0%A1%D1%82%D0%B5%D0%BC%D0%BC%D0%B8%D0%BD%D0%B3

[15] лемматизации (lemmatization): https://ru.wikipedia.org/wiki/%D0%9B%D0%B5%D0%BC%D0%BC%D0%B0%D1%82%D0%B8%D0%B7%D0%B0%D1%86%D0%B8%D1%8F

[16] AI для PHP-разработчиков: интуитивно и на практике: https://apphp.gitbook.io/ai-for-php-developers/

[17] интерактивные онлайн-примеры: https://aiwithphp.org/books/ai-for-php-developers/examples/

[18] Источник: https://habr.com/ru/articles/1034874/?utm_campaign=1034874&utm_source=habrahabr&utm_medium=rss

www.BrainTools.ru

Rambler's Top100