ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

K-means聚类MATLAB实战:从示例代码到可复用脚本的避坑指南

K-means聚类MATLAB实战:从示例代码到可复用脚本的避坑指南

简介:这份资源面向刚接触无监督学习、需要在 MATLAB 环境中完成数据聚类实验的学生与工程人员,围绕 K-means 算法的原理讲解与代码落地展开,帮助读者理解聚类中心初始化、样本分配、质心更新与迭代终止的完整流程。压缩包共 1 个 docx 文件,约 15KB,内容以文字讲解配合 MATLAB 示例代码为主,涵盖内置 kmeans 函数的调用方式、idx 与 centroids 返回值的含义,以及用 scatter 绘制分簇散点与质心的可视化写法。文中还讨论了 K 值选取、异常值敏感与初始化依赖等局限,并简要介绍 K-means++、Bisecting K-means 与 Kernel K-means 等改进思路,便于读者在复现示例后继续扩展。目前已有 444 人学习,适合作为课堂实验或自学聚类的入门参考材料。

1. 拿到一份 kmeans 聚类算法 matlab 代码,先别急着跑

七行二维数据、一个kmeans(data, K)调用、一张散点图,这份 kmeans 聚类算法 matlab 代码文档看起来简单到让人怀疑它值不值得单独存一份。但真正在 MATLAB 里做过聚类落地的人都知道,能跑通和能交付之间隔着一段距离:idx和centroids两个返回值怎么用、K 怎么定、重复跑三次结果为什么不一样、散点图上的颜色顺序为什么和簇编号对不上,这些问题在示例代码里一行注释都不会提。这份资源的价值不在于算法本身有多深,而在于它给了一个最小可复现的骨架,让你能把 K-means 从公式落到 MATLAB 的变量空间里,再往上叠评估、叠可视化、叠业务解释。适合两类人:一类是刚接触无监督学习、需要在课程设计或小数据集上快速出结果的新手;另一类是熟手,想找一个干净的基线脚本,用来对比自己写的初始化策略或距离度量改动。下面按「这份代码到底在做什么 → 怎么把它改成能用的脚本 → 哪里会翻车」的顺序拆开讲。

2. 七行数据背后的 K-means 执行链路:从 idx 到 centroids

2.1 示例代码逐行拆解与变量语义

先把原料里的代码原样摆出来,逐行看清楚每个变量在 MATLAB 工作区里到底是什么形状、什么类型。很多人跑完只看到图,却说不清idx是列向量还是行向量、centroids的行序和簇编号是不是一一对应,后面接评估指标时就会对不上号。

% K-means 聚类算法示例 % 初始化数据:7 个二维样本,每行一个样本 data = [1 1; 1.5 2; 3 4; 5 7; 3.5 5; 4.5 5; 3.5 4.5]; K = 2; % 聚类簇数 % 执行 K-means 聚类 % idx: N×1 向量,取值 1..K,表示每个样本归属的簇 % centroids: K×2 矩阵,第 i 行是第 i 个簇的质心 [idx, centroids] = kmeans(data, K); % 绘制聚类结果 figure; hold on; scatter(data(:, 1), data(:, 2), [], idx, 'filled'); scatter(centroids(:, 1), centroids(:, 2), 100, 'k', 'filled', 'Marker', 'x'); legend('Cluster 1', 'Cluster 2', 'Centroids'); hold off;

逻辑说明:data是 N×D 矩阵,N=7、D=2,这是 MATLAB 统计与机器学习工具箱里绝大多数函数的样本约定——行是样本、列是特征,搞反了不会报错但结果全错。kmeans(data, K)返回两个东西:idx是 N×1 的簇索引,centroids是 K×D 的质心矩阵。绘图部分用idx直接作为scatter的颜色参数,这是 MATLAB 里很顺手的一个技巧,因为scatter的第四个参数接受数值向量并自动映射到当前色图。

参数说明:K是唯一需要人定的超参,示例取 2 是因为数据肉眼可分两团。kmeans还有几个默认值藏在背后——距离度量默认是平方欧氏距离('sqeuclidean'),最大迭代默认 100,启动重复次数默认 1,初始化默认用'kmeans++'的采样策略。这几个默认值决定了同一份数据两次运行可能给出不同的簇编号,后面避坑章节会专门说。

