Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

6.1. Distance Metrics

Distance is a basic idea behind most classical machine learning. k-nearest neighbors ranks candidates by distance. DBSCAN grows a cluster by testing distance against a threshold. Silhouette scoring averages distances. k-means minimizes a squared distance.

RustyML defines all of this in one small module, crate::math::distance. The module holds the only definition of “how far apart are two points”. Every metric-aware estimator shares the same dispatcher. This page documents the public surface: what exists, what does not exist, and where the numerical shortcuts live.

The module has two layers. The bottom layer holds 3 free functions. These are allocation-free kernels, and each works on one pair of vectors at a time. The top layer is DistanceCalculationMetric, a small enum. The enum names a metric and dispatches to the kernels.

Estimators store the enum, not a function pointer, because the enum is Copy, it supports serde serialization, and a match on it costs little. Use the kernels directly to build your own nearest-neighbor logic. Use the enum to get the same metric abstraction the library uses.

6.1.1. The 3 row kernels

All 3 kernels are re-exported from rustyml::math. Their names matter: there is no function named euclidean_distance_row. The Euclidean kernel is named squared_euclidean_distance_row. It returns the sum of squared differences and never takes a square root.

This is not an oversight. It is the whole design. Callers that need only ordering, such as finding the nearest point, testing a radius, or finding the closest centroid, never need the root. Taking a sqrt for every pair would waste work. To get the true Euclidean distance, take the root yourself, or use the dispatcher.

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>

The input types accept both views and slices. Each function takes a reference to a 1-D ndarray array. Each function is generic over storage, through S: Data<Elem = f64>. An owned Array1<f64>, a borrowed ArrayView1<f64>, and a row of a matrix, such as &data.row(i), all work without change.

You do not copy a row into a Vec<f64> first. The element type is fixed at f64. There is no f32 path. S1 and S2 are independent type parameters, so the two arguments can use different storage types. You can compare an owned query vector against a matrix row without a problem.

None of the 3 functions panics by itself on a length mismatch. ndarray’s Zip requires equal lengths and panics there if you break that rule. Treat equal dimensionality as a precondition that you must keep true.

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];

    // Squared L2. No square root taken. Take the root yourself for the metric distance.
    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);

    // Minkowski is a superset: p = 1 recovers Manhattan, p = 2 recovers Euclidean.
    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 is the general form. It sums |a_i - b_i|^p over every coordinate, then raises the total to the power 1/p. It is the only one of the 3 functions that can panic. It panics when p is less than 1.0, or when p is NaN. The panic message ends with the value you passed:

invalid parameter `p`: Minkowski order must be at least 1.0, got 0.5

Orders below 1.0 are rejected because they do not form a metric. The triangle inequality fails for those orders. The kd-tree pruning described in 6.1.5 depends on the triangle inequality. An index that relies on it would return a wrong answer for those orders, not just an odd one.

The raw kernel does not reject p = f64::INFINITY. Infinity is not less than 1.0, and it is not NaN, so the guard passes. The function then computes a degenerate powf(inf) expression, not the Chebyshev (L-infinity) limit. Do not pass an infinite order and expect that limit. Section 6.1.4 shows that the estimator builders reject this case. The raw kernel does not.

6.1.2. The DistanceCalculationMetric dispatcher

DistanceCalculationMetric is the configurable-metric layer. It has 3 variants, and Euclidean is the Default:

VariantMeaningDispatches to
EuclideanL2 norm (default)squared_euclidean_distance_row(...).sqrt()
ManhattanL1 normmanhattan_distance_row(...)
Minkowski(f64)general p-norm, p carried inlineminkowski_distance_row(..., p)

The variant is the whole configuration. Minkowski carries its p value as its own payload. A metric is therefore a self-contained value, with no separate parameter to keep in sync elsewhere.

The enum derives Clone, Copy, PartialEq, and Default. Under the machine_learning or utils feature, it also derives Serialize and Deserialize. This makes the enum easy to store in a struct field. It also means the enum survives model persistence (see 7.2. Model Persistence in Depth).

Two methods on DistanceCalculationMetric are public. The first is distance(&self, a: ArrayView1<f64>, b: ArrayView1<f64>) -> f64. It is the single source of truth for metric dispatch. Every metric-aware estimator calls it, instead of writing its own match over the variants. It takes ArrayView1<f64> by value, not by reference. ArrayView1 is Copy, so you can pass v.view() or a matrix row directly, and reuse the same view across many calls without cloning.

