rustyml 0.15.0

A high-performance machine learning & deep learning library in pure Rust, offering ML algorithms and neural network support
Documentation
# 6.1. 距离度量

距离是大多数经典机器学习背后的一个基本概念。K 近邻按距离给候选点排序。DBSCAN 通过把距离与阈值比较来生长簇。轮廓系数对距离取平均。k-means 最小化的是距离的平方。

RustyML 把这一切都定义在一个小模块 [`crate::math::distance`](https://docs.rs/rustyml/latest/rustyml/math/distance/index.html) 里。这个模块是“两点之间有多远”的唯一定义之处。每个支持度量选择的估计器都共用同一个调度器。本页记录它的公开接口:有什么、没有什么,以及数值上的捷径在哪里。

这个模块分两层。底层是 3 个自由函数。它们是不分配内存的内核,每次处理一对向量。顶层是 [`DistanceCalculationMetric`](https://docs.rs/rustyml/latest/rustyml/math/enum.DistanceCalculationMetric.html),一个小枚举。这个枚举给度量命名,并调度到对应内核。

估计器存的是这个枚举,而不是函数指针,因为枚举是 `Copy` 的,支持 `serde` 序列化,`match` 起来开销也很小。要搭建自己的最近邻逻辑,就直接用内核。要获得和库内部一致的度量抽象,就用枚举。

## 6.1.1. 3 个逐行内核

这 3 个内核都从 `rustyml::math` 重新导出。它们的名字很重要:没有名为 `euclidean_distance_row` 的函数。欧几里得内核名叫 `squared_euclidean_distance_row`。它返回平方差之和,从不开方。

这不是疏忽,而是整个设计的核心。只关心排序的调用方——比如找最近点、判断是否落在半径内、找最近质心——根本用不到开方。给每一对点都算一次 `sqrt` 纯属浪费。真要拿到真正的欧几里得距离,就自己开方,或者走调度器。

```rust,ignore
pub fn squared_euclidean_distance_row<S1, S2>(x1: &ArrayBase<S1, Ix1>, x2: &ArrayBase<S2, Ix1>) -> f64;
pub fn manhattan_distance_row<S1, S2>(x1: &ArrayBase<S1, Ix1>, x2: &ArrayBase<S2, Ix1>) -> f64;
pub fn minkowski_distance_row<S1, S2>(x1: &ArrayBase<S1, Ix1>, x2: &ArrayBase<S2, Ix1>, p: f64) -> f64;
// where S1: Data<Elem = f64>, S2: Data<Elem = f64>
```

输入类型同时接受视图和切片。每个函数都接收一个一维 `ndarray` 数组的引用。每个函数都通过 `S: Data<Elem = f64>` 对存储方式泛型。自有的 `Array1<f64>`、借来的 `ArrayView1<f64>`,以及矩阵的一行(比如 `&data.row(i)`),全都能原样传入。

不必先把行拷进 `Vec<f64>`。元素类型固定为 `f64`,没有 `f32` 路径。`S1` 和 `S2` 是相互独立的类型参数,所以两个参数可以用不同的存储类型。拿一个自有的查询向量去和矩阵的某一行比较,完全没问题。

这 3 个函数本身都不会因长度不匹配而 panic。`ndarray` 的 `Zip` 要求两边等长,你若违反这条规则,panic 就发生在那里。请把维度相等当作一条由你自己维护的前置条件。

```rust
use ndarray::array;
use rustyml::math::{
    manhattan_distance_row, minkowski_distance_row, squared_euclidean_distance_row,
};

fn main() {
    let a = array![1.0, 2.0, 3.0];
    let b = array![4.0, 6.0, 8.0];

    // 平方 L2,不开方。要度量距离请自己开方。
    let sq = squared_euclidean_distance_row(&a, &b);
    let euclidean = sq.sqrt();

    let l1 = manhattan_distance_row(&a, &b);
    let l3 = minkowski_distance_row(&a, &b, 3.0);

    // 闵可夫斯基是超集:p = 1 退化为曼哈顿,p = 2 退化为欧几里得。
    let mink1 = minkowski_distance_row(&a, &b, 1.0);
    let mink2 = minkowski_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 消息的末尾就是你传进去的那个值:

```text
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`:

| 变体 | 含义 | 调度到 |
|---|---|---|
| `Euclidean` | L2 范数(默认) | `squared_euclidean_distance_row(...).sqrt()` |
| `Manhattan` | L1 范数 | `manhattan_distance_row(...)` |
| `Minkowski(f64)` | 通用 p 范数,`p` 内联携带 | `minkowski_distance_row(..., p)` |

变体本身就是全部配置。`Minkowski` 把 `p` 值作为自己的载荷带在身上。因此一个度量就是一个自洽的值,没有另外的参数需要保持同步。

这个枚举派生了 `Clone`、`Copy`、`PartialEq` 和 `Default`。在 `machine_learning` 或 `utils` feature 下,它还派生了 `Serialize` 和 `Deserialize`。这让它很容易存进结构体字段。这也意味着它能挺过模型持久化(见 [7.2. 深入模型持久化](../Chapter-07/7.2._深入模型持久化.md))。

`DistanceCalculationMetric` 上有两个公开方法。第一个是 `distance(&self, a: ArrayView1<f64>, b: ArrayView1<f64>) -> f64`。它是度量调度的唯一权威来源。每个支持度量选择的估计器都调它,而不是各自写一遍 `match`。它按值接收 `ArrayView1<f64>`,不是按引用。`ArrayView1` 是 `Copy` 的,所以你可以直接传 `v.view()` 或矩阵的一行,还能跨多次调用复用同一个视图而无需克隆。

第二个公开方法是 `within(&self, a, b, threshold) -> bool`。它回答的是 `distance(a, b) <= threshold` 是否成立,而且不必先算出真实距离。它转而在度量的免开方空间里比较,比如对 `Euclidean` 就是 `sq_dist <= threshold * threshold`。这个映射在非负值上单调,所以结果始终和朴素比较一致,却省掉了 `sqrt`。正因如此,半径查询应该优先用 `within`,而不是 `distance(...) <= r`。

```rust
use ndarray::array;
use rustyml::math::DistanceCalculationMetric;

fn main() {
    let a = array![0.0, 0.0];
    let b = array![3.0, 4.0];

    let euclidean = DistanceCalculationMetric::Euclidean;
    let manhattan = DistanceCalculationMetric::Manhattan;
    let minkowski = DistanceCalculationMetric::Minkowski(3.0);

    // ArrayView1<f64> 是 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`]../Chapter-02/2.3._K近邻.md | `.with_metric(...)?` 构建器 | 欧几里得、曼哈顿、闵可夫斯基(p >= 1,有限) |
| [`DBSCAN`]../Chapter-02/2.8._DBSCAN.md | `.with_metric(...)?` 构建器 | 欧几里得、曼哈顿、闵可夫斯基(p >= 1,有限) |
| [`silhouette_score`]../Chapter-05/5.3._聚类指标.md | `metric` 函数参数 | 欧几里得、曼哈顿、闵可夫斯基(p >= 1) |

`with_metric` 比裸内核更严格。当 `p < 1.0`,或者 `p` 不是有限值时,它会拒绝 `Minkowski(p)`。这种情况下,它返回 `Error::InvalidParameter`(见 [1.6. 错误处理](../Chapter-01/1.6._错误处理.md))。它不会把失败拖到 fit 时才以 panic 的形式暴露。这正是 `with_metric` 返回 `Result` 的原因:默认构造器绝不会在度量上失败,但覆盖它可能会失败。失败会立刻出现,就在构建器调用的那一刻。

两个估计器都默认使用 `Euclidean`。只有想要别的度量时,才需要调用 `with_metric`。

```rust
use ndarray::array;
use rustyml::machine_learning::neighbors::{KNN, WeightingStrategy};
use rustyml::math::DistanceCalculationMetric;

fn main() {
    let x_train = array![[0.0, 0.0], [10.0, 0.0], [0.0, 10.0]];
    let y_train = array![0_i32, 1, 1];

    let mut knn = KNN::<i32>::new(1)
        .unwrap()
        .with_weighting_strategy(WeightingStrategy::Uniform)
        .with_metric(DistanceCalculationMetric::Manhattan)
        .unwrap();
    knn.fit(&x_train, &y_train).unwrap();

    let x_test = array![[0.5, 0.0], [9.5, 0.0]];
    let preds = knn.predict(&x_test).unwrap();
    println!("{preds:?}");

    // 阶数小于 1 不是一个度量,构建器会在做任何事之前就拒绝它。
    let bad = KNN::<i32>::new(1)
        .unwrap()
        .with_metric(DistanceCalculationMetric::Minkowski(0.5));
    assert!(bad.is_err());
}
```

[`KMeans`](../Chapter-02/2.7._KMeans聚类.md) 和 [`MeanShift`](../Chapter-02/2.9._MeanShift.md) 都不接收度量参数。两者都直接调用 `squared_euclidean_distance_row`,所以从构造上就只能用欧几里得。k-means 的定义就是最小化簇内平方 L2 距离。换掉度量会破坏质心更新的数学,所以没有可以切换的选项。要做非欧几里得聚类,该用 DBSCAN。

给 `silhouette_score` 传一个度量,能让你在构建聚类时所用的同一套几何下评估它。举例来说,给一个用了曼哈顿距离的 DBSCAN 结果打分时,就用 `DistanceCalculationMetric::Manhattan`。这样能让评估和聚类保持一致。

```rust
use ndarray::array;
use rustyml::math::DistanceCalculationMetric;
use rustyml::metrics::silhouette_score;

fn main() {
    // 两团分得很开的点。
    let x = array![
        [0.0, 0.0], [0.2, 0.1], [0.1, -0.2],
        [10.0, 10.0], [10.1, 9.8], [9.9, 10.2],
    ];
    let labels = array![0_isize, 0, 0, 1, 1, 1];

    let s_euclidean = silhouette_score(&x, &labels, DistanceCalculationMetric::Euclidean);
    let s_manhattan = silhouette_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 个结构性事实:矩阵是对称的,对角线是零。把每个无序对只算一次,再把值镜像到对角线另一侧,对角线就留在零。这样能把距离计算的次数减半。在这个热点双重循环里,待在平方空间中,每个元素只开一次方。如果下游只需要排序,就干脆跳过开方。

```rust
use ndarray::{Array2, array};
use rustyml::math::squared_euclidean_distance_row;

fn main() {
    let data = array![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]];
    let n = data.nrows();

    // 对称且对角线为零:只填上三角,再镜像。
    let mut dist = Array2::<f64>::zeros((n, n));
    for i in 0..n {
        for j in (i + 1)..n {
            // 行按引用传入,不拷贝,每个元素只开一次方。
            let d = squared_euclidean_distance_row(&data.row(i), &data.row(j)).sqrt();
            dist[[i, j]] = d;
            dist[[j, i]] = d;
        }
    }

    // 对单个查询,跳过矩阵,直接扫一遍:
    let query = data.row(0);
    let nearest = (1..n)
        .min_by(|&a, &b| {
            // 在平方空间里比较,选最小值不需要开方。
            let da = squared_euclidean_distance_row(&query, &data.row(a));
            let db = squared_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. 性能调优与并行](../Chapter-07/7.3._性能调优与并行.md)。这个模块随附的兄弟数值原语,见 [6.2. 矩阵乘法](./6.2._矩阵乘法.md) 和 [6.3. 并行归约](./6.3._并行归约.md)。