☰
k-means聚类分析:原理、MATLAB实现与避坑指南
2026/10/28 10:38:07 网站建设 项目流程

简介:这份实验报告面向学习数据挖掘与机器学习的高校学生及初学者,围绕k-means聚类分析展开,帮助读者理解无监督学习的基本原理并完成MATLAB环境下的算法实现。报告系统梳理了算法流程,包括初始类中心选取、数据点分配、类中心更新与收敛判断,并以经典Iris数据集为对象,对比k=3与k=4时的聚类效果,分析初始中心、输入顺序及k值对结果的影响,同时讨论算法优势与局限。资源包共1个doc文件,约126KB,内含实验目的、算法原理、流程图、结果分析、实验结论与心得体会,附录还给出可直接运行的MATLAB源代码,便于对照复现与二次修改。目前已有2741人学习,适合作为课程实验参考或聚类入门实践材料。

1. 随机撒三颗种子,为什么最后总能长成三堆

把一堆没有标签的点丢进坐标系,随手挑三个点当中心,然后反复做两件事:把每个点归给最近的中心,把每个中心挪到它名下所有点的平均位置。就这么简单,几十行 MATLAB 就能写完,可它偏偏能把鸢尾花数据分成三堆,而且分得还挺像那么回事。k-means 聚类分析最反直觉的地方就在这里——它没有任何“正确答案”可参考,却靠一个平方误差目标函数硬生生把数据压成了紧凑的簇。搞懂它为什么能收敛、收敛到哪里、又在什么情况下翻车,比背下算法步骤有用得多。这份实验报告把原理、流程、MATLAB 源码和 k=3 与 k=4 的对比结果都摊开了,正好拿来拆底层机制。

2. 平方误差目标函数:k-means 到底在最小化什么

2.1 从“簇内紧凑”到 E 的数学定义

聚类要解决的问题是:给定 n 个对象,每个对象是 d 维空间里的一个点,把它分成 k 个簇,使得簇内相似度高、簇间相似度低。k-means 把这个模糊的“相似度”翻译成了一个可计算的量——平方误差总和 E。实验报告里写得很清楚:E 是数据集中所有对象的平方误差的总和,p 是空间中的点,是簇的平均值,距离度量用欧几里得距离。

用更直白的话说,对每个簇,算簇内每个点到该簇中心的距离平方,全部加起来,就是 E。k-means 的每一次迭代,本质上都在做一件贪心的事:在“分配”阶段固定中心、调整归属来降低 E;在“更新”阶段固定归属、调整中心来降低 E。两个阶段交替进行,E 单调不增,而 E 有下界(最小为 0),所以算法必然在有限步内停住。这就是收敛性的底层保证——不是玄学,是单调有界。

提示:E 单调不增只保证收敛到局部最优,不保证全局最优。初始中心选得不同,可能停在不同的局部极小值上,这是 k-means 最根本的设计取舍。

2.2 为什么用均值而不是中位数

簇中心取“平均值”,这是 k-means 名字的由来,也是它和 k-medoids 的核心区别。均值有一个很好的性质:在欧几里得距离下,使簇内距离平方和最小的点,恰好就是该簇的算术平均。换句话说,更新阶段把中心挪到均值位置,是对当前归属下 E 的精确最优解,不是近似。如果换成中位数,最小化的是绝对距离和,目标函数就变了,那是另一个算法。

但均值也带来了脆弱性。一个远离簇的孤立点会把均值狠狠拽过去,因为平方放大了大距离的权重。实验结论里专门提到“该算法对孤立点是敏感的,如果数据集中存在有极大值的对象,应该消除这种敏感性”,说的就是这个机制层面的原因。常见的实现思路是在跑 k-means 之前先做异常值剔除或标准化,否则一个离群点就能让整个簇的中心偏移。

2.3 距离计算在代码里长什么样

实验源码里计算距离的核心就一行:

dist = sum((repmat(x(i,:),k,1)-nc).^2,2);

