Skip to main content

scirs2_graph/community/
evaluation.rs

1//! Community quality evaluation metrics.
2//!
3//! This module provides standard metrics to assess the quality of a community
4//! partition, both intrinsically (without ground truth) and extrinsically
5//! (compared to a reference labelling).
6//!
7//! ## Intrinsic metrics
8//! - [`modularity`]: Newman-Girvan modularity Q
9//! - [`conductance`]: Cut fraction relative to minimum volume
10//! - [`coverage`]: Fraction of intra-community edges
11//! - [`normalized_cut`]: Normalised cut value
12//!
13//! ## Extrinsic metrics
14//! - [`nmi`]: Normalised Mutual Information
15//! - [`adjusted_rand_index`]: Adjusted Rand Index
16
17use std::collections::HashMap;
18
19use crate::error::{GraphError, Result};
20
21// ─────────────────────────────────────────────────────────────────────────────
22// Internal helpers
23// ─────────────────────────────────────────────────────────────────────────────
24
25/// Aggregate edge-list statistics: returns
26/// `(total_weight, intra_weight, degree[n], n_comms)`.
27fn graph_stats(
28    edges: &[(usize, usize, f64)],
29    n_nodes: usize,
30    communities: &[usize],
31) -> (f64, f64, Vec<f64>, usize) {
32    let n_comms = communities.iter().max().copied().unwrap_or(0) + 1;
33    let mut degree = vec![0.0f64; n_nodes];
34    let mut total_w = 0.0f64;
35    let mut intra_w = 0.0f64;
36
37    for &(u, v, w) in edges {
38        if u >= n_nodes || v >= n_nodes {
39            continue;
40        }
41        degree[u] += w;
42        if u != v {
43            degree[v] += w;
44        }
45        total_w += 2.0 * w;
46        if communities[u] == communities[v] {
47            intra_w += 2.0 * w;
48        }
49    }
50    (total_w, intra_w, degree, n_comms)
51}
52
53// ─────────────────────────────────────────────────────────────────────────────
54// Modularity
55// ─────────────────────────────────────────────────────────────────────────────
56
57/// Compute Newman-Girvan modularity Q for a partition of an edge-list graph.
58///
59/// `Q = 1/(2m) · Σ_{i,j} [A_{ij} − k_i·k_j/(2m)] · δ(c_i, c_j)`
60///
61/// # Arguments
62/// * `edges`       – Weighted edge list `(src, dst, weight)`.
63/// * `n_nodes`     – Total number of nodes.
64/// * `communities` – Community assignment for each node.
65pub fn modularity(edges: &[(usize, usize, f64)], n_nodes: usize, communities: &[usize]) -> f64 {
66    if n_nodes == 0 || communities.len() != n_nodes {
67        return 0.0;
68    }
69    let (two_m, intra_w, degree, n_comms) = graph_stats(edges, n_nodes, communities);
70    if two_m == 0.0 {
71        return 0.0;
72    }
73
74    // Sum of squared community degrees
75    let mut comm_degree = vec![0.0f64; n_comms];
76    for i in 0..n_nodes {
77        if communities[i] < n_comms {
78            comm_degree[communities[i]] += degree[i];
79        }
80    }
81    let sq_sum: f64 = comm_degree.iter().map(|&d| d * d).sum();
82
83    (intra_w / two_m) - (sq_sum / (two_m * two_m))
84}
85
86// ─────────────────────────────────────────────────────────────────────────────
87// Conductance
88// ─────────────────────────────────────────────────────────────────────────────
89
90/// Compute the conductance of a single community (set of node indices).
91///
92/// `φ(S) = cut(S, S̄) / min(vol(S), vol(S̄))`
93///
94/// where `vol(S) = Σ_{i∈S} k_i` is the volume of the set.
95///
96/// # Arguments
97/// * `edges`     – Weighted edge list `(src, dst, weight)`.
98/// * `n_nodes`   – Total number of nodes.
99/// * `community` – Set of node indices forming the community.
100///
101/// # Returns
102/// Conductance in `[0, 1]`; returns 1.0 if the community is empty or has zero volume.
103pub fn conductance(
104    edges: &[(usize, usize, f64)],
105    n_nodes: usize,
106    community: &[usize],
107) -> Result<f64> {
108    if community.is_empty() {
109        return Ok(1.0);
110    }
111    let in_community: std::collections::HashSet<usize> = community.iter().cloned().collect();
112
113    let mut degree = vec![0.0f64; n_nodes];
114    let mut cut = 0.0f64;
115
116    for &(u, v, w) in edges {
117        if u >= n_nodes || v >= n_nodes {
118            continue;
119        }
120        degree[u] += w;
121        if u != v {
122            degree[v] += w;
123        }
124        // Undirected: count both u→v and v→u
125        let u_in = in_community.contains(&u);
126        let v_in = in_community.contains(&v);
127        if u_in != v_in {
128            cut += w; // count once for undirected
129        }
130    }
131
132    let vol_s: f64 = community
133        .iter()
134        .filter(|&&n| n < n_nodes)
135        .map(|&n| degree[n])
136        .sum();
137    let vol_total: f64 = degree.iter().sum();
138    let vol_s_bar = vol_total - vol_s;
139
140    let min_vol = vol_s.min(vol_s_bar);
141    if min_vol == 0.0 {
142        return Ok(1.0);
143    }
144    Ok(cut / min_vol)
145}
146
147// ─────────────────────────────────────────────────────────────────────────────
148// Coverage
149// ─────────────────────────────────────────────────────────────────────────────
150
151/// Compute coverage: the fraction of edge weight inside communities.
152///
153/// `coverage = Σ_{intra} w_{ij} / Σ_{all} w_{ij}`
154///
155/// # Arguments
156/// * `edges`       – Weighted edge list.
157/// * `n_nodes`     – Total number of nodes.
158/// * `communities` – Community assignment for each node.
159pub fn coverage(edges: &[(usize, usize, f64)], n_nodes: usize, communities: &[usize]) -> f64 {
160    if edges.is_empty() || communities.len() != n_nodes {
161        return 0.0;
162    }
163    let mut total = 0.0f64;
164    let mut intra = 0.0f64;
165    for &(u, v, w) in edges {
166        if u >= n_nodes || v >= n_nodes {
167            continue;
168        }
169        total += w;
170        if communities[u] == communities[v] {
171            intra += w;
172        }
173    }
174    if total == 0.0 {
175        0.0
176    } else {
177        intra / total
178    }
179}
180
181// ─────────────────────────────────────────────────────────────────────────────
182// Normalised cut
183// ─────────────────────────────────────────────────────────────────────────────
184
185/// Compute the normalised cut value for a partition.
186///
187/// `NCut(M) = Σ_i  cut(i, Ī) / vol(i)`
188///
189/// where `cut(i, Ī)` is the weight of edges leaving community `i` and
190/// `vol(i)` is the sum of degrees of all nodes in community `i`.
191///
192/// # Arguments
193/// * `edges`       – Weighted edge list.
194/// * `n_nodes`     – Total number of nodes.
195/// * `communities` – Community assignment for each node.
196pub fn normalized_cut(edges: &[(usize, usize, f64)], n_nodes: usize, communities: &[usize]) -> f64 {
197    if communities.len() != n_nodes {
198        return 0.0;
199    }
200    let n_comms = communities.iter().max().copied().unwrap_or(0) + 1;
201
202    let mut degree = vec![0.0f64; n_nodes];
203    for &(u, v, w) in edges {
204        if u < n_nodes && v < n_nodes {
205            degree[u] += w;
206            if u != v {
207                degree[v] += w;
208            }
209        }
210    }
211
212    let mut vol_comm = vec![0.0f64; n_comms];
213    for i in 0..n_nodes {
214        if communities[i] < n_comms {
215            vol_comm[communities[i]] += degree[i];
216        }
217    }
218
219    let mut cut_comm = vec![0.0f64; n_comms];
220    for &(u, v, w) in edges {
221        if u >= n_nodes || v >= n_nodes {
222            continue;
223        }
224        if communities[u] != communities[v] {
225            cut_comm[communities[u]] += w;
226            cut_comm[communities[v]] += w;
227        }
228    }
229
230    let ncut: f64 = (0..n_comms)
231        .filter(|&c| vol_comm[c] > 0.0)
232        .map(|c| cut_comm[c] / vol_comm[c])
233        .sum();
234    ncut
235}
236
237// ─────────────────────────────────────────────────────────────────────────────
238// Normalised Mutual Information
239// ─────────────────────────────────────────────────────────────────────────────
240
241/// Compute Normalised Mutual Information (NMI) between two label vectors.
242///
243/// `NMI(X, Y) = 2 · I(X;Y) / [H(X) + H(Y)]`
244///
245/// Returns a value in `[0, 1]` where 1.0 indicates perfect agreement.
246///
247/// # Arguments
248/// * `true_labels` – Ground-truth community labels.
249/// * `pred_labels` – Predicted community labels.
250pub fn nmi(true_labels: &[usize], pred_labels: &[usize]) -> Result<f64> {
251    let n = true_labels.len();
252    if n != pred_labels.len() {
253        return Err(GraphError::InvalidParameter {
254            param: "pred_labels".into(),
255            value: format!("len={}", pred_labels.len()),
256            expected: format!("len={n}"),
257            context: "nmi".into(),
258        });
259    }
260    if n == 0 {
261        return Ok(1.0);
262    }
263
264    let fn64 = n as f64;
265
266    // Count contingency table
267    let mut contingency: HashMap<(usize, usize), usize> = HashMap::new();
268    let mut true_counts: HashMap<usize, usize> = HashMap::new();
269    let mut pred_counts: HashMap<usize, usize> = HashMap::new();
270
271    for i in 0..n {
272        *contingency
273            .entry((true_labels[i], pred_labels[i]))
274            .or_insert(0) += 1;
275        *true_counts.entry(true_labels[i]).or_insert(0) += 1;
276        *pred_counts.entry(pred_labels[i]).or_insert(0) += 1;
277    }
278
279    // Mutual information
280    let mi: f64 = contingency
281        .iter()
282        .map(|(&(t, p), &cnt)| {
283            let n_tp = cnt as f64;
284            let n_t = *true_counts.get(&t).unwrap_or(&1) as f64;
285            let n_p = *pred_counts.get(&p).unwrap_or(&1) as f64;
286            if n_tp > 0.0 {
287                n_tp / fn64 * (n_tp * fn64 / (n_t * n_p)).ln()
288            } else {
289                0.0
290            }
291        })
292        .sum();
293
294    // Entropies
295    let h_true: f64 = true_counts
296        .values()
297        .map(|&c| {
298            let p = c as f64 / fn64;
299            if p > 0.0 {
300                -p * p.ln()
301            } else {
302                0.0
303            }
304        })
305        .sum();
306
307    let h_pred: f64 = pred_counts
308        .values()
309        .map(|&c| {
310            let p = c as f64 / fn64;
311            if p > 0.0 {
312                -p * p.ln()
313            } else {
314                0.0
315            }
316        })
317        .sum();
318
319    let denom = h_true + h_pred;
320    if denom == 0.0 {
321        Ok(1.0)
322    } else {
323        Ok((2.0 * mi / denom).clamp(0.0, 1.0))
324    }
325}
326
327// ─────────────────────────────────────────────────────────────────────────────
328// Adjusted Rand Index
329// ─────────────────────────────────────────────────────────────────────────────
330
331/// Compute the Adjusted Rand Index (ARI) between two label vectors.
332///
333/// `ARI = (RI - E[RI]) / (max(RI) - E[RI])`
334///
335/// Returns a value in `[-1, 1]` where 1.0 is perfect, 0.0 is random.
336///
337/// # Arguments
338/// * `true_labels` – Ground-truth community labels.
339/// * `pred_labels` – Predicted community labels.
340pub fn adjusted_rand_index(true_labels: &[usize], pred_labels: &[usize]) -> Result<f64> {
341    let n = true_labels.len();
342    if n != pred_labels.len() {
343        return Err(GraphError::InvalidParameter {
344            param: "pred_labels".into(),
345            value: format!("len={}", pred_labels.len()),
346            expected: format!("len={n}"),
347            context: "adjusted_rand_index".into(),
348        });
349    }
350    if n == 0 {
351        return Ok(1.0);
352    }
353
354    // Build contingency table
355    let n_true = true_labels.iter().max().copied().unwrap_or(0) + 1;
356    let n_pred = pred_labels.iter().max().copied().unwrap_or(0) + 1;
357    let mut contingency = vec![vec![0u64; n_pred]; n_true];
358    for i in 0..n {
359        let t = true_labels[i];
360        let p = pred_labels[i];
361        if t < n_true && p < n_pred {
362            contingency[t][p] += 1;
363        }
364    }
365
366    // Row sums, column sums
367    let a: Vec<u64> = (0..n_true).map(|i| contingency[i].iter().sum()).collect();
368    let b: Vec<u64> = (0..n_pred)
369        .map(|j| (0..n_true).map(|i| contingency[i][j]).sum())
370        .collect();
371
372    // C(n_{ij}, 2), C(a_i, 2), C(b_j, 2)
373    let comb2 = |x: u64| -> f64 { (x * x.saturating_sub(1)) as f64 / 2.0 };
374
375    let sum_comb_c: f64 = contingency
376        .iter()
377        .flat_map(|row| row.iter())
378        .map(|&x| comb2(x))
379        .sum();
380    let sum_comb_a: f64 = a.iter().map(|&x| comb2(x)).sum();
381    let sum_comb_b: f64 = b.iter().map(|&x| comb2(x)).sum();
382    let comb_n = comb2(n as u64);
383
384    if comb_n == 0.0 {
385        // Only one element
386        return Ok(1.0);
387    }
388
389    let expected = sum_comb_a * sum_comb_b / comb_n;
390    let max_val = (sum_comb_a + sum_comb_b) / 2.0;
391    let denom = max_val - expected;
392    if denom.abs() < 1e-15 {
393        // Perfect agreement
394        return Ok(1.0);
395    }
396
397    Ok(((sum_comb_c - expected) / denom).clamp(-1.0, 1.0))
398}
399
400// ─────────────────────────────────────────────────────────────────────────────
401// Tests
402// ─────────────────────────────────────────────────────────────────────────────
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    fn two_clique_edges(k: usize) -> (Vec<(usize, usize, f64)>, usize) {
409        let n = 2 * k;
410        let mut edges = Vec::new();
411        for i in 0..k {
412            for j in (i + 1)..k {
413                edges.push((i, j, 1.0));
414                edges.push((k + i, k + j, 1.0));
415            }
416        }
417        edges.push((0, k, 0.05));
418        (edges, n)
419    }
420
421    #[test]
422    fn test_modularity_perfect() {
423        let (edges, n) = two_clique_edges(4);
424        let perfect: Vec<usize> = (0..8).map(|i| if i < 4 { 0 } else { 1 }).collect();
425        let q = modularity(&edges, n, &perfect);
426        assert!(q > 0.0, "modularity should be positive: {q}");
427    }
428
429    #[test]
430    fn test_modularity_single_community() {
431        let (edges, n) = two_clique_edges(3);
432        let single = vec![0usize; 6];
433        let q = modularity(&edges, n, &single);
434        assert!(
435            q <= 0.0 + 1e-10,
436            "single-community modularity should be ≤ 0: {q}"
437        );
438    }
439
440    #[test]
441    fn test_conductance_perfect_cut() {
442        let (edges, n) = two_clique_edges(4);
443        let community: Vec<usize> = (0..4).collect();
444        let phi = conductance(&edges, n, &community).expect("conductance");
445        // The only inter-community edge is the weak bridge (weight 0.05)
446        assert!(phi < 1.0, "conductance should be < 1: {phi}");
447        assert!(phi >= 0.0);
448    }
449
450    #[test]
451    fn test_conductance_empty_community() {
452        let phi = conductance(&[], 4, &[]).expect("conductance empty");
453        assert_eq!(phi, 1.0);
454    }
455
456    #[test]
457    fn test_coverage_perfect_partition() {
458        let (edges, n) = two_clique_edges(4);
459        let perfect: Vec<usize> = (0..8).map(|i| if i < 4 { 0 } else { 1 }).collect();
460        let cov = coverage(&edges, n, &perfect);
461        // Almost all weight is intra-community
462        assert!(cov > 0.9, "coverage should be high: {cov}");
463    }
464
465    #[test]
466    fn test_coverage_single_community() {
467        let (edges, n) = two_clique_edges(3);
468        let single = vec![0usize; 6];
469        let cov = coverage(&edges, n, &single);
470        assert!(
471            (cov - 1.0).abs() < 1e-9,
472            "single community coverage = 1: {cov}"
473        );
474    }
475
476    #[test]
477    fn test_normalized_cut_perfect_partition() {
478        let (edges, n) = two_clique_edges(4);
479        let perfect: Vec<usize> = (0..8).map(|i| if i < 4 { 0 } else { 1 }).collect();
480        let ncut = normalized_cut(&edges, n, &perfect);
481        assert!(ncut >= 0.0);
482        assert!(
483            ncut < 1.0,
484            "normalized cut for near-perfect partition should be small: {ncut}"
485        );
486    }
487
488    #[test]
489    fn test_nmi_perfect_agreement() {
490        let labels = vec![0, 0, 1, 1, 2, 2];
491        let nmi_val = nmi(&labels, &labels).expect("nmi perfect");
492        assert!(
493            (nmi_val - 1.0).abs() < 1e-9,
494            "NMI perfect agreement = 1: {nmi_val}"
495        );
496    }
497
498    #[test]
499    fn test_nmi_length_mismatch() {
500        assert!(nmi(&[0, 1], &[0]).is_err());
501    }
502
503    #[test]
504    fn test_nmi_empty() {
505        let v: Vec<usize> = vec![];
506        let val = nmi(&v, &v).expect("nmi empty");
507        assert_eq!(val, 1.0);
508    }
509
510    #[test]
511    fn test_ari_perfect_agreement() {
512        let labels = vec![0, 0, 0, 1, 1, 1];
513        let ari = adjusted_rand_index(&labels, &labels).expect("ari perfect");
514        assert!((ari - 1.0).abs() < 1e-9, "ARI perfect = 1: {ari}");
515    }
516
517    #[test]
518    fn test_ari_random_labels() {
519        // With random assignments, ARI should be close to 0
520        let true_l = vec![0, 0, 1, 1, 2, 2];
521        let pred_l = vec![0, 1, 2, 0, 1, 2]; // random-looking assignment
522        let ari = adjusted_rand_index(&true_l, &pred_l).expect("ari random");
523        assert!(
524            ari < 0.5,
525            "ARI for dissimilar partitions should be small: {ari}"
526        );
527    }
528
529    #[test]
530    fn test_ari_length_mismatch() {
531        assert!(adjusted_rand_index(&[0, 1], &[0]).is_err());
532    }
533
534    #[test]
535    fn test_ari_empty() {
536        let v: Vec<usize> = vec![];
537        let ari = adjusted_rand_index(&v, &v).expect("ari empty");
538        assert_eq!(ari, 1.0);
539    }
540}