【RustyML入门】5.3. 聚类指标
2026/8/20 14:46:31 网站建设 项目流程

5.3. 聚类指标

聚类没有可以平方的残差,也没有混淆矩阵,这一点和回归、分类都不一样。聚类从不给自己的分组起名字,它只是把样本切分成若干部分。这一点决定了本页每一个指标的设计:外部指标拿你的划分和一份真值划分作对比,内部指标只看划分本身的几何形状打分,两者都必须无视簇叫什么名字。RustyML 的聚类指标位于rustyml::metrics::clustering,被平铺重新导出到rustyml::metrics,也可以通过 prelude 拿到。写一句use rustyml::metrics::*;,就能把下面所有函数都引入作用域。

metrics这个叶子模块的其余部分一样,这些函数在前置条件被违反时会panic,例如长度不匹配、输入为空、簇数量越界,而不是返回 crate 的Error类型。这是有意为之:metrics只依赖ndarrayahash,所以它照搬了ndarray自己"维度不匹配就 panic"的规则,而不去引入 1.6. 错误处理里那套错误处理机制。如果一次 panic 会让程序崩掉,就在把标签交给指标之前先自行校验。

5.3.1. 两大类指标

外部指标接收两个标签数组,labels_truelabels_pred,都是isize类型,衡量预测划分复现参考划分的程度。本页每个指标都收isize标签,这正是 scikit-learnlabels_使用的类型。正因如此,crate 里任何一个聚类估计器都能喂给本页任何一个指标:KMeans、MeanShift、DBSCAN 全都返回Array1<isize>,永远不需要类型转换。DBSCAN 和 MeanShift 的-1噪声标签仍然需要你留意(见 5.3.7),但已经不需要转换类型了。内部指标接收特征矩阵x加一个标签数组,拿划分和数据自身的几何形状比对打分,完全不需要真值。对无标签数据做聚类时(这是常态)用内部指标;手上有一份参考划分、想拿它给某个算法当基准时用外部指标。

函数类别输入取值范围完美得分机会校正
adjusted_rand_index外部两个isize标签数组[-0.5, 1.0]1.0
adjusted_mutual_info外部两个isize标签数组通常[-1.0, 1.0]1.0
normalized_mutual_info外部两个isize标签数组[0.0, 1.0]1.0
v_measure_score外部两个isize标签数组[0.0, 1.0]1.0
homogeneity_score外部两个isize标签数组[0.0, 1.0]1.0
completeness_score外部两个isize标签数组[0.0, 1.0]1.0
fowlkes_mallows_score外部两个isize标签数组[0.0, 1.0]1.0
silhouette_score内部x、标签、metric[-1.0, 1.0]1.0(越高越好)不适用
davies_bouldin_score内部x、标签>= 0.00.0(越低越好)不适用
calinski_harabasz_score内部x、标签>= 0.0越高越好不适用

外部指标里除 2 个之外都是对称的:交换labels_truelabels_pred,其余指标的得分都不变。这 2 个例外是homogeneity_scorecompleteness_score,它们互为对偶,交换参数会互换两个得分。它们的调和平均v_measure_score又是对称的。

5.3.2. 为什么标签匹配是错误的工具

从分类指标那边带来的直觉,是把两个标签向量逐元素对齐、数一数有多少个对上了。这套直觉在聚类上不成立,因为簇的编号本来就是任意的。设想一次运行把某个分组标成0,另一次运行把同一个分组标成7:两者描述的是同一个划分,可逐元素的"准确率"依然会报告完全不一致。这不是什么罕见的边角情况,每次换个种子重跑 KMeans 都会发生,因为 KMeans 是按初始化顺序给簇编号的。

本页的指标避开了这个问题。它们基于两组标签的列联表(contingency table):有多少样本落进每个(真值簇,预测簇)的格子里。它们也基于由列联表派生出的成对一致性计数。这两样东西在簇被任意重命名后都保持不变。下面的例子把一个划分里两个簇的名字对调,可以看到朴素准确率直接掉到 0,而 ARI 和 NMI 稳稳钉在1.0

