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

Open
wants to merge 17 commits into
base: develop
Choose a base branch
from
Open
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
155 changes: 155 additions & 0 deletions src/_igraph/graphobject.c
Original file line number Diff line number Diff line change
Expand Up @@ -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 *
**********************************************************************/
Expand Down Expand Up @@ -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"
"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\"} (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\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 @@ -111,6 +111,7 @@
_community_edge_betweenness,
_community_fluid_communities,
_community_spinglass,
_community_voronoi,
_community_walktrap,
_k_core,
_community_leiden,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
63 changes: 63 additions & 0 deletions src/igraph/community.py
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,69 @@ 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
(L{igraph.Graph.ecc}). The generator vertices are chosen to be those with the
Copy link
Member

Choose a reason for hiding this comment

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

ecc() is not currently exposed. Please check if the links that were added are valid. You can build docs from the terminal using scripts/mkdoc.sh -c. There will be warnings if a link is invalid.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

hi, I can help, sure, however until now I wasn't able to find the issue

Copy link
Member

Choose a reason for hiding this comment

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

Let's just remove the link for now. We can do cross-linking once the ECC function is exposed.

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.
Comment on lines +333 to +340
Copy link
Member

Choose a reason for hiding this comment

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

Please use M{} instead of C{} for math, and use LaTeX syntax with standard mathematical notation (meaning no * for multiplication).

Please also wrap the formulas in M{} in the GraphBase version's documentation.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Should I use \cdot or something else? Since it looks strange in the built documentation

Copy link
Member

Choose a reason for hiding this comment

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

Alright, let's keep it as-is for now. Can you please add the C{} to the GraphBase version and mark this as resolved?


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 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")

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.
Expand Down
49 changes: 49 additions & 0 deletions tests/test_decomposition.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down