diff --git a/CHANGELOG.md b/CHANGELOG.md
index 054d6833..62b0791a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [0.6.11]
+### Fixed
+- `algorithm/neighbour/cosinepair.rs`: `CosinePair::query_row_top_k` now returns exact nearest neighbours whenever `approximate` is `false` (the default). Previously the query always sampled only `top_k` evenly strided candidate rows without documentation, and the bounded candidate heap evicted its closest entry, so the method could return the farthest of the sampled rows (#442). Strided sampling is now gated behind `CosinePairParameters { approximate: true, .. }` and is documented as approximate.
+- `algorithm/neighbour/cosinepair.rs`: `CosinePair` construction now evaluates each unordered row pair once (symmetric half-scan), precomputes row norms once in O(n·d), and scores pairs through zero-copy row views instead of materialising two `Vec`s per pair (#442). Distances are unchanged (bit-identical formula and operation order as `Cosine::new().distance(...)`); measured build time drops ~3x on a 1500x64 input. Construction remains Theta(n^2) dot products — `top_k` does not make it sub-quadratic; module and method docs now state both facts.
+
+### Changed
+- **Breaking**: `CosinePair` gained a private `row_norms` field holding the precomputed row norms. Construct the structure through `new` / `with_top_k` / `with_parameters` instead of struct literals.
+
## [0.6.10]
### Fixed
- `model_selection`: pinned the `KFold` seed in the `test_cross_val_predict_knn` and `test_cross_validate_knn` unit tests. Under `--all-features` (`std_rand`) an unseeded `KFold` draws OS entropy, so each CI run shuffled the folds differently; a sweep of 20 000 seeds showed 0.17% of shuffles violate the `MAE < 10.0` assertion (worst 12.81) and 0.01% violate `train_score < test_score`, making CI flaky. Library behaviour is unchanged; the entropy-seeded path stays covered by the `rand_custom` tests.
diff --git a/Cargo.toml b/Cargo.toml
index ef3cde97..b36a8c96 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -2,7 +2,7 @@
name = "smartcore"
description = "Machine Learning in Rust."
homepage = "https://smartcorelib.github.io/"
-version = "0.6.10"
+version = "0.6.11"
authors = ["smartcore Developers"]
edition = "2024"
rust-version = "1.85"
diff --git a/src/algorithm/neighbour/cosinepair.rs b/src/algorithm/neighbour/cosinepair.rs
index 5ab8f3a0..8ab8cb9e 100644
--- a/src/algorithm/neighbour/cosinepair.rs
+++ b/src/algorithm/neighbour/cosinepair.rs
@@ -1,6 +1,10 @@
///
/// ### CosinePair: Data-structure for the dynamic closest-pair problem.
///
+/// The structure keeps, for every row, the cosine-distance closest neighbour
+/// found by an exact symmetric half-scan. Construction costs Theta(n^2) dot
+/// products; `top_k` does not make it sub-quadratic.
+///
/// Reference:
/// Eppstein, David: Fast hierarchical clustering and other applications of
/// dynamic closest pairs. Journal of Experimental Algorithmics 5 (2000) 1.
@@ -25,24 +29,24 @@
///
use ordered_float::{FloatCore, OrderedFloat};
-use std::cmp::Reverse;
use std::collections::{BinaryHeap, HashMap};
-use num::Bounded;
-
use crate::error::{Failed, FailedError};
-use crate::linalg::basic::arrays::{Array1, Array2};
-use crate::metrics::distance::cosine::Cosine;
-use crate::metrics::distance::{Distance, PairwiseDistance};
+use crate::linalg::basic::arrays::{Array2, ArrayView1};
+use crate::metrics::distance::PairwiseDistance;
use crate::numbers::floatnum::FloatNumber;
use crate::numbers::realnum::RealNumber;
/// Parameters for CosinePair construction
#[derive(Debug, Clone)]
pub struct CosinePairParameters {
- /// Maximum number of neighbors to consider per point (default: all points)
+ /// Maximum number of neighbours returned by
+ /// [`CosinePair::query_row_top_k`] (default: all points). The build stays
+ /// an exact Theta(n^2) scan regardless of this value.
pub top_k: Option,
- /// Whether to use approximate nearest neighbor search
+ /// When `true`, queries score only `top_k` evenly strided candidate rows
+ /// instead of every row, so results are approximate. When `false`
+ /// (default), queries are exact.
pub approximate: bool,
}
@@ -63,6 +67,13 @@ impl Default for CosinePairParameters {
///
/// affinity used is Cosine as it is the most used
///
+/// Construction performs a symmetric half-scan over all unordered row pairs,
+/// so it costs Theta(n^2) dot products over zero-copy row views, with the
+/// Euclidean norm of every row precomputed once in O(n * d). The `top_k`
+/// parameter bounds the number of neighbours kept per row by
+/// [`CosinePair::query_row_top_k`]; it does not make the construction
+/// sub-quadratic.
+///
#[derive(Debug, Clone)]
pub struct CosinePair<'a, T: RealNumber + FloatNumber, M: Array2> {
/// initial matrix
@@ -71,6 +82,8 @@ pub struct CosinePair<'a, T: RealNumber + FloatNumber, M: Array2> {
pub distances: HashMap>,
/// conga line used to keep track of the closest pair
pub neighbours: Vec,
+ /// Euclidean norm (L2) of each row, computed once during construction
+ row_norms: Vec,
/// parameters used during construction
pub parameters: CosinePairParameters,
}
@@ -81,7 +94,10 @@ impl<'a, T: RealNumber + FloatNumber + FloatCore, M: Array2> CosinePair<'a, T
Self::with_parameters(m, CosinePairParameters::default())
}
- /// Constructor with top-k limiting for faster performance
+ /// Constructor that caps the number of neighbours returned by
+ /// [`CosinePair::query_row_top_k`] at `top_k`. Queries stay exact; set
+ /// `approximate` through [`CosinePair::with_parameters`] to score only
+ /// strided candidates.
pub fn with_top_k(m: &'a M, top_k: usize) -> Result {
Self::with_parameters(
m,
@@ -101,10 +117,13 @@ impl<'a, T: RealNumber + FloatNumber + FloatCore, M: Array2> CosinePair<'a, T
));
}
+ let row_norms = (0..m.shape().0).map(|i| m.get_row(i).norm2()).collect();
+
let mut init = Self {
samples: m,
distances: HashMap::with_capacity(m.shape().0),
neighbours: Vec::with_capacity(m.shape().0),
+ row_norms,
parameters,
};
init.init();
@@ -121,71 +140,89 @@ impl<'a, T: RealNumber + FloatNumber + FloatCore, M: Array2> CosinePair<'a, T
ordered.into_inner()
}
- /// Optimized initialization with top-k neighbor limiting
+ /// Cosine distance between two rows seen as zero-copy views, reusing the
+ /// norms precomputed at construction time. Mirrors
+ /// `Cosine::new().distance(...)`: a zero-magnitude row yields the
+ /// sentinel distance `1 - f64::MIN`.
+ fn cosine_distance_with_norms(
+ row_i: &dyn ArrayView1,
+ norm_i: f64,
+ row_j: &dyn ArrayView1,
+ norm_j: f64,
+ ) -> T {
+ let similarity = if norm_i == 0.0 || norm_j == 0.0 {
+ f64::MIN
+ } else {
+ row_i.dot(row_j).to_f64().unwrap() / (norm_i * norm_j)
+ };
+ T::from(1.0 - similarity).unwrap()
+ }
+
+ /// Cosine distance between two rows of the sample matrix
+ fn row_distance(&self, i: usize, j: usize) -> T {
+ let row_i = self.samples.get_row(i);
+ let row_j = self.samples.get_row(j);
+ Self::cosine_distance_with_norms(
+ row_i.as_ref(),
+ self.row_norms[i],
+ row_j.as_ref(),
+ self.row_norms[j],
+ )
+ }
+
+ /// Exact closest-neighbour search per row.
+ ///
+ /// Cosine distance is symmetric, so each unordered pair `(i, j)` with
+ /// `i < j` is evaluated once and updates the running best candidate of
+ /// both rows. This halves the Theta(n^2) distance evaluations and avoids
+ /// all per-pair allocations by operating on row views.
fn init(&mut self) {
let len = self.samples.shape().0;
- let max_neighbors: usize = self.parameters.top_k.unwrap_or(len - 1).min(len - 1);
let mut distances = HashMap::with_capacity(len);
let mut neighbours = Vec::with_capacity(len);
neighbours.extend(0..len);
- // Initialize with max distances
+ // best[i] = Some((distance, neighbour index)) of the closest row to i
+ // found so far; `None` until the first candidate arrives
+ let mut best: Vec