2.2 为什么示例选内置 kmeans 而不是手写迭代

有人会问:既然是学算法,为什么不自己写初始化、分配、更新质心那三步循环?答案是分场景的。如果你要交的是算法原理作业,手写一遍确实有必要,能逼你看清「分配」和「更新」交替进行时目标函数单调不增这件事。但如果你要的是工程落地,内置kmeans至少帮你处理了三件手写容易漏的事:空簇处理、收敛判据、以及多次启动取最优。

手写版本最常见的翻车是某个簇在迭代中变空,然后质心变成NaN,整个矩阵污染,后面所有距离计算全废。内置函数遇到空簇会重新选一个离现有质心最远的点作为新质心,这个策略不完美但至少不会让脚本崩掉。收敛判据上,内置实现比较的是连续两次迭代的簇分配是否稳定,而不是简单看质心位移小于某个阈值,后者在数据尺度差异大时很难定一个通用阈值。

所以我的建议是:用内置kmeans做基线,把手写版本作为对照实验。具体做法是把内置结果的簇内平方和(within-cluster sum of squares)算出来,再和你手写版本的结果比,如果手写版明显更差,大概率是初始化或空簇处理出了问题。这个对照不需要额外工具箱,几行代码就能搭起来。

% 计算簇内平方和,作为后续对比手写版本的基线 wcss = 0; for k = 1:K members = data(idx == k, :); if isempty(members) continue; end diff = members - centroids(k, :); wcss = wcss + sum(diff(:).^2); end fprintf('K=%d 时簇内平方和: %.4f\n', K, wcss);

这段代码里idx == k做逻辑索引取出第 k 簇的所有样本,diff(:).^2把矩阵拉平后逐元素平方再求和,得到该簇所有样本到质心的平方距离总和。wcss是后面选 K 的核心指标,也是判断一次聚类是否「聚散了」的量化依据。注意空簇判断不能省,虽然内置函数一般不会返回空簇,但你换成手写版本或改初始化策略后这个分支就会派上用场。

3. 把示例改成可复用脚本:K 值选择、重复启动与结果固化

3.1 用肘部法给 K 一个说得过去的理由

示例里K = 2是拍脑袋定的,真实数据上拍脑袋的代价是返工。常见做法是跑一串 K 值,记录每个 K 对应的簇内平方和,画出来找拐点。这不是什么高深方法,但它能把「我觉得分两类合适」变成「K 从 1 到 6 的 WCSS 曲线在 K=3 之后下降明显变缓」,后者在评审或答辩场合站得住脚。

% 肘部法:扫描 K=1..6,记录每个 K 的簇内平方和 K_range = 1:6; wcss_list = zeros(size(K_range)); for i = 1:numel(K_range) k = K_range(i); % Replicates=5:每次 K 跑 5 次启动,取 WCSS 最小的那次 [idx_tmp, C_tmp, sumd] = kmeans(data, k, 'Replicates', 5); wcss_list(i) = sum(sumd); % sumd 是每个簇的簇内距离和 end figure; plot(K_range, wcss_list, '-o'); xlabel('聚类簇数 K'); ylabel('簇内平方和 WCSS'); title('肘部法选择 K'); grid on;

逻辑说明:kmeans的第三个返回值sumd是 K×1 向量,存每个簇内样本到质心的距离平方和,sum(sumd)就是整体 WCSS,比手动循环算更省事。'Replicates', 5是关键参数,它让每次 K 值跑 5 次不同初始化并保留 WCSS 最小的结果,直接缓解了初始化敏感问题。

参数说明:Replicates取 5 是经验值,数据量大或簇结构复杂时可以加到 10 到 20,代价是运行时间线性增长。K_range的上界不要超过样本数的平方根太多,7 个样本扫到 6 已经接近极限,真实数据一般扫到 10 左右就够看拐点了。肘部法本身有主观性,拐点不明显时别硬找,换轮廓系数或直接上业务约束。

3.2 固定随机种子,让结果可复现

