Документация Engee
Notebook

Кластеризация документов методом "мешок слов"

В этом примере мы изучим способ представления документов, называемый "мешок слов" (bag of words, BoW). Этот способ перевода документов в числа позволит нам перевести каждый текст в векторное представление. Несмотря на примитивность такого метода векторизации, на его основе можно реализовывать прикладные задачи при условии наличия подходящего набора данных.

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

In [ ]:
# EngeePkg.purge()
In [ ]:
Pkg.add(["XLSX", "Clustering", "MultivariateStats", "TextAnalysis", "SnowballStemmer", "StatsBase"])
In [ ]:
using DataFrames, CSV, XLSX
using Statistics, StatsBase, LinearAlgebra, Clustering, Distances, MultivariateStats
using TextAnalysis, SnowballStemmer
In [ ]:
stopwords_ru = CSV.read("$(@__DIR__)/stopwords.csv", DataFrame, header=0).Column1;
stemmer_ru = SnowballStemmer.Stemmer("russian");

Мы подготовили окружение и загрузили необходимые объекты - перечень пропускаемых при анализе "стоп-слов" и простейший алгоритм выявления корня слова (чтобы избавляться от словоформ). Приступим к загрузке текстов.

Загрузим тексты

Набором данных, с которым мы поработаем в этом примере, будет таблица с названиями и описаниями примеров из сообщества Engee. Это набор небольших статей на различные технические темы, от радио до экономических расчетов. Названия и краткие описания написаны авторами статей.

Посмотрим, сможем ли мы примитивным BoW выполнить кластеризацию этих текстов.

In [ ]:
df = DataFrame(XLSX.readtable("$(@__DIR__)/Сообщество.xlsx", "Сообщество")) # Загружаем таблицу

titles = df.post_title
texts = df.post_title .* ". " .* df.post_description # Берём название и краткое описание

include("$(@__DIR__)/_scripts/doc_statistics.jl")
doc_statistics(texts)
Всего документов: 862
Длина документов: средняя: 17.599767981438514
                  медиана: 18.0
                      мин: 5
                     макс: 32
                   пустых: 0

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

Создание корпуса

Набор анализируемых текстов называется корпусом. Они являются одновременно и объектами, фиксирующими языковую норму, и отдельными экземплярами, которые могут выбиваться из неё. Для начала нам нужно снизить вариативность встречающихся слов. Мы переведем каждый текст в строчный (нижний) регистр и соберем объект Corpus из объектов StringDocument.

In [ ]:
using TextAnalysis
corpus = Corpus( StringDocument.(lowercase.(texts)) )
update_lexicon!(corpus)
print("Слов в словаре: $(length(corpus.lexicon))")
Слов в словаре: 5295

Если бы мы сейчас начали работать с этим корпусом, каждый документ мог бы быть представлен вектором из 5220 маркеров, каждый из которых соответствует количеству раз, которое то или иное слово встречается в документе. Сейчас слов гораздо больше, чем документов. Нужно немного упростить это пространство. Для этого мы удалим все числа, пробелы и пунктуацию из текстов и соберем корпус заново:

In [ ]:
prepare!(corpus, strip_punctuation | strip_numbers | strip_whitespace )
update_lexicon!(corpus)
print("Слов в словаре: $(length(corpus.lexicon))")
Слов в словаре: 4793

Удалим из него стоп-слова и слова, которые короче двух символов:

In [ ]:
corpus.lexicon = Dict( (String(word), Int(freq)) for (word, freq) in corpus.lexicon if (length(word) > 2 && word  stopwords_ru))
print("Слов в словаре: $(length(corpus.lexicon))")
Слов в словаре: 4685

И оставшиеся слова проведем через "стеммер" - алгоритм, обрезающий типичные окончания слов. В словаре останутся только обрезанные конструкции, которые иногда будут совпадать с корнем слова. Но зато мы избавимся от словоформ и сможем работать с более уникальными конструкциями.

