简介:这是一份完整的k-means数据聚类实验报告,附有MATLAB详细源代码,面向学习数据挖掘、机器学习及聚类分析的本科生和研究人员,便于快速掌握算法原理并上手编程实践。报告以经典Iris鸢尾花数据集为实验对象,系统讲解k-means算法初始化类中心、分配样本、更新均值、收敛判断的完整流程,并对比k=3与k=4下的聚类效果,直观展示类别数选择对结果的影响。文档还总结了初始类中心、数据输入顺序对算法收敛性的影响,分析了k-means对离群点敏感、k值难以确定等局限,并给出心得体会、参考文献及可直接运行的Matlab主程序代码,便于他人复现实验或二次开发。资源为单个doc文档,仅126KB,内容紧凑完整;已有2737人学习下载,适合作为课程实验报告模板或Matlab聚类练习参考。
1. 实验报告里的 k-means,为什么值得再认真读一遍
很多人对 k-means 的第一印象是“太简单了”:给定 K 个簇、随机选中心、迭代到收敛,几行代码就能跑完。可真到了写实验报告、要交代清楚“为什么这么设参数”的时候,才发现到处是坑——跑十次聚类出十种结果、空簇导致程序报错、数据量纲不一样聚类出来全是“形状相似但毫无意义”的簇。这份标题为“k-means数据聚类实验报告(MATLAB详细代码)”的资料,本质上是想解决一个实际问题:如何用 MATLAB 写出一份可复现、可解释、能交差的 k-means 聚类实现,而不是只调一下内置函数、贴一张彩图就算完。
我接触过不少做数据分析的开发者,大家普遍卡在三个地方:一是只知道调用kmeans函数,不知道手写一遍迭代逻辑时收敛条件怎么设;二是 K 值只会用肘部法则画个图,但不知道怎么定量判断“拐点”到底在哪儿;三是拿到的数据特征量纲差异极大,聚类出来的结果完全偏离直觉,还以为是算法出了问题。这些问题的答案,恰恰都藏在这个实验报告的标题里——它强调“详细代码”,意味着我们不只要会“用”,还要能“讲”、能“改”、能“排错”。
这篇文章会沿着一条从原理到落地的路径展开:先讲清楚 k-means 的优化目标和 MATLAB 实现时需要做的三件事,再给出一份可复现的手写实现与内置函数对照代码,然后重点说 K 值选择、初始化策略这些必调参数,最后用一整章讲我踩过的坑。适合正在做聚类实验、写数据分析报告、或者想搞明白“MATLAB 里聚类到底怎么调才靠谱”的读者。
2. k-means 聚类的原理与 MATLAB 选型:为什么手写一遍比直接调函数更有用
2.1 优化目标与迭代逻辑:先把数学讲透,代码才不会写错
k-means 的目标函数是让所有样本点到其所属簇中心的距离平方和最小,这个量在文献里通常写成 SSE(Sum of Squared Errors)。给定数据集 X,我们希望找到 K 个簇中心,使得每个样本都被分到距离最近的那个中心,同时中心的位置能让簇内样本到它的总距离达到极小。听起来很直观,但里面藏着一个循环依赖:簇中心决定样本归属,样本归属反过来决定簇中心,这没法一步求出解析解,只能迭代。
迭代的每一步其实只有两个操作。第一步是固定中心,把每个样本分到距离最近的中心,这叫 E 步(期望步,Expectation);第二步是固定归属,把每个簇的中心更新为该簇内所有样本的均值向量,这叫 M 步(最大化步,Maximization)。如此反复,直到簇中心不再变化,或者变化的幅度小于你设定的容差。MATLAB 在这两步上有一个天然优势:矩阵运算不需要循环。计算样本到中心的距离可以写成pdist2,更新均值可以按簇编号用accumarray或splitapply,向量化的写法比 for 循环快十倍以上,这在高维数据上感受会非常明显。
2.2 手写代码时必须想清楚的三个设计决策
第一个决策是初始化方式。因为目标函数是凸函数但实际数据分布通常不是凸分布,不同的初始中心会收敛到不同的局部最优解。常见做法是随机选 K 个样本作为初始中心,但更稳妥的方式是 k-means++,它让初始中心尽量分散,能显著降低最终 SSE 的方差。MATLAB 自带的kmeans函数默认用的就是 k-means++,这也是我们手写代码时推荐对齐的初始化策略。
第二个决策是距离度量。默认是欧氏距离,这适用于数据特征尺度相近的场景。如果你的特征包含不同物理单位(比如身高和收入),直接算欧氏距离会导致量纲大的特征在距离计算里占据主导地位。这个问题不是 k-means 本身能解决的,必须在聚类前做标准化。很多实验报告里聚类结果“看起来不对”,八成是数据没做预处理。
第三个决策是收敛判定。不能只判断中心“是否变化”,因为浮点数迭代时中心可能在极小范围内抖动。我一般会同时设两个条件:中心点绝对变化量小于1e-4,或者迭代次数达到上限(比如 100 次)。这两者取“先到者”,既保证收敛精度,又防止死循环。
2.3 MATLAB 在这个场景下的定位:不是唯一选择,但确实是实验报告最省事的工具
如果你只是做业务数据分析,Python 的 scikit-learn 可能是更主流的选择;但如果你做课程实验、算法对比、或者写需要嵌入矩阵推导的实验报告,MATLAB 有它的优势:矩阵语法和论文公式几乎一一对应,绘图和导出图片非常方便,调试时可以直观看到矩阵尺寸和数值变化。这份实验报告之所以选用 MATLAB,我猜测就是因为它的代码逐行可解释、公式与代码能互相印证。
换句话说,这里的定位不是“生产级大规模聚类工具”,而是“算法验证与教学演示环境”。明白了这一点,你就不会纠结于分布式聚类或者百万级样本性能优化,这些 MATLAB 里确实不是强项。我们需要的是:代码结构清晰、能跑通小规模数据、每一行都能跟公式对上、画出来的散点图能直接放进报告里。
3. 用 MATLAB 从零实现 k-means:最小代码块与逐行解释
3.1 一个不依赖工具箱的 k-means 函数:适合放进实验报告主体
先看最核心的实现。为了让代码能直接放进报告且不依赖 Statistics 工具箱,我一般用pdist2(这个在 Statistics 工具箱里)自己写距离计算,或者干脆用repmat手动展开。下面这份代码是不依赖任何工具箱的版本,在任何 MATLAB 版本上都能运行:
function [label, center, SSE] = my_kmeans(X, K, maxIter, tol) % 输入: % X - n×d 矩阵, n 个样本, d 维特征 % K - 聚类簇数 % maxIter - 最大迭代次数, 默认 100 % tol - 中心点变化容差, 默认 1e-4 % 输出: % label - n×1 向量, 每个样本的簇编号 % center - K×d 矩阵, 最终簇中心 % SSE - 每轮迭代的误差平方和, 用于画收敛曲线 [n, d] = size(X); % 1. 初始化: 随机选 K 个不重复样本作为初始中心 rng(42); % 固定随机种子, 保证实验可复现 initIdx = randperm(n, K); center = X(initIdx, :); SSE = zeros(maxIter, 1); for iter = 1:maxIter % 2. E 步: 计算每个样本到所有中心的距离, 分配最近簇 distMat = zeros(n, K); for k = 1:K diff = X - repmat(center(k, :), n, 1); distMat(:, k) = sum(diff .^ 2, 2); % 直接存平方距离 end [~, label] = min(distMat, [], 2); % 3. M 步: 按簇内样本均值更新中心 newCenter = zeros(K, d); for k = 1:K clusterPoints = X(label == k, :); if isempty(clusterPoints) % 空簇处理: 随机选一个远离所有中心的样本作为新中心 warning('第 %d 个簇为空, 重新初始化该中心', k); distToCenter = sum((X - center(k, :)).^2, 2); [~, farIdx] = max(distToCenter); newCenter(k, :) = X(farIdx, :); else newCenter(k, :) = mean(clusterPoints, 1); end end % 4. 计算当前 SSE 并检查收敛 curSSE = 0; for k = 1:K clusterPoints = X(label == k, :); if ~isempty(clusterPoints) diff = clusterPoints - repmat(newCenter(k, :), size(clusterPoints, 1), 1); curSSE = curSSE + sum(diff .^ 2, 'all'); end end SSE(iter) = curSSE; change = sum(sum((newCenter - center).^2, 2)); center = newCenter; fprintf('Iter %d, SSE = %.4f, center change = %.4f\n', iter, curSSE, change); if change < tol SSE = SSE(1:iter); fprintf('Converged at iteration %d\n', iter); return; end end % 如果到了最大迭代次数仍未收敛, 截断 SSE 数组 SSE = SSE(1:maxIter); end这段代码的逻辑和第一节的公式逐行对应:第 1 步是初始化,固定随机种子可以保证报告里的图和结果可以复现;第 2 步的 E 步是计算每个样本到每个中心的平方欧氏距离,这里没有用开方操作,因为最小化平方距离和最小化距离是等价的,还能省掉不必要的计算量;第 3 步的 M 步是均值更新,注意空簇的处理逻辑;第 4 步是收敛检查。
实际运行时你会看到每次迭代都会打印 SSE 和中心变化量,放进实验报告就是一张收敛过程表。这里的rng(42)是关键,我见过太多实验报告因为没固定随机种子,跑出来的图和正文描述不一致。你的报告如果要求“验证可复现”,固定种子是第一步。另外注意输入数据 X 的维度,如果特征是二维,可以直接用scatter画散点图;如果维度高于二维,建议先做 PCA 降维成二维再画,否则图形无法直观验证簇的分布。
3.2 使用内置 kmeans 函数做对照验证:报告里最好两个版本都有
手写实现的意义是让你把算法讲清楚,但内置函数的意义是快速验证手写版的结果是否合理。我在做实验报告时一般会写一个小脚本,同时跑手写版和内置版,对比它们的标签一致性:
% 对比脚本: 手写版 vs 内置版 rng(42); X = [randn(100, 2) * 0.5 + [2, 2]; ... randn(100, 2) * 0.5 + [-2, -2]; ... randn(100, 2) * 0.5 + [0, 3]]; K = 3; % 手写版 [label_mine, center_mine, SSE_mine] = my_kmeans(X, K, 100, 1e-4); % 内置版 [label_builtin, center_builtin] = kmeans(X, K, 'MaxIter', 100, 'Replicates', 5); % 用混淆矩阵检查两个版本的簇匹配程度 % 注意: 手写版和内置版的簇编号可能顺序不同, 需要先对齐这段代码里有一个很关键的细节:Replicates设为 5。内置版是会跑 5 次初始化并保留最优结果的,而手写版只跑了一次初始化。所以它们的结果通常不会完全一致,甚至簇编号标签可能完全不同——比如内置版把数据右上方的簇标为 1,而手写版标为 3。这时候不能直接对比标签值,要做标签对齐。我在报告里常见的写法是:先算出每个簇的样本均值,再通过匈牙利算法或简单贪心匹配对应关系,也可以用confusionmat看两个标签之间的映射关系。这里不需要贴完整代码,但一定要在报告里说清楚这个“编号顺序可能不同”的注意事项,否则会有人认为手写版实现有 bug。
3.3 报告里的可视化三板斧:散点图、质心轨迹图、收敛曲线图
实验报告没有图就没有说服力。我一般画三张图:第一张是原始数据散点图,不同簇用不同颜色标注,质心用黑色五角星标出;第二张是迭代过程中质心的移动轨迹,可以看出算法从初始位置“走”到最终位置的过程;第三张是 SSE 随迭代次数的下降曲线,观察是否单调递减、有没有震荡。三张图的代码可以拼在一个脚本里:
% 可视化: 三合一图 figure; subplot(1, 3, 1); gscatter(X(:,1), X(:,2), label_mine); hold on; plot(center_mine(:,1), center_mine(:,2), 'kx', 'MarkerSize', 12, 'LineWidth', 2); title('聚类结果'); % 质心轨迹: 需要在 my_kmeans 里额外保存每轮 center % 这里假设你已经修改 my_kmeans 返回了 centerHistory 变量 subplot(1, 3, 2); for k = 1:K traj = squeeze(centerHistory(:, k, :)); plot(traj(1, :), traj(2, :), 'o-'); hold on; end title('质心移动轨迹'); subplot(1, 3, 3); plot(1:length(SSE_mine), SSE_mine, 'b-', 'LineWidth', 1.5); xlabel('迭代次数'); ylabel('SSE'); title('收敛曲线');这段代码里,gscatter 是 Statistics 工具箱里的函数,如果不想增加工具箱依赖,可以换成scatter加循环以及legend手动控制颜色。质心轨迹图画出来以后,你可以很直观地看到初始质心是怎么“跑”到最终位置的——如果轨迹出现大范围横跳,说明初始中心选得太差,或者 K 值选得不合适。这些图放到报告里,既能证明你理解了算法过程,也能让老师一眼看出你的实验是真实跑的而不是编数据。
4. K 值与初始化策略:实验报告里最需要交代清楚的三个必调参数
4.1 K 值选择:肘部法则,但要会用“定量拐点”替代“肉眼拐点”
K 值是 k-means 唯一的超参数,实验报告里必须交代清楚“这个 K 为什么是这个数”。肘部法则的做法是:跑 K = 1 到 10,记录每个 K 对应的 SSE,然后画折线图。理论上 SSE 会随着 K 增大而下降,但下降速度会在某个点之后明显变慢,这个拐点就是“肘部”。然而实际数据往往没有特别明显的肘部,肉眼看到的“拐点”经常在这一轮和下一轮之间变化。
我给一个常用的定量判断方法:计算相邻 K 之间 SSE 的下降比例,选择下降比例首次低于 15% 的 K 值,或者选择下降比例相对上一段明显减小的 K。举个例子,如果 K 从 2 到 3 时 SSE 下降了 30%,从 3 到 4 时只下降了 8%,那我会选 K=3。下面是一段自动找肘部的小脚本:
% 自动选择 K: 计算下降比例并找肘部 K_range = 1:10; SSE_all = zeros(length(K_range), 1); for Ki = K_range [~, ~, SSE_curr] = my_kmeans(X, Ki, 100, 1e-4); SSE_all(Ki) = SSE_curr(end); end % 计算下降比例 dropRatio = diff(SSE_all) ./ SSE_all(1:end-1); % 找第一个下降比例低于 15% 的 K elbowK = find(dropRatio < 0.15, 1, 'first'); fprintf('建议 K = %d\n', elbowK);这段脚本的关键在于,每个 K 都只跑了一次my_kmeans,这在不同初始化下可能带来很大波动。更稳的做法是对每个 K 跑 5 次取最小 SSE,代码量会稍大,但报告里的说服力更强。我一般会在报告里写清楚:“为了消除初始化随机性的影响,每个 K 值重复 10 次实验,取最小 SSE”,这句话值得写进任何一份严肃的实验报告。
4.2 初始化方式:随机初始化与 k-means++ 的量化差异
如果你只跑一次 k-means,结果可能很“看脸”。用一个二维的混合高斯数据做测试,随机初始化时有时会把两个高斯簇合并成一个,出现异常的 SSE 值。k-means++ 的基本思想是:第一个中心随机选一个样本,之后每个新中心都以与已有中心的距离平方成比例的概率被选中。这样初始中心天然分散,后续迭代不容易陷入局部最优。
MATLAB 内置的kmeans默认就用了 k-means++。手写版如果想要对齐这个效果,可以加一个简单的初始化函数。我在报告中通常会把随机初始化和 k-means++ 初始化各跑 20 次,统计每次的 SSE(误差平方和)分布:
- 随机初始化:SSE 均值较大,标准差也大,说明结果不稳定
- k-means++:SSE 均值明显减小,标准差大幅缩小,结果更稳定
这个“性能对比”表放进实验报告里非常加分。它直接证明了你在实验中理解了初始化的意义,而不是只抄了一行代码。
4.3 后处理:空簇问题与聚类结果评估
实验过程中,不管初始化选得多好,都有可能出现空簇。空簇的原因通常是数据分布极不均匀,或者 K 选得比实际类别数大。处理空簇有两个常用策略:一是把空簇删除,最终的簇数少于预设 K,代价是报告里前后 K 值不一致;二是把离当前中心最远的样本点“强制分配”给空簇,当作新中心的起点,这也是我前面代码里采用的办法。第二种策略能保持簇的数量不变,优点是不会打破报告前后逻辑的一致性。
聚类结果的评估不能只看图。报告里至少要用两个指标:SSE(越小越好)和轮廓系数(Silhouette Coefficient,越接近 1 越好)。图捏出来的数据往往 SSE 好看但轮廓系数差,说明簇之间很接近。MATLAB 里的silhouette函数可以直接跑:
% 计算轮廓系数并画图 figure; silhouette(X, label_mine); title(sprintf('K = %d 时的轮廓系数', K));把轮廓系数放进报告的意义在于,它能在“视觉上看起来还行”时提醒你:这个聚类可能只是平庸的结果,并不是数据里真正有 T 个簇。我在实验里遇到过明明用肉眼看有 4 个团,轮廓系数只有 0.35 的情况,这就是因为团与团中间有大量过渡样本,聚类边界非常模糊。这个认知对实验报告的价值很高,因为它表明你理解了“聚类质量不是画图看出来的”。
5. 避坑与常见问题:写 MATLAB 聚类实验报告最容易翻车的 5 个点
5.1 初始化不固定,每次跑出来结果不一样
现象:同一份代码,第一次运行得到 cluster 1 有 80 个样本,第二次却变成 cluster 2 有 80 个样本,甚至连 SSE 值都不同。
原因:k-means 的初始化是随机的,除非手动指定随机种子,否则每次运行的起点都不一样。这在报告里是致命的:老师拿你的代码跑一遍,结果和报告里的截图对不上,直接怀疑你做假。
解决:在脚本最开头写明rng(42),行程里固定随机种子;如果你的 MATLAB 版本较旧,可以写成rand('seed', 42); rng(42);,双保险。把固定种子这件事郑重地写进报告,这本身就是实验严谨性的体现。
5.2 特征量纲差异导致聚类结果“画风诡异”
现象:数据集包含两个特征,一个在 0~1 范围,另一个在 5000~6000 范围。聚类出来的结果几乎只由第二个特征决定,散点图上看起来第一个特征完全没有区分度。
原因:欧氏距离对特征的绝对尺度极其敏感。量纲大的特征在距离计算中占据了绝对主导,小量纲特征的信息被淹没了。这不是算法问题,是数据预处理不到位。
解决:聚类之前做 Z-score 标准化,让每个特征为零均值、单位方差。MATLAB 里一行代码:X = zscore(X);。还有一种不那么推荐的做法是 Min-Max 归一化,但它会保留离群点的影响,而 zscore 用标准差做尺度,抗离群点能力好得多。
5.3 空簇直接报错而不是提示
现象:迭代到某一步,某个簇里没有任何样本,mean(clusterPoints, 1)对空矩阵计算均值,返回 NaN。之后的迭代全部被 NaN 传染,最终 SSE 和中心点全是 NaN,图也画不出来。
原因:没有做空簇检测。很多入门教程的代码默认每个簇非空,这对均匀分布的数据还成立,对真实数据几乎必然会出事。
解决:前面贴的my_kmeans函数里已经加上了空簇处理,随机选一个最远的样本作为新中心。另一个替代方案是直接删除空簇,并把 K 减 1,但这需要在报告里额外说明“为什么最终簇数不等于预设 K”。我通常采用“最远点补位”策略,因为它在保持 K 值不变的同时,还能顺带防止中心集体“塌缩”到一个区域。
5.4 收敛曲线不单调下降,甚至有震荡
现象:SSE 曲线在前几轮下降,后面突然回升,然后又下降,像锯齿一样。
原因:你在迭代过程中用的是旧中心算距离、分簇,然后用新中心算 SSE。每次迭代的 SSE 实际上应该下降,但如果数据点恰好在两个中心之间反复横跳,可能会出现局部震荡。更多时候是初始化太差,导致搜索过程在多个较差区域之间徘徊。
解决:检查初始化方式是否用了 k-means++;把容差调大一点,比如tol = 1e-3;或者直接增加Replicates。如果震荡持续发生在迭代后期,那基本可以断定 K 值或距离度量选错了。
5.5 两个簇的结果几乎完全对称,肉眼看不出差别
现象:聚类完成后,cluster 1 和 cluster 2 的中心几乎在一条直线上,而且簇的形状高度相似,疑似是两个中心互为“镜像”。
原因:K 值可能过大,一个原本连续的簇被强行切成了两半。或者数据本身只存在一个簇,但你强行设 K=3,算法只能“找茬”式地切分密集区域。
解决:用轮廓系数诊断——对每个簇单独计算平均轮廓系数,系数接近 1 说明簇紧凑、分离良好;接近 0 或为负数,说明这个簇不该存在。然后在报告里给出结论:“将 K 从 3 减为 2 后,轮廓系数从 0.3 提升到 0.7”,比任何解释都有说服力。
6. 进阶用法:把 k-means 实验代码改造成可复用的函数库
当你跑通一次之后,后面的工作最忌讳的就是每次都复制粘贴整片代码、改两个变量名再运行。我一般会在实验初期就把代码整理成三个独立函数:数据生成器、算法实现器、可视化报告器。这样做的收益不是省代码量,而是让实验不同环节可以独立对比。
数据生成器负责生成不同形状的数据,比如高斯混合、环形、月牙形。高斯混合适合 k-means 正常发挥;环形和月牙形适合“演示 k-means 不擅长处理非凸簇”——这个对比放进实验报告里非常出彩,因为它展示了你对算法适用边界的理解,而绝大多数报告只会展示一个完美的聚类结果。我在做模拟项目 X 时,就用同样的测试集评估了 k-means、DBSCAN 和层次聚类三种方法,报告里讨论的重点不是“谁好谁差”,而是“哪一种算法在哪种数据分布下更合适”。这样的分析深度是老师愿意给高分的关键。
算法实现器里,我建议把my_kmeans封装成带名称-值对参数的版本,接受'Distance'、'Init'、'MaxIter'、'Replicates'等参数。封装之后,做参数对比实验时就不需要改函数内部代码,只需要写一个循环,遍历不同的参数组合跑若干遍。这份my_kmeans不依赖任何工具箱,放到任何机器上都能直接运行,意味着你的实验报告在答辩现场可以随时复现。
可视化报告器除了画聚类结果、质心轨迹、收敛曲线以外,我还会加一个功能:输出聚类结果汇总表,包含每簇样本数、每簇 SSE 贡献、平均轮廓系数。这张表可以直接导出成 CSV 贴到报告里,省去了手抄数据的麻烦。这样做还有一个额外的好处——当你把算法换一种初始化策略或换一个 K 值时,对比表会直观地显示哪组参数更优,直接支撑“参数选择依据”那一章。
最后说一个我自己的习惯:每次跑实验,我会顺手把初始中心、每轮中心变化量和对应的 SSE 记录直接存到工作区里。等到报告快写完时,如果发现数据记录不够,不需要重跑全部实验,只重跑某一个随机种子下的结果补充即可。这比一开始不记录、后面返工要节约大量时间。这个习惯帮我少走了很多弯路,也避免过不少“临到交报告发现数据对不上”的尴尬。希望帮到你。
本文还有配套的精品资源,点击获取