AnyMath 文档
Notebook

使用"词袋"方法对文档进行聚类

在这个例子中,我们将探索一种被称为"一袋字"(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社区示例名称和描述的表。 这是一组关于各种技术主题的小文章,从无线电到经济计算。 标题和简短的描述是由文章的作者写的。

让我们看看我们是否可以使用原始弓来聚类这些文本。

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("词典中的单词:$(长度(语料库。词典))")
字典里的单词:5295

如果我们现在开始使用这个语料库,每个文档可以用一个由5220个标记组成的向量来表示,每个标记对应于一个特定单词在文档中出现的次数。 现在的单词比文档多很多。 我们需要简化一下这个空间。 为此,我们将从文本中删除所有数字,空格和标点符号并重新组合语料库。:

In [ ]:
prepare!(corpus, strip_punctuation | strip_numbers | strip_whitespace )
update_lexicon!(corpus)
print("词典中的单词:$(长度(语料库。词典))")
字典里的单词:4793

让我们从中删除停用词和短于两个字符的单词。:

In [ ]:
corpus.lexicon = Dict( (String(word), Int(freq)) for (word, freq) in corpus.lexicon if (length(word) > 2 && word  stopwords_ru))
print("词典中的单词:$(长度(语料库。词典))")
字典中的单词:4685

剩下的单词将通过"词干分析器"传递-一种切断典型单词结尾的算法。 只有裁剪的结构将保留在字典中,这有时会与单词的根相匹配。 但我们将摆脱单词形式,并能够使用更独特的结构。

In [ ]:
using StatsBase
corpus.lexicon = countmap([SnowballStemmer.stem(stemmer_ru, w) for (w,f) in corpus.lexicon for _ in 1:f])
print("字典中的单词:$(长度(语料库。词典))")
字典中的单词:2840

每个文档的特征都是一组特定的单词。 但对于我们的分析,我们不需要在任何其他文档中不会出现的唯一单词。 让我们通过从字典中过滤掉那些在任何文档中不会出现多次的单词来丢弃它们。

In [ ]:
corpus.lexicon = Dict( (String(word), Int(freq)) for (word, freq) in corpus.lexicon if freq >= 2)
print("词典中的单词:$(长度(语料库。词典))")
字典中的单词:1480

功能 dtm() 收集一个矩阵,其中将字典中的一组单词分配给每个文档。

In [ ]:
dtm = DocumentTermMatrix(corpus) # 标志×文件
Out[0]:
A 862 X 1480 DocumentTermMatrix

我们有850个文档,其中许多文档的特征是相同的单词(矩阵中的垂直线),因此归根结底,我们将看到围绕"work"或"example"等无信息单词的集群。

In [ ]:
# 弓(词频)
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="弓形投影", markersize=2, markerstrokewidth=0, alpha=0.7)
Out[0]:
No description has been provided for this image

这不是文档聚类的典型结果,尽管分析它也很有趣。 也许这些是沿着文档长度的簇,并且方差源于这样一个事实,即我们将数据投影到一个降维空间中,丢失了一些关于它们的可变性的信息,尽管文档在原

我们把所有的文件都安排在一个1458维的空间里. 当然,要在图上看到它们,我们需要减少维度,而矢量空间的主要组成部分的PCA分析将有助于我们。 它找到数据最易变化的投影。

为了突出突出突出的词,我们将继续使用稀疏表示的文本,但我们将尝试突出每个文本的主题。 我们不会在单词之间建立联系(密集文本分析),但会提高一些单词相对于其他单词的权重。

使用TFIDF降噪

我们需要尝试应用的下一个方法是在我们的包中"称重"单词的方法,称为TFIDF(term frequency,逆文档频率)。 我们将为单词添加权重,以便并非所有单词都具有相同的分析含义。

在我们的矢量空间中,文档将不再与单词"work"所在的其余文档位于同一个位置。 沿着没有信息的单词的轴,文档将滑动接近零,这些轴将变得没有信息。