usendarray::array;userustyml::metrics::{adjusted_rand_index,normalized_mutual_info};fnmain(){lettruth=array![0isize,0,1,1];// 相同的划分,只是簇名对调:{0,1} -> 标签 1,{2,3} -> 标签 0。letrelabeled=array![1isize,1,0,0];// 分组完全相同,逐元素的"准确率"却掉到了 0。letnaive_accuracy=truth.iter().zip(relabeled.iter()).filter(|&(a,b)|a==b).count()asf64/truth.len()asf64;letari=adjusted_rand_index(&truth,&relabeled);letnmi=normalized_mutual_info(&truth,&relabeled);println!("naive accuracy = {naive_accuracy}");// 0.0println!("ARI = {ari}, NMI = {nmi}");// 1.0, 1.0assert_eq!(naive_accuracy,0.0);assert!((ari-1.0).abs()<1e-12);assert!((nmi-1.0).abs()<1e-12);}

正因为有这种置换不变性,打分之前永远不需要先解一个指派问题,比如用匈牙利算法把预测簇和真值类别配对。指标本身已经处理了这一步。

5.3.3. 外部指标与机会校正

还有一个更隐蔽的陷阱:一个指标可以是置换不变的,却依然会误导你,因为随机标签并不会得 0 分。兰德指数(Rand index)统计的是两个划分在全部C(n, 2)个样本对里达成一致的比例,一致的意思是两个划分都把这对样本分到一起,或者都把它们分开。兰德指数在随机标签下的期望值不是 0,而且随着簇数增多还会升高,所以孤零零一个 0.7 的兰德指数本身说明不了任何问题。调整兰德指数减掉这个期望值再重新缩放,于是相互独立的标签得分约为0.0,完美匹配得1.0,而系统性地比随机还差的一致程度可以为负,低到约-0.5

互信息(mutual information)有同样的问题,而且更严重:MI 会随着数据被切成更多簇而不断上升,等到每个样本自成一簇时便达到完整的熵。因此拿不同 K 候选值下的原始 MI 互相比较毫无意义。adjusted_mutual_info减去了期望互信息(EMI)。RustyML 在一个簇大小相同的随机划分超几何模型下精确计算 EMI,用一张共享的对数阶乘表在对数空间里求出每一个二项式系数。因此 AMI 就是 ARI 在互信息上的对应物:对相互独立的标签约为0.0,对完全相同的标签为1.0,偶尔会略微为负。

normalized_mutual_info做机会校正,它只是把 MI 缩放到[0.0, 1.0]。做法是把 MI 除以两个聚类结果熵的算术平均,(H_true + H_pred) / 2。这个归一化方式在 RustyML 里是固定的约定,不像 scikit-learn 那样提供average_method开关。采用这个归一化因子后,NMI 在数值上与v_measure_score(同质性与完整性的调和平均)完全相等。由此得到一条经验法则:当两个聚类结果的簇数量不同时,用adjusted_rand_indexadjusted_mutual_info;把 NMI 和 V-measure 留给固定 K 的比较,那种情况下未经校正的偏差是个常数,会相互抵消。

这三个函数共用同一套签名,区别只在得分的含义。

pub fn adjusted_rand_index<S>(labels_true: &ArrayBase<S, Ix1>, labels_pred: &ArrayBase<S, Ix1>) -> f64 where S: Data<Elem = isize>; // adjusted_mutual_info 和 normalized_mutual_info 完全相同

这几个函数的退化情形各不相同,而当某个聚类平凡地把所有点都塞进一个簇时,这种差异就很关键。adjusted_rand_index在样本少于 2 个时返回1.0,因为没有样本对可供分歧;它在归一化因子消失时也返回1.0adjusted_mutual_info在自己的归一化因子退化时返回1.0normalized_mutual_info只要任一划分只有一个簇就返回0.0,因为零熵会让分母为零。不要把这些常数当成质量判断,它们只是比值为0/0时所定义的取值。

usendarray::array;userustyml::metrics::{adjusted_mutual_info,adjusted_rand_index,normalized_mutual_info};fnmain(){letlabels_true=array![0isize,0,1,1,2,2];letlabels_pred=array![0isize,0,1,2,1,2];// 一个真值簇被拆成了两个// 对照闭式解验证过:ARI = 1/6 ~= 0.167,AMI = 1/6,NMI ~= 0.579。println!("ARI = {:.4}",adjusted_rand_index(&labels_true,&labels_pred));println!("AMI = {:.4}",adjusted_mutual_info(&labels_true,&labels_pred));println!("NMI = {:.4}",normalized_mutual_info(&labels_true,&labels_pred));// 相互独立的标签:ARI 和 AMI 触到了各自经机会校正后的下限,约为 -0.5。// 这里 NMI 也归零,因为对相互独立的划分,MI 精确为 0。leta=array![0isize,0,1,1];letb=array![0isize,1,0,1];assert!((adjusted_rand_index(&a,&b)-(-0.5)).abs()<1e-9);assert!((adjusted_mutual_info(&a,&b)-(-0.5)).abs()<1e-9);assert!(normalized_mutual_info(&a,&b).abs()<1e-12);}

其余的外部指标是在补充细节,不是替代 ARI 或 AMI。homogeneity_score问的是每个预测簇是否纯净,也就是只包含一个真值类别。completeness_score是它的对偶:问的是每个真值类别是否都待在同一个簇里。v_measure_score是这两者的调和平均。fowlkes_mallows_score是成对精确率与召回率的几何平均。这 4 个指标都落在[0.0, 1.0]区间,而且都基于熵或成对比值,因此都不做机会校正,前面固定 K 的告诫对这 4 个同样适用。

5.3.4. 轮廓系数

聚类通常没有真值可用,轮廓系数(silhouette score)就是应对这种情况的主力工具。对每个样本,它计算a,即该样本到自己簇内其他成员的平均距离;再计算b,即到最近的那个其他簇成员的平均距离;然后把两者组合成下面的公式。

s = (b - a) / max(a, b)

s的取值从-1+1。得分-1表示样本离相邻簇比离自己簇还近,多半是分错了。得分0表示样本正好落在簇与簇的边界上。得分+1表示自己簇紧凑、邻簇遥远,样本聚得很好。silhouette_score返回的是s在所有样本上的均值。有两种边界情况在实践中很关键:某个样本若是自己簇里唯一的成员,它贡献0,因为根本没有a可算;当所有点重合、处处a = b = 0时,得分是0而不是NaN

pub fn silhouette_score<S1, S2>( x: &ArrayBase<S1, Ix2>, labels: &ArrayBase<S2, Ix1>, metric: DistanceCalculationMetric, ) -> f64 where S1: Data<Elem = f64> + Sync, S2: Data<Elem = isize>;

metric参数走的是DistanceCalculationMetric,和各个估计器用的是同一个调度点。EuclideanManhattanMinkowski(p)都能用,而且度量方式实实在在地改变结果,不只是换个标签而已。想要常规的轮廓系数就传DistanceCalculationMetric::Euclidean。这个枚举自带Default值,但本函数不使用它,你必须自己指明度量方式。

silhouette_score会在 4 种情况下 panic:x的行数和labels的长度不一致;输入为空;不同簇的数量落在2..=n_samples - 1之外(只有一个簇就没有b可算,全是单点簇的划分就没有a可算);第四种情况是传了p < 1Minkowski(p)。内部的davies_bouldin_scorecalinski_harabasz_score强制执行同样的长度、非空、簇数量边界,但这两个函数都不接收metric参数,所以Minkowski(p)那条检查对它们不适用。

usendarray::array;userustyml::math::DistanceCalculationMetric;userustyml::metrics::silhouette_score;fnmain(){// 2 个紧凑、彼此分得很开的二维簇(不共线,所以度量方式会起作用)。letx=array![[0.0,0.0],[0.0,1.0],[10.0,10.0],[10.0,11.0]];letlabels=array![0isize,0,1,1];leteuclidean=silhouette_score(&x,&labels,DistanceCalculationMetric::Euclidean);letmanhattan=silhouette_score(&x,&labels,DistanceCalculationMetric::Manhattan);println!("euclidean silhouette = {euclidean:.4}");println!("manhattan silhouette = {manhattan:.4}");assert!(euclidean>0.8&&euclidean<=1.0);assert!(manhattan>0.8&&manhattan<=1.0);// 不同的度量方式在这些点上确实会给出不同的得分。assert!((euclidean-manhattan).abs()>1e-3);}

5.3.5. 计算成本与并行填充

轮廓系数的全面是有代价的。要算出每一个ab,就需要每个样本到所有其他样本的距离。对d维空间中的n个样本来说,这项计算本质上是O(n^2 * d),关于点数是二次的。davies_bouldin_scorecalinski_harabasz_score的代价小得多,因为它们只碰质心:davies_bouldin_score对每个点相对自己质心做一次O(n)扫描,再加一个遍历质心两两组合的O(k^2)循环;calinski_harabasz_score只做一次O(n)扫描,完全没有质心两两配对的循环。在任何有点规模的数据集上,轮廓系数的二次代价都占主导,RustyML 的大部分工程功夫也正花在这里。

有两项技术让轮廓系数的代价保持在可控范围。其一,实现从不构建完整的n x n距离矩阵,而是累积一张紧凑的dist_to_cluster[[i, c]]表,记录样本i到每个簇c的距离总和,内存占用是O(n * k),不是O(n^2)。其二,实现利用了d(i, j) = d(j, i)这一对称性,只扫描距离矩阵的上三角,相比全量扫描把度量计算次数砍掉一半。度量越贵,这一半就越划算:Manhattan最便宜,Euclidean多一次开方,Minkowski(p)多一次powf调用,代价最高。

超过某个工作量阈值后,上三角的填充就会并行执行。这道闸门以扫描元素数来衡量,scan_work = n * n * d。一旦scan_work达到SILHOUETTE_PARALLEL_MIN_ELEMS(默认262_144),各行就会以轮转(round-robin)方式分发到rayoncurrent_num_threads()个桶里。第i行要做n - 1 - i次样本对计算,所以轮转分配比连续切分更能让各个桶负载均衡。每个桶折叠进自己的累加器,随后各桶按固定顺序求和。这种固定的分组让并行结果在同一台机器上多次运行都可复现。并行结果在数值上等于串行填充的结果,但未必逐比特相同。低于这道闸门时走串行路径,结果与全量扫描逐比特一致。这项填充有专门的基准测试。

cargobench--benchsilhouette

如果这个默认的切换点不适合你的硬件或数据形状,可以在运行时调整它。使用调优门面,rustyml::tuning::metrics::set_silhouette(value)get_silhouette()。7.3. 性能调优与并行讲了这套机制,以及关于并行闸门的通用原理。

5.3.6. 用轮廓系数扫描来选 K

KMeans 需要在运行之前先定好簇的数量 K。轮廓系数是挑选 K 最广为人知的工具。对每个候选 K 拟合一遍模型,给得到的划分打分,留下平均轮廓系数最高的那个 K。KMeans 把标签以Array1<isize>返回,无需任何转换就能直接喂给silhouette_score

usendarray::array;userustyml::machine_learning::KMeans;userustyml::math::DistanceCalculationMetric;userustyml::metrics::silhouette_score;fnmain(){// 3 个紧凑、彼此分得很开的团簇,每个 4 个点。letx=array![[0.0,0.0],[0.2,0.1],[0.1,0.2],[0.0,0.3],[5.0,5.0],[5.2,5.1],[5.1,5.2],[5.0,5.3],[0.0,5.0],[0.2,5.1],[0.1,5.2],[0.0,5.3],];letmutbest_k=0usize;letmutbest_score=f64::NEG_INFINITY;forkin2..=5{letlabels=KMeans::new(k,100,1e-4).unwrap().with_random_state(42)// 固定种子,扫描可复现.fit_predict(&x).unwrap();// silhouette_score 需要 2..=n-1 个不同的簇,跳过那种把某个簇塌缩掉的拟合。letmutdistinct=labels.to_vec();distinct.sort_unstable();distinct.dedup();ifdistinct.len()<2{continue;}lets=silhouette_score(&x,&labels,DistanceCalculationMetric::Euclidean);println!("k = {k}: silhouette = {s:.4}");ifs>best_score{best_score=s;best_k=k;}}println!("best k = {best_k} (silhouette = {best_score:.4})");assert_eq!(best_k,3);// 3 个真实的团簇胜出}

固定的with_random_state(42)调用让这次扫描可复现,换个种子可能改变 KMeans 的初始化,在临界处甚至会改变胜出的 K。参见 7.1. 可复现性与随机种子。distinct.len() < 2这道守卫有其具体理由:KMeans 可能返回一个空簇,那会让不同簇的数量掉到轮廓系数的下界以下,从而引发 panic。跳过这样的拟合,比去捕获 panic 更省事也更清晰。要在大n上跑得更快,就改用davies_bouldin_score(越低越好)或calinski_harabasz_score(越高越好)。davies_bouldin_score的代价是一次O(n)扫描加一个遍历质心两两组合的O(k^2)循环,calinski_harabasz_score的代价只是一次O(n)扫描。两者都避开了轮廓系数的二次代价,代价是对簇形状只有一个更粗糙、仅看质心的视角。

5.3.7. 处理 DBSCAN 的噪声哨兵值

来自 DBSCAN,或者cluster_all = false的 MeanShift 的标签,不需要任何转换就能通过本页每个指标的类型检查,因为估计器和指标两边都用isize。这消掉了机械上的摩擦,却没有消掉语义上的问题:这些函数没有任何一个懂得噪声哨兵值的概念。每一个不同的标签值都被当成一个完整的簇,于是-1就成了一个人造的噪声簇。对轮廓系数而言,这意味着零散的噪声点会被当作一个真实分组来打分,而这几乎从不是你想要的结果。(scikit-learn 的轮廓系数行为完全一样。)

干净的做法是在打分之前把噪声行丢掉。把x和标签都取子集,只留下 DBSCAN 真正聚成簇的那些点。

usendarray::{array,Array1,Axis};userustyml::machine_learning::DBSCAN;userustyml::math::DistanceCalculationMetric;userustyml::metrics::silhouette_score;fnmain(){// 2 个稠密的团簇,外加一个远远甩出去的离群点。letx=array![[0.0,0.0],[0.1,0.0],[0.0,0.1],[0.1,0.1],[5.0,5.0],[5.1,5.0],[5.0,5.1],[5.1,5.1],[50.0,50.0],// 噪声];// eps = 1.0,min_samples = 3:每个团簇是一个核心簇,离群点是噪声(-1)。letlabels=DBSCAN::new(1.0,3).unwrap().fit_predict(&x).unwrap();// 只保留成簇的行(标签 >= 0)。不需要转换:标签本来就是 isize。letkeep:Vec<usize>=labels.iter().enumerate().filter(|&(_,&l)|l>=0).map(|(i,_)|i).collect();letx_clustered=x.select(Axis(0),keep.as_slice());letlabels_clustered=Array1::from_iter(keep.iter().map(|&i|labels[i]));lets=silhouette_score(&x_clustered,&labels_clustered,DistanceCalculationMetric::Euclidean,);println!("silhouette over non-noise points = {s:.4}");// ~1.0,2 个干净的团簇assert!(s>0.9);}

过滤回答的是通常真正要问的问题:那些确实聚成了簇的点,彼此分得有多开。但它也掩盖了到底丢弃了多少数据,所以要把噪声占比和得分一起报告出来。另一种做法,是把噪声点保留在它们自己的-1标签下,这只对外部指标站得住脚:ARI、AMI、NMI 会把这个噪声类别当成任意其他簇一样和你的真值比对,这是一种自洽的评估,虽然严格。轮廓系数不支持这种做法,因为一团弥散的噪声根本不是簇,把它当簇来打分只会把结果搅浑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询