ЛР4
TF-IDF: отличительные слова важнее общих

TF-IDF · 3 балла

TF-IDF: отличительные слова важнее общих

Частое во всех статьях слово мало помогает различать статьи. Редкое слово в конкретной статье полезнее.

TF
Доля токена в документе.
DF
В скольких документах слово встречается хотя бы раз.
IDF
Обратная частота документа, повышает вес редких слов.
L2-норма
Длина вектора: корень из суммы квадратов.
Нормализация
Деление вектора на его длину, после этого длина равна 1.
На пальцах. «Исследование» написано почти на каждой научной полке. «Геном» гораздо лучше подсказывает биологию.

Сначала смысл, потом формула

Два документа: cat cat dog и cat bird. N=2, cat есть в обоих: df=2, IDF=1. Dog есть в одном: df=1, IDF=ln(3/2)+1=1.4055. У первого текста TF(cat)=2/3, TF(dog)=1/3. До нормализации веса: cat=0.6667, dog=0.4685. После L2: cat≈0.8182, dog≈0.5749.

IDF = ln((N+1)/(df+1)) + 1
TF-IDF = TF × IDF
Нормализация: v / sqrt(sum(v²))

TF фиксирован: 2 упоминания из 10 токенов, TF=0.2. Показан вес до L2.

Почему сравнение со sklearn точное

Я сначала делю счётчики на длину документа, потом умножаю на IDF и нормализую L2. Sklearn умножает сырые счётчики на IDF и нормализует. Общий множитель 1/длина сокращается при L2, поэтому результаты совпадают. Проверка: максимальная разница меньше 10⁻¹².

Ловушка. Считать IDF заново на test нельзя. Это изменит смысл чисел в столбцах. Пустая строка остаётся нулевым вектором, а не NaN.

Слово стало встречаться в большем числе документов. Его IDF...

Код и построчный разбор

Код исходной ячейки 30: class TFIDF(BagOfWords):
class TFIDF(BagOfWords):
    def fit(self, documents):
        super().fit(documents)
        counts = super().transform(documents)
        document_frequency = np.asarray((counts > 0).sum(axis=0)).ravel()
        self.idf = np.log((1 + len(documents))/(1 + document_frequency)) + 1
        return self

    def transform(self, documents):
        counts = super().transform(documents)
        # TF: делим на число всех токенов документа, в том числе вне словаря.
        lengths = np.array([len(self.tokenize(text)) for text in documents], dtype=float)
        tf = counts.multiply((1/np.maximum(lengths, 1))[:, None]).tocsr()
        weighted = tf.multiply(self.idf).tocsr()
        # L2-нормализация убирает влияние длины; как в sklearn по умолчанию.
        norms = np.sqrt(np.asarray(weighted.multiply(weighted).sum(axis=1)).ravel())
        return weighted.multiply((1/np.maximum(norms, 1e-15))[:, None]).tocsr()
  1. 1: class TFIDF(BagOfWords):
    Объявляет класс, который хранит настройки, словарь и методы преобразования.
  2. 2: def fit(self, documents):
    Объявляет функцию: код внутри выполняется при её вызове.
  3. 3: super().fit(documents)
    Обучает объект по переданным данным; test здесь использовать нельзя.
  4. 4: counts = super().transform(documents)
    Применяет уже выученную статистику, не добавляя слов из test.
  5. 5: document_frequency = np.asarray((counts > 0).sum(axis=0)).ravel()
    Превращает результат вычисления в массив NumPy; ravel делает его одномерным.
  6. 6: self.idf = np.log((1 + len(documents))/(1 + document_frequency)) + 1
    Вычисляет натуральный логарифм отношения; используется в IDF или контрасте частот.
  7. 7: return self
    Возвращает результат функции вызывающему коду.
  8. 9: def transform(self, documents):
    Объявляет функцию: код внутри выполняется при её вызове.
  9. 10: counts = super().transform(documents)
    Применяет уже выученную статистику, не добавляя слов из test.
  10. 11: # TF: делим на число всех токенов документа, в том числе вне словаря.
    Комментарий объясняет причину следующего действия.
  11. 12: lengths = np.array([len(self.tokenize(text)) for text in documents], dtype=float)
    Повторяет вложенный блок для каждого элемента указанной последовательности.
  12. 13: tf = counts.multiply((1/np.maximum(lengths, 1))[:, None]).tocsr()
    Выполняет поэлементное умножение разреженной матрицы, а не матричное @.
  13. 14: weighted = tf.multiply(self.idf).tocsr()
    Выполняет поэлементное умножение разреженной матрицы, а не матричное @.
  14. 15: # L2-нормализация убирает влияние длины; как в sklearn по умолчанию.
    Комментарий объясняет причину следующего действия.
  15. 16: norms = np.sqrt(np.asarray(weighted.multiply(weighted).sum(axis=1)).ravel())
    Выполняет поэлементное умножение разреженной матрицы, а не матричное @.
  16. 17: return weighted.multiply((1/np.maximum(norms, 1e-15))[:, None]).tocsr()
    Возвращает результат функции вызывающему коду.
