
简介包含KMeans、DBSCAN、LDA与Single_Pass四种文本聚类算法的Python实现完整代码面向机器学习课程设计、信息检索实验及文本聚类入门学习者。代码覆盖基于KMeans的无监督中文文本聚类、DBSCAN密度聚类、LDA主题模型聚类以及无需事先指定类别数的Single_Pass增量聚类四套方案均配备可直接运行的.py脚本注释清晰、结构模块化便于二次改造与算法对比。压缩包共35个文件以Python源码、中文样本数据、停用词表及README说明为主另有配置、License等辅助文件整体仅139KB轻量易下载。当前已有1622人学习使用适合作为课设报告附录或算法复现参考尤其能帮助理解不同聚类策略在中文文本上的效果差异与适用场景。1. 文本聚类课设的四个算法包不只是 KMeans还有三条备选路线做文本聚类课设的同学十个里有八个第一反应是 KMeans然后被“类别数 k 到底设多少”卡住。这份资源包里的价值恰恰是把四条路线一次凑齐了KMeans、DBSCAN、LDA、Single_Pass每个算法都有独立的 Python 脚本和对应的测试数据解压之后可以直接跑出结果。它不是那种“讲完原理就让你自己写”的讲义而是四个能运行的工程实现连停用词表都给你备好了。我拆这份压缩包时最直接的感受是它解决的不是“聚类算法怎么写”而是“课设验收时被追问‘你为什么用这个算法’怎么回答”。KMeans 答“我用了手肘法选 k”DBSCAN 答“我不用预设类别数”LDA 答“我按主题分布聚类”Single_Pass 答“我处理的是流式增量文本”——四种答辩话术对应四种完整实现这才是课设真正需要的。适合正在做机器学习或自然语言处理课程设计的在校生也适合工作中需要对一批短文本快速分堆的从业者。2. 预处理与特征向量聚类效果八成都卡在这一步聚类算法的数学原理不难真正让课设翻车的是喂给算法的特征向量质量太差。很多人拿原始文本直接去算 TF-IDF结果停用词“的、了、是”占据最高权重聚类结果变成按标点符号和语气词分堆。这份资源里单独放了一个 stop_words.txt就是在提醒你文本聚类的第一步不是选算法是先把词洗干净。2.1 中文分词与停用词过滤默认用 jieba但要去掉低频噪声中文文本没有天然空格分词是绕不开的前置步骤。资源里没有强制绑定某个分词库但按这个场景下最通用的方案我一般会用 jieba 做精确模式分词再叠加停用词表过滤。test_data.txt 和 test_data2.txt 分别给了不同规模的数据集处理逻辑是通用的。import jieba def load_stopwords(pathstop_words.txt): # 逐行读取去掉空行和前后空白 stopwords set() with open(path, encodingutf-8) as f: for line in f: line line.strip() if line and not line.startswith(#): stopwords.add(line) return stopwords def tokenize(text, stopwords): # 精确模式分词后过滤停用词和单字噪声 words jieba.lcut(text) return [w for w in words if w.strip() and w not in stopwords and len(w) 1]这段代码里有三个细节值得注意。第一停用词表用set存储判断单个词是否在表里是 O(1) 的几万篇文档循环下来速度差异很明显。第二len(w) 1这个条件会过滤掉大部分单字词中文里单字多为虚词或噪声留下的“计算机”“聚类”这类双字词更有区分度。第三#开头的行被跳过是为了允许你在停用词表里写注释方便标注来源或分类。处理完分词后建议顺手把结果落盘。我见过不少同学反复跑预处理每次跑都是从头分词1000 篇文档就够等上几分钟。落盘一次后面调聚类参数时直接读缓存省下的时间够你多试三组参数。2.2 向量化参数词袋还是 TF-IDF别一上来就默认 TF-IDF分词之后每条文本要变成一个固定维度的向量才能喂给 KMeans 或 DBSCAN。资源里没有限定向量化方式但四个算法对输入的要求不太一样。我的经验是KMeans 和 DBSCAN 用 TF-IDF 向量化LDA 用词频向量化因为 LDA 本身就是概率主题模型词频才是它的原始输入。from sklearn.feature_extraction.text import TfidfVectorizer def build_vectors(documents): # documents 是已经分好词、以空格拼接的文本列表 vectorizer TfidfVectorizer( max_features5000, min_df2, max_df0.8, sublinear_tfTrue ) X vectorizer.fit_transform(documents) print(特征维度:, X.shape[1]) return X, vectorizer参数的意义比调用本身更重要。max_features5000控制特征维度防止词表过大导致稀疏矩阵爆炸min_df2表示某词至少在 2 篇文档中出现过才保留过滤掉只在单篇文档里出现的生僻词max_df0.8表示某词在超过 80% 的文档中都出现就丢弃这类词相当于全文级的停用词对聚类没有区分度。最后一个sublinear_tfTrue是容易被忽略的细节。它把词频用1 log(tf)的方式缩放削弱高词频词的绝对主导地位——否则“今天”这种词在每篇文档里出现 10 次就可能把真正的主题词挤下去。2.3 距离度量KMeans 和 DBSCAN 的输入要求完全不同这是拆包时最容易忽略的一层。KMeans 默认用欧氏距离但在文本场景里欧氏距离对向量长度敏感。两篇同样讲“篮球”的文章一篇 500 字一篇 2000 字词频向量的模长差异很大欧氏距离会认为它们离得很远。所以文本聚类的标准做法是先用 TF-IDF 向量化再配合余弦相似度或者对向量做 L2 归一化让距离只跟“方向”有关跟文本长度无关。DBSCAN 就更特殊。如果你直接调用sklearn.cluster.DBSCAN并把原始稀疏矩阵传进去它默认用的也是欧氏距离在高维稀疏空间里基本不可用。正确做法是自己算好余弦距离矩阵然后指定metricprecomputed。这一步我在实际跑数据时几乎必踩后面避坑章节会专门展开。3. KMeans 与 DBSCAN预设类别数和密度阈值两种完全不同的思路KMeans 和 DBSCAN 是这份资源里最经典的对照组合也是课设答辩的天然考点一个是基于质心的划分一个是基于密度的连通。前者需要你预设类别数后者只需要你调好 eps 和 min_samples。两个脚本我都拆开看了核心逻辑是标准实现但对中文文本场景做了一些适配。3.1 KMeans 手肘法用轮廓系数和肘部图给 k 一个交代KMeans 脚本里最关键的参数是簇数n_clusters也就是课设答辩时老师必问的“k 怎么定的”。常见做法是用手肘法结合轮廓系数一起判断而不是拍脑袋填一个。from sklearn.cluster import KMeans from sklearn.metrics import silhouette_score def find_best_k(X, k_rangerange(3, 11)): best_k, best_score k_range.start, -1 for k in k_range: model KMeans(n_clustersk, random_state42, n_init10) labels model.fit_predict(X) # 稀疏矩阵下直接算轮廓系数样本多时可能要抽样 score silhouette_score(X, labels, metriccosine) print(fk{k}, silhouette{score:.4f}, inertia{model.inertia_:.2f}) if score best_score: best_k, best_score k, score return best_k轮廓系数范围是 [-1, 1]值越大说明簇内越紧凑、簇间越分离。文本聚类里 0.2 到 0.4 已经算不错的水平不要拿图像聚类的标准来卡文本。n_init10表示算法独立跑 10 次取最优结果能缓解 KMeans 对初始质心敏感的问题——这个问题在文本高维稀疏矩阵上特别明显后面避坑章还会提。手肘图则是把不同 k 对应的inertia_样本到质心的平方距离之和画出来找曲线拐点。我的习惯是两种方法结合先看手肘大概确定候选范围再在候选范围内逐个算轮廓系数最终两个标准都指向的那个 k 值答辩时说出来也最有底气。3.2 KMeans 结果解读不要只看簇标签要看每个簇的关键词KMeans 跑完之后只输出一堆 0、1、2 的数字标签这对接报告没有任何意义。真正有效的做法是每个簇取 TF-IDF 权重最高的若干个词用它们还原这个簇的主题。资源脚本里应该有类似的打印逻辑如果没有我一般会自己补上这一段。import numpy as np def show_top_terms(model, vectorizer, X, labels, n_terms8): # model.cluster_centers_ 每一行是一个质心维度对齐 feature_names feature_names np.array(vectorizer.get_feature_names_out()) for i in range(model.n_clusters): center model.cluster_centers_[i] top_idx center.argsort()[::-1][:n_terms] terms .join(feature_names[idx] for idx in top_idx) size (labels i).sum() print(f簇 {i} | 样本数 {size} | 关键词: {terms})这一步的价值在于让聚类结果从“机器数字”变成“人话”。课设报告里放上每个簇的 Top 关键词和样本数再配上两三篇代表性原文整个分析链条就闭环了。哪怕你只跑通了 KMeans 一个算法这份报告拿出去也已经相当完整。3.3 DBSCAN 的 eps 和 min_samples中文短文本怎么调DBSCAN 的好处是完全不需要预设类别数它能自动把噪声点归为 -1。但代价是你得跟两个超参搏斗邻域半径eps和最小样本数min_samples。资源里的 DbscanClustering.py 用的是标准 sklearn 实现关键参数我建议这样起步。from sklearn.cluster import DBSCAN from sklearn.metrics.pairwise import cosine_distances def dbscan_cluster(X, eps0.65, min_samples3): # 文本场景用余弦距离矩阵注意是“距离”不是“相似度” dist_matrix cosine_distances(X) model DBSCAN( epseps, min_samplesmin_samples, metricprecomputed, n_jobs-1 ) labels model.fit_predict(dist_matrix) n_clusters len(set(labels) - {-1}) n_noise (labels -1).sum() print(f簇数: {n_clusters}, 噪声点: {n_noise}, 总样本: {len(labels)}) return labelseps的物理意义是“两个样本之间余弦距离小于该值就认为是邻居”。余弦距离 1 - 余弦相似度所以 eps 越高邻居范围越大簇越容易合并。短文本我一般从 0.5 到 0.8 之间扫描min_samples从 3 起步。这两个参数的组合拳效果完全是玄学必须依赖可视化辅助判断。3.4 DBSCAN 调参技巧用梵高心智而不是肉眼看调 DBSCAN 最怕的是盲调。有人给过一套很实用的思路用样本间距离的 k-距离曲线来确定 eps。对所有样本计算它到第 k 个最近邻的距离把距离从大到小排序画成曲线拐点处的距离就是 eps 的参考值。k 就取min_samples - 1。这个思路的合理性在于DBSCAN 定义的密度本质上就是“第 k 个邻居离我多远”曲线拐点对应密度分布突变的位置。另一个更直观的方法是降维可视化。如果你把数据用 TSNE 或 PCA 降到二维把聚类结果按不同颜色画出来一眼就能看出簇是否合理。噪声点要是跟某个簇纠缠得看不出边界说明 eps 太小簇被切得太碎要是两个不相关的簇连成一片说明 eps 太大。4. LDA 与 Single_Pass主题抽取和增量聚类两条不需要预设类别数的路线如果说 KMeans 和 DBSCAN 的特征是“硬分”那 LDA 就是“软分”。它不给你一个绝对标签而是给每篇文档一个“主题分布”。Single_Pass 则完全是另一条路一条一条文本过边过边归堆适合没法一次性读入全部数据的新场景。这两个脚本放在同一个包里课设的丰富度立马上来了。4.1 LDA 主题数怎么定困惑度是参考可读性才是硬指标LDA 的输入不是 TF-IDF而是词频矩阵因为 LDA 模拟的是“文档——主题——词”的生成过程词频才是它的自然语言。资源的 LatentDirichletAllocationClustering.py 用的是 sklearn 的实现调参重心在三个地方主题数n_components、learning_decay和max_iter。from sklearn.decomposition import LatentDirichletAllocation def train_lda(corpus, n_topics8, max_iter20): # corpus 是词频矩阵比如 CountVectorizer 的输出 lda LatentDirichletAllocation( n_componentsn_topics, learning_methodbatch, max_itermax_iter, random_state42 ) # 拿到文档-主题分布矩阵 doc_topic lda.fit_transform(corpus) # 每篇文档取概率最高的主题作为聚类标签 labels doc_topic.argmax(axis1) return lda, labelslearning_methodbatch适用于数据集能全部塞进内存的场景收敛更稳定如果数据量很大可以换成online。max_iter20是起步值主题结果不稳定的表现是两次运行得到的主题词差异很大这时候优先调大max_iter而不是调主题数。主题数n_components没有标准答案。困惑度曲线只能给参考我最后几乎总是靠“人工检查主题词可读性”来定每个主题取 Top 10 词如果多数主题词串起来能读出明确主题——比如“篮球、比赛、球队、主场”——这个数就可用。如果出现四五个主题词表相似度极高的主题比如都在讲体育那就删掉冗余主题数。这里查重不存在的尽量用自己的学术判断力。4.2 LDA 的输出效果从主题分布到句子级可解释性LDA 相比 KMeans 的优势在于可解释性。KMeans 只能告诉你“这篇属于簇 3”LDA 能告诉你“这篇有 60% 是体育主题、30% 是科技主题、10% 是财经主题”。课设报告里贴这样一组概率分布比任何文字描述都有说服力。实际使用中我会先跑一遍主题分布然后每个主题挑权重最高的文档看这些文档是不是真的“同类”。如果某个主题下文档内容混杂说明主题数不对或预处理没做好需要回到第 2 章去检查分词和过滤。LDA 像一个黑匣子输入的是词频、输出的是主题分布中间过程不直观所以更需要在两头做质量检查。4.3 Single_Pass 的核心阈值决定聚类粒度顺序影响聚类结果Single_Pass 的思路很朴素维护一个簇列表新来一篇文本就计算它和每个簇的相似度取最大值如果最大值超过阈值归入该簇如果都低于阈值就新建一个簇。这个算法不需要预设类别数且天然支持流式数据——这也是它在课设里作为“创新点”被反复提及的原因。import numpy as np def single_pass_cluster(vec_list, similarity_threshold0.55): clusters [] # 每个簇保存质心向量和成员索引 members [] # 每个元素记录文本所属簇编号 for i, vec in enumerate(vec_list): vec vec.reshape(1, -1) best_idx -1 best_sim 0.0 for c_idx, centroid in enumerate(clusters): sim cosine_similarity(vec, centroid.reshape(1, -1))[0][0] if sim best_sim: best_sim sim best_idx c_idx if best_sim similarity_threshold and best_idx ! -1: members.append(best_idx) # 更新质心重算该簇所有向量的均值 cluster_vecs [vec_list[idx] for idx in range(len(members)) if members[idx] best_idx] clusters[best_idx] np.mean(cluster_vecs, axis0) else: clusters.append(vec.flatten()) members.append(len(clusters) - 1) return members, len(clusters)这个实现里有两个点要注意。第一阈值similarity_threshold直接决定聚类粒度阈值越高簇越多越细阈值越低簇越少越粗。我从 0.45 到 0.85 之间扫描观察簇数量的变化曲线找“平缓区间”对应的值。第二质心更新用的是增量平均而不是全局重算这导致结果对样本的输入顺序敏感——先来的样本会影响簇的初始位置。资原的 single_pass_cluster.py 应该在代码里处理了这两个细节。如果没处理课设答辩时老师问“结果是不是稳定”你至少能答出这个问题存在并补一个“多次随机打乱顺序取平均结果”的修正方案。4.4 流式处理的延伸内存占用与向量更新策略Single_Pass 的另一个优势是内存友好。KMeans 和 DBSCAN 需要把全部文档的向量一次性读入内存而 Single_Pass 可以逐条读文本、逐条处理、逐条丢弃原始文本只要保留每个簇的质心向量就够了。对千万级短文本这个差异是决定性的。代价是质心更新不能完美。增量均值更新的质心跟全局重算的质心之间存在误差数据分布越不均衡误差越大。我一般会在全部文本处理完后再做一轮“收敛”用归属结果全局重算每个簇的质心然后重新分配一遍文本重复两三轮结果会稳定很多这个思路相当于给 Single_Pass 补了一个类似 KMeans 的迭代精化步骤。5. 避坑与排查中文文本聚类最常见的五个翻车现场这个资源包里代码能跑通是一回事但你换自己的数据时几乎必然会撞上下面几个问题。我把它们按出现频率排序每条都按“现象 → 原因 → 解决”写省得你再从零排查。5.1 现象KMeans 聚类结果每次运行都不一样原因KMeans 的初始质心是随机的不同的初始质心会收敛到不同的局部最优解。在高维稀疏的文本向量上这个随机性被放大得特别明显可能两次运行簇标签对应的主题完全不一样。解决给KMeans固定random_state42同时把n_init从默认的 10 调大比如 20。n_init是独立随机初始化并运行的次数最终取其中最优的一次。对于课设场景还要在报告里注明随机种子和 n_init 的值这是答辩时展示工程素养的细节。5.2 现象DBSCAN 报错 “Sparse data for precomputed not supported”原因sklearn 的 DBSCAN 在metricprecomputed模式下不支持稀疏距离矩阵。你如果直接用cosine_distances得到的是稀疏格式就会触发这个报错。解决把距离矩阵转成稠密格式dist_matrix.toarray()或者干脆用cosine_similarity得到相似度矩阵后用1 - sim_matrix转成距离矩阵。注意矩阵大小1 万篇文档的距离矩阵是 1 万乘 1 万约 800MB 内存数据量大的时候这是个硬约束不如回到 KMeans 或 Single_Pass。5.3 现象聚类出来的簇主题词全是“我们、公司、进行”原因停用词表覆盖不全。资源自带的 stop_words.txt 是一个基础表但每个领域都有自己特有的高频噪声词比如商务文本里的“我们”、新闻文本里的“记者”和“报道”都对聚类主题没有区分度。解决第一次聚类跑完看每个簇的 Top 关键词把明显跟主题无关的词手动加进停用词表重新跑一遍预处理和聚类。这个“跑两遍”的过程看起来低效实际上比一次堆大量停用词更精准。我常用的办法是取高频 Top 500 词人工快速扫一遍把不该出现的词批量加入停用词表。5.4 现象TF-IDF 特征矩阵太大跑聚类时内存报警原因TfidfVectorizer没有限制max_features或者min_df设得太小导致特征维度冲到几十万甚至上百万。文本向量化后是高维稀疏矩阵每个特征都要占用索引空间维度越高内存开销越大。解决务必设置max_features5000到10000之间的值同时把min_df设为 2 或 3。这两个参数一设特征维度通常能压掉九成。另一个技巧是max_features会根据词频自动保留高频词而这部分词正是区分主题的关键丢失信息的风险可控。5.5 现象Single_Pass 的簇数量对阈值极其敏感改 0.01 就差出十几个簇原因Single_Pass 的聚类粒度完全由阈值控制阈值附近区域的相似度分布天然密集。短文本的 TF-IDF 向量稀疏很多向量之间的余弦相似度都集中在 0.3 到 0.5 区间阈值一落进这个区间微小扰动就会让大量样本在“归簇”和“新建簇”之间切换。解决先用小批量样本跑阈值扫描画出“阈值-簇数量”曲线选曲线平缓段的中点。不要直接跑全量数据不然每次调参都是分钟级等待。如果曲线一直陡峭说明特征质量差回到第 2 章去换特征构建方案。6. 验证聚类效果的三个实操习惯轮廓系数、主题词观察与阈值扫描跑通算法只是课设的第一步能证明“这个聚类结果是合理的”才是拿高分的关键。我从这个包里得到的最实用启发是验证手段比算法本身更值得花时间。三个习惯我一直在用你也可以直接抄。第一个习惯是轮廓系数作为客观指标。前面第 3 章代码里已经用了silhouette_score但文本场景要指定metriccosine否则默认的欧氏距离会给错误判断。轮廓系数不是万能的它偏好紧凑的凸形簇对 DBSCAN 这种能发现任意形状簇的算法会给出偏低分数——所以 DBSCAN 的验证我更依赖主题词观察。第二个习惯是每个簇的主题词检查。我对“聚类成功”的定义很简单打印每个簇的 Top 8 关键词如果能用一句话概括出这个簇在讲什么就算成功。如果某个簇的关键词是“问题、方法、分析、研究”说明系统聚类失败因为这四个词在几乎所有学术文本里都高频出现。这个习惯最费时间但回报也最大。第三个习惯是阈值扫描曲线。这个习惯适用于 Single_Pass 和 DBSCAN 这类对超参敏感的算法。我写了一个极小的扫描工具对一组阈值参数分别跑一遍聚类、记录簇数量最后输出一条曲线挑选曲线进入“平缓区间”的那个参数值。从那以后我每次调聚类超参都会强制走一遍阈值扫描哪怕最终选定的参数跟第一次猜的一样这个曲线也成了报告里的一张图答辩时能直观证明参数不是拍脑袋定的。希望帮到你。本文还有配套的精品资源点击获取