这行代码把第 i 个数据点复制 k 份,与 k 个中心逐元素相减,平方后按行求和,得到一个 k 维向量,第 j 个分量就是该点到第 j 个中心的欧几里得距离平方。repmat的作用是广播,避免写显式循环;.^2是逐元素平方;sum(...,2)沿第二维求和。随后[m,ind] = min(dist)取最小值的位置,就是该点归属的簇编号。

这里有个容易忽略的细节:代码比较的是距离平方而不是距离本身。因为平方根是单调函数,比较平方和比较距离等价,但省掉了开方运算。对 n 个点、k 个中心、迭代上百次的场景,这个省法在数值上没损失,在效率上有意义。这也是 k-means 对大数据集有很好伸缩性的一个小侧面。

3. 两阶段迭代:分配与更新为什么能交替收敛

3.1 分配阶段的贪心本质

分配阶段做的事很干脆:固定当前 k 个中心,对每个点独立地找最近中心。注意“独立”二字——每个点的归属只取决于它自己和所有中心,不依赖其他点的归属。这意味着分配阶段可以完全并行化,也是 k-means 能扛住大规模数据的结构原因。

但这种独立性也埋了一个坑:一个点改换门庭,可能让原簇和新簇的均值都发生变化,而分配阶段并不考虑这个连锁反应。它只管当下距离最近,不管改完之后 E 会不会因为中心移动而反弹。所以单次分配不保证 E 降到全局最低,只保证在当前中心下每个点都做了局部最优选择。

3.2 更新阶段:均值即最优中心

更新阶段的代码是:

for i = 1:k ind = find(cid==i); nc(i,:) = mean(x(ind,:)); nr(i) = length(ind); end

find(cid==i)找出所有被分到第 i 簇的点的下标,mean(x(ind,:))按列求平均得到新中心,nr(i)记录该簇的点数。前面说过,均值是平方误差下的最优中心,所以这一步对固定归属是精确最优。两个阶段合起来,就是坐标下降法的一个特例:先优化归属、再优化中心,交替下降,直到 E 不再变化。

实验报告里的流程图把这个循环画得很清楚:选 k 个对象作为初始平均值,聚类,重新计算平均值,判断新的平均值是否改变,变了就回去继续,不变就结束。判断条件“平均值发生改变”在代码里对应的是归属是否还在变动,因为中心不变等价于归属不变(在无并列距离的常规情况下)。

3.3 收敛判据与迭代上限

源码里有两层循环,第一层是固定maxgn=100的主迭代:

while iter < maxgn for i = 1:n dist = sum((repmat(x(i,:),k,1)-nc).^2,2); [m,ind] = min(dist); cid(i) = ind; end for i = 1:k ind = find(cid==i); nc(i,:) = mean(x(ind,:)); nr(i) = length(ind); end iter = iter + 1; end

这里用迭代次数上限代替了显式的收敛判断,跑满 100 次就退出。严格来说这不是最优写法——如果 E 提前稳定,继续迭代纯属浪费;如果 100 次还没稳定,强行截断可能得到未收敛的结果。常见的实现思路是在循环内比较新旧中心或新旧归属,一旦无变化就break。实验代码选择固定次数,更多是教学演示的简化,实际工程里应该加收敛判据。

第二层循环更有意思,它做的是“再判断”:

maxiter = 2; iter = 1; move = 1; while iter < maxiter & move ~= 0 move = 0; for i = 1:n dist = sum((repmat(x(i,:),k,1)-nc).^2,2); r = cid(i); dadj = nr./(nr+1).*dist'; [m,ind] = min(dadj); if ind ~= r cid(i) = ind; ic = find(cid == ind); nc(ind,:) = mean(x(ic,:)); move = 1; end end iter = iter+1; end

