diff --git a/docs/source/api/random_graph_generator_functions.rst b/docs/source/api/random_graph_generator_functions.rst index ba6823cfd..4bc52096e 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.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 000000000..6a59b5142 --- /dev/null +++ b/releasenotes/notes/hyperbolic-random-graph-d85c115930d8ac08.yaml @@ -0,0 +1,7 @@ +features: + - | + 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 samples the hyperbolic random graph model. diff --git a/rustworkx-core/src/generators/mod.rs b/rustworkx-core/src/generators/mod.rs index d0a2c8703..04d672749 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 b237d052d..1768619d5 100644 --- a/rustworkx-core/src/generators/random_graph.rs +++ b/rustworkx-core/src/generators/random_graph.rs @@ -619,15 +619,142 @@ where Ok(graph) } +/// Generate a hyperbolic random undirected graph (also called hyperbolic geometric graph). +/// +/// The hyperbolic random graph model connects pairs of nodes with a probability +/// that decreases as their hyperbolic distance increases. +/// +/// 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: +/// +/// * `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`. +/// * `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![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, +/// || {()}, +/// || {()}, +/// ).unwrap(); +/// assert_eq!(g.node_count(), 3); +/// assert_eq!(g.edge_count(), 1); +/// ``` +pub fn hyperbolic_random_graph( + pos: &[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 = pos.len(); + if num_nodes == 0 { + return Err(InvalidInputError {}); + } + if pos.iter().any(|xs| xs.iter().any(|x| x.is_nan())) { + return Err(InvalidInputError {}); + } + 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()) { + 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, 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_inverse = (b / 2. * (dist - r)).exp() + 1.; + let u: f64 = between.sample(&mut rng); + prob_inverse * u < 1. + } + 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(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)] 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 super::hyperbolic_distance; + // Test gnp_random_graph #[test] @@ -916,4 +1043,174 @@ mod tests { Err(e) => assert_eq!(e, InvalidInputError), }; } + + // 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_dist_inf() { + assert_eq!( + hyperbolic_distance(&[f64::INFINITY, f64::INFINITY, 0.], &[1., 0., 0.]), + f64::INFINITY + ); + } + + #[test] + fn test_hyperbolic_random_graph_seeded() { + let g = hyperbolic_random_graph::, _, _, _, _>( + &[ + 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), + || (), + || (), + ) + .unwrap(); + assert_eq!(g.node_count(), 4); + assert_eq!(g.edge_count(), 2); + } + + #[test] + fn test_hyperbolic_random_graph_threshold() { + let g = hyperbolic_random_graph::, _, _, _, _>( + &[ + 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, + || (), + || (), + ) + .unwrap(); + assert_eq!(g.node_count(), 3); + assert_eq!(g.edge_count(), 1); + } + + #[test] + fn test_hyperbolic_random_graph_invalid_dim_error() { + match hyperbolic_random_graph::, _, _, _, _>( + &[vec![1., 0.]], + None, + 1., + None, + || (), + || (), + ) { + Ok(_) => panic!("Returned a non-error"), + Err(e) => assert_eq!(e, InvalidInputError), + } + } + + #[test] + fn test_hyperbolic_random_graph_invalid_first_coord_error() { + match hyperbolic_random_graph::, _, _, _, _>( + &[vec![0., 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![1., 0., 0.], vec![1., 0., 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![1., 0., 0.], vec![1., 0., 0.]], + Some(-1.), + 1., + None, + || (), + || (), + ) { + Ok(_) => panic!("Returned a non-error"), + Err(e) => assert_eq!(e, InvalidInputError), + } + } + + #[test] + fn test_hyperbolic_random_graph_diff_dims_error() { + match hyperbolic_random_graph::, _, _, _, _>( + &[vec![1., 0., 0.], vec![1., 0., 0., 0.]], + 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::, _, _, _, _>( + &[], + 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![1., 0., 0.], vec![1., 0., 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 f5bcdeb71..f25387975 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 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 2edcdde67..bebe0520e 100644 --- a/rustworkx/rustworkx.pyi +++ b/rustworkx/rustworkx.pyi @@ -558,6 +558,13 @@ def random_geometric_graph( p: float = ..., seed: int | None = ..., ) -> PyGraph: ... +def hyperbolic_random_graph( + pos: list[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 096f30072..cde6034fc 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!(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 39520a23e..f0e6ee679 100644 --- a/src/random_graph.rs +++ b/src/random_graph.rs @@ -380,6 +380,63 @@ pub fn random_geometric_graph( Ok(graph) } +/// Return a hyperbolic random undirected graph (also called hyperbolic geometric graph). +/// +/// 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 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[x_u^0 x_v^0 - \sum_{j=1}^D x_u^j x_v^j \right], +/// +/// 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``. +/// +/// If ``beta`` is ``None``, all pairs of nodes with a distance smaller than ``r`` are connected. +/// +/// This algorithm has a time complexity of :math:`O(n^2)` for :math:`n` 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. +/// :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 = "(pos, beta, r, /, seed=None)")] +pub fn hyperbolic_random_graph( + py: Python, + pos: Vec>, + r: f64, + beta: Option, + seed: Option, +) -> PyResult { + let default_fn = || py.None(); + 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, + 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 601ccc9d3..02cfcd36a 100644 --- a/tests/test_random.py +++ b/tests/test_random.py @@ -12,6 +12,7 @@ import unittest import random +import math import rustworkx @@ -224,6 +225,60 @@ 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.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.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.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.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.hyperbolic_random_graph([], 1.0, None) + + def test_hyperbolic_random_different_dim_pos(self): + with self.assertRaises(ValueError): + rustworkx.hyperbolic_random_graph([[1, 0, 0], [1, 0, 0, 0]], 1.0, None) + + def test_hyperbolic_random_outofbounds_first_dim(self): + with self.assertRaises(ValueError): + 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.hyperbolic_random_graph([[1, 0, 0], [1, 0, 0]], -1.0, None) + + def test_hyperbolic_random_neg_beta(self): + with self.assertRaises(ValueError): + rustworkx.hyperbolic_random_graph([[1, 0, 0], [1, 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)