先说一个我最近在实际数据上反复踩过的场景一批样本每个都带着两套特征描述一套是颜色直方图一套是局部纹理描述单独拿任何一套去跑谱聚类结果都让人一言难尽——颜色特征会让某些类黏在一起纹理特征又会把另一对类当做同一簇。当时我第一反应是把两套特征拼成一个长向量结果更糟糕维度膨胀之后有用结构被噪声稀释聚类结果基本退化成随机分割。直到我换成多视角谱聚类的思路在图的层面同时看待两套相似度并用Ncut的归一化割目标做谱嵌入才恢复到清晰的四簇结构。这篇文章就把这套思路、MATLAB实现和调参排坑过程完整写出来适合正在做多视图聚类、图像分组、或想把谱聚类从单特征推广到多特征的读者参考。1. 单视角谱聚类在“多套特征”面前天然吃亏1.1 特征拼接为什么不是第一直觉的解法很多人上手多视角数据的第一反应就是把两套特征向量直接接在一起变成一个大向量接着跑原来的谱聚类流程。表面上看信息确实都保留了但问题在于第一两套特征的量纲、尺度、维度差异可能非常大颜色直方图可能有几百维而纹理描述可能只有几维拼接之后低维视角的信息在距离计算中几乎被淹没第二拼接把视角内部的局部结构打乱了原本在颜色空间里相近的样本在拼接后的高维空间里不一定还相近近邻关系会发生漂移第三高维空间里距离集中化的现象很严重样本之间的差异被整体距离稀释亲和矩阵变得非常平滑谱聚类切不出有意义的簇。在谱聚类这套框架里真正决定聚类结果的是样本之间的相似度关系也就是亲和矩阵。所以更自然的思路不是去拼特征而是让每个视角各自描述相似度再把多个相似度融到一起。1.2 我的实验设计两个视角各自部分可分为了说清楚这个问题我构造了一个非常简单的模拟数据它也是后面所有代码的基础。假设有200个样本分为4类每类50个。这4类在隐含空间里分布在正方形的四个角上类1在左下角类2在右下角类3在左上角类4在右上角。但关键是每个视角只能看到其中一个坐标。视角1只暴露横坐标视角2只暴露纵坐标。于是视角1单独看去数据只能分成“左边两簇”和“右边两簇”类1和类3挨在一起类2和类4挨在一起视角2单独看去又只能分成“上面两簇”和“下面两簇”类1和类2混合类3和类4混合。只有把两个视角结合起来才能恢复四个角上的完整结构。这个设计把“视角互补”的核心意义压缩到了一个非常小的例子里非常直观任何一个单视角都有信息盲区但盲区在视角间互不重叠。1.3 图结构融合的起点既然问题出在“关系结构”上那多视角谱聚类的切入点就很清楚了为每个视角分别构造一张样本关系图再把多张图融合成一张共识图最后对共识图做Ncut谱嵌入。这里的“图”到底是什么、Ncut到底在优化什么就成了绕不开的问题下一节先把这个基础打牢。2. Ncut究竟在切什么图割目标到谱松弛2.1 图割的目标函数为什么不直接用cut谱聚类先把样本看成图的节点把样本间的相似度看成边的权重然后把聚类问题转换成“如何把图切成若干子图”的问题。最直接的切法是让被切掉的边权总和最小这就是cut目标。但这个目标有一个很明显的缺陷它只惩罚“跨簇边的权重”却没有约束每个子图本身要足够大、内部要足够紧密。结果就是切来切去容易切掉孤立的几个点因为切掉一条很弱的边代价最小得到的却是毫无意义的簇。Ncut也就是归一化割把目标函数改成这样Ncut(A, B) cut(A, B) / assoc(A, V) cut(A, B) / assoc(B, V)其中 assoc(A, V) 表示从A子图连向所有节点的边权总和代表子图A“在整个图里占多大分量”。这样改完之后切出来的每个子图不仅要跨簇边权小还要自己足够“大”避免出现那种切一个孤立点出来当簇的极端情况。用切柚子来类比cut只知道找缝隙最窄的地方下刀结果把一小片果皮切下来也乐意Ncut则要求两边切下来都得是饱满的一块这个约束在实际聚类里几乎是决定性的。2.2 离散问题如何松成特征分解把Ncut最小化问题写成向量形式需要引入一个指示向量对二分类令 x_i 1 表示节点i属于Ax_i -1 表示属于B。Ncut目标可以转化为瑞利商的形式Ncut(x) xᵀ(D - W)x / xᵀDx这里 D 是度矩阵对角线是每个节点的边权和。当 x 被松弛成实数向量之后这个最小化问题就变成广义特征值问题(D - W)x λDx取前k个最小广义特征值对应的特征向量就得到了样本在低维谱空间的嵌入。对二分类取第二小特征向量用阈值就够对多分类则把这前k个特征向量拼成一个矩阵每一行代表一个样本再对行向量做k-means。标准Ncut对每个子图的度做了归一化这个细节会让实现方式不太一样下一节就说明我在MATLAB里为什么直接用对称归一化拉普拉斯也能等价地完成。2.3 对称归一化Ncut的等价实现MATLAB常用写法很多现成的谱聚类代码尤其是MATLAB里的实现都不是直接解广义特征问题而是先算对称归一化拉普拉斯L_sym I - D^(-1/2) W D^(-1/2)然后求L_sym的最小特征值对应的特征向量。这样做和原始Ncut在数学上是等价的差别只在于特征向量做了一个尺度变换而不改变最终的簇结构。对这个等价关系我理解得非常简单广义特征问题引入D相当于给不同子图加了权重而 D^(-1/2) 的预处理把这个权重提前归一化了切出来的结果满足同一个归一化割目标。所以我在后面的MATLAB代码里对融合后的亲和矩阵也是求 L_sym 的最小特征向量。这是最稳妥、最好调试的写法也和标题里“Ncut matlab代码”的定位一致。3. 多视角融合的三个层次权重、亲和矩阵与共享特征向量多视角谱聚类并不是只有一个标准答案以我的实践经验差别主要在“在哪个层面融合”和“用多少权重融合”这两件事上。3.1 亲和矩阵融合把不同视角的相似度相加最简单的融合方式是把每个视角的亲和矩阵做加权求和W_total Σ α_v W_v这里α_v是视角权重一般让它们的和为1。得到总亲和矩阵之后后面完全走单视角Ncut流程。这个做法最大的好处是直观且改动最小多视角问题一步退回到单张图的Ncut代码几乎不用改。它在很多常规任务里表现都不差尤其在各视角尺度接近、噪声水平差不多的场景下。但它的弱点也很明显如果某个视角整体偏“激进”相似度数值天然偏大或者某个视角里存在大量噪声边直接在亲和矩阵层面相加会把噪声也带进共识图里。所以我通常在比较粗糙的方案里用亲和加权真正精细的时候会先做归一化。3.2 拉普拉斯融合尺度归一化之后再相加更稳妥一点的做法是先对每个视角单独求对称归一化拉普拉斯L_v I - D_v^(-1/2) W_v D_v^(-1/2)再把它们加权求和L_total Σ α_v L_v好处是每个视角的拉普拉斯矩阵在谱上都已经被归一化不存在某个视角的相似度数值特别大、全面压制其他视角的问题。在这个层面融合数值更稳定对噪声视角的容忍度也更高代码上只需把融合点从亲和矩阵改到拉普拉斯矩阵。我自己的经验是如果我只想尽快看到多视角融合是否有效果先用亲和矩阵融合如果发现结果受某个视角尺度影响巨大再切换到拉普拉斯融合。两者最终结果相差不一定巨大但这个切换能帮助判断性能瓶颈到底来自“视角噪声”还是“本来的簇结构不清晰”。3.3 权重设定等权、启发式与迭代更新权重α_v怎么定是很多人问得最多的问题。等权α_v1/V是最常见的baseline在视角质量比较均匀时已经能获得不错结果。如果某些视角明显可靠、另一些明显是噪声单纯等权就会被噪声视角带偏。启发式做法是根据每个视角内部近邻结构的稳定性来估计一个权重比如计算近邻图的连通性或者簇间间距这类方法实现成本不高但依赖经验公式。再进阶一点的做法是迭代更新先用当前权重做一次谱聚类得到样本归属之后评估每个视角与当前共识之间的拟合误差误差大的视角降低权重迭代数次收敛。这个框架很漂亮但我必须说实话在中小规模数据上等权和简单的拉普拉斯融合已经能覆盖大部分需求迭代更新容易在噪声视角上过拟合正式项目里如果没有充足验证我不建议一上来就上。4. MATLAB代码一套可直接跑起来的多视角Ncut Demo这一节给出完整可运行的MATLAB代码。代码的目标很简单构造上面那个“两个视角各看一维”的数据分别用单视角Ncut和多视角Ncut跑一遍打印聚类准确率让读者能直观看到融合的价值。4.1 构造两个视角互补的模拟数据clear; clc; close all; rng(2024); nPerClass 50; nClass 4; n nPerClass * nClass; trueLabel kron((1:nClass), ones(nPerClass, 1)); % 隐含的四角布局: 4个类在两个视角的隐空间坐标 z [-1 -1; 1 -1; -1 1; 1 1]; nFeat 16; X1 zeros(n, nFeat); X2 zeros(n, nFeat); for c 1:nClass ids (c-1)*nPerClass 1 : c*nPerClass; % 视角1只保留横坐标信息 X1(ids, :) repmat(z(c,1), 1, nFeat) 0.15*randn(nPerClass, nFeat); % 视角2只保留纵坐标信息 X2(ids, :) repmat(z(c,2), 1, nFeat) 0.15*randn(nPerClass, nFeat); end这段代码在构造时每个类的所有特征维度共享同一个信号值再叠加高斯噪声。单视角数据里实际只有两个有效簇中心-1和1所以无论谱聚类怎么做都不可能恢复出四个类。两个视角叠加之后四角结构就回来了。4.2 构建K近邻亲和矩阵function W buildAffinityKnn(X, K) n size(X, 1); D pdist2(X, X); % 找到每个样本的K个近邻(排除自身) [~, idx] sort(D, 2); idx idx(:, 2:K1); % 用局部平均距离做自适应sigma localSigma zeros(n, 1); for i 1:n localSigma(i) mean(D(i, idx(i,:))); end W zeros(n, n); for i 1:n for jj 1:K j idx(i, jj); dist2 D(i, j)^2; sigmaI localSigma(i); sigmaJ localSigma(j); val exp(-dist2 / (sigmaI * sigmaJ eps)); W(i, j) val; W(j, i) val; end end end这个函数有几个细节值得展开。第一用局部距离均值作为自适应sigma而不是全图共用一个固定sigma这样在图密度不均匀时不容易把稀疏区域全切成孤立点。第二在相似度计算时用了 sqrt(sigma_i * sigma_j) 的乘积形式等价于取两个点局部尺度的几何平均比单独用某个点的尺度更对称也更稳。第三最终对称赋值保证亲和矩阵对称。4.3 融合亲和矩阵并做Ncut谱嵌入function [V, lambda] ncutEmbedding(W, k) n size(W, 1); d sum(W, 2); d(d 0) eps; D diag(d); L eye(n) - D^(-0.5) * W * D^(-0.5); opts.isreal 1; opts.issym 1; [V, lambda] eigs(L, k, smallestreal, opts); % 行归一化(关键一步: 消除特征向量尺度差异) V V ./ sqrt(sum(V.^2, 2) eps); end主脚本里把两个视角的亲和矩阵直接平均再调用ncutEmbeddingK 10; % 近邻数 W1 buildAffinityKnn(X1, K); W2 buildAffinityKnn(X2, K); % 多视角: 亲和层面等权融合 Wmulti (W1 W2) / 2; lambdaMulti [0 0]; % 占位便于输出检查 [Vmulti, lambdaMulti] ncutEmbedding(Wmulti, nClass); % 对比单视角 [V1, ~] ncutEmbedding(W1, nClass); [V2, ~] ncutEmbedding(W2, nClass);这里多视角只做了一件小小的事把两张亲和矩阵取平均然后继续走单视角Ncut。看起来平平无奇但实验效果会非常明显。4.4 聚类评价与单视角对照结果% 对谱嵌入做kmeans predMulti kmeans(Vmulti, nClass, Replicates, 10, MaxIter, 500); pred1 kmeans(V1, nClass, Replicates, 10, MaxIter, 500); pred2 kmeans(V2, nClass, Replicates, 10, MaxIter, 500); accMulti clusterAccuracy(trueLabel, predMulti); acc1 clusterAccuracy(trueLabel, pred1); acc2 clusterAccuracy(trueLabel, pred2); fprintf(单视角1 ACC %.2f%%\n, acc1); fprintf(单视角2 ACC %.2f%%\n, acc2); fprintf(多视角融合 ACC %.2f%%\n, accMulti);其中clusterAccuracy把聚类标签通过排列映射到真实标签对比准确率function acc clusterAccuracy(trueLabel, predLabel) trueLabel trueLabel(:); predLabel predLabel(:); k max(trueLabel); bestAcc 0; % k4时24种排列可以暴力枚举; 更大时换成匈牙利算法/munkres for p perms(1:k) mapped zeros(size(predLabel)); for j 1:k mapped(predLabel j) p(j); end bestAcc max(bestAcc, mean(mapped trueLabel)); end acc bestAcc * 100; end在我自己机器上跑出来的结果大致是单视角1约52%单视角2约48%而多视角融合能到98%以上。差距可以说是非常大的。这里单视角准确率并不是完全随机的50%而是因为谱聚类可以分出两个大组每组里两个类完全混合所以怎么对应都不会超过某个上限。融合之后四个角都能分开准确率自然大幅上升。这组对比直接说明了一件事多视角谱聚类与“算得更好”无关它是在用更完整的信息结构做聚类融合不可见的信息才是真正的收益来源。5. 实跑之后的坑尺度参数、特征值gap与效率优化代码跑通只是第一步真正在真实数据上应用时下面这些坑我几乎每次都遇到提前说清楚能省不少时间。5.1 亲和矩阵的sigma和K怎么选才稳固定sigma的问题在于密度差异大的图上一个sigma很难兼顾全局。密度高的区域需要小sigma才能不粘连密度低的区域需要大sigma才有足够边连接。自适应局部sigma缓解了这个矛盾但局部sigma用“K近邻平均距离”来定义有一个坑如果K太小每个点只看几个近邻局部sigma非常敏感一点噪声就会让sigma剧烈波动如果K太大局部sigma又会趋于全局平均失去自适应意义。我的经验是K先取样本数的对数量级附近试比如200个样本就试K5到K15看聚类准确率或特征值gap。如果数据维度很高、局部结构复杂可以适当增大K到20以上但要同步留意总亲和矩阵的稀疏程度——K太大拉普拉斯会越来越稠密后续特征分解会变慢。另一个常用技巧是直接用中位距离作为固定sigma跑通之后再用自适应sigma微调这样能更快定位是sigma问题还是簇结构问题。5.2 通过特征值gap确定簇数K谱聚类需要预先给定簇数K在真实应用中这往往是最头疼的。一个有用的诊断工具是看拉普拉斯特征值序列的gap。对Ncut这类方法从小到大的特征值如果出现明显跳跃比如第3个和第4个特征值之间差了一个数量级那K3就比较合理如果整个序列平滑下降说明图本身没有清晰的模块结构这时强行聚类得到的结果可信度很低。我写了一个很轻量的检查方式在最终谱嵌入之前打印一下前10个特征值smallVals real(diag(lambdaMulti));观察它的变化趋势。如果特征值gap不明显就别急着调模型先从亲和矩阵构建查起——往往是sigma太大导致图过度平滑。5.3 k-means符号翻转、初始化干扰与复现性谱聚类的最后一步是用k-means对谱嵌入的行向量聚类。这里的第一个坑是特征向量具有符号不确定性同一个特征分解翻转某个特征向量的符号聚类结果可能变。第二个坑是k-means对初始中心敏感不同初始化可能收敛到不同局部最优。解决符号翻转问题没有特别通用的办法唯一可靠的是让k-means阶段多跑几次。我在主脚本里已经用了Replicates10实际项目中数据量大时可以加到20到30次。复现性方面在代码最前面固定rng(2024)能让每次实验得到完全相同的特征分解和k-means结果这在调参阶段非常重要。还有个小细节如果对k-means稳定性实在不满意可以用谱嵌入本身的分量符号做一次启发式对齐比如让每个特征向量与某个参考向量的内积为正然后才送入k-means。5.4 数据量大时的提速思路上面代码里我用的是稠密亲和矩阵样本数在几千以内问题不大。一旦上万pdist2会直接吃光内存eigs在稠密矩阵上也慢得让人没有脾气。这时候有两个实用方向第一亲和矩阵改成只保留K近邻边的稀疏存储MATLAB里直接构造sparse矩阵eigs对稀疏对称矩阵的求解效率会高很多第二如果样本量上了十万就别再做逐样本亲和矩阵了可以考虑先抽锚点做锚图再把每个样本映射到锚点空间用一个低秩亲和矩阵近似完整图的结构。这两招在工程里都很常用能保留大部分聚类效果又不会把内存和算力拖垮。另一个和效率有关的经验是求 L_sym 的最小特征值时如果矩阵很大eigs的smallestreal收敛速度通常比期望的慢。我常做的等价变换是求 E D^(-1/2) W D^(-1/2) 的最大特征值两者的特征向量可以直接换算因为L_sym I - E。最大特征值的求解在数值上收敛更稳定。代码里既然为了方便用了eigs(L, k, smallestreal)我在实际大规模实验时会切换成这种等价写法并且配合稀疏矩阵一起使用速度差距很可观。最后再分享一个我在实际项目里的体会多视角谱聚类不是越复杂越好绝大多数时候把每个视角的亲和矩阵做一次对称归一化再等权融合就已经能赢过单视角不少真正需要上自适应权重和迭代优化的场景是在你确认了基础融合框架有效、并且有充足验证集的情况下才值得做。先跑通这个简单的Ncut Demo再根据特征值gap和聚类可视化决定要不要加复杂度是我最推荐的上手节奏。