最具表现力的坐标将是表征文档中很少在整个语料库中找到的单词存在的数字。 一个单词在文档中出现的频率越高,在语料库中出现的频率越低,我们的文档在与这个字典单词对应的坐标中距离零就越远。

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

一个简单的TFIDF文档矩阵看起来与弓后的频率矩阵相同,但我们只看到一个数字的存在或不存在,而不是它的值。 最独特或具体的话会增加更多的重量。

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("\Cluster$cluster_id($(length(idx_in_cluster))dock,keywords:$keywords)")
    for (rank, idx) in enumerate(top_docs)
        println("  $rank. [$idx] $(titles[idx])")
    end
end
集群1(111doc.,关键字:快照,拍摄,投影,klyuchev,地球)
  1. [41]降维
  2. [173]弹跳球(物理模型)
  3. [222]B16故障锁定测试程序
  4. [424]具有寻的系统的导弹的模型
  5. [469]永磁电机
  6. [565]使用回调实现物联网模型的自动化

集群2(432doc.,关键词:最小化,非平稳,seb,风扇,矩阵)
  1. [19]确保测量的可靠性
  2. [84]茱莉亚集
  3. [95]圣诞树
  4. [165]克莱默的规则
  5. [191]康托尔集
  6. [202]平衡支架

集群3(24doc.,关键词:劣势,综合,移位,图,级)
  1. [254]输送机型号
  2. [261]异步电动机模型
  3. [619]平板热交换器的温度场的计算
  4. [646]工资单
  5. [648]平方根的计算
  6. [649]人口增长的模拟

集群4(30doc.,关键词:振膜,无人机,导数,调制器,matlab)
  1. [117]IEEE9总线模型的稳态模式的计算
  2. [169] CUSIP
  3. [388]古德温模型:朱莉娅的实证分析
  4. [87]花环控制算法的快速原型设计
  5. [371]AFE再生电压整流器
  6. [833]使用PID控制器的示例的控制系统的建模

集群5(265doc.,关键字:utg,俄罗斯天然气工业股份公司,接收器,导数,抽取器)
  1. [81]太阳系的建模
  2. [86]检测曲线第2部分:蒙特卡罗模拟
  3. [93]紧急飞行中止系统
  4. [258]随机目标函数的优化
  5. [295]如何估计几何级数的分母?
  6. [303]使用AI的实时信号重建

并不是说差异很明显。 让我们来看看统计数据:

In [ ]:
# 让我们检查矩阵的稀疏性
println("TF-IDF矩阵的稀疏性:$(1-SparseArrays.nnz(tfidf_mat)/prod(size(tfidf_mat)))%")
println("每个文档非零元素的平均数:$(SparseArrays.nnz(tfidf_mat)/大小(tfidf_mat,1))")

# 让我们来看看方差
row_variances = [var(collect(row)) for row in eachrow(tfidf_mat)]
println("文档方差:平均值$(平均值(row_variances))")
println("                         中位数$(中位数(row_variances))")
println("                        最小/最大$(最小(row_variances))/$(最大(row_variances))")
TF-IDF矩阵的稀疏性:0.998734871762714%
每个文档的非零元素的平均数量:1.8723897911832947
根据文件的差异:平均0.0072900143183377945
                         中位数0.004897149381269027
                        最小/最大0.0/0.03086995392837496

我们的文件不太适合这种集群。 矩阵的高稀疏性意味着很少的文档可以根据这样的向量特征进行分组。

我们还看到每个文档的特征是平均1.8个唯一单词(我们的矩阵平均有这么多非零元素)。 这是非常小的,最好将这个数字增加到词汇量的5-10%,但由于我们的文本非常短,我们将无法做到这一点。

每个文档有1-2个重要单词(词干提取后),我们得到的方差几乎为零-所有文档的信息都很差。

结论

稀疏文本分析方法通常在辅助任务中显示出良好的结果:垃圾邮件过滤,识别唯一文本或符号,比较具有特定术语的文档以及基本聚类。

我们还研究了在每个文本分析应用程序项目的每个步骤中使用的文本预处理方法。 正如你所看到的,我们可以使用几十个函数来组织一个相对复杂的文本处理链。