In [ ]:
using StatsBase
corpus.lexicon = countmap([SnowballStemmer.stem(stemmer_ru, w) for (w,f) in corpus.lexicon for _ in 1:f])
print("Слов в словаре: $(length(corpus.lexicon))")
Слов в словаре: 2840

Каждый документ характеризуется некоторым набором слов. Но для нашего анализа нам не нужны будут уникальные слова, которые не встречаются больше ни в одном другом документе. Отбросим их, отфильтровав из словаря те слова, которые ни к одном документе не встречаются больше одного раза.

In [ ]:
corpus.lexicon = Dict( (String(word), Int(freq)) for (word, freq) in corpus.lexicon if freq >= 2)
print("Слов в словаре: $(length(corpus.lexicon))")
Слов в словаре: 1480

Функция dtm() собирает матрицу, в которой каждому документу сопоставляется набор слов из словаря.

In [ ]:
dtm = DocumentTermMatrix(corpus) # Признаки × Документы
Out[0]:
A 862 X 1480 DocumentTermMatrix

Мы имеем 850 документов, многие из которых характеризуются одними и теми же словами (вертикальные линии в матрице), поэтому в финальном анализе мы увидим кластеры вокруг неинформативных слов вроде "работа" или "пример".

In [ ]:
# BoW (частота слов)
bow_dense = Matrix(dtm[:, :])'  # Признаки × Документы

# PCA
pca = fit(PCA, bow_dense; maxoutdim=3)
X_pca = MultivariateStats.predict(pca, bow_dense)  # (3, n_docs)

# Визуализация (цвет = количество слов в документе)
scatter(X_pca[1, :], X_pca[2, :], X_pca[3, :], legend=false,
        title="BoW проекция", markersize=2, markerstrokewidth=0, alpha=0.7)
Out[0]:
No description has been provided for this image

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

Мы расположили все документы в 1458-мерном пространстве. Естественно, чтобы увидеть их на графике нам нужно снизить размерность, и в этом нам поможет PCA - анализ основных компонент векторного пространства. Он находит проекцию, в которой данные наиболее вариативны.

Чтобы подсветить выдающиеся слова, мы продолжим работать с разреженным представлением текста, но попробуем высветить тематику каждого текста. Мы не будем устанавливать связи между словами (плотный анализ текста), а поднимем вес некоторых слов относительно остальных.

Подавление шума при помощи TFIDF

Следующий метод, который нам нужно попробовать применить – это метод "взвешивания" слов в нашем мешке, который называется TFIDF (term frequency, inverse document frequency). Мы добавим веса к словам, чтобы не все они имели одинаковое значение для анализа.

В нашем вектором пространстве документы теперь будут расположены не том же месте, где и остальные документы где есть слово "работа". По осям неинформативных слов документы скатятся ближе в нулю, и эти оси станут неинформативными.

Наиболее выразительными координатами станут числа, характеризующие наличие в документе таких слов, которые редко встречаются во всем корпусе. Чем чаще слово встречается в документе и чем реже оно встречается в корпусе, тем дальше от нуля наш документ будет по координате, соответствующей этому словарному слову.

