diff --git a/jgrapht-core/pom.xml b/jgrapht-core/pom.xml index cca88c02c29..97c454a8731 100644 --- a/jgrapht-core/pom.xml +++ b/jgrapht-core/pom.xml @@ -88,6 +88,8 @@ Ref: https://github.com/jgrapht/jgrapht/issues/1170 --> --add-exports org.jgrapht.core/org.jgrapht.perf.clique=ALL-UNNAMED + --add-exports org.jgrapht.core/org.jgrapht.perf.clustering=ALL-UNNAMED + --add-exports org.jgrapht.core/org.jgrapht.perf.clustering.jmh_generated=ALL-UNNAMED --add-exports org.jgrapht.core/org.jgrapht.perf.connectivity=ALL-UNNAMED --add-exports org.jgrapht.core/org.jgrapht.perf.flow=ALL-UNNAMED --add-exports org.jgrapht.core/org.jgrapht.perf.graph=ALL-UNNAMED diff --git a/jgrapht-core/src/main/java/org/jgrapht/alg/clustering/LouvainClustering.java b/jgrapht-core/src/main/java/org/jgrapht/alg/clustering/LouvainClustering.java new file mode 100644 index 00000000000..5ac400286e7 --- /dev/null +++ b/jgrapht-core/src/main/java/org/jgrapht/alg/clustering/LouvainClustering.java @@ -0,0 +1,377 @@ +/* + * (C) Copyright 2026-2026, by seilat and Contributors. + * + * JGraphT : a free Java graph-theory library + * + * See the CONTRIBUTORS.md file distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the + * GNU Lesser General Public License v2.1 or later + * which is available at + * http://www.gnu.org/licenses/old-licenses/lgpl-2.1-standalone.html. + * + * SPDX-License-Identifier: EPL-2.0 OR LGPL-2.1-or-later + */ +package org.jgrapht.alg.clustering; + +import org.jgrapht.*; +import org.jgrapht.alg.interfaces.*; + +import java.util.*; + +/** + * The Louvain method for community detection. + * + *

+ * Greedily optimizes the + * modularity of a vertex + * partition. The algorithm is described in detail in the following + * paper: + *

+ * + *

+ * The method proceeds in alternating phases. In the local-moving phase every vertex starts + * in its own community and is repeatedly moved to the neighbouring community that yields the + * largest positive gain in modularity, until no move improves the objective. In the + * aggregation phase the discovered communities are contracted into super-vertices (with + * intra-community edges becoming self-loops) and the two phases repeat on the smaller graph. The + * process stops once a local-moving phase merges no communities. The final partition is projected + * back onto the original vertices. + * + *

+ * The algorithm runs on undirected graphs and supports edge weights; parallel edges are collapsed + * by summing their weights and self-loops are honoured using the same conventions as + * {@link UndirectedModularityMeasurer}. Edge weights must be non-negative (modularity is undefined + * for negative weights); a negative weight triggers an {@link IllegalArgumentException} when the + * clustering is computed. Its empirical running time is close to linear in the number of edges, + * although no worst-case guarantee is provided. + * + *

