ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

文本聚类算法选型指南:KMeans、DBSCAN、LDA与Single_Pass实战对比

文本聚类算法选型指南:KMeans、DBSCAN、LDA与Single_Pass实战对比

简介:这份资源面向机器学习初学者与高校课程设计场景,围绕中文文本聚类任务,提供KMeans、DBSCAN、LDA与Single_Pass四种算法的Python实现。其中KMeans与DBSCAN分别完成基于划分和密度的无监督中文文本聚类,LDA用于主题模型下的文本聚类,Single_Pass则采用单遍策略,无需预先设定类别数,适合处理流式或类别未知的语料。压缩包共35个文件,以py脚本、sample样例、head与txt数据文件为主,另含config、md说明及版本控制相关文件,整体约139KB,结构按算法模块分目录组织,便于对照阅读与二次修改。目前已有1622人学习下载。读者可借此理解不同聚类策略的适用条件与实现差异,直接运行脚本观察分词、停用词过滤与聚类效果,并在此基础上完成课设报告或扩展实验,是一份轻量且完整的文本聚类实践参考。

1. 文本聚类四件套:KMeans、DBSCAN、LDA 和 Single_Pass 到底该怎么选

做课设最怕的不是写不出代码,而是把四种算法硬凑在一个main.py里,跑完发现 KMeans 的簇大小均匀得像切蛋糕,DBSCAN 把一半样本判成噪声,LDA 的主题词全是“的、了、是”,Single_Pass 又因为阈值没调好把整篇文档聚成一个类。文本聚类的核心矛盾从来不是“算法够不够多”,而是“你的数据形态配不配这个算法”。KMeans 假设簇是球形且大小相近,DBSCAN 假设簇密度可达且能容忍噪声,LDA 假设文档是主题的混合分布,Single_Pass 假设流式到达且单遍扫描。这四种假设对应四类完全不同的文本场景:短文本分类、长文档去重、主题发现、增量聚类。课设里把它们放在一起,真正要回答的是:给定一份中文语料,从分词、向量化到聚类评估,每一步的参数怎么定、结果怎么读、翻车了怎么救。下面按“先立住理论、再动手复现、最后避坑”的顺序拆开讲,适合正在做课设但不想只交一份能跑就行的代码的人。

2. 从原始文本到向量空间:分词、停用词与 TF-IDF 的工程细节

2.1 中文分词与停用词表:jieba 的三种模式和自定义词典

文本聚类的第一步不是选算法,而是把中文句子切成词。jieba 提供精确模式、全模式和搜索引擎模式,课设里最常用的是精确模式,但遇到专业术语或人名时,精确模式会把“文本聚类”切成“文本”和“聚类”,导致后续 TF-IDF 丢失关键特征。常见做法是加载自定义词典,把领域词提前塞进去。

import jieba import jieba.analyse # 加载自定义词典,每行格式:词语 词频 词性(词频和词性可省略) jieba.load_userdict("user_dict.txt") # 加载停用词表,一行一个词 with open("stopwords.txt", "r", encoding="utf-8") as f: stopwords = set([line.strip() for line in f]) def tokenize(text): # 精确模式切分,过滤停用词和单字 words = jieba.lcut(text, cut_all=False) return [w for w in words if w not in stopwords and len(w) > 1] # 测试 text = "文本聚类算法在课设中需要处理中文分词和停用词过滤" print(tokenize(text))

逻辑说明:jieba.lcut返回列表,cut_all=False表示精确模式。过滤单字是因为中文单字区分度低,保留会引入噪声。参数说明:user_dict.txt的格式是“词语 词频 词性”,词频越高越不容易被切开;停用词表建议用哈工大停用词表加自定义补充,比如“课设”“本文”“如下”这类词在聚类中无区分度。注意:不要用jieba.analyse.extract_tags代替分词,它返回的是关键词不是完整词序列,后续 TF-IDF 会丢词频信息。

2.2 TF-IDF 向量化:max_features 和 ngram_range 怎么定