Код исходной ячейки 31: tfidf = TFIDF(ngram_range=(1, 2), max_features=15)
tfidf = TFIDF(ngram_range=(1, 2), max_features=15)
test_sentences = [
    "tf-idf measures word importance relative to its frequency in a document",
    "tf-idf considers word uniqueness across all documents in the corpus",
    "it identifies significant words common in a document yet rare across all documents"
]
vectors = tfidf.fit_transform(test_sentences)
display(pd.DataFrame(vectors.toarray(), columns=list(tfidf.vocabulary)))
manual = TFIDF().fit(['cat cat dog', 'cat bird'])
np.testing.assert_allclose(manual.idf, [np.log(3/2)+1, 1, np.log(3/2)+1])
expected = np.array([0, 2, np.log(3/2)+1]); expected /= np.linalg.norm(expected)
np.testing.assert_allclose(manual.transform(['cat cat dog']).toarray()[0], expected)
print('IDF и TF-IDF проверены вручную')
  1. 1: tfidf = TFIDF(ngram_range=(1, 2), max_features=15)
    Вычисляет правую часть и сохраняет результат в tfidf.
  2. 2: test_sentences = [
    Вычисляет правую часть и сохраняет результат в test_sentences.
  3. 3: "tf-idf measures word importance relative to its frequency in a document",
    Продолжает выражение предыдущей строки: здесь заданы данные или аргументы вызова.
  4. 4: "tf-idf considers word uniqueness across all documents in the corpus",
    Продолжает выражение предыдущей строки: здесь заданы данные или аргументы вызова.
  5. 5: "it identifies significant words common in a document yet rare across all documents"
    Продолжает выражение предыдущей строки: здесь заданы данные или аргументы вызова.
  6. 6: ]
    Завершает многострочное выражение или набор настроек.
  7. 7: vectors = tfidf.fit_transform(test_sentences)
    Выучивает словарь и статистику по обучающим текстам и сразу строит их матрицу.
  8. 8: display(pd.DataFrame(vectors.toarray(), columns=list(tfidf.vocabulary)))
    Разворачивает маленький пример в плотную таблицу; большой корпус так не храним.
  9. 9: manual = TFIDF().fit(['cat cat dog', 'cat bird'])
    Обучает объект по переданным данным; test здесь использовать нельзя.
  10. 10: np.testing.assert_allclose(manual.idf, [np.log(3/2)+1, 1, np.log(3/2)+1])
    Вычисляет натуральный логарифм отношения; используется в IDF или контрасте частот.
  11. 11: expected = np.array([0, 2, np.log(3/2)+1]); expected /= np.linalg.norm(expected)
    Вычисляет натуральный логарифм отношения; используется в IDF или контрасте частот.
  12. 12: np.testing.assert_allclose(manual.transform(['cat cat dog']).toarray()[0], expected)
    Применяет уже выученную статистику, не добавляя слов из test.
  13. 13: print('IDF и TF-IDF проверены вручную')
    Печатает число или пояснение, чтобы проверить результат выполнения.
Код исходной ячейки 33: for ngrams, limit in [((1, 1), 5000), ((1, 1), 15000), ((1, 2), 15000)]:
for ngrams, limit in [((1, 1), 5000), ((1, 1), 15000), ((1, 2), 15000)]:
    evaluate(f'own TFIDF {ngrams} {limit}', TFIDF(ngrams, limit))
display(pd.DataFrame(results).round(4))
  1. 1: for ngrams, limit in [((1, 1), 5000), ((1, 1), 15000), ((1, 2), 15000)]:
    Повторяет вложенный блок для каждого элемента указанной последовательности.
  2. 2: evaluate(f'own TFIDF {ngrams} {limit}', TFIDF(ngrams, limit))
    Продолжает выражение предыдущей строки: здесь заданы данные или аргументы вызова.
  3. 3: display(pd.DataFrame(results).round(4))
    Показывает таблицу в ноутбуке.
