尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

【RustyML入门】2.7. KMeans聚类

【RustyML入门】2.7. KMeans聚类 2.7. KMeans聚类KMeans把样本划分到固定数量的簇里。它交替执行两步先把每个点分配给最近的质心再把每个质心重新计算为其成员的均值。RustyML 的实现是一套并行的 Lloyd 算法配上 k-means 初始化和「重启若干次、取最优」的策略。如果你熟悉 scikit-learn 的sklearn.cluster.KMeans那套心智模型的大部分都能直接搬过来用。唯一一处刻意不同的默认值是n_init原因见 2.7.1。2.7.1. 估计器的工作原理一次fit调用会把整套流程跑n_init遍保留 inertia 最低的那一遍。每一遍里质心都从k-means初始化开始。第一个中心是从样本中均匀随机抽取的一个点。之后每个中心都从剩余的点里抽取抽中的概率正比于该点到最近的已选中心的平方距离经典的 D^2 轮盘赌。这样能把初始中心尽量拉开给 Lloyd 迭代一个比均匀随机初始化更好的起点。如果所有候选点的平方距离都为零实现会转而对这个中心做均匀随机挑选。这种情况只有在所有点都与已选中心重复时才会发生。初始化之后每轮迭代做两件事分配步把每个点归到最近的质心更新步把每个质心替换成分配给它的那些点的均值。当质心不再移动时迭代停止。收敛判据比较的是所有质心的平方位移之和与一个按方差缩放的容差。这个阈值等于数据每维总体方差的均值再乘以tol。所以tol是相对容差而不是绝对距离。这与 scikit-learn 的约定一致。收敛解是一个不动点每个质心都恰好等于分配给它的点的均值。一次因为用光预算、而不是因为收敛而结束的拟合在返回之前会多跑一趟分配。Lloyd 循环先按当前质心给点打标签然后才装入更新后的质心。所以停在max_iterations上会让labels和inertia描述的是旧质心而get_centroids报告的却是新质心。这样一来predict(x)就会与get_labels()对不上。最后这一趟按模型真正存下来的质心重新分配。scikit-learn 出于同样的原因重跑它最后那一步 E-step。与 scikit-learn 唯一一处刻意的差别是重启次数。这里n_init默认是 10。scikit-learn 在 k-means 下的n_initauto是 1。这不是疏漏。scikit-learn 的默认值建立在它的贪心k-means 之上每个中心会抽2 ln(k)个候选并留下最好的那个。所以那里单次初始化本身方差就已经很低。RustyML 用的是朴素 k-means每个中心只做一次 D^2 抽取。重启机制存在的意义正是为了弥补这种更高方差的初始化。想要严格对齐 scikit-learn就传with_n_init(1)。这样做之后预期拟合结果会随之改变。各次重启的种子由random_state确定性地派生所以带种子的拟合仍然可复现。2.7.2. 构造模型构造函数接收 3 个位置参数并会即时校验。配置不合法会在构造时就失败而不是拖到fit。pub fn new(n_clusters: usize, max_iterations: usize, tolerance: f64) - ResultSelf, Error参数位置类型含义n_clusters第 1 个usize要形成的簇数k。必须大于 0。max_iterations第 2 个usize每次重启内部 Lloyd 迭代的上限。必须大于 0。tolerance第 3 个f64相对收敛容差按特征方差缩放。必须为正且有限。任何一条不满足都会返回Error::InvalidParametern_clusters 0、max_iterations 0或者tolerance为零、为负、NaN或无穷。注意它和数据校验错误之间的不对称。越界的超参数会返回InvalidParameter。数据里的非有限值则返回NonFinite。这个区分是全 crate 通用的约定。KMeans::default()给你n_clusters 8、max_iterations 300、tolerance 1e-4、n_init 10且不带种子。这份配置永远合法。余下两项设置是 builder 步骤它们各自拿走实例的所有权、再把它交还所以可以接在new后面链式调用。with_random_state(seed)不会失败。with_n_init(n)返回Result因为0次重启会触发Error::InvalidParameterlet mut km KMeans::new(3, 300, 1e-4) .unwrap() .with_n_init(1) // 对齐 scikit-learn返回 Result .unwrap() .with_random_state(42); // 返回 Self不带种子时k-means 从系统熵取随机数每次fit都会得到不同的划分。带上种子fit就可复现详见 2.7.6。这种可复现性也覆盖每一次重启每次重启都会从你设的种子确定性地派生出自己的子种子。2.7.3. 训练、预测与读取结果驱动模型的方法有 3 个它们在返回值和改动的状态上各不相同。fit(mut self, data)原地训练模型返回Resultmut Self, Error。它会算出并存下质心、训练集标签、inertia 和迭代次数。这 4 个值都描述胜出的那次重启。predict(self, data)把新矩阵的每一行归到最近的已拟合质心返回一个持有所有权的Array1isize。它以不可变方式借用self不改动任何已存状态所以拟合一次之后想调用多少次都行。标签用的是有符号类型而非无符号类型。这样才能让 crate 里每个聚类估计器共享同一种标签类型。DBSCAN 和 MeanShift 都用-1表示噪声共享的标签类型让它们不加转换就能喂给 5.3. 聚类指标 里的各项指标。k-means 自己永远不会返回负标签。fit_predict(mut self, data)会调用fit然后返回训练标签的一份克隆。只想要刚训练那批数据的标签时用它。想给新样本打分时改用fit加predict。已拟合的状态通过一组 getter 暴露出来fit之前它们都返回NoneGetter返回说明get_centroids()OptionArray2f64形状(n_clusters, n_features)。对应 scikit-learn 的cluster_centers_。get_labels()OptionArray1isize训练集的归属。对应 scikit-learn 的labels_。get_inertia()Optionf64各点到最近质心的平方距离之和。对应 scikit-learn 的inertia_。无论是否收敛都与get_centroids()保持一致。get_actual_iterations()Optionusize胜出那次重启实际跑的迭代次数1..max_iterations。对应 scikit-learn 的n_iter_。get_n_clusters()/get_max_iterations()/get_tolerance()/get_n_init()/get_random_state()普通值 /Optionu64回读配置本身。fit会报 3 类数据错误。矩阵行数为零时返回Error::EmptyInput。任何值为NaN或无穷时返回Error::NonFinite。样本数少于簇数时返回Error::InvalidInput因为点数不到k个就摆不下k个质心。predict还会另外报 2 种错误。在fit之前调用返回Error::NotFitted。特征数与训练数据对不上返回Error::DimensionMismatch。它还会做和fit一样的空输入与非有限值检查。2.7.4. 端到端聚类三个数据簇3 个紧凑、彼此分得很开的数据簇是最经典的正确性自检。任何正确的 k3 拟合都必须把每个数据簇单独分成一类。这个例子用了一份小而确定的数据集数据本身不含随机数再加一个固定种子。它瞬间就能跑完结果的结构也在意料之中。userustyml::machine_learning::KMeans;usendarray::{array,Array2};fnmain(){// 三个数据簇各 5 个点中心在 (0,0)、(10,0)、(5,10)。letdata:Array2f64array![[-0.05,0.03],[0.04,-0.02],[0.01,0.05],[-0.03,-0.04],[0.02,0.01],[9.95,0.03],[10.04,-0.02],[10.01,0.05],[9.97,-0.04],[10.02,0.01],[4.95,10.03],[5.04,9.98],[5.01,10.05],[4.97,9.96],[5.02,10.01],];letmutkmKMeans::new(3,300,1e-4).unwrap().with_random_state(42);km.fit(data).unwrap();letlabelskm.get_labels().unwrap();letcentroidskm.get_centroids().unwrap();println!(labels: {:?},labels);println!(centroids: {:?},centroids);println!(inertia: {:.6},km.get_inertia().unwrap());println!(iters: {},km.get_actual_iterations().unwrap());// 给落在各数据簇真实中心上的新样本打分。letnew_pointsarray![[0.0,0.0],[10.0,0.0],[5.0,10.0]];letpredictedkm.predict(new_points).unwrap();println!(new-point labels: {:?},predicted);}具体的簇编号是任意的k-means 按发现顺序给簇编号所以哪个数据簇成为 0 号取决于种子。但结构是固定的labels: 每个数据簇的 5 个点共享同一个编号。3 个数据簇会拿到 3 个互不相同的编号 0、1、2 的某种排列。 centroids: 形状 (3, 2)。三行按某种顺序分别落在 (0,0)、(10,0)、(5,10) 附近约 0.1 的范围内。 inertia: 一个很小的正 f64各点到质心距离的平方和。 iters: 若干次远低于 max_iterations。 new-point labels: 每个测试点都映射到它所在数据簇对应的那个编号。簇编号取决于排列方式所以永远不要用相等去比较 2 个分别拟合的模型的标签。应该改用排列不变的指标比如 5.3. 聚类指标 里的调整兰德指数。2.7.5. 如何选择 kk-means 没法告诉你k是多少得你自己给出。有 2 种方法能帮你缩小范围RustyML 为两者都备好了原料。肘部法把 inertia 对k画成曲线。inertia 会随着k增大而单调下降因为质心越多平方距离之和只会更小。到k n时它会降到零。要找的是「肘部」也就是下降变平的那个点。inertia 可以直接从get_inertia读出来。轮廓系数更有决断力因为它有一个内部最优值而不是单调趋势。对每个点它衡量的是这个点离自己所在的簇比离最近的其他簇近多少。它把这些值平均成[-1, 1]里的一个分数越高越好。metrics 模块把它作为silhouette_score提供。它接收特征矩阵、标签和一个DistanceCalculationMetric。它只对2..n-1个不同的簇有定义所以没法给k 1打分。userustyml::machine_learning::KMeans;userustyml::metrics::silhouette_score;userustyml::math::DistanceCalculationMetric;usendarray::{array,Array2};fnmain(){letdata:Array2f64array![[-0.05,0.03],[0.04,-0.02],[0.01,0.05],[-0.03,-0.04],[0.02,0.01],[9.95,0.03],[10.04,-0.02],[10.01,0.05],[9.97,-0.04],[10.02,0.01],[4.95,10.03],[5.04,9.98],[5.01,10.05],[4.97,9.96],[5.02,10.01],];println!( k inertia silhouette);forkin1..5usize{letmutkmKMeans::new(k,300,1e-4).unwrap().with_random_state(42);letlabelskm.fit_predict(data).unwrap();letinertiakm.get_inertia().unwrap();ifk2{letssilhouette_score(data,labels,DistanceCalculationMetric::Euclidean);println!({k:2} {inertia:8.4} {s:7.4});}else{println!({k:2} {inertia:8.4} (n/a));}}}在这 3 个干净的数据簇上inertia 从k 1到k 3骤降然后趋平。轮廓系数在k 3达到峰值。两者都指向真实结构。真实数据要浑浊得多所以轮廓系数的内部极大值往往能给出更清晰的信号。完整的指标清单见 5.3. 聚类指标其中还有 Davies-Bouldin 和 Calinski-Harabasz能给出独立的第二意见。2.7.6. 可复现的聚类局部种子与全局种子k-means 带随机性所以不设种子的模型每次fit都会产出不同的划分。有 2 种办法能把它钉死而且它们组合起来的行为是可预料的。局部种子用with_random_state(seed)设定。它是自足的直接为该估计器的初始化 RNG 播种。它无视任何全局状态也绝不影响其他组件。n_init次重启各自的子种子是通过一个固定的混合步骤从它派生出来的而不是靠推进同一个共享 RNG。所以某次重启的初始化不会取决于之前几次重启消耗了多少随机数。用同一个局部种子构建、并在同一份数据上拟合的 2 个模型会产出完全一致的质心、标签和 inertia。迭代次数也一样一致精确到最后一位。全局种子通过rustyml::random::set_global_seed(seed)设定能只用一次调用就固定此后在同一线程上构建并拟合的每一个未设种子的随机化组件。这包括 k-means、神经网络的初始化器、train_test_split等等。这照搬了 Keras 的全局种子行为。未设种子的 k-means 模型会在fit时从全局流里取一个独立的子种子。要复现某次运行就在重新拟合之前再设一遍全局种子。这里有 2 点需要留意。全局种子是线程局部的所以要在拟合模型的那个线程上设它。未设种子的组件还会按拟合顺序消费这个流所以它们的可复现性对顺序敏感。with_random_state种子能绕开这两个问题这正是它适合用来钉死单个估计器的原因。userustyml::machine_learning::KMeans;userustyml::random::{set_global_seed,clear_global_seed};usendarray::{array,Array2};fnmain(){letdata:Array2f64array![[0.0,0.0],[0.1,0.0],[0.0,0.1],[10.0,0.0],[10.1,0.0],[10.0,0.1],[5.0,10.0],[5.1,10.0],[5.0,10.1],];// 局部种子结果完全一致与任何全局状态无关。letmutaKMeans::new(3,300,1e-4).unwrap().with_random_state(42);letmutbKMeans::new(3,300,1e-4).unwrap().with_random_state(42);a.fit(data).unwrap();b.fit(data).unwrap();assert_eq!(a.get_labels().unwrap(),b.get_labels().unwrap());// 全局种子每次拟合前重设一遍就能复现未设种子的运行。set_global_seed(7);letmutcKMeans::new(3,300,1e-4).unwrap();c.fit(data).unwrap();letlabels_cc.get_labels().unwrap().clone();set_global_seed(7);letmutdKMeans::new(3,300,1e-4).unwrap();d.fit(data).unwrap();assert_eq!(labels_c,d.get_labels().unwrap());clear_global_seed();}这种确定性之所以成立是因为并行算术从设计上就追求可复现而不只是追求速度。具体做法见 2.7.8。整个 crate 的完整播种模型见 7.1. 可复现性与随机种子。2.7.7. 失败模式与常见陷阱**空簇。**当 Lloyd 的分配让某个质心一个点都没分到时RustyML 不会丢掉这个簇。它也不会留下一个陈旧的质心。每一轮迭代都会给每个空簇重新播种用的是离它自己所属质心最远的那个点也就是对 inertia 贡献最大的那个点。这样模型始终保持恰好n_clusters个质心并在下一轮把它从退化状态里推出来。所以get_centroids永远会返回n_clusters行。在病态数据上比如大量重复、或者k接近n标签仍可能解析出少于k个不同的值。**对特征尺度敏感。**k-means 最小化的是欧几里得距离。所以一个以千计的特征会压过一个以零点几计的特征聚类实际上就把小尺度那个特征忽略了。它没有内置的标准化。只要你的特征处在不同量级就先用 4.2. 标准化与归一化 里的工具做标准化。k-means 结果看起来不对最常见的原因就是这一个。**对离群点敏感。**质心只是普通均值所以几个极端点就能把它从簇的稠密区域拽走并抬高 inertia。数据里有离群点时要么把它们裁掉要么改用把它们当作噪声处理的基于密度的方法。2.8. DBSCAN 会显式地标出离群点。2.9. MeanShift 无需预设k就能找到密度峰值。**非球状簇。**k-means 把空间划分成质心周围的 Voronoi 元胞。所以它只能还原大致凸形、大小相近的簇。同心圆环或狭长的流形无论k取多少都能难倒它。这种几何结构正是 DBSCAN 和 2.12. t-SNE 里的流形方法要对付的。**样本太少。**要求的簇数比点数还多会返回Error::InvalidInput。它不会悄悄截断k。如果k取决于数据就在拟合前先检查n_clusters data.nrows()。2.7.8. 并行与性能分配步是开销的大头。每轮迭代它都要拿每个点去比对每个质心并行也就落在这里。每轮迭代把所有点到质心的投影当成一次并行矩阵乘积算出来见 6.2. 矩阵乘法。再用一次简短的 arg-min 扫描找出每个点最近的质心。质心更新会把每个点的那一行累加进它所属簇的累积和里做法是一次确定性分块归约见 6.3. 并行归约。k-means 初始化的距离计算也用同样的方式并行。“确定性”这个词在这里很关键。上面每个阶段都只在超过一个标定过的工作量阈值时才并行。归约始终按固定的分块顺序求和而不是按线程到达的顺序。正因如此并行路径才会给出和串行路径一样的答案。也正因如此带种子的拟合在同一台机器上才可复现不管 rayon 用了多少线程。浮点加法不满足结合律。按线程恰好完成的顺序求和会把线程数泄漏进结果里。跨不同机器或不同构建目标时算术后端最后一位上的微小差异仍有可能出现。但在同一台机器内固定种子就给出固定答案。输入较小时一切都保持单线程这样能避免在用不着的数据上白付 rayon 的协调开销。这里有 3 个阈值控制着切换点。arg-min 扫描那一步用的是 f64 扫描门限。累加那一步用的是 f64 求和门限。把每个质心除以其簇大小那一步用的是 f64 cheap-map 门限。扫描门限和求和门限都在rustyml::tuning::reduction里。cheap-map 门限在rustyml::tuning::elementwise里。如果你在聚类一些不寻常的形状、想挪动某个阈值就调它们。多数用户从不碰它们。7.3. 性能调优与并行 讲解了这些旋钮。开启show_progressfeature 构建时fit过程中会画一条实时的 inertia 与迭代进度条。这在大数据集上很趁手feature 关掉时它不产生任何开销。2.7.9. 保存与加载已训练的模型KMeans派生了 serde 的Serialize和Deserialize。配套生成的save_to_path和load_from_path方法会把整个模型持久化成一段紧凑的 postcard 二进制块其中包括质心、标签、超参数和元数据。文件扩展名并不重要因为格式始终是二进制 postcard。加载回来的模型与原模型预测完全一致连质心的最后一位都不差。引入重启机制时序列化布局多了n_init这个字段。所以旧版本写出的二进制块加载不了。请重新拟合模型、再重新保存。userustyml::machine_learning::KMeans;usendarray::{array,Array2};usestd::fs::remove_file;fnmain(){letdata:Array2f64array![[0.0,0.0],[0.1,0.0],[0.0,0.1],[10.0,0.0],[10.1,0.0],[10.0,0.1],[5.0,10.0],[5.1,10.0],[5.0,10.1],];letmutkmKMeans::new(3,300,1e-4).unwrap().with_random_state(42);km.fit(data).unwrap();km.save_to_path(kmeans_model.bin).unwrap();letloadedKMeans::load_from_path(kmeans_model.bin).unwrap();letoriginalkm.predict(data).unwrap();letrestoredloaded.predict(data).unwrap();assert_eq!(original,restored);remove_file(kmeans_model.bin).unwrap();}I/O 和反序列化的问题会以Error::Io的形式出现。加载时文件不存在是最常见的情形。持久化格式、版本兼容方面的考量以及它如何与 crate 其余部分配合见 7.2. 深入模型持久化。
返回列表