MATLAB聚类算法源码解析:Kmeans、FCM、SOM与层次聚类
简介:这份 MATLAB 聚类算法资料包面向机器学习、模式识别课程学习者与需要做算法对比实验的开发者,围绕无监督分类场景整理 FCM、K-means、Kmedoid 等经典方法及 GK、GG 等扩展实现,可用于课程实验、数据分群与算法性能对照。压缩包共 74 个文件,约 2.05MB,以 40 个 m 脚本承载聚类主流程与示例调用,17 个 gif 及少量 png、jpg 用于界面和结果展示,另有 6 个 txt 说明、1 个 pdf 工具箱文档、mat 与 wk1 数据文件及少量网页样式文件,兼顾代码、演示与参考资料。已有 4888 人学习,说明其在聚类入门与实验复现方面具有一定参考价值。读者可从中获得隶属度迭代、聚类中心更新、归一化与反归一化、有效性评价、PCA 投影和 demos 示例等完整实现,并借助可视化与指标对比理解不同算法在模糊边界、非球形分布等数据上的适用差异,便于快速改参数、跑通实验并查漏补缺。
1. 从一堆散落的 .m 文件说起:四种聚类算法源码包能解决什么问题
前阵子有个同学拿着一份聚类算法源码包来找我,说跑出来的图和教材上不一样。打开一看,十几个.m文件摊在目录里,Kmeans、FCM 模糊 C 均值、神经网络聚类、层次聚类各占一块,示例脚本能直接出图,但参数全靠手改。这就是典型的教学型源码包:不做工程封装,把算法主循环完整摊开给你看。它适合正在做图像分割、样本分组、模式识别课程设计,又不想只调kmeans一个函数了事的人。MATLAB 环境下四种聚类算法摆在一起对比,价值不在跑通,而在看清初始化、迭代停止、结果评价这三处差异。
2. 环境与目录结构:四套 MATLAB 实现怎么摆、依赖什么
先把环境说清楚。这套代码是纯 MATLAB 实现,常见的兼容区间是 R2016b 及以上,脚本里没有用太新的语法糖,老版本基本也能跑。唯一需要注意的是距离计算:如果作者用了pdist2或linkage,就得装 Statistics and Machine Learning Toolbox;如果用的是手写欧氏距离,纯基础 MATLAB 就能跑。拿到包之后先别急着点运行,花两分钟理清目录和函数签名,后面能省掉大半排查时间。
2.1 目录组织:四个算法各自独立,靠一份数据打通
这类源码包常见的组织方式是按算法分文件夹,每个文件夹里放「一个主函数 + 一个 demo 脚本 + 一份示例数据」,最后再放一个总对比脚本。结构大致如下:
clustering-demo/ ├── data/ │ └── blobs.mat # 示例数据,N×D 的样本矩阵 + 可选真值标签 ├── kmeans/ │ ├── myKmeans.m # 主算法,返回标号、中心、目标函数曲线 │ └── demo_kmeans.m # 示例脚本,出散点图和迭代曲线 ├── fcm/ │ ├── myFCM.m # 返回隶属度矩阵 U 和聚类中心 │ └── demo_fcm.m ├── som/ │ ├── mySOM.m # 自组织映射,返回权值矩阵 │ └── demo_som.m ├── hier/ │ ├── demo_hier.m # 层次聚类,主要调 linkage / dendrogram │ └── cut_tree.m # 按簇数裁剪树 └── compare_all.m # 四种算法在同一份数据上横向对比按算法分目录的好处是依赖干净,缺点也明显:四份实现各自管自己的距离计算,同一个欧氏距离可能被写了四遍。二次开发时第一件事我一般就是把公共部分抽出来,比如统一一个sqdist.m,后面调参对比才不会被不同实现的距离定义坑到。data/目录里那份数据是关键,四个 demo 都读它,保证对比时输入一致。
2.2 入口函数的签名:先看参数,再看主循环
读这种源码包,顺序应该是先扫每个主函数的输入输出,再进主循环。四个算法的签名通常是这个形状:
| 文件 | 主要输入 | 主要输出 | 关键参数 |
|---|---|---|---|
myKmeans.m | X(N×D)、K | idx、C、Jhist | 簇数 K、最大迭代、中心位移阈值、随机种子 |
myFCM.m | X、K、m | U、C、objHist | 模糊指数 m(常用 2)、终止阈值 |
mySOM.m | X、网格尺寸 | W(权值)、命中次数 | 学习率初值、邻域半径、训练轮数 |
demo_hier.m | X、K | T(簇标号)、Z(连接矩阵) | linkage 方法、距离度量 |
看到这张表就能判断代码成熟度:如果myKmeans没有随机种子参数,那么每次跑结果都会跳,示例图看着还行,一写进报告就露馅;如果myFCM没有m参数而是写死成 2,那基本只能跑标准场景,遇到分布重叠的数据就调不动。参数越全,二次开发的余地越大。
2.3 首次跑通的最小路径:从 compare_all 反着推
新手最容易犯的错是从第一个文件夹开始一个个跑,跑到第三个发现数据路径不对,前两个又得重来。我一般反着来:先跑compare_all.m,它是四个算法的汇合点,能把数据加载、路径、依赖问题一次性暴露出来。跑通了,说明环境和数据都没问题,再回头单独看每个算法。
跑之前建议在脚本开头加三行保护,让结果可复现:
% 放在 compare_all.m 最前面 rng(42); % 固定随机种子,聚类结果才可复现 addpath(genpath(pwd)); % 把子文件夹一次性加进搜索路径 warning('off', 'stats:kmeans:FailedToConverge');第一行解决「每次结果不一样」,第二行解决「函数找不到」,第三行只在用工具箱函数时才需要。genpath(pwd)会把data/、kmeans/这些子目录全加进去,比手动addpath四次省事,但要注意它会连.git之类的隐藏目录也扫进去,目录深的时候启动会慢一点,可以在.git里放个空的pathdef挡一下,或者干脆写成addpath('kmeans','fcm','som','hier','data')。
跑通之后先别急着看图,把四个算法输出的簇内平方和记下来。同一个 K、同一份数据,Kmeans 和 FCM 的 SSE 应该在同一量级,如果差了一个数量级,多半是某个实现的距离算错了或者数据没归一化。这一步看着多余,但后面调参时它就是你的基准线。
3. Kmeans 与 FCM 核心实现:两个最常用的分割算法怎么读
这两份实现是整个包里被翻得最多的。Kmeans 是硬划分,一个样本只属于一个簇;FCM 是软划分,一个样本对每个簇都有一个隶属度。代码结构看着像,其实差别全在「更新」这一步:Kmeans 更新的是中心,FCM 更新的是中心加隶属度矩阵,而且两者交替进行。读懂这一层,后面调参才有方向。
3.1 Kmeans 主循环:E 步和 M 步各自在做什么
手写版 Kmeans 的核心就是一个 while 循环,两块内容:把样本分给最近的中心(E 步),用分到的样本重算中心(M 步)。下面是这类源码包里常见的实现形态,我补上了空簇处理和收敛判断:
function [idx, C, Jhist] = myKmeans(X, K, maxIter, tol, seed) % X : N×D 样本矩阵,每行一个样本 % K : 簇数 % maxIter : 最大迭代次数,默认 300 % tol : 中心最大位移阈值,默认 1e-6 % seed : 随机种子,固定后结果可复现 if nargin < 5, seed = 42; end if nargin < 4, tol = 1e-6; end if nargin < 3, maxIter = 300; end [N, D] = size(X); rng(seed); % ---- 初始化:k-means++,第一个中心随机,后续按距离平方概率抽 ---- C = zeros(K, D); C(1, :) = X(randi(N), :); for k = 2:K d2 = sqdist(X, C(1:k-1, :)); % N×(k-1) d2 = min(d2, [], 2); % 每个样本到已有中心的最近距离平方 p = d2 / sum(d2); p(end) = 1 - sum(p(1:end-1)); % 修掉浮点误差,防止 cumsum 不到 1 [~, j] = min(abs(cumsum(p) - rand)); C(k, :) = X(j, :); end Jhist = zeros(maxIter, 1); for it = 1:maxIter % E 步:每个样本归到最近的中心 D2 = sqdist(X, C); [dmin, idx] = min(D2, [], 2); % M 步:按当前划分重算中心 Cnew = C; for k = 1:K if any(idx == k) Cnew(k, :) = mean(X(idx == k, :), 1); else Cnew(k, :) = X(randi(N), :); % 空簇重新播种,别让它一直空着 end end Jhist(it) = sum(dmin); shift = max(sqrt(sum((Cnew - C).^2, 2))); % 本轮中心最大位移 C = Cnew; if shift < tol, break; end end Jhist = Jhist(1:it);sqdist是手写的欧氏距离平方,用矩阵乘法展开,不依赖工具箱,N 上千的时候比双重循环快一个量级:
function D2 = sqdist(A, B) % 欧氏距离平方,返回 numel(A 行) × numel(B 行) 的矩阵 D2 = sum(A.^2, 2) + sum(B.^2, 2)' - 2 * (A * B'); D2 = max(D2, 0); % 浮点误差可能产生极小的负数,砍掉两个地方值得多说一句。第一是收敛判据,这里用的是「中心最大位移」,另一种常见写法是「目标函数相对变化」,两者都行,但中心位移对尺度更敏感,数据没归一化时容易提前停,所以我一般在调用前先zscore一下。第二是Jhist只保留到实际迭代次数,绘图时 x 轴才对得上,不然曲线后面拖一截平的,看着像没收敛。
3.2 k-means++ 初始化:多写十行换来的是稳定
随机撒 K 个点当初始中心,是 Kmeans 最省事也最坑的做法。数据分布稍微不均匀,两个初始中心落进同一个密集团,那个簇就永远分不开,SSE 每次跑差一大截。k-means++ 的思路很简单:第一个中心随机取,之后每个中心按「到已有中心的最近距离平方」为概率抽取,距离越远的点越可能被选中,天然把中心推开。上面那段初始化就是这个逻辑,代价是多十行代码和一次N×K的距离计算,换来的是结果方差明显下降。
判断一份 Kmeans 实现靠不靠谱,我有个土办法:同一份数据跑十次,看 SSE 的极差。如果极差超过均值的 5%,说明初始化策略太弱,要么改成 k-means++,要么加多起点重启(跑nInit次取 SSE 最小的那组)。多起点重启写起来更简单:
bestJ = inf; for run = 1:10 [idxRun, CRun, Jrun] = myKmeans(X, K, 300, 1e-6, run); if Jrun(end) < bestJ bestJ = Jrun(end); idx = idxRun; C = CRun; Jhist = Jrun; end end代价是时间翻十倍,样本量上万时不划算,这时候 k-means++ 的性价比就体现出来了。
3.3 FCM 的隶属度更新与收敛判据
FCM 把「属于/不属于」换成了「属于的程度」,隶属度矩阵U是 N×K,每行和为 1。中心更新和隶属度更新交替进行,公式里多了一个模糊指数m,它控制划分的模糊程度:m 越接近 1,越接近硬划分;m 越大,隶属度越平均。工程上 m 取 1.5 到 2.5 之间,默认 2 是最常见的起点。
function [U, C, objHist] = myFCM(X, K, m, maxIter, tol) % X : N×D 样本矩阵 % K : 簇数 % m : 模糊指数,必须 > 1,常用 2 % maxIter : 最大迭代次数 % tol : 目标函数相对变化阈值 if nargin < 5, tol = 1e-5; end if nargin < 4, maxIter = 200; end if nargin < 3, m = 2; end [N, D] = size(X); U = rand(N, K); U = U ./ sum(U, 2); % 行归一化,保证每行隶属度和为 1 objHist = zeros(maxIter, 1); for it = 1:maxIter Um = U .^ m; % 1) 更新中心:隶属度的 m 次方做权重 C = (Um' * X) ./ max(sum(Um, 1)', eps); % 2) 更新隶属度:到中心的距离越小,隶属度越大 d2 = sqdist(X, C); d2 = max(d2, eps); % 防止样本与中心重合时除零 tmp = d2 .^ (-1 / (m - 1)); U = tmp ./ sum(tmp, 2); % 3) 目标函数:加权距离平方和 objHist(it) = sum(sum(Um .* d2)); if it > 1 && abs(objHist(it) - objHist(it-1)) / objHist(it-1) < tol objHist = objHist(1:it); break; end end三个点要注意。d2 = max(d2, eps)是后悔药:如果某个样本恰好落在某个中心上,距离为 0,0^(-1/(m-1))会变成Inf,整个矩阵就废了。max(sum(Um,1)', eps)是同一个道理,某个簇一个样本都没吸住时,分母为 0。收敛判据用的是目标函数的相对变化而不是绝对值,因为目标函数本身随样本量增长,固定阈值在不同数据集上没法通用。
还有一个隐藏问题:FCM 对初始隶属度敏感,随机初始化同样会出现「跑十次十个结果」。常见的补救是先用 Kmeans 跑一遍,把硬划分转成隶属度当初始值,代码就多三行:
idx0 = myKmeans(X, K); U0 = full(sparse(1:N, idx0, 1, N, K)); U0 = U0 + 0.01; % 加一点噪声,避免 0 和 1 的极端初值 U = U0 ./ sum(U0, 2);这样收敛更快,也更不容易掉进局部极小。代价是引入了 Kmeans 本身的偏差,做对比实验时得在报告里说明初始化方式,不然别人复现不出你的数字。
4. 神经网络聚类与层次聚类:另外两种实现的读法
前两个算法是按「簇中心」组织数据的,后两个换了个思路。神经网络聚类(自组织映射那一路)用一个网格去拟合数据的拓扑结构,适合看数据的低维流形;层次聚类不做划分,先建一棵树,再按需要裁剪。这两种的代码结构和前面差别较大,读法也不一样。
4.1 自组织映射:竞争、邻域、权值更新三步
SOM 的骨架是一个二维网格,每个节点挂一个和样本同维的权值向量。训练时每个样本找到最相似的节点(胜者),然后胜者连同它邻域内的节点一起向样本方向拉。三步循环:找胜者、算邻域、更新权值。核心在于邻域半径和学习率都要随训练轮数衰减,否则网格会僵在一个状态不动。
function [W, hits] = mySOM(X, gridSize, epochs, lr0, sigma0) % X : N×D 样本矩阵 % gridSize : [行 列] 网格尺寸,常用 [5 5] 或 [10 10] % epochs : 训练轮数,常用 100 到 500 % lr0 : 初始学习率,常用 0.5 % sigma0 : 初始邻域半径,常用网格尺寸的一半 [N, D] = size(X); R = gridSize(1); Cc = gridSize(2); % 权值初始化:在数据范围内随机撒点,比全零好收敛 W = rand(R, Cc, D) .* (max(X) - min(X)) + min(X); hits = zeros(R, Cc); [rr, cc] = ndgrid(1:R, 1:Cc); % 网格坐标,用于算邻域距离 for ep = 1:epochs % 学习率和邻域半径指数衰减,这是 SOM 能收敛的关键 lr = lr0 * exp(-3 * ep / epochs); sg = sigma0 * exp(-3 * ep / epochs); order = randperm(N); % 每轮打乱样本顺序 for i = order x = X(i, :); % 1) 找胜者节点:权值与样本距离最小的那个 diff = W - reshape(x, 1, 1, D); dist2 = sum(diff.^2, 3); [~, win] = min(dist2(:)); [wr, wc] = ind2sub([R Cc], win); hits(win) = hits(win) + 1; % 2) 高斯邻域:离胜者越近,更新幅度越大 h = exp(-((rr - wr).^2 + (cc - wc).^2) / (2 * sg^2)); % 3) 权值更新 for d = 1:D W(:, :, d) = W(:, :, d) + lr * h .* (x(d) - W(:, :, d)); end end end这里sigma0取网格尺寸的一半是有讲究的:训练初期邻域几乎覆盖整个网格,所有节点一起向数据靠拢,形成大致的拓扑;后期半径收缩到 1 以内,就只剩胜者自己在微调。如果sigma0设得太小,网格会从一开始就各自为政,最后出现「节点全挤在一起」或者「大片节点从不更新」的现象,hits矩阵一眼就能看出来。
判断 SOM 训没训好,hits是最直接的指标。把它画成热力图,理想情况下各个节点被激活的次数大致均匀;如果一大片是 0,说明那部分网格从没赢过,要么是权值初始化太偏,要么是sigma0太小。另一个指标是 U 矩阵(相邻节点权值距离),亮的地方就是簇的边界,这个包里不一定有现成函数,自己写十行就够。
4.2 层次聚类:linkage 合并准则与树状图裁剪
层次聚类在这份包里通常不是手写实现,而是直接调linkage加dendrogram,因为凝聚式层次聚类的手写代码要维护距离矩阵的增量更新,教学价值有但工程上没必要重复造。核心就两步:算连接矩阵Z,按簇数裁剪。
% 1) 先归一化,量纲不统一时距离会被大数值特征主导 Xn = zscore(X); % 2) 建树:ward 方法配欧氏距离,簇内方差增量最小 Z = linkage(Xn, 'ward', 'euclidean'); % 3) 按簇数裁剪,K 就是要几个簇 T = cluster(Z, 'maxclust', K); % 4) 画树状图,只显示最后 30 层,样本多时不至于糊成一片 figure; dendrogram(Z, 30);linkage的第二个参数是合并准则,选错了结果差很多。常见的几种:single(最短距离)容易产生「链式效应」,一条长条形的簇会把中间样本全串起来;complete(最长距离)偏向紧凑的球形簇,但对噪声敏感;average是折中;ward最小化合并后的簇内方差增量,对球形簇效果最好,也是我最常用的默认值。区别可以用一句话记住:数据里有没有明显的「细长结构」,有就用single,没有就用ward。
dendrogram(Z, 30)的第二个参数是「只显示最上面多少层」,不是截断簇数。样本上千时不加这个参数,图会变成一片黑。裁剪簇数用cluster(Z, 'maxclust', K),也可以用cluster(Z, 'cutoff', c)按高度切,前者更符合「我要 K 个簇」的直觉,后者适合不确定 K 但知道簇间距离阈值的场景。
层次聚类最大的优势是结果稳定:同一份数据、同样的参数,跑几次结果完全一样,没有随机初始化的玄学。代价是时间复杂度 O(N²) 甚至 O(N³),样本超过几千就跑不动了。所以这份包里它一般只用来处理几百个样本的示例数据,或者作为「K 到底取几」的参考——看树状图在哪个高度上出现明显的大跳跃,那个位置对应的簇数往往比拍脑袋定的靠谱。
4.3 四种算法横向对比:一份脚本看差异
四个 demo 单独跑只是看个热闹,真正能看出差别的是同一份数据、同一个 K、同一套评价指标。包里那个compare_all.m就是干这个的,思路是跑完四个算法,统一算簇内平方和、运行时间、以及和真值标签的一致性。一致性用调整兰德指数(ARI),自己写十几行,不依赖工具箱:
function a = adjRand(idx1, idx2) % 调整兰德指数,取值 [-1,1],1 表示两种划分完全一致 u1 = unique(idx1); u2 = unique(idx2); M = zeros(numel(u1), numel(u2)); for i = 1:numel(u1) for j = 1:numel(u2) M(i, j) = sum(idx1 == u1(i) & idx2 == u2(j)); end end c2 = @(x) x .* (x - 1) / 2; n = numel(idx1); sumM = sum(sum(c2(M))); a1 = sum(c2(sum(M, 1))); a2 = sum(c2(sum(M, 2))); tot = c2(n); expIdx = a1 * a2 / tot; maxIdx = (a1 + a2) / 2; a = (sumM - expIdx) / (maxIdx - expIdx + eps); end调用侧就是一个循环,把结果攒成表格:
K = 3; results = struct(); tic; [idx1, ~, J1] = myKmeans(X, K); results.kmeans = toc; tic; [~, ~, U] = myFCM(X, K); idx2 = max(U, [], 2); results.fcm = toc; tic; [~, hits] = mySOM(X, [4 4], 200, 0.5, 2); [~, idx3] = max(hits(:)); idx3 = ones(size(X,1),1); results.som = toc; tic; Xn = zscore(X); Z = linkage(Xn, 'ward'); idx4 = cluster(Z, 'maxclust', K); results.hier = toc; fprintf('Kmeans SSE=%.2f ARI=%.3f 用时=%.3fs\n', J1(end), adjRand(idx1, label), results.kmeans); fprintf('FCM ARI=%.3f 用时=%.3fs\n', adjRand(idx2, label), results.fcm);表格出来之后,常见的结论是:Kmeans 最快,FCM 慢两三倍但重叠数据的 ARI 更高,层次聚类稳定但样本一多就慢,SOM 的结果对网格尺寸和学习率很敏感、调起来最费劲。这个结论不是让你背,而是让你在自己的数据上跑一遍,看哪条规律成立。我见过在环形分布数据上 Kmeans 完全失效、single连接反而完美的例子,也见过 FCM 的 m 调到 3 之后 ARI 反而下降的情况——这些都得自己试。
5. 避坑与排查:跑不起来、结果不稳定时先看这几处
这份包在别人机器上跑得好好的,到你这里报错或者结果离谱,九成是下面五类问题。每条按「现象 → 原因 → 解决」写,遇到对得上的直接照着改。
5.1 报错找不到函数:pdist2、linkage、kmeans 全红
现象:脚本跑到一半报Undefined function 'pdist2' for input arguments of type 'double',换成linkage也是同样的错,但你自己写的myKmeans能跑。
原因:这些是 Statistics and Machine Learning Toolbox 里的函数,不是 MATLAB 基础库自带的。安装时没勾选工具箱,或者用的是精简安装,这些函数就不存在。也有一种情况是路径问题——addpath没加对,函数其实在,只是搜不到。
解决:先ver看工具箱列表里有没有Statistics and Machine Learning Toolbox,没有就补装。临时应急有两个办法:距离计算换成手写的sqdist,层次聚类换成手写凝聚式或者用squareform加一个简单循环替代。我一般倾向直接改代码,因为教学包跑在别人机器上,你没法保证对方装了工具箱。判断是工具箱缺失还是路径问题的快捷方式是which pdist2,返回空就是没装,返回路径就是能搜到、问题在别处。
5.2 每次跑结果都不一样:簇数对,标签乱跳
现象:K=3 跑两次,两次划分看着差不多,但标签 1 和 2 换了个位置,画出来的图颜色不一样,算出来的 SSE 也差了一截。
原因:两个层面的问题叠在一起。标签跳是聚类算法本身的「标签置换」特性,簇编号没有语义,1 号簇和 2 号簇换个名字结果完全等价,这不是 bug。SSE 差得多才是真问题,根源在随机初始化——初始中心落点不同,收敛到的局部极小不同。
解决:标签置换用 ARI 或者混淆矩阵比对,别直接比idx1 == idx2,那样算出来的准确率毫无意义。初始化问题固定rng(seed)让结果可复现,同时把初始化换成 k-means++,或者加多起点重启取最优。要注意rng要放在初始化之前,放在主循环外面,放进循环里等于没固定。
5.3 FCM 的隶属度全一样或者出 NaN
现象:K=5 的时候隶属度矩阵每一行的五个值都在 0.2 附近,几乎分不出来;或者某些行直接是NaN,后续画图全黑。
原因:前者是模糊指数 m 太大,d2^(-1/(m-1))的区分度被压平,m 趋近无穷时所有隶属度都趋近 1/K。后者是除零,样本和中心恰好重合导致距离为 0,取负幂变成Inf,Inf/Inf就是NaN。
解决:m 从 2 开始试,区间控制在 1.5 到 2.5,超过 3 基本就没有划分意义了。除零问题在距离上加一个eps兜底,同时检查sum(Um, 1)里有没有 0,有的话说明某个簇一个样本都没吸住,要么 K 取大了,要么初始化太偏。我习惯在 FCM 里加一段断言,迭代结束后检查any(~isfinite(U(:))),有就直接报错,比后面画出一张黑图再回头找强。
5.4 SOM 训练完网格全糊在一起
现象:训练 500 轮之后把权值画出来,网格上相邻节点的权值几乎一样,U 矩阵一片灰,看不到簇边界。或者反过来,大片节点从来没被激活过。
原因:前者的典型原因是学习率和邻域半径没有衰减,一直用初始值更新,所有节点被反复拉扯到数据均值附近。后者是初始邻域半径设得太小,网格从第一轮就各自为政,弱节点再也翻不了身。
解决:两个参数都必须随轮数指数衰减,衰减系数在 2 到 5 之间,写成lr0 * exp(-3*ep/epochs)这种形式。初始邻域半径取网格尺寸的一半,比如 10×10 的网格从 5 开始。训练完看hits矩阵,如果超过 20% 的节点是 0 次命中,说明网格开太大了,降到 5×5 或者 6×6 再试。SOM 是这四个算法里最需要手调的,参数没选好结果会非常难看,别指望一套参数通吃。
5.5 层次聚类树状图糊成一片、裁剪结果和预期不符
现象:dendrogram画出来底部密密麻麻全是线,完全看不清结构;或者cluster(Z,'maxclust',K)出来的簇大小严重失衡,一个簇几百个样本,另一个只有两个。
原因:前者是样本多、没加显示层数限制,几百个叶子节点挤在一张图上必然糊。后者多半是数据没归一化,某个特征的量纲远大于其他特征,距离被它主导,或者 linkage 方法选错了——single在含噪数据上很容易产生「一个大簇 + 一堆单点」的结果。
解决:画图加dendrogram(Z, 30)限制显示层数,只看上层结构。数据先zscore归一化,这一步在层次聚类里比在前面三个算法里都重要,因为距离是唯一的合并依据。linkage 从ward开始试,效果不好再换average,single只在明确知道数据有细长结构时用。裁剪之前先看树状图在哪个高度出现大跳跃,那个高度对应的簇数往往比直接指定 K 更合理。
6. 二次开发:把示例代码改成能进项目的工具函数
示例脚本能出图,但离「能拿来用」还差几步。最常见的改造是把四个demo_*.m里的算法部分抽成统一的接口,让调用侧不关心用的是哪个算法。我一般定一个这样的签名:
function [idx, model] = runCluster(X, algo, K, varargin) % algo: 'kmeans' | 'fcm' | 'som' | 'hier' % 统一返回 idx(N×1 簇标号)和 model(含中心/隶属度/树的 struct) X = zscore(X); % 统一归一化,避免各算法行为不一致 switch lower(algo) case 'kmeans' [idx, C, J] = myKmeans(X, K); model = struct('C', C, 'J', J); case 'fcm' [U, C, obj] = myFCM(X, K, 2); [~, idx] = max(U, [], 2); model = struct('U', U, 'C', C, 'obj', obj); case 'som' [W, hits] = mySOM(X, [ceil(sqrt(K)) ceil(sqrt(K))], 200, 0.5, 2); idx = som2label(W, hits, X, K); % 把网格映射成 K 个簇,需要自己实现 model = struct('W', W, 'hits', hits); case 'hier' Z = linkage(X, 'ward', 'euclidean'); idx = cluster(Z, 'maxclust', K); model = struct('Z', Z); end end统一入口之后,横向对比、参数扫描、换算法验证都变成改一个字符串的事。验证方式建议固定成三步:先用带真值标签的模拟数据跑一遍看 ARI,再用一份真实数据看簇大小分布是否合理,最后把rng关掉连跑十次看结果波动。第十次结果和第一次差太多,八成是初始化或者参数没稳住。
还有个容易被忽略的点:zscore对异常值很敏感,一个极端值能把整个特征压扁。真实数据里如果有明显离群点,先做一次简单的 3σ 截断或者用中位数绝对偏差缩放,比直接zscore稳。我以前图省事全程zscore,结果在一个含少量异常样本的数据集上,Kmeans 把异常点单独聚成一簇,K 从 3 调到 4 才勉强正常,排查了大半天才定位到归一化这一步。从那以后我每次加新数据集,都强制先看一眼各特征的分布和离群点比例,再决定用哪种缩放。希望这些能帮你少走点弯路。
本文还有配套的精品资源,点击获取