The second public method is within(&self, a, b, threshold) -> bool. It answers whether distance(a, b) <= threshold, without computing the actual distance first. It compares instead in the metric’s root-free space, for example sq_dist <= threshold * threshold for Euclidean. This mapping is monotonic on non-negative values, so the result always matches a plain comparison, but it skips the sqrt. Prefer within over distance(...) <= r for a radius query, for this reason.

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> is Copy, so the same view is reused across calls.
    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` compares in root-free space: 5^2 <= 5^2 is true, 5^2 <= 4.9^2 is false.
    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());
}

You can import the dispatcher from 2 equivalent places. Its home is rustyml::math::DistanceCalculationMetric. A re-export for ML users is rustyml::machine_learning::DistanceCalculationMetric. Both names refer to the same type. Use whichever path matches the module you already import from.

6.1.3. Metric properties and the Minkowski order

All 3 metrics share several properties. Each is non-negative and symmetric: d(a, b) == d(b, a). Each is zero only when the two vectors are identical. Each satisfies the triangle inequality, for the orders the library allows. These properties make the kd-tree pruning in 6.1.5 correct. The estimators depend on them.

The Minkowski order p moves between the named metrics. It controls how much a single large coordinate gap dominates the result. At p = 1, you get Manhattan distance. Each axis then contributes its raw absolute difference. A diagonal move costs the sum of the 2 legs.

At p = 2, you get Euclidean distance, the ordinary straight-line distance. As p grows past 2, the largest single-axis difference dominates the sum more and more. Each difference is raised to the power p, so a larger p favors the biggest gap. The metric then behaves more like the largest coordinate difference alone. That behavior is the Chebyshev limit, also called the L-infinity limit.

RustyML does not offer that limit as a usable metric. There is no Chebyshev variant. Minkowski(f64::INFINITY) is not a valid configuration for the estimators (see 6.1.4). To get behavior close to the largest-coordinate-difference limit, pick a large finite p instead. Treat the result as an approximation, not the true limit.

Fractional orders between 1 and 2, such as Minkowski(1.5), are valid metrics. They sit between city-block and straight-line geometry. Use them to soften Euclidean’s sensitivity to outliers, without moving all the way to Manhattan distance.

6.1.4. Which estimators accept which metrics

2 estimators let you choose a metric. Both use the same pattern: a with_metric builder that returns Result. It returns Result because it validates the Minkowski order at the moment you set it.

EstimatorHow to set the metricMetrics honored
KNN.with_metric(...)? builderEuclidean, Manhattan, Minkowski(p >= 1, finite)
DBSCAN.with_metric(...)? builderEuclidean, Manhattan, Minkowski(p >= 1, finite)
silhouette_scoremetric function argumentEuclidean, Manhattan, Minkowski(p >= 1)

with_metric is stricter than the raw kernel. It rejects Minkowski(p) when p < 1.0, or when p is not finite. In that case, it returns Error::InvalidParameter (see 1.6. Error Handling). It does not defer the failure to a panic at fit time. This is why with_metric returns Result: the default constructors never fail on the metric, but overriding it can fail. The failure then appears immediately, at the builder call.

Both estimators default to Euclidean. You only call with_metric when you want a different metric.

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:?}");

    // An order below 1 is not a metric. The builder rejects it before any work happens.
    let bad = KNN::<i32>::new(1)
        .unwrap()
        .with_metric(DistanceCalculationMetric::Minkowski(0.5));
    assert!(bad.is_err());
}

KMeans and MeanShift do not take a metric parameter. Both call squared_euclidean_distance_row directly, so both are Euclidean-only by construction. k-means minimizes the within-cluster squared L2 distance, by definition. Swapping the metric would break the centroid-update math, so there is no option to change it. DBSCAN is the estimator to use for non-Euclidean clustering.

Passing a metric to silhouette_score lets you evaluate a clustering under the same geometry you used to build it. For example, score a DBSCAN result that used Manhattan distance with DistanceCalculationMetric::Manhattan. This keeps the evaluation consistent with the clustering.

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

fn main() {
    // Two well-separated blobs.
    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 distances and the comparable-space optimization

squared_euclidean_distance_row exposes the squared-distance shortcut as a public function. The library also uses the same shortcut internally, through a private idea called comparable space. Every metric has a monotonic, root-free form: square for Euclidean, the power p for Minkowski, and the identity for Manhattan. This transform is monotonic on non-negative inputs.

Some decisions depend only on ordering. Examples include which point is nearer, whether a point falls inside a radius, and which point is the k-th closest. Any such decision can run in comparable space, and it never pays the cost of a root.

The internal kd-tree does exactly this. KNN and DBSCAN use it automatically, in low dimensions. It stores each candidate distance in comparable space. It prunes branches using a per-axis lower bound, in that same space. It converts a comparable-space value back to a true distance only for the few results it actually returns. within (see 6.1.2) is the public tip of this idea.

The practical lesson applies to your own code too. When you rank points or set a threshold, rather than report a distance to a person, stay in squared space. Comparing squared_euclidean_distance_row values is correct for deciding which point is closer. It is also strictly cheaper than comparing the square roots. Call .sqrt() only at the point where a real distance leaves your loop.

6.1.6. Worked example: a pairwise distance matrix

The kernels are all you need to build a full pairwise-distance matrix, for your own nearest-neighbor logic. Every metric here guarantees 2 structural facts: the matrix is symmetric, and its diagonal is zero. Compute each unordered pair once, then mirror the value across the diagonal, and leave the diagonal at zero. This halves the number of distance evaluations. Inside the hot double loop, work in squared space, and take the root once per entry. Skip the root entirely if you only need ordering downstream.

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();

    // Symmetric with a zero diagonal: fill only the upper triangle, then mirror.
    let mut dist = Array2::<f64>::zeros((n, n));
    for i in 0..n {
        for j in (i + 1)..n {
            // Rows pass by reference with no copy. The sqrt runs once per entry.
            let d = squared_euclidean_distance_row(&data.row(i), &data.row(j)).sqrt();
            dist[[i, j]] = d;
            dist[[j, i]] = d;
        }
    }

    // For one query, skip the matrix and scan instead:
    let query = data.row(0);
    let nearest = (1..n)
        .min_by(|&a, &b| {
            // Compare in squared space. The root is not needed to pick the minimum.
            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}");
}

The min_by half of the example is the real point. A full O(n^2) matrix is the wrong tool when you query only a few points. For a single nearest-neighbor lookup, scan once in O(n) and never touch a root. Build the full matrix only when a downstream algorithm consumes all of it, such as hierarchical clustering or an MDS embedding. Do not build it just to find one neighbor.

6.1.7. Performance, SIMD, and parallelism

Each kernel runs in a single pass, with no intermediate allocation. squared_euclidean_distance_row and manhattan_distance_row each fold one ndarray::Zip over the two inputs. minkowski_distance_row does the same, but adds one powf call per element, plus one final powf(1.0 / p) call. All 3 functions carry the #[inline] attribute.

Cost per call is linear in the dimensionality d. Euclidean and Manhattan cost one subtraction, plus one multiply or one absolute value, per element. Both are cheap. Minkowski costs one transcendental powf call per element, which makes it noticeably slower. Prefer Euclidean or Manhattan when either fits your need. Avoid Minkowski(2.0) or Minkowski(1.0), which compute the same numbers through the slower path.

These kernels contain no hand-written SIMD code, and there is no f32 path. The Zip loops are tight and branch-free, except in Minkowski. An optimizing compiler can autovectorize code of this shape. Nothing in the source forces vectorization, though, so do not assume a specific instruction set.

A single distance call is entirely serial, with no rayon call inside any kernel. This is the right choice: one row is not enough work to justify the cost of thread dispatch. Parallelism instead lives one level up, at the caller. silhouette_score splits its pairwise scan across the rayon pool, above a tunable element threshold. KNN offers a predict_parallel method, and DBSCAN parallelizes its neighborhood queries.

If your own pairwise-matrix loop is the bottleneck, parallelize its outer loop over rows with rayon yourself. The kernels are pure functions and are Send-safe, so this composes cleanly. For the broader picture of when parallelism pays off, and how to tune the gates, see 7.3. Performance Tuning and Parallelism. For the sibling numeric primitives this module ships alongside, see 6.2. Matrix Multiplication and 6.3. Parallel Reductions.