Merge commit 'd803bfe2b1fe7f5e219e50ac20d6801a0a58ac75' as 'vendor/ruvector'
This commit is contained in:
357
vendor/ruvector/crates/ruvector-math/src/spectral/chebyshev.rs
vendored
Normal file
357
vendor/ruvector/crates/ruvector-math/src/spectral/chebyshev.rs
vendored
Normal file
@@ -0,0 +1,357 @@
|
||||
//! Chebyshev Polynomials
|
||||
//!
|
||||
//! Efficient polynomial approximation using Chebyshev basis.
|
||||
//! Key for matrix function approximation without eigendecomposition.
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
/// Chebyshev polynomial of the first kind
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChebyshevPolynomial {
|
||||
/// Polynomial degree
|
||||
pub degree: usize,
|
||||
}
|
||||
|
||||
impl ChebyshevPolynomial {
|
||||
/// Create Chebyshev polynomial T_n
|
||||
pub fn new(degree: usize) -> Self {
|
||||
Self { degree }
|
||||
}
|
||||
|
||||
/// Evaluate T_n(x) using recurrence
|
||||
/// T_0(x) = 1, T_1(x) = x, T_{n+1}(x) = 2x·T_n(x) - T_{n-1}(x)
|
||||
pub fn eval(&self, x: f64) -> f64 {
|
||||
if self.degree == 0 {
|
||||
return 1.0;
|
||||
}
|
||||
if self.degree == 1 {
|
||||
return x;
|
||||
}
|
||||
|
||||
let mut t_prev = 1.0;
|
||||
let mut t_curr = x;
|
||||
|
||||
for _ in 2..=self.degree {
|
||||
let t_next = 2.0 * x * t_curr - t_prev;
|
||||
t_prev = t_curr;
|
||||
t_curr = t_next;
|
||||
}
|
||||
|
||||
t_curr
|
||||
}
|
||||
|
||||
/// Evaluate all Chebyshev polynomials T_0(x) through T_n(x)
|
||||
pub fn eval_all(x: f64, max_degree: usize) -> Vec<f64> {
|
||||
if max_degree == 0 {
|
||||
return vec![1.0];
|
||||
}
|
||||
|
||||
let mut result = Vec::with_capacity(max_degree + 1);
|
||||
result.push(1.0);
|
||||
result.push(x);
|
||||
|
||||
for k in 2..=max_degree {
|
||||
let t_k = 2.0 * x * result[k - 1] - result[k - 2];
|
||||
result.push(t_k);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Chebyshev nodes for interpolation: x_k = cos((2k+1)π/(2n))
|
||||
pub fn nodes(n: usize) -> Vec<f64> {
|
||||
(0..n)
|
||||
.map(|k| ((2 * k + 1) as f64 * PI / (2 * n) as f64).cos())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Derivative: T'_n(x) = n * U_{n-1}(x) where U is Chebyshev of second kind
|
||||
pub fn derivative(&self, x: f64) -> f64 {
|
||||
if self.degree == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
if self.degree == 1 {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
// Use: T'_n(x) = n * U_{n-1}(x)
|
||||
// where U_0 = 1, U_1 = 2x, U_{n+1} = 2x*U_n - U_{n-1}
|
||||
let n = self.degree;
|
||||
let mut u_prev = 1.0;
|
||||
let mut u_curr = 2.0 * x;
|
||||
|
||||
for _ in 2..n {
|
||||
let u_next = 2.0 * x * u_curr - u_prev;
|
||||
u_prev = u_curr;
|
||||
u_curr = u_next;
|
||||
}
|
||||
|
||||
n as f64 * if n == 1 { u_prev } else { u_curr }
|
||||
}
|
||||
}
|
||||
|
||||
/// Chebyshev expansion of a function
|
||||
/// f(x) ≈ Σ c_k T_k(x)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChebyshevExpansion {
|
||||
/// Chebyshev coefficients c_k
|
||||
pub coefficients: Vec<f64>,
|
||||
}
|
||||
|
||||
impl ChebyshevExpansion {
|
||||
/// Create from coefficients
|
||||
pub fn new(coefficients: Vec<f64>) -> Self {
|
||||
Self { coefficients }
|
||||
}
|
||||
|
||||
/// Approximate function on [-1, 1] using n+1 Chebyshev nodes
|
||||
pub fn from_function<F: Fn(f64) -> f64>(f: F, degree: usize) -> Self {
|
||||
let n = degree + 1;
|
||||
let nodes = ChebyshevPolynomial::nodes(n);
|
||||
|
||||
// Evaluate function at nodes
|
||||
let f_values: Vec<f64> = nodes.iter().map(|&x| f(x)).collect();
|
||||
|
||||
// Compute coefficients via DCT-like formula
|
||||
let mut coefficients = Vec::with_capacity(n);
|
||||
|
||||
for k in 0..n {
|
||||
let mut c_k = 0.0;
|
||||
for (j, &f_j) in f_values.iter().enumerate() {
|
||||
let t_k_at_node = ChebyshevPolynomial::new(k).eval(nodes[j]);
|
||||
c_k += f_j * t_k_at_node;
|
||||
}
|
||||
c_k *= 2.0 / n as f64;
|
||||
if k == 0 {
|
||||
c_k *= 0.5;
|
||||
}
|
||||
coefficients.push(c_k);
|
||||
}
|
||||
|
||||
Self { coefficients }
|
||||
}
|
||||
|
||||
/// Approximate exp(-t*x) for heat kernel (x in [0, 2])
|
||||
/// Maps [0, 2] to [-1, 1] via x' = x - 1
|
||||
pub fn heat_kernel(t: f64, degree: usize) -> Self {
|
||||
Self::from_function(
|
||||
|x| {
|
||||
let exponent = -t * (x + 1.0);
|
||||
// Clamp to prevent overflow (exp(709) ≈ max f64, exp(-745) ≈ 0)
|
||||
let clamped = exponent.clamp(-700.0, 700.0);
|
||||
clamped.exp()
|
||||
},
|
||||
degree,
|
||||
)
|
||||
}
|
||||
|
||||
/// Approximate low-pass filter: 1 if λ < cutoff, 0 otherwise
|
||||
/// Smooth transition via sigmoid-like function
|
||||
pub fn low_pass(cutoff: f64, degree: usize) -> Self {
|
||||
let steepness = 10.0 / cutoff.max(0.1);
|
||||
Self::from_function(
|
||||
|x| {
|
||||
let lambda = (x + 1.0) / 2.0 * 2.0; // Map [-1,1] to [0,2]
|
||||
let exponent = steepness * (lambda - cutoff);
|
||||
// Clamp to prevent overflow
|
||||
let clamped = exponent.clamp(-700.0, 700.0);
|
||||
1.0 / (1.0 + clamped.exp())
|
||||
},
|
||||
degree,
|
||||
)
|
||||
}
|
||||
|
||||
/// Evaluate expansion at point x using Clenshaw recurrence
|
||||
/// More numerically stable than direct summation
|
||||
pub fn eval(&self, x: f64) -> f64 {
|
||||
if self.coefficients.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
if self.coefficients.len() == 1 {
|
||||
return self.coefficients[0];
|
||||
}
|
||||
|
||||
// Clenshaw recurrence
|
||||
let n = self.coefficients.len();
|
||||
let mut b_next = 0.0;
|
||||
let mut b_curr = 0.0;
|
||||
|
||||
for k in (1..n).rev() {
|
||||
let b_prev = 2.0 * x * b_curr - b_next + self.coefficients[k];
|
||||
b_next = b_curr;
|
||||
b_curr = b_prev;
|
||||
}
|
||||
|
||||
self.coefficients[0] + x * b_curr - b_next
|
||||
}
|
||||
|
||||
/// Evaluate expansion on vector: apply filter to each component
|
||||
pub fn eval_vector(&self, x: &[f64]) -> Vec<f64> {
|
||||
x.iter().map(|&xi| self.eval(xi)).collect()
|
||||
}
|
||||
|
||||
/// Degree of expansion
|
||||
pub fn degree(&self) -> usize {
|
||||
self.coefficients.len().saturating_sub(1)
|
||||
}
|
||||
|
||||
/// Truncate to lower degree
|
||||
pub fn truncate(&self, new_degree: usize) -> Self {
|
||||
let n = (new_degree + 1).min(self.coefficients.len());
|
||||
Self {
|
||||
coefficients: self.coefficients[..n].to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add two expansions
|
||||
pub fn add(&self, other: &Self) -> Self {
|
||||
let max_len = self.coefficients.len().max(other.coefficients.len());
|
||||
let mut coefficients = vec![0.0; max_len];
|
||||
|
||||
for (i, &c) in self.coefficients.iter().enumerate() {
|
||||
coefficients[i] += c;
|
||||
}
|
||||
for (i, &c) in other.coefficients.iter().enumerate() {
|
||||
coefficients[i] += c;
|
||||
}
|
||||
|
||||
Self { coefficients }
|
||||
}
|
||||
|
||||
/// Scale by constant
|
||||
pub fn scale(&self, s: f64) -> Self {
|
||||
Self {
|
||||
coefficients: self.coefficients.iter().map(|&c| c * s).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Derivative expansion
|
||||
/// d/dx Σ c_k T_k(x) = Σ c'_k T_k(x)
|
||||
pub fn derivative(&self) -> Self {
|
||||
let n = self.coefficients.len();
|
||||
if n <= 1 {
|
||||
return Self::new(vec![0.0]);
|
||||
}
|
||||
|
||||
let mut d_coeffs = vec![0.0; n - 1];
|
||||
|
||||
// Backward recurrence for derivative coefficients
|
||||
for k in (0..n - 1).rev() {
|
||||
d_coeffs[k] = 2.0 * (k + 1) as f64 * self.coefficients[k + 1];
|
||||
if k + 2 < n {
|
||||
d_coeffs[k] += if k == 0 { 0.0 } else { d_coeffs[k + 2] };
|
||||
}
|
||||
}
|
||||
|
||||
// First coefficient needs halving
|
||||
if !d_coeffs.is_empty() {
|
||||
d_coeffs[0] *= 0.5;
|
||||
}
|
||||
|
||||
Self {
|
||||
coefficients: d_coeffs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_chebyshev_polynomial() {
|
||||
// T_0(x) = 1
|
||||
assert!((ChebyshevPolynomial::new(0).eval(0.5) - 1.0).abs() < 1e-10);
|
||||
|
||||
// T_1(x) = x
|
||||
assert!((ChebyshevPolynomial::new(1).eval(0.5) - 0.5).abs() < 1e-10);
|
||||
|
||||
// T_2(x) = 2x² - 1
|
||||
let t2_at_half = 2.0 * 0.5 * 0.5 - 1.0;
|
||||
assert!((ChebyshevPolynomial::new(2).eval(0.5) - t2_at_half).abs() < 1e-10);
|
||||
|
||||
// T_3(x) = 4x³ - 3x
|
||||
let t3_at_half = 4.0 * 0.5_f64.powi(3) - 3.0 * 0.5;
|
||||
assert!((ChebyshevPolynomial::new(3).eval(0.5) - t3_at_half).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_eval_all() {
|
||||
let x = 0.5;
|
||||
let all = ChebyshevPolynomial::eval_all(x, 5);
|
||||
|
||||
assert_eq!(all.len(), 6);
|
||||
for (k, &t_k) in all.iter().enumerate() {
|
||||
let expected = ChebyshevPolynomial::new(k).eval(x);
|
||||
assert!((t_k - expected).abs() < 1e-10);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chebyshev_nodes() {
|
||||
let nodes = ChebyshevPolynomial::nodes(4);
|
||||
assert_eq!(nodes.len(), 4);
|
||||
|
||||
// All nodes should be in [-1, 1]
|
||||
for &x in &nodes {
|
||||
assert!(x >= -1.0 && x <= 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expansion_constant() {
|
||||
let expansion = ChebyshevExpansion::from_function(|_| 5.0, 3);
|
||||
|
||||
// Should approximate 5.0 everywhere
|
||||
for x in [-0.9, -0.5, 0.0, 0.5, 0.9] {
|
||||
assert!((expansion.eval(x) - 5.0).abs() < 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expansion_linear() {
|
||||
let expansion = ChebyshevExpansion::from_function(|x| 2.0 * x + 1.0, 5);
|
||||
|
||||
for x in [-0.8, -0.3, 0.0, 0.4, 0.7] {
|
||||
let expected = 2.0 * x + 1.0;
|
||||
assert!(
|
||||
(expansion.eval(x) - expected).abs() < 0.1,
|
||||
"x={}, expected={}, got={}",
|
||||
x,
|
||||
expected,
|
||||
expansion.eval(x)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_heat_kernel() {
|
||||
let heat = ChebyshevExpansion::heat_kernel(1.0, 10);
|
||||
|
||||
// At x = -1 (λ = 0): exp(0) = 1
|
||||
let at_zero = heat.eval(-1.0);
|
||||
assert!((at_zero - 1.0).abs() < 0.1);
|
||||
|
||||
// At x = 1 (λ = 2): exp(-2) ≈ 0.135
|
||||
let at_two = heat.eval(1.0);
|
||||
assert!((at_two - (-2.0_f64).exp()).abs() < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clenshaw_stability() {
|
||||
// High degree expansion should still be numerically stable
|
||||
let expansion = ChebyshevExpansion::from_function(|x| x.sin(), 20);
|
||||
|
||||
for x in [-0.9, 0.0, 0.9] {
|
||||
let approx = expansion.eval(x);
|
||||
let exact = x.sin();
|
||||
assert!(
|
||||
(approx - exact).abs() < 0.01,
|
||||
"x={}, approx={}, exact={}",
|
||||
x,
|
||||
approx,
|
||||
exact
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
441
vendor/ruvector/crates/ruvector-math/src/spectral/clustering.rs
vendored
Normal file
441
vendor/ruvector/crates/ruvector-math/src/spectral/clustering.rs
vendored
Normal file
@@ -0,0 +1,441 @@
|
||||
//! Spectral Clustering
|
||||
//!
|
||||
//! Graph partitioning using spectral methods.
|
||||
//! Efficient approximation via Chebyshev polynomials.
|
||||
|
||||
use super::ScaledLaplacian;
|
||||
|
||||
/// Spectral clustering configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClusteringConfig {
|
||||
/// Number of clusters
|
||||
pub k: usize,
|
||||
/// Number of eigenvectors to use
|
||||
pub num_eigenvectors: usize,
|
||||
/// Power iteration steps for eigenvector approximation
|
||||
pub power_iters: usize,
|
||||
/// K-means iterations
|
||||
pub kmeans_iters: usize,
|
||||
/// Random seed
|
||||
pub seed: u64,
|
||||
}
|
||||
|
||||
impl Default for ClusteringConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
k: 2,
|
||||
num_eigenvectors: 10,
|
||||
power_iters: 50,
|
||||
kmeans_iters: 20,
|
||||
seed: 42,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spectral clustering result
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClusteringResult {
|
||||
/// Cluster assignment for each vertex
|
||||
pub assignments: Vec<usize>,
|
||||
/// Eigenvector embedding (n × k)
|
||||
pub embedding: Vec<Vec<f64>>,
|
||||
/// Number of clusters
|
||||
pub k: usize,
|
||||
}
|
||||
|
||||
impl ClusteringResult {
|
||||
/// Get vertices in cluster c
|
||||
pub fn cluster(&self, c: usize) -> Vec<usize> {
|
||||
self.assignments
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, &a)| a == c)
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Cluster sizes
|
||||
pub fn cluster_sizes(&self) -> Vec<usize> {
|
||||
let mut sizes = vec![0; self.k];
|
||||
for &a in &self.assignments {
|
||||
if a < self.k {
|
||||
sizes[a] += 1;
|
||||
}
|
||||
}
|
||||
sizes
|
||||
}
|
||||
}
|
||||
|
||||
/// Spectral clustering
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SpectralClustering {
|
||||
/// Configuration
|
||||
config: ClusteringConfig,
|
||||
}
|
||||
|
||||
impl SpectralClustering {
|
||||
/// Create with configuration
|
||||
pub fn new(config: ClusteringConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
/// Create with just number of clusters
|
||||
pub fn with_k(k: usize) -> Self {
|
||||
Self::new(ClusteringConfig {
|
||||
k,
|
||||
num_eigenvectors: k,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Cluster graph using normalized Laplacian eigenvectors
|
||||
pub fn cluster(&self, laplacian: &ScaledLaplacian) -> ClusteringResult {
|
||||
let n = laplacian.n;
|
||||
let k = self.config.k.min(n);
|
||||
let num_eig = self.config.num_eigenvectors.min(n);
|
||||
|
||||
// Compute approximate eigenvectors of Laplacian
|
||||
// We want the k smallest eigenvalues (smoothest eigenvectors)
|
||||
// Use inverse power method on shifted Laplacian
|
||||
let embedding = self.compute_embedding(laplacian, num_eig);
|
||||
|
||||
// Run k-means on embedding
|
||||
let assignments = self.kmeans(&embedding, k);
|
||||
|
||||
ClusteringResult {
|
||||
assignments,
|
||||
embedding,
|
||||
k,
|
||||
}
|
||||
}
|
||||
|
||||
/// Cluster using Fiedler vector (k=2)
|
||||
pub fn bipartition(&self, laplacian: &ScaledLaplacian) -> ClusteringResult {
|
||||
let n = laplacian.n;
|
||||
|
||||
// Compute Fiedler vector (second smallest eigenvector)
|
||||
let fiedler = self.compute_fiedler(laplacian);
|
||||
|
||||
// Partition by sign
|
||||
let assignments: Vec<usize> = fiedler
|
||||
.iter()
|
||||
.map(|&v| if v >= 0.0 { 0 } else { 1 })
|
||||
.collect();
|
||||
|
||||
ClusteringResult {
|
||||
assignments,
|
||||
embedding: vec![fiedler],
|
||||
k: 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute spectral embedding (k smallest non-trivial eigenvectors)
|
||||
fn compute_embedding(&self, laplacian: &ScaledLaplacian, k: usize) -> Vec<Vec<f64>> {
|
||||
let n = laplacian.n;
|
||||
if k == 0 || n == 0 {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
// Initialize random vectors
|
||||
let mut vectors: Vec<Vec<f64>> = (0..k)
|
||||
.map(|i| {
|
||||
(0..n)
|
||||
.map(|j| {
|
||||
let x = ((j * 2654435769 + i * 1103515245 + self.config.seed as usize)
|
||||
as f64
|
||||
/ 4294967296.0)
|
||||
* 2.0
|
||||
- 1.0;
|
||||
x
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Power iteration to find smallest eigenvectors
|
||||
// We use (I - L_scaled) which has largest eigenvalue where L_scaled has smallest
|
||||
for _ in 0..self.config.power_iters {
|
||||
for i in 0..k {
|
||||
// Apply (I - L_scaled) = (2I - L)/λ_max approximately
|
||||
// Simpler: just use deflated power iteration on L for smallest
|
||||
let mut y = vec![0.0; n];
|
||||
let lx = laplacian.apply(&vectors[i]);
|
||||
|
||||
// We want small eigenvalues, so use (λ_max*I - L)
|
||||
let shift = 2.0; // Approximate max eigenvalue of scaled Laplacian
|
||||
for j in 0..n {
|
||||
y[j] = shift * vectors[i][j] - lx[j];
|
||||
}
|
||||
|
||||
// Orthogonalize against previous vectors and constant vector
|
||||
// First, remove constant component (eigenvalue 0)
|
||||
let mean: f64 = y.iter().sum::<f64>() / n as f64;
|
||||
for j in 0..n {
|
||||
y[j] -= mean;
|
||||
}
|
||||
|
||||
// Then orthogonalize against previous eigenvectors
|
||||
for prev in 0..i {
|
||||
let dot: f64 = y.iter().zip(vectors[prev].iter()).map(|(a, b)| a * b).sum();
|
||||
for j in 0..n {
|
||||
y[j] -= dot * vectors[prev][j];
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize
|
||||
let norm: f64 = y.iter().map(|x| x * x).sum::<f64>().sqrt();
|
||||
if norm > 1e-15 {
|
||||
for j in 0..n {
|
||||
y[j] /= norm;
|
||||
}
|
||||
}
|
||||
|
||||
vectors[i] = y;
|
||||
}
|
||||
}
|
||||
|
||||
vectors
|
||||
}
|
||||
|
||||
/// Compute Fiedler vector (second smallest eigenvector)
|
||||
fn compute_fiedler(&self, laplacian: &ScaledLaplacian) -> Vec<f64> {
|
||||
let embedding = self.compute_embedding(laplacian, 1);
|
||||
if embedding.is_empty() {
|
||||
return vec![0.0; laplacian.n];
|
||||
}
|
||||
embedding[0].clone()
|
||||
}
|
||||
|
||||
/// K-means clustering on embedding
|
||||
fn kmeans(&self, embedding: &[Vec<f64>], k: usize) -> Vec<usize> {
|
||||
if embedding.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let n = embedding[0].len();
|
||||
let dim = embedding.len();
|
||||
|
||||
if n == 0 || k == 0 {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
// Initialize centroids (k-means++ style)
|
||||
let mut centroids: Vec<Vec<f64>> = Vec::with_capacity(k);
|
||||
|
||||
// First centroid: random point
|
||||
let first = (self.config.seed as usize) % n;
|
||||
centroids.push((0..dim).map(|d| embedding[d][first]).collect());
|
||||
|
||||
// Remaining centroids: proportional to squared distance
|
||||
for _ in 1..k {
|
||||
let mut distances: Vec<f64> = (0..n)
|
||||
.map(|i| {
|
||||
centroids
|
||||
.iter()
|
||||
.map(|c| {
|
||||
(0..dim)
|
||||
.map(|d| (embedding[d][i] - c[d]).powi(2))
|
||||
.sum::<f64>()
|
||||
})
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let total: f64 = distances.iter().sum();
|
||||
if total > 0.0 {
|
||||
let threshold = (self.config.seed as f64 / 4294967296.0) * total;
|
||||
let mut cumsum = 0.0;
|
||||
let mut chosen = 0;
|
||||
for (i, &d) in distances.iter().enumerate() {
|
||||
cumsum += d;
|
||||
if cumsum >= threshold {
|
||||
chosen = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
centroids.push((0..dim).map(|d| embedding[d][chosen]).collect());
|
||||
} else {
|
||||
// Degenerate case
|
||||
centroids.push(vec![0.0; dim]);
|
||||
}
|
||||
}
|
||||
|
||||
// K-means iterations
|
||||
let mut assignments = vec![0; n];
|
||||
|
||||
for _ in 0..self.config.kmeans_iters {
|
||||
// Assign points to nearest centroid
|
||||
for i in 0..n {
|
||||
let mut best_cluster = 0;
|
||||
let mut best_dist = f64::INFINITY;
|
||||
|
||||
for (c, centroid) in centroids.iter().enumerate() {
|
||||
let dist: f64 = (0..dim)
|
||||
.map(|d| (embedding[d][i] - centroid[d]).powi(2))
|
||||
.sum();
|
||||
|
||||
if dist < best_dist {
|
||||
best_dist = dist;
|
||||
best_cluster = c;
|
||||
}
|
||||
}
|
||||
|
||||
assignments[i] = best_cluster;
|
||||
}
|
||||
|
||||
// Update centroids
|
||||
let mut counts = vec![0usize; k];
|
||||
for centroid in centroids.iter_mut() {
|
||||
for v in centroid.iter_mut() {
|
||||
*v = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
for (i, &c) in assignments.iter().enumerate() {
|
||||
counts[c] += 1;
|
||||
for d in 0..dim {
|
||||
centroids[c][d] += embedding[d][i];
|
||||
}
|
||||
}
|
||||
|
||||
for (c, centroid) in centroids.iter_mut().enumerate() {
|
||||
if counts[c] > 0 {
|
||||
for v in centroid.iter_mut() {
|
||||
*v /= counts[c] as f64;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assignments
|
||||
}
|
||||
|
||||
/// Compute normalized cut value for a bipartition
|
||||
pub fn normalized_cut(&self, laplacian: &ScaledLaplacian, partition: &[bool]) -> f64 {
|
||||
let n = laplacian.n;
|
||||
if n == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Compute cut and volumes
|
||||
let mut cut = 0.0;
|
||||
let mut vol_a = 0.0;
|
||||
let mut vol_b = 0.0;
|
||||
|
||||
// For each entry in Laplacian
|
||||
for &(i, j, v) in &laplacian.entries {
|
||||
if i < n && j < n && i != j {
|
||||
// This is an edge (negative Laplacian entry)
|
||||
let w = -v; // Edge weight
|
||||
if w > 0.0 && partition[i] != partition[j] {
|
||||
cut += w;
|
||||
}
|
||||
}
|
||||
if i == j && i < n {
|
||||
// Diagonal = degree
|
||||
if partition[i] {
|
||||
vol_a += v;
|
||||
} else {
|
||||
vol_b += v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NCut = cut/vol(A) + cut/vol(B)
|
||||
let ncut = if vol_a > 0.0 { cut / vol_a } else { 0.0 }
|
||||
+ if vol_b > 0.0 { cut / vol_b } else { 0.0 };
|
||||
|
||||
ncut
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn two_cliques_graph() -> ScaledLaplacian {
|
||||
// Two cliques of size 3 connected by one edge
|
||||
let edges = vec![
|
||||
// Clique 1
|
||||
(0, 1, 1.0),
|
||||
(0, 2, 1.0),
|
||||
(1, 2, 1.0),
|
||||
// Clique 2
|
||||
(3, 4, 1.0),
|
||||
(3, 5, 1.0),
|
||||
(4, 5, 1.0),
|
||||
// Bridge
|
||||
(2, 3, 0.1),
|
||||
];
|
||||
ScaledLaplacian::from_sparse_adjacency(&edges, 6)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_spectral_clustering() {
|
||||
let laplacian = two_cliques_graph();
|
||||
let clustering = SpectralClustering::with_k(2);
|
||||
|
||||
let result = clustering.cluster(&laplacian);
|
||||
|
||||
assert_eq!(result.assignments.len(), 6);
|
||||
assert_eq!(result.k, 2);
|
||||
|
||||
// Should roughly separate the two cliques
|
||||
let sizes = result.cluster_sizes();
|
||||
assert_eq!(sizes.iter().sum::<usize>(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bipartition() {
|
||||
let laplacian = two_cliques_graph();
|
||||
let clustering = SpectralClustering::with_k(2);
|
||||
|
||||
let result = clustering.bipartition(&laplacian);
|
||||
|
||||
assert_eq!(result.assignments.len(), 6);
|
||||
assert_eq!(result.k, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cluster_extraction() {
|
||||
let laplacian = two_cliques_graph();
|
||||
let clustering = SpectralClustering::with_k(2);
|
||||
let result = clustering.cluster(&laplacian);
|
||||
|
||||
let c0 = result.cluster(0);
|
||||
let c1 = result.cluster(1);
|
||||
|
||||
// All vertices assigned
|
||||
assert_eq!(c0.len() + c1.len(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalized_cut() {
|
||||
let laplacian = two_cliques_graph();
|
||||
let clustering = SpectralClustering::with_k(2);
|
||||
|
||||
// Good partition: separate cliques
|
||||
let good_partition = vec![true, true, true, false, false, false];
|
||||
let good_ncut = clustering.normalized_cut(&laplacian, &good_partition);
|
||||
|
||||
// Bad partition: mix cliques
|
||||
let bad_partition = vec![true, false, true, false, true, false];
|
||||
let bad_ncut = clustering.normalized_cut(&laplacian, &bad_partition);
|
||||
|
||||
// Good partition should have lower normalized cut
|
||||
// (This is a heuristic test, actual values depend on graph structure)
|
||||
assert!(good_ncut >= 0.0);
|
||||
assert!(bad_ncut >= 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_node() {
|
||||
let laplacian = ScaledLaplacian::from_sparse_adjacency(&[], 1);
|
||||
let clustering = SpectralClustering::with_k(1);
|
||||
|
||||
let result = clustering.cluster(&laplacian);
|
||||
|
||||
assert_eq!(result.assignments.len(), 1);
|
||||
assert_eq!(result.assignments[0], 0);
|
||||
}
|
||||
}
|
||||
337
vendor/ruvector/crates/ruvector-math/src/spectral/graph_filter.rs
vendored
Normal file
337
vendor/ruvector/crates/ruvector-math/src/spectral/graph_filter.rs
vendored
Normal file
@@ -0,0 +1,337 @@
|
||||
//! Graph Filtering via Chebyshev Polynomials
|
||||
//!
|
||||
//! Efficient O(Km) graph filtering where K is polynomial degree
|
||||
//! and m is the number of edges. No eigendecomposition required.
|
||||
|
||||
use super::{ChebyshevExpansion, ScaledLaplacian};
|
||||
|
||||
/// Type of spectral filter
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum FilterType {
|
||||
/// Low-pass: attenuate high frequencies
|
||||
LowPass { cutoff: f64 },
|
||||
/// High-pass: attenuate low frequencies
|
||||
HighPass { cutoff: f64 },
|
||||
/// Band-pass: keep frequencies in range
|
||||
BandPass { low: f64, high: f64 },
|
||||
/// Heat diffusion: exp(-t*L)
|
||||
Heat { time: f64 },
|
||||
/// Custom polynomial
|
||||
Custom,
|
||||
}
|
||||
|
||||
/// Spectral graph filter using Chebyshev approximation
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SpectralFilter {
|
||||
/// Chebyshev expansion of filter function
|
||||
pub expansion: ChebyshevExpansion,
|
||||
/// Filter type
|
||||
pub filter_type: FilterType,
|
||||
/// Polynomial degree
|
||||
pub degree: usize,
|
||||
}
|
||||
|
||||
impl SpectralFilter {
|
||||
/// Create heat diffusion filter: exp(-t*L)
|
||||
pub fn heat(time: f64, degree: usize) -> Self {
|
||||
Self {
|
||||
expansion: ChebyshevExpansion::heat_kernel(time, degree),
|
||||
filter_type: FilterType::Heat { time },
|
||||
degree,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create low-pass filter
|
||||
pub fn low_pass(cutoff: f64, degree: usize) -> Self {
|
||||
let steepness = 5.0 / cutoff.max(0.1);
|
||||
let expansion = ChebyshevExpansion::from_function(
|
||||
|x| {
|
||||
let lambda = (x + 1.0); // Map [-1,1] to [0,2]
|
||||
1.0 / (1.0 + (steepness * (lambda - cutoff)).exp())
|
||||
},
|
||||
degree,
|
||||
);
|
||||
|
||||
Self {
|
||||
expansion,
|
||||
filter_type: FilterType::LowPass { cutoff },
|
||||
degree,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create high-pass filter
|
||||
pub fn high_pass(cutoff: f64, degree: usize) -> Self {
|
||||
let steepness = 5.0 / cutoff.max(0.1);
|
||||
let expansion = ChebyshevExpansion::from_function(
|
||||
|x| {
|
||||
let lambda = (x + 1.0);
|
||||
1.0 / (1.0 + (steepness * (cutoff - lambda)).exp())
|
||||
},
|
||||
degree,
|
||||
);
|
||||
|
||||
Self {
|
||||
expansion,
|
||||
filter_type: FilterType::HighPass { cutoff },
|
||||
degree,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create band-pass filter
|
||||
pub fn band_pass(low: f64, high: f64, degree: usize) -> Self {
|
||||
let steepness = 5.0;
|
||||
let expansion = ChebyshevExpansion::from_function(
|
||||
|x| {
|
||||
let lambda = (x + 1.0);
|
||||
let low_gate = 1.0 / (1.0 + (steepness * (low - lambda)).exp());
|
||||
let high_gate = 1.0 / (1.0 + (steepness * (lambda - high)).exp());
|
||||
low_gate * high_gate
|
||||
},
|
||||
degree,
|
||||
);
|
||||
|
||||
Self {
|
||||
expansion,
|
||||
filter_type: FilterType::BandPass { low, high },
|
||||
degree,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create from custom Chebyshev expansion
|
||||
pub fn custom(expansion: ChebyshevExpansion) -> Self {
|
||||
let degree = expansion.degree();
|
||||
Self {
|
||||
expansion,
|
||||
filter_type: FilterType::Custom,
|
||||
degree,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Graph filter that applies spectral operations
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GraphFilter {
|
||||
/// Scaled Laplacian
|
||||
laplacian: ScaledLaplacian,
|
||||
/// Spectral filter to apply
|
||||
filter: SpectralFilter,
|
||||
}
|
||||
|
||||
impl GraphFilter {
|
||||
/// Create graph filter from adjacency and filter specification
|
||||
pub fn new(laplacian: ScaledLaplacian, filter: SpectralFilter) -> Self {
|
||||
Self { laplacian, filter }
|
||||
}
|
||||
|
||||
/// Create from dense adjacency matrix
|
||||
pub fn from_adjacency(adj: &[f64], n: usize, filter: SpectralFilter) -> Self {
|
||||
let laplacian = ScaledLaplacian::from_adjacency(adj, n);
|
||||
Self::new(laplacian, filter)
|
||||
}
|
||||
|
||||
/// Create from sparse edges
|
||||
pub fn from_sparse(edges: &[(usize, usize, f64)], n: usize, filter: SpectralFilter) -> Self {
|
||||
let laplacian = ScaledLaplacian::from_sparse_adjacency(edges, n);
|
||||
Self::new(laplacian, filter)
|
||||
}
|
||||
|
||||
/// Apply filter to signal: y = h(L) * x
|
||||
/// Uses Chebyshev recurrence: O(K*m) where K is degree, m is edges
|
||||
pub fn apply(&self, signal: &[f64]) -> Vec<f64> {
|
||||
let n = self.laplacian.n;
|
||||
let k = self.filter.degree;
|
||||
let coeffs = &self.filter.expansion.coefficients;
|
||||
|
||||
if coeffs.is_empty() || signal.len() != n {
|
||||
return vec![0.0; n];
|
||||
}
|
||||
|
||||
// Chebyshev recurrence on graph:
|
||||
// T_0(L) * x = x
|
||||
// T_1(L) * x = L * x
|
||||
// T_{k+1}(L) * x = 2*L*T_k(L)*x - T_{k-1}(L)*x
|
||||
|
||||
let mut t_prev: Vec<f64> = signal.to_vec(); // T_0 * x = x
|
||||
let mut t_curr: Vec<f64> = self.laplacian.apply(signal); // T_1 * x = L * x
|
||||
|
||||
// Output: y = sum_k c_k * T_k(L) * x
|
||||
let mut output = vec![0.0; n];
|
||||
|
||||
// Add c_0 * T_0 * x
|
||||
for i in 0..n {
|
||||
output[i] += coeffs[0] * t_prev[i];
|
||||
}
|
||||
|
||||
// Add c_1 * T_1 * x if exists
|
||||
if coeffs.len() > 1 {
|
||||
for i in 0..n {
|
||||
output[i] += coeffs[1] * t_curr[i];
|
||||
}
|
||||
}
|
||||
|
||||
// Recurrence for k >= 2
|
||||
for ki in 2..=k {
|
||||
if ki >= coeffs.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
// T_{k+1} * x = 2*L*T_k*x - T_{k-1}*x
|
||||
let lt_curr = self.laplacian.apply(&t_curr);
|
||||
let mut t_next = vec![0.0; n];
|
||||
for i in 0..n {
|
||||
t_next[i] = 2.0 * lt_curr[i] - t_prev[i];
|
||||
}
|
||||
|
||||
// Add c_k * T_k * x
|
||||
for i in 0..n {
|
||||
output[i] += coeffs[ki] * t_next[i];
|
||||
}
|
||||
|
||||
// Shift
|
||||
t_prev = t_curr;
|
||||
t_curr = t_next;
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
/// Apply filter multiple times (for stronger effect)
|
||||
pub fn apply_n(&self, signal: &[f64], n_times: usize) -> Vec<f64> {
|
||||
let mut result = signal.to_vec();
|
||||
for _ in 0..n_times {
|
||||
result = self.apply(&result);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Compute filter energy: x^T h(L) x
|
||||
pub fn energy(&self, signal: &[f64]) -> f64 {
|
||||
let filtered = self.apply(signal);
|
||||
signal
|
||||
.iter()
|
||||
.zip(filtered.iter())
|
||||
.map(|(&x, &y)| x * y)
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Get estimated spectral range
|
||||
pub fn lambda_max(&self) -> f64 {
|
||||
self.laplacian.lambda_max
|
||||
}
|
||||
}
|
||||
|
||||
/// Multi-scale graph filtering
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MultiscaleFilter {
|
||||
/// Filters at different scales
|
||||
filters: Vec<GraphFilter>,
|
||||
/// Scale parameters
|
||||
scales: Vec<f64>,
|
||||
}
|
||||
|
||||
impl MultiscaleFilter {
|
||||
/// Create multiscale heat diffusion filters
|
||||
pub fn heat_scales(laplacian: ScaledLaplacian, scales: Vec<f64>, degree: usize) -> Self {
|
||||
let filters: Vec<GraphFilter> = scales
|
||||
.iter()
|
||||
.map(|&t| GraphFilter::new(laplacian.clone(), SpectralFilter::heat(t, degree)))
|
||||
.collect();
|
||||
|
||||
Self { filters, scales }
|
||||
}
|
||||
|
||||
/// Apply all scales and return matrix (n × num_scales)
|
||||
pub fn apply_all(&self, signal: &[f64]) -> Vec<Vec<f64>> {
|
||||
self.filters.iter().map(|f| f.apply(signal)).collect()
|
||||
}
|
||||
|
||||
/// Get scale values
|
||||
pub fn scales(&self) -> &[f64] {
|
||||
&self.scales
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn simple_graph() -> (Vec<f64>, usize) {
|
||||
// Triangle graph: complete K_3
|
||||
let adj = vec![0.0, 1.0, 1.0, 1.0, 0.0, 1.0, 1.0, 1.0, 0.0];
|
||||
(adj, 3)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_heat_filter() {
|
||||
let (adj, n) = simple_graph();
|
||||
let filter = GraphFilter::from_adjacency(&adj, n, SpectralFilter::heat(0.5, 10));
|
||||
|
||||
let signal = vec![1.0, 0.0, 0.0]; // Delta at node 0
|
||||
let smoothed = filter.apply(&signal);
|
||||
|
||||
assert_eq!(smoothed.len(), 3);
|
||||
// Heat diffusion should spread the signal
|
||||
// After smoothing, node 0 should have less concentration
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_low_pass_filter() {
|
||||
let (adj, n) = simple_graph();
|
||||
let filter = GraphFilter::from_adjacency(&adj, n, SpectralFilter::low_pass(0.5, 10));
|
||||
|
||||
let signal = vec![1.0, -1.0, 0.0]; // High frequency component
|
||||
let filtered = filter.apply(&signal);
|
||||
|
||||
assert_eq!(filtered.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_constant_signal() {
|
||||
let (adj, n) = simple_graph();
|
||||
let filter = GraphFilter::from_adjacency(&adj, n, SpectralFilter::heat(1.0, 10));
|
||||
|
||||
// Constant signal is in null space of Laplacian
|
||||
let signal = vec![1.0, 1.0, 1.0];
|
||||
let filtered = filter.apply(&signal);
|
||||
|
||||
// Should remain approximately constant
|
||||
let mean: f64 = filtered.iter().sum::<f64>() / 3.0;
|
||||
for &v in &filtered {
|
||||
assert!(
|
||||
(v - mean).abs() < 0.5,
|
||||
"Constant signal not preserved: {:?}",
|
||||
filtered
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiscale() {
|
||||
let (adj, n) = simple_graph();
|
||||
let laplacian = ScaledLaplacian::from_adjacency(&adj, n);
|
||||
let scales = vec![0.1, 0.5, 1.0, 2.0];
|
||||
|
||||
let multiscale = MultiscaleFilter::heat_scales(laplacian, scales.clone(), 10);
|
||||
|
||||
let signal = vec![1.0, 0.0, 0.0];
|
||||
let all_scales = multiscale.apply_all(&signal);
|
||||
|
||||
assert_eq!(all_scales.len(), 4);
|
||||
for scale_result in &all_scales {
|
||||
assert_eq!(scale_result.len(), 3);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sparse_graph() {
|
||||
let edges = vec![(0, 1, 1.0), (1, 2, 1.0), (2, 3, 1.0)];
|
||||
let n = 4;
|
||||
|
||||
let filter = GraphFilter::from_sparse(&edges, n, SpectralFilter::heat(0.5, 10));
|
||||
|
||||
let signal = vec![1.0, 0.0, 0.0, 0.0];
|
||||
let smoothed = filter.apply(&signal);
|
||||
|
||||
assert_eq!(smoothed.len(), 4);
|
||||
}
|
||||
}
|
||||
236
vendor/ruvector/crates/ruvector-math/src/spectral/mod.rs
vendored
Normal file
236
vendor/ruvector/crates/ruvector-math/src/spectral/mod.rs
vendored
Normal file
@@ -0,0 +1,236 @@
|
||||
//! Spectral Methods for Graph Analysis
|
||||
//!
|
||||
//! Chebyshev polynomials and spectral graph theory for efficient
|
||||
//! diffusion and filtering without eigendecomposition.
|
||||
//!
|
||||
//! ## Key Capabilities
|
||||
//!
|
||||
//! - **Chebyshev Graph Filtering**: O(Km) filtering where K is polynomial degree
|
||||
//! - **Graph Diffusion**: Heat kernel approximation via Chebyshev expansion
|
||||
//! - **Spectral Clustering**: Efficient k-way partitioning
|
||||
//! - **Wavelet Transforms**: Multi-scale graph analysis
|
||||
//!
|
||||
//! ## Integration with Mincut
|
||||
//!
|
||||
//! Spectral methods pair naturally with mincut:
|
||||
//! - Mincut identifies partition boundaries
|
||||
//! - Chebyshev smooths attention within partitions
|
||||
//! - Spectral clustering provides initial segmentation hints
|
||||
//!
|
||||
//! ## Mathematical Background
|
||||
//!
|
||||
//! Chebyshev polynomials T_k(x) satisfy:
|
||||
//! - T_0(x) = 1
|
||||
//! - T_1(x) = x
|
||||
//! - T_{k+1}(x) = 2x·T_k(x) - T_{k-1}(x)
|
||||
//!
|
||||
//! This recurrence enables O(K) evaluation of degree-K polynomial filters.
|
||||
|
||||
mod chebyshev;
|
||||
mod clustering;
|
||||
mod graph_filter;
|
||||
mod wavelets;
|
||||
|
||||
pub use chebyshev::{ChebyshevExpansion, ChebyshevPolynomial};
|
||||
pub use clustering::{ClusteringConfig, SpectralClustering};
|
||||
pub use graph_filter::{FilterType, GraphFilter, SpectralFilter};
|
||||
pub use wavelets::{GraphWavelet, SpectralWaveletTransform, WaveletScale};
|
||||
|
||||
/// Scaled Laplacian for Chebyshev approximation
|
||||
/// L_scaled = 2L/λ_max - I (eigenvalues in [-1, 1])
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScaledLaplacian {
|
||||
/// Sparse representation: (row, col, value)
|
||||
pub entries: Vec<(usize, usize, f64)>,
|
||||
/// Matrix dimension
|
||||
pub n: usize,
|
||||
/// Estimated maximum eigenvalue
|
||||
pub lambda_max: f64,
|
||||
}
|
||||
|
||||
impl ScaledLaplacian {
|
||||
/// Build from adjacency matrix (dense)
|
||||
pub fn from_adjacency(adj: &[f64], n: usize) -> Self {
|
||||
// Compute degree and Laplacian
|
||||
let mut degrees = vec![0.0; n];
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
degrees[i] += adj[i * n + j];
|
||||
}
|
||||
}
|
||||
|
||||
// Build sparse Laplacian entries
|
||||
let mut entries = Vec::new();
|
||||
for i in 0..n {
|
||||
// Diagonal: degree
|
||||
if degrees[i] > 0.0 {
|
||||
entries.push((i, i, degrees[i]));
|
||||
}
|
||||
// Off-diagonal: -adjacency
|
||||
for j in 0..n {
|
||||
if i != j && adj[i * n + j] != 0.0 {
|
||||
entries.push((i, j, -adj[i * n + j]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Estimate λ_max via power iteration
|
||||
let lambda_max = Self::estimate_lambda_max(&entries, n, 20);
|
||||
|
||||
// Scale to [-1, 1]: L_scaled = 2L/λ_max - I
|
||||
let scale = 2.0 / lambda_max;
|
||||
let scaled_entries: Vec<(usize, usize, f64)> = entries
|
||||
.iter()
|
||||
.map(|&(i, j, v)| {
|
||||
if i == j {
|
||||
(i, j, scale * v - 1.0)
|
||||
} else {
|
||||
(i, j, scale * v)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
entries: scaled_entries,
|
||||
n,
|
||||
lambda_max,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build from sparse adjacency list
|
||||
pub fn from_sparse_adjacency(edges: &[(usize, usize, f64)], n: usize) -> Self {
|
||||
// Compute degrees
|
||||
let mut degrees = vec![0.0; n];
|
||||
for &(i, j, w) in edges {
|
||||
degrees[i] += w;
|
||||
if i != j {
|
||||
degrees[j] += w; // Symmetric
|
||||
}
|
||||
}
|
||||
|
||||
// Build Laplacian entries
|
||||
let mut entries = Vec::new();
|
||||
for i in 0..n {
|
||||
if degrees[i] > 0.0 {
|
||||
entries.push((i, i, degrees[i]));
|
||||
}
|
||||
}
|
||||
for &(i, j, w) in edges {
|
||||
if w != 0.0 {
|
||||
entries.push((i, j, -w));
|
||||
if i != j {
|
||||
entries.push((j, i, -w));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let lambda_max = Self::estimate_lambda_max(&entries, n, 20);
|
||||
let scale = 2.0 / lambda_max;
|
||||
|
||||
let scaled_entries: Vec<(usize, usize, f64)> = entries
|
||||
.iter()
|
||||
.map(|&(i, j, v)| {
|
||||
if i == j {
|
||||
(i, j, scale * v - 1.0)
|
||||
} else {
|
||||
(i, j, scale * v)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
entries: scaled_entries,
|
||||
n,
|
||||
lambda_max,
|
||||
}
|
||||
}
|
||||
|
||||
/// Estimate maximum eigenvalue via power iteration
|
||||
fn estimate_lambda_max(entries: &[(usize, usize, f64)], n: usize, iters: usize) -> f64 {
|
||||
let mut x = vec![1.0 / (n as f64).sqrt(); n];
|
||||
let mut lambda = 1.0;
|
||||
|
||||
for _ in 0..iters {
|
||||
// y = L * x
|
||||
let mut y = vec![0.0; n];
|
||||
for &(i, j, v) in entries {
|
||||
y[i] += v * x[j];
|
||||
}
|
||||
|
||||
// Estimate eigenvalue
|
||||
let mut dot = 0.0;
|
||||
let mut norm_sq = 0.0;
|
||||
for i in 0..n {
|
||||
dot += x[i] * y[i];
|
||||
norm_sq += y[i] * y[i];
|
||||
}
|
||||
lambda = dot;
|
||||
|
||||
// Normalize
|
||||
let norm = norm_sq.sqrt().max(1e-15);
|
||||
for i in 0..n {
|
||||
x[i] = y[i] / norm;
|
||||
}
|
||||
}
|
||||
|
||||
lambda.abs().max(1.0)
|
||||
}
|
||||
|
||||
/// Apply scaled Laplacian to vector: y = L_scaled * x
|
||||
pub fn apply(&self, x: &[f64]) -> Vec<f64> {
|
||||
let mut y = vec![0.0; self.n];
|
||||
for &(i, j, v) in &self.entries {
|
||||
if j < x.len() {
|
||||
y[i] += v * x[j];
|
||||
}
|
||||
}
|
||||
y
|
||||
}
|
||||
|
||||
/// Get original (unscaled) maximum eigenvalue estimate
|
||||
pub fn lambda_max(&self) -> f64 {
|
||||
self.lambda_max
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalized Laplacian (symmetric or random walk)
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum LaplacianNorm {
|
||||
/// Unnormalized: L = D - A
|
||||
Unnormalized,
|
||||
/// Symmetric: L_sym = D^{-1/2} L D^{-1/2}
|
||||
Symmetric,
|
||||
/// Random walk: L_rw = D^{-1} L
|
||||
RandomWalk,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_scaled_laplacian() {
|
||||
// Simple 3-node path graph: 0 -- 1 -- 2
|
||||
let adj = vec![0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0];
|
||||
|
||||
let laplacian = ScaledLaplacian::from_adjacency(&adj, 3);
|
||||
|
||||
assert_eq!(laplacian.n, 3);
|
||||
assert!(laplacian.lambda_max > 0.0);
|
||||
|
||||
// Apply to vector
|
||||
let x = vec![1.0, 0.0, -1.0];
|
||||
let y = laplacian.apply(&x);
|
||||
assert_eq!(y.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sparse_laplacian() {
|
||||
// Same path graph as sparse edges
|
||||
let edges = vec![(0, 1, 1.0), (1, 2, 1.0)];
|
||||
let laplacian = ScaledLaplacian::from_sparse_adjacency(&edges, 3);
|
||||
|
||||
assert_eq!(laplacian.n, 3);
|
||||
assert!(laplacian.lambda_max > 0.0);
|
||||
}
|
||||
}
|
||||
334
vendor/ruvector/crates/ruvector-math/src/spectral/wavelets.rs
vendored
Normal file
334
vendor/ruvector/crates/ruvector-math/src/spectral/wavelets.rs
vendored
Normal file
@@ -0,0 +1,334 @@
|
||||
//! Graph Wavelets
|
||||
//!
|
||||
//! Multi-scale analysis on graphs using spectral graph wavelets.
|
||||
//! Based on Hammond et al. "Wavelets on Graphs via Spectral Graph Theory"
|
||||
|
||||
use super::{ChebyshevExpansion, ScaledLaplacian};
|
||||
|
||||
/// Wavelet scale configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WaveletScale {
|
||||
/// Scale parameter (larger = coarser)
|
||||
pub scale: f64,
|
||||
/// Chebyshev expansion for this scale
|
||||
pub filter: ChebyshevExpansion,
|
||||
}
|
||||
|
||||
impl WaveletScale {
|
||||
/// Create wavelet at given scale using Mexican hat kernel
|
||||
/// g(λ) = λ * exp(-λ * scale)
|
||||
pub fn mexican_hat(scale: f64, degree: usize) -> Self {
|
||||
let filter = ChebyshevExpansion::from_function(
|
||||
|x| {
|
||||
let lambda = (x + 1.0); // Map [-1,1] to [0,2]
|
||||
lambda * (-lambda * scale).exp()
|
||||
},
|
||||
degree,
|
||||
);
|
||||
|
||||
Self { scale, filter }
|
||||
}
|
||||
|
||||
/// Create wavelet using heat kernel derivative
|
||||
/// g(λ) = λ * exp(-λ * scale) (same as Mexican hat)
|
||||
pub fn heat_derivative(scale: f64, degree: usize) -> Self {
|
||||
Self::mexican_hat(scale, degree)
|
||||
}
|
||||
|
||||
/// Create scaling function (low-pass for residual)
|
||||
/// h(λ) = exp(-λ * scale)
|
||||
pub fn scaling_function(scale: f64, degree: usize) -> Self {
|
||||
let filter = ChebyshevExpansion::from_function(
|
||||
|x| {
|
||||
let lambda = (x + 1.0);
|
||||
(-lambda * scale).exp()
|
||||
},
|
||||
degree,
|
||||
);
|
||||
|
||||
Self { scale, filter }
|
||||
}
|
||||
}
|
||||
|
||||
/// Graph wavelet at specific vertex
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GraphWavelet {
|
||||
/// Wavelet scale
|
||||
pub scale: WaveletScale,
|
||||
/// Center vertex
|
||||
pub center: usize,
|
||||
/// Wavelet coefficients for all vertices
|
||||
pub coefficients: Vec<f64>,
|
||||
}
|
||||
|
||||
impl GraphWavelet {
|
||||
/// Compute wavelet centered at vertex
|
||||
pub fn at_vertex(laplacian: &ScaledLaplacian, scale: &WaveletScale, center: usize) -> Self {
|
||||
let n = laplacian.n;
|
||||
|
||||
// Delta function at center
|
||||
let mut delta = vec![0.0; n];
|
||||
if center < n {
|
||||
delta[center] = 1.0;
|
||||
}
|
||||
|
||||
// Apply wavelet filter: ψ_s,v = g(L) δ_v
|
||||
let coefficients = apply_filter(laplacian, &scale.filter, &delta);
|
||||
|
||||
Self {
|
||||
scale: scale.clone(),
|
||||
center,
|
||||
coefficients,
|
||||
}
|
||||
}
|
||||
|
||||
/// Inner product with signal
|
||||
pub fn inner_product(&self, signal: &[f64]) -> f64 {
|
||||
self.coefficients
|
||||
.iter()
|
||||
.zip(signal.iter())
|
||||
.map(|(&w, &s)| w * s)
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// L2 norm
|
||||
pub fn norm(&self) -> f64 {
|
||||
self.coefficients.iter().map(|x| x * x).sum::<f64>().sqrt()
|
||||
}
|
||||
}
|
||||
|
||||
/// Spectral Wavelet Transform
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SpectralWaveletTransform {
|
||||
/// Laplacian
|
||||
laplacian: ScaledLaplacian,
|
||||
/// Wavelet scales (finest to coarsest)
|
||||
scales: Vec<WaveletScale>,
|
||||
/// Scaling function (for residual)
|
||||
scaling: WaveletScale,
|
||||
/// Chebyshev degree
|
||||
degree: usize,
|
||||
}
|
||||
|
||||
impl SpectralWaveletTransform {
|
||||
/// Create wavelet transform with logarithmically spaced scales
|
||||
pub fn new(laplacian: ScaledLaplacian, num_scales: usize, degree: usize) -> Self {
|
||||
// Scales from fine (small t) to coarse (large t)
|
||||
let min_scale = 0.1;
|
||||
let max_scale = 2.0 / laplacian.lambda_max;
|
||||
|
||||
let scales: Vec<WaveletScale> = (0..num_scales)
|
||||
.map(|i| {
|
||||
let t = if num_scales > 1 {
|
||||
min_scale * (max_scale / min_scale).powf(i as f64 / (num_scales - 1) as f64)
|
||||
} else {
|
||||
min_scale
|
||||
};
|
||||
WaveletScale::mexican_hat(t, degree)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let scaling = WaveletScale::scaling_function(max_scale, degree);
|
||||
|
||||
Self {
|
||||
laplacian,
|
||||
scales,
|
||||
scaling,
|
||||
degree,
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward transform: compute wavelet coefficients
|
||||
/// Returns (scaling_coeffs, [wavelet_coeffs_scale_0, wavelet_coeffs_scale_1, ...])
|
||||
pub fn forward(&self, signal: &[f64]) -> (Vec<f64>, Vec<Vec<f64>>) {
|
||||
// Scaling coefficients
|
||||
let scaling_coeffs = apply_filter(&self.laplacian, &self.scaling.filter, signal);
|
||||
|
||||
// Wavelet coefficients at each scale
|
||||
let wavelet_coeffs: Vec<Vec<f64>> = self
|
||||
.scales
|
||||
.iter()
|
||||
.map(|s| apply_filter(&self.laplacian, &s.filter, signal))
|
||||
.collect();
|
||||
|
||||
(scaling_coeffs, wavelet_coeffs)
|
||||
}
|
||||
|
||||
/// Inverse transform: reconstruct signal from coefficients
|
||||
/// Note: Perfect reconstruction requires frame bounds analysis
|
||||
pub fn inverse(&self, scaling_coeffs: &[f64], wavelet_coeffs: &[Vec<f64>]) -> Vec<f64> {
|
||||
let n = self.laplacian.n;
|
||||
let mut signal = vec![0.0; n];
|
||||
|
||||
// Add scaling contribution
|
||||
let scaled_scaling = apply_filter(&self.laplacian, &self.scaling.filter, scaling_coeffs);
|
||||
for i in 0..n {
|
||||
signal[i] += scaled_scaling[i];
|
||||
}
|
||||
|
||||
// Add wavelet contributions
|
||||
for (scale, coeffs) in self.scales.iter().zip(wavelet_coeffs.iter()) {
|
||||
let scaled_wavelet = apply_filter(&self.laplacian, &scale.filter, coeffs);
|
||||
for i in 0..n {
|
||||
signal[i] += scaled_wavelet[i];
|
||||
}
|
||||
}
|
||||
|
||||
signal
|
||||
}
|
||||
|
||||
/// Compute wavelet energy at each scale
|
||||
pub fn scale_energies(&self, signal: &[f64]) -> Vec<f64> {
|
||||
let (_, wavelet_coeffs) = self.forward(signal);
|
||||
|
||||
wavelet_coeffs
|
||||
.iter()
|
||||
.map(|coeffs| coeffs.iter().map(|x| x * x).sum::<f64>())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get all wavelets centered at a vertex
|
||||
pub fn wavelets_at(&self, vertex: usize) -> Vec<GraphWavelet> {
|
||||
self.scales
|
||||
.iter()
|
||||
.map(|s| GraphWavelet::at_vertex(&self.laplacian, s, vertex))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Number of scales
|
||||
pub fn num_scales(&self) -> usize {
|
||||
self.scales.len()
|
||||
}
|
||||
|
||||
/// Get scale parameters
|
||||
pub fn scale_values(&self) -> Vec<f64> {
|
||||
self.scales.iter().map(|s| s.scale).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply Chebyshev filter to signal using recurrence
|
||||
fn apply_filter(
|
||||
laplacian: &ScaledLaplacian,
|
||||
filter: &ChebyshevExpansion,
|
||||
signal: &[f64],
|
||||
) -> Vec<f64> {
|
||||
let n = laplacian.n;
|
||||
let coeffs = &filter.coefficients;
|
||||
|
||||
if coeffs.is_empty() || signal.len() != n {
|
||||
return vec![0.0; n];
|
||||
}
|
||||
|
||||
let k = coeffs.len() - 1;
|
||||
|
||||
let mut t_prev: Vec<f64> = signal.to_vec();
|
||||
let mut t_curr: Vec<f64> = laplacian.apply(signal);
|
||||
|
||||
let mut output = vec![0.0; n];
|
||||
|
||||
// c_0 * T_0 * x
|
||||
for i in 0..n {
|
||||
output[i] += coeffs[0] * t_prev[i];
|
||||
}
|
||||
|
||||
// c_1 * T_1 * x
|
||||
if coeffs.len() > 1 {
|
||||
for i in 0..n {
|
||||
output[i] += coeffs[1] * t_curr[i];
|
||||
}
|
||||
}
|
||||
|
||||
// Recurrence
|
||||
for ki in 2..=k {
|
||||
let lt_curr = laplacian.apply(&t_curr);
|
||||
let mut t_next = vec![0.0; n];
|
||||
for i in 0..n {
|
||||
t_next[i] = 2.0 * lt_curr[i] - t_prev[i];
|
||||
}
|
||||
|
||||
for i in 0..n {
|
||||
output[i] += coeffs[ki] * t_next[i];
|
||||
}
|
||||
|
||||
t_prev = t_curr;
|
||||
t_curr = t_next;
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn path_graph_laplacian(n: usize) -> ScaledLaplacian {
|
||||
let edges: Vec<(usize, usize, f64)> = (0..n - 1).map(|i| (i, i + 1, 1.0)).collect();
|
||||
ScaledLaplacian::from_sparse_adjacency(&edges, n)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wavelet_scale() {
|
||||
let scale = WaveletScale::mexican_hat(0.5, 10);
|
||||
assert_eq!(scale.scale, 0.5);
|
||||
assert!(!scale.filter.coefficients.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_graph_wavelet() {
|
||||
let laplacian = path_graph_laplacian(10);
|
||||
let scale = WaveletScale::mexican_hat(0.5, 10);
|
||||
|
||||
let wavelet = GraphWavelet::at_vertex(&laplacian, &scale, 5);
|
||||
|
||||
assert_eq!(wavelet.center, 5);
|
||||
assert_eq!(wavelet.coefficients.len(), 10);
|
||||
// Wavelet should be localized around center
|
||||
assert!(wavelet.coefficients[5].abs() > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wavelet_transform() {
|
||||
let laplacian = path_graph_laplacian(20);
|
||||
let transform = SpectralWaveletTransform::new(laplacian, 4, 10);
|
||||
|
||||
assert_eq!(transform.num_scales(), 4);
|
||||
|
||||
// Test forward transform
|
||||
let signal: Vec<f64> = (0..20).map(|i| (i as f64 * 0.3).sin()).collect();
|
||||
let (scaling, wavelets) = transform.forward(&signal);
|
||||
|
||||
assert_eq!(scaling.len(), 20);
|
||||
assert_eq!(wavelets.len(), 4);
|
||||
for w in &wavelets {
|
||||
assert_eq!(w.len(), 20);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scale_energies() {
|
||||
let laplacian = path_graph_laplacian(20);
|
||||
let transform = SpectralWaveletTransform::new(laplacian, 4, 10);
|
||||
|
||||
let signal: Vec<f64> = (0..20).map(|i| (i as f64 * 0.3).sin()).collect();
|
||||
let energies = transform.scale_energies(&signal);
|
||||
|
||||
assert_eq!(energies.len(), 4);
|
||||
// All energies should be non-negative
|
||||
for e in energies {
|
||||
assert!(e >= 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wavelets_at_vertex() {
|
||||
let laplacian = path_graph_laplacian(10);
|
||||
let transform = SpectralWaveletTransform::new(laplacian, 3, 8);
|
||||
|
||||
let wavelets = transform.wavelets_at(5);
|
||||
|
||||
assert_eq!(wavelets.len(), 3);
|
||||
for w in &wavelets {
|
||||
assert_eq!(w.center, 5);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user