分词后得到的是词列表,必须转成数值向量。TF-IDF 是最稳的基线,TfidfVectorizer的几个参数直接决定聚类效果。max_features控制词表大小,太大导致维度灾难,太小丢失关键特征;ngram_range决定是否保留二元词组,对短文本有用但对长文档会爆炸。

from sklearn.feature_extraction.text import TfidfVectorizer # 假设 corpus 是分词后用空格拼接的字符串列表 corpus = [" ".join(tokenize(doc)) for doc in raw_docs] vectorizer = TfidfVectorizer( max_features=3000, # 保留 TF-IDF 最高的 3000 个词 ngram_range=(1, 2), # 保留一元和二元词组 min_df=2, # 至少出现在 2 篇文档中才保留 max_df=0.8, # 出现在超过 80% 文档中的词丢弃 sublinear_tf=True # 用 1+log(tf) 代替 tf,抑制高频词 ) X = vectorizer.fit_transform(corpus) print(X.shape) # (文档数, 特征数)

逻辑说明:min_df=2过滤只出现一次的词,这些词往往是噪声或拼写错误;max_df=0.8过滤“的”“是”这类几乎每篇都有的词,即使停用词表没覆盖也能兜底。sublinear_tf=True对长文档更友好,因为长文档词频天然高,取对数后差距缩小。参数说明:课设语料通常几百到几千篇,max_features设在 2000 到 5000 之间比较稳;如果语料是短文本(如标题、评论),ngram_range=(1,2)能提升区分度,但特征数会翻倍,需要同步调低max_features。注意:fit_transform只能在训练集上调用,如果做增量聚类,新文档要用transform而不是重新fit。

2.3 降维与稀疏矩阵:TruncatedSVD 还是直接上稠密

TF-IDF 矩阵是稀疏的,KMeans 和 DBSCAN 都能处理稀疏输入,但 LDA 和 Single_Pass 的实现方式不同。LDA 在 sklearn 中要求词频矩阵(CountVectorizer),不是 TF-IDF;Single_Pass 通常用余弦相似度,稀疏矩阵直接算即可。如果特征数超过一万,建议用TruncatedSVD降到 100 到 300 维,既加速又去噪。

from sklearn.decomposition import TruncatedSVD svd = TruncatedSVD(n_components=200, random_state=42) X_reduced = svd.fit_transform(X) print("解释方差比:", svd.explained_variance_ratio_.sum())

逻辑说明:TruncatedSVD不中心化,适合稀疏矩阵。explained_variance_ratio_.sum()看保留了多少信息,一般 0.3 以上就够聚类用。参数说明:n_components设 100 到 300,太小丢信息,太大降维没意义。注意:降维后的向量不再稀疏,DBSCAN 在稠密向量上计算距离更慢,如果语料超过五千篇,优先用稀疏矩阵直接聚类。

3. KMeans 与 DBSCAN:划分聚类和密度聚类的参数怎么调

3.1 KMeans 的 K 值选择:肘部法、轮廓系数和业务约束

KMeans 最大的坑是 K 值靠猜。课设里常见做法是跑肘部法看 SSE 拐点,再结合轮廓系数验证。但文本聚类往往没有明确类别数,这时候业务约束比数学指标更重要——比如课设要求聚成 5 类,那就直接设 5,别纠结。

from sklearn.cluster import KMeans from sklearn.metrics import silhouette_score import matplotlib.pyplot as plt sse = [] sil_scores = [] K_range = range(2, 11) for k in K_range: kmeans = KMeans(n_clusters=k, init="k-means++", n_init=10, random_state=42) labels = kmeans.fit_predict(X_reduced) sse.append(kmeans.inertia_) sil_scores.append(silhouette_score(X_reduced, labels)) # 画肘部图 plt.plot(K_range, sse, "bx-") plt.xlabel("K") plt.ylabel("SSE") plt.show() # 打印轮廓系数 for k, s in zip(K_range, sil_scores): print(f"K={k}, silhouette={s:.4f}")

