Skip to content
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
2 changes: 2 additions & 0 deletions docs/source/api/generators/xgi.generators.random.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,7 @@ xgi.generators.random
.. autofunction:: chung_lu_hypergraph
.. autofunction:: dcsbm_hypergraph
.. autofunction:: fast_random_hypergraph
.. autofunction:: random_nested_hypergraph
.. autofunction:: random_hypergraph
.. autofunction:: simplicial_chung_lu_hypergraph
.. autofunction:: watts_strogatz_hypergraph
49 changes: 49 additions & 0 deletions tests/generators/test_random.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,55 @@ def test_fast_random_hypergraph():
xgi.fast_random_hypergraph(10, [-0.1, 1])


def test_simplicial_chung_lu_hypergraph():
k1 = {i: random.randint(1, 10) for i in range(20)}
k2 = {i: sorted(k1.values())[i] for i in range(20)}

H = xgi.simplicial_chung_lu_hypergraph(k1, k2, p=0.5)
assert H.num_nodes == 20

# seed reproducibility
H1 = xgi.simplicial_chung_lu_hypergraph(
k1, k2, p=0.5, seed=np.random.default_rng(2)
)
H2 = xgi.simplicial_chung_lu_hypergraph(
k1, k2, p=0.5, seed=np.random.default_rng(2)
)
assert H1._edge == H2._edge

# warning on mismatched degree/size sums
with pytest.warns(Warning):
_ = xgi.simplicial_chung_lu_hypergraph({0: 1, 1: 2}, {0: 2, 1: 2}, p=0.5)

# invalid p
with pytest.raises(ValueError):
xgi.simplicial_chung_lu_hypergraph(k1, k2, p=-0.1)
with pytest.raises(ValueError):
xgi.simplicial_chung_lu_hypergraph(k1, k2, p=1.5)


def test_random_nested_hypergraph():
H = xgi.random_nested_hypergraph(20, 5, 4, [0.8, 0.5])
assert H.num_nodes == 20

# edge sizes should be between 2 and d
for edge in H.edges.members():
assert 2 <= len(edge) <= 4

# scalar epsilon
H = xgi.random_nested_hypergraph(20, 5, 4, 0.8)
assert H.num_nodes == 20

# seed reproducibility
H1 = xgi.random_nested_hypergraph(
20, 5, 4, [0.8, 0.5], seed=np.random.default_rng(2)
)
H2 = xgi.random_nested_hypergraph(
20, 5, 4, [0.8, 0.5], seed=np.random.default_rng(2)
)
assert H1._edge == H2._edge


def test_pr_655():
import warnings

Expand Down
2 changes: 2 additions & 0 deletions xgi/generators/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@
"random_hypergraph",
"fast_random_hypergraph",
"chung_lu_hypergraph",
"simplicial_chung_lu_hypergraph",
"dcsbm_hypergraph",
"random_nested_hypergraph",
"node_swap",
"shuffle_hyperedges",
"flag_complex",
Expand Down
214 changes: 213 additions & 1 deletion xgi/generators/random.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import numpy as np
from scipy.special import comb

from ..utils import geometric
from ..utils import geometric, powerset
from .classic import empty_hypergraph
from .lattice import ring_lattice
from .uniform import _index_to_edge_comb
Expand All @@ -17,8 +17,10 @@
"fast_random_hypergraph",
"random_hypergraph",
"chung_lu_hypergraph",
"simplicial_chung_lu_hypergraph",
"dcsbm_hypergraph",
"watts_strogatz_hypergraph",
"random_nested_hypergraph",
]


Expand Down Expand Up @@ -463,3 +465,213 @@ def watts_strogatz_hypergraph(n, d, k, l, p, seed=None):
H.remove_edges_from(to_remove)
H.add_edges_from(to_add)
return H


def simplicial_chung_lu_hypergraph(k1, k2, p, seed=None):
"""A function to generate a simplicial Chung-Lu hypergraph.

Parameters
----------
k1 : dictionary
Dictionary where the keys are node ids
and the values are node degrees.
k2 : dictionary
Dictionary where the keys are edge ids
and the values are edge sizes.
p : float
Probability (between 0 and 1) of generating a simplicial edge
instead of a Chung-Lu edge. Controls the amount of nestedness.
seed : int, numpy.random.Generator, or None, optional
The seed for the random number generator. By default, None.

Returns
-------
Hypergraph object
The generated hypergraph

Warns
-----
warnings.warn
If the sums of the edge sizes and node degrees are not equal, the
algorithm still runs, but raises a warning.

Notes
-----
The sums of k1 and k2 should be the same. If they are not the same,
this function returns a warning but still runs.

References
----------
Jordan Barrett, Paweł Prałat, Aaron Smith, and François Théberge,
"Counting simplicial pairs in hypergraphs",
J. Complex Netw. **13**, cnaf021 (2025).
https://doi.org/10.1093/comnet/cnaf021

See Also
--------
chung_lu_hypergraph

Example
-------
>>> import xgi
>>> import numpy as np
>>> n = 50
>>> rng = np.random.default_rng(0)
>>> k1 = {i : rng.integers(1, 11) for i in range(n)}
>>> k2 = {i : sorted(k1.values())[i] for i in range(n)}
>>> H = xgi.simplicial_chung_lu_hypergraph(k1, k2, p=0.5)

"""
if not 0 <= p <= 1:
raise ValueError("p must be between 0 and 1.")
rng = np.random.default_rng(seed)

