Skip to content

Commit 11bebd1

Browse files
committed
fix(louvain): prevent over-merging on small and dense graphs
Three fixes to Louvain community detection: - Cap adaptive threshold at 1/(4m) so modularity gains are detectable on dense graphs where per-node delta-Q ~ 1/(2m) - Skip Phase 2 aggregation on the last hierarchy level to prevent node-to-community key mismatches - Raise hierarchical optimization threshold to 200 nodes and converge at <=2 communities to prevent over-merging small graphs
1 parent 5bef09d commit 11bebd1

1 file changed

Lines changed: 21 additions & 6 deletions

File tree

src/algorithms/clustering/louvain.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -287,14 +287,19 @@ export const detectCommunities = <N extends Node, E extends Edge>(graph: Graph<N
287287
superNodes.set(node.id, new Set([node.id]));
288288
}
289289

290-
// Adaptive strategy: Use hierarchical optimization only for larger graphs
291-
// Very small graphs (<= 50 nodes) get sufficient quality with single-level optimization
290+
// Adaptive strategy: Use hierarchical optimization only for larger graphs.
291+
// Small graphs (≤200 nodes) get sufficient quality with single-level optimization.
292+
// Hierarchical aggregation on small graphs risks over-merging: with few
293+
// communities (e.g., 5), Phase 1 at the next level can collapse them all into 1.
292294
const nodeCount = allNodes.length;
293-
const useHierarchicalOptimization = nodeCount > 50;
295+
const useHierarchicalOptimization = nodeCount > 200;
294296

295297
// T010: Adaptive modularity threshold using helper function (spec-027 Phase 1)
298+
// Cap the threshold at 1/(4m) to ensure individual node moves are detectable.
299+
// For dense graphs (high m), per-node ΔQ ≈ 1/(2m), so a threshold above that
300+
// blocks all moves and produces degenerate singleton communities.
296301
const adaptiveMinModularityIncrease = minModularityIncrease ??
297-
getAdaptiveThreshold(nodeCount);
302+
Math.min(getAdaptiveThreshold(nodeCount), 1 / (4 * m));
298303

299304
// Multi-level optimization: Phase 1 + Phase 2 repeated
300305
let hierarchyLevel = 0;
@@ -509,8 +514,18 @@ export const detectCommunities = <N extends Node, E extends Edge>(graph: Graph<N
509514

510515
// Phase 2: Aggregate communities into new super-nodes
511516
const numberCommunities = communities.size;
512-
if (numberCommunities <= 1 || numberCommunities >= superNodes.size) {
513-
// Converged - only 1 community or no merging happened
517+
if (numberCommunities <= 2 || numberCommunities >= superNodes.size) {
518+
// Converged: ≤2 communities leaves too few super-nodes for meaningful
519+
// hierarchical refinement (the next Phase 1 would likely over-merge),
520+
// or no merging happened at all.
521+
break;
522+
}
523+
524+
// Skip aggregation on last level: the next iteration would rebuild
525+
// nodeToCommunity for new super-node IDs, but there is no next iteration.
526+
// Without this, finalNodeToCommunity finds no matching keys and returns
527+
// zero communities.
528+
if (hierarchyLevel >= MAX_HIERARCHY_LEVELS) {
514529
break;
515530
}
516531

0 commit comments

Comments
 (0)