Skip to main content

rust_igraph/algorithms/properties/
graph_entropy.rs

1//! Graph structural entropy and complexity measures (ALGO-TR-016).
2//!
3//! Information-theoretic descriptors of graph structure, used as
4//! graph-level features in graph classification, complexity analysis,
5//! and for characterizing random graph ensembles.
6//!
7//! - **Degree entropy**: Shannon entropy of the degree distribution
8//! - **Edge entropy**: entropy based on edge endpoint degree product
9//! - **Von Neumann entropy**: quantum graph entropy from normalized Laplacian eigenvalues
10//!   (approximated via trace formula)
11//! - **Structural information content**: log of the number of automorphisms
12//!   approximation via degree sequence partition
13
14#![allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
15
16use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
17
18/// Shannon entropy of the degree distribution.
19///
20/// `H_degree = -Σ_k p(k) log2(p(k))`
21///
22/// where `p(k)` is the fraction of vertices with degree `k`.
23/// Higher entropy indicates a more heterogeneous degree distribution.
24///
25/// # Examples
26///
27/// ```
28/// use rust_igraph::{Graph, degree_entropy};
29///
30/// // Regular graph (cycle): all degrees equal → entropy = 0
31/// let g = Graph::from_edges(&[(0,1),(1,2),(2,3),(3,0)], false, Some(4)).unwrap();
32/// let h = degree_entropy(&g).unwrap();
33/// assert!(h.abs() < 1e-10);
34/// ```
35pub fn degree_entropy(graph: &Graph) -> IgraphResult<f64> {
36    let nv = graph.vcount() as usize;
37    if nv == 0 {
38        return Ok(0.0);
39    }
40
41    let mut degrees = Vec::with_capacity(nv);
42    let mut max_deg = 0usize;
43    for v in 0..nv {
44        let d = graph.degree(v as VertexId)?;
45        if d > max_deg {
46            max_deg = d;
47        }
48        degrees.push(d);
49    }
50
51    // Build degree histogram
52    let mut hist = vec![0usize; max_deg + 1];
53    for &d in &degrees {
54        hist[d] += 1;
55    }
56
57    // Compute Shannon entropy
58    let n_f64 = nv as f64;
59    let mut entropy = 0.0;
60    for &count in &hist {
61        if count > 0 {
62            let p = count as f64 / n_f64;
63            entropy -= p * p.log2();
64        }
65    }
66
67    Ok(entropy)
68}
69
70/// Shannon entropy of the edge-degree distribution.
71///
72/// `H_edge = -Σ_e p(e) log2(p(e))`
73///
74/// where `p(e)` is the normalized weight of edge `e` defined as
75/// `1 / (deg(u) * deg(v))` divided by the sum of all such weights.
76/// Captures how "uniformly" connectivity is distributed across edges.
77///
78/// # Examples
79///
80/// ```
81/// use rust_igraph::{Graph, edge_entropy};
82///
83/// // Star graph: heterogeneous edge weights → lower entropy than regular
84/// let star = Graph::from_edges(&[(0,1),(0,2),(0,3)], false, Some(4)).unwrap();
85/// let h = edge_entropy(&star).unwrap();
86/// assert!(h > 0.0);
87/// ```
88pub fn edge_entropy(graph: &Graph) -> IgraphResult<f64> {
89    let nv = graph.vcount() as usize;
90    let ne = graph.ecount();
91    if ne == 0 {
92        return Ok(0.0);
93    }
94
95    let mut degrees = Vec::with_capacity(nv);
96    for v in 0..nv {
97        degrees.push(graph.degree(v as VertexId)?);
98    }
99
100    // Compute unnormalized weights
101    let mut weights = Vec::with_capacity(ne);
102    let mut total_weight = 0.0;
103    for (u, v) in graph.edges() {
104        let du = degrees[u as usize];
105        let dv = degrees[v as usize];
106        let w = if du > 0 && dv > 0 {
107            1.0 / (du as f64 * dv as f64)
108        } else {
109            0.0
110        };
111        weights.push(w);
112        total_weight += w;
113    }
114
115    if total_weight <= 0.0 {
116        return Ok(0.0);
117    }
118
119    // Compute entropy of normalized distribution
120    let mut entropy = 0.0;
121    for &w in &weights {
122        if w > 0.0 {
123            let p = w / total_weight;
124            entropy -= p * p.log2();
125        }
126    }
127
128    Ok(entropy)
129}
130
131/// Approximate Von Neumann entropy of the graph.
132///
133/// The Von Neumann entropy is `S = -Σ_i λ_i log2(λ_i)` where `λ_i` are
134/// the eigenvalues of the density matrix `ρ = L / trace(L)` with `L` being
135/// the combinatorial Laplacian.
136///
137/// Since `trace(L) = 2|E|` and eigenvalues sum to `trace(L)`,
138/// we use the quadratic approximation:
139/// `S ≈ 1 - (1/|V|) - (1/(2|E|)²) Σ_v deg(v)²`  (in bits)
140///
141/// This avoids expensive eigendecomposition while capturing the essential
142/// structural complexity.
143///
144/// # Examples
145///
146/// ```
147/// use rust_igraph::{Graph, von_neumann_entropy};
148///
149/// // Complete graph has high structural complexity
150/// let k4 = Graph::from_edges(
151///     &[(0,1),(0,2),(0,3),(1,2),(1,3),(2,3)], false, Some(4)
152/// ).unwrap();
153/// let h = von_neumann_entropy(&k4).unwrap();
154/// assert!(h > 0.0);
155/// ```
156pub fn von_neumann_entropy(graph: &Graph) -> IgraphResult<f64> {
157    if graph.is_directed() {
158        return Err(IgraphError::InvalidArgument(
159            "von_neumann_entropy is defined for undirected graphs only".to_string(),
160        ));
161    }
162
163    let nv = graph.vcount() as usize;
164    let ne = graph.ecount();
165
166    if nv == 0 || ne == 0 {
167        return Ok(0.0);
168    }
169
170    let mut sum_deg_sq = 0.0_f64;
171    for v in 0..nv {
172        let d = graph.degree(v as VertexId)? as f64;
173        sum_deg_sq += d * d;
174    }
175
176    let two_m = 2.0 * ne as f64;
177
178    // Normalized Laplacian eigenvalue sum of squares:
179    // Σ λ_i² = Σ_v (1 + deg(v)²/(2m)) for normalized Laplacian
180    // For the density matrix ρ = L_norm / n:
181    // S ≈ 1 - 1/n - (1/(4m²)) * Σ_v deg(v)²
182    let entropy = 1.0 - 1.0 / nv as f64 - sum_deg_sq / (two_m * two_m);
183
184    // Clamp to non-negative (approximation can go slightly negative for sparse graphs)
185    Ok(entropy.max(0.0))
186}
187
188/// Structural information content based on degree sequence.
189///
190/// `I = log2(|V|!) - Σ_k log2(n_k!)`
191///
192/// where `n_k` is the number of vertices with degree `k`.
193/// This approximates the logarithm of the automorphism group size
194/// from the degree partition alone.
195///
196/// # Examples
197///
198/// ```
199/// use rust_igraph::{Graph, degree_structural_info};
200///
201/// // Regular graph: all vertices equivalent → I = 0
202/// let g = Graph::from_edges(&[(0,1),(1,2),(2,3),(3,0)], false, Some(4)).unwrap();
203/// let info = degree_structural_info(&g).unwrap();
204/// assert!(info.abs() < 1e-10);
205/// ```
206pub fn degree_structural_info(graph: &Graph) -> IgraphResult<f64> {
207    let nv = graph.vcount() as usize;
208    if nv <= 1 {
209        return Ok(0.0);
210    }
211
212    let mut max_deg = 0usize;
213    let mut degrees = Vec::with_capacity(nv);
214    for v in 0..nv {
215        let d = graph.degree(v as VertexId)?;
216        if d > max_deg {
217            max_deg = d;
218        }
219        degrees.push(d);
220    }
221
222    // Build degree histogram
223    let mut hist = vec![0usize; max_deg + 1];
224    for &d in &degrees {
225        hist[d] += 1;
226    }
227
228    // I = log2(n!) - Σ_k log2(n_k!)
229    let log_n_fact = log2_factorial(nv);
230    let mut sum_log_nk_fact = 0.0;
231    for &count in &hist {
232        if count > 1 {
233            sum_log_nk_fact += log2_factorial(count);
234        }
235    }
236
237    Ok(log_n_fact - sum_log_nk_fact)
238}
239
240// --- Internal helpers ---
241
242fn log2_factorial(n: usize) -> f64 {
243    // Stirling for large n, exact sum for small n
244    if n <= 1 {
245        return 0.0;
246    }
247    if n <= 20 {
248        let mut result = 0.0;
249        for i in 2..=n {
250            result += (i as f64).log2();
251        }
252        return result;
253    }
254    // Stirling's approximation: log2(n!) ≈ n*log2(n) - n*log2(e) + 0.5*log2(2πn)
255    let nf = n as f64;
256    nf * nf.log2() - nf * std::f64::consts::E.log2()
257        + 0.5 * (2.0 * std::f64::consts::PI * nf).log2()
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    fn cycle4() -> Graph {
265        Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 0)], false, Some(4)).unwrap()
266    }
267
268    fn path4() -> Graph {
269        Graph::from_edges(&[(0, 1), (1, 2), (2, 3)], false, Some(4)).unwrap()
270    }
271
272    fn star4() -> Graph {
273        Graph::from_edges(&[(0, 1), (0, 2), (0, 3)], false, Some(4)).unwrap()
274    }
275
276    fn complete4() -> Graph {
277        Graph::from_edges(
278            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
279            false,
280            Some(4),
281        )
282        .unwrap()
283    }
284
285    // --- degree_entropy tests ---
286
287    #[test]
288    fn degree_entropy_regular() {
289        let g = cycle4();
290        let h = degree_entropy(&g).unwrap();
291        // All degrees equal → single bin → entropy = 0
292        assert!(h.abs() < 1e-10);
293    }
294
295    #[test]
296    fn degree_entropy_star() {
297        let g = star4();
298        let h = degree_entropy(&g).unwrap();
299        // Degrees: [3, 1, 1, 1] → p(1)=3/4, p(3)=1/4
300        let expected = -(3.0 / 4.0) * (3.0_f64 / 4.0).log2() - (1.0 / 4.0) * (1.0_f64 / 4.0).log2();
301        assert!((h - expected).abs() < 1e-10);
302    }
303
304    #[test]
305    fn degree_entropy_path() {
306        let g = path4();
307        let h = degree_entropy(&g).unwrap();
308        // Degrees: [1, 2, 2, 1] → p(1)=2/4, p(2)=2/4
309        let expected = -2.0 * (0.5 * 0.5_f64.log2());
310        assert!((h - expected).abs() < 1e-10);
311    }
312
313    #[test]
314    fn degree_entropy_empty() {
315        let g = Graph::with_vertices(0);
316        let h = degree_entropy(&g).unwrap();
317        assert!(h.abs() < 1e-10);
318    }
319
320    #[test]
321    fn degree_entropy_isolated() {
322        let g = Graph::with_vertices(5);
323        let h = degree_entropy(&g).unwrap();
324        // All degree 0 → single bin → entropy = 0
325        assert!(h.abs() < 1e-10);
326    }
327
328    // --- edge_entropy tests ---
329
330    #[test]
331    fn edge_entropy_regular() {
332        let g = cycle4();
333        let h = edge_entropy(&g).unwrap();
334        // All edges have same weight → uniform → entropy = log2(4)
335        let expected = 4.0_f64.log2();
336        assert!((h - expected).abs() < 1e-10);
337    }
338
339    #[test]
340    fn edge_entropy_empty() {
341        let g = Graph::with_vertices(3);
342        let h = edge_entropy(&g).unwrap();
343        assert!(h.abs() < 1e-10);
344    }
345
346    #[test]
347    fn edge_entropy_positive() {
348        let g = star4();
349        let h = edge_entropy(&g).unwrap();
350        assert!(h > 0.0);
351    }
352
353    // --- von_neumann_entropy tests ---
354
355    #[test]
356    fn vne_positive_for_complex() {
357        let g = complete4();
358        let h = von_neumann_entropy(&g).unwrap();
359        assert!(h > 0.0);
360    }
361
362    #[test]
363    fn vne_empty() {
364        let g = Graph::with_vertices(3);
365        let h = von_neumann_entropy(&g).unwrap();
366        assert!(h.abs() < 1e-10);
367    }
368
369    #[test]
370    fn vne_directed_error() {
371        let g = Graph::from_edges(&[(0, 1)], true, Some(2)).unwrap();
372        assert!(von_neumann_entropy(&g).is_err());
373    }
374
375    #[test]
376    fn vne_non_negative() {
377        let g = path4();
378        let h = von_neumann_entropy(&g).unwrap();
379        assert!(h >= 0.0);
380    }
381
382    // --- degree_structural_info tests ---
383
384    #[test]
385    fn dsi_regular_zero() {
386        let g = cycle4();
387        let info = degree_structural_info(&g).unwrap();
388        // All vertices same degree → info = 0
389        assert!(info.abs() < 1e-10);
390    }
391
392    #[test]
393    fn dsi_star_positive() {
394        let g = star4();
395        let info = degree_structural_info(&g).unwrap();
396        // Degrees [3,1,1,1]: log2(4!) - log2(3!) - log2(1!)
397        let expected = log2_factorial(4) - log2_factorial(3);
398        assert!((info - expected).abs() < 1e-10);
399    }
400
401    #[test]
402    fn dsi_all_different() {
403        // Path of 4: degrees [1,2,2,1] → log2(4!) - log2(2!) - log2(2!)
404        let g = path4();
405        let info = degree_structural_info(&g).unwrap();
406        let expected = log2_factorial(4) - 2.0 * log2_factorial(2);
407        assert!((info - expected).abs() < 1e-10);
408    }
409
410    #[test]
411    fn dsi_single_vertex() {
412        let g = Graph::with_vertices(1);
413        let info = degree_structural_info(&g).unwrap();
414        assert!(info.abs() < 1e-10);
415    }
416
417    #[test]
418    fn dsi_empty() {
419        let g = Graph::with_vertices(0);
420        let info = degree_structural_info(&g).unwrap();
421        assert!(info.abs() < 1e-10);
422    }
423}