In [ ]:
dtm = DocumentTermMatrix(corpus)
tfidf_mat = tf_idf(dtm)
Out[0]:
862×1480 SparseArrays.SparseMatrixCSC{Float64, Int64} with 1614 stored entries:
⎡⣃⡷⣺⡔⡅⠆⠄⠣⠈⣀⠈⠀⠀⠚⣳⢨⠠⠒⠰⠀⠰⠀⠃⠂⠀⢠⡶⠰⢰⠠⠀⢪⠐⠀⠑⠦⠰⠁⣈⠄⎤
⎢⠳⡿⢯⢽⡣⢃⡀⡀⠙⣦⠀⠀⠠⣺⠁⢈⠂⢂⠀⠀⢈⠀⠣⢀⡁⢸⡷⠔⠡⢙⠀⢝⡁⢂⠈⢀⠠⠡⠡⠐⎥
⎢⡹⣭⠴⢺⡇⠀⠀⣀⠈⠐⠨⡁⠀⢸⠐⠁⠐⠄⠂⠀⠄⠁⡠⠀⡆⠈⡗⠀⠀⠁⠀⠄⠈⠀⡨⠡⠀⢀⣅⠌⎥
⎢⢫⡿⣑⡊⣖⡃⠈⢀⣤⠁⠀⡘⠀⢚⢔⣩⡰⡄⠀⠀⢐⢄⠐⡈⡁⠐⡷⢂⢲⣨⠀⣈⠀⠀⠅⠁⠰⢦⢑⠈⎥
⎢⣼⣿⣛⣅⡗⡄⠁⠡⠁⡂⠀⢀⠠⢼⢐⠼⠅⠀⠀⠀⠠⠀⠀⠀⠄⠠⡃⠏⢰⠨⣀⣌⠈⠀⣙⠁⠀⠎⡦⢡⎥
⎢⢸⡿⠡⡈⢃⡁⣂⢀⠀⠔⠀⠶⠈⢨⠈⢘⣀⠍⠄⠀⢘⢁⢁⠀⠂⠂⡇⢐⠰⢘⠀⠇⢀⡐⠠⡂⠀⠪⡐⠀⎥
⎢⢃⡿⣾⡖⡃⠓⠂⠐⠔⠀⠀⠀⠀⢹⠠⠡⢀⠖⠐⠀⠀⠀⠢⠐⠅⠀⡃⢶⡡⣠⢂⠃⠨⠐⢆⡄⠀⠣⠤⠀⎥
⎢⣜⠿⡶⠊⡁⡀⠀⡠⠠⠧⠀⢤⠀⢸⠰⠀⢚⠺⠀⠀⢀⠠⠁⠅⠆⠠⡯⢰⣸⠁⢄⠗⠀⠈⠀⡔⠀⡂⡄⠈⎥
⎢⠒⣿⢦⢈⠃⡂⡈⠀⢂⠂⠀⡡⠀⢸⠀⠐⠀⡲⠈⢀⠐⠀⠀⠀⠇⠀⡗⠐⢪⠄⠀⡄⠑⠀⠖⡀⠀⣥⡐⢐⎥
⎢⠀⣿⠠⢄⠁⠈⠐⡠⢤⡉⠐⡐⠀⢸⡩⠄⠐⡆⠍⠀⠐⡌⠪⠀⡃⠀⡇⠐⠐⢹⠀⢇⠠⠀⢄⠠⢀⡑⠐⠠⎥
⎢⣖⣿⢏⠳⢐⠤⠀⣥⠡⡽⠀⠑⠀⣸⠤⠃⠅⡁⢈⢐⠠⠡⢈⠀⡊⠀⡇⠑⡠⠂⡅⡧⠈⠐⠀⠅⠈⢃⡦⠂⎥
⎣⠒⠋⠋⠉⠃⠁⠀⠃⠈⠁⠀⠀⠀⠘⠑⠀⠀⠁⠂⠐⠀⠈⠀⠀⠁⠀⠃⠂⠃⠀⠀⠁⠉⠀⠁⠘⠀⠀⠃⠀⎦

Простая TFIDF матрица документов выглядит так же как частотная матрица после BoW, но мы видим только наличие или отсутствие числа, а не его значение. Наиболее уникальные или специфичные слова получат больший вес.

In [ ]:
# Максимальный вес в одном документе (уникальные/специфичные слова)
vocab = collect(keys(corpus.lexicon))
max_tfidf = maximum(tfidf_mat, dims=2)[:]
top_idx = sortperm(max_tfidf, rev=true)[1:10]
top_words = join(vocab[top_idx], ", ")
Out[0]:
"модулятор, трёхмерн, перестановк, индексац, полутонов, минимизац, записа, шифр, регулярн, инструментар"

Проблема в том, что мы работаем с короткими текстами. Если в тексте от 2 до 23 слов (средняя длина 13), то почти любые слова встречаются редко и будут выглядеть как шум. И все же посмотрим, как теперь располагаются в пространстве наши тексты.