if sum(k1.values()) != sum(k2.values()):
warnings.warn(
"The sum of the degree sequence does not match the sum of the size sequence"
)

node_labels = np.array(list(k1.keys()))
degrees = np.array([k1[v] for v in node_labels], dtype=float)
S = degrees.sum()
if S == 0:
H = empty_hypergraph()
H.add_nodes_from(k1.keys())
return H
node_probs = degrees / S

# Build the edge-size sequence in random order.
size_sequence = rng.permutation(list(k2.values())).tolist()

edges = [] # list of frozensets

for k in size_sequence:
if rng.random() < p:
# Generate a simplicial edge.
edges_not_k = [e for e in edges if len(e) != k]

if not edges_not_k:
# No edges of different size exist → plain Chung-Lu edge
e_new = frozenset(rng.choice(node_labels, size=k, replace=True, p=node_probs))
else:
# Sample an existing edge of a different size.
e_prime = edges_not_k[rng.integers(len(edges_not_k))]
if len(e_prime) > k:
# Take a random subset of the sampled edge.
e_new = frozenset(rng.choice(list(e_prime), size=k, replace=False))
else:
# Extend the sampled edge with additional Chung-Lu nodes.
extra = frozenset(
rng.choice(node_labels, size=k - len(e_prime), replace=True, p=node_probs)
)
e_new = e_prime | extra
else:
# Generate a plain Chung-Lu edge.
e_new = frozenset(rng.choice(node_labels, size=k, replace=True, p=node_probs))

edges.append(e_new)

H = empty_hypergraph()
H.add_nodes_from(k1.keys())
H.add_edges_from(edges)
return H


def random_nested_hypergraph(n, m, d, epsilon, seed=None):
"""A function to generate a random nested hypergraph.

Parameters
----------
n : int
Number of nodes.
m : int
Number of facets.
d : int
Size of each facet.
epsilon : float or list of float
Retention probability. If a float, the same retention probability
is used for all sub-facet sizes. If a list, ``epsilon[i]`` is the
retention probability for hyperedges of size ``i+2``, for
``i = 0, ..., d-3``. Hyperedges of size ``t`` are rewired with
probability ``1 - epsilon[t-2]``.
seed : int, numpy.random.Generator, or None, optional
The seed for the random number generator. By default, None.

Returns
-------
Hypergraph object
The generated hypergraph

Notes
-----
The algorithm proceeds as follows:

1. Generate m facets of size d by uniform random node assignment,
rejecting exact duplicate facets.
2. For each facet, enumerate all proper subsets of sizes 2 through
d − 1 (singletons excluded). Together with the facets
themselves, these form the initial edge set. Duplicates across
facets are removed.
3. For each hyperedge of size t < d: with probability
1 − ``epsilon[t-2]``, rewire the edge by picking one pivot node
uniformly at random from the edge and replacing the other t − 1
nodes with nodes sampled uniformly from {0, …, n − 1}.

The nestedness is controlled primarily by ``epsilon``: larger values
preserve more induced subedges and therefore produce more nested
hypergraphs, with ``epsilon=1`` yielding a fully nested construction
and ``epsilon=0`` rewiring every non-facet edge. If ``epsilon`` is a
list, it tunes nestedness separately for each subedge size.

References
----------
Jihye Kim, Deok-Sun Lee, and K-I. Goh,
"Contagion dynamics on hypergraphs with nested hyperedges",
Phys. Rev. E **108**, 034313 (2023).
https://doi.org/10.1103/PhysRevE.108.034313

Example
-------
>>> import xgi
>>> H = xgi.random_nested_hypergraph(20, 5, 4, 0.8)

"""
if isinstance(epsilon, (int, float)):
epsilon = [epsilon] * (d - 2)
rng = np.random.default_rng(seed)

nodes = range(n)

# Step 1: Generate m unique facets of size d
facets = set()
while len(facets) < m:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This may go into an infinite loop if m is large and d large compared to N right?
If so, add some checks earlier on and throw an error to prevent this

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

That's fair! Actual condition is m > (n choose d).

How much optimization is worth it to the library?
I could implement sampling non-edges if m > (n choose d) // 2, which would give good performance to this algorithm in the sparse and dense regimes. Maybe there's another quick win for m \approx (n choose d) // 2.

facet = frozenset(rng.choice(nodes, size=d, replace=False))
facets.add(facet)

# Step 2: For each facet, enumerate all subsets of sizes 2..d
# (facets themselves are included as edges; singletons excluded)
all_edges = set()
for facet in facets:
all_edges.update(
frozenset(subset)
for subset in powerset(facet, include_singletons=False, include_full=True)
)

# Step 3: Rewire hyperedges of size t < d
final_edges = set()
for e in all_edges:
t = len(e)
if t < d:
eps_t = epsilon[t - 2]
if rng.random() > eps_t:
# Rewire: pick one pivot, replace the rest
pivot = rng.choice(list(e))
others = [v for v in nodes if v != pivot]
new_nodes = rng.choice(others, size=t - 1, replace=False)
e = frozenset([pivot, *new_nodes])
final_edges.add(e)

H = empty_hypergraph()
H.add_nodes_from(nodes)
H.add_edges_from(final_edges)
return H
Loading