
K-means应该是机器学习里最“平易近人”的算法了结构简单、原理直观几行代码就能跑起来所以我也见过不少人拿它直接怼文本聚类跑完后看着结果一脸懵。不是K-means不好用而是很多教程只教了怎么调API没告诉你文本该用什么方式喂进去、K值怎么选、质心初始化怎么处理以及聚类完怎么解释。这篇文章想把这些东西一次聊透从算法原理到文本预处理、向量化、K值选择、完整代码实现再到我实际跑文本聚类时踩过的坑全部过一遍。适合刚入门聚类算法、需要在中文语料上做文本打标/分群的读者也适合想回头补齐原理细节的工程师。1. 内容整体设计与思路拆解1.1 为什么文本聚类仍然首选K-means先说个容易被忽视的事实文本聚类这活儿大部分业务场景其实是没有标注数据的。你想把几万条新闻、工单、评论快速分个堆看看大概有哪些主题这时候很少有人有精力先标个几千条数据再做监督分类。无监督聚类里K-means几乎是性价比最高的基线方案原因就三条快、好解释、实现成本极低。有人会反驳K-means不是假设簇是凸的吗文本特征那么高维那么稀疏簇结构哪里是凸的这话没毛病但从工程角度看高维稀疏的TF-IDF向量经过归一化之后在高维空间里K-means的“质心”其实更像一个语义中心词分布和“凸簇”这种几何概念强行绑定没有多大意义。我跑过几次数万条新闻的分群用K-means依然能稳定分出清晰的体育、财经、科技、娱乐这样的主题簇而且每个簇的质心词拿来当主题标签非常好用。换句话说K-means在实际文本场景里承担的角色是“快速粗分主题发现”而不是“寻找完美簇结构”。它的劣势比如对初始质心敏感、K值要预设、容易受离群噪声影响在文本聚类里都可以通过预处理和参数调整很大程度缓解。这也是这篇文章想给你的一套完整套路不追求理论上最优雅而是让你跑起来就能用。1.2 一次完整文本聚类的流程设计文本聚类和普通数值特征聚类最大的不同在于原始输入是自然语言机器不认识“足球”“芯片”这些词必须先转换成向量。整个流程我一般拆成这样原始语料收集文本清洗去空行、去无意义符号、去短文本中文分词停用词过滤向量化词频/TF-IDFK值探索肘部法则、轮廓系数K-means聚类聚类结果分析与主题抽取。每一步都有坑但影响最大的其实是第3到第5步。我之前见过有人把原始中文文本直接丢给TfidfVectorizer结果因为默认token_pattern匹配不到连续中文词最后每个“词”都变成一段话聚类出来完全是垃圾。这种问题不看数据根本发现不了所以后面我会把分词和向量化的细节单独拉出来讲。整体设计上我会先用小的示例数据把全流程跑通再告诉你如何扩展到大语料。不要一上来就搞一万条数据否则出了bug你根本分不清是分词的问题还是聚类参数的问题。1.3 K-means算法原理直观理解与数学表达K-means的目标很朴素给定一堆点想把它分成K组让组内的点尽量互相靠近。用数学语言说就是最小化所有点到它所属簇质心的距离平方和公式是这样的J Σ_{i1}^{K} Σ_{x ∈ C_i} || x - μ_i ||²其中C_i是第i个簇μ_i是该簇质心。这个目标函数也叫SSESum of Squared Errors后面选K值时要反复用到它。求解这个最优化问题采用的是迭代法核心只有两步分配步固定质心μ把所有点归到离它最近的质心那边更新步重新计算每个簇里所有点的均值作为新质心μ。这两步反复循环直到质心移动距离小于某个阈值或者达到最大迭代次数。这个套路其实和EM算法是亲兄弟分配是“E步”更新是“M步”只是K-means里的归属是硬分配一个点必须属于且仅属于一个簇。为什么更新步要用均值因为给定一个簇的成员能让SSE最小的质心恰恰是算术平均这在高斯分布假设下是最大似然估计。生活化类比就是你有一群人要找一个“中心位置”让他们集合最优解就是他们的平均坐标而不是中位数或者其他什么位置。不过这里有个代价算法只收敛到局部最优不保证全局最优。这也是为什么scikit-learn里默认用k-means初始化并且建议设置n_init跑多次取最好结果。后面实操部分我会给具体参数。2. 核心细节解析与实操要点2.1 中文分词的坑与应对文本聚类的第一步是分词这一步在中文项目里经常是最影响效果的。英文有天然空格分词中文没有而且同一个词在不同语境下可能切法不同。比如“武汉市长江大桥”你让一个没有词典的模型切很可能切成“武汉市/长江大桥”也可能切成“武汉/市长/江大桥”语义完全跑偏。我的习惯是用jieba分词。它支持三种模式全模式、搜索引擎模式、精确模式。文本聚类推荐用精确模式默认cut_allFalse这样单词不会切太碎也不会像搜索引擎模式那样把“长江大桥”额外切成“长江/大桥”。分词之后还有一件容易被忽略的事过滤停用词。中文里“的、了、是、在、我、你、他”这类词出现频率高但对聚类没有区分度。你如果不滤掉它们TF-IDF里的IDF部分虽然能压低常见词权重但效果不如直接过滤干净。我一般用常见中文停用词表再根据语料特点额外加词比如新闻文本里的“记者”“报道”“编辑”这类词如果不加进停用词表它们会跑进每一个簇干扰主题抽取。还有一点如果你手头语料里有大量专业术语比如“大语言模型”“量化交易”强烈建议建一个自定义词典用jieba.load_userdict加载进去否则经常会被切成奇奇怪怪的碎片。我跑财经类文本时把“央行降准”“北向资金”这种四个字以上的词全加进去了聚类主题明显干净很多。2.2 TF-IDF为什么还是文本聚类的首选向量化方式现在做文本表示有很多选择词向量平均、BERT句向量、TF-IDF……但落在K-means文本聚类这个场景我依然首选TF-IDF原因很实际。第一可解释性。K-means聚完后每个簇的质心向量直接对应原始词汇表的权重分布我只需要找出权重最高的Top N个词就能知道这个簇是什么主题。换成Word2Vec平均向量或者BERT句向量做聚类是可以但簇别的语义解释要多绕好几层工程上非常不直观。第二效率和稳定性。TF-IDF矩阵是稀疏的sklearn内部用scipy稀疏矩阵存储几万条文本、几十万维特征也只需要几百兆内存。但词向量平均之后变成稠密矩阵几万条文本乘三百维向量内存和计算开销都不小还不一定带来质的提升。第三TF-IDF本身就能压制高频无意义词。它是词频和逆文档频率的乘积如果一个词在太多文档里出现它的IDF会变小权重被自动压制。比如“我们”在每篇文章里都有虽然词频高但聚类贡献很低。这和K-means的“均值漂移”叠加起来正好适合做主题区分。当然TF-IDF也不是没有问题。它只考虑词面信息不解决同义词问题也不理解上下文。但在“先快速把语料分成几大类”这个目标下这点代价完全可以接受。如果你后续想做更精细的语义聚类再用预训练句向量替换掉这一层即可整体流程不用推翻重来。2.3 向量化参数怎么调才靠谱TfidfVectorizer里有几个参数每次我都建议按下述思路设置否则很容易翻车。首先是min_df和max_df。它们分别用来过滤极低频和极高频的词。比如min_df2意思是一个词至少在2篇文档中出现过才会被保留这能过滤大量拼写错误和只出现过一次的生僻词。max_df0.8意思是出现在80%以上文档中的词会被忽略相当于自动做了一次停用词扩展。这两个参数对特征规模影响很大建议先试着跑一版打印一下feature_names数量一般控制在5000以内比较舒服。其次是max_features。如果语料很大词汇表动辄几十万我习惯直接限制特征数上限比如max_features5000保留词频最高的5000个词。原因是K-means在高维空间里更容易受噪声维度影响维度太多反而稀释了主题词的权重。再就是sublinear_tfTrue。它把词频tf做一个对数变换1 log(tf)。这样处理能削弱那些在一篇超长文档里反复出现的词的绝对词频优势实际效果经常比默认值好。可以说这个参数是廉价的“长文本矫正”。最后记得设置norml2。对每个文本向量做L2归一化之后K-means用的欧氏距离就和余弦相似度在排序意义上等价了。具体说如果向量x和y都归一化到了单位长度那么||x-y||² 2 - 2cos(x, y)欧氏距离越小就是余弦相似度越大。这解决了K-means“用欧氏距离算相似度”和文本领域“用余弦相似度更合理”之间的矛盾是个很重要的细节。2.4 K值选择的三种实用方法K值是K-means唯一必须预设的核心超参数选错就从头错到尾。我常用的方法有三种实际使用时建议交叉验证。第一种是肘部法则。算不同K值下的SSE画一条K-SSE曲线找那个像手肘一样的拐点。拐点左边增加簇数让SSE下降很快说明收益明显拐点之后SSE下降变缓说明再多分簇只是强行切分。这是最简单也最常用的方法。需要说明的是教科书上对K-means的SSE确实随K单调下降但不是下降幅度越小就是拐点要结合业务去判断。第二种是轮廓系数。对每个样本计算它的轮廓系数s取值在-1到1之间越接近1说明样本离自己簇的中心近、离其他簇中心远聚类越合理。把所有样本的s取平均值就是当前K值下的整体轮廓系数。一般来说轮廓系数的峰值对应比较合理的K不过文本语料经常不会出现特别尖锐的峰值需要多试几个K再看。第三种是业务解释法。算完不同K值的聚类结果后打印每个簇的Top主题词人工看看这些簇能不能被解释成有意义的主题。如果某个簇的Top词混着体育和娱乐说明K太小了如果发现两个簇的Top词几乎一模一样说明K太大了。这个方法“土”但在真实业务场景里最靠谱因为聚类最终是要给人解释的。三种方法很难每次都统一我的经验是先用肘部法则框一个K的候选范围再用轮廓系数缩小范围最后用主题词人工确认。不过要提醒你K值在这个任务上没有“唯一正确答案”——文档是连续的语义空间你怎么切都有道理关键是切出来别人能不能看懂。3. 实操过程与核心环节实现3.1 环境准备与示例数据下面进入完整实操。我用Python 3.10环境依赖库如下jieba中文分词scikit-learnTF-IDF向量化和KMeansnumpy数值处理matplotlib画K值曲线。安装命令就一行pip install jieba scikit-learn numpy matplotlib为了让你能直接复现我用一组小型的新闻标题语料来演示。一共12条横跨三个主题体育、科技、财经。数据量很小但足够跑通全流程也能直观看出每个模块的作用。实际跑大语料时只需要把docs列表换成从文件读取的文本即可。docs [ 国家队主教练公布最新名单 年轻球员迎来机会, 中超联赛收官 山东队逆转夺冠创造历史, 冬奥会短道速滑项目中国队再添金牌, 篮球世界杯淘汰赛 主力中锋带伤砍下三双, 人工智能大模型发布 支持多模态理解能力, 手机厂商发布折叠屏新品 电池续航大幅提升, 芯片设计实现新突破 制程工艺进入新阶段, 开源社区推出高性能计算框架 代码全面开放, 央行宣布下调存贷款利率 市场流动性有望改善, 上市公司发布年度财报 净利润同比增长三成, 股票市场震荡上行 新能源板块表现活跃, 基金公司公布净值数据 权益类产品收益领先, ]3.2 完整代码实现与逐步解释第一步做分词和停用词过滤。我这里内置一个简版停用词集合实际项目里建议加载完整停用词表并补充业务词。import jieba stopwords set([的, 了, 是, 在, 中, 报告, 数据, 月度]) def clean_text(text): words [w.strip() for w in jieba.cut(text) if w.strip()] words [w for w in words if w not in stopwords] return .join(words) docs_clean [clean_text(doc) for doc in docs]注意我把分词结果用空格拼接成一个字符串。这么做是为了方便TfidfVectorizer后续处理每个词天然以空格分隔不用再依赖分词器。分词过程里去掉单字词和停用词后文本已经比较干净。第二步向量化。这里的关键是设置token_pattern让它能识别中文词然后加上sublinear_tf和L2归一化。from sklearn.feature_extraction.text import TfidfVectorizer vectorizer TfidfVectorizer( token_patternr(?u)\b\w\b, sublinear_tfTrue, norml2, min_df1, max_df0.9, max_features2000 ) X vectorizer.fit_transform(docs_clean)为什么我不用默认的token_pattern因为默认正则是token_patternr(?u)\b\w\w\b要求每个token至少两个字符。对中文来说两个及以上字符的“词”能被匹配但单字词比如“金”“跌”会被丢掉。如果停用词过滤没把该滤的单字滤干净用默认正则反而是一种保安措施但如果你有一些有意义的单字词默认规则会误伤。先eval一下这里我显式设置了token_pattern保证所有剩余词都参与。第三步计算不同K值下的SSE和轮廓系数辅助选K。K从2试到6画出曲线。import matplotlib.pyplot as plt import numpy as np from sklearn.cluster import KMeans from sklearn.metrics import silhouette_score sses [] sil_scores [] k_range range(2, 7) for k in k_range: km KMeans(n_clustersk, initk-means, n_init10, max_iter300, random_state42) labels km.fit_predict(X) sses.append(km.inertia_) if k 2: sil silhouette_score(X, labels) sil_scores.append(sil) fig, axes plt.subplots(1, 2, figsize(12, 4)) axes[0].plot(list(k_range), sses, markero) axes[0].set_title(SSE / K) axes[1].plot(list(k_range)[1:], sil_scores, markero) axes[1].set_title(Silhouette / K) plt.show()km.inertia_就是K-means的SSE。在这个小数据集上你会看到SSE在K3附近出现一个明显的拐点轮廓系数在K3时也最高。考虑到原始语料就是三个主题K3是合理选择。真实项目中不要想当然一定要跑这个曲线。第四步执行聚类并输出每个簇的主题词。km KMeans(n_clusters3, initk-means, n_init10, max_iter300, random_state42) labels km.fit_predict(X) feature_names vectorizer.get_feature_names_out() for cluster_id in range(3): cluster_center km.cluster_centers_[cluster_id] top_indices np.argsort(cluster_center)[::-1][:8] top_words [feature_names[i] for i in top_indices] print(f簇 {cluster_id} 主题词: {top_words}) for idx, doc in enumerate(docs): print(f文本{idx}: {doc} - 簇 {labels[idx]})在这里km.cluster_centers_是一个3×特征数的矩阵每行是一个簇的质心向量元素值表示该词在簇内的加权重要程度。按降序取前8个词就是该簇的主题标签。第五步降维可视化。我习惯先对稀疏矩阵做主成分分析再画散点图用簇标签着色。from sklearn.decomposition import PCA pca PCA(n_components2, random_state42) X_pca pca.fit_transform(X.toarray()) plt.figure(figsize(8, 6)) scatter plt.scatter(X_pca[:, 0], X_pca[:, 1], clabels, cmapviridis, s100) plt.colorbar(scatter) plt.title(K-means 文本聚类结果PCA降维) plt.show()这里要提醒一点PCA只能作为可视化辅助它的二维投影可能有信息损失不能当作聚类效果的直接证明。有些点不同簇在二维图上看起来重在一起不代表算法真的分错了也可能只是PCA把高维信息压没了。这是很常见的误读。3.3 聚类结果解释与簇主题命名跑完上面的代码主题词输出大概是这样的效果簇0国家队、球员、联赛、夺冠、冬奥、金牌……明显是体育簇1模型、多模态、折叠屏、芯片、开源、计算……明显是科技簇2央行、利率、市场、财报、净利润、基金……明显是财经。这说明流程是通的。不过在真实项目中簇的边界不会这么干净。我的做法是先输出每个簇的Top 20词结合簇内样本抽几条看看然后给每个簇起一个“人能看懂”的名字比如“体育赛事新闻”“科技产品与AI”“金融市场动态”。这一步其实就是文本聚类的最终交付物。如果你还想更量化地了解每个簇的大小占比可以打印一下from collections import Counter print(Counter(labels))这样能看到类别是否均衡如果某个簇吃掉了90%的文本说明你预处理或者K值选择出了问题大概率是停用词没滤干净导致所有文本都有一个高权重的“公共词”把所有人往一个中心拽。这也是我在实际项目里遇到最频繁的问题。4. 常见问题与排查技巧实录这部分内容纯粹来自我实际跑文本聚类时踩过的坑有些坑你甚至要在线上跑半夜才能发现。我按“现象→原因→解决”的方式整理方便你直接对号入座并在文末放一个速查表。4.1 空簇某个簇里面没有任何样本现象很明确KMeans跑完发现某个簇标签下没有文本或者只有一个文本。空簇在K-means里并不少见常见原因有三个K设得比真实簇数大强行多分导致某些簇吸不到人初始化质心落在偏僻区域迭代后没有任何点被分给它语料中存在极端离群文档拉走了一整个簇。解决思路是先降K用肘部法则重新确认。如果K没法降就检查数据把那些长度过短、或者内容明显不属于任何主题的文本清洗掉。还有一个小技巧把n_init调大比如20以上让算法多试几次初始质心挑一个SSE最低的解空簇概率会明显下降。4.2 聚类结果换了random_state就完全不同这是K-means的随机初始化导致的不是你的代码写错了。官方实现里init默认是k-means它已经比纯随机初始化稳定很多但k-means本身也依赖随机数种子。所以你如果换了random_state结果可能略有不同尤其是数据本身类间重叠比较严重的时候。工程上的规范做法是固定一个random_state或者写死一个可靠的seed保证每次跑流程结果可复现。做完之后再把这组参数固化到配置里。如果你发现不同random_state下的聚类结果差异非常大那多半是数据本身的簇结构太弱比如主题词被停用词淹没这时候要先回头优化分词和向量化而不是反复换种子碰运气。4.3 线上跑大语料时内存爆炸或速度极慢TfidfVectorizer输出的是稀疏矩阵按理说内存压力不大。容易出问题的往往是后面你自己把稀疏矩阵转成numpy数组比如我在可视化代码里用了toarray()如果特征数特别大、文本数特别多这一下的内存开销可能直接吃掉几个GB。应对方案有三个一是向量化阶段用max_features限制特征数量直接把词汇表压下来二是用scipy的截断SVDTruncatedSVD做降维而不是先toarray再PCA因为TruncatedSVD可以直接作用于稀疏矩阵三是如果文本量几十万起步建议用MiniBatchKMeans替代KMeans它的计算速度更快在存储较大的语料时内存更友好。4.4 聚类结果里每个簇的主题词都一样这是最让人崩溃的问题。症状是无论怎么调K每个簇的Top词都出现同样的“报道”“新闻”“记者”这类词簇之间根本拉不开差距。原因几乎可以肯定是停用词过滤不到位或者max_df设得太高导致高频无意义词还是进入了特征。我的排查顺序是先打印feature_names看看词汇表里有没有明显的高频噪声词把这些词批量加进停用词集合从预处理阶段回到分词如果噪声是“报道”“年度”这类和业务强相关的词可以考虑对它做IDF手动惩罚但最省事的还是直接滤掉。文本聚类是一个反复迭代的过程第一次跑出烂结果是常态重点是要有快速调整的手段。4.5 轮廓系数高但业务看不懂聚类结果这种情况经常会遇到即指标很好人看了直摇头。原因是轮廓系数衡量的是向量空间里的几何距离不代表语义上的类别清晰。如果语料本身有很强的写作模板比如工单文本的格式都差不多它们在向量空间里可能天然分成“带关键词A的模板”和“带关键词B的模板”但业务上这两类都该归为同一类。所以我在实战中从来不是只看指标一定会结合人工抽检。随机抽每个簇20条文本自己读一遍判断是否真的属于同一个业务主题。聚类本质是探索性分析指标、可视化、人工抽检三者互相印证才算是一个可信的聚类结果。4.6 常见问题速查表问题现象可能原因排查与解决出现空簇K值偏大 / 初始质心差 / 离群文本降低K调高n_init清洗异常短文本结果随random_state大幅变化簇结构弱 / 随机初始化影响大固定seed优化停用词增加特征质量内存爆掉 / 跑得极慢特征维度过高 / 稠密转换限制max_features使用TruncatedSVD改用MiniBatchKMeans所有簇主题词一样停用词遗漏 / max_df过高扩充停用词表调小max_df检查feature_names指标好但业务不认可向量空间几何与业务语义不一致人工抽检文本调整分词粒度结合业务重新定K单个簇占比过高公共词权重过大 / K太小检查高频词调整max_df提升K值或做IDF重加权以上这些问题我基本都真实遇到过。文本聚类这个活儿难就难在判断“结果好还是不好”没有绝对标准把预处理做得干净些、参数解释得清楚些比单纯堆模型管用得多。5. 一些只有跑过才知道的经验最后聊几句我自己的体会。文本聚类这个流程里算法只有三成七成都花在数据清洗、分词与停用词迭代上。很多人以为聚类结果不好是K-means的锅其实大多数时候是输入文本本身没收拾干净。我现在的习惯是每跑完一轮就先看每个簇的主题词再随机抽几十条原文看归属确定是不是“语义上说得通”而不是只看SSE和轮廓系数。另外一个经验是不要迷信“最佳K值”。数学指标给出的K只是一个参考锚点业务上的分群粒度才是最终标准。同样的新闻语料运营想要三个大主题编辑想要八个细分类都是合理需求K-means都能满足。你需要做的就是把流程固化下来让改K值、改停用词、看结果这件事变成几分钟的迭代循环。如果你准备用这套方案做更大规模的项目建议后续往两个方向扩展一是把TF-IDF替换成预训练句向量配合K-means或GMM做语义更精细的聚类二是用LDA做主题模型和K-means互为补充一个看文档主题分布一个看硬分群。但不管怎么扩展文章里这套“分词→向量化→选K→聚类→解释→人工校验”的骨架基本不会变。