Код исходной ячейки 35: own_tf = TFIDF((1, 2), 15000).fit(train_text)
own_tf = TFIDF((1, 2), 15000).fit(train_text)
sk_tf = TfidfVectorizer(vocabulary=own_tf.vocabulary, ngram_range=(1, 2)).fit(train_text)
for texts in [train_text, test_text, pd.Series(['', 'unknownwordxyz'])]:
    delta = own_tf.transform(texts) - sk_tf.transform(texts)
    assert np.max(np.abs(delta.data), initial=0) < 1e-12
print('TF-IDF совпадает со sklearn с точностью 1e-12')
evaluate('sklearn TFIDF fixed vocabulary', sk_tf)

for (kind, title), search in searches.items():
    pred = search.predict(test_text)
    print(kind, title, 'CV:', round(search.best_score_,4), 'test macro-F1:', round(f1_score(y_test,pred,average='macro'),4))
    print(classification_report(y_test, pred, digits=3, zero_division=0))
    plt.figure(figsize=(7,5))
    sns.heatmap(confusion_matrix(y_test,pred,labels=labels),annot=True,fmt='d',xticklabels=labels,yticklabels=labels,cmap='YlOrBr')
    plt.xlabel('Предсказанный домен');plt.ylabel('Настоящий домен');plt.title(f'GridSearch {kind} / {title}')
    plt.xticks(rotation=25,ha='right');plt.yticks(rotation=0);plt.tight_layout();plt.show()
  1. 1: own_tf = TFIDF((1, 2), 15000).fit(train_text)
    Обучает объект по переданным данным; test здесь использовать нельзя.
  2. 2: sk_tf = TfidfVectorizer(vocabulary=own_tf.vocabulary, ngram_range=(1, 2)).fit(train_text)
    Обучает объект по переданным данным; test здесь использовать нельзя.
  3. 3: for texts in [train_text, test_text, pd.Series(['', 'unknownwordxyz'])]:
    Повторяет вложенный блок для каждого элемента указанной последовательности.
  4. 4: delta = own_tf.transform(texts) - sk_tf.transform(texts)
    Применяет уже выученную статистику, не добавляя слов из test.
  5. 5: assert np.max(np.abs(delta.data), initial=0) < 1e-12
    Проверяет ожидаемое свойство; если оно нарушено, останавливает запуск.
  6. 6: print('TF-IDF совпадает со sklearn с точностью 1e-12')
    Печатает число или пояснение, чтобы проверить результат выполнения.
  7. 7: evaluate('sklearn TFIDF fixed vocabulary', sk_tf)
    Продолжает выражение предыдущей строки: здесь заданы данные или аргументы вызова.
  8. 9: for (kind, title), search in searches.items():
    Повторяет вложенный блок для каждого элемента указанной последовательности.
  9. 10: pred = search.predict(test_text)
    Получает предсказанные домены из обученного классификатора.
  10. 11: print(kind, title, 'CV:', round(search.best_score_,4), 'test macro-F1:', round(f1_score(y_test,pred,average='macro'),4))
    Печатает число или пояснение, чтобы проверить результат выполнения.
  11. 12: print(classification_report(y_test, pred, digits=3, zero_division=0))
    Выводит precision, recall, F1 и support отдельно по каждому классу.
  12. 13: plt.figure(figsize=(7,5))
    Настраивает график, подписи или выводит его на экран.
  13. 14: sns.heatmap(confusion_matrix(y_test,pred,labels=labels),annot=True,fmt='d',xticklabels=labels,yticklabels=labels,cmap='YlOrBr')
    Считает таблицу настоящих и предсказанных меток.
  14. 15: plt.xlabel('Предсказанный домен');plt.ylabel('Настоящий домен');plt.title(f'GridSearch {kind} / {title}')
    Настраивает график, подписи или выводит его на экран.
  15. 16: plt.xticks(rotation=25,ha='right');plt.yticks(rotation=0);plt.tight_layout();plt.show()
    Настраивает график, подписи или выводит его на экран.
Матрица ошибок итоговой конфигурации после GridSearch.
Матрица ошибок итоговой конфигурации после GridSearch.

Вопросы защиты

Зачем +1?

Сглаживание: нет деления на ноль, IDF при df=N равен 1.

max_features по IDF?

Нет. По условию словарь отбирается по суммарной частоте токенов, потом считаются IDF.

Нужно ли умножать TF и IDF циклом?

Нет. Используется поэлементное умножение разреженной матрицы на вектор IDF.

TF-IDF понимает смысл?

Он учитывает статистическую редкость, но не понимает синонимы сам по себе.

Одна фразаTF-IDF оставляет формат мешка слов, но меняет вес каждого слова.