diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5d4734b5..3d0d158a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -87,6 +87,9 @@ jobs: - name: Check no_std with nalgebra and rand run: cargo check --target riscv32imac-unknown-none-elf --no-default-features --features nalgebra,rand --lib + - name: Check no_std with KDE + run: cargo check --target riscv32imac-unknown-none-elf --no-default-features --features kde --lib + features: needs: [clippy, fmt] runs-on: ubuntu-latest diff --git a/Cargo.lock.MSRV b/Cargo.lock.MSRV index d7585419..435d8e08 100644 --- a/Cargo.lock.MSRV +++ b/Cargo.lock.MSRV @@ -347,12 +347,12 @@ dependencies = [ [[package]] name = "kdtree" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f0a0e9f770b65bac9aad00f97a67ab5c5319effed07f6da385da3c2115e47ba" +version = "0.8.1" +source = "git+https://github.com/mrhooray/kdtree-rs?rev=db9af2f8e987c4f1c21251998f032b89a8bd2d52#db9af2f8e987c4f1c21251998f032b89a8bd2d52" dependencies = [ "num-traits", - "thiserror 1.0.69", + "serde", + "thiserror", ] [[package]] @@ -718,7 +718,7 @@ dependencies = [ "nalgebra", "num-traits", "rand", - "thiserror 2.0.19", + "thiserror", ] [[package]] @@ -743,33 +743,13 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - [[package]] name = "thiserror" version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ - "thiserror-impl 2.0.19", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "thiserror-impl", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 120e3bfa..61039884 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,11 +31,11 @@ required-features = ["rand", "std", "kde"] [features] default = ["std", "nalgebra", "rand"] -std = ["approx/std", "num-traits/std", "nalgebra?/std", "rand?/std"] +std = ["approx/std", "num-traits/std", "nalgebra?/std", "rand?/std", "kdtree?/std"] nalgebra = ["dep:nalgebra", "nalgebra/alloc", "nalgebra/libm"] rand = ["dep:rand", "nalgebra?/rand-no-std", "rand?/std_rng"] # kd-tree backed density estimation (src/density), implemented in terms of nalgebra vectors -kde = ["dep:kdtree", "nalgebra", "std"] +kde = ["dep:kdtree", "kdtree/libm", "nalgebra"] [dependencies] approx = { version = "0.5.0", default-features = false } @@ -53,8 +53,11 @@ optional = true default-features = false [dependencies.kdtree] -version = "0.7.0" +version = "0.8.1" +git = "https://github.com/mrhooray/kdtree-rs" +rev = "db9af2f8e987c4f1c21251998f032b89a8bd2d52" optional = true +default-features = false [dev-dependencies] criterion = "0.8" diff --git a/README.md b/README.md index 540f2642..a63ff5e8 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,13 @@ matrix-backed distributions without `std`, enable `nalgebra` directly: statrs = { version = "*", default-features = false, features = ["nalgebra"] } ``` +To enable kernel density estimation without `std`, enable `kde`: + +```toml +[dependencies] +statrs = { version = "*", default-features = false, features = ["kde"] } +``` + Heap-backed APIs use Rust's `alloc` crate. A `no_std` application that calls these APIs must provide and initialize a global allocator suitable for its target: diff --git a/src/density/kde.rs b/src/density/kde.rs index caa17720..50587900 100644 --- a/src/density/kde.rs +++ b/src/density/kde.rs @@ -1,7 +1,9 @@ use kdtree::distance::squared_euclidean; +#[cfg(not(feature = "std"))] +use num_traits::Float as _; use crate::{ - density::{Container, DensityError, nearest_neighbors}, + density::{Container, DensityError, nearest_neighbors, neighborhood_radius}, function::kernel::{Gaussian, Kernel}, }; @@ -30,7 +32,7 @@ where if neighbors.is_empty() { Err(DensityError::EmptyNeighborhood) } else { - let radius = neighbors.last().unwrap().sqrt(); // safe to unwrap here since `neighbors` is not empty + let radius = neighborhood_radius(&neighbors).unwrap(); let d = x.length() as i32; Ok((1. / (n_samples * radius.powi(d))) * samples diff --git a/src/density/knn.rs b/src/density/knn.rs index 1f58637d..1d6b476a 100644 --- a/src/density/knn.rs +++ b/src/density/knn.rs @@ -1,9 +1,11 @@ use super::Container; use crate::{ - density::{DensityError, nearest_neighbors}, + density::{DensityError, nearest_neighbors, neighborhood_radius}, function::gamma::gamma, }; use core::f64::consts::PI; +#[cfg(not(feature = "std"))] +use num_traits::Float as _; /// Computes the `k`-nearest neighbor density estimate for a given point `x` /// using the samples provided. @@ -30,7 +32,7 @@ where if neighbors.is_empty() { Err(DensityError::EmptyNeighborhood) } else { - let radius = neighbors.last().unwrap().sqrt(); + let radius = neighborhood_radius(&neighbors).unwrap(); let d = x.length() as f64; Ok((k / n_samples) * (gamma(d / 2. + 1.) / (PI.powf(d / 2.) * radius.powf(d)))) } diff --git a/src/density/mod.rs b/src/density/mod.rs index cfb51cbc..9bad855a 100644 --- a/src/density/mod.rs +++ b/src/density/mod.rs @@ -16,6 +16,8 @@ pub mod kde; pub mod knn; use alloc::vec::Vec; use kdtree::{ErrorKind, KdTree, distance::squared_euclidean}; +#[cfg(not(feature = "std"))] +use num_traits::Float as _; use thiserror::Error; /// Errors that can occur when estimating a density from a sample. @@ -79,6 +81,14 @@ impl_container!( ); pub type NearestNeighbors = (Vec, f64); +pub(crate) fn neighborhood_radius(neighbors: &[f64]) -> Option { + neighbors + .iter() + .copied() + .max_by(f64::total_cmp) + .map(f64::sqrt) +} + pub(crate) fn nearest_neighbors( x: &X, samples: &S, @@ -118,6 +128,11 @@ mod tests { use super::*; + #[test] + fn neighborhood_radius_uses_farthest_distance() { + assert_eq!(neighborhood_radius(&[4.0, 1.0, 9.0, 2.0]), Some(3.0)); + } + #[test] fn test_vec_container() { let v1 = vec![1.0, 2.0, 3.0]; diff --git a/tests/no_std/Cargo.lock b/tests/no_std/Cargo.lock index b87a7466..01f87fe3 100644 --- a/tests/no_std/Cargo.lock +++ b/tests/no_std/Cargo.lock @@ -70,6 +70,15 @@ dependencies = [ "libm", ] +[[package]] +name = "kdtree" +version = "0.8.1" +source = "git+https://github.com/mrhooray/kdtree-rs?rev=db9af2f8e987c4f1c21251998f032b89a8bd2d52#db9af2f8e987c4f1c21251998f032b89a8bd2d52" +dependencies = [ + "num-traits", + "thiserror", +] + [[package]] name = "libc" version = "0.2.186" @@ -172,6 +181,7 @@ name = "statrs" version = "0.19.1" dependencies = [ "approx", + "kdtree", "nalgebra", "num-traits", "thiserror", diff --git a/tests/no_std/Cargo.toml b/tests/no_std/Cargo.toml index a8ea7b67..322dd108 100644 --- a/tests/no_std/Cargo.toml +++ b/tests/no_std/Cargo.toml @@ -12,7 +12,7 @@ crate-type = ["cdylib"] [dependencies] dlmalloc = { version = "0.2.14", features = ["global"] } -statrs = { path = "../..", default-features = false, features = ["nalgebra"] } +statrs = { path = "../..", default-features = false, features = ["kde"] } [profile.dev] panic = "abort" diff --git a/tests/no_std/src/lib.rs b/tests/no_std/src/lib.rs index 2b83b8bb..ed602602 100644 --- a/tests/no_std/src/lib.rs +++ b/tests/no_std/src/lib.rs @@ -3,6 +3,7 @@ extern crate alloc; use alloc::vec; +use statrs::density::{kde::kde_pdf, knn::knn_pdf}; use statrs::distribution::{Categorical, Continuous, Empirical, Multinomial, MultivariateNormal}; use statrs::generate::log_spaced; use statrs::statistics::{Data, Distribution, MeanN, OrderStatistics, RankTieBreaker}; @@ -22,6 +23,10 @@ fn assert_close(actual: f64, expected: f64, tolerance: f64) { #[unsafe(no_mangle)] pub extern "C" fn verify() { + let kde_samples = vec![[-1.0], [0.0], [1.0]]; + assert!(kde_pdf(&[0.0], &kde_samples, Some(1.0)).unwrap() > 0.0); + assert!(knn_pdf(&[0.0], &kde_samples, Some(1.0)).unwrap() > 0.0); + let categorical = Categorical::new(&[1.0, 2.0, 3.0]).unwrap(); assert_close(categorical.mean().unwrap(), 4.0 / 3.0, 1e-12);