同一份数据、同一个 K,两次运行给出不同的簇编号,这是 K-means 最容易被误判为「算法不稳定」的地方。根因是初始化带随机性,而 MATLAB 的随机数流默认每次启动都不同。解决办法是在调用前固定随机种子,让整个流程可复现。

% 固定随机种子,保证多次运行结果一致 rng(42); % 42 是任意选的整数,换成别的也行,关键是固定 % 用固定种子重新跑一次,并显式指定距离度量和重复次数 [idx_fixed, C_fixed, sumd_fixed] = kmeans(data, 2, ... 'Distance', 'sqeuclidean', ... 'Replicates', 10, ... 'MaxIter', 200, ... 'Display', 'final'); % 把结果固化到结构体,方便后续保存或传给其他函数 result.idx = idx_fixed; result.centroids = C_fixed; result.wcss = sum(sumd_fixed); result.K = 2; save('kmeans_result.mat', 'result');

逻辑说明:rng(42)设定全局随机数生成器的起点,之后所有依赖随机数的函数都从同一序列取值,所以只要代码和输入不变,结果就稳定。'Display', 'final'会在命令窗口打印最终迭代次数和 WCSS,调试时比闷头跑有用。最后把结果塞进结构体存成.mat,是为了把「跑出结果」和「用结果」解耦——绘图、评估、导出可以分脚本做,不用每次重跑聚类。

参数说明:'Distance'显式写出来是为了防止有人后来改了全局默认却不知道;可选值还有'cityblock'(曼哈顿距离)、'cosine'(余弦距离),选哪个取决于你的特征语义,欧氏距离适合连续且量纲一致的数值特征。'MaxIter'从默认 100 提到 200 是给复杂数据留余量,正常收敛远用不到。注意rng只保证同一 MATLAB 版本内可复现,跨版本不保证,所以固化结果比固化种子更可靠。

3.3 把 idx 和 centroids 接到下游:导出与可视化增强

聚类结果最终要被人看、被别的程序用。示例里的散点图只能看个大概,实际交付时至少要做两件事:把每个样本的簇归属导出成表,以及在图上标出簇编号和质心坐标,避免图例和实际簇号错位。

% 导出样本归属表 T = array2table(data, 'VariableNames', {'x', 'y'}); T.cluster = idx_fixed; writetable(T, 'cluster_assignments.csv'); % 增强可视化:标注簇编号与质心坐标 figure; gscatter(data(:,1), data(:,2), idx_fixed, lines(K), 'o', 8); hold on; for k = 1:K text(C_fixed(k,1), C_fixed(k,2), sprintf('C%d', k), ... 'FontSize', 12, 'FontWeight', 'bold', 'Color', 'k'); plot(C_fixed(k,1), C_fixed(k,2), 'kx', 'MarkerSize', 14, 'LineWidth', 2); end xlabel('特征 1'); ylabel('特征 2'); title(sprintf('K-means 聚类结果(K=%d)', K)); hold off;

逻辑说明:array2table把数值矩阵转成带列名的表,追加cluster列后writetable导出 CSV,这是把聚类结果交给非 MATLAB 环境(比如表格软件或下游脚本)最省事的方式。可视化部分换成gscatter,它按分组变量自动着色并生成图例,比手动scatter加legend更不容易出现「图例写 Cluster 1 但实际颜色对应簇 2」的错位。text在质心位置标C1、C2,plot用黑色叉号叠一层,保证打印成黑白也能分辨。

参数说明:lines(K)生成 K 种区分度较高的颜色,K 超过 7 时颜色会开始接近,这时候要考虑换标记形状或分面画图。'MarkerSize', 14和'LineWidth', 2是为了质心在图上足够醒目,数值可按输出尺寸调。导出的 CSV 里簇编号是数值,下游如果按字符串处理记得转换。

4. 避坑与排查:K-means 在 MATLAB 里最容易翻车的五处

4.1 现象:两次运行簇编号对调,误以为算法不稳定

原因:K-means 的簇编号本身没有语义,初始化不同会导致簇 1 和簇 2 互换,但聚类结构其实一样。解决:固定rng种子,并在比较两次结果时不要直接比idx是否相等,而是比 WCSS 或调整兰德指数;如果只是展示,固定种子后编号就稳定了。