这段代码引入了一个调整后的距离dadj = nr./(nr+1).*dist',用簇大小对距离做加权。nr./(nr+1)这个因子随簇内点数增加而趋近于 1,对小簇则明显小于 1,效果是让点更倾向于留在或加入较小的簇,缓解大簇越滚越大的马太效应。这是对标准 k-means 的一个经验性修补,不是教科书里的标准步骤,属于实现层面的调优。move标志记录本轮是否有任何点换了簇,没有就退出。maxiter=2限制了这层修补最多跑两轮,防止在边界点上反复横跳。

4. 初始中心与 k 值:两个决定成败的输入

4.1 为什么初始中心不能随便选

实验报告在心得体会里写得很直白:初始类中心的选择对聚类的准确度有较大影响,最好选择两两距离较大、且能代表不同数据样本类别的点作为初始类中心。这句话背后是局部最优的机制——k-means 的收敛点由初始中心所在的“吸引域”决定,初始中心落在哪个盆地,最终就滑到哪个局部极小。

源码里的初始化方式是:

bn = round(n/k*rand); nc = [x(bn,:); x(2*bn,:); x(3*bn,:);];

n/k把数据均分成 k 段,rand在第一段内随机取一个偏移,然后取第 bn、2bn、3bn 个点作为三个初始中心。这个做法的意图是让初始中心在数据序列上均匀铺开,避免三个中心挤在一起。实验报告解释为“前 k 个数据有较大差别,可以提高程序的运行效率和分类结果的准确率”,本质上是想用数据本身的排列顺序来近似“两两距离较大”。

但这种依赖数据顺序的初始化很脆弱。如果数据没有按类别聚集排列,bn、2bn、3bn 可能落在同一类里,初始中心就退化了。工业界更常用 k-means++:第一个中心随机选,后续每个中心按与已选中心距离平方成正比的概率选取,从概率上保证初始中心分散。实验代码没上 k-means++,是教学简化,但理解这个差距对实际使用很重要。

4.2 k 值:用户输入背后的领域判断

k 由用户输入,最终类别数就是用户给的数。实验报告在结论里强调“k-means 对类别数的选择 k 值有较高的要求,如果类别数较少,则不能区分数据”。k=3 时鸢尾花数据能较好分成三类,k=4 时出现过度分割,某些类别过于稀疏——这不是算法算错了,是 k 本身超出了数据真实结构。

k 值的选择没有算法内的自动机制,必须靠对数据的先验了解。实验心得里说“需要使用该算法的人员对于数据分类有一定的了解,并且可以根据观察部分原始抽样数据,得出该样本数据的大致类别数目”,这是实话。工程上常见的辅助手段是肘部法(画 E 随 k 变化的曲线找拐点)或轮廓系数,但这些都是事后评估,不是 k-means 自带的。

4.3 数据输入顺序的影响边界

实验报告有一句容易被误读的话:“数据的输入顺序不同,同样影响迭代次数,而对聚类结果没有太大的影响。”这句话要拆开看。输入顺序影响的是初始化时 bn、2bn、3bn 取到哪些点,从而影响初始中心,进而影响收敛路径和迭代次数。但如果初始中心碰巧落在同一个吸引域,最终结果确实可能一样。说“没有太大影响”是经验观察,不是理论保证——换个顺序完全可能滑到另一个局部最优,结果就变了。把这句话当成“顺序无所谓”来用,迟早翻车。

5. 避坑与排查:k-means 跑出怪结果时先看这几处

5.1 空簇导致中心变成 NaN

现象:某次迭代后某个簇一个点都没有,mean对空矩阵求平均返回 NaN,后续所有距离计算全变 NaN,聚类彻底崩掉。

原因:初始中心选得太偏,或者某个中心在迭代中被完全“抢空”。源码里nc(i,:) = mean(x(ind,:))没有对ind为空做保护,这是教学代码的常见疏漏。

解决:在更新阶段加判断,若isempty(ind),要么保留原中心不动,要么从数据中随机挑一个点重置该中心。工程实现里后者更常见,能帮助算法跳出退化状态。

5.2 迭代次数爆掉但 E 不降

现象:跑满 100 次上限,E 曲线早就平了,程序还在空转。

