Skip to content

feat: expose voronoi to python #833

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 10 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 171 additions & 0 deletions src/_igraph/graphobject.c
Original file line number Diff line number Diff line change
Expand Up @@ -13748,6 +13748,137 @@ PyObject *igraphmodule_Graph_community_leiden(igraphmodule_GraphObject *self,
return error ? NULL : Py_BuildValue("Nd", res, (double) quality);
}

/**
* Voronoi clustering
*/
PyObject *igraphmodule_Graph_community_voronoi(igraphmodule_GraphObject *self,
PyObject *args, PyObject *kwds) {
static char *kwlist[] = {"modularity", "lengths", "weights", "mode", "radius", NULL};
PyObject *lengths_o = Py_None, *weights_o = Py_None;
PyObject *mode_o = Py_None;
PyObject *radius_o = Py_None;
PyObject *modularity_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_ALL;
igraph_real_t radius = -1.0; /* negative means auto-optimize */
igraph_real_t modularity = IGRAPH_NAN;
PyObject *membership_o, *generators_o, *result_o;
igraph_bool_t return_modularity = false;

if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOOOO", kwlist,
&modularity_o, &lengths_o, &weights_o, &mode_o, &radius_o))
return NULL;

if (modularity_o != Py_None){
if (igraphmodule_PyObject_to_real_t(modularity_o, &modularity))
return NULL;;
}
else {
return_modularity = true;
}

/* Handle mode parameter */
if (igraphmodule_PyObject_to_neimode_t(mode_o, &mode))
return NULL;

/* Handle radius parameter */
if (radius_o != Py_None) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Useigraphmodule_PyObject_to_real_t here, this simplifies the entire logic that you have coded here. I think it does not handle Py_None so you need to handle that separately.

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,
return_modularity ? &modularity : NULL,
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 */
if (return_modularity) {
result_o = Py_BuildValue("(NNd)", membership_o, generators_o, modularity);
} else {
result_o = Py_BuildValue("(NN)", membership_o, generators_o);
}

return result_o;
}

/**********************************************************************
* Random walks *
**********************************************************************/
Expand Down Expand Up @@ -18598,6 +18729,46 @@ 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=\"all\", radius=None, modularity=None)\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"
"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"
"@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\"}, 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"
"@param modularity: if not C{None}, the modularity score will be calculated\n"
" and returned as part of the result tuple.\n"
"@return: a tuple containing the membership vector and generator vertices.\n"
" When modularity calculation is requested, also includes the modularity score\n"
" as a third element: (membership, generators, modularity).\n"
" Otherwise: (membership, generators).\n"
"@rtype: tuple\n\n"
"B{References}\n\n"
" - Deritei et al., Community detection by graph Voronoi diagrams,\n"
" New Journal of Physics 16, 063007 (2014)\n"
" 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"
" https://doi.org/10.1038/s41598-024-58624-4\n"
},
{"community_leiden",
(PyCFunction) igraphmodule_Graph_community_leiden,
METH_VARARGS | METH_KEYWORDS,
Expand Down
3 changes: 3 additions & 0 deletions src/igraph/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@
_community_optimal_modularity,
_community_edge_betweenness,
_community_spinglass,
_community_voronoi,
_community_walktrap,
_k_core,
_community_leiden,
Expand Down Expand Up @@ -659,6 +660,7 @@ def es(self):
community_optimal_modularity = _community_optimal_modularity
community_edge_betweenness = _community_edge_betweenness
community_spinglass = _community_spinglass
community_voronoi = _community_voronoi
community_walktrap = _community_walktrap
k_core = _k_core
community_leiden = _community_leiden
Expand Down Expand Up @@ -1101,6 +1103,7 @@ def write(graph, filename, *args, **kwds):
_community_optimal_modularity,
_community_edge_betweenness,
_community_spinglass,
_community_voronoi,
_community_walktrap,
_k_core,
_community_leiden,
Expand Down
63 changes: 63 additions & 0 deletions src/igraph/community.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,69 @@ def _community_spinglass(graph, *args, **kwds):
return VertexClustering(graph, membership, modularity_params=modularity_params)


def _community_voronoi(graph, modularity=None, lengths=None, weights=None, mode="all", 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
(L{igraph.Graph.ecc}). 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: if C{"out"}, distances from generator points to all other
nodes are considered. If C{"in"}, the reverse distances are used.
If C{"all"}, edge directions are ignored. 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 extra attributes
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")

if modularity is None:
membership, generators, modularity = GraphBase.community_voronoi(graph, modularity, lengths, weights, mode, radius)
else:
membership, generators = GraphBase.community_voronoi(graph, modularity, 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.
Expand Down
56 changes: 56 additions & 0 deletions tests/test_decomposition.py
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,62 @@ 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
g.add_edges([(3, 4), (4, 5), (5, 3)]) # Triangle 2
g.add_edges([(6, 7), (7, 8), (8, 6)]) # Triangle 3

cl = g.community_voronoi()

# Should find exactly 3 communities
self.assertEqual(len(cl), 3)

# Each triangle should be in its own community
triangles = [
{cl.membership[0], cl.membership[1], cl.membership[2]},
{cl.membership[3], cl.membership[4], cl.membership[5]},
{cl.membership[6], cl.membership[7], cl.membership[8]},
]

def testWalktrap(self):
g = Graph.Full(5) + Graph.Full(5) + Graph.Full(5)
Expand Down
Loading