4.2 现象:数据量纲差异大时,某个特征完全主导距离

原因:平方欧氏距离对数值范围敏感,一个取值在千级的特征会淹没取值在个级的特征。解决:聚类前做标准化,data_norm = normalize(data)或手动(data - mean(data)) ./ std(data),再喂给kmeans。注意标准化参数要保存下来,新样本预测时用同一套均值和标准差。

4.3 现象:K 设大了出现空簇或单样本簇

原因:数据本身簇结构少于 K,或者初始化把两个质心扔进了同一团。解决:先看肘部曲线确认合理 K 范围;确实需要大 K 时提高Replicates次数,并检查返回的sumd里是否有接近 0 的项,那通常就是单样本簇,业务上要判断是否该合并。

4.4 现象:idx拿去索引原矩阵时报维度不匹配

原因:idx是 N×1 列向量,有人当成行向量用,或者data本身是 D×N 转置过的。解决:进函数前统一用size(data,1)断言样本数,assert(size(data,1) == numel(idx)),把维度问题挡在聚类之后、下游之前。

4.5 现象:含缺失值的数据直接报错或结果异常

原因:kmeans不接受NaN,部分版本会直接报错,部分场景下静默丢弃导致样本数对不上。解决:聚类前显式处理缺失,要么删行data = data(~any(isnan(data),2),:),要么用均值或中位数填补,并在结果表里保留原始行号映射,避免导出后对不上原始记录。

5. 从二维示例到真实数据:维度扩展与结果验证的一个习惯

示例只有两个特征,画个散点图就能肉眼验收,真实数据动辄十几维,图是画不出来的,这时候验证手段要换。我一般会做三件事,按顺序走一遍再决定这份聚类结果能不能用。

第一件是看簇规模分布。用tabulate(idx)或histcounts(idx, 1:K+1)看每个簇有多少样本,如果出现一个簇占了 90% 以上,基本说明 K 偏小或距离度量不合适,别急着解释业务含义。第二件是看质心在原始特征上的取值,把centroids反标准化回原始量纲,逐维看哪个特征在簇间差异大,这比任何降维图都直接。第三件是留出验证:如果样本量允许,随机抽 20% 不参与聚类,用训练出的质心给它们做最近邻分配,看分配结果的簇规模比例和训练集是否接近,偏差过大说明簇结构不稳定。

% 真实数据上的三步验证骨架 data_norm = normalize(data); % 标准化 [mu, sigma] = deal(mean(data), std(data)); % 保存反标准化参数 rng(7); [idx_v, C_v, sumd_v] = kmeans(data_norm, K, 'Replicates', 20); % 1) 簇规模分布 fprintf('簇规模分布:\n'); tabulate(idx_v); % 2) 质心反标准化,回到原始量纲看差异 C_original = C_v .* sigma + mu; disp('各簇质心(原始量纲):'); disp(array2table(C_original, 'VariableNames', feature_names)); % 3) 留出验证:用质心给新样本做最近邻分配 % 这里 new_data 需先按同一套 mu/sigma 标准化 new_norm = (new_data - mu) ./ sigma; d = pdist2(new_norm, C_v); % 新样本到各质心的距离 [~, new_idx] = min(d, [], 2); % 取最近质心 fprintf('留出样本簇规模分布:\n'); tabulate(new_idx);

这段骨架里pdist2算的是新样本到每个质心的距离矩阵,min(d, [], 2)沿第二维取最小值得到最近质心编号,这就是 K-means 分配步骤的复用。feature_names需要你按实际列名填,没有就跳过表头。留出验证的簇规模比例和训练集差多少算「接近」没有硬标准,我的经验是单个簇的占比偏差超过 10 个百分点就要回头查标准化是否一致、K 是否合理。

最后说一个我踩过的坑:早期做聚类交付时,我把idx直接当业务标签用,结果换一批数据重跑,簇编号全变了,下游报表对不上。从那以后我每次固化聚类结果,都会同时存质心矩阵和标准化参数,新数据一律走「标准化 → 算距离 → 取最近质心」这条固定路径,不再依赖重跑聚类来对齐编号。这个习惯比任何调参技巧都省事,希望帮到你。

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

RELATED READING

延伸阅读

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