逻辑说明:init="k-means++"比随机初始化更稳,n_init=10表示跑 10 次取最优,避免局部最优。inertia_是簇内平方和,肘部法看它下降变缓的点。轮廓系数越接近 1 越好,但文本聚类通常只有 0.1 到 0.3,不要期望太高。参数说明:n_init在 sklearn 1.4 之后默认是 "auto",显式设 10 更可控。注意:KMeans 对异常值敏感,如果语料里有几篇超长文档,会拉偏质心,建议先做长度过滤或截断。

3.2 DBSCAN 的 eps 和 min_samples:用 k-距离图找拐点

DBSCAN 不需要指定簇数,但eps和min_samples比 K 值更难调。eps太小全成噪声,太大全聚成一类。常见做法是画 k-距离图,找曲线拐点作为eps。

from sklearn.cluster import DBSCAN from sklearn.neighbors import NearestNeighbors import numpy as np # 用 k-距离图找 eps k = 5 # 对应 min_samples nbrs = NearestNeighbors(n_neighbors=k).fit(X_reduced) distances, indices = nbrs.kneighbors(X_reduced) distances = np.sort(distances[:, k-1], axis=0) plt.plot(distances) plt.xlabel("样本排序") plt.ylabel(f"{k}-距离") plt.show() # 根据拐点设 eps,比如 0.5 dbscan = DBSCAN(eps=0.5, min_samples=5, metric="cosine") labels = dbscan.fit_predict(X_reduced) print("簇数:", len(set(labels)) - (1 if -1 in labels else 0)) print("噪声数:", list(labels).count(-1))

逻辑说明:k-距离图里,曲线急剧上升的拐点就是合适的eps。metric="cosine"对文本向量更合适,因为 TF-IDF 向量的欧氏距离受长度影响大。参数说明:min_samples一般设 3 到 10,语料越大设越大;eps在 0.3 到 0.8 之间试,具体看 k-距离图。注意:DBSCAN 在稀疏高维数据上容易失效,如果噪声比例超过 50%,要么降维要么换算法。

3.3 两种聚类结果的解读:簇内关键词和噪声样本

聚类跑完不是看标签就完事,要能解释每个簇在讲什么。常见做法是取每个簇的质心,找 TF-IDF 权重最高的词作为簇关键词。DBSCAN 的噪声样本单独看,往往是短文本或混合主题文档。

def get_cluster_keywords(vectorizer, kmeans, n_top=10): order_centroids = kmeans.cluster_centers_.argsort()[:, ::-1] terms = vectorizer.get_feature_names_out() for i in range(kmeans.n_clusters): print(f"簇 {i}:", [terms[ind] for ind in order_centroids[i, :n_top]]) get_cluster_keywords(vectorizer, kmeans)

逻辑说明:argsort()[:, ::-1]对质心权重降序排列,取前n_top个词。参数说明:n_top设 10 到 20,太少看不出主题,太多混入噪声词。注意:如果簇关键词全是“的”“了”,说明停用词没过滤干净或max_df设太高。

4. LDA 与 Single_Pass:主题模型和增量聚类的落地方式

4.1 LDA 主题数确定:困惑度、一致性和人工判断

LDA 需要指定主题数n_topics,常用困惑度(perplexity)和一致性(coherence)来选。困惑度越低越好,但实际中一致性更贴近人工判断。课设里可以跑多个主题数,看每个主题的词是否可解释。

from sklearn.decomposition import LatentDirichletAllocation from sklearn.feature_extraction.text import CountVectorizer # LDA 需要词频矩阵,不是 TF-IDF count_vectorizer = CountVectorizer(max_features=3000, min_df=2, max_df=0.8) X_counts = count_vectorizer.fit_transform(corpus) lda = LatentDirichletAllocation( n_components=5, # 主题数 max_iter=50, # 最大迭代次数 learning_method="batch", random_state=42 ) lda.fit(X_counts) # 打印每个主题的 top 词 terms = count_vectorizer.get_feature_names_out() for topic_idx, topic in enumerate(lda.components_): top_terms = [terms[i] for i in topic.argsort()[:-11:-1]] print(f"主题 {topic_idx}:", top_terms)

