新闻详情

新闻详情

首页 / 资讯中心 / 详情

MATLAB实战Kmeans聚类:从原理到代码实现与避坑指南

发布时间:2026/8/22 20:04:41
MATLAB实战Kmeans聚类:从原理到代码实现与避坑指南
1. 从数据分堆到Kmeans一个直观的算法入门如果你手头有一堆数据点比如几百个客户的消费记录或者一批传感器的读数老板让你“把它们分分类看看有没有什么规律”你会怎么做一个最朴素的想法可能就是把那些看起来“挨得近”的点归到一堆把“离得远”的点放到另一堆。这个“分堆”的过程在数据科学里就叫聚类。而Kmeans算法就是实现这个想法最经典、最直接的工具之一没有“之一”。它简单到你可能自己都能想出来但强大到在无数工业场景里从用户画像到图像分割都还在发光发热。今天我们不谈那些复杂的数学公式推导就从“分堆”这个最原始的需求出发手把手带你用MATLAB把Kmeans算法实现一遍。我会把重点放在“为什么代码要这么写”以及“实际跑起来会遇到哪些坑”上。毕竟看懂了原理和能写出跑通的代码之间还隔着十万八千里的调试距离。读完这篇你不仅能理解Kmeans是怎么“动”起来的更能获得一份可以直接复制粘贴、修改参数就能用的MATLAB实战代码以及我踩过好几次才明白的避坑指南。2. Kmeans的核心思想用“中心点”说话在深入代码之前我们必须把Kmeans到底在干什么这件事彻底掰扯清楚。很多教程一上来就扔出最小化平方误差的公式这固然严谨但对初学者建立直觉帮助不大。我们换个方式理解。2.1 算法的灵魂迭代与收敛想象你是一个幼儿园老师面前有一群小朋友在操场上随意玩耍。你的任务是把他们分成K个小组比如3组并且让每个小组内部的小朋友尽可能站得近一些。你怎么做一个很自然的流程是随便指定三个小组长初始中心点让他们站在操场上的某个位置。你大喊一声“所有小朋友请跑到离你最近的那个小组长身后站好”于是操场被划分成了三个区域簇每个区域里的小朋友都属于同一个小组完成了“分配”。你发现小组长的位置好像不太对有的组长离自己的组员太远了。于是你让每个小组长挪动一下站到自己组所有小朋友位置的正中心更新中心点。小组长挪了位置有些小朋友离其他组长更近了。于是你重复第2步让他们重新选择离自己最近的小组长。重复第2、3步直到小组长的位置不再发生明显变化或者小朋友们不再更换小组为止。这个过程就是Kmeans算法的核心交替执行“分配”和“更新”。分配Assignment计算每个数据点到所有中心点的距离将其归入距离最近的中心点所在的簇。这就像小朋友找最近的组长。更新Update重新计算每个簇中所有数据点的均值将这个均值作为该簇新的中心点。这就像组长站到组员的中心位置。这个循环会一直进行直到满足停止条件比如中心点的移动距离小于某个阈值或者分配结果不再改变。此时我们认为算法已经“收敛”了。2.2 距离度量如何定义“近”在上面的例子里“最近”是用眼睛看的欧几里得距离。在数学上对于两个n维空间中的点x (x1, x2, ..., xn)和y (y1, y2, ..., yn)最常用的欧氏距离公式是距离 sqrt((x1-y1)^2 (x2-y2)^2 ... (xn-yn)^2)在MATLAB里计算一个数据点到所有中心点的距离非常方便我们后面会看到。这里需要理解的是距离的选择直接影响聚类的结果。欧氏距离是最常见的但它对数据的尺度非常敏感。如果你的数据中一列是身高单位米范围1.5-2.0另一列是收入单位万元范围5-100直接计算欧氏距离会导致收入这一列完全主导了距离的计算身高几乎不起作用。注意因此在应用Kmeans之前对数据进行标准化或归一化例如将每一列数据转换为均值为0、标准差为1的分布是至关重要的一步预处理。这能保证所有特征在计算距离时具有可比性。2.3 目标函数算法在优化什么虽然我们从直觉出发但Kmeans有一个明确的数学目标最小化簇内平方误差和。对于第k个簇其误差是该簇内所有点到其中心点c_k的欧氏距离的平方和。所有K个簇的误差加起来就是总目标函数JJ Σ对于簇k中的每个点i 计算 (x_i - c_k)^2 的和Kmeans的迭代过程分配和更新实际上就是在不断地降低这个J的值。可以证明分配步骤将点分配给最近的中心和更新步骤将中心移动到簇的均值点都能保证J不会增加通常会减少。因此算法最终会收敛到一个局部最优解。理解这一点很重要因为它引出了Kmeans最大的一个局限性对初始中心点的选择敏感。由于算法只能找到局部最优如果一开始随机选的“小组长”位置很糟糕最终可能收敛到一个很差的聚类结果上。这就好比如果你一开始指定的三个组长都挤在操场的同一个角落那么最终分出来的组可能非常不均衡。3. MATLAB实战从零编写Kmeans函数理论说得再多不如一行代码。我们现在就动手在MATLAB里实现一个完整的Kmeans函数。我会逐行解释并穿插我调试时遇到的坑。3.1 函数骨架与输入输出设计首先我们定义函数的接口。一个健壮的Kmeans函数应该考虑哪些参数function [idx, C, sumd, D] myKmeans(X, K, varargin) % 我的Kmeans实现 % 输入 % X - 数据矩阵大小为 [n_samples, n_features]n行样本m列特征 % K - 要聚类的簇数量 % varargin - 可选参数对包括 % MaxIter - 最大迭代次数默认 100 % Tol - 中心点变化的容忍度默认 1e-4 % Replicates - 重复运行次数用于克服初始值敏感默认 1 % Distance - 距离度量默认 sqeuclidean (平方欧氏距离计算更快) % Start - 初始中心点选择方法默认 sample (随机选择样本点) % 输出 % idx - 每个样本所属簇的索引大小为 [n_samples, 1] % C - 最终的中心点坐标大小为 [K, n_features] % sumd - 每个簇内点到中心距离的总和大小为 [K, 1] % D - 每个样本到所有中心点的距离大小为 [n_samples, K]为什么输出里要有sumd和Dsumd可以帮助我们评估每个簇的紧密度而D在某些后续分析比如判断边界点时可能有用。这是从MATLAB内置kmeans函数借鉴来的设计实用性很强。3.2 解析输入参数与初始化接下来我们需要处理用户传入的可选参数并为算法运行做好准备。% 1. 设置默认参数 p inputParser; addParameter(p, MaxIter, 100); addParameter(p, Tol, 1e-4); addParameter(p, Replicates, 1); addParameter(p, Distance, sqeuclidean); addParameter(p, Start, sample); parse(p, varargin{:}); max_iter p.Results.MaxIter; tol p.Results.Tol; replicates p.Results.Replicates; dist_metric p.Results.Distance; start_method p.Results.Start; [n_samples, n_features] size(X); % 2. 数据校验非常重要的防御性编程 if K 0 || K n_samples error(簇数量K必须在1和样本数之间。); end if ~ismember(dist_metric, {sqeuclidean, cityblock, cosine, correlation}) error(不支持的距离度量。请使用 sqeuclidean, cityblock, cosine 或 correlation。); end这里我使用了inputParser来优雅地处理可选参数比手动检查varargin更清晰。数据校验部分经常被忽略但却是写出稳健代码的关键。我曾经因为传入的K是浮点数而debug了半天。3.3 核心迭代循环的实现这是算法的核心部分。我们将“分配”和“更新”步骤放入一个while循环中。为了处理“重复运行”我们会在外层再套一个循环保留效果最好的一次结果。% 初始化最佳结果变量 best_idx []; best_C []; best_sumd inf; % 初始化为无穷大任何结果都比它好 best_D []; for rep 1:replicates % 3. 初始化中心点 (Centroid Initialization) C initCentroids(X, K, start_method); iter 0; C_old C; converged false; while iter max_iter ~converged iter iter 1; % 4. 分配步骤计算每个点到所有中心的距离并找到最近的中心 D pdist2(X, C, dist_metric); % 关键函数计算成对距离 [~, idx] min(D, [], 2); % 沿着第二维列即中心点维度取最小值得到索引 % 5. 更新步骤重新计算每个簇的中心点均值 for k 1:K members (idx k); % 逻辑索引找出属于第k簇的样本 if sum(members) 0 % 防止空簇 C(k, :) mean(X(members, :), 1); else % 如果出现空簇处理策略随机选择一个样本作为新中心 warning(在迭代 %d 中出现了空簇已重新初始化。, iter); C(k, :) X(randi(n_samples), :); end end % 6. 检查收敛条件中心点移动是否非常小 centroid_shift sqrt(sum((C - C_old).^2, 2)); % 每个中心点的移动距离 if max(centroid_shift) tol converged true; fprintf(副本 %d 在 %d 次迭代后收敛。\n, rep, iter); end C_old C; % 为下一次迭代保存旧中心 end % 7. 计算本次运行的总误差目标函数值 current_sumd 0; for k 1:K members (idx k); if sum(members) 0 % 计算该簇内所有点到其中心的距离平方和 dist_vec pdist2(X(members, :), C(k, :), dist_metric); current_sumd current_sumd sum(dist_vec.^2); % 注意pdist2返回的是距离平方误差需要平方 end end % 8. 保留误差最小的那次运行结果 if current_sumd best_sumd best_sumd current_sumd; best_idx idx; best_C C; best_D D; end end % 返回最佳结果 idx best_idx; C best_C; sumd best_sumd; D best_D; end这段代码有几个极易出错的关键点pdist2函数这是计算距离矩阵的核心。D pdist2(X, C, dist_metric)生成一个n_samples行、K列的矩阵D(i, k)就是第i个样本到第k个中心的距离。使用平方欧氏距离sqeuclidean比euclidean少一步开方计算更快且不影响“最近”的判断因为平方函数是单调的。空簇处理在更新中心点时如果某个簇一个样本都没有计算均值会出错。我的处理方式是随机选择一个数据点作为该簇的新中心。这是一种简单策略其他策略还包括选择离其他中心最远的点或者直接合并簇等。收敛判断我检查的是所有中心点移动距离的最大值是否小于容忍度tol。也可以检查中心点位置矩阵C的整体变化范数。目标函数计算注意当使用sqeuclidean时pdist2返回的已经是距离的平方所以dist_vec.^2在理论上是不需要的。但为了代码通用性兼容其他距离度量我保留了平方操作。在实际优化时这里可以做一个判断来提升效率。3.4 初始中心点选择函数的实现初始中心点的选择对结果影响巨大。我们来实现几种常见的方法。function C initCentroids(X, K, method) [n, ~] size(X); C zeros(K, size(X, 2)); switch lower(method) case sample % 方法1随机选择K个不同的样本点 randidx randperm(n, K); C X(randidx, :); case kmeans % 方法2K-means 初始化 (能有效改善聚类质量) % 第一步随机选择一个中心 C(1, :) X(randi(n), :); for k 2:K % 计算每个样本点到已有最近中心的距离平方 D pdist2(X, C(1:k-1, :), sqeuclidean); minDist min(D, [], 2); % 每个样本到其最近中心的距离平方 % 以概率正比于距离平方的方式选择下一个中心 prob minDist / sum(minDist); cumprob cumsum(prob); r rand(); next_idx find(cumprob r, 1); C(k, :) X(next_idx, :); end case uniform % 方法3在数据范围边界内均匀随机生成适用于特定场景 minVals min(X, [], 1); maxVals max(X, [], 1); for d 1:size(X, 2) C(:, d) minVals(d) (maxVals(d) - minVals(d)) .* rand(K, 1); end otherwise error(未知的初始化方法: %s, method); end endK-means是实践中强烈推荐的方法。它的核心思想是让初始中心点彼此尽可能远离。虽然多了一些计算但它能显著提高找到优质聚类结果的概率减少所需的重运行次数Replicates。我强烈建议你在实际使用时将Start参数设置为kmeans。4. 算法测试与可视化眼见为实写好了函数不跑起来看看怎么行我们用经典的鸢尾花数据集进行测试并可视化结果。4.1 数据准备与预处理% 加载数据MATLAB自带 load fisheriris; X meas; % 150个样本4个特征花萼长宽花瓣长宽 true_labels species; % 真实的类别标签用于对比聚类本身是无监督的 % 数据标准化非常重要 X_normalized zscore(X); % 使每个特征均值为0标准差为1 % 设定簇数量K我们知道鸢尾花有3类但实际中K是未知的 K 3;zscore标准化是这里的关键一步。你可以尝试对比使用X和X_normalized的聚类结果差异会非常明显。4.2 运行自编的Kmeans函数% 使用我们编写的函数 [idx, C, sumd, D] myKmeans(X_normalized, K, Replicates, 10, Start, kmeans, MaxIter, 200); fprintf(总误差和: %.4f\n, sum(sumd));这里我设置了Replicates10让算法用不同的初始中心运行10次并返回误差最小的一次结果这能有效缓解初始值敏感问题。4.3 结果可视化与分析聚类结果本身没有标签我们需要用可视化的方式来判断好坏。最直接的就是画出数据点和簇中心。% 选取两个特征进行二维可视化例如花瓣长度和宽度 feat1 3; % 花瓣长度 feat2 4; % 花瓣宽度 figure(Position, [100, 100, 1200, 400]); % 子图1真实类别 subplot(1, 3, 1); gscatter(X(:, feat1), X(:, feat2), true_labels); title(真实类别 (Species)); xlabel(花瓣长度 (cm)); ylabel(花瓣宽度 (cm)); legend(Location, best); grid on; % 子图2Kmeans聚类结果 subplot(1, 3, 2); gscatter(X(:, feat1), X(:, feat2), idx); hold on; plot(C(:, feat1), C(:, feat2), kx, MarkerSize, 15, LineWidth, 3); % 标记中心点 title(sprintf(Kmeans聚类结果 (K%d), K)); xlabel(花瓣长度 (cm)); ylabel(花瓣宽度 (cm)); legend off; grid on; % 子图3轮廓系数图Silhouette Plot - 评估聚类质量 subplot(1, 3, 3); silhouette(X_normalized, idx); title(轮廓系数分布);gscatter函数可以根据分组自动用不同颜色画散点图非常方便。轮廓系数是评估聚类质量的一个常用内部指标其值在-1到1之间越接近1说明聚类效果越好。MATLAB的silhouette函数可以直接生成这个图。运行这段代码你会看到三幅图。通过对比第一幅真实标签和第二幅聚类结果你能直观地感受Kmeans的性能。通常在花瓣特征上鸢尾花数据能获得不错的聚类效果但和真实分类仍会有一些错分这很正常因为聚类是无监督的它只根据数据分布“分堆”而不关心真实的生物类别。4.4 与MATLAB内置函数对比为了验证我们自编函数的正确性可以将其结果与MATLAB内置的kmeans函数进行对比。% 使用MATLAB内置kmeans (使用相同的参数以获得公平比较) [idx_matlab, C_matlab, sumd_matlab] kmeans(X_normalized, K, Replicates, 10, Start, plus, MaxIter, 200); % 比较总误差 fprintf(自编函数总误差: %.4f\n, sum(sumd)); fprintf(内置函数总误差: %.4f\n, sum(sumd_matlab)); % 比较聚类一致性注意聚类索引的编号是任意的需要重新对齐 % 使用匈牙利算法或简单匹配来比较分配的一致性这里用一个简单的示例 % 计算两个划分的调整兰德指数(ARI)需要额外的函数这里仅做示意性对比。 % 可以观察两个idx向量中样本的归属模式是否一致。你会发现在大多数情况下两者的总误差会非常接近这说明我们的自编函数基本正确。细微的差异可能来源于随机初始化的不同或空簇处理策略的微小区别。5. 进阶讨论与避坑指南掌握了基础实现后我们来看看在实际项目中你会遇到哪些更棘手的问题。5.1 如何确定最佳的K值这是Kmeans应用中最经典、最头疼的问题。我们事先往往不知道数据应该分成几类。这里介绍两种最实用的方法1. 肘部法则绘制不同K值对应的总误差和sum(sumd)的曲线。随着K增大误差会下降但下降幅度会逐渐变缓。曲线拐弯像“手肘”的地方通常被认为是合适的K值。maxK 10; inertia zeros(maxK, 1); for k 1:maxK [~, ~, sumd] myKmeans(X_normalized, k, Replicates, 5, Start, kmeans); inertia(k) sum(sumd); end figure; plot(1:maxK, inertia, -o); xlabel(簇数量 K); ylabel(总误差和 (Inertia)); title(肘部法则 (Elbow Method)); grid on;你需要观察曲线找到那个“肘点”即误差下降速度突然变缓的点。对于鸢尾花数据你很可能在K3附近看到这样的点。2. 轮廓系数平均法计算不同K值下所有样本轮廓系数的平均值。选择使平均轮廓系数最大的K。avg_sil zeros(maxK, 1); for k 2:maxK % 轮廓系数要求至少2个簇 [idx, ~] myKmeans(X_normalized, k, Replicates, 5, Start, kmeans); s silhouette(X_normalized, idx); avg_sil(k) mean(s); end figure; plot(2:maxK, avg_sil(2:end), -o); xlabel(簇数量 K); ylabel(平均轮廓系数); title(平均轮廓系数法); grid on;肘部法则更直观但有时拐点不明显轮廓系数更量化但计算量稍大。通常建议两者结合来看。5.2 高维数据与“维数灾难”当特征数量非常多时例如成百上千维所有数据点在高维空间中都会显得非常“稀疏”且距离趋同这使得基于欧氏距离的Kmeans效果变差。这就是“维数灾难”。应对策略特征选择筛选出与聚类目标最相关的特征。降维在聚类前先使用主成分分析、t-SNE或UMAP等方法将数据降至较低的维度如2-10维然后在低维空间进行聚类。这不仅能提升效果还能方便可视化。% 使用PCA降维示例 [coeff, score, ~, ~, explained] pca(X_normalized); % 取前两个主成分解释大部分方差 X_pca score(:, 1:2); % 在二维空间进行聚类和可视化 [idx_pca, C_pca] myKmeans(X_pca, 3, Replicates, 10); figure; gscatter(X_pca(:,1), X_pca(:,2), idx_pca); hold on; plot(C_pca(:,1), C_pca(:,2), kx, MarkerSize, 15, LineWidth, 3); xlabel(第一主成分); ylabel(第二主成分); title(PCA降维后聚类);5.3 异常值的影响与处理Kmeans使用均值作为中心点而均值对异常值非常敏感。一个远离群体的异常点会把它所在簇的中心点“拉”过去导致整个簇的定位失真。处理方法异常值检测与剔除在聚类前使用统计方法如3σ原则或孤立森林等算法检测并移除异常值。使用K-medoids算法这是Kmeans的变种它选择簇中实际存在的一个样本点中位数点作为中心而不是计算均值因此对异常值不敏感。MATLAB中可以通过kmedoids函数实现。5.4 性能优化与大规模数据当数据量巨大时样本数或特征数极大我们的简单实现可能会遇到性能瓶颈。优化方向向量化操作我们的代码中pdist2和均值计算已经是向量化的这是MATLAB的优势。确保避免在循环中进行逐元素操作。距离计算优化对于欧氏距离可以利用(a-b)^2 a^2 b^2 - 2ab的公式通过矩阵乘法高效计算。MATLAB的pdist2函数已经做了高度优化。使用更快的初始化方法K-means虽然好但慢。对于超大数据可以使用随机采样或K-means||等可扩展的初始化方法。考虑使用Mini-Batch Kmeans这是Kmeans的在线学习变种每次迭代只使用数据的一个子集来更新中心点极大地减少了计算量尤其适合无法全部装入内存的数据。你可以尝试自己实现或者寻找相关的工具箱。6. 从原理到拓展Kmeans的家族与变种理解了经典Kmeans你会发现有一整个算法家族基于类似的思想。模糊C均值聚类允许一个样本以不同的隶属度属于多个簇而不是“非此即彼”。适用于边界模糊的数据。层次Kmeans先进行粗粒度聚类再在簇内进行细粒度聚类形成树状结构。谱聚类先对数据点构建相似度图然后对图进行切割来聚类。它在处理非凸形状的簇时比Kmeans强得多。DBSCAN基于密度的聚类不需要预先指定K能发现任意形状的簇并能识别噪声点。选择哪种算法完全取决于你的数据特性和业务需求。Kmeans因其简单、高效、易于理解和实现在数据呈球形分布、簇大小相近时永远是第一个值得尝试的基准模型。最后我个人的一点体会是Kmeans就像一把瑞士军刀里的主刀它不是万能的但在大多数情况下都能派上用场而且足够可靠。真正考验功力的往往不是调用kmeans()那一行代码而是之前的数据预处理标准化、处理异常值、降维和之后的K值选择、结果评估与业务解读。把这些环节都打通你才算真正掌握了聚类分析这门手艺。下次当你面对一堆杂乱无章的数据时不妨就从写一个自己的Kmeans函数开始一步步把它变成有意义的洞察。
网站建设 高端定制 企业官网