Source code for meanap.pipeline.louvain
"""Louvain modularity optimization, port of ``community_louvain.m`` (BCT).
Single-run community detection. Inherently stochastic (random node
processing order each pass) — used as the building block for
``modularity.py``'s consensus-clustering wrapper, which stabilizes the
randomness across many runs. Not bit-reproducible against MATLAB (different
RNG stream) — see ``modularity.py``'s module docstring.
"""
from __future__ import annotations
import numpy as np
[docs]
def community_louvain(
w: np.ndarray, gamma: float = 1.0, rng: np.random.Generator | None = None,
) -> tuple[np.ndarray, float]:
"""Returns (M, Q): community affiliation vector (1-indexed, like MATLAB) and modularity.
Direct port of ``community_louvain.m``'s default ('modularity') path
with no initial partition — matches how ``mod_consensus_cluster_iterate.m``
always calls it (``community_louvain(adjM)``, no extra args). Renumbering
of module labels happens exactly once per hierarchical level, after the
local-moving phase fully converges — matching MATLAB's structure, not
after every node sweep.
"""
if rng is None:
rng = np.random.default_rng()
w = np.asarray(w, dtype=float)
n = len(w)
s = w.sum()
if s == 0:
return np.arange(1, n + 1), 0.0
b = (w - gamma * np.outer(w.sum(axis=1), w.sum(axis=0)) / s) / s
b = (b + b.T) / 2.0
m = np.arange(n) # final (across-hierarchy) community label per original node
mb = np.arange(n) # current level's community label
hnm = np.zeros((n, n))
for mod in range(mb.max() + 1):
hnm[:, mod] = b[:, mb == mod].sum(axis=1)
q0 = -np.inf
q = b[np.equal.outer(m, m)].sum() # m == arange(n) here: just trace(b)
first_iteration = True
while q - q0 > 1e-10:
flag = True
while flag:
flag = False
for u in rng.permutation(len(mb)):
ma = mb[u]
dq = hnm[u, :] - hnm[u, ma] + b[u, u]
dq[ma] = 0.0
mb_new = int(np.argmax(dq))
max_dq = dq[mb_new]
if max_dq > 1e-10:
flag = True
mb[u] = mb_new
hnm[:, mb_new] += b[:, u]
hnm[:, ma] -= b[:, u]
_, mb = np.unique(mb, return_inverse=True)
m0 = m.copy()
if first_iteration:
m = mb.copy()
first_iteration = False
else:
for u in range(m0.max() + 1):
m[m0 == u] = mb[u]
n_mod = mb.max() + 1
b1 = np.zeros((n_mod, n_mod))
for u in range(n_mod):
for v in range(u, n_mod):
bm = b[np.ix_(mb == u, mb == v)].sum()
b1[u, v] = bm
b1[v, u] = bm
b = b1
mb = np.arange(n_mod)
hnm = b.copy()
q0 = q
q = float(np.trace(b))
return m + 1, float(q) # +1 to match MATLAB's 1-indexed community labels