原因:源码用固定maxgn=100代替收敛判据,没有提前退出机制。数据量大时这是实打实的时间浪费。

解决:在每轮更新后比较新旧中心矩阵,若norm(nc_new - nc_old) < tol(tol 取 1e-6 量级)就break。或者比较归属向量是否还有变化,逻辑更直接。

5.3 k=4 时出现“过度分割”就以为算法坏了

现象:鸢尾花明明三类,设 k=4 后多出一个稀疏簇,就怀疑代码有 bug。

原因:k-means 会忠实地把数据分成你要求的 k 簇,哪怕真实结构只有三类。多出来的簇是从某个大类里硬切出来的,自然稀疏。这是 k 值设定问题,不是实现问题。

解决:先用肘部法或领域知识确认 k,再跑聚类。看到稀疏簇先回头查 k,别急着改代码。

5.4 孤立点把整个簇拽偏

现象:某个簇的中心明显偏离该簇主体,簇内点分布被拉成一条长条。

原因:平方误差对大距离敏感,一个远离主体的点对均值的拉扯远超普通点。实验结论里明确点出了这个敏感性。

解决:聚类前做异常值检测(如 3σ 或 IQR 规则)并剔除,或改用对离群点更鲁棒的 k-medoids。若业务上不能删点,至少对特征做标准化,压缩极端值的量纲影响。

5.5 不同次运行结果不一致就以为随机性失控

现象:同一份数据跑两次,簇的编号和边界略有不同。

原因:初始化用了rand,每次 bn 不同,初始中心不同,收敛到的局部最优可能不同。簇编号本身没有语义,编号变了不代表聚类质量变了。

解决:固定随机种子(rng(42))保证可复现;评估质量看 E 或轮廓系数,不看簇编号。若多次运行 E 波动大,说明数据本身簇结构不明显,该考虑换算法了。

6. 从教学代码到工程实现:k-means 的设计取舍在哪里

这份实验报告的价值不在于它给出了一个多完美的 k-means 实现,而在于它把算法的骨架、初始化的土办法、收敛判据的简化、以及 k 值和初始中心这两个“人肉输入”的权重,全都摊在了明面上。拿它和工业级实现对比,能看清几个关键取舍。

第一个取舍是初始化。教学代码用n/k分段加随机偏移,赌的是数据顺序有结构;k-means++ 用概率分布保证初始中心分散,代价是多一轮距离计算。前者快但脆,后者稳但多花时间。数据量小的时候差别不大,数据量大且簇结构复杂时,k-means++ 能显著降低陷入坏局部最优的概率。

第二个取舍是收敛判据。固定迭代上限简单、可预测耗时,但要么浪费要么截断;动态判据省时间,但需要选阈值,阈值太松收敛不彻底,太紧则空转。工程上通常两者结合:设一个宽松的上限兜底,同时用中心变化量做提前退出。

第三个取舍是目标函数本身。平方误差配均值,数学上干净、实现上简单、对凸形且密度相近的簇效果好,这是实验结论里承认的优势。但它对非凸簇、密度差异大的簇、含离群点的数据都不友好。这时候不是调参能救的,得换 DBSCAN 或层次聚类。k-means 的定位应该是“快速拿到一个还行的划分”,而不是“任何数据都能分对”。

第四个取舍是那个nr./(nr+1)加权修补。它不属于标准 k-means,是源码作者加的经验项,用来抑制大簇膨胀。这类修补在工程里很常见,但每加一个就离“标准算法”远一步,可解释性和可复现性都会打折。用之前得想清楚:是要一个数学上干净的基线,还是要一个针对当前数据调过的实用版。

我自己的习惯是,每次拿到一份聚类代码,先不看它怎么迭代,先看它怎么初始化、怎么判收敛、怎么处理空簇和离群点——这三处最能暴露实现者的取舍,也最容易在换数据时翻车。从那以后我每次跑 k-means 都强制先固定种子、再画一次 E 随迭代的曲线,确认它真的在降而不是在抖。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询