逻辑说明:learning_method="batch"适合小语料,online适合大语料。components_是主题-词分布,取每行最大的词作为主题词。参数说明:n_components设 3 到 10,max_iter设 50 到 100,太小不收敛,太大过拟合。注意:LDA 对短文本效果差,如果语料是短评论,考虑用Biterm或直接上 BERT 句向量。

4.2 Single_Pass 的阈值设定:余弦相似度与动态更新

Single_Pass 是流式聚类,每来一篇文档就和已有簇的质心比相似度,超过阈值就加入,否则新建簇。阈值是唯一的关键参数,通常用余弦相似度,阈值设 0.6 到 0.8。

import numpy as np from sklearn.metrics.pairwise import cosine_similarity class SinglePass: def __init__(self, threshold=0.7): self.threshold = threshold self.centroids = [] # 每个簇的质心向量 self.cluster_docs = [] # 每个簇的文档索引 def fit_predict(self, X): labels = [] for i in range(X.shape[0]): vec = X[i] if not self.centroids: self.centroids.append(vec) self.cluster_docs.append([i]) labels.append(0) continue sims = [cosine_similarity(vec, c)[0][0] for c in self.centroids] max_sim = max(sims) max_idx = sims.index(max_sim) if max_sim >= self.threshold: labels.append(max_idx) self.cluster_docs[max_idx].append(i) # 更新质心:取簇内所有文档的均值 cluster_matrix = X[self.cluster_docs[max_idx]] self.centroids[max_idx] = np.asarray(cluster_matrix.mean(axis=0)).flatten() else: self.centroids.append(vec) self.cluster_docs.append([i]) labels.append(len(self.centroids) - 1) return labels sp = SinglePass(threshold=0.7) labels = sp.fit_predict(X_reduced) print("簇数:", len(set(labels)))

逻辑说明:每次加入新文档后更新质心,保证簇的表示随数据动态调整。cosine_similarity返回矩阵,取[0][0]得标量。参数说明:threshold越高簇越纯但数量越多,越低越容易合并。注意:Single_Pass 对输入顺序敏感,同一批文档打乱顺序可能得到不同簇数,课设里可以固定随机种子或多次跑取平均。

4.3 四种算法的评估:轮廓系数、Calinski-Harabasz 和人工抽查

聚类没有标签,评估只能靠内部指标加人工。轮廓系数适合 KMeans 和 DBSCAN,Calinski-Harabasz 适合比较不同算法的簇分离度。LDA 用困惑度,Single_Pass 看簇大小分布。

from sklearn.metrics import calinski_harabasz_score # 假设 labels_kmeans 和 labels_dbscan 已经得到 print("KMeans CH:", calinski_harabasz_score(X_reduced, labels_kmeans)) print("DBSCAN CH:", calinski_harabasz_score(X_reduced, labels_dbscan)) # 人工抽查:每个簇随机抽 3 篇文档标题 import random for cluster_id in set(labels_kmeans): idxs = [i for i, l in enumerate(labels_kmeans) if l == cluster_id] samples = random.sample(idxs, min(3, len(idxs))) print(f"簇 {cluster_id}:", [raw_docs[i][:30] for i in samples])

逻辑说明:CH 值越大簇间越分离,但不同算法之间可比性有限,主要看同一算法不同参数的对比。人工抽查是最终标准,如果簇内文档主题明显不一致,指标再高也没用。参数说明:random.sample的k不能超过列表长度,用min兜底。注意:DBSCAN 的噪声标签 -1 不参与指标计算,要先过滤。

5. 避坑与排查:文本聚类课设里最容易翻车的 5 个点

5.1 现象:所有文档聚成一类,轮廓系数接近 0

原因:TF-IDF 的max_df设太高或停用词没过滤,导致所有文档向量几乎一样;或者 KMeans 的 K 设成 1。解决:检查max_df是否超过 0.9,停用词表是否加载成功,打印X.shape看特征数是否过少。如果特征数少于 100,说明分词或过滤太狠。

5.2 现象:DBSCAN 噪声比例超过 80%

原因:eps太小或min_samples太大,导致大部分点密度不可达。解决:重画 k-距离图,把eps调到拐点附近;如果降维后的向量维度太高,先降到 50 维再跑 DBSCAN。另外,TF-IDF 向量用欧氏距离会受长度影响,改用余弦距离。

