diff --git a/src/_igraph/graphobject.c b/src/_igraph/graphobject.c index a2d982ad7..0c1401f60 100644 --- a/src/_igraph/graphobject.c +++ b/src/_igraph/graphobject.c @@ -13775,6 +13775,125 @@ PyObject *igraphmodule_Graph_community_fluid_communities(igraphmodule_GraphObjec return result; } +/** + * Voronoi clustering + */ +PyObject *igraphmodule_Graph_community_voronoi(igraphmodule_GraphObject *self, + PyObject *args, PyObject *kwds) { + static char *kwlist[] = {"lengths", "weights", "mode", "radius", NULL}; + PyObject *lengths_o = Py_None, *weights_o = Py_None; + PyObject *mode_o = Py_None; + PyObject *radius_o = Py_None; + igraph_vector_t *lengths_v = NULL; + igraph_vector_t *weights_v = NULL; + igraph_vector_int_t membership_v, generators_v; + igraph_neimode_t mode = IGRAPH_OUT; + igraph_real_t radius = -1.0; /* negative means auto-optimize */ + igraph_real_t modularity = IGRAPH_NAN; + PyObject *membership_o, *generators_o, *result_o; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOOO", kwlist, + &lengths_o, &weights_o, &mode_o, &radius_o)) { + return NULL; + } + + /* Handle mode parameter */ + if (igraphmodule_PyObject_to_neimode_t(mode_o, &mode)) { + return NULL; + } + + /* Handle radius parameter */ + if (radius_o != Py_None) { + if (igraphmodule_PyObject_to_real_t(radius_o, &radius)) { + return NULL; + } + } + + /* Handle lengths parameter */ + if (igraphmodule_attrib_to_vector_t(lengths_o, self, &lengths_v, ATTRIBUTE_TYPE_EDGE)) { + return NULL; + } + + /* Handle weights parameter */ + if (igraphmodule_attrib_to_vector_t(weights_o, self, &weights_v, ATTRIBUTE_TYPE_EDGE)) { + if (lengths_v != NULL) { + igraph_vector_destroy(lengths_v); free(lengths_v); + } + return NULL; + } + + /* Initialize result vectors */ + if (igraph_vector_int_init(&membership_v, 0)) { + if (lengths_v != NULL) { + igraph_vector_destroy(lengths_v); free(lengths_v); + } + if (weights_v != NULL) { + igraph_vector_destroy(weights_v); free(weights_v); + } + igraphmodule_handle_igraph_error(); + return NULL; + } + + if (igraph_vector_int_init(&generators_v, 0)) { + if (lengths_v != NULL) { + igraph_vector_destroy(lengths_v); free(lengths_v); + } + if (weights_v != NULL) { + igraph_vector_destroy(weights_v); free(weights_v); + } + igraph_vector_int_destroy(&membership_v); + igraphmodule_handle_igraph_error(); + return NULL; + } + + /* Call the C function - pass NULL for None parameters */ + if (igraph_community_voronoi(&self->g, &membership_v, &generators_v, + &modularity, + lengths_v, + weights_v, + mode, radius)) { + + if (lengths_v != NULL) { + igraph_vector_destroy(lengths_v); free(lengths_v); + } + if (weights_v != NULL) { + igraph_vector_destroy(weights_v); free(weights_v); + } + igraph_vector_int_destroy(&membership_v); + igraph_vector_int_destroy(&generators_v); + igraphmodule_handle_igraph_error(); + return NULL; + } + + /* Clean up input vectors */ + if (lengths_v != NULL) { + igraph_vector_destroy(lengths_v); free(lengths_v); + } + if (weights_v != NULL) { + igraph_vector_destroy(weights_v); free(weights_v); + } + + /* Convert results to Python objects */ + membership_o = igraphmodule_vector_int_t_to_PyList(&membership_v); + igraph_vector_int_destroy(&membership_v); + if (!membership_o) { + igraph_vector_int_destroy(&generators_v); + return NULL; + } + + generators_o = igraphmodule_vector_int_t_to_PyList(&generators_v); + igraph_vector_int_destroy(&generators_v); + if (!generators_o) { + Py_DECREF(membership_o); + return NULL; + } + + /* Return tuple with membership, generators, and modularity */ + result_o = Py_BuildValue("(NNd)", membership_o, generators_o, modularity); + + return result_o; +} + /********************************************************************** * Random walks * **********************************************************************/ @@ -18653,6 +18772,42 @@ struct PyMethodDef igraphmodule_Graph_methods[] = { " original implementation is used.\n" "@return: the community membership vector.\n" }, + {"community_voronoi", + (PyCFunction) igraphmodule_Graph_community_voronoi, + METH_VARARGS | METH_KEYWORDS, + "community_voronoi(lengths=None, weights=None, mode=\"out\", radius=None)\n--\n\n" + "Finds communities using Voronoi partitioning.\n\n" + "This function finds communities using a Voronoi partitioning of vertices based\n" + "on the given edge lengths divided by the edge clustering coefficient.\n" + "The generator vertices are chosen to be those with the largest local relative\n" + "density within a radius, with the local relative density of a vertex defined as\n" + "C{s * m / (m + k)}, where s is the strength of the vertex, m is the number of\n" + "edges within the vertex's first order neighborhood, while k is the number of\n" + "edges with only one endpoint within this neighborhood.\n\n" + "B{References}\n\n" + " - Deritei et al., Community detection by graph Voronoi diagrams,\n" + " New Journal of Physics 16, 063007 (2014)\n" + " U{https://doi.org/10.1088/1367-2630/16/6/063007}\n" + " - Molnár et al., Community Detection in Directed Weighted Networks\n" + " using Voronoi Partitioning, Scientific Reports 14, 8124 (2024)\n" + " U{https://doi.org/10.1038/s41598-024-58624-4}\n\n" + "@param lengths: edge lengths, or C{None} to consider all edges as having\n" + " unit length. Voronoi partitioning will use edge lengths equal to\n" + " lengths / ECC where ECC is the edge clustering coefficient.\n" + "@param weights: edge weights, or C{None} to consider all edges as having\n" + " unit weight. Weights are used when selecting generator points, as well\n" + " as for computing modularity.\n" + "@param mode: if C{\"out\"} (the default), distances from generator points to all other\n" + " nodes are considered. If C{\"in\"}, the reverse distances are used.\n" + " If C{\"all\"}, edge directions are ignored. This parameter is ignored\n" + " for undirected graphs.\n" + "@param radius: the radius/resolution to use when selecting generator points.\n" + " The larger this value, the fewer partitions there will be. Pass C{None}\n" + " to automatically select the radius that maximizes modularity.\n" + "@return: a tuple containing the membership vector, generator vertices, and\n" + " modularity score: (membership, generators, modularity).\n" + "@rtype: tuple\n" + }, {"community_leiden", (PyCFunction) igraphmodule_Graph_community_leiden, METH_VARARGS | METH_KEYWORDS, diff --git a/src/igraph/__init__.py b/src/igraph/__init__.py index a1e87d13e..78786b980 100644 --- a/src/igraph/__init__.py +++ b/src/igraph/__init__.py @@ -111,6 +111,7 @@ _community_edge_betweenness, _community_fluid_communities, _community_spinglass, + _community_voronoi, _community_walktrap, _k_core, _community_leiden, @@ -661,6 +662,7 @@ def es(self): community_edge_betweenness = _community_edge_betweenness community_fluid_communities = _community_fluid_communities community_spinglass = _community_spinglass + community_voronoi = _community_voronoi community_walktrap = _community_walktrap k_core = _k_core community_leiden = _community_leiden @@ -1104,6 +1106,7 @@ def write(graph, filename, *args, **kwds): _community_edge_betweenness, _community_fluid_communities, _community_spinglass, + _community_voronoi, _community_walktrap, _k_core, _community_leiden, diff --git a/src/igraph/community.py b/src/igraph/community.py index 1150dcb57..d08546bd7 100644 --- a/src/igraph/community.py +++ b/src/igraph/community.py @@ -327,6 +327,68 @@ def _community_spinglass(graph, *args, **kwds): return VertexClustering(graph, membership, modularity_params=modularity_params) +def _community_voronoi(graph, lengths=None, weights=None, mode="out", radius=None): + """Finds communities using Voronoi partitioning. + + This function finds communities using a Voronoi partitioning of vertices based + on the given edge lengths divided by the edge clustering coefficient. + The generator vertices are chosen to be those with the largest local relative + density within a radius, with the local relative density of a vertex defined + as C{s * m / (m + k)}, where C{s} is the strength of the vertex, C{m} is + the number of edges within the vertex's first order neighborhood, while C{k} + is the number of edges with only one endpoint within this neighborhood. + + B{References} + + - Deritei et al., Community detection by graph Voronoi diagrams, + I{New Journal of Physics} 16, 063007 (2014). + U{https://doi.org/10.1088/1367-2630/16/6/063007}. + - Molnár et al., Community Detection in Directed Weighted Networks using + Voronoi Partitioning, I{Scientific Reports} 14, 8124 (2024). + U{https://doi.org/10.1038/s41598-024-58624-4}. + + @param lengths: edge lengths, or C{None} to consider all edges as having + unit length. Voronoi partitioning will use edge lengths equal to + lengths / ECC where ECC is the edge clustering coefficient. + @param weights: edge weights, or C{None} to consider all edges as having + unit weight. Weights are used when selecting generator points, as well + as for computing modularity. + @param mode: specifies how to use the direction of edges when computing + distances from generator points. If C{"out"} (the default), distances + from generator points to all other nodes are considered following the + direction of edges. If C{"in"}, distances are computed in the reverse + direction (i.e., from all nodes to generator points). If C{"all"}, + edge directions are ignored and the graph is treated as undirected. + This parameter is ignored for undirected graphs. + @param radius: the radius/resolution to use when selecting generator points. + The larger this value, the fewer partitions there will be. Pass C{None} + to automatically select the radius that maximizes modularity. + @return: an appropriate L{VertexClustering} object with an extra attribute + called C{generators} (the generator vertices). + """ + # Convert mode string to proper enum value to avoid deprecation warning + if isinstance(mode, str): + mode_map = {"out": "out", "in": "in", "all": "all", "total": "all"} # alias + if mode.lower() in mode_map: + mode = mode_map[mode.lower()] + else: + raise ValueError(f"Invalid mode '{mode}'. Must be one of: out, in, all") + + membership, generators, modularity = GraphBase.community_voronoi(graph, lengths, weights, mode, radius) + + params = {"generators": generators} + modularity_params = {} + if weights is not None: + modularity_params["weights"] = weights + + clustering = VertexClustering( + graph, membership, modularity=modularity, params=params, modularity_params=modularity_params + ) + + clustering.generators = generators + return clustering + + def _community_walktrap(graph, weights=None, steps=4): """Community detection algorithm of Latapy & Pons, based on random walks. diff --git a/tests/test_decomposition.py b/tests/test_decomposition.py index 5bcb0f7c3..109cad0dd 100644 --- a/tests/test_decomposition.py +++ b/tests/test_decomposition.py @@ -534,6 +534,55 @@ def testSpinglass(self): ok = True break self.assertTrue(ok) + + def testVoronoi(self): + # Test 1: Two disconnected cliques - should find exactly 2 communities + g = Graph.Full(5) + Graph.Full(5) # Two separate complete graphs + cl = g.community_voronoi() + + # Should find exactly 2 communities + self.assertEqual(len(cl), 2) + + # Vertices 0-4 should be in one community, vertices 5-9 in another + communities = [set(), set()] + for vertex, community in enumerate(cl.membership): + communities[community].add(vertex) + + # One community should have vertices 0-4, the other should have 5-9 + expected_communities = [{0, 1, 2, 3, 4}, {5, 6, 7, 8, 9}] + self.assertEqual( + set(frozenset(c) for c in communities), + set(frozenset(c) for c in expected_communities) + ) + + # Test 2: Two cliques connected by a single bridge edge + g = Graph.Full(4) + Graph.Full(4) + g.add_edges([(0, 4)]) # Bridge connecting the two cliques + + cl = g.community_voronoi() + + # Should still find 2 communities (bridge is weak) + self.assertEqual(len(cl), 2) + + # Check that vertices within each clique are in the same community + # Vertices 0,1,2,3 should be together, and 4,5,6,7 should be together + comm_0123 = {cl.membership[i] for i in [0, 1, 2, 3]} + comm_4567 = {cl.membership[i] for i in [4, 5, 6, 7]} + + self.assertEqual(len(comm_0123), 1) # All in same community + self.assertEqual(len(comm_4567), 1) # All in same community + self.assertNotEqual(comm_0123, comm_4567) # Different communities + + # Test 3: Three disconnected triangles + g = Graph(9) + g.add_edges([(0, 1), (1, 2), (2, 0), # Triangle 1 + (3, 4), (4, 5), (5, 3), # Triangle 2 + (6, 7), (7, 8), (8, 6)]) # Triangle 3 + + cl = g.community_voronoi() + + # Should find exactly 3 communities + self.assertEqual(len(cl), 3) def testWalktrap(self): g = Graph.Full(5) + Graph.Full(5) + Graph.Full(5)