【RustyML入门】5.3. 聚类指标 📅 发布时间:2026/8/20 14:46:36 👁 浏览次数: 5.3. 聚类指标聚类没有可以平方的残差也没有混淆矩阵这一点和回归、分类都不一样。聚类从不给自己的分组起名字它只是把样本切分成若干部分。这一点决定了本页每一个指标的设计外部指标拿你的划分和一份真值划分作对比内部指标只看划分本身的几何形状打分两者都必须无视簇叫什么名字。RustyML 的聚类指标位于rustyml::metrics::clustering被平铺重新导出到rustyml::metrics也可以通过 prelude 拿到。写一句use rustyml::metrics::*;就能把下面所有函数都引入作用域。和metrics这个叶子模块的其余部分一样这些函数在前置条件被违反时会panic例如长度不匹配、输入为空、簇数量越界而不是返回 crate 的Error类型。这是有意为之metrics只依赖ndarray和ahash所以它照搬了ndarray自己维度不匹配就 panic的规则而不去引入 1.6. 错误处理里那套错误处理机制。如果一次 panic 会让程序崩掉就在把标签交给指标之前先自行校验。5.3.1. 两大类指标外部指标接收两个标签数组labels_true和labels_pred都是isize类型衡量预测划分复现参考划分的程度。本页每个指标都收isize标签这正是 scikit-learnlabels_使用的类型。正因如此crate 里任何一个聚类估计器都能喂给本页任何一个指标KMeans、MeanShift、DBSCAN 全都返回Array1isize永远不需要类型转换。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_true和labels_pred其余指标的得分都不变。这 2 个例外是homogeneity_score和completeness_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(){lettrutharray![0isize,0,1,1];// 相同的划分只是簇名对调{0,1} - 标签 1{2,3} - 标签 0。letrelabeledarray![1isize,1,0,0];// 分组完全相同逐元素的准确率却掉到了 0。letnaive_accuracytruth.iter().zip(relabeled.iter()).filter(|(a,b)|ab).count()asf64/truth.len()asf64;letariadjusted_rand_index(truth,relabeled);letnminormalized_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_index或adjusted_mutual_info把 NMI 和 V-measure 留给固定 K 的比较那种情况下未经校正的偏差是个常数会相互抵消。这三个函数共用同一套签名区别只在得分的含义。pub fn adjusted_rand_indexS(labels_true: ArrayBaseS, Ix1, labels_pred: ArrayBaseS, Ix1) - f64 where S: DataElem isize; // adjusted_mutual_info 和 normalized_mutual_info 完全相同这几个函数的退化情形各不相同而当某个聚类平凡地把所有点都塞进一个簇时这种差异就很关键。adjusted_rand_index在样本少于 2 个时返回1.0因为没有样本对可供分歧它在归一化因子消失时也返回1.0。adjusted_mutual_info在自己的归一化因子退化时返回1.0。normalized_mutual_info只要任一划分只有一个簇就返回0.0因为零熵会让分母为零。不要把这些常数当成质量判断它们只是比值为0/0时所定义的取值。usendarray::array;userustyml::metrics::{adjusted_mutual_info,adjusted_rand_index,normalized_mutual_info};fnmain(){letlabels_truearray![0isize,0,1,1,2,2];letlabels_predarray![0isize,0,1,2,1,2];// 一个真值簇被拆成了两个// 对照闭式解验证过ARI 1/6 ~ 0.167AMI 1/6NMI ~ 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。letaarray![0isize,0,1,1];letbarray![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_scoreS1, S2( x: ArrayBaseS1, Ix2, labels: ArrayBaseS2, Ix1, metric: DistanceCalculationMetric, ) - f64 where S1: DataElem f64 Sync, S2: DataElem isize;metric参数走的是DistanceCalculationMetric和各个估计器用的是同一个调度点。Euclidean、Manhattan、Minkowski(p)都能用而且度量方式实实在在地改变结果不只是换个标签而已。想要常规的轮廓系数就传DistanceCalculationMetric::Euclidean。这个枚举自带Default值但本函数不使用它你必须自己指明度量方式。silhouette_score会在 4 种情况下 panicx的行数和labels的长度不一致输入为空不同簇的数量落在2..n_samples - 1之外只有一个簇就没有b可算全是单点簇的划分就没有a可算第四种情况是传了p 1的Minkowski(p)。内部的davies_bouldin_score和calinski_harabasz_score强制执行同样的长度、非空、簇数量边界但这两个函数都不接收metric参数所以Minkowski(p)那条检查对它们不适用。usendarray::array;userustyml::math::DistanceCalculationMetric;userustyml::metrics::silhouette_score;fnmain(){// 2 个紧凑、彼此分得很开的二维簇不共线所以度量方式会起作用。letxarray![[0.0,0.0],[0.0,1.0],[10.0,10.0],[10.0,11.0]];letlabelsarray![0isize,0,1,1];leteuclideansilhouette_score(x,labels,DistanceCalculationMetric::Euclidean);letmanhattansilhouette_score(x,labels,DistanceCalculationMetric::Manhattan);println!(euclidean silhouette {euclidean:.4});println!(manhattan silhouette {manhattan:.4});assert!(euclidean0.8euclidean1.0);assert!(manhattan0.8manhattan1.0);// 不同的度量方式在这些点上确实会给出不同的得分。assert!((euclidean-manhattan).abs()1e-3);}5.3.5. 计算成本与并行填充轮廓系数的全面是有代价的。要算出每一个a和b就需要每个样本到所有其他样本的距离。对d维空间中的n个样本来说这项计算本质上是O(n^2 * d)关于点数是二次的。davies_bouldin_score和calinski_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方式分发到rayon的current_num_threads()个桶里。第i行要做n - 1 - i次样本对计算所以轮转分配比连续切分更能让各个桶负载均衡。每个桶折叠进自己的累加器随后各桶按固定顺序求和。这种固定的分组让并行结果在同一台机器上多次运行都可复现。并行结果在数值上等于串行填充的结果但未必逐比特相同。低于这道闸门时走串行路径结果与全量扫描逐比特一致。这项填充有专门的基准测试。cargobench--benchsilhouette如果这个默认的切换点不适合你的硬件或数据形状可以在运行时调整它。使用调优门面rustyml::tuning::metrics::set_silhouette(value)和get_silhouette()。7.3. 性能调优与并行讲了这套机制以及关于并行闸门的通用原理。5.3.6. 用轮廓系数扫描来选 KKMeans 需要在运行之前先定好簇的数量 K。轮廓系数是挑选 K 最广为人知的工具。对每个候选 K 拟合一遍模型给得到的划分打分留下平均轮廓系数最高的那个 K。KMeans 把标签以Array1isize返回无需任何转换就能直接喂给silhouette_score。usendarray::array;userustyml::machine_learning::KMeans;userustyml::math::DistanceCalculationMetric;userustyml::metrics::silhouette_score;fnmain(){// 3 个紧凑、彼此分得很开的团簇每个 4 个点。letxarray![[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_k0usize;letmutbest_scoref64::NEG_INFINITY;forkin2..5{letlabelsKMeans::new(k,100,1e-4).unwrap().with_random_state(42)// 固定种子扫描可复现.fit_predict(x).unwrap();// silhouette_score 需要 2..n-1 个不同的簇跳过那种把某个簇塌缩掉的拟合。letmutdistinctlabels.to_vec();distinct.sort_unstable();distinct.dedup();ifdistinct.len()2{continue;}letssilhouette_score(x,labels,DistanceCalculationMetric::Euclidean);println!(k {k}: silhouette {s:.4});ifsbest_score{best_scores;best_kk;}}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 个稠密的团簇外加一个远远甩出去的离群点。letxarray![[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.0min_samples 3每个团簇是一个核心簇离群点是噪声-1。letlabelsDBSCAN::new(1.0,3).unwrap().fit_predict(x).unwrap();// 只保留成簇的行标签 0。不需要转换标签本来就是 isize。letkeep:Vecusizelabels.iter().enumerate().filter(|(_,l)|l0).map(|(i,_)|i).collect();letx_clusteredx.select(Axis(0),keep.as_slice());letlabels_clusteredArray1::from_iter(keep.iter().map(|i|labels[i]));letssilhouette_score(x_clustered,labels_clustered,DistanceCalculationMetric::Euclidean,);println!(silhouette over non-noise points {s:.4});// ~1.02 个干净的团簇assert!(s0.9);}过滤回答的是通常真正要问的问题那些确实聚成了簇的点彼此分得有多开。但它也掩盖了到底丢弃了多少数据所以要把噪声占比和得分一起报告出来。另一种做法是把噪声点保留在它们自己的-1标签下这只对外部指标站得住脚ARI、AMI、NMI 会把这个噪声类别当成任意其他簇一样和你的真值比对这是一种自洽的评估虽然严格。轮廓系数不支持这种做法因为一团弥散的噪声根本不是簇把它当簇来打分只会把结果搅浑。