use super::core::Embedding;
use crate::base::{EdgeWeight, Graph, Node};
use crate::error::{GraphError, Result};
use crate::spectral::LaplacianType;
use scirs2_core::ndarray::{Array1, Array2, ArrayView1};
use scirs2_core::random::{Rng, RngExt};
use scirs2_core::simd_ops::SimdUnifiedOps;
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct SpectralEmbeddingConfig {
pub dimensions: usize,
pub laplacian_type: SpectralLaplacianType,
pub tolerance: f64,
pub max_iterations: usize,
pub normalize: bool,
pub drop_first: bool,
}
impl Default for SpectralEmbeddingConfig {
fn default() -> Self {
SpectralEmbeddingConfig {
dimensions: 2,
laplacian_type: SpectralLaplacianType::NormalizedNgJordanWeiss,
tolerance: 1e-8,
max_iterations: 300,
normalize: true,
drop_first: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SpectralLaplacianType {
Unnormalized,
Normalized,
RandomWalk,
NormalizedNgJordanWeiss,
}
pub struct SpectralEmbedding<N: Node> {
config: SpectralEmbeddingConfig,
node_to_idx: HashMap<N, usize>,
idx_to_node: Vec<N>,
embedding_matrix: Option<Array2<f64>>,
eigenvalues: Option<Array1<f64>>,
}
impl<N: Node + std::fmt::Debug> SpectralEmbedding<N> {
pub fn new(config: SpectralEmbeddingConfig) -> Self {
SpectralEmbedding {
config,
node_to_idx: HashMap::new(),
idx_to_node: Vec::new(),
embedding_matrix: None,
eigenvalues: None,
}
}
pub fn fit<E, Ix>(&mut self, graph: &Graph<N, E, Ix>) -> Result<()>
where
N: Clone,
E: EdgeWeight + Into<f64> + scirs2_core::numeric::Zero + scirs2_core::numeric::One + Copy,
Ix: petgraph::graph::IndexType,
{
let n = graph.node_count();
if n == 0 {
return Err(GraphError::InvalidGraph(
"Cannot compute spectral embedding for empty graph".to_string(),
));
}
let needed_dims = if self.config.drop_first {
self.config.dimensions + 1
} else {
self.config.dimensions
};
if needed_dims > n {
return Err(GraphError::InvalidParameter {
param: "dimensions".to_string(),
value: self.config.dimensions.to_string(),
expected: format!(
"at most {} (number of nodes{})",
if self.config.drop_first { n - 1 } else { n },
if self.config.drop_first { " - 1" } else { "" }
),
context: "Spectral embedding requires dimensions <= number of nodes".to_string(),
});
}
let nodes: Vec<N> = graph.nodes().into_iter().cloned().collect();
self.node_to_idx.clear();
self.idx_to_node = nodes.clone();
for (i, node) in nodes.iter().enumerate() {
self.node_to_idx.insert(node.clone(), i);
}
let lap_type = match self.config.laplacian_type {
SpectralLaplacianType::Unnormalized => LaplacianType::Standard,
SpectralLaplacianType::Normalized | SpectralLaplacianType::NormalizedNgJordanWeiss => {
LaplacianType::Normalized
}
SpectralLaplacianType::RandomWalk => LaplacianType::RandomWalk,
};
let laplacian = crate::spectral::laplacian(graph, lap_type)?;
let (eigenvalues, eigenvectors) =
self.compute_smallest_eigenvectors(&laplacian, needed_dims)?;
let start_idx = if self.config.drop_first { 1 } else { 0 };
let end_idx = start_idx + self.config.dimensions;
let mut embedding = Array2::zeros((n, self.config.dimensions));
for i in 0..n {
for (j, col_idx) in (start_idx..end_idx).enumerate() {
embedding[[i, j]] = eigenvectors[[i, col_idx]];
}
}
if self.config.laplacian_type == SpectralLaplacianType::NormalizedNgJordanWeiss {
for i in 0..n {
let row = embedding.row(i);
let norm = if let Some(slice) = row.as_slice() {
let view = ArrayView1::from(slice);
f64::simd_norm(&view)
} else {
row.iter().map(|x| x * x).sum::<f64>().sqrt()
};
if norm > 1e-15 {
for j in 0..self.config.dimensions {
embedding[[i, j]] /= norm;
}
}
}
}
if self.config.normalize
&& self.config.laplacian_type != SpectralLaplacianType::NormalizedNgJordanWeiss
{
for i in 0..n {
let row = embedding.row(i);
let norm = row.iter().map(|x| x * x).sum::<f64>().sqrt();
if norm > 1e-15 {
for j in 0..self.config.dimensions {
embedding[[i, j]] /= norm;
}
}
}
}
let selected_eigenvalues = Array1::from_vec(
eigenvalues
.iter()
.skip(start_idx)
.take(self.config.dimensions)
.copied()
.collect(),
);
self.embedding_matrix = Some(embedding);
self.eigenvalues = Some(selected_eigenvalues);
Ok(())
}
fn compute_smallest_eigenvectors(
&self,
matrix: &Array2<f64>,
k: usize,
) -> Result<(Vec<f64>, Array2<f64>)> {
let n = matrix.nrows();
let mut eigenvalues = Vec::with_capacity(k);
let mut eigenvectors = Array2::zeros((n, k));
let mut rng = scirs2_core::random::rng();
let mut max_gershgorin = 0.0_f64;
for i in 0..n {
let diag = matrix[[i, i]];
let off_diag_sum: f64 = (0..n)
.filter(|&j| j != i)
.map(|j| matrix[[i, j]].abs())
.sum();
max_gershgorin = max_gershgorin.max(diag + off_diag_sum);
}
let sigma = max_gershgorin + 1.0;
let mut shifted = Array2::zeros((n, n));
for i in 0..n {
for j in 0..n {
shifted[[i, j]] = -matrix[[i, j]];
}
shifted[[i, i]] += sigma;
}
let mut deflation_vectors: Vec<Array1<f64>> = Vec::new();
for kk in 0..k {
let mut v = Array1::from_vec(
(0..n)
.map(|_| rng.random::<f64>() - 0.5)
.collect::<Vec<f64>>(),
);
let norm = v.iter().map(|x| x * x).sum::<f64>().sqrt();
if norm > 1e-15 {
v.mapv_inplace(|x| x / norm);
}
let mut eigenvalue = 0.0;
for iter in 0..self.config.max_iterations {
let mut w = Array1::zeros(n);
for i in 0..n {
let row = shifted.row(i);
w[i] = if let (Some(row_s), Some(v_s)) = (row.as_slice(), v.as_slice()) {
let rv = ArrayView1::from(row_s);
let vv = ArrayView1::from(v_s);
f64::simd_dot(&rv, &vv)
} else {
row.dot(&v)
};
}
for prev_v in &deflation_vectors {
let proj = w.dot(prev_v);
for i in 0..n {
w[i] -= proj * prev_v[i];
}
}
let new_eigenvalue = v.dot(&w);
let w_norm = w.iter().map(|x| x * x).sum::<f64>().sqrt();
if w_norm < 1e-15 {
break;
}
w.mapv_inplace(|x| x / w_norm);
if iter > 0 && (new_eigenvalue - eigenvalue).abs() < self.config.tolerance {
eigenvalue = new_eigenvalue;
v = w;
break;
}
eigenvalue = new_eigenvalue;
v = w;
}
let actual_eigenvalue = sigma - eigenvalue;
eigenvalues.push(actual_eigenvalue);
for i in 0..n {
eigenvectors[[i, kk]] = v[i];
}
deflation_vectors.push(v);
}
let mut indices: Vec<usize> = (0..k).collect();
indices.sort_by(|&a, &b| {
eigenvalues[a]
.partial_cmp(&eigenvalues[b])
.unwrap_or(std::cmp::Ordering::Equal)
});
let sorted_eigenvalues: Vec<f64> = indices.iter().map(|&i| eigenvalues[i]).collect();
let mut sorted_eigenvectors = Array2::zeros((n, k));
for (new_col, &old_col) in indices.iter().enumerate() {
for i in 0..n {
sorted_eigenvectors[[i, new_col]] = eigenvectors[[i, old_col]];
}
}
Ok((sorted_eigenvalues, sorted_eigenvectors))
}
pub fn get_embedding(&self, node: &N) -> Result<Embedding> {
let idx = self
.node_to_idx
.get(node)
.ok_or_else(|| GraphError::node_not_found(format!("{node:?}")))?;
let matrix = self.embedding_matrix.as_ref().ok_or_else(|| {
GraphError::AlgorithmError("Spectral embedding not computed yet".to_string())
})?;
let row = matrix.row(*idx);
Ok(Embedding {
vector: row.to_vec(),
})
}
pub fn embeddings(&self) -> Result<HashMap<N, Embedding>>
where
N: Clone,
{
let matrix = self.embedding_matrix.as_ref().ok_or_else(|| {
GraphError::AlgorithmError("Spectral embedding not computed yet".to_string())
})?;
let mut result = HashMap::new();
for (i, node) in self.idx_to_node.iter().enumerate() {
let row = matrix.row(i);
result.insert(
node.clone(),
Embedding {
vector: row.to_vec(),
},
);
}
Ok(result)
}
pub fn embedding_matrix(&self) -> Result<&Array2<f64>> {
self.embedding_matrix.as_ref().ok_or_else(|| {
GraphError::AlgorithmError("Spectral embedding not computed yet".to_string())
})
}
pub fn eigenvalues(&self) -> Result<&Array1<f64>> {
self.eigenvalues.as_ref().ok_or_else(|| {
GraphError::AlgorithmError("Spectral embedding not computed yet".to_string())
})
}
pub fn dimensions(&self) -> usize {
self.config.dimensions
}
pub fn pairwise_distances(&self) -> Result<Array2<f64>> {
let matrix = self.embedding_matrix.as_ref().ok_or_else(|| {
GraphError::AlgorithmError("Spectral embedding not computed yet".to_string())
})?;
let n = matrix.nrows();
let d = matrix.ncols();
let mut distances = Array2::zeros((n, n));
for i in 0..n {
for j in (i + 1)..n {
let mut dist_sq = 0.0;
for k in 0..d {
let diff = matrix[[i, k]] - matrix[[j, k]];
dist_sq += diff * diff;
}
let dist = dist_sq.sqrt();
distances[[i, j]] = dist;
distances[[j, i]] = dist;
}
}
Ok(distances)
}
pub fn compute_stress<E, Ix>(&self, graph: &Graph<N, E, Ix>) -> Result<f64>
where
N: Clone,
E: EdgeWeight + Into<f64> + Clone,
Ix: petgraph::graph::IndexType,
{
let matrix = self.embedding_matrix.as_ref().ok_or_else(|| {
GraphError::AlgorithmError("Spectral embedding not computed yet".to_string())
})?;
let n = matrix.nrows();
let d = matrix.ncols();
let mut stress_num = 0.0;
let mut stress_den = 0.0;
let edges = graph.edges();
for edge in &edges {
let i = self
.node_to_idx
.get(&edge.source)
.copied()
.ok_or_else(|| GraphError::node_not_found("source"))?;
let j = self
.node_to_idx
.get(&edge.target)
.copied()
.ok_or_else(|| GraphError::node_not_found("target"))?;
let graph_dist: f64 = edge.weight.clone().into();
let graph_dist = if graph_dist > 0.0 {
1.0 / graph_dist
} else {
1.0
};
let mut emb_dist_sq = 0.0;
for k in 0..d {
let diff = matrix[[i, k]] - matrix[[j, k]];
emb_dist_sq += diff * diff;
}
let emb_dist = emb_dist_sq.sqrt();
stress_num += (graph_dist - emb_dist).powi(2);
stress_den += graph_dist.powi(2);
}
if stress_den > 0.0 {
Ok(stress_num / stress_den)
} else {
Ok(0.0)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_path_graph() -> Graph<i32, f64> {
let mut g = Graph::new();
for i in 0..4 {
g.add_node(i);
}
let _ = g.add_edge(0, 1, 1.0);
let _ = g.add_edge(1, 2, 1.0);
let _ = g.add_edge(2, 3, 1.0);
g
}
fn make_complete_graph() -> Graph<i32, f64> {
let mut g = Graph::new();
for i in 0..4 {
g.add_node(i);
}
for i in 0..4 {
for j in (i + 1)..4 {
let _ = g.add_edge(i, j, 1.0);
}
}
g
}
fn make_two_community_graph() -> Graph<i32, f64> {
let mut g = Graph::new();
for i in 0..8 {
g.add_node(i);
}
for i in 0..4 {
for j in (i + 1)..4 {
let _ = g.add_edge(i, j, 1.0);
}
}
for i in 4..8 {
for j in (i + 1)..8 {
let _ = g.add_edge(i, j, 1.0);
}
}
let _ = g.add_edge(3, 4, 1.0);
g
}
#[test]
fn test_spectral_embedding_basic() {
let g = make_path_graph();
let config = SpectralEmbeddingConfig {
dimensions: 2,
laplacian_type: SpectralLaplacianType::Unnormalized,
tolerance: 1e-6,
max_iterations: 200,
normalize: false,
drop_first: true,
};
let mut se = SpectralEmbedding::new(config);
let result = se.fit(&g);
assert!(
result.is_ok(),
"Spectral embedding should succeed: {:?}",
result.err()
);
for node in 0..4 {
let emb = se.get_embedding(&node);
assert!(emb.is_ok(), "Node {node} should have embedding");
let emb = emb.expect("embedding should be valid");
assert_eq!(emb.vector.len(), 2);
}
}
#[test]
fn test_spectral_embedding_eigenvalues() {
let g = make_complete_graph();
let config = SpectralEmbeddingConfig {
dimensions: 2,
laplacian_type: SpectralLaplacianType::Unnormalized,
tolerance: 1e-8,
max_iterations: 300,
normalize: false,
drop_first: true,
};
let mut se = SpectralEmbedding::new(config);
let _ = se.fit(&g);
let eigenvalues = se.eigenvalues();
assert!(eigenvalues.is_ok());
let eigenvalues = eigenvalues.expect("eigenvalues should be valid");
for &val in eigenvalues.iter() {
assert!(
val > 0.0,
"Non-trivial eigenvalues of K4 should be positive, got {val}"
);
}
}
#[test]
fn test_spectral_embedding_normalized() {
let g = make_path_graph();
let config = SpectralEmbeddingConfig {
dimensions: 2,
laplacian_type: SpectralLaplacianType::Normalized,
normalize: true,
..Default::default()
};
let mut se = SpectralEmbedding::new(config);
let result = se.fit(&g);
assert!(result.is_ok());
for node in 0..4 {
let emb = se.get_embedding(&node);
assert!(emb.is_ok());
let emb = emb.expect("embedding should be valid");
let norm: f64 = emb.vector.iter().map(|x| x * x).sum::<f64>().sqrt();
assert!(
(norm - 1.0).abs() < 0.1 || norm < 0.01,
"Normalized embedding norm should be close to 1.0 or near zero, got {norm}"
);
}
}
#[test]
fn test_spectral_embedding_two_communities() {
let g = make_two_community_graph();
let config = SpectralEmbeddingConfig {
dimensions: 2,
laplacian_type: SpectralLaplacianType::Unnormalized,
tolerance: 1e-8,
max_iterations: 500,
normalize: false,
drop_first: true,
};
let mut se = SpectralEmbedding::new(config);
let result = se.fit(&g);
assert!(
result.is_ok(),
"Should succeed for two-community graph: {:?}",
result.err()
);
for i in 0..8 {
let emb = se.get_embedding(&i);
assert!(emb.is_ok(), "Node {i} should have an embedding");
}
let eigenvalues = se.eigenvalues();
assert!(eigenvalues.is_ok());
let eigenvalues = eigenvalues.expect("eigenvalues should be valid");
assert!(
eigenvalues.len() == 2,
"Should have 2 eigenvalues, got {}",
eigenvalues.len()
);
let distances = se.pairwise_distances();
assert!(distances.is_ok());
let distances = distances.expect("distances should be valid");
let mut within_sum = 0.0;
let mut within_count = 0;
let mut between_sum = 0.0;
let mut between_count = 0;
for i in 0..8 {
for j in (i + 1)..8 {
let d = distances[[i, j]];
if (i < 4 && j < 4) || (i >= 4 && j >= 4) {
within_sum += d;
within_count += 1;
} else {
between_sum += d;
between_count += 1;
}
}
}
let avg_within = if within_count > 0 {
within_sum / within_count as f64
} else {
0.0
};
let avg_between = if between_count > 0 {
between_sum / between_count as f64
} else {
0.0
};
assert!(
avg_within.is_finite(),
"Within-community distance should be finite"
);
assert!(
avg_between.is_finite(),
"Between-community distance should be finite"
);
}
#[test]
fn test_spectral_embedding_empty_graph_error() {
let g: Graph<i32, f64> = Graph::new();
let config = SpectralEmbeddingConfig::default();
let mut se = SpectralEmbedding::new(config);
let result = se.fit(&g);
assert!(result.is_err(), "Should fail for empty graph");
}
#[test]
fn test_spectral_embedding_too_many_dims_error() {
let g = make_path_graph(); let config = SpectralEmbeddingConfig {
dimensions: 10, drop_first: true,
..Default::default()
};
let mut se = SpectralEmbedding::new(config);
let result = se.fit(&g);
assert!(result.is_err(), "Should fail when dimensions > nodes");
}
#[test]
fn test_spectral_embedding_pairwise_distances() {
let g = make_path_graph();
let config = SpectralEmbeddingConfig {
dimensions: 2,
normalize: false,
drop_first: true,
..Default::default()
};
let mut se = SpectralEmbedding::new(config);
let _ = se.fit(&g);
let distances = se.pairwise_distances();
assert!(distances.is_ok());
let distances = distances.expect("distances should be valid");
for i in 0..4 {
assert!(
distances[[i, i]].abs() < 1e-10,
"Self-distance should be zero"
);
}
for i in 0..4 {
for j in 0..4 {
assert!(
(distances[[i, j]] - distances[[j, i]]).abs() < 1e-10,
"Distance matrix should be symmetric"
);
}
}
}
#[test]
fn test_spectral_embedding_stress() {
let g = make_path_graph();
let config = SpectralEmbeddingConfig {
dimensions: 2,
normalize: false,
drop_first: true,
..Default::default()
};
let mut se = SpectralEmbedding::new(config);
let _ = se.fit(&g);
let stress = se.compute_stress(&g);
assert!(stress.is_ok());
let stress = stress.expect("stress should be valid");
assert!(stress.is_finite(), "Stress should be finite, got {stress}");
assert!(stress >= 0.0, "Stress should be non-negative, got {stress}");
}
#[test]
fn test_spectral_embedding_random_walk_laplacian() {
let g = make_path_graph();
let config = SpectralEmbeddingConfig {
dimensions: 2,
laplacian_type: SpectralLaplacianType::RandomWalk,
tolerance: 1e-6,
max_iterations: 200,
normalize: false,
drop_first: true,
};
let mut se = SpectralEmbedding::new(config);
let result = se.fit(&g);
assert!(
result.is_ok(),
"Random walk spectral embedding should succeed"
);
let embs = se.embeddings();
assert!(embs.is_ok());
assert_eq!(embs.expect("embeddings should be valid").len(), 4);
}
}