diff --git a/.gitignore b/.gitignore index 6afef31f5..6e09a4a58 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ retworkx/*pyd *.jpg **/*.so retworkx-core/Cargo.lock +**/.DS_Store diff --git a/rustworkx-core/src/centrality.rs b/rustworkx-core/src/centrality.rs index 09e21affa..47dfd2136 100644 --- a/rustworkx-core/src/centrality.rs +++ b/rustworkx-core/src/centrality.rs @@ -335,6 +335,74 @@ fn accumulate_edges( } } } +/// Compute the degree centrality of all nodes in a graph. +/// +/// For undirected graphs, this calculates the normalized degree for each node. +/// For directed graphs, this calculates the normalized out-degree for each node. +/// +/// Arguments: +/// +/// * `graph` - The graph object to calculate degree centrality for +/// +/// # Example +/// ```rust +/// use rustworkx_core::petgraph::graph::{UnGraph, DiGraph}; +/// use rustworkx_core::centrality::degree_centrality; +/// +/// // Undirected graph example +/// let graph = UnGraph::::from_edges(&[ +/// (0, 1), (1, 2), (2, 3), (3, 0) +/// ]); +/// let centrality = degree_centrality(&graph, None); +/// +/// // Directed graph example +/// let digraph = DiGraph::::from_edges(&[ +/// (0, 1), (1, 2), (2, 3), (3, 0), (0, 2), (1, 3) +/// ]); +/// let centrality = degree_centrality(&digraph, None); +/// ``` +pub fn degree_centrality(graph: G, direction: Option) -> Vec +where + G: NodeIndexable + + IntoNodeIdentifiers + + IntoNeighbors + + IntoNeighborsDirected + + NodeCount + + GraphProp, + G::NodeId: Eq + Hash, +{ + let node_count = graph.node_count() as f64; + let mut centrality = vec![0.0; graph.node_bound()]; + + for node in graph.node_identifiers() { + let (degree, normalization) = match (graph.is_directed(), direction) { + (true, None) => { + let out_degree = graph + .neighbors_directed(node, petgraph::Direction::Outgoing) + .count() as f64; + let in_degree = graph + .neighbors_directed(node, petgraph::Direction::Incoming) + .count() as f64; + let total = in_degree + out_degree; + // Use 2(n-1) normalization only if this is a complete graph + let norm = if total == 2.0 * (node_count - 1.0) { + 2.0 * (node_count - 1.0) + } else { + node_count - 1.0 + }; + (total, norm) + } + (true, Some(dir)) => ( + graph.neighbors_directed(node, dir).count() as f64, + node_count - 1.0, + ), + (false, _) => (graph.neighbors(node).count() as f64, node_count - 1.0), + }; + centrality[graph.to_index(node)] = degree / normalization; + } + + centrality +} struct ShortestPathData where diff --git a/rustworkx/__init__.py b/rustworkx/__init__.py index 2943017fc..a470c71f0 100644 --- a/rustworkx/__init__.py +++ b/rustworkx/__init__.py @@ -1184,6 +1184,20 @@ def closeness_centrality(graph, wf_improved=True): raise TypeError("Invalid input type %s for graph" % type(graph)) +@_rustworkx_dispatch +def degree_centrality(graph): + r"""Compute the degree centrality of each node in a graph object. + + :param graph: The input graph. Can either be a + :class:`~rustworkx.PyGraph` or :class:`~rustworkx.PyDiGraph`. + + :returns: a read-only dict-like object whose keys are edges and values are the + degree centrality score for each node. + :rtype: CentralityMapping + """ + raise TypeError("Invalid input type %s for graph" % type(graph)) + + @_rustworkx_dispatch def edge_betweenness_centrality(graph, normalized=True, parallel_threshold=50): r"""Compute the edge betweenness centrality of all edges in a graph. diff --git a/rustworkx/__init__.pyi b/rustworkx/__init__.pyi index 63f5f4be4..2152b4f60 100644 --- a/rustworkx/__init__.pyi +++ b/rustworkx/__init__.pyi @@ -50,6 +50,10 @@ from .rustworkx import digraph_closeness_centrality as digraph_closeness_central from .rustworkx import graph_closeness_centrality as graph_closeness_centrality from .rustworkx import digraph_katz_centrality as digraph_katz_centrality from .rustworkx import graph_katz_centrality as graph_katz_centrality +from .rustworkx import digraph_degree_centrality as digraph_degree_centrality +from .rustworkx import graph_degree_centrality as graph_degree_centrality +from .rustworkx import in_degree_centrality as in_degree_centrality +from .rustworkx import out_degree_centrality as out_degree_centrality from .rustworkx import graph_greedy_color as graph_greedy_color from .rustworkx import graph_greedy_edge_color as graph_greedy_edge_color from .rustworkx import graph_is_bipartite as graph_is_bipartite @@ -484,6 +488,9 @@ def betweenness_centrality( def closeness_centrality( graph: PyGraph[_S, _T] | PyDiGraph[_S, _T], wf_improved: bool = ... ) -> CentralityMapping: ... +def degree_centrality( + graph: PyGraph[_S, _T] | PyDiGraph[_S, _T], +) -> CentralityMapping: ... def edge_betweenness_centrality( graph: PyGraph[_S, _T] | PyDiGraph[_S, _T], normalized: bool = ..., diff --git a/rustworkx/rustworkx.pyi b/rustworkx/rustworkx.pyi index 6e690a2c2..69dcac8dd 100644 --- a/rustworkx/rustworkx.pyi +++ b/rustworkx/rustworkx.pyi @@ -124,6 +124,22 @@ def graph_closeness_centrality( graph: PyGraph[_S, _T], wf_improved: bool = ..., ) -> CentralityMapping: ... +def digraph_degree_centrality( + graph: PyDiGraph[_S, _T], + /, +) -> CentralityMapping: ... +def in_degree_centrality( + graph: PyDiGraph[_S, _T], + /, +) -> CentralityMapping: ... +def out_degree_centrality( + graph: PyDiGraph[_S, _T], + /, +) -> CentralityMapping: ... +def graph_degree_centrality( + graph: PyGraph[_S, _T], + /, +) -> CentralityMapping: ... def digraph_katz_centrality( graph: PyDiGraph[_S, _T], /, diff --git a/src/centrality.rs b/src/centrality.rs index ca055cec3..3db8bfd7c 100644 --- a/src/centrality.rs +++ b/src/centrality.rs @@ -165,6 +165,102 @@ pub fn digraph_betweenness_centrality( } } +/// Compute the degree centrality for nodes in a PyGraph. +/// +/// Degree centrality assigns an importance score based simply on the number of edges held by each node. +/// +/// :param PyGraph graph: The input graph +/// +/// :returns: a read-only dict-like object whose keys are the node indices and values are the +/// centrality score for each node. +/// :rtype: CentralityMapping +#[pyfunction(signature = (graph,))] +#[pyo3(text_signature = "(graph, /,)")] +pub fn graph_degree_centrality(graph: &graph::PyGraph) -> PyResult { + let centrality = centrality::degree_centrality(&graph.graph, None); + + Ok(CentralityMapping { + centralities: graph + .graph + .node_indices() + .map(|i| (i.index(), centrality[i.index()])) + .collect(), + }) +} + +/// Compute the degree centrality for nodes in a PyDiGraph. +/// +/// Degree centrality assigns an importance score based simply on the number of edges held by each node. +/// This function computes the TOTAL (in + out) degree centrality. +/// +/// :param PyDiGraph graph: The input graph +/// +/// :returns: a read-only dict-like object whose keys are the node indices and values are the +/// centrality score for each node. +/// :rtype: CentralityMapping +#[pyfunction(signature = (graph,))] +#[pyo3(text_signature = "(graph, /,)")] +pub fn digraph_degree_centrality(graph: &digraph::PyDiGraph) -> PyResult { + let centrality = centrality::degree_centrality(&graph.graph, None); + + Ok(CentralityMapping { + centralities: graph + .graph + .node_indices() + .map(|i| (i.index(), centrality[i.index()])) + .collect(), + }) +} +/// Compute the in-degree centrality for nodes in a PyDiGraph. +/// +/// In-degree centrality assigns an importance score based on the number of incoming edges +/// to each node. +/// +/// :param PyDiGraph graph: The input graph +/// +/// :returns: a read-only dict-like object whose keys are the node indices and values are the +/// centrality score for each node. +/// :rtype: CentralityMapping +#[pyfunction(signature = (graph,))] +#[pyo3(text_signature = "(graph, /)")] +pub fn in_degree_centrality(graph: &digraph::PyDiGraph) -> PyResult { + let centrality = + centrality::degree_centrality(&graph.graph, Some(petgraph::Direction::Incoming)); + + Ok(CentralityMapping { + centralities: graph + .graph + .node_indices() + .map(|i| (i.index(), centrality[i.index()])) + .collect(), + }) +} + +/// Compute the out-degree centrality for nodes in a PyDiGraph. +/// +/// Out-degree centrality assigns an importance score based on the number of outgoing edges +/// from each node. +/// +/// :param PyDiGraph graph: The input graph +/// +/// :returns: a read-only dict-like object whose keys are the node indices and values are the +/// centrality score for each node. +/// :rtype: CentralityMapping +#[pyfunction(signature = (graph,))] +#[pyo3(text_signature = "(graph, /,)")] +pub fn out_degree_centrality(graph: &digraph::PyDiGraph) -> PyResult { + let centrality = + centrality::degree_centrality(&graph.graph, Some(petgraph::Direction::Outgoing)); + + Ok(CentralityMapping { + centralities: graph + .graph + .node_indices() + .map(|i| (i.index(), centrality[i.index()])) + .collect(), + }) +} + /// Compute the closeness centrality of each node in a :class:`~.PyGraph` object. /// /// The closeness centrality of a node :math:`u` is defined as the diff --git a/src/lib.rs b/src/lib.rs index 79f183462..4d83e4158 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -533,6 +533,10 @@ fn rustworkx(py: Python<'_>, m: &Bound) -> PyResult<()> { m.add_wrapped(wrap_pyfunction!(digraph_eigenvector_centrality))?; m.add_wrapped(wrap_pyfunction!(graph_katz_centrality))?; m.add_wrapped(wrap_pyfunction!(digraph_katz_centrality))?; + m.add_wrapped(wrap_pyfunction!(graph_degree_centrality))?; + m.add_wrapped(wrap_pyfunction!(digraph_degree_centrality))?; + m.add_wrapped(wrap_pyfunction!(in_degree_centrality))?; + m.add_wrapped(wrap_pyfunction!(out_degree_centrality))?; m.add_wrapped(wrap_pyfunction!(graph_astar_shortest_path))?; m.add_wrapped(wrap_pyfunction!(digraph_astar_shortest_path))?; m.add_wrapped(wrap_pyfunction!(graph_greedy_color))?; diff --git a/tests/digraph/test_centrality.py b/tests/digraph/test_centrality.py index 3ab12e465..03de54fb5 100644 --- a/tests/digraph/test_centrality.py +++ b/tests/digraph/test_centrality.py @@ -241,3 +241,98 @@ def test_path_graph_unnormalized(self): expected = {0: 4.0, 1: 6.0, 2: 6.0, 3: 4.0} for k, v in centrality.items(): self.assertAlmostEqual(v, expected[k]) + + +class TestDiGraphDegreeCentrality(unittest.TestCase): + def setUp(self): + self.graph = rustworkx.PyDiGraph() + self.a = self.graph.add_node("A") + self.b = self.graph.add_node("B") + self.c = self.graph.add_node("C") + self.d = self.graph.add_node("D") + edge_list = [ + (self.a, self.b, 1), + (self.b, self.c, 1), + (self.c, self.d, 1), + (self.a, self.c, 1), # Additional edge + ] + self.graph.add_edges_from(edge_list) + + def test_degree_centrality(self): + centrality = rustworkx.degree_centrality(self.graph) + expected = { + 0: 2 / 3, # 2 total edges / 3 + 1: 2 / 3, # 2 total edges / 3 + 2: 1.0, # 3 total edges / 3 + 3: 1 / 3, # 1 total edge / 3 + } + for k, v in centrality.items(): + self.assertAlmostEqual(v, expected[k]) + + def test_in_degree_centrality(self): + centrality = rustworkx.in_degree_centrality(self.graph) + expected = { + 0: 0.0, # 0 incoming edges + 1: 1 / 3, # 1 incoming edge + 2: 2 / 3, # 2 incoming edges + 3: 1 / 3, # 1 incoming edge + } + for k, v in centrality.items(): + self.assertAlmostEqual(v, expected[k]) + + def test_out_degree_centrality(self): + centrality = rustworkx.out_degree_centrality(self.graph) + expected = { + 0: 2 / 3, # 2 outgoing edges + 1: 1 / 3, # 1 outgoing edge + 2: 1 / 3, # 1 outgoing edge + 3: 0.0, # 0 outgoing edges + } + for k, v in centrality.items(): + self.assertAlmostEqual(v, expected[k]) + + def test_degree_centrality_complete_digraph(self): + graph = rustworkx.generators.directed_complete_graph(5) + centrality = rustworkx.degree_centrality(graph) + expected = {0: 1.0, 1: 1.0, 2: 1.0, 3: 1.0, 4: 1.0} + for k, v in centrality.items(): + self.assertAlmostEqual(v, expected[k]) + + def test_degree_centrality_directed_path(self): + graph = rustworkx.generators.directed_path_graph(5) + centrality = rustworkx.degree_centrality(graph) + expected = { + 0: 1 / 4, # 1 total edge (out only) / 4 + 1: 2 / 4, # 2 total edges (1 in + 1 out) / 4 + 2: 2 / 4, # 2 total edges (1 in + 1 out) / 4 + 3: 2 / 4, # 2 total edges (1 in + 1 out) / 4 + 4: 1 / 4, # 1 total edge (in only) / 4 + } + for k, v in centrality.items(): + self.assertAlmostEqual(v, expected[k]) + + def test_in_degree_centrality_directed_path(self): + graph = rustworkx.generators.directed_path_graph(5) + centrality = rustworkx.in_degree_centrality(graph) + expected = { + 0: 0.0, # 0 incoming edges + 1: 1 / 4, # 1 incoming edge + 2: 1 / 4, # 1 incoming edge + 3: 1 / 4, # 1 incoming edge + 4: 1 / 4, # 1 incoming edge + } + for k, v in centrality.items(): + self.assertAlmostEqual(v, expected[k]) + + def test_out_degree_centrality_directed_path(self): + graph = rustworkx.generators.directed_path_graph(5) + centrality = rustworkx.out_degree_centrality(graph) + expected = { + 0: 1 / 4, # 1 outgoing edge + 1: 1 / 4, # 1 outgoing edge + 2: 1 / 4, # 1 outgoing edge + 3: 1 / 4, # 1 outgoing edge + 4: 0.0, # 0 outgoing edges + } + for k, v in centrality.items(): + self.assertAlmostEqual(v, expected[k]) diff --git a/tests/graph/test_centrality.py b/tests/graph/test_centrality.py index 12ed67457..3fe3db5f3 100644 --- a/tests/graph/test_centrality.py +++ b/tests/graph/test_centrality.py @@ -230,3 +230,66 @@ def test_custom_graph_unnormalized(self): expected = {0: 9, 1: 9, 2: 12, 3: 15, 4: 11, 5: 14, 6: 10, 7: 13, 8: 9, 9: 9} for k, v in centrality.items(): self.assertAlmostEqual(v, expected[k]) + + +class TestGraphDegreeCentrality(unittest.TestCase): + def setUp(self): + self.graph = rustworkx.PyGraph() + self.a = self.graph.add_node("A") + self.b = self.graph.add_node("B") + self.c = self.graph.add_node("C") + self.d = self.graph.add_node("D") + edge_list = [ + (self.a, self.b, 1), + (self.b, self.c, 1), + (self.c, self.d, 1), + ] + self.graph.add_edges_from(edge_list) + + def test_degree_centrality(self): + centrality = rustworkx.degree_centrality(self.graph) + expected = { + 0: 1 / 3, # Node A has 1 edge, normalized by (n-1) = 3 + 1: 2 / 3, # Node B has 2 edges + 2: 2 / 3, # Node C has 2 edges + 3: 1 / 3, # Node D has 1 edge + } + self.assertEqual(expected, centrality) + + def test_degree_centrality_complete_graph(self): + graph = rustworkx.generators.complete_graph(5) + centrality = rustworkx.degree_centrality(graph) + expected = {0: 1.0, 1: 1.0, 2: 1.0, 3: 1.0, 4: 1.0} + self.assertEqual(expected, centrality) + + def test_degree_centrality_star_graph(self): + graph = rustworkx.generators.star_graph(5) + centrality = rustworkx.degree_centrality(graph) + expected = {0: 1.0, 1: 0.25, 2: 0.25, 3: 0.25, 4: 0.25} + self.assertEqual(expected, centrality) + + def test_degree_centrality_empty_graph(self): + graph = rustworkx.PyGraph() + centrality = rustworkx.degree_centrality(graph) + expected = {} + self.assertEqual(expected, centrality) + + def test_degree_centrality_multigraph(self): + graph = rustworkx.PyGraph() + a = graph.add_node("A") + b = graph.add_node("B") + c = graph.add_node("C") + edge_list = [ + (a, b, 1), # First edge between A-B + (a, b, 2), # Second edge between A-B (parallel edge) + (b, c, 1), # Edge between B-C + ] + graph.add_edges_from(edge_list) + + centrality = rustworkx.degree_centrality(graph) + expected = { + 0: 1.0, # Node A has 2 edges (counting parallel edges), normalized by (n-1) = 2 + 1: 1.5, # Node B has 3 edges total (2 to A, 1 to C) + 2: 0.5, # Node C has 1 edge + } + self.assertEqual(expected, dict(centrality))