Skip to main content

rust_igraph/algorithms/properties/
normalized_laplacian.rs

1//! Normalized Laplacian spectrum and derived metrics (ALGO-TR-024).
2//!
3//! The **normalized Laplacian** `L_norm = D^{-1/2} L D^{-1/2}` has
4//! eigenvalues in `[0, 2]` and is better suited than the combinatorial
5//! Laplacian for comparing graphs of different sizes/densities.
6//!
7//! - **Normalized Laplacian spectrum**: eigenvalues `μ_1 ≤ … ≤ μ_n` of
8//!   `L_norm = I - D^{-1/2} A D^{-1/2}`.
9//! - **Cheeger constant bound**: `h(G)/2 ≤ μ_2 ≤ 2·h(G)` (Cheeger
10//!   inequality). We report a lower bound `μ_2/2` and upper bound
11//!   `√(2·μ_2)` for the isoperimetric number.
12//! - **Spectral gap ratio**: `μ_2 / μ_n` — a normalized measure of
13//!   expansion; closer to 1 implies more uniform connectivity.
14//! - **Normalized algebraic connectivity**: `μ_2(L_norm)` — degree-normalized
15//!   version of the algebraic connectivity.
16//! - **Bipartiteness ratio**: `(2 - μ_n) / 2` — measures deviation from
17//!   bipartiteness; 0 for bipartite graphs, positive otherwise.
18
19#![allow(
20    clippy::cast_possible_truncation,
21    clippy::cast_precision_loss,
22    clippy::many_single_char_names,
23    clippy::needless_range_loop,
24    clippy::similar_names,
25    clippy::too_many_lines
26)]
27
28use crate::core::{Graph, IgraphError, IgraphResult};
29
30/// Build the dense normalized Laplacian `L_norm = I - D^{-1/2} A D^{-1/2}` (row-major, n×n).
31///
32/// Isolated vertices get `L_norm[v,v] = 0` (convention: `0/0 = 0`).
33fn dense_normalized_laplacian(graph: &Graph) -> Vec<f64> {
34    let n = graph.vcount() as usize;
35    let mut lnorm = vec![0.0_f64; n * n];
36
37    let mut deg = vec![0_u32; n];
38    for (u, v) in graph.edges() {
39        deg[u as usize] += 1;
40        deg[v as usize] += 1;
41    }
42
43    let inv_sqrt_deg: Vec<f64> = deg
44        .iter()
45        .map(|&d| {
46            if d == 0 {
47                0.0
48            } else {
49                1.0 / (f64::from(d)).sqrt()
50            }
51        })
52        .collect();
53
54    for i in 0..n {
55        if deg[i] > 0 {
56            lnorm[i * n + i] = 1.0;
57        }
58    }
59
60    for (u, v) in graph.edges() {
61        let ui = u as usize;
62        let vi = v as usize;
63        let val = inv_sqrt_deg[ui] * inv_sqrt_deg[vi];
64        lnorm[ui * n + vi] -= val;
65        lnorm[vi * n + ui] -= val;
66    }
67
68    lnorm
69}
70
71/// Jacobi eigenvalue algorithm for real symmetric matrices.
72/// Returns eigenvalues sorted in **increasing** order and eigenvector columns.
73fn jacobi_eigen_ascending(mat: &mut [f64], n: usize) -> (Vec<f64>, Vec<Vec<f64>>) {
74    if n == 0 {
75        return (Vec::new(), Vec::new());
76    }
77    if n == 1 {
78        return (vec![mat[0]], vec![vec![1.0]]);
79    }
80
81    let mut v = vec![0.0_f64; n * n];
82    for i in 0..n {
83        v[i * n + i] = 1.0;
84    }
85
86    let max_sweeps = 100;
87    for _ in 0..max_sweeps {
88        let mut max_off = 0.0_f64;
89        let mut p = 0;
90        let mut q = 1;
91        for i in 0..n {
92            for j in (i + 1)..n {
93                let val = mat[i * n + j].abs();
94                if val > max_off {
95                    max_off = val;
96                    p = i;
97                    q = j;
98                }
99            }
100        }
101
102        if max_off < 1e-14 {
103            break;
104        }
105
106        let app = mat[p * n + p];
107        let aqq = mat[q * n + q];
108        let apq = mat[p * n + q];
109
110        let (cos, sin) = if (app - aqq).abs() < 1e-300 {
111            let s = std::f64::consts::FRAC_1_SQRT_2;
112            (s, s)
113        } else {
114            let tau = (aqq - app) / (2.0 * apq);
115            let t = if tau >= 0.0 {
116                1.0 / (tau + (1.0 + tau * tau).sqrt())
117            } else {
118                -1.0 / (-tau + (1.0 + tau * tau).sqrt())
119            };
120            let c = 1.0 / (1.0 + t * t).sqrt();
121            (c, t * c)
122        };
123
124        for i in 0..n {
125            if i == p || i == q {
126                continue;
127            }
128            let aip = mat[i * n + p];
129            let aiq = mat[i * n + q];
130            mat[i * n + p] = cos * aip - sin * aiq;
131            mat[p * n + i] = mat[i * n + p];
132            mat[i * n + q] = sin * aip + cos * aiq;
133            mat[q * n + i] = mat[i * n + q];
134        }
135
136        let new_pp = cos * cos * app - 2.0 * sin * cos * apq + sin * sin * aqq;
137        let new_qq = sin * sin * app + 2.0 * sin * cos * apq + cos * cos * aqq;
138        mat[p * n + p] = new_pp;
139        mat[q * n + q] = new_qq;
140        mat[p * n + q] = 0.0;
141        mat[q * n + p] = 0.0;
142
143        for i in 0..n {
144            let vip = v[i * n + p];
145            let viq = v[i * n + q];
146            v[i * n + p] = cos * vip - sin * viq;
147            v[i * n + q] = sin * vip + cos * viq;
148        }
149    }
150
151    let eigenvalues: Vec<f64> = (0..n).map(|i| mat[i * n + i]).collect();
152    let mut indices: Vec<usize> = (0..n).collect();
153    indices.sort_by(|&a, &b| {
154        eigenvalues[a]
155            .partial_cmp(&eigenvalues[b])
156            .unwrap_or(std::cmp::Ordering::Equal)
157    });
158
159    let sorted_vals: Vec<f64> = indices.iter().map(|&i| eigenvalues[i]).collect();
160    let sorted_vecs: Vec<Vec<f64>> = indices
161        .iter()
162        .map(|&idx| {
163            let mut col = vec![0.0_f64; n];
164            for i in 0..n {
165                col[i] = v[i * n + idx];
166            }
167            col
168        })
169        .collect();
170
171    (sorted_vals, sorted_vecs)
172}
173
174/// Compute the internal decomposition.
175fn normalized_laplacian_internal(graph: &Graph) -> (Vec<f64>, Vec<Vec<f64>>) {
176    let n = graph.vcount() as usize;
177    if n == 0 {
178        return (Vec::new(), Vec::new());
179    }
180    let mut lnorm = dense_normalized_laplacian(graph);
181    jacobi_eigen_ascending(&mut lnorm, n)
182}
183
184/// Compute the normalized Laplacian spectrum, sorted ascending.
185///
186/// The normalized Laplacian `L_norm = I - D^{-1/2} A D^{-1/2}` has all
187/// eigenvalues in `[0, 2]`. The multiplicity of the zero eigenvalue
188/// equals the number of connected components (among non-isolated
189/// vertices).
190///
191/// For undirected graphs only.
192///
193/// # Examples
194///
195/// ```
196/// use rust_igraph::{Graph, normalized_laplacian_spectrum};
197///
198/// // K_3: normalized Laplacian eigenvalues {0, 3/2, 3/2}
199/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
200/// let spec = normalized_laplacian_spectrum(&g).unwrap();
201/// assert!(spec[0].abs() < 0.01);
202/// assert!((spec[1] - 1.5).abs() < 0.1);
203/// ```
204pub fn normalized_laplacian_spectrum(graph: &Graph) -> IgraphResult<Vec<f64>> {
205    if graph.is_directed() {
206        return Err(IgraphError::InvalidArgument(
207            "normalized_laplacian_spectrum is defined for undirected graphs only".into(),
208        ));
209    }
210    let (vals, _) = normalized_laplacian_internal(graph);
211    Ok(vals)
212}
213
214/// Compute the normalized algebraic connectivity `μ_2(L_norm)`.
215///
216/// This is the second-smallest eigenvalue of the normalized Laplacian.
217/// It is in `[0, 1]` for connected graphs and equals 0 for disconnected
218/// ones. Unlike the combinatorial `a(G) = λ_2(L)`, it is bounded
219/// independently of the graph size.
220///
221/// # Examples
222///
223/// ```
224/// use rust_igraph::{Graph, normalized_algebraic_connectivity};
225///
226/// // K_3: μ_2 = 3/2
227/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
228/// let mu2 = normalized_algebraic_connectivity(&g).unwrap();
229/// assert!((mu2 - 1.5).abs() < 0.1);
230/// ```
231pub fn normalized_algebraic_connectivity(graph: &Graph) -> IgraphResult<f64> {
232    if graph.is_directed() {
233        return Err(IgraphError::InvalidArgument(
234            "normalized_algebraic_connectivity is defined for undirected graphs only".into(),
235        ));
236    }
237    let n = graph.vcount() as usize;
238    if n <= 1 {
239        return Ok(0.0);
240    }
241
242    let (vals, _) = normalized_laplacian_internal(graph);
243    Ok(vals[1].max(0.0))
244}
245
246/// Compute Cheeger constant bounds from the normalized Laplacian.
247///
248/// The Cheeger inequality relates the isoperimetric number `h(G)` to the
249/// normalized algebraic connectivity `μ_2`:
250///
251/// `μ_2/2 ≤ h(G) ≤ √(2·μ_2)`
252///
253/// Returns `(lower_bound, upper_bound)`.
254///
255/// # Examples
256///
257/// ```
258/// use rust_igraph::{Graph, cheeger_bounds};
259///
260/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
261/// let (lo, hi) = cheeger_bounds(&g).unwrap();
262/// assert!(lo <= hi + 1e-10);
263/// assert!(lo >= 0.0);
264/// ```
265pub fn cheeger_bounds(graph: &Graph) -> IgraphResult<(f64, f64)> {
266    let mu2 = normalized_algebraic_connectivity(graph)?;
267    let lower = mu2 / 2.0;
268    let upper = (2.0 * mu2).sqrt();
269    Ok((lower, upper))
270}
271
272/// Compute the spectral gap ratio `μ_2 / μ_n`.
273///
274/// A ratio close to 1 indicates uniform expansion (the graph is close
275/// to Ramanujan). Returns `0.0` if `μ_n` is zero or the graph has
276/// fewer than 2 vertices.
277///
278/// # Examples
279///
280/// ```
281/// use rust_igraph::{Graph, spectral_gap_ratio};
282///
283/// // K_3: μ_2 = μ_3 = 3/2, ratio = 1.0
284/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
285/// let r = spectral_gap_ratio(&g).unwrap();
286/// assert!((r - 1.0).abs() < 0.1);
287/// ```
288pub fn spectral_gap_ratio(graph: &Graph) -> IgraphResult<f64> {
289    if graph.is_directed() {
290        return Err(IgraphError::InvalidArgument(
291            "spectral_gap_ratio is defined for undirected graphs only".into(),
292        ));
293    }
294    let n = graph.vcount() as usize;
295    if n <= 1 {
296        return Ok(0.0);
297    }
298
299    let (vals, _) = normalized_laplacian_internal(graph);
300    let mu_n = vals[n - 1];
301    if mu_n.abs() < 1e-14 {
302        return Ok(0.0);
303    }
304    Ok(vals[1].max(0.0) / mu_n)
305}
306
307/// Compute the bipartiteness ratio `(2 - μ_n) / 2`.
308///
309/// For bipartite graphs `μ_n = 2`, so the ratio is `0`. For
310/// non-bipartite graphs `μ_n < 2`, giving a positive value that
311/// measures how far the graph is from being bipartite.
312///
313/// Returns `1.0` for graphs with `μ_n = 0` (isolated vertices only).
314///
315/// # Examples
316///
317/// ```
318/// use rust_igraph::{Graph, bipartiteness_ratio};
319///
320/// // K_{2,2} (bipartite): ratio = 0
321/// let g = Graph::from_edges(&[(0,2),(0,3),(1,2),(1,3)], false, Some(4)).unwrap();
322/// let br = bipartiteness_ratio(&g).unwrap();
323/// assert!(br.abs() < 0.01);
324///
325/// // K_3 (non-bipartite): μ_n = 3/2, ratio = (2 - 3/2)/2 = 1/4
326/// let k3 = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
327/// let br3 = bipartiteness_ratio(&k3).unwrap();
328/// assert!((br3 - 0.25).abs() < 0.05);
329/// ```
330pub fn bipartiteness_ratio(graph: &Graph) -> IgraphResult<f64> {
331    if graph.is_directed() {
332        return Err(IgraphError::InvalidArgument(
333            "bipartiteness_ratio is defined for undirected graphs only".into(),
334        ));
335    }
336    let n = graph.vcount() as usize;
337    if n == 0 {
338        return Ok(1.0);
339    }
340
341    let (vals, _) = normalized_laplacian_internal(graph);
342    let mu_n = vals[n - 1];
343    Ok((2.0 - mu_n) / 2.0)
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    fn k3() -> Graph {
351        Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap()
352    }
353
354    fn k4() -> Graph {
355        Graph::from_edges(
356            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
357            false,
358            Some(4),
359        )
360        .unwrap()
361    }
362
363    fn path4() -> Graph {
364        Graph::from_edges(&[(0, 1), (1, 2), (2, 3)], false, Some(4)).unwrap()
365    }
366
367    fn cycle4() -> Graph {
368        Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 0)], false, Some(4)).unwrap()
369    }
370
371    fn star4() -> Graph {
372        Graph::from_edges(&[(0, 1), (0, 2), (0, 3)], false, Some(4)).unwrap()
373    }
374
375    fn k22() -> Graph {
376        Graph::from_edges(&[(0, 2), (0, 3), (1, 2), (1, 3)], false, Some(4)).unwrap()
377    }
378
379    // --- normalized_laplacian_spectrum ---
380
381    #[test]
382    fn nls_empty() {
383        let g = Graph::with_vertices(0);
384        let spec = normalized_laplacian_spectrum(&g).unwrap();
385        assert!(spec.is_empty());
386    }
387
388    #[test]
389    fn nls_single() {
390        let g = Graph::with_vertices(1);
391        let spec = normalized_laplacian_spectrum(&g).unwrap();
392        assert_eq!(spec.len(), 1);
393        assert!(spec[0].abs() < 1e-10);
394    }
395
396    #[test]
397    fn nls_k3() {
398        let g = k3();
399        let spec = normalized_laplacian_spectrum(&g).unwrap();
400        assert_eq!(spec.len(), 3);
401        assert!(spec[0].abs() < 0.01);
402        assert!((spec[1] - 1.5).abs() < 0.1);
403        assert!((spec[2] - 1.5).abs() < 0.1);
404    }
405
406    #[test]
407    fn nls_k4() {
408        let g = k4();
409        let spec = normalized_laplacian_spectrum(&g).unwrap();
410        // K_n: normalized Laplacian eigenvalues {0, n/(n-1), ..., n/(n-1)}
411        // K_4: {0, 4/3, 4/3, 4/3}
412        assert!(spec[0].abs() < 0.01);
413        for i in 1..4 {
414            assert!((spec[i] - 4.0 / 3.0).abs() < 0.1);
415        }
416    }
417
418    #[test]
419    fn nls_in_range() {
420        let g = star4();
421        let spec = normalized_laplacian_spectrum(&g).unwrap();
422        for &v in &spec {
423            assert!((-0.01..=2.01).contains(&v), "eigenvalue {v} out of [0,2]");
424        }
425    }
426
427    #[test]
428    fn nls_ascending() {
429        let g = cycle4();
430        let spec = normalized_laplacian_spectrum(&g).unwrap();
431        for i in 1..spec.len() {
432            assert!(spec[i] >= spec[i - 1] - 1e-10);
433        }
434    }
435
436    #[test]
437    fn nls_first_is_zero() {
438        let g = path4();
439        let spec = normalized_laplacian_spectrum(&g).unwrap();
440        assert!(spec[0].abs() < 0.01);
441    }
442
443    #[test]
444    fn nls_bipartite_last_is_two() {
445        // Bipartite graphs have μ_n = 2
446        let g = k22();
447        let spec = normalized_laplacian_spectrum(&g).unwrap();
448        assert!((spec[spec.len() - 1] - 2.0).abs() < 0.01);
449    }
450
451    #[test]
452    fn nls_disconnected() {
453        let g = Graph::with_vertices(3);
454        let spec = normalized_laplacian_spectrum(&g).unwrap();
455        for &v in &spec {
456            assert!(v.abs() < 0.01);
457        }
458    }
459
460    #[test]
461    fn nls_directed_error() {
462        let g = Graph::from_edges(&[(0, 1)], true, Some(2)).unwrap();
463        assert!(normalized_laplacian_spectrum(&g).is_err());
464    }
465
466    // --- normalized_algebraic_connectivity ---
467
468    #[test]
469    fn nac_k3() {
470        let g = k3();
471        let mu2 = normalized_algebraic_connectivity(&g).unwrap();
472        assert!((mu2 - 1.5).abs() < 0.1);
473    }
474
475    #[test]
476    fn nac_k4() {
477        let g = k4();
478        let mu2 = normalized_algebraic_connectivity(&g).unwrap();
479        assert!((mu2 - 4.0 / 3.0).abs() < 0.1);
480    }
481
482    #[test]
483    fn nac_disconnected() {
484        let g = Graph::with_vertices(3);
485        let mu2 = normalized_algebraic_connectivity(&g).unwrap();
486        assert!(mu2.abs() < 0.01);
487    }
488
489    #[test]
490    fn nac_single() {
491        let g = Graph::with_vertices(1);
492        let mu2 = normalized_algebraic_connectivity(&g).unwrap();
493        assert!(mu2.abs() < 1e-10);
494    }
495
496    #[test]
497    fn nac_cycle4() {
498        let g = cycle4();
499        let mu2 = normalized_algebraic_connectivity(&g).unwrap();
500        // C_4: regular with degree 2, so normalized = combinatorial / degree
501        // Combinatorial eigenvalues {0, 2, 2, 4}, normalized {0, 1, 1, 2}
502        assert!((mu2 - 1.0).abs() < 0.1);
503    }
504
505    // --- cheeger_bounds ---
506
507    #[test]
508    fn cb_connected() {
509        let g = k3();
510        let (lo, hi) = cheeger_bounds(&g).unwrap();
511        assert!(lo >= 0.0);
512        assert!(lo <= hi + 1e-10);
513    }
514
515    #[test]
516    fn cb_disconnected() {
517        let g = Graph::with_vertices(3);
518        let (lo, hi) = cheeger_bounds(&g).unwrap();
519        assert!(lo.abs() < 0.01);
520        assert!(hi.abs() < 0.01);
521    }
522
523    #[test]
524    fn cb_single() {
525        let g = Graph::with_vertices(1);
526        let (lo, hi) = cheeger_bounds(&g).unwrap();
527        assert!(lo.abs() < 1e-10);
528        assert!(hi.abs() < 1e-10);
529    }
530
531    // --- spectral_gap_ratio ---
532
533    #[test]
534    fn sgr_k3() {
535        let g = k3();
536        let r = spectral_gap_ratio(&g).unwrap();
537        // K_3: μ_2 = μ_3 = 3/2, ratio = 1.0
538        assert!((r - 1.0).abs() < 0.1);
539    }
540
541    #[test]
542    fn sgr_k4() {
543        let g = k4();
544        let r = spectral_gap_ratio(&g).unwrap();
545        // All non-zero eigenvalues equal → ratio = 1.0
546        assert!((r - 1.0).abs() < 0.1);
547    }
548
549    #[test]
550    fn sgr_path() {
551        let g = path4();
552        let r = spectral_gap_ratio(&g).unwrap();
553        assert!(r > 0.0 && r <= 1.0 + 1e-10);
554    }
555
556    #[test]
557    fn sgr_single() {
558        let g = Graph::with_vertices(1);
559        let r = spectral_gap_ratio(&g).unwrap();
560        assert!(r.abs() < 1e-10);
561    }
562
563    #[test]
564    fn sgr_disconnected() {
565        let g = Graph::with_vertices(3);
566        let r = spectral_gap_ratio(&g).unwrap();
567        assert!(r.abs() < 1e-10);
568    }
569
570    // --- bipartiteness_ratio ---
571
572    #[test]
573    fn br_bipartite() {
574        let g = k22();
575        let br = bipartiteness_ratio(&g).unwrap();
576        assert!(br.abs() < 0.01);
577    }
578
579    #[test]
580    fn br_path_bipartite() {
581        let g = path4();
582        let br = bipartiteness_ratio(&g).unwrap();
583        assert!(br.abs() < 0.01);
584    }
585
586    #[test]
587    fn br_k3_nonbipartite() {
588        let g = k3();
589        let br = bipartiteness_ratio(&g).unwrap();
590        // μ_n = 3/2, ratio = (2 - 3/2) / 2 = 1/4
591        assert!((br - 0.25).abs() < 0.05);
592    }
593
594    #[test]
595    fn br_k4_nonbipartite() {
596        let g = k4();
597        let br = bipartiteness_ratio(&g).unwrap();
598        // μ_n = 4/3, ratio = (2 - 4/3)/2 = 1/3
599        assert!((br - 1.0 / 3.0).abs() < 0.05);
600    }
601
602    #[test]
603    fn br_nonneg() {
604        let g = cycle4();
605        let br = bipartiteness_ratio(&g).unwrap();
606        assert!(br >= -0.01);
607    }
608
609    // --- cross-consistency ---
610
611    #[test]
612    fn nac_equals_second_eigenvalue() {
613        let g = star4();
614        let mu2 = normalized_algebraic_connectivity(&g).unwrap();
615        let spec = normalized_laplacian_spectrum(&g).unwrap();
616        assert!((mu2 - spec[1]).abs() < 0.01);
617    }
618
619    #[test]
620    fn regular_graph_normalized_equals_scaled_combinatorial() {
621        // For k-regular graphs: normalized eigenvalues = combinatorial / k
622        let g = cycle4(); // 2-regular
623        let spec = normalized_laplacian_spectrum(&g).unwrap();
624        // Combinatorial eigenvalues of C_4: {0, 2, 2, 4}
625        // Normalized: {0, 1, 1, 2}
626        assert!(spec[0].abs() < 0.01);
627        assert!((spec[1] - 1.0).abs() < 0.1);
628        assert!((spec[2] - 1.0).abs() < 0.1);
629        assert!((spec[3] - 2.0).abs() < 0.1);
630    }
631
632    #[test]
633    fn trace_equals_nontrivial_vertex_count() {
634        // tr(L_norm) = number of non-isolated vertices
635        let g = star4();
636        let spec = normalized_laplacian_spectrum(&g).unwrap();
637        let trace: f64 = spec.iter().sum();
638        assert!((trace - 4.0).abs() < 0.1);
639    }
640}