Source code for meanap.pipeline.modularity
"""Consensus-clustering modularity, port of ``mod_consensus_cluster_iterate.m``.
Runs Louvain community detection (``louvain.py``) many times, builds a
consensus co-classification matrix (Lancichinetti & Fortunato 2012), and
repeats on the thresholded consensus matrix until it becomes block-diagonal
(stable partition).
**Not bit-reproducible against MATLAB.** Louvain's local-moving phase visits
nodes in a random order each pass (``randperm`` in MATLAB, a different RNG
stream than Python's), so the specific community *labels* and even the
partition itself can differ between MATLAB and Python runs — same situation
as Step 3's probabilistic thresholding. What's expected to match is
*quality*: modularity Q should land in a similar range, and consensus
clustering should still converge to a stable, block-diagonal partition.
"""
from __future__ import annotations
import numpy as np
from meanap.pipeline.louvain import community_louvain
[docs]
def consensus_coclassify(partitions: list[np.ndarray]) -> np.ndarray:
"""Co-classification matrix: fraction of partitions where i, j share a module."""
stack = np.stack(partitions, axis=1) # (n_nodes, n_partitions)
n = stack.shape[0]
d = np.zeros((n, n))
for k in range(stack.shape[1]):
labels = stack[:, k]
d += (labels[:, None] == labels[None, :]).astype(float)
return d / stack.shape[1]
[docs]
def consensuscheck(d: np.ndarray) -> bool:
"""True if ``d`` is binary (only 0s and 1s) and symmetric (block-diagonal)."""
if not np.allclose(d.sum(axis=0), d.sum(axis=1)):
return False
return bool(np.count_nonzero((d == 0) | (d == 1)) == d.size)
[docs]
def mod_consensus_cluster_iterate(
adj_m: np.ndarray,
threshold: float = 0.4,
rep_num: int = 50,
rng: np.random.Generator | None = None,
max_outer_iterations: int = 50,
) -> tuple[np.ndarray, float, int]:
"""Returns (Ci, Q, num_repeats): consensus community affiliation + modularity.
``max_outer_iterations`` is a safety cap not present in MATLAB (which
loops unconditionally until block-diagonal) — consensus clustering on
real data converges in a handful of iterations; this just prevents a
pathological input from hanging forever.
"""
if rng is None:
rng = np.random.default_rng()
n = adj_m.shape[0]
if n < 2:
return np.ones(max(n, 1), dtype=int), 0.0, 0
m = [community_louvain(adj_m, rng=rng)[0] for _ in range(rep_num)]
d = consensus_coclassify(m)
d[d < threshold] = 0.0
num_repeats = 0
block_diag = False
b = m # fallback if the loop never executes (shouldn't happen for n>=2)
q_list = [0.0] * rep_num
while not block_diag and num_repeats < max_outer_iterations:
b = []
q_list = []
for _ in range(rep_num):
ci, q = community_louvain(d, rng=rng)
b.append(ci)
q_list.append(q)
d = consensus_coclassify(b)
d[d < threshold] = 0.0
num_repeats += 1
block_diag = consensuscheck(d)
return b[0], q_list[0], num_repeats