Merge commit 'd803bfe2b1fe7f5e219e50ac20d6801a0a58ac75' as 'vendor/ruvector'

This commit is contained in:
ruv
2026-02-28 14:39:40 -05:00
7854 changed files with 3522914 additions and 0 deletions

View 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
);
}
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}
}