Skip to main content

rust_igraph/algorithms/properties/
modularity_ratios.rs

1//! Modularity-based ratio indices (ALGO-TR-117).
2//!
3//! Measures based on community structure and modularity concepts:
4//!
5//! - **Modularity upper bound ratio** — actual modularity of a greedy
6//!   partition / theoretical maximum modularity
7//! - **Community size balance** — entropy of community size distribution
8//!   normalized by log(k) where k = number of communities
9//! - **Inter-community edge ratio** — fraction of edges that connect
10//!   vertices in different communities
11
12#![allow(
13    clippy::cast_lossless,
14    clippy::cast_possible_truncation,
15    clippy::cast_precision_loss,
16    clippy::many_single_char_names,
17    clippy::needless_range_loop,
18    clippy::similar_names,
19    clippy::too_many_lines
20)]
21
22use crate::core::{Graph, IgraphResult};
23
24/// Compute the modularity upper bound ratio.
25///
26/// Runs a greedy label-propagation-style partition (assign each vertex
27/// to the community of its most frequent neighbor label), then computes
28/// modularity Q of that partition divided by the theoretical maximum
29/// (1 - 1/k where k is the number of communities found). Values near 1
30/// indicate the graph is highly modular; values near 0 indicate weak
31/// community structure. Returns 0.0 for trivial or edgeless graphs.
32///
33/// # Examples
34///
35/// ```
36/// use rust_igraph::{Graph, modularity_upper_bound_ratio};
37///
38/// // Two disconnected K_2s: perfect community structure
39/// let g = Graph::from_edges(&[(0,1),(2,3)], false, Some(4)).unwrap();
40/// let r = modularity_upper_bound_ratio(&g).unwrap();
41/// assert!(r > 0.5);
42/// ```
43pub fn modularity_upper_bound_ratio(graph: &Graph) -> IgraphResult<f64> {
44    let n = graph.vcount() as usize;
45    let m = graph.ecount();
46    if n < 2 || m == 0 {
47        return Ok(0.0);
48    }
49
50    let membership = greedy_communities(graph, n)?;
51    let q = compute_modularity(graph, n, m, &membership)?;
52
53    let k = *membership.iter().max().unwrap_or(&0) + 1;
54    if k <= 1 {
55        return Ok(0.0);
56    }
57
58    let q_max = 1.0 - 1.0 / k as f64;
59    if q_max < 1e-30 {
60        return Ok(0.0);
61    }
62
63    Ok((q / q_max).clamp(0.0, 1.0))
64}
65
66/// Compute the community size balance.
67///
68/// Entropy of the community size distribution (from greedy partition)
69/// normalized by log(k). Values near 1 indicate balanced community
70/// sizes; values near 0 indicate one dominant community. Returns 0.0
71/// for trivial graphs or when only one community exists.
72///
73/// # Examples
74///
75/// ```
76/// use rust_igraph::{Graph, community_size_balance};
77///
78/// // Two disconnected K_2s: 2 communities of equal size → balance = 1.0
79/// let g = Graph::from_edges(&[(0,1),(2,3)], false, Some(4)).unwrap();
80/// let r = community_size_balance(&g).unwrap();
81/// assert!((r - 1.0).abs() < 0.1);
82/// ```
83pub fn community_size_balance(graph: &Graph) -> IgraphResult<f64> {
84    let n = graph.vcount() as usize;
85    if n < 2 {
86        return Ok(0.0);
87    }
88
89    let m = graph.ecount();
90    if m == 0 {
91        return Ok(0.0);
92    }
93
94    let membership = greedy_communities(graph, n)?;
95    let k = *membership.iter().max().unwrap_or(&0) + 1;
96    if k <= 1 {
97        return Ok(0.0);
98    }
99
100    let mut sizes = vec![0_u64; k];
101    for &c in &membership {
102        sizes[c] += 1;
103    }
104
105    let n_f = n as f64;
106    let mut entropy = 0.0_f64;
107    for &s in &sizes {
108        if s > 0 {
109            let p = s as f64 / n_f;
110            entropy -= p * p.ln();
111        }
112    }
113
114    let max_entropy = (k as f64).ln();
115    if max_entropy < 1e-30 {
116        return Ok(0.0);
117    }
118
119    Ok(entropy / max_entropy)
120}
121
122/// Compute the inter-community edge ratio.
123///
124/// Fraction of edges whose endpoints belong to different communities
125/// (using greedy partition). Values near 0 indicate strong community
126/// structure (few inter-community edges); values near 1 indicate weak
127/// or no community structure. Returns 0.0 for trivial or edgeless graphs.
128///
129/// # Examples
130///
131/// ```
132/// use rust_igraph::{Graph, inter_community_edge_ratio};
133///
134/// // Two disconnected K_2s: no inter-community edges → 0.0
135/// let g = Graph::from_edges(&[(0,1),(2,3)], false, Some(4)).unwrap();
136/// assert!(inter_community_edge_ratio(&g).unwrap() < 0.01);
137/// ```
138pub fn inter_community_edge_ratio(graph: &Graph) -> IgraphResult<f64> {
139    let n = graph.vcount() as usize;
140    let m = graph.ecount();
141    if n < 2 || m == 0 {
142        return Ok(0.0);
143    }
144
145    let membership = greedy_communities(graph, n)?;
146
147    let mut inter_edges = 0_u64;
148    for v in 0..n {
149        let nbrs = graph.neighbors(v as u32)?;
150        for &u in &nbrs {
151            let ui = u as usize;
152            if ui > v && membership[v] != membership[ui] {
153                inter_edges += 1;
154            }
155        }
156    }
157
158    Ok(inter_edges as f64 / m as f64)
159}
160
161/// Greedy community detection via label propagation (single pass).
162/// Each vertex starts in its own community; iteratively assigns each
163/// vertex to the most frequent community among its neighbors.
164fn greedy_communities(graph: &Graph, n: usize) -> IgraphResult<Vec<usize>> {
165    let mut membership: Vec<usize> = (0..n).collect();
166
167    for _ in 0..10 {
168        let mut changed = false;
169        for v in 0..n {
170            let nbrs = graph.neighbors(v as u32)?;
171            if nbrs.is_empty() {
172                continue;
173            }
174
175            let mut freq: std::collections::HashMap<usize, usize> =
176                std::collections::HashMap::new();
177            for &u in &nbrs {
178                *freq.entry(membership[u as usize]).or_insert(0) += 1;
179            }
180
181            let best_community = freq
182                .into_iter()
183                .max_by_key(|&(_, count)| count)
184                .map_or(membership[v], |(comm, _)| comm);
185
186            if best_community != membership[v] {
187                membership[v] = best_community;
188                changed = true;
189            }
190        }
191        if !changed {
192            break;
193        }
194    }
195
196    // Renumber communities to 0..k-1
197    let mut mapping: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
198    let mut next_id = 0_usize;
199    for v in 0..n {
200        let c = membership[v];
201        let new_id = *mapping.entry(c).or_insert_with(|| {
202            let id = next_id;
203            next_id += 1;
204            id
205        });
206        membership[v] = new_id;
207    }
208
209    Ok(membership)
210}
211
212/// Compute Newman-Girvan modularity Q for a given partition.
213fn compute_modularity(
214    graph: &Graph,
215    n: usize,
216    m: usize,
217    membership: &[usize],
218) -> IgraphResult<f64> {
219    if m == 0 {
220        return Ok(0.0);
221    }
222
223    let two_m = 2.0 * m as f64;
224    let mut degrees = Vec::with_capacity(n);
225    for v in 0..n {
226        degrees.push(graph.degree(v as u32)? as f64);
227    }
228
229    let k = *membership.iter().max().unwrap_or(&0) + 1;
230    let mut e_cc = vec![0.0_f64; k]; // edges within community c (counted once)
231    let mut a_c = vec![0.0_f64; k]; // sum of degrees in community c
232
233    for v in 0..n {
234        let c = membership[v];
235        a_c[c] += degrees[v];
236        let nbrs = graph.neighbors(v as u32)?;
237        for &u in &nbrs {
238            let ui = u as usize;
239            if ui > v && membership[ui] == c {
240                e_cc[c] += 1.0;
241            }
242        }
243    }
244
245    let mut q = 0.0_f64;
246    for c in 0..k {
247        q += e_cc[c] / (m as f64) - (a_c[c] / two_m).powi(2);
248    }
249
250    Ok(q)
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    fn empty() -> Graph {
258        Graph::with_vertices(0)
259    }
260
261    fn single() -> Graph {
262        Graph::with_vertices(1)
263    }
264
265    fn single_edge() -> Graph {
266        Graph::from_edges(&[(0, 1)], false, Some(2)).unwrap()
267    }
268
269    fn k3() -> Graph {
270        Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap()
271    }
272
273    fn k4() -> Graph {
274        Graph::from_edges(
275            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
276            false,
277            Some(4),
278        )
279        .unwrap()
280    }
281
282    fn cycle4() -> Graph {
283        Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 0)], false, Some(4)).unwrap()
284    }
285
286    fn star5() -> Graph {
287        Graph::from_edges(&[(0, 1), (0, 2), (0, 3), (0, 4)], false, Some(5)).unwrap()
288    }
289
290    fn two_triangles() -> Graph {
291        // Two K_3 connected by one edge: clear community structure
292        Graph::from_edges(
293            &[(0, 1), (0, 2), (1, 2), (2, 3), (3, 4), (3, 5), (4, 5)],
294            false,
295            Some(6),
296        )
297        .unwrap()
298    }
299
300    fn disconnected_k2s() -> Graph {
301        Graph::from_edges(&[(0, 1), (2, 3)], false, Some(4)).unwrap()
302    }
303
304    // --- modularity_upper_bound_ratio ---
305
306    #[test]
307    fn mubr_empty() {
308        assert!(modularity_upper_bound_ratio(&empty()).unwrap().abs() < 1e-10);
309    }
310
311    #[test]
312    fn mubr_single() {
313        assert!(modularity_upper_bound_ratio(&single()).unwrap().abs() < 1e-10);
314    }
315
316    #[test]
317    fn mubr_in_01() {
318        for g in &[
319            single_edge(),
320            k3(),
321            k4(),
322            cycle4(),
323            star5(),
324            two_triangles(),
325        ] {
326            let r = modularity_upper_bound_ratio(g).unwrap();
327            assert!(r >= -0.01);
328            assert!(r <= 1.01);
329        }
330    }
331
332    #[test]
333    fn mubr_disconnected_high() {
334        // Disconnected graphs should have high modularity
335        let r = modularity_upper_bound_ratio(&disconnected_k2s()).unwrap();
336        assert!(r > 0.5);
337    }
338
339    #[test]
340    fn mubr_finite() {
341        for g in &[
342            single_edge(),
343            k3(),
344            k4(),
345            cycle4(),
346            star5(),
347            two_triangles(),
348        ] {
349            assert!(modularity_upper_bound_ratio(g).unwrap().is_finite());
350        }
351    }
352
353    // --- community_size_balance ---
354
355    #[test]
356    fn csb_empty() {
357        assert!(community_size_balance(&empty()).unwrap().abs() < 1e-10);
358    }
359
360    #[test]
361    fn csb_single() {
362        assert!(community_size_balance(&single()).unwrap().abs() < 1e-10);
363    }
364
365    #[test]
366    fn csb_disconnected() {
367        // Two K_2s → 2 communities of size 2 → perfect balance
368        let r = community_size_balance(&disconnected_k2s()).unwrap();
369        assert!((r - 1.0).abs() < 0.1);
370    }
371
372    #[test]
373    fn csb_in_01() {
374        for g in &[
375            single_edge(),
376            k3(),
377            k4(),
378            cycle4(),
379            star5(),
380            two_triangles(),
381        ] {
382            let r = community_size_balance(g).unwrap();
383            assert!(r >= -0.01);
384            assert!(r <= 1.01);
385        }
386    }
387
388    #[test]
389    fn csb_finite() {
390        for g in &[single_edge(), k3(), k4(), cycle4(), star5()] {
391            assert!(community_size_balance(g).unwrap().is_finite());
392        }
393    }
394
395    // --- inter_community_edge_ratio ---
396
397    #[test]
398    fn icer_empty() {
399        assert!(inter_community_edge_ratio(&empty()).unwrap().abs() < 1e-10);
400    }
401
402    #[test]
403    fn icer_single() {
404        assert!(inter_community_edge_ratio(&single()).unwrap().abs() < 1e-10);
405    }
406
407    #[test]
408    fn icer_disconnected() {
409        // Two K_2s → 0 inter-community edges
410        assert!(
411            inter_community_edge_ratio(&disconnected_k2s())
412                .unwrap()
413                .abs()
414                < 1e-10
415        );
416    }
417
418    #[test]
419    fn icer_in_01() {
420        for g in &[
421            single_edge(),
422            k3(),
423            k4(),
424            cycle4(),
425            star5(),
426            two_triangles(),
427        ] {
428            let r = inter_community_edge_ratio(g).unwrap();
429            assert!(r >= -0.01);
430            assert!(r <= 1.01);
431        }
432    }
433
434    #[test]
435    fn icer_finite() {
436        for g in &[single_edge(), k3(), k4(), cycle4(), star5()] {
437            assert!(inter_community_edge_ratio(g).unwrap().is_finite());
438        }
439    }
440
441    // --- cross-consistency ---
442
443    #[test]
444    fn disconnected_strong_community() {
445        let g = disconnected_k2s();
446        // Should have high modularity, balanced sizes, zero inter edges
447        assert!(modularity_upper_bound_ratio(&g).unwrap() > 0.5);
448        assert!(community_size_balance(&g).unwrap() > 0.8);
449        assert!(inter_community_edge_ratio(&g).unwrap() < 0.01);
450    }
451
452    #[test]
453    fn complete_weak_community() {
454        // K_4 has no clear community structure
455        let r = inter_community_edge_ratio(&k4()).unwrap();
456        // Complete graph in one community → 0 inter edges
457        // Or if split, most edges are inter → depends on algorithm
458        assert!(r.is_finite());
459    }
460}