Skip to main content

yo_graph/algo/
community.rs

1//! Communities, by moving nodes until modularity stops going up.
2//!
3//! Two algorithms and the measure they both optimise.
4//!
5//! [`louvain()`] is Blondel, Guillaume, Lambiotte and Lefebvre, "Fast unfolding
6//! of communities in large networks", J. Stat. Mech. 2008, with the queue driven
7//! local move from Traag, "Faster unfolding of communities", Phys. Rev. E 2015.
8//!
9//! [`leiden()`] is Traag, Waltman and van Eck, "From Louvain to Leiden:
10//! guaranteeing well-connected communities", Scientific Reports 2019.
11//!
12//! # What modularity is
13//!
14//! A community is supposed to be a group with more edges inside it than you
15//! would expect by chance. Modularity is that sentence as a number: for each
16//! community, the share of all edge ends that are inside it, minus the share you
17//! would get if the same nodes kept their degrees and rewired at random. It runs
18//! from about -0.5 to 1, and a real social graph split sensibly comes out around
19//! 0.4 to 0.7.
20//!
21//! The resolution turns the dial on what "expected" means. Above one, chance
22//! looks more likely and communities come out smaller; below one, larger. It is
23//! the honest way to deal with modularity's resolution limit, which is that at
24//! resolution one no method can see a community much smaller than the square
25//! root of the edge count.
26//!
27//! # Why Leiden and not just Louvain
28//!
29//! Louvain has a defect that took eleven years to write down: a community it
30//! returns can be internally disconnected. It happens when a node that was
31//! acting as the only bridge inside its community moves out, and the community
32//! is then aggregated into a single node before anybody notices it fell into two
33//! pieces. Once aggregated the pieces can never be separated again. On real
34//! graphs the 2019 paper found this in a few percent of communities, and it is
35//! not a rounding error: a "community" in two halves with nothing joining them
36//! is not a community by any reading.
37//!
38//! Leiden fixes it by putting a step between the moving and the aggregating. The
39//! partition found by moving is refined: inside each community, nodes start
40//! alone again and merge only into subsets that are well connected to the rest
41//! of the community, and merges are chosen randomly among the good ones rather
42//! than greedily. The graph is then aggregated on the refined subsets rather
43//! than on the communities, so a community that fell into two pieces arrives at
44//! the next level as two nodes and can still be pulled apart.
45//!
46//! Both are here because the difference is worth being able to measure, and
47//! because Louvain is still the thing everybody else reports.
48//!
49//! # Why the answer is fixed
50//!
51//! The visiting order is random in both, and Leiden's merges are random by
52//! design, which is where the guarantee comes from. All of it is drawn from
53//! [`yo_common::Rng`] on a fixed seed, so two runs over one snapshot agree.
54//!
55//! ```
56//! use yo_graph::{Graph, NO_PROPS, Snapshot, algo};
57//!
58//! let mut g = Graph::new();
59//! // Two four cliques joined by a single edge.
60//! for (a, b) in [(1u64, 2u64), (1, 3), (1, 4), (2, 3), (2, 4), (3, 4)] {
61//!     g.link(a, b, 1, NO_PROPS)?;
62//!     g.link(a + 10, b + 10, 1, NO_PROPS)?;
63//! }
64//! g.link(4, 11, 1, NO_PROPS)?;
65//!
66//! let s = Snapshot::of(&g);
67//! let c = algo::leiden(&s);
68//! assert_eq!(c.count(), 2);
69//! assert!(algo::modularity(&s, c.labels()) > 0.4);
70//! # Ok::<(), yo_common::Error>(())
71//! ```
72
73use crate::Snapshot;
74use crate::algo::{Components, tidy};
75use yo_common::Rng;
76
77/// The resolution that makes modularity mean what the 2004 paper says.
78pub const RESOLUTION: f64 = 1.0;
79
80/// How hard Leiden's refinement leans towards the best merge it can see.
81///
82/// The randomness is the point: a refinement that always took the best merge
83/// would be Louvain's greedy step again and would lose the guarantee. Small
84/// enough that a clearly better merge nearly always wins, large enough that a
85/// close second is a real possibility, which is the value the paper uses.
86const THETA: f64 = 0.01;
87
88/// Enough levels that a real graph has run out of them long before.
89///
90/// Each level shrinks the graph to its community count, so a graph that has not
91/// stopped in this many is one where a level is merging almost nothing.
92const LEVELS: u32 = 64;
93
94/// How many times the whole thing is run again from the original nodes.
95///
96/// Nearly always two: one to find a partition and one to confirm that nothing
97/// wants to move out of it. The cap is there because a run whose randomness
98/// keeps finding a different partition of the same quality would otherwise not
99/// stop, and past a handful of them the difference is not worth the time.
100const PASSES: u32 = 8;
101
102const SEED: u64 = 0x1ead_e401;
103
104/// The communities of `g`, by the Leiden method.
105///
106/// The one to use. It costs about a third more than [`louvain()`] and it cannot
107/// hand back a community that is internally disconnected.
108#[must_use]
109pub fn leiden(g: &Snapshot) -> Components {
110    leiden_with(g, RESOLUTION)
111}
112
113/// The same, at a resolution other than one.
114#[must_use]
115pub fn leiden_with(g: &Snapshot, resolution: f64) -> Components {
116    unfold(g, resolution, true)
117}
118
119/// The communities of `g`, by the Louvain method.
120///
121/// Here to be measured against, and because it is what everybody else reports.
122/// A community it returns may be internally disconnected, which is the whole
123/// reason [`leiden()`] exists.
124#[must_use]
125pub fn louvain(g: &Snapshot) -> Components {
126    louvain_with(g, RESOLUTION)
127}
128
129/// The same, at a resolution other than one.
130#[must_use]
131pub fn louvain_with(g: &Snapshot, resolution: f64) -> Components {
132    unfold(g, resolution, false)
133}
134
135/// How good a partition of `g` is, at resolution one.
136///
137/// `labels` is one label per node in dense id order, and the labels themselves
138/// mean nothing beyond which nodes share one, so anything that groups the nodes
139/// will do: a [`Components`] from any of these algorithms, or a caller's own
140/// grouping being checked against them.
141///
142/// # Panics
143///
144/// If `labels` is not one label per node.
145#[must_use]
146pub fn modularity(g: &Snapshot, labels: &[u32]) -> f64 {
147    modularity_with(g, labels, RESOLUTION)
148}
149
150/// The same, at a resolution other than one.
151///
152/// # Panics
153///
154/// If `labels` is not one label per node.
155#[must_use]
156pub fn modularity_with(g: &Snapshot, labels: &[u32], resolution: f64) -> f64 {
157    let w = Weighted::of(g);
158    assert_eq!(labels.len(), w.nodes(), "one label a node");
159    let (labels, groups) = renumber(labels);
160    w.quality(&labels, groups, resolution)
161}
162
163/// The undirected weighted view the two methods run over.
164///
165/// Modularity is not a question about which way an edge points, so the two
166/// directions of the snapshot are folded into one neighbour list per node with a
167/// weight, and a pair of nodes joined twice becomes one edge of weight two. That
168/// fold is also what makes aggregation work: the graph of communities is another
169/// one of these, so a level is the same code as the level below it.
170#[derive(Clone)]
171struct Weighted {
172    at: Vec<u64>,
173    to: Vec<u32>,
174    w: Vec<f64>,
175    /// The weight of each node's own loop, counted once.
176    self_w: Vec<f64>,
177    /// Every edge end at a node, with a self loop counting twice.
178    strength: Vec<f64>,
179    /// Every edge end in the graph, which is what modularity divides by.
180    total: f64,
181}
182
183impl Weighted {
184    /// Both directions of a snapshot, folded together.
185    fn of(g: &Snapshot) -> Weighted {
186        let n = g.nodes() as usize;
187        let mut at = vec![0u64; n + 1];
188        for node in 0..n {
189            let both = g.out_degree(node as u32) + g.in_degree(node as u32);
190            at[node + 1] = at[node] + u64::from(both);
191        }
192
193        // Every incident edge lands in the list once: an edge stored from u to v
194        // is in u's outgoing side and v's incoming side. A self loop is in both
195        // sides of the same node, so it is taken off the outgoing side and left
196        // out of the incoming one.
197        let mut to = vec![0u32; at[n] as usize];
198        let mut self_w = vec![0f64; n];
199        let mut fill = at.clone();
200        for node in 0..n as u32 {
201            for other in g.out(node) {
202                if *other == node {
203                    self_w[node as usize] += 1.0;
204                    continue;
205                }
206                to[fill[node as usize] as usize] = *other;
207                fill[node as usize] += 1;
208            }
209            for other in g.into_(node) {
210                if *other == node {
211                    continue;
212                }
213                to[fill[node as usize] as usize] = *other;
214                fill[node as usize] += 1;
215            }
216        }
217
218        // Then the duplicates are combined, which is what turns a pair of nodes
219        // joined three ways into one edge of weight three.
220        let mut edges: Vec<(u32, f64)> = Vec::new();
221        let mut out = Vec::with_capacity(to.len());
222        let mut w = Vec::with_capacity(to.len());
223        let mut next = vec![0u64; n + 1];
224        for node in 0..n {
225            let mine = &mut to[at[node] as usize..fill[node] as usize];
226            mine.sort_unstable();
227            edges.clear();
228            for other in mine.iter() {
229                match edges.last_mut() {
230                    Some((last, weight)) if last == other => *weight += 1.0,
231                    _ => edges.push((*other, 1.0)),
232                }
233            }
234            for (other, weight) in &edges {
235                out.push(*other);
236                w.push(*weight);
237            }
238            next[node + 1] = out.len() as u64;
239        }
240
241        Weighted::new(next, out, w, self_w)
242    }
243
244    /// The pieces, with the strengths worked out from them.
245    fn new(at: Vec<u64>, to: Vec<u32>, w: Vec<f64>, self_w: Vec<f64>) -> Weighted {
246        let n = self_w.len();
247        let mut strength = vec![0f64; n];
248        for node in 0..n {
249            let mine = at[node] as usize..at[node + 1] as usize;
250            strength[node] = w[mine].iter().sum::<f64>() + 2.0 * self_w[node];
251        }
252        let total = strength.iter().sum();
253        Weighted {
254            at,
255            to,
256            w,
257            self_w,
258            strength,
259            total,
260        }
261    }
262
263    fn nodes(&self) -> usize {
264        self.self_w.len()
265    }
266
267    /// One node's neighbours and what each edge weighs.
268    fn near(&self, node: u32) -> (&[u32], &[f64]) {
269        let mine = self.at[node as usize] as usize..self.at[node as usize + 1] as usize;
270        (&self.to[mine.clone()], &self.w[mine])
271    }
272
273    /// The modularity of a partition, with the labels already numbered from
274    /// zero.
275    fn quality(&self, of: &[u32], groups: usize, resolution: f64) -> f64 {
276        if self.total == 0.0 {
277            return 0.0;
278        }
279        let mut inside = vec![0f64; groups];
280        let mut tot = vec![0f64; groups];
281        for node in 0..self.nodes() {
282            let mine = of[node] as usize;
283            tot[mine] += self.strength[node];
284            inside[mine] += 2.0 * self.self_w[node];
285            let (near, w) = self.near(node as u32);
286            for (other, weight) in near.iter().zip(w) {
287                if of[*other as usize] as usize == mine {
288                    inside[mine] += weight;
289                }
290            }
291        }
292        (0..groups)
293            .map(|c| inside[c] / self.total - resolution * (tot[c] / self.total).powi(2))
294            .sum()
295    }
296}
297
298/// The two methods, which differ only in what the graph is aggregated on.
299///
300/// The whole thing is run again from the original nodes, starting from the
301/// partition the run before it found, until a run changes nothing. That is what
302/// buys node optimality: a run that begins by asking every node on its own
303/// whether it would rather be somewhere else, and ends with the same partition
304/// it started from, has answered no for every node. One pass cannot say that,
305/// because after the first level a node only ever moves as part of the group it
306/// was aggregated into.
307fn unfold(g: &Snapshot, resolution: f64, refined: bool) -> Components {
308    let base = Weighted::of(g);
309    let n = base.nodes();
310    let mut answer: Vec<u32> = (0..n as u32).collect();
311    if n == 0 {
312        return tidy(answer);
313    }
314
315    let mut rng = Rng::new(SEED);
316    for _ in 0..PASSES {
317        let next = pass(&base, &answer, resolution, refined, &mut rng);
318        if next == answer {
319            break;
320        }
321        answer = next;
322    }
323    tidy(answer)
324}
325
326/// One run of the level loop, from the original nodes up.
327fn pass(base: &Weighted, start: &[u32], resolution: f64, refined: bool, rng: &mut Rng) -> Vec<u32> {
328    let n = base.nodes();
329    let mut answer = vec![0u32; n];
330    let mut w = base.clone();
331    // Where each of the original nodes has ended up in the current graph.
332    let mut at: Vec<u32> = (0..n as u32).collect();
333    let (mut comm, _) = renumber(start);
334
335    for _ in 0..LEVELS {
336        local_move(&w, &mut comm, resolution, rng);
337        let (tidied, groups) = renumber(&comm);
338        for (node, at) in at.iter().enumerate() {
339            answer[node] = tidied[*at as usize];
340        }
341        // Every node in a community of its own means there was nothing to
342        // aggregate and the next level would do exactly this one again.
343        if groups == w.nodes() {
344            break;
345        }
346
347        let split = if refined {
348            refine(&w, &tidied, groups, resolution, rng)
349        } else {
350            tidied.clone()
351        };
352        let (next, next_comm, moved) = aggregate(&w, &split, &tidied);
353        for at in &mut at {
354            *at = moved[*at as usize];
355        }
356        w = next;
357        comm = next_comm;
358    }
359    // Numbered by the first node holding each label, so that two runs that
360    // found the same partition hand back the same vector and the caller above
361    // can tell they agreed.
362    renumber(&answer).0
363}
364
365/// Move each node to whichever community it does modularity the most good in,
366/// over and over until nobody wants to move.
367///
368/// The queue is Traag's 2015 point and it is most of the running time. A round
369/// over every node is nearly all wasted once the partition is nearly settled,
370/// because a node can only want to move if one of its neighbours moved. So the
371/// nodes to look at are held in a queue, and moving a node puts its neighbours
372/// back on it.
373fn local_move(g: &Weighted, comm: &mut [u32], resolution: f64, rng: &mut Rng) {
374    let n = g.nodes();
375    if n == 0 || g.total == 0.0 {
376        return;
377    }
378    let mut tot = vec![0f64; n];
379    let mut size = vec![0u32; n];
380    for node in 0..n {
381        tot[comm[node] as usize] += g.strength[node];
382        size[comm[node] as usize] += 1;
383    }
384    // Communities nobody is in, so that a node can leave for one when every
385    // community it can see would be worse than being alone.
386    let mut free: Vec<u32> = (0..n as u32).filter(|c| size[*c as usize] == 0).collect();
387
388    let mut queue: Vec<u32> = (0..n as u32).collect();
389    shuffle(&mut queue, rng);
390    let mut queued = vec![true; n];
391    let mut head = 0usize;
392
393    // The weight from the node being moved to each community it can see.
394    let mut link = vec![0f64; n];
395    let mut seen: Vec<u32> = Vec::new();
396
397    while head < queue.len() {
398        let node = queue[head];
399        head += 1;
400        queued[node as usize] = false;
401        let was = comm[node as usize];
402        let strength = g.strength[node as usize];
403
404        tot[was as usize] -= strength;
405        size[was as usize] -= 1;
406        if size[was as usize] == 0 {
407            free.push(was);
408        }
409
410        seen.clear();
411        let (near, w) = g.near(node);
412        for (other, weight) in near.iter().zip(w) {
413            let at = comm[*other as usize] as usize;
414            if link[at] == 0.0 {
415                seen.push(comm[*other as usize]);
416            }
417            link[at] += weight;
418        }
419
420        // Staying put is the baseline, and an empty community is the floor: it
421        // is worth nothing, which beats a neighbourhood that is worth less.
422        let value = |c: u32, link: &[f64]| {
423            link[c as usize] - resolution * strength * tot[c as usize] / g.total
424        };
425        let mut best = was;
426        let mut most = value(was, &link);
427        if most < 0.0 && size[was as usize] > 0 {
428            while let Some(empty) = free.pop() {
429                if size[empty as usize] == 0 {
430                    (best, most) = (empty, 0.0);
431                    free.push(empty);
432                    break;
433                }
434            }
435        }
436        for c in &seen {
437            let worth = value(*c, &link);
438            // Ties go to the lowest numbered community, so the answer does not
439            // depend on the order the neighbours came in.
440            if worth > most || (worth == most && *c < best) {
441                (best, most) = (*c, worth);
442            }
443        }
444        for c in &seen {
445            link[*c as usize] = 0.0;
446        }
447
448        comm[node as usize] = best;
449        tot[best as usize] += strength;
450        size[best as usize] += 1;
451        if best == was {
452            continue;
453        }
454        // Only a neighbour outside where this node landed can have been made
455        // to want to move by it moving.
456        for other in near {
457            if comm[*other as usize] != best && !queued[*other as usize] {
458                queued[*other as usize] = true;
459                queue.push(*other);
460            }
461        }
462    }
463}
464
465/// Split each community back into the pieces that are well connected to it.
466///
467/// This is the step Louvain does not have. Inside a community every node starts
468/// alone again and merges only into a subset that is well connected to the rest
469/// of the community, and only if the merge is worth something. A node acting as
470/// the sole bridge inside its community, which is the case Louvain gets wrong,
471/// is not well connected to it and so stays on its own and arrives at the next
472/// level as its own node.
473///
474/// The merge is chosen at random among the ones worth making, weighted towards
475/// the better ones. Taking the best one every time would be the greedy step
476/// again, and the guarantee comes from not doing that.
477fn refine(g: &Weighted, comm: &[u32], groups: usize, resolution: f64, rng: &mut Rng) -> Vec<u32> {
478    let n = g.nodes();
479    let mut refined: Vec<u32> = (0..n as u32).collect();
480    if g.total == 0.0 {
481        return refined;
482    }
483
484    // The nodes of each community, together.
485    let mut at = vec![0u32; groups + 1];
486    for c in comm {
487        at[*c as usize + 1] += 1;
488    }
489    for c in 0..groups {
490        at[c + 1] += at[c];
491    }
492    let mut member = vec![0u32; n];
493    let mut fill = at.clone();
494    for (node, c) in comm.iter().enumerate() {
495        member[fill[*c as usize] as usize] = node as u32;
496        fill[*c as usize] += 1;
497    }
498
499    // Per refined subset: its total strength, and how much of it points at the
500    // rest of the community it lives in.
501    let mut tot = g.strength.clone();
502    let mut out = vec![0f64; n];
503    let mut link = vec![0f64; n];
504    let mut seen: Vec<u32> = Vec::new();
505    let mut pick: Vec<(u32, f64)> = Vec::new();
506
507    let mut order: Vec<u32> = Vec::new();
508    for c in 0..groups {
509        let mine = &member[at[c] as usize..at[c + 1] as usize];
510        if mine.len() < 3 {
511            continue;
512        }
513        let whole: f64 = mine.iter().map(|node| g.strength[*node as usize]).sum();
514
515        // How much each node points at the rest of its own community, which is
516        // both the well connected test for it and the starting value for the
517        // subset it is alone in.
518        for node in mine {
519            let (near, w) = g.near(*node);
520            out[*node as usize] = near
521                .iter()
522                .zip(w)
523                .filter(|(other, _)| comm[**other as usize] as usize == c)
524                .map(|(_, weight)| *weight)
525                .sum();
526        }
527
528        order.clear();
529        order.extend_from_slice(mine);
530        shuffle(&mut order, rng);
531        for node in &order {
532            let node = *node;
533            // Only a node still on its own can start a merge, and only one that
534            // is well connected to the rest of its community should.
535            if refined[node as usize] != node || tot[node as usize] != g.strength[node as usize] {
536                continue;
537            }
538            let strength = g.strength[node as usize];
539            if out[node as usize] < resolution * strength * (whole - strength) / g.total {
540                continue;
541            }
542
543            seen.clear();
544            let (near, w) = g.near(node);
545            for (other, weight) in near.iter().zip(w) {
546                if comm[*other as usize] as usize != c {
547                    continue;
548                }
549                let into = refined[*other as usize] as usize;
550                if into == node as usize {
551                    continue;
552                }
553                if link[into] == 0.0 {
554                    seen.push(refined[*other as usize]);
555                }
556                link[into] += weight;
557            }
558
559            pick.clear();
560            let mut top = f64::NEG_INFINITY;
561            for subset in &seen {
562                let there = tot[*subset as usize];
563                if out[*subset as usize] < resolution * there * (whole - there) / g.total {
564                    continue;
565                }
566                let worth = link[*subset as usize] - resolution * strength * there / g.total;
567                if worth >= 0.0 {
568                    top = top.max(worth);
569                    pick.push((*subset, worth));
570                }
571            }
572
573            // Weighted by exp of the gain over theta, with the best one taken
574            // off first so the exponential cannot run away.
575            if !pick.is_empty() {
576                let mut sum = 0.0;
577                for (_, worth) in &mut pick {
578                    *worth = ((*worth - top) / THETA).exp();
579                    sum += *worth;
580                }
581                let mut want = uniform(rng) * sum;
582                let mut into = pick[pick.len() - 1].0;
583                for (subset, weight) in &pick {
584                    want -= weight;
585                    if want <= 0.0 {
586                        into = *subset;
587                        break;
588                    }
589                }
590                refined[node as usize] = into;
591                tot[into as usize] += strength;
592                // What the subset points at the rest of the community changes by
593                // what the node brought, less twice what the two already had
594                // between them, which is now inside.
595                out[into as usize] += out[node as usize] - 2.0 * link[into as usize];
596                tot[node as usize] = 0.0;
597            }
598
599            for subset in &seen {
600                link[*subset as usize] = 0.0;
601            }
602        }
603    }
604    refined
605}
606
607/// Shrink the graph so that each group of `split` is one node.
608///
609/// Returns the smaller graph, the community each of its nodes starts in, and
610/// where each of the old nodes went. For Louvain `split` and `comm` are the same
611/// thing, and every new node starts alone. For Leiden `split` is finer, and a
612/// new node starts in the community the piece it came from belonged to, which is
613/// what carries the partition down to the next level.
614fn aggregate(g: &Weighted, split: &[u32], comm: &[u32]) -> (Weighted, Vec<u32>, Vec<u32>) {
615    let (moved, n) = renumber(split);
616
617    let mut self_w = vec![0f64; n];
618    let mut edges: Vec<(u32, u32, f64)> = Vec::new();
619    for node in 0..g.nodes() {
620        let mine = moved[node];
621        self_w[mine as usize] += g.self_w[node];
622        let (near, w) = g.near(node as u32);
623        for (other, weight) in near.iter().zip(w) {
624            let theirs = moved[*other as usize];
625            if theirs == mine {
626                // Both ends of this edge are in here now, and it is going to be
627                // seen once from each end.
628                self_w[mine as usize] += weight / 2.0;
629            } else {
630                edges.push((mine, theirs, *weight));
631            }
632        }
633    }
634
635    edges.sort_unstable_by_key(|(from, to, _)| (*from, *to));
636    let mut at = vec![0u64; n + 1];
637    let mut to = Vec::new();
638    let mut w = Vec::new();
639    for (from, other, weight) in &edges {
640        match to.last() {
641            Some(last) if *last == *other && at[*from as usize + 1] == to.len() as u64 => {
642                *w.last_mut().expect("a weight") += weight;
643            }
644            _ => {
645                to.push(*other);
646                w.push(*weight);
647                at[*from as usize + 1] = to.len() as u64;
648            }
649        }
650        at[*from as usize + 1] = to.len() as u64;
651    }
652    for node in 0..n {
653        at[node + 1] = at[node + 1].max(at[node]);
654    }
655
656    // Which community each of the new nodes starts in.
657    let mut starts = vec![0u32; n];
658    for node in 0..g.nodes() {
659        starts[moved[node] as usize] = comm[node];
660    }
661    let (starts, _) = renumber(&starts);
662    (Weighted::new(at, to, w, self_w), starts, moved)
663}
664
665/// The same grouping with the labels numbered from zero, and how many there are.
666fn renumber(of: &[u32]) -> (Vec<u32>, usize) {
667    let mut seen = vec![u32::MAX; of.len()];
668    let mut next = 0u32;
669    let mut out = vec![0u32; of.len()];
670    for (node, at) in of.iter().enumerate() {
671        let seen = &mut seen[*at as usize];
672        if *seen == u32::MAX {
673            *seen = next;
674            next += 1;
675        }
676        out[node] = *seen;
677    }
678    (out, next as usize)
679}
680
681/// Fisher and Yates, since the order nodes are looked at in is part of both.
682fn shuffle(order: &mut [u32], rng: &mut Rng) {
683    for at in (1..order.len()).rev() {
684        order.swap(at, (rng.next_u64() % (at as u64 + 1)) as usize);
685    }
686}
687
688/// A number in `[0, 1)`, off the top 53 bits, which is all a double holds.
689fn uniform(rng: &mut Rng) -> f64 {
690    (rng.next_u64() >> 11) as f64 / (1u64 << 53) as f64
691}
692
693#[cfg(test)]
694mod tests {
695    use super::*;
696    use crate::algo::{label_propagation, wcc};
697    use crate::graph::NO_PROPS;
698    use crate::{Graph, Snapshot};
699    use yo_common::Rng;
700
701    fn linked(edges: &[(u64, u64)]) -> Graph {
702        let mut g = Graph::new();
703        for (from, to) in edges {
704            g.link(*from, *to, 1, NO_PROPS).expect("an edge");
705        }
706        g
707    }
708
709    fn clique(first: u64, size: u64) -> Vec<(u64, u64)> {
710        let mut edges = Vec::new();
711        for a in first..first + size {
712            for b in a + 1..first + size {
713                edges.push((a, b));
714            }
715        }
716        edges
717    }
718
719    /// Cliques in a ring, joined one edge apiece, which is the graph everybody
720    /// tests community detection on because the answer is not in doubt.
721    fn ring(groups: u64, size: u64) -> Vec<(u64, u64)> {
722        let mut edges = Vec::new();
723        for group in 0..groups {
724            edges.extend(clique(group * 1000, size));
725        }
726        for group in 0..groups {
727            edges.push((group * 1000, (group + 1) % groups * 1000 + 1));
728        }
729        edges
730    }
731
732    /// Modularity worked out the slow way, straight off the definition, one
733    /// pair of nodes at a time.
734    fn slow(g: &Snapshot, of: &[u32], resolution: f64) -> f64 {
735        let n = g.nodes() as usize;
736        let mut a = vec![vec![0f64; n]; n];
737        for node in 0..n as u32 {
738            for other in g.out(node) {
739                a[node as usize][*other as usize] += 1.0;
740                a[*other as usize][node as usize] += 1.0;
741            }
742        }
743        let degree: Vec<f64> = (0..n).map(|node| a[node].iter().sum()).collect();
744        let total: f64 = degree.iter().sum();
745        if total == 0.0 {
746            return 0.0;
747        }
748        let mut q = 0.0;
749        for i in 0..n {
750            for j in 0..n {
751                if of[i] == of[j] {
752                    q += a[i][j] - resolution * degree[i] * degree[j] / total;
753                }
754            }
755        }
756        q / total
757    }
758
759    #[test]
760    fn the_measure_agrees_with_the_definition() {
761        let mut rng = Rng::new(0x9d1);
762        // Fewer and smaller cases under Miri. Each edge goes in through
763        // `Graph`, which is a document put per end and one for the edge, and
764        // that is what the size costs here. The definition is a sum over pairs
765        // and it either agrees on a graph or it does not, so what the cases buy
766        // is the odd shape rather than the size: an isolated node, a self loop,
767        // a community nobody is in. All of those turn up in a handful of small
768        // graphs.
769        let (cases, spread) = if cfg!(miri) { (3, 8) } else { (40, 30) };
770        for case in 0..cases {
771            let nodes = 2 + rng.next_u64() % spread;
772            let edges: Vec<(u64, u64)> = (0..nodes * 2)
773                .map(|_| (rng.next_u64() % nodes, rng.next_u64() % nodes))
774                .collect();
775            let s = Snapshot::of(&linked(&edges));
776            // Three groups, or one a node where there are fewer than three
777            // nodes. A label above the node count is a valid grouping by the
778            // documented contract and `modularity` panics on one, which is
779            // issue #462 and not this test's business. The larger
780            // graphs never handed it one by luck.
781            let groups = u64::from(s.nodes()).clamp(1, 3);
782            let of: Vec<u32> = (0..s.nodes())
783                .map(|_| (rng.next_u64() % groups) as u32)
784                .collect();
785            for resolution in [0.5, 1.0, 2.0] {
786                let (mine, theirs) = (
787                    modularity_with(&s, &of, resolution),
788                    slow(&s, &of, resolution),
789                );
790                assert!(
791                    (mine - theirs).abs() < 1e-9,
792                    "case {case} at {resolution}, {mine} against {theirs}"
793                );
794            }
795        }
796    }
797
798    #[test]
799    fn the_measure_knows_a_good_split_from_a_bad_one() {
800        let s = Snapshot::of(&linked(&ring(4, 8)));
801        let good: Vec<u32> = (0..s.nodes()).map(|node| node / 8).collect();
802        let one = vec![0u32; s.nodes() as usize];
803        let each: Vec<u32> = (0..s.nodes()).collect();
804        assert!(modularity(&s, &good) > 0.6, "{}", modularity(&s, &good));
805        assert!((modularity(&s, &one)).abs() < 1e-9);
806        assert!(modularity(&s, &each) < 0.0);
807    }
808
809    #[test]
810    fn both_find_the_ring_of_cliques() {
811        let s = Snapshot::of(&linked(&ring(6, 10)));
812        for c in [leiden(&s), louvain(&s)] {
813            assert_eq!(c.count(), 6);
814            for group in 0..6u64 {
815                let a = s.dense(group * 1000 + 2).expect("a");
816                let b = s.dense(group * 1000 + 7).expect("b");
817                assert!(c.same(a, b), "group {group}");
818            }
819        }
820    }
821
822    #[test]
823    fn both_beat_label_propagation_for_modularity() {
824        let s = Snapshot::of(&linked(&ring(8, 6)));
825        let quick = modularity(&s, label_propagation(&s).labels());
826        for c in [leiden(&s), louvain(&s)] {
827            assert!(modularity(&s, c.labels()) >= quick - 1e-9);
828        }
829    }
830
831    /// The Leiden guarantee, which is the reason it is here. Every community it
832    /// hands back is connected inside itself.
833    #[test]
834    fn leiden_communities_are_never_disconnected() {
835        let mut rng = Rng::new(0x1ead);
836        // A community that is not connected is one Leiden built wrong, and it
837        // builds it wrong on a small graph the same way, so fewer and smaller
838        // under Miri. Three edges a node is kept because that is what makes the
839        // refinement step have anything to do.
840        let (cases, spread) = if cfg!(miri) { (3, 6) } else { (30, 90) };
841        for case in 0..cases {
842            let nodes = if cfg!(miri) { 6 } else { 10 } + rng.next_u64() % spread;
843            let edges: Vec<(u64, u64)> = (0..nodes * 3)
844                .map(|_| (rng.next_u64() % nodes, rng.next_u64() % nodes))
845                .collect();
846            let s = Snapshot::of(&linked(&edges));
847            let c = leiden(&s);
848            assert!(connected(&s, c.labels()), "case {case}");
849        }
850    }
851
852    /// Whether every group of `of` is joined up inside itself, walked with the
853    /// edges that stay inside the group.
854    fn connected(g: &Snapshot, of: &[u32]) -> bool {
855        let n = g.nodes() as usize;
856        let mut seen = vec![false; n];
857        let mut groups = std::collections::HashSet::new();
858        for node in 0..n {
859            if seen[node] || !groups.insert(of[node]) {
860                if !seen[node] {
861                    return false;
862                }
863                continue;
864            }
865            let mut todo = vec![node as u32];
866            seen[node] = true;
867            while let Some(at) = todo.pop() {
868                for other in g.out(at).iter().chain(g.into_(at)) {
869                    if of[*other as usize] == of[node] && !seen[*other as usize] {
870                        seen[*other as usize] = true;
871                        todo.push(*other);
872                    }
873                }
874            }
875        }
876        true
877    }
878
879    #[test]
880    fn a_community_never_crosses_a_component() {
881        let mut rng = Rng::new(0x1ea0);
882        // One edge a node, so the graph comes out in several pieces, which is
883        // the whole point of it. Fewer and smaller under Miri, and it still
884        // comes out in pieces because that is the degree rather than the size.
885        let (cases, spread) = if cfg!(miri) { (3, 10) } else { (30, 50) };
886        for case in 0..cases {
887            let nodes = 2 + rng.next_u64() % spread;
888            let edges: Vec<(u64, u64)> = (0..nodes)
889                .map(|_| (rng.next_u64() % nodes, rng.next_u64() % nodes))
890                .collect();
891            let s = Snapshot::of(&linked(&edges));
892            let weak = wcc(&s);
893            for c in [leiden(&s), louvain(&s)] {
894                for node in 0..s.nodes() {
895                    for other in 0..s.nodes() {
896                        if c.same(node, other) {
897                            assert!(weak.same(node, other), "case {case}");
898                        }
899                    }
900                }
901            }
902        }
903    }
904
905    #[test]
906    fn one_clique_is_one_community() {
907        let s = Snapshot::of(&linked(&clique(0, 15)));
908        assert_eq!(leiden(&s).count(), 1);
909        assert_eq!(louvain(&s).count(), 1);
910    }
911
912    #[test]
913    fn a_higher_resolution_cuts_finer() {
914        let s = Snapshot::of(&linked(&ring(4, 12)));
915        let coarse = leiden_with(&s, 0.25).count();
916        let plain = leiden_with(&s, 1.0).count();
917        let fine = leiden_with(&s, 6.0).count();
918        assert!(coarse <= plain, "{coarse} against {plain}");
919        assert!(fine > plain, "{fine} against {plain}");
920    }
921
922    #[test]
923    fn nothing_at_all() {
924        for c in [leiden(&Snapshot::default()), louvain(&Snapshot::default())] {
925            assert_eq!(c.count(), 0);
926            assert!(c.is_empty());
927        }
928        assert_eq!(modularity(&Snapshot::default(), &[]), 0.0);
929    }
930
931    #[test]
932    fn a_graph_with_no_edges_is_all_singletons() {
933        let mut g = Graph::new();
934        for id in 0..6u64 {
935            g.add_node(id).expect("a node");
936        }
937        let s = Snapshot::of(&g);
938        assert_eq!(leiden(&s).count(), 6);
939        assert_eq!(louvain(&s).count(), 6);
940    }
941
942    #[test]
943    fn a_self_loop_does_not_break_the_measure() {
944        // A triangle with a loop on one corner is worth nothing however it is
945        // split, so what is being checked is that the loop is counted the same
946        // way by both sides and does not turn into a negative.
947        let s = Snapshot::of(&linked(&[(1, 1), (1, 2), (2, 3), (3, 1)]));
948        for c in [leiden(&s), louvain(&s)] {
949            assert!(modularity(&s, c.labels()).abs() < 1e-9);
950        }
951    }
952
953    #[test]
954    fn two_runs_agree() {
955        let s = Snapshot::of(&linked(&ring(5, 9)));
956        assert_eq!(leiden(&s).labels(), leiden(&s).labels());
957        assert_eq!(louvain(&s).labels(), louvain(&s).labels());
958    }
959
960    #[test]
961    fn direction_does_not_matter() {
962        let edges = ring(4, 8);
963        let forward = Snapshot::of(&linked(&edges));
964        let flipped: Vec<(u64, u64)> = edges.iter().map(|(a, b)| (*b, *a)).collect();
965        let back = Snapshot::of(&linked(&flipped));
966        assert_eq!(leiden(&forward).labels(), leiden(&back).labels());
967    }
968
969    /// Nothing either of them returns can be improved by moving one node.
970    #[test]
971    fn no_single_node_move_helps() {
972        let mut rng = Rng::new(0x1ea2);
973        // The assert walks every node and tries it in every neighbouring
974        // community, so a case costs its nodes times its degree twice over,
975        // once for each algorithm. Fewer and smaller under Miri. What it says
976        // is that the answer is a local optimum, and a local optimum is local.
977        let (cases, base, spread) = if cfg!(miri) { (2, 6, 4) } else { (15, 20, 40) };
978        for case in 0..cases {
979            let nodes = base + rng.next_u64() % spread;
980            let edges: Vec<(u64, u64)> = (0..nodes * 4)
981                .map(|_| (rng.next_u64() % nodes, rng.next_u64() % nodes))
982                .collect();
983            let s = Snapshot::of(&linked(&edges));
984            for c in [leiden(&s), louvain(&s)] {
985                let mut of = c.labels().to_vec();
986                let now = modularity(&s, &of);
987                for node in 0..s.nodes() {
988                    let was = of[node as usize];
989                    for other in c.labels() {
990                        of[node as usize] = *other;
991                        let then = modularity(&s, &of);
992                        assert!(then <= now + 1e-9, "case {case}, node {node}");
993                    }
994                    of[node as usize] = was;
995                }
996            }
997        }
998    }
999
1000    /// The labels are the lowest numbered node in each community.
1001    #[test]
1002    fn the_labels_are_tidy() {
1003        let s = Snapshot::of(&linked(&ring(4, 7)));
1004        let c = leiden(&s);
1005        for node in 0..s.nodes() {
1006            assert_eq!(c.of(c.of(node)), c.of(node));
1007            assert!(c.of(node) <= node);
1008        }
1009    }
1010}