From 16ffcd52135817f07784e0b3c58f120f8a1bdfb1 Mon Sep 17 00:00:00 2001 From: Simon Lizotte Date: Sat, 18 May 2024 10:36:00 -0400 Subject: [PATCH 1/9] add hyperbolic random graph model generator --- .../api/random_graph_generator_functions.rst | 1 + ...erbolic-random-graph-d85c115930d8ac08.yaml | 11 + rustworkx-core/src/generators/mod.rs | 1 + rustworkx-core/src/generators/random_graph.rs | 278 +++++++++++++++++- rustworkx/__init__.pyi | 1 + rustworkx/rustworkx.pyi | 8 + src/lib.rs | 1 + src/random_graph.rs | 58 ++++ tests/test_random.py | 46 +++ 9 files changed, 403 insertions(+), 2 deletions(-) create mode 100644 releasenotes/notes/hyperbolic-random-graph-d85c115930d8ac08.yaml diff --git a/docs/source/api/random_graph_generator_functions.rst b/docs/source/api/random_graph_generator_functions.rst index ba6823cfda..73710ceb5f 100644 --- a/docs/source/api/random_graph_generator_functions.rst +++ b/docs/source/api/random_graph_generator_functions.rst @@ -11,6 +11,7 @@ Random Graph Generator Functions rustworkx.directed_gnm_random_graph rustworkx.undirected_gnm_random_graph rustworkx.random_geometric_graph + rustworkx.undirected_hyperbolic_random_graph rustworkx.barabasi_albert_graph rustworkx.directed_barabasi_albert_graph rustworkx.directed_random_bipartite_graph diff --git a/releasenotes/notes/hyperbolic-random-graph-d85c115930d8ac08.yaml b/releasenotes/notes/hyperbolic-random-graph-d85c115930d8ac08.yaml new file mode 100644 index 0000000000..cb41698cee --- /dev/null +++ b/releasenotes/notes/hyperbolic-random-graph-d85c115930d8ac08.yaml @@ -0,0 +1,11 @@ +features: + - | + Adds new random graph generator function, :func:`.undirected_hyperbolic_random_graph` + to generate a random graph using the H² random graph model. + - | + Adds new function to the rustworkx-core module ``rustworkx_core::generators`` + ``hyperbolic_random_graph()`` that generates a H^2 hyperbolic random graph. + +issues: + - | + Related to issue `#150 `. diff --git a/rustworkx-core/src/generators/mod.rs b/rustworkx-core/src/generators/mod.rs index d0a2c8703c..04d672749f 100644 --- a/rustworkx-core/src/generators/mod.rs +++ b/rustworkx-core/src/generators/mod.rs @@ -59,6 +59,7 @@ pub use petersen_graph::petersen_graph; pub use random_graph::barabasi_albert_graph; pub use random_graph::gnm_random_graph; pub use random_graph::gnp_random_graph; +pub use random_graph::hyperbolic_random_graph; pub use random_graph::random_bipartite_graph; pub use random_graph::random_geometric_graph; pub use star_graph::star_graph; diff --git a/rustworkx-core/src/generators/random_graph.rs b/rustworkx-core/src/generators/random_graph.rs index b237d052d8..dfb4994c63 100644 --- a/rustworkx-core/src/generators/random_graph.rs +++ b/rustworkx-core/src/generators/random_graph.rs @@ -12,6 +12,7 @@ #![allow(clippy::float_cmp)] +use std::f64::consts::PI; use std::hash::Hash; use petgraph::data::{Build, Create}; @@ -619,14 +620,141 @@ where Ok(graph) } +/// Generate a hyperbolic random undirected graph. +/// +/// The H² hyperbolic random graph model connects pairs of nodes with a probability +/// that decreases as their hyperbolic distance increases. +/// +/// The number of nodes is inferred from the coordinates `radii` and `angles`. `radii` +/// and `angles` must have the same size and cannot be empty. If `beta` is `None`, +/// all pairs of nodes with a distance smaller than ``r`` are connected. +/// +/// D. Krioukov et al. "Hyperbolic geometry of complex networks", Phys. Rev. E 82, pp 036106, 2010. +/// +/// Arguments: +/// +/// * `radii` - radial coordinates (nonnegative) of the nodes. +/// * `angles` - angular coordinates (between -pi and pi) of the nodes. +/// * `beta` - Sigmoid sharpness (nonnegative) of the connection probability. +/// * `r` - Distance at which the connection probability is 0.5 for the probabilistic model. +/// Threshold when ``beta`` is ``None``. +/// * `seed` - An optional seed to use for the random number generator. +/// * `default_node_weight` - A callable that will return the weight to use +/// for newly created nodes. +/// * `default_edge_weight` - A callable that will return the weight object +/// to use for newly created edges. +/// +/// # Example +/// ```rust +/// use rustworkx_core::petgraph; +/// use rustworkx_core::generators::hyperbolic_random_graph; +/// +/// let g: petgraph::graph::UnGraph<(), ()> = hyperbolic_random_graph( +/// vec![0.4, 2., 3.], +/// vec![0., 0., 0.], +/// None, +/// 2., +/// None, +/// || {()}, +/// || {()}, +/// ).unwrap(); +/// assert_eq!(g.node_count(), 3); +/// assert_eq!(g.edge_count(), 2); +/// ``` +pub fn hyperbolic_random_graph( + radii: Vec, + angles: Vec, + beta: Option, + r: f64, + seed: Option, + mut default_node_weight: F, + mut default_edge_weight: H, +) -> Result +where + G: Build + Create + Data + NodeIndexable + GraphProp, + F: FnMut() -> T, + H: FnMut() -> M, + G::NodeId: Eq + Hash, +{ + let num_nodes = radii.len(); + if num_nodes == 0 || radii.len() != angles.len() { + return Err(InvalidInputError {}); + } + if radii.iter().any(|x| x.is_infinite() || x.is_nan()) { + return Err(InvalidInputError {}); + } + if angles.iter().any(|x| x.is_infinite() || x.is_nan()) { + return Err(InvalidInputError {}); + } + if radii.iter().fold(0_f64, |a, &b| a.min(b)) < 0. { + return Err(InvalidInputError {}); + } + if angles.iter().map(|x| x.abs()).fold(0_f64, |a, b| a.max(b)) > PI { + return Err(InvalidInputError {}); + } + if beta.is_some_and(|b| b < 0. || b.is_nan()) { + return Err(InvalidInputError {}); + } + if r < 0. || r.is_nan() { + return Err(InvalidInputError {}); + } + + let mut rng: Pcg64 = match seed { + Some(seed) => Pcg64::seed_from_u64(seed), + None => Pcg64::from_entropy(), + }; + let mut graph = G::with_capacity(num_nodes, num_nodes); + if graph.is_directed() { + return Err(InvalidInputError {}); + } + + for _ in 0..num_nodes { + graph.add_node(default_node_weight()); + } + + let between = Uniform::new(0.0, 1.0); + for (v, (r1, theta1)) in radii + .iter() + .zip(angles.iter()) + .enumerate() + .take(num_nodes - 1) + { + for (w, (r2, theta2)) in radii.iter().zip(angles.iter()).enumerate().skip(v + 1) { + let dist = hyperbolic_distance(r1, theta1, r2, theta2); + let is_edge = match beta { + Some(b) => { + let prob = 1. / ((b / 2. * (dist - r)).exp() + 1.); + let u: f64 = between.sample(&mut rng); + u < prob + } + None => dist < r, + }; + if is_edge { + graph.add_edge( + graph.from_index(v), + graph.from_index(w), + default_edge_weight(), + ); + } + } + } + Ok(graph) +} + +#[inline] +fn hyperbolic_distance(r1: &f64, theta1: &f64, r2: &f64, theta2: &f64) -> f64 { + (r1.cosh() * r2.cosh() - r1.sinh() * r2.sinh() * (theta1 - theta2).cos()).acosh() +} + #[cfg(test)] mod tests { use crate::generators::InvalidInputError; use crate::generators::{ - barabasi_albert_graph, gnm_random_graph, gnp_random_graph, path_graph, - random_bipartite_graph, random_geometric_graph, + barabasi_albert_graph, gnm_random_graph, gnp_random_graph, hyperbolic_random_graph, + path_graph, random_bipartite_graph, random_geometric_graph, }; use crate::petgraph; + use std::f64::consts::PI; // Test gnp_random_graph @@ -916,4 +1044,150 @@ mod tests { Err(e) => assert_eq!(e, InvalidInputError), }; } + + // Test hyperbolic_random_graph + + #[test] + fn test_hyperbolic_random_graph_seeded() { + let g = hyperbolic_random_graph::, _, _, _, _>( + vec![3., 0.5, 0.5, 0.], + vec![0., PI, 0., 0.], + Some(10000.), + 0.75, + Some(10), + || (), + || (), + ) + .unwrap(); + assert_eq!(g.node_count(), 4); + assert_eq!(g.edge_count(), 2); + } + + #[test] + fn test_hyperbolic_random_graph_empty() { + let g = hyperbolic_random_graph::, _, _, _, _>( + vec![3., 0.5, 1.], + vec![0., PI, 0.], + None, + 1., + None, + || (), + || (), + ) + .unwrap(); + assert_eq!(g.node_count(), 3); + assert_eq!(g.edge_count(), 0); + } + + #[test] + fn test_hyperbolic_random_graph_bad_angle_error() { + match hyperbolic_random_graph::, _, _, _, _>( + vec![0., 0.], + vec![0., 3.142], + None, + 1., + None, + || (), + || (), + ) { + Ok(_) => panic!("Returned a non-error"), + Err(e) => assert_eq!(e, InvalidInputError), + } + } + + #[test] + fn test_hyperbolic_random_graph_neg_radii_error() { + match hyperbolic_random_graph::, _, _, _, _>( + vec![0., -1.], + vec![0., 0.], + None, + 1., + None, + || (), + || (), + ) { + Ok(_) => panic!("Returned a non-error"), + Err(e) => assert_eq!(e, InvalidInputError), + } + } + + #[test] + fn test_hyperbolic_random_graph_neg_r_error() { + match hyperbolic_random_graph::, _, _, _, _>( + vec![0.], + vec![0.], + None, + -1., + None, + || (), + || (), + ) { + Ok(_) => panic!("Returned a non-error"), + Err(e) => assert_eq!(e, InvalidInputError), + } + } + + #[test] + fn test_hyperbolic_random_graph_neg_beta_error() { + match hyperbolic_random_graph::, _, _, _, _>( + vec![0.], + vec![0.], + Some(-1.), + 1., + None, + || (), + || (), + ) { + Ok(_) => panic!("Returned a non-error"), + Err(e) => assert_eq!(e, InvalidInputError), + } + } + + #[test] + fn test_hyperbolic_random_graph_len_coord_error() { + match hyperbolic_random_graph::, _, _, _, _>( + vec![1.], + vec![1., 2.], + None, + 1., + None, + || (), + || (), + ) { + Ok(_) => panic!("Returned a non-error"), + Err(e) => assert_eq!(e, InvalidInputError), + } + } + + #[test] + fn test_hyperbolic_random_graph_empty_error() { + match hyperbolic_random_graph::, _, _, _, _>( + vec![], + vec![], + None, + 1., + None, + || (), + || (), + ) { + Ok(_) => panic!("Returned a non-error"), + Err(e) => assert_eq!(e, InvalidInputError), + } + } + + #[test] + fn test_hyperbolic_random_graph_directed_error() { + match hyperbolic_random_graph::, _, _, _, _>( + vec![0.], + vec![0.], + None, + 1., + None, + || (), + || (), + ) { + Ok(_) => panic!("Returned a non-error"), + Err(e) => assert_eq!(e, InvalidInputError), + } + } } diff --git a/rustworkx/__init__.pyi b/rustworkx/__init__.pyi index f5bcdeb71e..043f62ea9b 100644 --- a/rustworkx/__init__.pyi +++ b/rustworkx/__init__.pyi @@ -128,6 +128,7 @@ from .rustworkx import undirected_gnm_random_graph as undirected_gnm_random_grap from .rustworkx import directed_gnp_random_graph as directed_gnp_random_graph from .rustworkx import undirected_gnp_random_graph as undirected_gnp_random_graph from .rustworkx import random_geometric_graph as random_geometric_graph +from .rustworkx import undirected_hyperbolic_random_graph as undirected_hyperbolic_random_graph from .rustworkx import barabasi_albert_graph as barabasi_albert_graph from .rustworkx import directed_barabasi_albert_graph as directed_barabasi_albert_graph from .rustworkx import undirected_random_bipartite_graph as undirected_random_bipartite_graph diff --git a/rustworkx/rustworkx.pyi b/rustworkx/rustworkx.pyi index 2edcdde675..4dc8bb36ba 100644 --- a/rustworkx/rustworkx.pyi +++ b/rustworkx/rustworkx.pyi @@ -558,6 +558,14 @@ def random_geometric_graph( p: float = ..., seed: int | None = ..., ) -> PyGraph: ... +def undirected_hyperbolic_random_graph( + radii: list[float], + angles: list[float], + r: float, + beta: float | None, + /, + seed: int | None = ..., +) -> PyGraph: ... def barabasi_albert_graph( n: int, m: int, diff --git a/src/lib.rs b/src/lib.rs index 096f30072a..9a585f8432 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -477,6 +477,7 @@ fn rustworkx(py: Python<'_>, m: &Bound) -> PyResult<()> { m.add_wrapped(wrap_pyfunction!(directed_gnm_random_graph))?; m.add_wrapped(wrap_pyfunction!(undirected_gnm_random_graph))?; m.add_wrapped(wrap_pyfunction!(random_geometric_graph))?; + m.add_wrapped(wrap_pyfunction!(undirected_hyperbolic_random_graph))?; m.add_wrapped(wrap_pyfunction!(barabasi_albert_graph))?; m.add_wrapped(wrap_pyfunction!(directed_barabasi_albert_graph))?; m.add_wrapped(wrap_pyfunction!(directed_random_bipartite_graph))?; diff --git a/src/random_graph.rs b/src/random_graph.rs index 39520a23ee..634039a625 100644 --- a/src/random_graph.rs +++ b/src/random_graph.rs @@ -380,6 +380,64 @@ pub fn random_geometric_graph( Ok(graph) } +/// Return a :math:`\mathbb{H}^2` hyperbolic random undirected graph. +/// +/// The :math:`\mathbb{H}^2` hyperbolic random graph model connects pairs of nodes with probability +/// +/// .. math:: +/// +/// P[(u,v) \in E] = \frac{1}{1+\exp(\beta(d(u,v) - R)/2)}, +/// +/// a function that decreases with the hyperbolic distance between nodes :math:`u` and :math:`v` +/// +/// .. math:: +/// +/// d(u,v) = \text{arccosh}\left[\cosh(r_u) \cosh(r_v) - \sinh(r_u) \sinh(r_v)\cos(\theta_u-\theta_v)\right], +/// +/// where :math:`r_u` and :math:`\theta_u` are the hyperbolic polar coordinates of node :math:`u`. +/// +/// The number of nodes is inferred from the coordinates ``radii`` and ``angles``. ``radii`` and +/// ``angles`` must have the same size and cannot be empty. If ``beta`` is ``None``, all pairs of +/// nodes with a distance smaller than `r` are connected. +/// +/// D. Krioukov et al. "Hyperbolic geometry of complex networks", Phys. Rev. E 82, pp 036106, 2010. +/// +/// This algorithm has a time complexity of :math:`O(n^2)`. +/// +/// :param list[float] radii: radial coordinates (nonnegative) of the nodes. +/// :param list[float] angles: angular coordinates (in :math:`[-\pi, \pi]`) of the nodes. +/// :param float beta: Sigmoid sharpness (nonnegative) of the connection probability. +/// :param float r: Distance at which the connection probability is 0.5 for the probabilistic model. +/// Threshold when `beta` is `None`. +/// :param int seed: An optional seed to use for the random number generator. +/// +/// :return: A PyGraph object +/// :rtype: PyGraph +#[pyfunction] +#[pyo3(text_signature = "(radii, angles, r, beta, /, seed=None)")] +pub fn undirected_hyperbolic_random_graph( + py: Python, + radii: Vec, + angles: Vec, + r: f64, + beta: Option, + seed: Option, +) -> PyResult { + let default_fn = || py.None(); + let graph: StablePyGraph = match core_generators::hyperbolic_random_graph( + radii, angles, beta, r, seed, default_fn, default_fn, + ) { + Ok(graph) => graph, + Err(_) => return Err(PyValueError::new_err("invalid positions or parameters")), + }; + Ok(graph::PyGraph { + graph, + node_removed: false, + multigraph: false, + attrs: py.None(), + }) +} + /// Generate a random graph using Barabási–Albert preferential attachment /// /// A graph is grown to $n$ nodes by adding new nodes each with $m$ edges that diff --git a/tests/test_random.py b/tests/test_random.py index 601ccc9d31..7f8fd5bf3b 100644 --- a/tests/test_random.py +++ b/tests/test_random.py @@ -224,6 +224,52 @@ def test_random_geometric_pos_num_nodes_incomp(self): rustworkx.random_geometric_graph(3, 0.15, pos=[[0.5, 0.5]]) +class TestHyperbolicRandomGraph(unittest.TestCase): + def test_hyperbolic_random_threshold_empty(self): + graph = rustworkx.undirected_hyperbolic_random_graph([0.5, 1.0], [0.0, 3.1415], 1.0, None) + self.assertEqual(graph.num_edges(), 0) + + def test_hyperbolic_random_prob_empty(self): + graph = rustworkx.undirected_hyperbolic_random_graph( + [0.5, 1.0], [0.0, 3.1415], 1.0, 500.0, seed=10 + ) + self.assertEqual(graph.num_edges(), 0) + + def test_hyperbolic_random_threshold_complete(self): + graph = rustworkx.undirected_hyperbolic_random_graph([0.5, 1.0], [0.0, 3.1415], 1.55, None) + self.assertEqual(graph.num_edges(), 1) + + def test_hyperbolic_random_prob_complete(self): + graph = rustworkx.undirected_hyperbolic_random_graph( + [0.5, 1.0], [0.0, 3.1415], 1.55, 500.0, seed=10 + ) + self.assertEqual(graph.num_edges(), 1) + + def test_hyperbolic_random_no_pos(self): + with self.assertRaises(ValueError): + rustworkx.undirected_hyperbolic_random_graph([], [], 1.0, None) + + def test_hyperbolic_random_different_len_pos(self): + with self.assertRaises(ValueError): + rustworkx.undirected_hyperbolic_random_graph([0.0], [0.0, 0.0], 1.0, None) + + def test_hyperbolic_random_outofbounds_radii(self): + with self.assertRaises(ValueError): + rustworkx.undirected_hyperbolic_random_graph([-1.0], [0.0], 1.0, None) + + def test_hyperbolic_random_outofbounds_angles(self): + with self.assertRaises(ValueError): + rustworkx.undirected_hyperbolic_random_graph([0.0], [0.0, 3.142], 1.0, None) + + def test_hyperbolic_random_neg_r(self): + with self.assertRaises(ValueError): + rustworkx.undirected_hyperbolic_random_graph([0.0], [0.0], -1.0, None) + + def test_hyperbolic_random_neg_beta(self): + with self.assertRaises(ValueError): + rustworkx.undirected_hyperbolic_random_graph([0.0], [0.0], 1.0, -1.0) + + class TestRandomSubGraphIsomorphism(unittest.TestCase): def test_random_gnm_induced_subgraph_isomorphism(self): graph = rustworkx.undirected_gnm_random_graph(50, 150) From de1e638b8d886262411453e23cf0570cb554e20e Mon Sep 17 00:00:00 2001 From: Matthew Treinish Date: Sat, 18 May 2024 08:01:04 -0400 Subject: [PATCH 2/9] Loosen trait constraints and simplify structure for longest_path (#1195) In the recently merged #1192 a new generic DAG longest_path function was added to rustworkx-core. However, the trait bounds on the function were a bit tighter than they needed to be. The traits were forcing NodeId to be of a NodeIndex type and this wasn't really required. The only requirement that the NodeId type can be put on a hashmap and do a partial compare (that implements Hash, Eq, and PartialOrd). Also the IntoNeighborsDirected wasn't required because it's methods weren't ever used. This commit loosens the traits bounds to facilitate this. At the same time this also simplifies the code structure a bit to reduce the separation of the rust code structure in the rustworkx crate using longest_path(). --- rustworkx-core/src/dag_algo.rs | 25 ++++++------- src/dag_algo/longest_path.rs | 64 ---------------------------------- src/dag_algo/mod.rs | 57 ++++++++++++++++++++++++++---- 3 files changed, 62 insertions(+), 84 deletions(-) delete mode 100644 src/dag_algo/longest_path.rs diff --git a/rustworkx-core/src/dag_algo.rs b/rustworkx-core/src/dag_algo.rs index 0e1a0b9e0f..b29389a73b 100644 --- a/rustworkx-core/src/dag_algo.rs +++ b/rustworkx-core/src/dag_algo.rs @@ -9,13 +9,15 @@ // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the // License for the specific language governing permissions and limitations // under the License. + +use std::cmp::Eq; +use std::hash::Hash; + use hashbrown::HashMap; use petgraph::algo; -use petgraph::graph::NodeIndex; use petgraph::visit::{ - EdgeRef, GraphBase, GraphProp, IntoEdgesDirected, IntoNeighborsDirected, IntoNodeIdentifiers, - Visitable, + EdgeRef, GraphBase, GraphProp, IntoEdgesDirected, IntoNodeIdentifiers, Visitable, }; use petgraph::Directed; @@ -51,7 +53,6 @@ type LongestPathResult = Result>, T)>, E>; /// # Example /// ``` /// use petgraph::graph::DiGraph; -/// use petgraph::graph::NodeIndex; /// use petgraph::Directed; /// use rustworkx_core::dag_algo::longest_path; /// @@ -69,14 +70,10 @@ type LongestPathResult = Result>, T)>, E>; /// ``` pub fn longest_path(graph: G, mut weight_fn: F) -> LongestPathResult where - G: GraphProp - + IntoNodeIdentifiers - + IntoNeighborsDirected - + IntoEdgesDirected - + Visitable - + GraphBase, + G: GraphProp + IntoNodeIdentifiers + IntoEdgesDirected + Visitable, F: FnMut(G::EdgeRef) -> Result, T: Num + Zero + PartialOrd + Copy, + ::NodeId: Hash + Eq + PartialOrd, { let mut path: Vec> = Vec::new(); let nodes = match algo::toposort(graph, None) { @@ -88,12 +85,12 @@ where return Ok(Some((path, T::zero()))); } - let mut dist: HashMap = HashMap::with_capacity(nodes.len()); // Stores the distance and the previous node + let mut dist: HashMap = HashMap::with_capacity(nodes.len()); // Stores the distance and the previous node // Iterate over nodes in topological order for node in nodes { let parents = graph.edges_directed(node, petgraph::Direction::Incoming); - let mut incoming_path: Vec<(T, NodeIndex)> = Vec::new(); // Stores the distance and the previous node for each parent + let mut incoming_path: Vec<(T, G::NodeId)> = Vec::new(); // Stores the distance and the previous node for each parent for p_edge in parents { let p_node = p_edge.source(); let weight: T = weight_fn(p_edge)?; @@ -101,7 +98,7 @@ where incoming_path.push((length, p_node)); } // Determine the maximum distance and corresponding parent node - let max_path: (T, NodeIndex) = incoming_path + let max_path: (T, G::NodeId) = incoming_path .into_iter() .max_by(|a, b| a.0.partial_cmp(&b.0).unwrap()) .unwrap_or((T::zero(), node)); // If there are no incoming edges, the distance is zero @@ -114,7 +111,7 @@ where .max_by(|a, b| a.1.partial_cmp(b.1).unwrap()) .unwrap(); let mut v = *first; - let mut u: Option = None; + let mut u: Option = None; // Backtrack from this node to find the path while u.map_or(true, |u| u != v) { path.push(v); diff --git a/src/dag_algo/longest_path.rs b/src/dag_algo/longest_path.rs deleted file mode 100644 index bdb1cf91a7..0000000000 --- a/src/dag_algo/longest_path.rs +++ /dev/null @@ -1,64 +0,0 @@ -// Licensed under the Apache License, Version 2.0 (the "License"); you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - -use crate::{digraph, DAGHasCycle}; -use rustworkx_core::dag_algo::longest_path as core_longest_path; - -use petgraph::stable_graph::{EdgeReference, NodeIndex}; -use petgraph::visit::EdgeRef; - -use pyo3::prelude::*; - -use num_traits::{Num, Zero}; - -/// Calculate the longest path in a directed acyclic graph (DAG). -/// -/// This function interfaces with the Python `PyDiGraph` object to compute the longest path -/// using the provided weight function. -/// -/// # Arguments -/// * `graph`: Reference to a `PyDiGraph` object. -/// * `weight_fn`: A callable that takes the source node index, target node index, and the weight -/// object and returns the weight of the edge as a `PyResult`. -/// -/// # Type Parameters -/// * `F`: Type of the weight function. -/// * `T`: The type of the edge weight. Must implement `Num`, `Zero`, `PartialOrd`, and `Copy`. -/// -/// # Returns -/// * `PyResult<(Vec, T)>` representing the longest path as a sequence of node indices and its total weight. -pub fn longest_path(graph: &digraph::PyDiGraph, mut weight_fn: F) -> PyResult<(Vec, T)> -where - F: FnMut(usize, usize, &PyObject) -> PyResult, - T: Num + Zero + PartialOrd + Copy, -{ - let dag = &graph.graph; - - // Create a new weight function that matches the required signature - let edge_cost = |edge_ref: EdgeReference<'_, PyObject>| -> Result { - let source = edge_ref.source().index(); - let target = edge_ref.target().index(); - let weight = edge_ref.weight(); - weight_fn(source, target, weight) - }; - - let (path, path_weight) = match core_longest_path(dag, edge_cost) { - Ok(Some((path, path_weight))) => ( - path.into_iter().map(NodeIndex::index).collect(), - path_weight, - ), - Ok(None) => return Err(DAGHasCycle::new_err("The graph contains a cycle")), - Err(e) => return Err(e), - }; - - Ok((path, path_weight)) -} diff --git a/src/dag_algo/mod.rs b/src/dag_algo/mod.rs index 854c91b5c9..206fa9b455 100644 --- a/src/dag_algo/mod.rs +++ b/src/dag_algo/mod.rs @@ -10,8 +10,6 @@ // License for the specific language governing permissions and limitations // under the License. -mod longest_path; - use super::DictMap; use hashbrown::{HashMap, HashSet}; use indexmap::IndexSet; @@ -22,6 +20,7 @@ use std::collections::BinaryHeap; use super::iterators::NodeIndices; use crate::{digraph, DAGHasCycle, InvalidNode, StablePyGraph}; +use rustworkx_core::dag_algo::longest_path as core_longest_path; use rustworkx_core::traversal::dfs_edges; use pyo3::exceptions::PyValueError; @@ -32,8 +31,54 @@ use pyo3::Python; use petgraph::algo; use petgraph::graph::NodeIndex; use petgraph::prelude::*; +use petgraph::stable_graph::EdgeReference; use petgraph::visit::NodeCount; +use num_traits::{Num, Zero}; + +/// Calculate the longest path in a directed acyclic graph (DAG). +/// +/// This function interfaces with the Python `PyDiGraph` object to compute the longest path +/// using the provided weight function. +/// +/// # Arguments +/// * `graph`: Reference to a `PyDiGraph` object. +/// * `weight_fn`: A callable that takes the source node index, target node index, and the weight +/// object and returns the weight of the edge as a `PyResult`. +/// +/// # Type Parameters +/// * `F`: Type of the weight function. +/// * `T`: The type of the edge weight. Must implement `Num`, `Zero`, `PartialOrd`, and `Copy`. +/// +/// # Returns +/// * `PyResult<(Vec, T)>` representing the longest path as a sequence of node indices and its total weight. +fn longest_path(graph: &digraph::PyDiGraph, mut weight_fn: F) -> PyResult<(Vec, T)> +where + F: FnMut(usize, usize, &PyObject) -> PyResult, + T: Num + Zero + PartialOrd + Copy, +{ + let dag = &graph.graph; + + // Create a new weight function that matches the required signature + let edge_cost = |edge_ref: EdgeReference<'_, PyObject>| -> Result { + let source = edge_ref.source().index(); + let target = edge_ref.target().index(); + let weight = edge_ref.weight(); + weight_fn(source, target, weight) + }; + + let (path, path_weight) = match core_longest_path(dag, edge_cost) { + Ok(Some((path, path_weight))) => ( + path.into_iter().map(NodeIndex::index).collect(), + path_weight, + ), + Ok(None) => return Err(DAGHasCycle::new_err("The graph contains a cycle")), + Err(e) => return Err(e), + }; + + Ok((path, path_weight)) +} + /// Return a pair of [`petgraph::Direction`] values corresponding to the "forwards" and "backwards" /// direction of graph traversal, based on whether the graph is being traved forwards (following /// the edges) or backward (reversing along edges). The order of returns is (forwards, backwards). @@ -82,7 +127,7 @@ pub fn dag_longest_path( } }; Ok(NodeIndices { - nodes: longest_path::longest_path(graph, edge_weight_callable)?.0, + nodes: longest_path(graph, edge_weight_callable)?.0, }) } @@ -121,7 +166,7 @@ pub fn dag_longest_path_length( None => Ok(1), } }; - let (_, path_weight) = longest_path::longest_path(graph, edge_weight_callable)?; + let (_, path_weight) = longest_path(graph, edge_weight_callable)?; Ok(path_weight) } @@ -163,7 +208,7 @@ pub fn dag_weighted_longest_path( Ok(float_res) }; Ok(NodeIndices { - nodes: longest_path::longest_path(graph, edge_weight_callable)?.0, + nodes: longest_path(graph, edge_weight_callable)?.0, }) } @@ -204,7 +249,7 @@ pub fn dag_weighted_longest_path_length( } Ok(float_res) }; - let (_, path_weight) = longest_path::longest_path(graph, edge_weight_callable)?; + let (_, path_weight) = longest_path(graph, edge_weight_callable)?; Ok(path_weight) } From 4920ba59e05b4418e135b80ca6e9d652b30492f2 Mon Sep 17 00:00:00 2001 From: Simon Lizotte Date: Sat, 18 May 2024 11:14:07 -0400 Subject: [PATCH 3/9] use vector references --- rustworkx-core/src/generators/random_graph.rs | 44 +++++++++---------- src/random_graph.rs | 2 +- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/rustworkx-core/src/generators/random_graph.rs b/rustworkx-core/src/generators/random_graph.rs index dfb4994c63..7eaaae150f 100644 --- a/rustworkx-core/src/generators/random_graph.rs +++ b/rustworkx-core/src/generators/random_graph.rs @@ -650,8 +650,8 @@ where /// use rustworkx_core::generators::hyperbolic_random_graph; /// /// let g: petgraph::graph::UnGraph<(), ()> = hyperbolic_random_graph( -/// vec![0.4, 2., 3.], -/// vec![0., 0., 0.], +/// &vec![0.4, 2., 3.], +/// &vec![0., 0., 0.], /// None, /// 2., /// None, @@ -662,8 +662,8 @@ where /// assert_eq!(g.edge_count(), 2); /// ``` pub fn hyperbolic_random_graph( - radii: Vec, - angles: Vec, + radii: &Vec, + angles: &Vec, beta: Option, r: f64, seed: Option, @@ -1050,8 +1050,8 @@ mod tests { #[test] fn test_hyperbolic_random_graph_seeded() { let g = hyperbolic_random_graph::, _, _, _, _>( - vec![3., 0.5, 0.5, 0.], - vec![0., PI, 0., 0.], + &vec![3., 0.5, 0.5, 0.], + &vec![0., PI, 0., 0.], Some(10000.), 0.75, Some(10), @@ -1066,8 +1066,8 @@ mod tests { #[test] fn test_hyperbolic_random_graph_empty() { let g = hyperbolic_random_graph::, _, _, _, _>( - vec![3., 0.5, 1.], - vec![0., PI, 0.], + &vec![3., 0.5, 1.], + &vec![0., PI, 0.], None, 1., None, @@ -1082,8 +1082,8 @@ mod tests { #[test] fn test_hyperbolic_random_graph_bad_angle_error() { match hyperbolic_random_graph::, _, _, _, _>( - vec![0., 0.], - vec![0., 3.142], + &vec![0., 0.], + &vec![0., 3.142], None, 1., None, @@ -1098,8 +1098,8 @@ mod tests { #[test] fn test_hyperbolic_random_graph_neg_radii_error() { match hyperbolic_random_graph::, _, _, _, _>( - vec![0., -1.], - vec![0., 0.], + &vec![0., -1.], + &vec![0., 0.], None, 1., None, @@ -1114,8 +1114,8 @@ mod tests { #[test] fn test_hyperbolic_random_graph_neg_r_error() { match hyperbolic_random_graph::, _, _, _, _>( - vec![0.], - vec![0.], + &vec![0.], + &vec![0.], None, -1., None, @@ -1130,8 +1130,8 @@ mod tests { #[test] fn test_hyperbolic_random_graph_neg_beta_error() { match hyperbolic_random_graph::, _, _, _, _>( - vec![0.], - vec![0.], + &vec![0.], + &vec![0.], Some(-1.), 1., None, @@ -1146,8 +1146,8 @@ mod tests { #[test] fn test_hyperbolic_random_graph_len_coord_error() { match hyperbolic_random_graph::, _, _, _, _>( - vec![1.], - vec![1., 2.], + &vec![1.], + &vec![1., 2.], None, 1., None, @@ -1162,8 +1162,8 @@ mod tests { #[test] fn test_hyperbolic_random_graph_empty_error() { match hyperbolic_random_graph::, _, _, _, _>( - vec![], - vec![], + &vec![], + &vec![], None, 1., None, @@ -1178,8 +1178,8 @@ mod tests { #[test] fn test_hyperbolic_random_graph_directed_error() { match hyperbolic_random_graph::, _, _, _, _>( - vec![0.], - vec![0.], + &vec![0.], + &vec![0.], None, 1., None, diff --git a/src/random_graph.rs b/src/random_graph.rs index 634039a625..c64116ad88 100644 --- a/src/random_graph.rs +++ b/src/random_graph.rs @@ -425,7 +425,7 @@ pub fn undirected_hyperbolic_random_graph( ) -> PyResult { let default_fn = || py.None(); let graph: StablePyGraph = match core_generators::hyperbolic_random_graph( - radii, angles, beta, r, seed, default_fn, default_fn, + &radii, &angles, beta, r, seed, default_fn, default_fn, ) { Ok(graph) => graph, Err(_) => return Err(PyValueError::new_err("invalid positions or parameters")), From 13aba306e50efe419e855e03b78b7f5889d7dce9 Mon Sep 17 00:00:00 2001 From: Simon Lizotte Date: Sat, 18 May 2024 12:13:11 -0400 Subject: [PATCH 4/9] change to slice (clippy) --- rustworkx-core/src/generators/random_graph.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rustworkx-core/src/generators/random_graph.rs b/rustworkx-core/src/generators/random_graph.rs index 7eaaae150f..b1b1218e7f 100644 --- a/rustworkx-core/src/generators/random_graph.rs +++ b/rustworkx-core/src/generators/random_graph.rs @@ -662,8 +662,8 @@ where /// assert_eq!(g.edge_count(), 2); /// ``` pub fn hyperbolic_random_graph( - radii: &Vec, - angles: &Vec, + radii: &[f64], + angles: &[f64], beta: Option, r: f64, seed: Option, From 2117508c278e4c135f38d19eed05bf0d860222aa Mon Sep 17 00:00:00 2001 From: Simon Lizotte Date: Mon, 20 May 2024 18:48:18 -0400 Subject: [PATCH 5/9] generalize to H^D, improve numerical accuracy --- .../api/random_graph_generator_functions.rst | 2 +- ...erbolic-random-graph-d85c115930d8ac08.yaml | 10 +- rustworkx-core/src/generators/random_graph.rs | 143 ++++++++++-------- rustworkx/__init__.pyi | 2 +- rustworkx/rustworkx.pyi | 5 +- src/lib.rs | 2 +- src/random_graph.rs | 45 +++--- tests/test_random.py | 43 +++--- 8 files changed, 137 insertions(+), 115 deletions(-) diff --git a/docs/source/api/random_graph_generator_functions.rst b/docs/source/api/random_graph_generator_functions.rst index 73710ceb5f..4bc52096ec 100644 --- a/docs/source/api/random_graph_generator_functions.rst +++ b/docs/source/api/random_graph_generator_functions.rst @@ -11,7 +11,7 @@ Random Graph Generator Functions rustworkx.directed_gnm_random_graph rustworkx.undirected_gnm_random_graph rustworkx.random_geometric_graph - rustworkx.undirected_hyperbolic_random_graph + rustworkx.hyperbolic_random_graph rustworkx.barabasi_albert_graph rustworkx.directed_barabasi_albert_graph rustworkx.directed_random_bipartite_graph diff --git a/releasenotes/notes/hyperbolic-random-graph-d85c115930d8ac08.yaml b/releasenotes/notes/hyperbolic-random-graph-d85c115930d8ac08.yaml index cb41698cee..6a59b51423 100644 --- a/releasenotes/notes/hyperbolic-random-graph-d85c115930d8ac08.yaml +++ b/releasenotes/notes/hyperbolic-random-graph-d85c115930d8ac08.yaml @@ -1,11 +1,7 @@ features: - | - Adds new random graph generator function, :func:`.undirected_hyperbolic_random_graph` - to generate a random graph using the H² random graph model. + Adds new random graph generator function, :func:`.hyperbolic_random_graph` + to sample the hyperbolic random graph model. - | Adds new function to the rustworkx-core module ``rustworkx_core::generators`` - ``hyperbolic_random_graph()`` that generates a H^2 hyperbolic random graph. - -issues: - - | - Related to issue `#150 `. + ``hyperbolic_random_graph()`` that samples the hyperbolic random graph model. diff --git a/rustworkx-core/src/generators/random_graph.rs b/rustworkx-core/src/generators/random_graph.rs index b1b1218e7f..1e4298b4ca 100644 --- a/rustworkx-core/src/generators/random_graph.rs +++ b/rustworkx-core/src/generators/random_graph.rs @@ -12,9 +12,9 @@ #![allow(clippy::float_cmp)] -use std::f64::consts::PI; use std::hash::Hash; +use num_traits::Float; use petgraph::data::{Build, Create}; use petgraph::visit::{ Data, EdgeRef, GraphBase, GraphProp, IntoEdgeReferences, IntoEdgesDirected, @@ -620,24 +620,23 @@ where Ok(graph) } -/// Generate a hyperbolic random undirected graph. +/// Generate a hyperbolic random undirected graph (also called hyperbolic geometric graph). /// -/// The H² hyperbolic random graph model connects pairs of nodes with a probability +/// The hyperbolic random graph model connects pairs of nodes with a probability /// that decreases as their hyperbolic distance increases. /// -/// The number of nodes is inferred from the coordinates `radii` and `angles`. `radii` -/// and `angles` must have the same size and cannot be empty. If `beta` is `None`, -/// all pairs of nodes with a distance smaller than ``r`` are connected. -/// -/// D. Krioukov et al. "Hyperbolic geometry of complex networks", Phys. Rev. E 82, pp 036106, 2010. +/// The number of nodes and the dimension are inferred from the coordinates `pos` of the +/// hyperboloid model (at least 3-dimensional). If `beta` is `None`, all pairs of nodes +/// with a distance smaller than ``r`` are connected. /// /// Arguments: /// -/// * `radii` - radial coordinates (nonnegative) of the nodes. -/// * `angles` - angular coordinates (between -pi and pi) of the nodes. +/// * `pos` - Hyperboloid model coordinates of the nodes `[p_1, p_2, ...]` where `p_i` is the +/// position of node i. The first dimension corresponds to the negative term in the metric +/// and so for each node i, `p_i[0]` must be at least 1. /// * `beta` - Sigmoid sharpness (nonnegative) of the connection probability. /// * `r` - Distance at which the connection probability is 0.5 for the probabilistic model. -/// Threshold when ``beta`` is ``None``. +/// Threshold when `beta` is `None`. /// * `seed` - An optional seed to use for the random number generator. /// * `default_node_weight` - A callable that will return the weight to use /// for newly created nodes. @@ -650,8 +649,9 @@ where /// use rustworkx_core::generators::hyperbolic_random_graph; /// /// let g: petgraph::graph::UnGraph<(), ()> = hyperbolic_random_graph( -/// &vec![0.4, 2., 3.], -/// &vec![0., 0., 0.], +/// &[vec![1_f64.cosh(), 3_f64.sinh(), 0.], +/// vec![0.5_f64.cosh(), -0.5_f64.sinh(), 0.], +/// vec![1_f64.cosh(), -1_f64.sinh(), 0.]], /// None, /// 2., /// None, @@ -659,11 +659,10 @@ where /// || {()}, /// ).unwrap(); /// assert_eq!(g.node_count(), 3); -/// assert_eq!(g.edge_count(), 2); +/// assert_eq!(g.edge_count(), 1); /// ``` pub fn hyperbolic_random_graph( - radii: &[f64], - angles: &[f64], + pos: &[Vec], beta: Option, r: f64, seed: Option, @@ -676,20 +675,18 @@ where H: FnMut() -> M, G::NodeId: Eq + Hash, { - let num_nodes = radii.len(); - if num_nodes == 0 || radii.len() != angles.len() { - return Err(InvalidInputError {}); - } - if radii.iter().any(|x| x.is_infinite() || x.is_nan()) { - return Err(InvalidInputError {}); - } - if angles.iter().any(|x| x.is_infinite() || x.is_nan()) { + let num_nodes = pos.len(); + if num_nodes == 0 { return Err(InvalidInputError {}); } - if radii.iter().fold(0_f64, |a, &b| a.min(b)) < 0. { + if pos + .iter() + .any(|xs| xs.iter().any(|x| x.is_infinite() || x.is_nan())) + { return Err(InvalidInputError {}); } - if angles.iter().map(|x| x.abs()).fold(0_f64, |a, b| a.max(b)) > PI { + let dim = pos[0].len(); + if dim < 3 || pos.iter().any(|x| x.len() != dim || x[0] < 1.) { return Err(InvalidInputError {}); } if beta.is_some_and(|b| b < 0. || b.is_nan()) { @@ -713,19 +710,14 @@ where } let between = Uniform::new(0.0, 1.0); - for (v, (r1, theta1)) in radii - .iter() - .zip(angles.iter()) - .enumerate() - .take(num_nodes - 1) - { - for (w, (r2, theta2)) in radii.iter().zip(angles.iter()).enumerate().skip(v + 1) { - let dist = hyperbolic_distance(r1, theta1, r2, theta2); + for (v, p1) in pos.iter().enumerate().take(num_nodes - 1) { + for (w, p2) in pos.iter().enumerate().skip(v + 1) { + let dist = hyperbolic_distance(p1, p2); let is_edge = match beta { Some(b) => { - let prob = 1. / ((b / 2. * (dist - r)).exp() + 1.); + let prob_inverse = (b / 2. * (dist - r)).exp() + 1.; let u: f64 = between.sample(&mut rng); - u < prob + prob_inverse * u < 1. } None => dist < r, }; @@ -742,8 +734,17 @@ where } #[inline] -fn hyperbolic_distance(r1: &f64, theta1: &f64, r2: &f64, theta2: &f64) -> f64 { - (r1.cosh() * r2.cosh() - r1.sinh() * r2.sinh() * (theta1 - theta2).cos()).acosh() +fn hyperbolic_distance(p1: &[F], p2: &[F]) -> F +where + F: Float + std::iter::Sum, +{ + (p1[0] * p2[0] + - p1.iter() + .skip(1) + .zip(p2.iter().skip(1)) + .map(|(&x, &y)| x * y) + .sum()) + .acosh() } #[cfg(test)] @@ -754,7 +755,8 @@ mod tests { path_graph, random_bipartite_graph, random_geometric_graph, }; use crate::petgraph; - use std::f64::consts::PI; + + use super::hyperbolic_distance; // Test gnp_random_graph @@ -1046,12 +1048,33 @@ mod tests { } // Test hyperbolic_random_graph + // + // Hyperboloid (H^2) "polar" coordinates (r, theta) are transformed to "cartesian" + // coordinates using + // z = cosh(r) + // x = sinh(r)cos(theta) + // y = sinh(r)sin(theta) + + #[test] + fn test_hyperbolic_dist() { + assert_eq!( + hyperbolic_distance( + &[3_f64.cosh(), 3_f64.sinh(), 0.], + &[0.5_f64.cosh(), -0.5_f64.sinh(), 0.] + ), + 3.5 + ); + } #[test] fn test_hyperbolic_random_graph_seeded() { let g = hyperbolic_random_graph::, _, _, _, _>( - &vec![3., 0.5, 0.5, 0.], - &vec![0., PI, 0., 0.], + &[ + vec![3_f64.cosh(), 3_f64.sinh(), 0.], + vec![0.5_f64.cosh(), -0.5_f64.sinh(), 0.], + vec![0.5_f64.cosh(), 0.5_f64.sinh(), 0.], + vec![1., 0., 0.], + ], Some(10000.), 0.75, Some(10), @@ -1064,10 +1087,13 @@ mod tests { } #[test] - fn test_hyperbolic_random_graph_empty() { + fn test_hyperbolic_random_graph_threshold() { let g = hyperbolic_random_graph::, _, _, _, _>( - &vec![3., 0.5, 1.], - &vec![0., PI, 0.], + &[ + vec![1_f64.cosh(), 3_f64.sinh(), 0.], + vec![0.5_f64.cosh(), -0.5_f64.sinh(), 0.], + vec![1_f64.cosh(), -1_f64.sinh(), 0.], + ], None, 1., None, @@ -1076,14 +1102,13 @@ mod tests { ) .unwrap(); assert_eq!(g.node_count(), 3); - assert_eq!(g.edge_count(), 0); + assert_eq!(g.edge_count(), 1); } #[test] - fn test_hyperbolic_random_graph_bad_angle_error() { + fn test_hyperbolic_random_graph_invalid_dim_error() { match hyperbolic_random_graph::, _, _, _, _>( - &vec![0., 0.], - &vec![0., 3.142], + &[vec![1., 0.]], None, 1., None, @@ -1096,10 +1121,9 @@ mod tests { } #[test] - fn test_hyperbolic_random_graph_neg_radii_error() { + fn test_hyperbolic_random_graph_invalid_first_coord_error() { match hyperbolic_random_graph::, _, _, _, _>( - &vec![0., -1.], - &vec![0., 0.], + &[vec![0., 0., 0.]], None, 1., None, @@ -1114,8 +1138,7 @@ mod tests { #[test] fn test_hyperbolic_random_graph_neg_r_error() { match hyperbolic_random_graph::, _, _, _, _>( - &vec![0.], - &vec![0.], + &[vec![1., 0., 0.], vec![1., 0., 0.]], None, -1., None, @@ -1130,8 +1153,7 @@ mod tests { #[test] fn test_hyperbolic_random_graph_neg_beta_error() { match hyperbolic_random_graph::, _, _, _, _>( - &vec![0.], - &vec![0.], + &[vec![1., 0., 0.], vec![1., 0., 0.]], Some(-1.), 1., None, @@ -1144,10 +1166,9 @@ mod tests { } #[test] - fn test_hyperbolic_random_graph_len_coord_error() { + fn test_hyperbolic_random_graph_diff_dims_error() { match hyperbolic_random_graph::, _, _, _, _>( - &vec![1.], - &vec![1., 2.], + &[vec![1., 0., 0.], vec![1., 0., 0., 0.]], None, 1., None, @@ -1162,8 +1183,7 @@ mod tests { #[test] fn test_hyperbolic_random_graph_empty_error() { match hyperbolic_random_graph::, _, _, _, _>( - &vec![], - &vec![], + &[], None, 1., None, @@ -1178,8 +1198,7 @@ mod tests { #[test] fn test_hyperbolic_random_graph_directed_error() { match hyperbolic_random_graph::, _, _, _, _>( - &vec![0.], - &vec![0.], + &[vec![1., 0., 0.], vec![1., 0., 0.]], None, 1., None, diff --git a/rustworkx/__init__.pyi b/rustworkx/__init__.pyi index 043f62ea9b..f253879754 100644 --- a/rustworkx/__init__.pyi +++ b/rustworkx/__init__.pyi @@ -128,7 +128,7 @@ from .rustworkx import undirected_gnm_random_graph as undirected_gnm_random_grap from .rustworkx import directed_gnp_random_graph as directed_gnp_random_graph from .rustworkx import undirected_gnp_random_graph as undirected_gnp_random_graph from .rustworkx import random_geometric_graph as random_geometric_graph -from .rustworkx import undirected_hyperbolic_random_graph as undirected_hyperbolic_random_graph +from .rustworkx import hyperbolic_random_graph as hyperbolic_random_graph from .rustworkx import barabasi_albert_graph as barabasi_albert_graph from .rustworkx import directed_barabasi_albert_graph as directed_barabasi_albert_graph from .rustworkx import undirected_random_bipartite_graph as undirected_random_bipartite_graph diff --git a/rustworkx/rustworkx.pyi b/rustworkx/rustworkx.pyi index 4dc8bb36ba..bebe0520ee 100644 --- a/rustworkx/rustworkx.pyi +++ b/rustworkx/rustworkx.pyi @@ -558,9 +558,8 @@ def random_geometric_graph( p: float = ..., seed: int | None = ..., ) -> PyGraph: ... -def undirected_hyperbolic_random_graph( - radii: list[float], - angles: list[float], +def hyperbolic_random_graph( + pos: list[list[float]], r: float, beta: float | None, /, diff --git a/src/lib.rs b/src/lib.rs index 9a585f8432..cde6034fca 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -477,7 +477,7 @@ fn rustworkx(py: Python<'_>, m: &Bound) -> PyResult<()> { m.add_wrapped(wrap_pyfunction!(directed_gnm_random_graph))?; m.add_wrapped(wrap_pyfunction!(undirected_gnm_random_graph))?; m.add_wrapped(wrap_pyfunction!(random_geometric_graph))?; - m.add_wrapped(wrap_pyfunction!(undirected_hyperbolic_random_graph))?; + m.add_wrapped(wrap_pyfunction!(hyperbolic_random_graph))?; m.add_wrapped(wrap_pyfunction!(barabasi_albert_graph))?; m.add_wrapped(wrap_pyfunction!(directed_barabasi_albert_graph))?; m.add_wrapped(wrap_pyfunction!(directed_random_bipartite_graph))?; diff --git a/src/random_graph.rs b/src/random_graph.rs index c64116ad88..4bf451bdea 100644 --- a/src/random_graph.rs +++ b/src/random_graph.rs @@ -380,56 +380,55 @@ pub fn random_geometric_graph( Ok(graph) } -/// Return a :math:`\mathbb{H}^2` hyperbolic random undirected graph. +/// Return a hyperbolic random undirected graph (also called hyperbolic geometric graph). /// -/// The :math:`\mathbb{H}^2` hyperbolic random graph model connects pairs of nodes with probability +/// The usual hyperbolic random graph model connects pairs of nodes with probability /// /// .. math:: /// /// P[(u,v) \in E] = \frac{1}{1+\exp(\beta(d(u,v) - R)/2)}, /// -/// a function that decreases with the hyperbolic distance between nodes :math:`u` and :math:`v` +/// a sigmoid function that decreases as the hyperbolic distance between nodes :math:`u` +/// and :math:`v` increases. The hyperbolic distance is given by /// /// .. math:: /// -/// d(u,v) = \text{arccosh}\left[\cosh(r_u) \cosh(r_v) - \sinh(r_u) \sinh(r_v)\cos(\theta_u-\theta_v)\right], +/// d(u,v) = \text{arccosh}\left[x_u^0 x_v^0 - \sum_{j=1}^D x_u^j x_v^j \right], /// -/// where :math:`r_u` and :math:`\theta_u` are the hyperbolic polar coordinates of node :math:`u`. +/// where :math:`D` is the dimension of the hyperbolic space and :math:`x_u^d` is the +/// :math:`d` th-dimension coordinate of node :math:`u` in the hyperboloid model. The +/// number of nodes and the dimension are inferred from the coordinates ``pos``. /// -/// The number of nodes is inferred from the coordinates ``radii`` and ``angles``. ``radii`` and -/// ``angles`` must have the same size and cannot be empty. If ``beta`` is ``None``, all pairs of -/// nodes with a distance smaller than `r` are connected. +/// If ``beta`` is ``None``, all pairs of nodes with a distance smaller than ``r`` are connected. /// -/// D. Krioukov et al. "Hyperbolic geometry of complex networks", Phys. Rev. E 82, pp 036106, 2010. +/// This algorithm has a time complexity of :math:`O(n^2)` for :math:`n` nodes. /// -/// This algorithm has a time complexity of :math:`O(n^2)`. -/// -/// :param list[float] radii: radial coordinates (nonnegative) of the nodes. -/// :param list[float] angles: angular coordinates (in :math:`[-\pi, \pi]`) of the nodes. +/// :param list[list[float]] radii: Hyperboloid coordinates of the nodes +/// [[:math:`x_1^0`, ..., :math:`x_1^D`], ...]. Since the first dimension is associated to +/// the positive term in the metric, each :math:`x_u^0` must be at least 1. /// :param float beta: Sigmoid sharpness (nonnegative) of the connection probability. /// :param float r: Distance at which the connection probability is 0.5 for the probabilistic model. -/// Threshold when `beta` is `None`. +/// Threshold when ``beta`` is ``None``. /// :param int seed: An optional seed to use for the random number generator. /// /// :return: A PyGraph object /// :rtype: PyGraph #[pyfunction] #[pyo3(text_signature = "(radii, angles, r, beta, /, seed=None)")] -pub fn undirected_hyperbolic_random_graph( +pub fn hyperbolic_random_graph( py: Python, - radii: Vec, - angles: Vec, + pos: Vec>, r: f64, beta: Option, seed: Option, ) -> PyResult { let default_fn = || py.None(); - let graph: StablePyGraph = match core_generators::hyperbolic_random_graph( - &radii, &angles, beta, r, seed, default_fn, default_fn, - ) { - Ok(graph) => graph, - Err(_) => return Err(PyValueError::new_err("invalid positions or parameters")), - }; + let graph: StablePyGraph = + match core_generators::hyperbolic_random_graph(&pos, beta, r, seed, default_fn, default_fn) + { + Ok(graph) => graph, + Err(_) => return Err(PyValueError::new_err("invalid positions or parameters")), + }; Ok(graph::PyGraph { graph, node_removed: false, diff --git a/tests/test_random.py b/tests/test_random.py index 7f8fd5bf3b..02cfcd36aa 100644 --- a/tests/test_random.py +++ b/tests/test_random.py @@ -12,6 +12,7 @@ import unittest import random +import math import rustworkx @@ -226,48 +227,56 @@ def test_random_geometric_pos_num_nodes_incomp(self): class TestHyperbolicRandomGraph(unittest.TestCase): def test_hyperbolic_random_threshold_empty(self): - graph = rustworkx.undirected_hyperbolic_random_graph([0.5, 1.0], [0.0, 3.1415], 1.0, None) + graph = rustworkx.hyperbolic_random_graph( + [[math.cosh(0.5), math.sinh(0.5), 0], [math.cosh(1), -math.sinh(1), 0]], 1.0, None + ) self.assertEqual(graph.num_edges(), 0) def test_hyperbolic_random_prob_empty(self): - graph = rustworkx.undirected_hyperbolic_random_graph( - [0.5, 1.0], [0.0, 3.1415], 1.0, 500.0, seed=10 + graph = rustworkx.hyperbolic_random_graph( + [[math.cosh(0.5), math.sinh(0.5), 0], [math.cosh(1), -math.sinh(1), 0]], + 1.0, + 500.0, + seed=10, ) self.assertEqual(graph.num_edges(), 0) def test_hyperbolic_random_threshold_complete(self): - graph = rustworkx.undirected_hyperbolic_random_graph([0.5, 1.0], [0.0, 3.1415], 1.55, None) + graph = rustworkx.hyperbolic_random_graph( + [[math.cosh(0.5), math.sinh(0.5), 0], [math.cosh(1), -math.sinh(1), 0]], + 1.55, + None, + ) self.assertEqual(graph.num_edges(), 1) def test_hyperbolic_random_prob_complete(self): - graph = rustworkx.undirected_hyperbolic_random_graph( - [0.5, 1.0], [0.0, 3.1415], 1.55, 500.0, seed=10 + graph = rustworkx.hyperbolic_random_graph( + [[math.cosh(0.5), math.sinh(0.5), 0], [math.cosh(1), -math.sinh(1), 0]], + 1.55, + 500.0, + seed=10, ) self.assertEqual(graph.num_edges(), 1) def test_hyperbolic_random_no_pos(self): with self.assertRaises(ValueError): - rustworkx.undirected_hyperbolic_random_graph([], [], 1.0, None) - - def test_hyperbolic_random_different_len_pos(self): - with self.assertRaises(ValueError): - rustworkx.undirected_hyperbolic_random_graph([0.0], [0.0, 0.0], 1.0, None) + rustworkx.hyperbolic_random_graph([], 1.0, None) - def test_hyperbolic_random_outofbounds_radii(self): + def test_hyperbolic_random_different_dim_pos(self): with self.assertRaises(ValueError): - rustworkx.undirected_hyperbolic_random_graph([-1.0], [0.0], 1.0, None) + rustworkx.hyperbolic_random_graph([[1, 0, 0], [1, 0, 0, 0]], 1.0, None) - def test_hyperbolic_random_outofbounds_angles(self): + def test_hyperbolic_random_outofbounds_first_dim(self): with self.assertRaises(ValueError): - rustworkx.undirected_hyperbolic_random_graph([0.0], [0.0, 3.142], 1.0, None) + rustworkx.hyperbolic_random_graph([[1, 0, 0], [0, 0, 0]], 1.0, None) def test_hyperbolic_random_neg_r(self): with self.assertRaises(ValueError): - rustworkx.undirected_hyperbolic_random_graph([0.0], [0.0], -1.0, None) + rustworkx.hyperbolic_random_graph([[1, 0, 0], [1, 0, 0]], -1.0, None) def test_hyperbolic_random_neg_beta(self): with self.assertRaises(ValueError): - rustworkx.undirected_hyperbolic_random_graph([0.0], [0.0], 1.0, -1.0) + rustworkx.hyperbolic_random_graph([[1, 0, 0], [1, 0, 0]], 1.0, -1.0) class TestRandomSubGraphIsomorphism(unittest.TestCase): From e54eec89f0454676bf8d1b3a0c5300090566e643 Mon Sep 17 00:00:00 2001 From: Simon Lizotte Date: Mon, 20 May 2024 19:05:07 -0400 Subject: [PATCH 6/9] allow infinite coordinate --- rustworkx-core/src/generators/random_graph.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rustworkx-core/src/generators/random_graph.rs b/rustworkx-core/src/generators/random_graph.rs index 1e4298b4ca..a93383da39 100644 --- a/rustworkx-core/src/generators/random_graph.rs +++ b/rustworkx-core/src/generators/random_graph.rs @@ -681,7 +681,7 @@ where } if pos .iter() - .any(|xs| xs.iter().any(|x| x.is_infinite() || x.is_nan())) + .any(|xs| xs.iter().any(|x| x.is_nan())) { return Err(InvalidInputError {}); } From 4ccdde86654eb2951007dd3deb6869a5dab08e1d Mon Sep 17 00:00:00 2001 From: Simon Lizotte Date: Mon, 20 May 2024 19:11:51 -0400 Subject: [PATCH 7/9] handle infinity in hyperbolic distance --- rustworkx-core/src/generators/random_graph.rs | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/rustworkx-core/src/generators/random_graph.rs b/rustworkx-core/src/generators/random_graph.rs index a93383da39..8677f47bbb 100644 --- a/rustworkx-core/src/generators/random_graph.rs +++ b/rustworkx-core/src/generators/random_graph.rs @@ -679,10 +679,7 @@ where if num_nodes == 0 { return Err(InvalidInputError {}); } - if pos - .iter() - .any(|xs| xs.iter().any(|x| x.is_nan())) - { + if pos.iter().any(|xs| xs.iter().any(|x| x.is_nan())) { return Err(InvalidInputError {}); } let dim = pos[0].len(); @@ -734,17 +731,18 @@ where } #[inline] -fn hyperbolic_distance(p1: &[F], p2: &[F]) -> F -where - F: Float + std::iter::Sum, -{ - (p1[0] * p2[0] - - p1.iter() - .skip(1) - .zip(p2.iter().skip(1)) - .map(|(&x, &y)| x * y) - .sum()) - .acosh() +fn hyperbolic_distance(p1: &[f64], p2: &[f64]) -> f64 { + if p1.iter().chain(p2.iter()).any(|x| x.is_infinite()) { + f64::INFINITY + } else { + (p1[0] * p2[0] + - p1.iter() + .skip(1) + .zip(p2.iter().skip(1)) + .map(|(&x, &y)| x * y) + .sum::()) + .acosh() + } } #[cfg(test)] @@ -1065,6 +1063,13 @@ mod tests { 3.5 ); } + #[test] + fn test_hyperbolic_dist_inf() { + assert_eq!( + hyperbolic_distance(&[f64::INFINITY, f64::INFINITY, 0.], &[1., 0., 0.]), + f64::INFINITY + ); + } #[test] fn test_hyperbolic_random_graph_seeded() { From 8dbf01074b8e195c10410f445ae4692584d98cf1 Mon Sep 17 00:00:00 2001 From: Simon Lizotte Date: Mon, 20 May 2024 19:28:52 -0400 Subject: [PATCH 8/9] remove unused import (clippy) --- rustworkx-core/src/generators/random_graph.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/rustworkx-core/src/generators/random_graph.rs b/rustworkx-core/src/generators/random_graph.rs index 8677f47bbb..1768619d53 100644 --- a/rustworkx-core/src/generators/random_graph.rs +++ b/rustworkx-core/src/generators/random_graph.rs @@ -14,7 +14,6 @@ use std::hash::Hash; -use num_traits::Float; use petgraph::data::{Build, Create}; use petgraph::visit::{ Data, EdgeRef, GraphBase, GraphProp, IntoEdgeReferences, IntoEdgesDirected, From 692144b5b19b3d62aca9e9cdf5abc22bab1dacc6 Mon Sep 17 00:00:00 2001 From: Simon Lizotte Date: Mon, 20 May 2024 19:58:43 -0400 Subject: [PATCH 9/9] fix python stub --- src/random_graph.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/random_graph.rs b/src/random_graph.rs index 4bf451bdea..f0e6ee679b 100644 --- a/src/random_graph.rs +++ b/src/random_graph.rs @@ -403,7 +403,7 @@ pub fn random_geometric_graph( /// /// This algorithm has a time complexity of :math:`O(n^2)` for :math:`n` nodes. /// -/// :param list[list[float]] radii: Hyperboloid coordinates of the nodes +/// :param list[list[float]] pos: Hyperboloid coordinates of the nodes /// [[:math:`x_1^0`, ..., :math:`x_1^D`], ...]. Since the first dimension is associated to /// the positive term in the metric, each :math:`x_u^0` must be at least 1. /// :param float beta: Sigmoid sharpness (nonnegative) of the connection probability. @@ -414,7 +414,7 @@ pub fn random_geometric_graph( /// :return: A PyGraph object /// :rtype: PyGraph #[pyfunction] -#[pyo3(text_signature = "(radii, angles, r, beta, /, seed=None)")] +#[pyo3(text_signature = "(pos, beta, r, /, seed=None)")] pub fn hyperbolic_random_graph( py: Python, pos: Vec>,