6.1. 距离度量距离是大多数经典机器学习背后的一个基本概念。K 近邻按距离给候选点排序。DBSCAN 通过把距离与阈值比较来生长簇。轮廓系数对距离取平均。k-means 最小化的是距离的平方。RustyML 把这一切都定义在一个小模块crate::math::distance里。这个模块是“两点之间有多远”的唯一定义之处。每个支持度量选择的估计器都共用同一个调度器。本页记录它的公开接口有什么、没有什么以及数值上的捷径在哪里。这个模块分两层。底层是 3 个自由函数。它们是不分配内存的内核每次处理一对向量。顶层是DistanceCalculationMetric一个小枚举。这个枚举给度量命名并调度到对应内核。估计器存的是这个枚举而不是函数指针因为枚举是Copy的支持serde序列化match起来开销也很小。要搭建自己的最近邻逻辑就直接用内核。要获得和库内部一致的度量抽象就用枚举。6.1.1. 3 个逐行内核这 3 个内核都从rustyml::math重新导出。它们的名字很重要没有名为euclidean_distance_row的函数。欧几里得内核名叫squared_euclidean_distance_row。它返回平方差之和从不开方。这不是疏忽而是整个设计的核心。只关心排序的调用方——比如找最近点、判断是否落在半径内、找最近质心——根本用不到开方。给每一对点都算一次sqrt纯属浪费。真要拿到真正的欧几里得距离就自己开方或者走调度器。pub fn squared_euclidean_distance_rowS1, S2(x1: ArrayBaseS1, Ix1, x2: ArrayBaseS2, Ix1) - f64; pub fn manhattan_distance_rowS1, S2(x1: ArrayBaseS1, Ix1, x2: ArrayBaseS2, Ix1) - f64; pub fn minkowski_distance_rowS1, S2(x1: ArrayBaseS1, Ix1, x2: ArrayBaseS2, Ix1, p: f64) - f64; // where S1: DataElem f64, S2: DataElem f64输入类型同时接受视图和切片。每个函数都接收一个一维ndarray数组的引用。每个函数都通过S: DataElem f64对存储方式泛型。自有的Array1f64、借来的ArrayView1f64以及矩阵的一行比如data.row(i)全都能原样传入。不必先把行拷进Vecf64。元素类型固定为f64没有f32路径。S1和S2是相互独立的类型参数所以两个参数可以用不同的存储类型。拿一个自有的查询向量去和矩阵的某一行比较完全没问题。这 3 个函数本身都不会因长度不匹配而 panic。ndarray的Zip要求两边等长你若违反这条规则panic 就发生在那里。请把维度相等当作一条由你自己维护的前置条件。usendarray::array;userustyml::math::{manhattan_distance_row,minkowski_distance_row,squared_euclidean_distance_row,};fnmain(){letaarray![1.0,2.0,3.0];letbarray![4.0,6.0,8.0];// 平方 L2不开方。要度量距离请自己开方。letsqsquared_euclidean_distance_row(a,b);leteuclideansq.sqrt();letl1manhattan_distance_row(a,b);letl3minkowski_distance_row(a,b,3.0);// 闵可夫斯基是超集p 1 退化为曼哈顿p 2 退化为欧几里得。letmink1minkowski_distance_row(a,b,1.0);letmink2minkowski_distance_row(a,b,2.0);assert!((mink1-l1).abs()1e-12);assert!((mink2-euclidean).abs()1e-12);println!(sq{sq} l2{euclidean} l1{l1} l3{l3});}minkowski_distance_row是通用形式。它对每个坐标累加|a_i - b_i|^p再把总和取1/p次方。它是这 3 个函数里唯一可能 panic 的一个。当p小于 1.0或者p为NaN时它就会 panic。panic 消息的末尾就是你传进去的那个值invalid parameter p: Minkowski order must be at least 1.0, got 0.5阶数小于 1.0 会被拒绝是因为它们根本构不成一个度量。三角不等式对这些阶数不成立。6.1.5 里讲到的 kd 树剪枝依赖三角不等式。任何依赖它的索引在这些阶数下都会返回错误的答案而不只是奇怪的答案。裸内核并不拒绝p f64::INFINITY。无穷大不小于 1.0也不是NaN所以检查会通过。函数于是会算出一个退化的powf(inf)表达式而不是切比雪夫L-infinity极限。别指望传一个无穷阶数就能得到那个极限。6.1.4 会说明估计器的构建器拒绝这种情况而裸内核并不拒绝。6.1.2.DistanceCalculationMetric调度器DistanceCalculationMetric是可配置度量那一层。它有 3 个变体Euclidean是Default变体含义调度到EuclideanL2 范数默认squared_euclidean_distance_row(...).sqrt()ManhattanL1 范数manhattan_distance_row(...)Minkowski(f64)通用 p 范数p内联携带minkowski_distance_row(..., p)变体本身就是全部配置。Minkowski把p值作为自己的载荷带在身上。因此一个度量就是一个自洽的值没有另外的参数需要保持同步。这个枚举派生了Clone、Copy、PartialEq和Default。在machine_learning或utilsfeature 下它还派生了Serialize和Deserialize。这让它很容易存进结构体字段。这也意味着它能挺过模型持久化见 7.2. 深入模型持久化。DistanceCalculationMetric上有两个公开方法。第一个是distance(self, a: ArrayView1f64, b: ArrayView1f64) - f64。它是度量调度的唯一权威来源。每个支持度量选择的估计器都调它而不是各自写一遍match。它按值接收ArrayView1f64不是按引用。ArrayView1是Copy的所以你可以直接传v.view()或矩阵的一行还能跨多次调用复用同一个视图而无需克隆。第二个公开方法是within(self, a, b, threshold) - bool。它回答的是distance(a, b) threshold是否成立而且不必先算出真实距离。它转而在度量的免开方空间里比较比如对Euclidean就是sq_dist threshold * threshold。这个映射在非负值上单调所以结果始终和朴素比较一致却省掉了sqrt。正因如此半径查询应该优先用within而不是distance(...) r。usendarray::array;userustyml::math::DistanceCalculationMetric;fnmain(){letaarray![0.0,0.0];letbarray![3.0,4.0];leteuclideanDistanceCalculationMetric::Euclidean;letmanhattanDistanceCalculationMetric::Manhattan;letminkowskiDistanceCalculationMetric::Minkowski(3.0);// ArrayView1f64 是 Copy 的所以同一个视图能跨多次调用复用。assert_eq!(euclidean.distance(a.view(),b.view()),5.0);// sqrt(9 16)assert_eq!(manhattan.distance(a.view(),b.view()),7.0);// 3 4// within 在免开方空间里比较5^2 5^2 为真5^2 4.9^2 为假。assert!(euclidean.within(a.view(),b.view(),5.0));assert!(!euclidean.within(a.view(),b.view(),4.9));let_minkowski.distance(a.view(),b.view());}你可以从 2 个等价的位置导入这个调度器。它的老家是rustyml::math::DistanceCalculationMetric。给 ML 用户的重新导出是rustyml::machine_learning::DistanceCalculationMetric。两个名字指向同一个类型。用哪个路径取决于你已经在从哪个模块导入。6.1.3. 度量的性质与闵可夫斯基阶数这 3 个度量共享几条性质。每一个都非负且对称d(a, b) d(b, a)。每一个都只在两个向量相同时为零。每一个都满足三角不等式前提是库允许的那些阶数。这些性质让 6.1.5 里的 kd 树剪枝得以成立。估计器依赖着它们。闵可夫斯基阶数p在几个有名的度量之间移动。它控制单个大坐标差能主导结果到什么程度。当p 1时得到的是曼哈顿距离。此时每根轴贡献自己的原始绝对差。斜着走一步代价是两条直角边之和。当p 2时得到的是欧几里得距离也就是普通的直线距离。当p涨过 2最大的单轴差会越来越主导总和。每个差值都被提升到p次方所以更大的p会更偏袒那个最大的差距。度量的行为就变得越来越像“只看最大坐标差”。这种行为就是切比雪夫极限也叫 L-infinity 极限。RustyML 不把这个极限做成一个可用的度量。没有Chebyshev这个变体。Minkowski(f64::INFINITY)对估计器来说不是合法配置见 6.1.4。想要接近“最大坐标差”那种行为就挑一个大的有限p。把这个结果当作一种近似而不是真正的极限。(1, 2)之间的分数阶比如Minkowski(1.5)是合法的度量。它们落在城区距离和直线几何之间。用它们来缓和欧几里得对离群点的敏感度同时不必彻底走到曼哈顿距离。6.1.4. 哪些估计器接受哪些度量有 2 个估计器让你选择度量。两者用的是同一套模式一个返回Result的with_metric构建器。它返回Result是因为它在你设置度量的那一刻就校验闵可夫斯基阶数。估计器如何设置度量支持的度量KNN.with_metric(...)?构建器欧几里得、曼哈顿、闵可夫斯基p 1有限DBSCAN.with_metric(...)?构建器欧几里得、曼哈顿、闵可夫斯基p 1有限silhouette_scoremetric函数参数欧几里得、曼哈顿、闵可夫斯基p 1with_metric比裸内核更严格。当p 1.0或者p不是有限值时它会拒绝Minkowski(p)。这种情况下它返回Error::InvalidParameter见 1.6. 错误处理。它不会把失败拖到 fit 时才以 panic 的形式暴露。这正是with_metric返回Result的原因默认构造器绝不会在度量上失败但覆盖它可能会失败。失败会立刻出现就在构建器调用的那一刻。两个估计器都默认使用Euclidean。只有想要别的度量时才需要调用with_metric。usendarray::array;userustyml::machine_learning::neighbors::{KNN,WeightingStrategy};userustyml::math::DistanceCalculationMetric;fnmain(){letx_trainarray![[0.0,0.0],[10.0,0.0],[0.0,10.0]];lety_trainarray![0_i32,1,1];letmutknnKNN::i32::new(1).unwrap().with_weighting_strategy(WeightingStrategy::Uniform).with_metric(DistanceCalculationMetric::Manhattan).unwrap();knn.fit(x_train,y_train).unwrap();letx_testarray![[0.5,0.0],[9.5,0.0]];letpredsknn.predict(x_test).unwrap();println!({preds:?});// 阶数小于 1 不是一个度量构建器会在做任何事之前就拒绝它。letbadKNN::i32::new(1).unwrap().with_metric(DistanceCalculationMetric::Minkowski(0.5));assert!(bad.is_err());}KMeans和MeanShift都不接收度量参数。两者都直接调用squared_euclidean_distance_row所以从构造上就只能用欧几里得。k-means 的定义就是最小化簇内平方 L2 距离。换掉度量会破坏质心更新的数学所以没有可以切换的选项。要做非欧几里得聚类该用 DBSCAN。给silhouette_score传一个度量能让你在构建聚类时所用的同一套几何下评估它。举例来说给一个用了曼哈顿距离的 DBSCAN 结果打分时就用DistanceCalculationMetric::Manhattan。这样能让评估和聚类保持一致。usendarray::array;userustyml::math::DistanceCalculationMetric;userustyml::metrics::silhouette_score;fnmain(){// 两团分得很开的点。letxarray![[0.0,0.0],[0.2,0.1],[0.1,-0.2],[10.0,10.0],[10.1,9.8],[9.9,10.2],];letlabelsarray![0_isize,0,0,1,1,1];lets_euclideansilhouette_score(x,labels,DistanceCalculationMetric::Euclidean);lets_manhattansilhouette_score(x,labels,DistanceCalculationMetric::Manhattan);println!(euclidean{s_euclidean} manhattan{s_manhattan});}6.1.5. 平方距离与可比空间优化squared_euclidean_distance_row把平方距离这条捷径以一个公开函数的形式暴露出来。库内部也在用同一条捷径通过一个私有的想法可比空间。每个度量都有一个单调、免开方的形式欧几里得取平方闵可夫斯基取p次方曼哈顿则是恒等变换。这个变换在非负输入上是单调的。有些判断只依赖排序。比如哪个点更近、某点是否落在半径内、哪个点是第 k 近的点。任何这样的判断都能在可比空间里完成而且永远不用为开方买单。内部的 kd 树做的正是这件事。KNN 和 DBSCAN 在低维时会自动使用它。它把每个候选距离都存在可比空间里。它用同一空间里的逐轴下界来剪枝。它只对真正要返回的那几个结果才把可比空间的值换算回真实距离。within见 6.1.2就是这个想法露出水面的公开一角。这条实用的经验同样适用于你自己的代码。当你是在排序或设阈值而不是把距离报给人看时就待在平方空间里。比较squared_euclidean_distance_row的值对判断“哪个点更近”是正确的。而且它严格地比比较平方根更便宜。只在真实距离要离开你的循环那一刻才调用.sqrt()。6.1.6. 实例成对距离矩阵要为你自己的最近邻逻辑建一整张成对距离矩阵光靠这些内核就够了。这里的每个度量都保证 2 个结构性事实矩阵是对称的对角线是零。把每个无序对只算一次再把值镜像到对角线另一侧对角线就留在零。这样能把距离计算的次数减半。在这个热点双重循环里待在平方空间中每个元素只开一次方。如果下游只需要排序就干脆跳过开方。usendarray::{Array2,array};userustyml::math::squared_euclidean_distance_row;fnmain(){letdataarray![[0.0,0.0],[1.0,0.0],[0.0,1.0],[1.0,1.0]];letndata.nrows();// 对称且对角线为零只填上三角再镜像。letmutdistArray2::f64::zeros((n,n));foriin0..n{forjin(i1)..n{// 行按引用传入不拷贝每个元素只开一次方。letdsquared_euclidean_distance_row(data.row(i),data.row(j)).sqrt();dist[[i,j]]d;dist[[j,i]]d;}}// 对单个查询跳过矩阵直接扫一遍letquerydata.row(0);letnearest(1..n).min_by(|a,b|{// 在平方空间里比较选最小值不需要开方。letdasquared_euclidean_distance_row(query,data.row(a));letdbsquared_euclidean_distance_row(query,data.row(b));da.total_cmp(db)}).unwrap();println!(matrix\n{dist:?}\nnearest to row 0: {nearest});}这个例子里真正的重点在min_by那一半。只查几个点时一整张O(n^2)矩阵是选错了工具。做一次最近邻查找只需在O(n)里扫一遍从不碰开方。只有当下游算法要吃掉整张矩阵时比如层次聚类或者 MDS 嵌入才值得建出完整矩阵。不要为了找一个邻居就去建它。6.1.7. 性能、SIMD 与并行每个内核都是单趟运行没有中间分配。squared_euclidean_distance_row和manhattan_distance_row各自在两个输入上折叠一次ndarray::Zip。minkowski_distance_row做的是同样的事只是每个元素多一次powf调用最后再加一次powf(1.0 / p)调用。这 3 个函数都带有#[inline]属性。每次调用的开销和维度d呈线性关系。欧几里得和曼哈顿每个元素只需一次减法加一次乘法或一次取绝对值。两者都很便宜。闵可夫斯基每个元素要付一次超越函数powf调用这让它明显更慢。能用Euclidean或Manhattan时就优先用它们。避免使用Minkowski(2.0)或Minkowski(1.0)它们只是用更慢的路径算出同样的数字。这些内核里没有手写的 SIMD 代码也没有f32路径。Zip循环紧凑、无分支闵可夫斯基除外。这类代码的形态正是优化编译器能自动向量化的那种。但源码里没有任何东西强制向量化所以不要假设某一套具体的指令集。单次距离调用完全是串行的内核内部没有任何 rayon 调用。这是对的选择一行的工作量太小不值得为它承担线程调度的开销。并行发生在上一层也就是调用方。silhouette_score会在元素数超过一个可调阈值时把成对扫描铺到 rayon 线程池上。KNN 提供了predict_parallel方法DBSCAN 则会并行化它的邻域查询。如果瓶颈出在你自己的成对矩阵循环上就自己用 rayon 把按行的外层循环并行化。这些内核都是纯函数且是Send-safe 的所以这样接起来很干净。关于并行何时划算、以及那些阈值怎么调的更全面讨论见 7.3. 性能调优与并行。这个模块随附的兄弟数值原语见 6.2. 矩阵乘法 和 6.3. 并行归约。