Skip to main content

scirs2_graph/temporal/
community.rs

1//! Dynamic community detection for temporal graphs
2//!
3//! Implements evolutionary clustering algorithms that track community structure
4//! as it evolves over time.  The key challenge is to balance:
5//! - **Community quality** at each snapshot (intra-snapshot modularity)
6//! - **Temporal smoothness** across consecutive snapshots (community continuity)
7//!
8//! # Algorithm: Evolutionary Clustering
9//!
10//! The `evolutionary_clustering` function divides the temporal graph into
11//! `n_snapshots` equal-width time windows and runs a greedy modularity
12//! optimisation on each snapshot.  A temporal penalty term penalises label
13//! switches between adjacent snapshots, controlled by `alpha ∈ [0, 1]`.
14//!
15//! A higher `alpha` gives more weight to temporal smoothness (communities
16//! change slowly); a lower `alpha` gives more weight to snapshot quality
17//! (communities can change abruptly).
18//!
19//! # References
20//! - Chi, Y., Song, X., Zhou, D., Hino, K., & Zhu, B. L. (2007).
21//!   Evolutionary spectral clustering by incorporating temporal smoothness.
22//!   KDD 2007.
23//! - Yang, T., Chi, Y., Zhu, S., Gong, Y., & Jin, R. (2011).
24//!   Detecting communities and their evolutions in dynamic social networks.
25//!   Machine Learning 82(2).
26
27use super::graph::TemporalGraph;
28use std::collections::HashMap;
29
30// ─────────────────────────────────────────────────────────────────────────────
31// DynamicCommunity
32// ─────────────────────────────────────────────────────────────────────────────
33
34/// Result of dynamic community detection.
35///
36/// Stores the community assignment for each node at each snapshot.
37/// `node_memberships[snapshot][node]` gives the community ID for `node` at
38/// time `snapshot`.  Community IDs are contiguous integers starting from 0.
39#[derive(Debug, Clone)]
40pub struct DynamicCommunity {
41    /// `node_memberships[t][v]` = community of node `v` at snapshot `t`
42    pub node_memberships: Vec<Vec<usize>>,
43    /// Number of snapshots
44    pub n_snapshots: usize,
45    /// Number of nodes
46    pub n_nodes: usize,
47    /// Number of communities found at each snapshot
48    pub community_counts: Vec<usize>,
49}
50
51impl DynamicCommunity {
52    /// Create a DynamicCommunity from a 2D membership matrix.
53    pub fn new(node_memberships: Vec<Vec<usize>>) -> Self {
54        let n_snapshots = node_memberships.len();
55        let n_nodes = node_memberships.first().map(|v| v.len()).unwrap_or(0);
56        let community_counts = node_memberships
57            .iter()
58            .map(|snap| snap.iter().copied().max().map(|m| m + 1).unwrap_or(0))
59            .collect();
60        DynamicCommunity {
61            node_memberships,
62            n_snapshots,
63            n_nodes,
64            community_counts,
65        }
66    }
67
68    /// Get the community of node `v` at snapshot `t`.
69    pub fn membership(&self, t: usize, v: usize) -> Option<usize> {
70        self.node_memberships
71            .get(t)
72            .and_then(|snap| snap.get(v))
73            .copied()
74    }
75
76    /// Compute the average normalised mutual information (NMI) between adjacent
77    /// snapshots as a measure of temporal stability (1.0 = perfectly stable).
78    pub fn temporal_stability(&self) -> f64 {
79        if self.n_snapshots < 2 {
80            return 1.0;
81        }
82
83        let mut total = 0.0;
84        let count = (self.n_snapshots - 1) as f64;
85        for t in 0..(self.n_snapshots - 1) {
86            total +=
87                nmi_between_snapshots(&self.node_memberships[t], &self.node_memberships[t + 1]);
88        }
89        total / count
90    }
91}
92
93/// Compute approximate normalised mutual information between two label vectors.
94fn nmi_between_snapshots(labels_a: &[usize], labels_b: &[usize]) -> f64 {
95    let n = labels_a.len().min(labels_b.len());
96    if n == 0 {
97        return 1.0;
98    }
99
100    let mut joint: HashMap<(usize, usize), usize> = HashMap::new();
101    let mut freq_a: HashMap<usize, usize> = HashMap::new();
102    let mut freq_b: HashMap<usize, usize> = HashMap::new();
103
104    for i in 0..n {
105        let a = labels_a[i];
106        let b = labels_b[i];
107        *joint.entry((a, b)).or_insert(0) += 1;
108        *freq_a.entry(a).or_insert(0) += 1;
109        *freq_b.entry(b).or_insert(0) += 1;
110    }
111
112    let n_f = n as f64;
113    let mut h_ab = 0.0;
114    let mut h_a = 0.0;
115    let mut h_b = 0.0;
116
117    for &cnt in joint.values() {
118        let p = cnt as f64 / n_f;
119        if p > 0.0 {
120            h_ab -= p * p.ln();
121        }
122    }
123    for &cnt in freq_a.values() {
124        let p = cnt as f64 / n_f;
125        if p > 0.0 {
126            h_a -= p * p.ln();
127        }
128    }
129    for &cnt in freq_b.values() {
130        let p = cnt as f64 / n_f;
131        if p > 0.0 {
132            h_b -= p * p.ln();
133        }
134    }
135
136    let mi = h_a + h_b - h_ab;
137    let denom = (h_a + h_b) / 2.0;
138    if denom <= 0.0 {
139        1.0
140    } else {
141        mi / denom
142    }
143}
144
145// ─────────────────────────────────────────────────────────────────────────────
146// evolutionary_clustering
147// ─────────────────────────────────────────────────────────────────────────────
148
149/// Run evolutionary clustering on a temporal graph.
150///
151/// The temporal graph is partitioned into `n_snapshots` equal time windows.
152/// For each snapshot, a modularity-maximising community assignment is computed
153/// using a greedy label-propagation heuristic.  A temporal smoothness penalty
154/// (controlled by `alpha`) discourages nodes from changing community labels
155/// between consecutive snapshots.
156///
157/// # Arguments
158/// * `tg`          – mutable reference to the temporal graph
159/// * `n_snapshots` – number of time snapshots to create
160/// * `alpha`       – smoothness weight in [0, 1]; 0 = purely snapshot quality,
161///   1 = purely temporal smoothness
162///
163/// # Returns
164/// A `DynamicCommunity` containing membership vectors for each snapshot.
165pub fn evolutionary_clustering(
166    tg: &mut TemporalGraph,
167    n_snapshots: usize,
168    alpha: f64,
169) -> DynamicCommunity {
170    let n = tg.nodes;
171    if n == 0 || n_snapshots == 0 {
172        return DynamicCommunity::new(vec![vec![0usize; n]; n_snapshots.max(1)]);
173    }
174
175    tg.ensure_sorted();
176    let alpha = alpha.clamp(0.0, 1.0);
177
178    let (t_min, t_max) = if tg.edges.is_empty() {
179        (0.0, 1.0)
180    } else {
181        (
182            tg.edges.first().map(|e| e.timestamp).unwrap_or(0.0),
183            tg.edges.last().map(|e| e.timestamp).unwrap_or(1.0),
184        )
185    };
186
187    let window_width = if (t_max - t_min).abs() < 1e-12 {
188        1.0
189    } else {
190        (t_max - t_min) / n_snapshots as f64
191    };
192
193    let mut all_memberships: Vec<Vec<usize>> = Vec::with_capacity(n_snapshots);
194    let mut prev_memberships: Option<Vec<usize>> = None;
195
196    for snap_idx in 0..n_snapshots {
197        let t_start = t_min + snap_idx as f64 * window_width;
198        let t_end = if snap_idx + 1 == n_snapshots {
199            t_max + 1.0 // include last edge
200        } else {
201            t_min + (snap_idx + 1) as f64 * window_width
202        };
203
204        // Build adjacency for this snapshot
205        let adj = build_snapshot_adjacency(tg, n, t_start, t_end);
206
207        // Run label-propagation with temporal smoothness penalty
208        let memberships =
209            label_propagation_with_smoothness(&adj, n, prev_memberships.as_deref(), alpha);
210
211        prev_memberships = Some(memberships.clone());
212        all_memberships.push(memberships);
213    }
214
215    DynamicCommunity::new(all_memberships)
216}
217
218/// Build an adjacency list (weighted) for a given time window.
219fn build_snapshot_adjacency(
220    tg: &TemporalGraph,
221    n: usize,
222    t_start: f64,
223    t_end: f64,
224) -> Vec<Vec<(usize, f64)>> {
225    let mut adj: Vec<Vec<(usize, f64)>> = vec![Vec::new(); n];
226    // Accumulate weights per edge (undirected)
227    let mut weight_map: HashMap<(usize, usize), f64> = HashMap::new();
228
229    for e in &tg.edges {
230        if e.timestamp < t_start || e.timestamp >= t_end {
231            continue;
232        }
233        let key = (e.source.min(e.target), e.source.max(e.target));
234        *weight_map.entry(key).or_insert(0.0) += e.weight;
235    }
236
237    for ((u, v), w) in weight_map {
238        adj[u].push((v, w));
239        adj[v].push((u, w));
240    }
241
242    adj
243}
244
245/// Greedy label-propagation community detection with optional temporal smoothness.
246///
247/// Each node starts in its own community (or from the previous snapshot's labels
248/// when `prev` is provided).  Nodes are randomly updated by adopting the most
249/// popular label among their neighbours, weighted by edge weights plus the
250/// temporal smoothness bonus for keeping the previous label.
251fn label_propagation_with_smoothness(
252    adj: &[Vec<(usize, f64)>],
253    n: usize,
254    prev: Option<&[usize]>,
255    alpha: f64,
256) -> Vec<usize> {
257    // Initialise labels: from previous snapshot if available, else own index
258    let mut labels: Vec<usize> = if let Some(p) = prev {
259        p.to_vec()
260    } else {
261        (0..n).collect()
262    };
263
264    let max_iter = 20usize;
265    let mut changed = true;
266    let mut iter = 0;
267
268    while changed && iter < max_iter {
269        changed = false;
270        // Deterministic update order (sorted by node index)
271        let order: Vec<usize> = (0..n).collect();
272
273        for &v in &order {
274            // Count label votes from neighbours
275            let mut vote: HashMap<usize, f64> = HashMap::new();
276
277            for &(nbr, w) in &adj[v] {
278                *vote.entry(labels[nbr]).or_insert(0.0) += w;
279            }
280
281            if vote.is_empty() {
282                continue;
283            }
284
285            // Add temporal smoothness bonus for keeping the previous label
286            if let Some(p) = prev {
287                if v < p.len() {
288                    *vote.entry(p[v]).or_insert(0.0) += alpha * 1.0;
289                }
290            }
291
292            // Choose the label with the highest vote
293            let best_label = vote
294                .iter()
295                .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
296                .map(|(&l, _)| l);
297
298            if let Some(best) = best_label {
299                if best != labels[v] {
300                    labels[v] = best;
301                    changed = true;
302                }
303            }
304        }
305
306        iter += 1;
307    }
308
309    // Compact label IDs to be contiguous starting from 0
310    compact_labels(&mut labels);
311    labels
312}
313
314/// Remap label IDs to be contiguous integers starting from 0.
315fn compact_labels(labels: &mut [usize]) {
316    let mut mapping: HashMap<usize, usize> = HashMap::new();
317    let mut next_id = 0usize;
318    for l in labels.iter_mut() {
319        let id = mapping.entry(*l).or_insert_with(|| {
320            let id = next_id;
321            next_id += 1;
322            id
323        });
324        *l = *id;
325    }
326}
327
328// ─────────────────────────────────────────────────────────────────────────────
329// Modularity computation (helper, not exported)
330// ─────────────────────────────────────────────────────────────────────────────
331
332/// Compute modularity Q for a given community assignment on a weighted graph.
333/// Returns Q ∈ [-0.5, 1.0]; higher is better.
334#[allow(dead_code)]
335fn compute_modularity(adj: &[Vec<(usize, f64)>], labels: &[usize]) -> f64 {
336    let n = adj.len();
337    let mut total_weight = 0.0;
338    let mut degree: Vec<f64> = vec![0.0; n];
339
340    for (u, neighbors) in adj.iter().enumerate() {
341        for &(_, w) in neighbors {
342            degree[u] += w;
343            total_weight += w;
344        }
345    }
346    total_weight /= 2.0; // Each edge counted twice
347
348    if total_weight <= 0.0 {
349        return 0.0;
350    }
351
352    let mut q = 0.0;
353    for u in 0..n {
354        for &(v, w) in &adj[u] {
355            if labels[u] == labels[v] {
356                q += w - degree[u] * degree[v] / (2.0 * total_weight);
357            }
358        }
359    }
360
361    q / (2.0 * total_weight)
362}
363
364// ─────────────────────────────────────────────────────────────────────────────
365// Tests
366// ─────────────────────────────────────────────────────────────────────────────
367
368#[cfg(test)]
369mod tests {
370    use super::super::graph::TemporalEdge;
371    use super::*;
372
373    /// Create a graph with two clear communities: {0,1,2} and {3,4,5}
374    /// with dense within-group contacts and a sparse bridge.
375    fn make_two_community_graph() -> TemporalGraph {
376        let mut tg = TemporalGraph::new(6);
377        // Community A: nodes 0, 1, 2 — dense contacts at t=1..3
378        for t in 1..=3 {
379            tg.add_edge(TemporalEdge::with_weight(0, 1, t as f64, 2.0));
380            tg.add_edge(TemporalEdge::with_weight(1, 2, t as f64 + 0.1, 2.0));
381            tg.add_edge(TemporalEdge::with_weight(0, 2, t as f64 + 0.2, 2.0));
382        }
383        // Community B: nodes 3, 4, 5 — dense contacts at t=4..6
384        for t in 4..=6 {
385            tg.add_edge(TemporalEdge::with_weight(3, 4, t as f64, 2.0));
386            tg.add_edge(TemporalEdge::with_weight(4, 5, t as f64 + 0.1, 2.0));
387            tg.add_edge(TemporalEdge::with_weight(3, 5, t as f64 + 0.2, 2.0));
388        }
389        // Sparse bridge between communities at t=7
390        tg.add_edge(TemporalEdge::with_weight(2, 3, 7.0, 0.1));
391        tg
392    }
393
394    #[test]
395    fn test_evolutionary_clustering_basic() {
396        let mut tg = make_two_community_graph();
397        let dyn_com = evolutionary_clustering(&mut tg, 3, 0.5);
398
399        assert_eq!(dyn_com.n_snapshots, 3);
400        assert_eq!(dyn_com.n_nodes, 6);
401        assert_eq!(dyn_com.node_memberships.len(), 3);
402
403        // Each snapshot should have community IDs for all 6 nodes
404        for snap in &dyn_com.node_memberships {
405            assert_eq!(snap.len(), 6);
406        }
407    }
408
409    #[test]
410    fn test_evolutionary_clustering_stability() {
411        let mut tg = make_two_community_graph();
412        // High alpha → more temporal smoothness
413        let dyn_com = evolutionary_clustering(&mut tg, 4, 0.9);
414        let stability = dyn_com.temporal_stability();
415        // With high smoothness, stability should be reasonably high
416        assert!(
417            (0.0..=1.0).contains(&stability),
418            "stability should be in [0,1], got {stability}"
419        );
420    }
421
422    #[test]
423    fn test_evolutionary_clustering_empty_graph() {
424        let mut tg = TemporalGraph::new(5);
425        let dyn_com = evolutionary_clustering(&mut tg, 3, 0.5);
426        assert_eq!(dyn_com.n_snapshots, 3);
427        assert_eq!(dyn_com.n_nodes, 5);
428    }
429
430    #[test]
431    fn test_dynamic_community_membership_access() {
432        let mut tg = make_two_community_graph();
433        let dyn_com = evolutionary_clustering(&mut tg, 2, 0.5);
434        // All node memberships should be accessible
435        for t in 0..dyn_com.n_snapshots {
436            for v in 0..dyn_com.n_nodes {
437                let m = dyn_com.membership(t, v);
438                assert!(m.is_some(), "membership({t},{v}) should be Some");
439            }
440        }
441    }
442
443    #[test]
444    fn test_community_counts_nonnegative() {
445        let mut tg = make_two_community_graph();
446        let dyn_com = evolutionary_clustering(&mut tg, 3, 0.5);
447        for &cnt in &dyn_com.community_counts {
448            assert!(cnt >= 1, "each snapshot should have at least 1 community");
449        }
450    }
451
452    #[test]
453    fn test_compact_labels() {
454        let mut labels = vec![5, 10, 5, 20, 10, 20];
455        compact_labels(&mut labels);
456        // After compaction, labels should be in 0..k for some k ≤ 3
457        let max_label = labels.iter().copied().max().unwrap_or(0);
458        assert!(
459            max_label <= 2,
460            "compact labels should be 0-indexed, max={max_label}"
461        );
462    }
463
464    #[test]
465    fn test_temporal_stability_single_snapshot() {
466        let dc = DynamicCommunity::new(vec![vec![0, 1, 0, 1]]);
467        assert_eq!(dc.temporal_stability(), 1.0);
468    }
469}