5.3 现象:LDA 主题词全是“的、了、是”

原因:用了 TF-IDF 矩阵而不是词频矩阵,或者max_df没设,停用词没过滤。解决:LDA 必须用CountVectorizer,并且设置max_df=0.8和stop_words。如果还是不行,检查分词结果里是否混入了标点符号。

5.4 现象:Single_Pass 簇数随输入顺序剧烈变化

原因:阈值设得太接近相似度分布的中位数,导致边界文档摇摆。解决:把阈值调到 0.75 以上,或者对输入文档按长度排序后再跑。另一种做法是跑多次取簇数的众数,但课设里直接固定顺序更简单。

5.5 现象:KMeans 跑得特别慢,内存爆掉

原因:TF-IDF 特征数过万且没降维,KMeans 在稀疏矩阵上迭代计算量大。解决:用TruncatedSVD降到 200 维,或者把max_features降到 2000。如果语料超过一万篇,考虑用MiniBatchKMeans,它每次只用一个批次更新质心,速度快很多但精度略降。

6. 进阶技巧:用轮廓系数热力图快速锁定 KMeans 和 DBSCAN 的最优参数

课设答辩时老师最爱问“你怎么证明参数选得对”。与其口头解释,不如画一张参数热力图,把 KMeans 的 K 值和 DBSCAN 的 eps 同时扫一遍,用轮廓系数着色,一眼看出稳定区域。下面这段代码可以直接抄,跑完保存成图片贴进报告。

import numpy as np import matplotlib.pyplot as plt from sklearn.cluster import KMeans, DBSCAN from sklearn.metrics import silhouette_score # 假设 X_reduced 是降维后的向量 k_range = range(2, 11) eps_range = np.arange(0.3, 0.9, 0.1) kmeans_scores = [] dbscan_scores = [] for k in k_range: km = KMeans(n_clusters=k, init="k-means++", n_init=10, random_state=42) labels = km.fit_predict(X_reduced) kmeans_scores.append(silhouette_score(X_reduced, labels)) for eps in eps_range: db = DBSCAN(eps=eps, min_samples=5, metric="cosine") labels = db.fit_predict(X_reduced) # 过滤噪声后计算轮廓系数 mask = labels != -1 if len(set(labels[mask])) > 1: dbscan_scores.append(silhouette_score(X_reduced[mask], labels[mask])) else: dbscan_scores.append(-1) # 画图 fig, axes = plt.subplots(1, 2, figsize=(12, 4)) axes[0].plot(list(k_range), kmeans_scores, "bo-") axes[0].set_xlabel("K") axes[0].set_ylabel("轮廓系数") axes[0].set_title("KMeans 参数扫描") axes[1].plot(eps_range, dbscan_scores, "ro-") axes[1].set_xlabel("eps") axes[1].set_ylabel("轮廓系数") axes[1].set_title("DBSCAN 参数扫描") plt.tight_layout() plt.savefig("cluster_param_scan.png", dpi=150) plt.show()

逻辑说明:KMeans 的轮廓系数通常在 K=3 到 6 之间出现峰值,选峰值对应的 K。DBSCAN 的轮廓系数在过滤噪声后计算,如果所有 eps 都低于 0.1,说明数据本身不适合密度聚类,换 KMeans 或 LDA。参数说明:eps_range从 0.3 到 0.9 步长 0.1,实际跑的时候可以缩小到 0.05 步长。min_samples固定 5,如果语料小于 500 篇可以设 3。注意:DBSCAN 的轮廓系数在噪声比例高时会虚高,因为噪声被排除了,所以还要看簇数和噪声数。

我自己的习惯是:先跑参数扫描图,把 KMeans 的 K 和 DBSCAN 的 eps 定下来,再跑 LDA 看主题词,最后用 Single_Pass 模拟流式场景。课设报告里不要只贴最终结果,把参数扫描图放上去,老师一看就知道你不是瞎调的。另外,所有随机种子固定成 42,不然每次跑结果不一样,答辩现场翻车就尴尬了。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进