Skip to content

Commit 4119c94

Browse files
BeaMarton13szhorvatMBea13
authored
feat: expose voronoi to python (#833)
Co-authored-by: Szabolcs Horvát <szhorvat@gmail.com> Co-authored-by: MIBea13 <mildikokak@gmail.com>
1 parent 20d628a commit 4119c94

File tree

4 files changed

+269
-0
lines changed

4 files changed

+269
-0
lines changed

src/_igraph/graphobject.c

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13775,6 +13775,125 @@ PyObject *igraphmodule_Graph_community_fluid_communities(igraphmodule_GraphObjec
1377513775
return result;
1377613776
}
1377713777

13778+
/**
13779+
* Voronoi clustering
13780+
*/
13781+
PyObject *igraphmodule_Graph_community_voronoi(igraphmodule_GraphObject *self,
13782+
PyObject *args, PyObject *kwds) {
13783+
static char *kwlist[] = {"lengths", "weights", "mode", "radius", NULL};
13784+
PyObject *lengths_o = Py_None, *weights_o = Py_None;
13785+
PyObject *mode_o = Py_None;
13786+
PyObject *radius_o = Py_None;
13787+
igraph_vector_t *lengths_v = NULL;
13788+
igraph_vector_t *weights_v = NULL;
13789+
igraph_vector_int_t membership_v, generators_v;
13790+
igraph_neimode_t mode = IGRAPH_OUT;
13791+
igraph_real_t radius = -1.0; /* negative means auto-optimize */
13792+
igraph_real_t modularity = IGRAPH_NAN;
13793+
PyObject *membership_o, *generators_o, *result_o;
13794+
13795+
if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOOO", kwlist,
13796+
&lengths_o, &weights_o, &mode_o, &radius_o)) {
13797+
return NULL;
13798+
}
13799+
13800+
/* Handle mode parameter */
13801+
if (igraphmodule_PyObject_to_neimode_t(mode_o, &mode)) {
13802+
return NULL;
13803+
}
13804+
13805+
/* Handle radius parameter */
13806+
if (radius_o != Py_None) {
13807+
if (igraphmodule_PyObject_to_real_t(radius_o, &radius)) {
13808+
return NULL;
13809+
}
13810+
}
13811+
13812+
/* Handle lengths parameter */
13813+
if (igraphmodule_attrib_to_vector_t(lengths_o, self, &lengths_v, ATTRIBUTE_TYPE_EDGE)) {
13814+
return NULL;
13815+
}
13816+
13817+
/* Handle weights parameter */
13818+
if (igraphmodule_attrib_to_vector_t(weights_o, self, &weights_v, ATTRIBUTE_TYPE_EDGE)) {
13819+
if (lengths_v != NULL) {
13820+
igraph_vector_destroy(lengths_v); free(lengths_v);
13821+
}
13822+
return NULL;
13823+
}
13824+
13825+
/* Initialize result vectors */
13826+
if (igraph_vector_int_init(&membership_v, 0)) {
13827+
if (lengths_v != NULL) {
13828+
igraph_vector_destroy(lengths_v); free(lengths_v);
13829+
}
13830+
if (weights_v != NULL) {
13831+
igraph_vector_destroy(weights_v); free(weights_v);
13832+
}
13833+
igraphmodule_handle_igraph_error();
13834+
return NULL;
13835+
}
13836+
13837+
if (igraph_vector_int_init(&generators_v, 0)) {
13838+
if (lengths_v != NULL) {
13839+
igraph_vector_destroy(lengths_v); free(lengths_v);
13840+
}
13841+
if (weights_v != NULL) {
13842+
igraph_vector_destroy(weights_v); free(weights_v);
13843+
}
13844+
igraph_vector_int_destroy(&membership_v);
13845+
igraphmodule_handle_igraph_error();
13846+
return NULL;
13847+
}
13848+
13849+
/* Call the C function - pass NULL for None parameters */
13850+
if (igraph_community_voronoi(&self->g, &membership_v, &generators_v,
13851+
&modularity,
13852+
lengths_v,
13853+
weights_v,
13854+
mode, radius)) {
13855+
13856+
if (lengths_v != NULL) {
13857+
igraph_vector_destroy(lengths_v); free(lengths_v);
13858+
}
13859+
if (weights_v != NULL) {
13860+
igraph_vector_destroy(weights_v); free(weights_v);
13861+
}
13862+
igraph_vector_int_destroy(&membership_v);
13863+
igraph_vector_int_destroy(&generators_v);
13864+
igraphmodule_handle_igraph_error();
13865+
return NULL;
13866+
}
13867+
13868+
/* Clean up input vectors */
13869+
if (lengths_v != NULL) {
13870+
igraph_vector_destroy(lengths_v); free(lengths_v);
13871+
}
13872+
if (weights_v != NULL) {
13873+
igraph_vector_destroy(weights_v); free(weights_v);
13874+
}
13875+
13876+
/* Convert results to Python objects */
13877+
membership_o = igraphmodule_vector_int_t_to_PyList(&membership_v);
13878+
igraph_vector_int_destroy(&membership_v);
13879+
if (!membership_o) {
13880+
igraph_vector_int_destroy(&generators_v);
13881+
return NULL;
13882+
}
13883+
13884+
generators_o = igraphmodule_vector_int_t_to_PyList(&generators_v);
13885+
igraph_vector_int_destroy(&generators_v);
13886+
if (!generators_o) {
13887+
Py_DECREF(membership_o);
13888+
return NULL;
13889+
}
13890+
13891+
/* Return tuple with membership, generators, and modularity */
13892+
result_o = Py_BuildValue("(NNd)", membership_o, generators_o, modularity);
13893+
13894+
return result_o;
13895+
}
13896+
1377813897
/**********************************************************************
1377913898
* Random walks *
1378013899
**********************************************************************/
@@ -18653,6 +18772,42 @@ struct PyMethodDef igraphmodule_Graph_methods[] = {
1865318772
" original implementation is used.\n"
1865418773
"@return: the community membership vector.\n"
1865518774
},
18775+
{"community_voronoi",
18776+
(PyCFunction) igraphmodule_Graph_community_voronoi,
18777+
METH_VARARGS | METH_KEYWORDS,
18778+
"community_voronoi(lengths=None, weights=None, mode=\"out\", radius=None)\n--\n\n"
18779+
"Finds communities using Voronoi partitioning.\n\n"
18780+
"This function finds communities using a Voronoi partitioning of vertices based\n"
18781+
"on the given edge lengths divided by the edge clustering coefficient.\n"
18782+
"The generator vertices are chosen to be those with the largest local relative\n"
18783+
"density within a radius, with the local relative density of a vertex defined as\n"
18784+
"C{s * m / (m + k)}, where s is the strength of the vertex, m is the number of\n"
18785+
"edges within the vertex's first order neighborhood, while k is the number of\n"
18786+
"edges with only one endpoint within this neighborhood.\n\n"
18787+
"B{References}\n\n"
18788+
" - Deritei et al., Community detection by graph Voronoi diagrams,\n"
18789+
" New Journal of Physics 16, 063007 (2014)\n"
18790+
" U{https://doi.org/10.1088/1367-2630/16/6/063007}\n"
18791+
" - Molnár et al., Community Detection in Directed Weighted Networks\n"
18792+
" using Voronoi Partitioning, Scientific Reports 14, 8124 (2024)\n"
18793+
" U{https://doi.org/10.1038/s41598-024-58624-4}\n\n"
18794+
"@param lengths: edge lengths, or C{None} to consider all edges as having\n"
18795+
" unit length. Voronoi partitioning will use edge lengths equal to\n"
18796+
" lengths / ECC where ECC is the edge clustering coefficient.\n"
18797+
"@param weights: edge weights, or C{None} to consider all edges as having\n"
18798+
" unit weight. Weights are used when selecting generator points, as well\n"
18799+
" as for computing modularity.\n"
18800+
"@param mode: if C{\"out\"} (the default), distances from generator points to all other\n"
18801+
" nodes are considered. If C{\"in\"}, the reverse distances are used.\n"
18802+
" If C{\"all\"}, edge directions are ignored. This parameter is ignored\n"
18803+
" for undirected graphs.\n"
18804+
"@param radius: the radius/resolution to use when selecting generator points.\n"
18805+
" The larger this value, the fewer partitions there will be. Pass C{None}\n"
18806+
" to automatically select the radius that maximizes modularity.\n"
18807+
"@return: a tuple containing the membership vector, generator vertices, and\n"
18808+
" modularity score: (membership, generators, modularity).\n"
18809+
"@rtype: tuple\n"
18810+
},
1865618811
{"community_leiden",
1865718812
(PyCFunction) igraphmodule_Graph_community_leiden,
1865818813
METH_VARARGS | METH_KEYWORDS,

src/igraph/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@
111111
_community_edge_betweenness,
112112
_community_fluid_communities,
113113
_community_spinglass,
114+
_community_voronoi,
114115
_community_walktrap,
115116
_k_core,
116117
_community_leiden,
@@ -661,6 +662,7 @@ def es(self):
661662
community_edge_betweenness = _community_edge_betweenness
662663
community_fluid_communities = _community_fluid_communities
663664
community_spinglass = _community_spinglass
665+
community_voronoi = _community_voronoi
664666
community_walktrap = _community_walktrap
665667
k_core = _k_core
666668
community_leiden = _community_leiden
@@ -1104,6 +1106,7 @@ def write(graph, filename, *args, **kwds):
11041106
_community_edge_betweenness,
11051107
_community_fluid_communities,
11061108
_community_spinglass,
1109+
_community_voronoi,
11071110
_community_walktrap,
11081111
_k_core,
11091112
_community_leiden,

src/igraph/community.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,68 @@ def _community_spinglass(graph, *args, **kwds):
327327
return VertexClustering(graph, membership, modularity_params=modularity_params)
328328

329329

330+
def _community_voronoi(graph, lengths=None, weights=None, mode="out", radius=None):
331+
"""Finds communities using Voronoi partitioning.
332+
333+
This function finds communities using a Voronoi partitioning of vertices based
334+
on the given edge lengths divided by the edge clustering coefficient.
335+
The generator vertices are chosen to be those with the largest local relative
336+
density within a radius, with the local relative density of a vertex defined
337+
as C{s * m / (m + k)}, where C{s} is the strength of the vertex, C{m} is
338+
the number of edges within the vertex's first order neighborhood, while C{k}
339+
is the number of edges with only one endpoint within this neighborhood.
340+
341+
B{References}
342+
343+
- Deritei et al., Community detection by graph Voronoi diagrams,
344+
I{New Journal of Physics} 16, 063007 (2014).
345+
U{https://doi.org/10.1088/1367-2630/16/6/063007}.
346+
- Molnár et al., Community Detection in Directed Weighted Networks using
347+
Voronoi Partitioning, I{Scientific Reports} 14, 8124 (2024).
348+
U{https://doi.org/10.1038/s41598-024-58624-4}.
349+
350+
@param lengths: edge lengths, or C{None} to consider all edges as having
351+
unit length. Voronoi partitioning will use edge lengths equal to
352+
lengths / ECC where ECC is the edge clustering coefficient.
353+
@param weights: edge weights, or C{None} to consider all edges as having
354+
unit weight. Weights are used when selecting generator points, as well
355+
as for computing modularity.
356+
@param mode: specifies how to use the direction of edges when computing
357+
distances from generator points. If C{"out"} (the default), distances
358+
from generator points to all other nodes are considered following the
359+
direction of edges. If C{"in"}, distances are computed in the reverse
360+
direction (i.e., from all nodes to generator points). If C{"all"},
361+
edge directions are ignored and the graph is treated as undirected.
362+
This parameter is ignored for undirected graphs.
363+
@param radius: the radius/resolution to use when selecting generator points.
364+
The larger this value, the fewer partitions there will be. Pass C{None}
365+
to automatically select the radius that maximizes modularity.
366+
@return: an appropriate L{VertexClustering} object with an extra attribute
367+
called C{generators} (the generator vertices).
368+
"""
369+
# Convert mode string to proper enum value to avoid deprecation warning
370+
if isinstance(mode, str):
371+
mode_map = {"out": "out", "in": "in", "all": "all", "total": "all"} # alias
372+
if mode.lower() in mode_map:
373+
mode = mode_map[mode.lower()]
374+
else:
375+
raise ValueError(f"Invalid mode '{mode}'. Must be one of: out, in, all")
376+
377+
membership, generators, modularity = GraphBase.community_voronoi(graph, lengths, weights, mode, radius)
378+
379+
params = {"generators": generators}
380+
modularity_params = {}
381+
if weights is not None:
382+
modularity_params["weights"] = weights
383+
384+
clustering = VertexClustering(
385+
graph, membership, modularity=modularity, params=params, modularity_params=modularity_params
386+
)
387+
388+
clustering.generators = generators
389+
return clustering
390+
391+
330392
def _community_walktrap(graph, weights=None, steps=4):
331393
"""Community detection algorithm of Latapy & Pons, based on random
332394
walks.

tests/test_decomposition.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,6 +534,55 @@ def testSpinglass(self):
534534
ok = True
535535
break
536536
self.assertTrue(ok)
537+
538+
def testVoronoi(self):
539+
# Test 1: Two disconnected cliques - should find exactly 2 communities
540+
g = Graph.Full(5) + Graph.Full(5) # Two separate complete graphs
541+
cl = g.community_voronoi()
542+
543+
# Should find exactly 2 communities
544+
self.assertEqual(len(cl), 2)
545+
546+
# Vertices 0-4 should be in one community, vertices 5-9 in another
547+
communities = [set(), set()]
548+
for vertex, community in enumerate(cl.membership):
549+
communities[community].add(vertex)
550+
551+
# One community should have vertices 0-4, the other should have 5-9
552+
expected_communities = [{0, 1, 2, 3, 4}, {5, 6, 7, 8, 9}]
553+
self.assertEqual(
554+
set(frozenset(c) for c in communities),
555+
set(frozenset(c) for c in expected_communities)
556+
)
557+
558+
# Test 2: Two cliques connected by a single bridge edge
559+
g = Graph.Full(4) + Graph.Full(4)
560+
g.add_edges([(0, 4)]) # Bridge connecting the two cliques
561+
562+
cl = g.community_voronoi()
563+
564+
# Should still find 2 communities (bridge is weak)
565+
self.assertEqual(len(cl), 2)
566+
567+
# Check that vertices within each clique are in the same community
568+
# Vertices 0,1,2,3 should be together, and 4,5,6,7 should be together
569+
comm_0123 = {cl.membership[i] for i in [0, 1, 2, 3]}
570+
comm_4567 = {cl.membership[i] for i in [4, 5, 6, 7]}
571+
572+
self.assertEqual(len(comm_0123), 1) # All in same community
573+
self.assertEqual(len(comm_4567), 1) # All in same community
574+
self.assertNotEqual(comm_0123, comm_4567) # Different communities
575+
576+
# Test 3: Three disconnected triangles
577+
g = Graph(9)
578+
g.add_edges([(0, 1), (1, 2), (2, 0), # Triangle 1
579+
(3, 4), (4, 5), (5, 3), # Triangle 2
580+
(6, 7), (7, 8), (8, 6)]) # Triangle 3
581+
582+
cl = g.community_voronoi()
583+
584+
# Should find exactly 3 communities
585+
self.assertEqual(len(cl), 3)
537586

538587
def testWalktrap(self):
539588
g = Graph.Full(5) + Graph.Full(5) + Graph.Full(5)

0 commit comments

Comments
 (0)