In [ ]:
tfidf_dense = Matrix(tfidf_mat)'; # Dimensions: Features × Documents

mean_vec = mean(tfidf_dense, dims=2)      # Center the data first
centered_data = tfidf_dense .- mean_vec

pca = fit(PCA, centered_data; maxoutdim=3)
X_pca = MultivariateStats.predict(pca, (tfidf_dense))  # (n_docs, n_components)

scatter(X_pca[1, :], X_pca[2, :], X_pca[3, :], title="Проекция пространства токенов", leg=false,
                markersize=2, markerstrokewidth=0, alpha=0.7)

plot!(size=(1000,400), titlefont=font(8))
Out[0]:
No description has been provided for this image

Интересно, что многие тесты находятся в середине системы координат (не репрезентативные), но есть несколько "лучей" - группы слов, которые встречаются вместе и характеризуют свои группы документов. Это характерная картинка для задачи тематического моделирования.

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

К тому же, это группировка после проекции, в исходном пространстве могло не быть никаких "лучей". Но тексты однозначно находились на разном расстоянии от начала координат.

Выполним кластеризацию на заданное нами количество кластеров.

In [ ]:
# Кластеризация
k = 5
clusters = kmeans(X_pca, k; distance=CosineDist(), maxiter=500)

# Для каждого кластера находим топ-3 самых удалённых документа
top_ids_list = Dict{Int, Vector{Int}}()
for cluster_id in 1:k
    idx_in_cluster = findall(clusters.assignments .== cluster_id)
    if isempty(idx_in_cluster); continue; end
    
    center = clusters.centers[:, cluster_id]
    distances = [norm(X_pca[:, i] - center) for i in idx_in_cluster]
    top_idx = idx_in_cluster[sortperm(distances, rev=true)[1:min(3, end)]]
    top_ids_list[cluster_id] = top_idx
end

# Рисуем отдельно каждый кластер с его легендой
p = plot()
for cluster_id in 1:k
    idx_in_cluster = findall(clusters.assignments .== cluster_id)
    if isempty(idx_in_cluster); continue; end
    
    # Название кластера в легенде
    label = string("Класс ", cluster_id, " (", join(top_ids_list[cluster_id], ", "), ")")
    
    scatter!(p, X_pca[1, idx_in_cluster], X_pca[2, idx_in_cluster], X_pca[3, idx_in_cluster],
             label=label, markersize=3, alpha=0.6)
end

plot!(p, title="Кластеризация лучей (k=$k)", legend=:outertopright, size=(1000,600))
display(p)
No description has been provided for this image

Кластеризация по косинусной метрике, для центрированных данных, позволяет нам кластеризовать документы по их "пространственному углу". Мы попытались сделать так, чтобы отдельные лучи попадали в отдельные кластеры.

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

In [ ]:
max_docs = 6

for cluster_id in 1:k
    idx_in_cluster = findall(clusters.assignments .== cluster_id)
    if isempty(idx_in_cluster); continue; end
    
    # Ключевые слова для кластера (периферийные документы)
    center = clusters.centers[:, cluster_id]
    distances = [norm(X_pca[:, i] - center) for i in idx_in_cluster]
    threshold = quantile(distances, 0.7)
    peripheral_idx = idx_in_cluster[distances .>= threshold]
    
    # Топ-5 слов из периферии
    if !isempty(peripheral_idx)
        avg_tfidf = mean(tfidf_mat[peripheral_idx, :], dims=1)[:]
        top_idx = sortperm(avg_tfidf, rev=true)[1:min(5, end)]
        top_words = vocab[top_idx]
        keywords = join(top_words, ", ")
    else
        keywords = "—"
    end
    
    # Топ-3 самых удалённых документа
    top_docs = idx_in_cluster[sortperm(distances, rev=true)[1:min(max_docs, end)]]
    
    println("\nКластер $cluster_id ($(length(idx_in_cluster)) док., ключевые: $keywords)")
    for (rank, idx) in enumerate(top_docs)
        println("  $rank. [$idx] $(titles[idx])")
    end