+ * The local-moving phase visits vertices in a random order, so two runs on the same graph may + * return different (but typically similar quality) partitions. Supply a seeded {@link Random} via + * the constructor for deterministic behaviour. + * + * @author seilat + * + * @param the graph vertex type + * @param the graph edge type + */ +public class LouvainClustering implements ClusteringAlgorithm +{ + /** + * Default minimum modularity gain that justifies moving a vertex to another community. Guards + * against floating-point oscillation in the local-moving phase. + */ + public static final double DEFAULT_TOLERANCE = 1e-7; + + private final Graph graph; + private final Random rng; + private final double tolerance; + + private Clustering result; + private double modularity; + + /** + * Create a new clustering algorithm with a fresh random number generator. + * + * @param graph the graph (needs to be undirected) + */ + public LouvainClustering(Graph graph) + { + this(graph, new Random(), DEFAULT_TOLERANCE); + } + + /** + * Create a new clustering algorithm with a user-supplied random number generator. Provide a + * seeded generator for reproducible results. + * + * @param graph the graph (needs to be undirected) + * @param rng random number generator + */ + public LouvainClustering(Graph graph, Random rng) + { + this(graph, rng, DEFAULT_TOLERANCE); + } + + /** + * Create a new clustering algorithm. + * + * @param graph the graph (needs to be undirected) + * @param rng random number generator + * @param tolerance minimum modularity gain that justifies moving a vertex; must be non-negative + */ + public LouvainClustering(Graph graph, Random rng, double tolerance) + { + this.graph = GraphTests.requireUndirected(graph); + this.rng = Objects.requireNonNull(rng, "Random number generator cannot be null"); + if (tolerance < 0d) { + throw new IllegalArgumentException("Tolerance cannot be negative"); + } + this.tolerance = tolerance; + } + + @Override + public Clustering getClustering() + { + if (result == null) { + compute(); + } + return result; + } + + /** + * Returns the modularity of the computed clustering. The clustering is computed on first + * access. The modularity of a graph with no positive total edge weight (no edges, or all edge + * weights {@code 0}) is defined here to be {@code 0}. + * + * @return the modularity of the clustering in the range $[-0.5, 1)$ + */ + public double getModularity() + { + getClustering(); + return modularity; + } + + private void compute() + { + List indexToVertex = new ArrayList<>(graph.vertexSet()); + final int n = indexToVertex.size(); + if (n == 0) { + result = new ClusteringImpl<>(Collections.emptyList()); + modularity = 0d; + return; + } + Map vertexToIndex = new HashMap<>(n); + for (int i = 0; i < n; i++) { + vertexToIndex.put(indexToVertex.get(i), i); + } + + // Build the level-0 weighted adjacency, collapsing parallel edges and tracking self-loops. + List> adjacency = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + adjacency.add(new HashMap<>()); + } + double[] selfLoop = new double[n]; + boolean weighted = graph.getType().isWeighted(); + for (E e : graph.edgeSet()) { + int u = vertexToIndex.get(graph.getEdgeSource(e)); + int v = vertexToIndex.get(graph.getEdgeTarget(e)); + double w = weighted ? graph.getEdgeWeight(e) : 1d; + if (w < 0d) { + throw new IllegalArgumentException("Edge weights must be non-negative"); + } + if (u == v) { + selfLoop[u] += w; + } else { + adjacency.get(u).merge(v, w, Double::sum); + adjacency.get(v).merge(u, w, Double::sum); + } + } + + // 2m: sum of all weighted degrees, invariant across aggregation levels. + double totalWeight = 0d; + for (int i = 0; i < n; i++) { + totalWeight += 2d * selfLoop[i]; + for (double w : adjacency.get(i).values()) { + totalWeight += w; + } + } + + // Map every original vertex to its node in the current (aggregated) level. + int[] vertexToNode = new int[n]; + for (int i = 0; i < n; i++) { + vertexToNode[i] = i; + } + + if (totalWeight > 0d) { + while (true) { + int[] community = localMoving(adjacency, selfLoop, totalWeight); + int communities = countCommunities(community); + for (int v = 0; v < n; v++) { + vertexToNode[v] = community[vertexToNode[v]]; + } + if (communities == adjacency.size()) { + // The local-moving phase merged nothing: converged. + break; + } + List> aggregatedAdjacency = new ArrayList<>(communities); + for (int c = 0; c < communities; c++) { + aggregatedAdjacency.add(new HashMap<>()); + } + double[] aggregatedSelfLoop = + aggregate(adjacency, selfLoop, community, communities, aggregatedAdjacency); + adjacency = aggregatedAdjacency; + selfLoop = aggregatedSelfLoop; + } + } + + result = buildClustering(indexToVertex, vertexToNode); + // Guard on totalWeight, not edgeSet emptiness: a graph whose edges all have weight 0 has + // 2m == 0, which would make the measurer divide by zero and return NaN. + modularity = totalWeight > 0d + ? new UndirectedModularityMeasurer<>(graph).modularity(result.getClusters()) + : 0d; + } + + /** + * Local-moving phase. Each node starts in its own community and is repeatedly relocated to the + * neighbouring community that maximises the modularity gain until no move improves the + * objective. Returns a compacted community label (in {@code [0, k)}) for every node. + */ + private int[] localMoving( + List> adjacency, double[] selfLoop, double totalWeight) + { + final int n = adjacency.size(); + double[] degree = new double[n]; + double[] sigmaTot = new double[n]; + int[] community = new int[n]; + for (int i = 0; i < n; i++) { + double d = 2d * selfLoop[i]; + for (double w : adjacency.get(i).values()) { + d += w; + } + degree[i] = d; + sigmaTot[i] = d; + community[i] = i; + } + + Integer[] order = new Integer[n]; + for (int i = 0; i < n; i++) { + order[i] = i; + } + + boolean improvement = true; + while (improvement) { + improvement = false; + Collections.shuffle(Arrays.asList(order), rng); + for (int oi = 0; oi < n; oi++) { + int i = order[oi]; + double ki = degree[i]; + + // Weight from i to each neighbouring community. + Map weightToCommunity = new HashMap<>(); + for (Map.Entry en : adjacency.get(i).entrySet()) { + weightToCommunity.merge(community[en.getKey()], en.getValue(), Double::sum); + } + + int currentCommunity = community[i]; + // Tentatively remove i from its community. + sigmaTot[currentCommunity] -= ki; + + int bestCommunity = currentCommunity; + double bestGain = weightToCommunity.getOrDefault(currentCommunity, 0d) + - sigmaTot[currentCommunity] * ki / totalWeight; + for (Map.Entry en : weightToCommunity.entrySet()) { + int c = en.getKey(); + if (c == currentCommunity) { + continue; + } + double gain = en.getValue() - sigmaTot[c] * ki / totalWeight; + if (gain > bestGain + tolerance) { + bestGain = gain; + bestCommunity = c; + } + } + + sigmaTot[bestCommunity] += ki; + if (bestCommunity != currentCommunity) { + community[i] = bestCommunity; + improvement = true; + } + } + } + return compact(community); + } + + /** + * Contracts each community into a single node. Intra-community edges (and pre-existing + * self-loops) become the new node's self-loop; inter-community edges are summed into the new + * adjacency. Returns the self-loop weights of the aggregated nodes and fills + * {@code aggregatedAdjacency}. Degrees are conserved: the aggregated node degree equals the + * total degree of its members. + */ + private double[] aggregate( + List> adjacency, double[] selfLoop, int[] community, int communities, + List> aggregatedAdjacency) + { + double[] aggregatedSelfLoop = new double[communities]; + double[] internalAccumulator = new double[communities]; + final int n = adjacency.size(); + for (int u = 0; u < n; u++) { + int cu = community[u]; + aggregatedSelfLoop[cu] += selfLoop[u]; + for (Map.Entry en : adjacency.get(u).entrySet()) { + int cv = community[en.getKey()]; + if (cu == cv) { + // Each intra-community edge is seen once per direction; halved below. + internalAccumulator[cu] += en.getValue(); + } else { + aggregatedAdjacency.get(cu).merge(cv, en.getValue(), Double::sum); + } + } + } + for (int c = 0; c < communities; c++) { + aggregatedSelfLoop[c] += internalAccumulator[c] / 2d; + } + return aggregatedSelfLoop; + } + + /** + * Builds the list-of-sets clustering from the original-vertex-to-final-community map, ordered + * by community id. + */ + private Clustering buildClustering(List indexToVertex, int[] vertexToNode) + { + int[] compacted = compact(vertexToNode); + int k = countCommunities(compacted); + List> clusters = new ArrayList<>(k); + for (int c = 0; c < k; c++) { + clusters.add(new LinkedHashSet<>()); + } + for (int v = 0; v < compacted.length; v++) { + clusters.get(compacted[v]).add(indexToVertex.get(v)); + } + return new ClusteringImpl<>(clusters); + } + + /** + * Relabels arbitrary community ids to a dense range {@code [0, k)} preserving first-seen order. + */ + private static int[] compact(int[] labels) + { + Map remap = new HashMap<>(); + int[] out = new int[labels.length]; + int next = 0; + for (int i = 0; i < labels.length; i++) { + Integer mapped = remap.get(labels[i]); + if (mapped == null) { + mapped = next++; + remap.put(labels[i], mapped); + } + out[i] = mapped; + } + return out; + } + + /** + * Number of distinct labels in a compacted label array (its maximum plus one, or zero if + * empty). + */ + private static int countCommunities(int[] compactedLabels) + { + int max = -1; + for (int label : compactedLabels) { + if (label > max) { + max = label; + } + } + return max + 1; + } +} diff --git a/jgrapht-core/src/test/java/org/jgrapht/alg/clustering/LouvainClusteringTest.java b/jgrapht-core/src/test/java/org/jgrapht/alg/clustering/LouvainClusteringTest.java new file mode 100644 index 00000000000..3e94116772b --- /dev/null +++ b/jgrapht-core/src/test/java/org/jgrapht/alg/clustering/LouvainClusteringTest.java @@ -0,0 +1,354 @@ +/* + * (C) Copyright 2026-2026, by seilat and Contributors. + * + * JGraphT : a free Java graph-theory library + * + * See the CONTRIBUTORS.md file distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the + * GNU Lesser General Public License v2.1 or later + * which is available at + * http://www.gnu.org/licenses/old-licenses/lgpl-2.1-standalone.html. + * + * SPDX-License-Identifier: EPL-2.0 OR LGPL-2.1-or-later + */ +package org.jgrapht.alg.clustering; + +import org.jgrapht.*; +import org.jgrapht.alg.interfaces.ClusteringAlgorithm.*; +import org.jgrapht.graph.*; +import org.jgrapht.graph.builder.*; +import org.jgrapht.util.*; +import org.junit.jupiter.api.*; + +import java.util.*; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for {@link LouvainClustering}. + * + * @author seilat + */ +public class LouvainClusteringTest +{ + private static final long SEED = 0x1234ABCDL; + + @Test + public void twoCliquesJoinedByOneEdge() + { + Graph g = unweighted(); + addClique(g, 0, 1, 2, 3); + addClique(g, 4, 5, 6, 7); + g.addEdge(3, 4); + + Clustering c = new LouvainClustering<>(g, new Random(SEED)).getClustering(); + + assertEquals(2, c.getNumberClusters()); + assertEquals( + Set.of(Set.of(0, 1, 2, 3), Set.of(4, 5, 6, 7)), new HashSet<>(c.getClusters())); + } + + @Test + public void threeCliquesInARing() + { + Graph g = unweighted(); + addClique(g, 0, 1, 2, 3); + addClique(g, 4, 5, 6, 7); + addClique(g, 8, 9, 10, 11); + g.addEdge(3, 4); + g.addEdge(7, 8); + g.addEdge(11, 0); + + Clustering c = new LouvainClustering<>(g, new Random(SEED)).getClustering(); + + assertEquals(3, c.getNumberClusters()); + assertEquals( + Set.of(Set.of(0, 1, 2, 3), Set.of(4, 5, 6, 7), Set.of(8, 9, 10, 11)), + new HashSet<>(c.getClusters())); + } + + @Test + public void completeGraphIsOneCommunity() + { + Graph g = unweighted(); + addClique(g, 0, 1, 2, 3, 4, 5); + + LouvainClustering alg = new LouvainClustering<>(g, new Random(SEED)); + Clustering c = alg.getClustering(); + + assertEquals(1, c.getNumberClusters()); + assertEquals(Set.of(0, 1, 2, 3, 4, 5), c.getClusters().get(0)); + // The single-community partition of a complete graph has modularity 0; no split improves. + assertEquals(0d, alg.getModularity(), 1e-9); + } + + @Test + public void weightedGraphFollowsHeavyEdges() + { + // Two triangles linked by a single light edge; heavy intra-triangle weights. + Graph g = weighted(); + for (int i = 0; i < 6; i++) { + g.addVertex(i); + } + setEdge(g, 0, 1, 10); + setEdge(g, 1, 2, 10); + setEdge(g, 0, 2, 10); + setEdge(g, 3, 4, 10); + setEdge(g, 4, 5, 10); + setEdge(g, 3, 5, 10); + setEdge(g, 2, 3, 1); // light bridge + + Clustering c = new LouvainClustering<>(g, new Random(SEED)).getClustering(); + + assertEquals(2, c.getNumberClusters()); + assertEquals(Set.of(Set.of(0, 1, 2), Set.of(3, 4, 5)), new HashSet<>(c.getClusters())); + } + + @Test + public void seedYieldsDeterministicResult() + { + Graph g = unweighted(); + addClique(g, 0, 1, 2, 3); + addClique(g, 4, 5, 6, 7); + addClique(g, 8, 9, 10, 11); + g.addEdge(3, 4); + g.addEdge(7, 8); + + List> a = + new LouvainClustering<>(g, new Random(SEED)).getClustering().getClusters(); + List> b = + new LouvainClustering<>(g, new Random(SEED)).getClustering().getClusters(); + + assertEquals(a, b); + } + + @Test + public void singleVertex() + { + Graph g = unweighted(); + g.addVertex(0); + + LouvainClustering alg = new LouvainClustering<>(g, new Random(SEED)); + Clustering c = alg.getClustering(); + + assertEquals(1, c.getNumberClusters()); + assertEquals(Set.of(0), c.getClusters().get(0)); + assertEquals(0d, alg.getModularity(), 1e-9); + } + + @Test + public void emptyGraph() + { + Graph g = unweighted(); + LouvainClustering alg = new LouvainClustering<>(g, new Random(SEED)); + Clustering c = alg.getClustering(); + assertEquals(0, c.getNumberClusters()); + assertEquals(0d, alg.getModularity(), 1e-9); + } + + @Test + public void isolatedVerticesEachOwnCluster() + { + Graph g = unweighted(); + for (int i = 0; i < 4; i++) { + g.addVertex(i); + } + LouvainClustering alg = new LouvainClustering<>(g, new Random(SEED)); + Clustering c = alg.getClustering(); + assertEquals(4, c.getNumberClusters()); + assertEquals(0d, alg.getModularity(), 1e-9); + } + + @Test + public void selfLoopsAreAccepted() + { + Graph g = unweighted(); + addClique(g, 0, 1, 2, 3); + addClique(g, 4, 5, 6, 7); + g.addEdge(3, 4); + g.addEdge(0, 0); // self-loop + g.addEdge(5, 5); + + Clustering c = new LouvainClustering<>(g, new Random(SEED)).getClustering(); + assertEquals(2, c.getNumberClusters()); + assertClusteringIsPartition(g, c); + } + + @Test + public void clusteringIsAlwaysAValidPartition() + { + Random gen = new Random(99); + for (int t = 0; t < 20; t++) { + Graph g = randomUndirected(25, 0.2, gen); + Clustering c = new LouvainClustering<>(g, new Random(SEED)).getClustering(); + assertClusteringIsPartition(g, c); + } + } + + @Test + public void modularityIsExactForTwoCliques() + { + // Two K4 cliques joined by edge 3-4: m = 13, 2m = 26. The optimal 2-community partition + // (each clique) has Q = 2 * (2*6/26 - (13/26)^2) = 11/26. Pinning the value avoids a + // circular check against UndirectedModularityMeasurer (which getModularity delegates to). + Graph g = unweighted(); + addClique(g, 0, 1, 2, 3); + addClique(g, 4, 5, 6, 7); + g.addEdge(3, 4); + + LouvainClustering alg = new LouvainClustering<>(g, new Random(SEED)); + assertEquals(2, alg.getClustering().getNumberClusters()); + assertEquals(11.0 / 26.0, alg.getModularity(), 1e-9); + } + + @Test + public void zeroWeightEdgesGiveZeroModularityNotNaN() + { + // Regression: edges all of weight 0 => 2m == 0; modularity must be 0, not NaN (which a + // naive measurer call would produce by dividing by 2m). + Graph g = weighted(); + for (int i = 0; i < 4; i++) { + g.addVertex(i); + } + setEdge(g, 0, 1, 0d); + setEdge(g, 1, 2, 0d); + setEdge(g, 2, 3, 0d); + + LouvainClustering alg = new LouvainClustering<>(g, new Random(SEED)); + assertEquals(0d, alg.getModularity(), 0d); // exact compare: fails on NaN + assertClusteringIsPartition(g, alg.getClustering()); + } + + @Test + public void negativeEdgeWeightIsRejected() + { + Graph g = weighted(); + g.addVertex(0); + g.addVertex(1); + setEdge(g, 0, 1, -1d); + + LouvainClustering alg = new LouvainClustering<>(g, new Random(SEED)); + assertThrows(IllegalArgumentException.class, alg::getClustering); + } + + @Test + public void modularityBeatsAllSingletonsPartition() + { + Graph g = unweighted(); + addClique(g, 0, 1, 2, 3); + addClique(g, 4, 5, 6, 7); + addClique(g, 8, 9, 10, 11); + g.addEdge(3, 4); + g.addEdge(7, 8); + + LouvainClustering alg = new LouvainClustering<>(g, new Random(SEED)); + double louvain = alg.getModularity(); + + UndirectedModularityMeasurer measurer = + new UndirectedModularityMeasurer<>(g); + List> singletons = new ArrayList<>(); + for (Integer v : g.vertexSet()) { + singletons.add(Set.of(v)); + } + assertTrue(louvain > measurer.modularity(singletons), "Louvain should beat all-singletons"); + assertTrue(louvain > 0.3, "planted partition should have clearly positive modularity"); + } + + @Test + public void directedGraphIsRejected() + { + Graph g = new SimpleDirectedGraph<>(DefaultEdge.class); + g.addVertex(0); + g.addVertex(1); + g.addEdge(0, 1); + assertThrows(IllegalArgumentException.class, () -> new LouvainClustering<>(g)); + } + + @Test + public void nullRngIsRejected() + { + Graph g = unweighted(); + g.addVertex(0); + assertThrows(NullPointerException.class, () -> new LouvainClustering<>(g, null)); + } + + @Test + public void negativeToleranceIsRejected() + { + Graph g = unweighted(); + g.addVertex(0); + assertThrows( + IllegalArgumentException.class, + () -> new LouvainClustering<>(g, new Random(SEED), -1d)); + } + + // ---- helpers ---- + + private static Graph unweighted() + { + return GraphTypeBuilder.undirected().allowingMultipleEdges(true).allowingSelfLoops(true) + .weighted(false).edgeSupplier(SupplierUtil.DEFAULT_EDGE_SUPPLIER) + .vertexSupplier(SupplierUtil.createIntegerSupplier()).buildGraph(); + } + + private static Graph weighted() + { + return GraphTypeBuilder.undirected().allowingMultipleEdges(false).allowingSelfLoops(true) + .weighted(true).edgeSupplier(SupplierUtil.DEFAULT_EDGE_SUPPLIER) + .vertexSupplier(SupplierUtil.createIntegerSupplier()).buildGraph(); + } + + private static void addClique(Graph g, int... vs) + { + for (int v : vs) { + if (!g.containsVertex(v)) { + g.addVertex(v); + } + } + for (int i = 0; i < vs.length; i++) { + for (int j = i + 1; j < vs.length; j++) { + g.addEdge(vs[i], vs[j]); + } + } + } + + private static void setEdge(Graph g, int u, int v, double w) + { + DefaultEdge e = g.addEdge(u, v); + g.setEdgeWeight(e, w); + } + + private static Graph randomUndirected(int n, double p, Random rng) + { + Graph g = unweighted(); + for (int i = 0; i < n; i++) { + g.addVertex(i); + } + for (int i = 0; i < n; i++) { + for (int j = i + 1; j < n; j++) { + if (rng.nextDouble() < p) { + g.addEdge(i, j); + } + } + } + return g; + } + + private static void assertClusteringIsPartition( + Graph g, Clustering c) + { + Set seen = new HashSet<>(); + for (Set cluster : c.getClusters()) { + for (Integer v : cluster) { + assertTrue(seen.add(v), "vertex " + v + " appears in more than one cluster"); + } + } + assertEquals(g.vertexSet(), seen, "clustering must cover every vertex exactly once"); + } +} diff --git a/jgrapht-core/src/test/java/org/jgrapht/perf/clustering/LouvainClusteringPerformanceTest.java b/jgrapht-core/src/test/java/org/jgrapht/perf/clustering/LouvainClusteringPerformanceTest.java new file mode 100644 index 00000000000..f32b6a4dcf6 --- /dev/null +++ b/jgrapht-core/src/test/java/org/jgrapht/perf/clustering/LouvainClusteringPerformanceTest.java @@ -0,0 +1,118 @@ +/* + * (C) Copyright 2026-2026, by seilat and Contributors. + * + * JGraphT : a free Java graph-theory library + * + * See the CONTRIBUTORS.md file distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the + * GNU Lesser General Public License v2.1 or later + * which is available at + * http://www.gnu.org/licenses/old-licenses/lgpl-2.1-standalone.html. + * + * SPDX-License-Identifier: EPL-2.0 OR LGPL-2.1-or-later + */ +package org.jgrapht.perf.clustering; + +import org.jgrapht.*; +import org.jgrapht.alg.clustering.*; +import org.jgrapht.alg.interfaces.ClusteringAlgorithm.*; +import org.jgrapht.generate.*; +import org.jgrapht.graph.*; +import org.jgrapht.graph.builder.*; +import org.jgrapht.util.*; +import org.junit.jupiter.api.*; +import org.openjdk.jmh.annotations.*; +import org.openjdk.jmh.runner.*; +import org.openjdk.jmh.runner.options.*; + +import java.util.*; +import java.util.concurrent.*; + +/** + * JMH benchmark comparing {@link LouvainClustering} against the other modularity-oriented + * community-detection algorithms in the package ({@link LabelPropagationClustering} and + * {@link GreedyModularityAlgorithm}) on planted-partition graphs of increasing size. + * + *

+ * Cells are kept deliberately small and ordered smallest-first so the suite self-bounds; raise the + * {@code groups}/{@code groupSize} parameters locally for a heavier sweep. + * + * @author seilat + */ +@BenchmarkMode(Mode.AverageTime) +@Fork(value = 1, warmups = 0) +@Warmup(iterations = 3, time = 2) +@Measurement(iterations = 5, time = 2) +@OutputTimeUnit(TimeUnit.MILLISECONDS) +public class LouvainClusteringPerformanceTest +{ + /** + * In-process JMH launcher with short, self-bounded warmup/measurement so the benchmark stays + * within the fast-test budget. Run with {@code -Dtest=LouvainClusteringPerformanceTest}. + * + * @throws RunnerException if the benchmark harness fails + */ + @Test + public void runBenchmark() + throws RunnerException + { + Options opt = new OptionsBuilder() + .include(".*" + LouvainClusteringPerformanceTest.class.getSimpleName() + ".*") + .forks(0).warmupIterations(2).warmupTime(TimeValue.seconds(1)) + .measurementIterations(3).measurementTime(TimeValue.seconds(1)) + .shouldFailOnError(true).build(); + new Runner(opt).run(); + } + + @Benchmark + public Clustering louvain(ClusteringState state) + { + return new LouvainClustering<>(state.graph, new Random(state.seed)).getClustering(); + } + + @Benchmark + public Clustering labelPropagation(ClusteringState state) + { + return new LabelPropagationClustering<>(state.graph, new Random(state.seed)).getClustering(); + } + + @Benchmark + public Clustering greedyModularity(ClusteringState state) + { + return new GreedyModularityAlgorithm<>(state.graph).getClustering(); + } + + /** + * Benchmark state: a freshly generated planted-partition graph per iteration. + */ + @State(Scope.Benchmark) + public static class ClusteringState + { + @Param({ "5", "20" }) + int groups; + @Param({ "25" }) + int groupSize; + @Param({ "0.4" }) + double intraProbability; + @Param({ "0.02" }) + double interProbability; + + final long seed = 42L; + Graph graph; + + @Setup(Level.Iteration) + public void generate() + { + graph = GraphTypeBuilder + .undirected().allowingMultipleEdges(false).allowingSelfLoops(false).weighted(false) + .edgeSupplier(SupplierUtil.DEFAULT_EDGE_SUPPLIER) + .vertexSupplier(SupplierUtil.createIntegerSupplier()).buildGraph(); + new PlantedPartitionGraphGenerator( + groups, groupSize, intraProbability, interProbability, seed).generateGraph(graph); + } + } +}