end
Кластер 1 (111 док., ключевые: снимк, сдела, проектирован, ключев, земл)
  1. [41] Снижение размерности
  2. [173] Прыгающий мячик (физическая модель)
  3. [222] В16 Программа испытаний блокировки при нарушениях работы
  4. [424] Модель ракеты с системой самонаведения
  5. [469] Двигатель с постоянными магнитами
  6. [565] Автоматизация моделей для IoT при помощи обратных вызовов

Кластер 2 (432 док., ключевые: минимизац, нестационарн, себ, вентиляц, матриц)
  1. [19] Обеспечение надежности измерений
  2. [84] Множество Жюлиа
  3. [95] Новогодняя ёлка
  4. [165] Правило Крамера
  5. [191] Канторово множество
  6. [202] Сбалансированные скобки

Кластер 3 (24 док., ключевые: недостатк, интегрирован, сдвиг, диаграмм, уровен)
  1. [254] Модель конвейера
  2. [261] Модель асинхронного двигателя
  3. [619] Расчёт температурного поля плоского теплообменника
  4. [646] Расчёт заработной платы
  5. [648] Расчёт корней квадратного уровня
  6. [649] Имитация прироста численности населения

Кластер 4 (30 док., ключевые: диафрагм, дрон, производн, модулятор, matlab)
  1. [117] Расчет установившегося режима модели IEEE 9 Bus
  2. [169] CUSIP
  3. [388] Модель Гудвина: эмпирический анализ в Julia
  4. [87] Быстрое прототипирование алгоритма управления гирляндой
  5. [371] Рекуперативный выпрямитель напряжения AFE
  6. [833] Моделирование систем управления на примере ПИД-регулятора

Кластер 5 (265 док., ключевые: utg, газпром, приёмник, производн, дециматор)
  1. [81] Моделирование Солнечной системы
  2. [86] Кривые обнаружения часть2: моделирование методом Монте Карло
  3. [93] Система аварийного прерывания полёта
  4. [258] Оптимизация стохастической целевой функции
  5. [295] Как оценить знаменатель геометрической прогрессии?
  6. [303] Реконструкция сигнала в реальном времени с помощью ИИ

Нельзя сказать, чтобы различия бросались в глаза. Посмотрим на статистику:

In [ ]:
# Проверим разреженность матрицы
println("Разреженность TF-IDF матрицы: $(1 - SparseArrays.nnz(tfidf_mat)/prod(size(tfidf_mat)))%")
println("Среднее количество ненулевых элементов на документ: $(SparseArrays.nnz(tfidf_mat)/size(tfidf_mat, 1))")

# Посмотрим на дисперсию
row_variances = [var(collect(row)) for row in eachrow(tfidf_mat)]
println("Дисперсия по документам: cредняя $(mean(row_variances))")
println("                         медиана $(median(row_variances))")
println("                        мин/макс $(minimum(row_variances)) / $(maximum(row_variances))")
Разреженность TF-IDF матрицы: 0.998734871762714%
Среднее количество ненулевых элементов на документ: 1.8723897911832947
Дисперсия по документам: cредняя 0.0072900143183377945
                         медиана 0.004897149381269027
                        мин/макс 0.0 / 0.03086995392837496

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

Мы также видим, что каждый документ охарактеризован в среднем 1.8 уникальными словами (столько ненулевых элементов в среднем имеет наша матрица). Это очень мало, лучше довести этот показатель до 5-10% от словаря, но поскольку наши тексты очень короткие, мы не сможем это сделать.

Имея 1-2 значимых слова на документ (после стемминга) мы получаем почти нулевую дисперсию - все документы очень бедны на информацию.

Заключение

Разреженные методы анализа текста обычно показывают неплохие результаты во вспомогательных задачах: в фильтрации спама, в идентификации уникальных текстов или символов, в сравнении документов со специфической терминологией, в базовой кластеризации.

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