diff --git a/codecov.yml b/codecov.yml index 2b46e0e13..1ed2a6b0b 100644 --- a/codecov.yml +++ b/codecov.yml @@ -8,7 +8,5 @@ coverage: # basic target: auto threshold: 0.1 - patch: - default: - # basic - target: 0 \ No newline at end of file + removed_code_behavior: removals_only # off, removals_only, adjust_base + patch: off \ No newline at end of file diff --git a/docs/source/api/generators.rst b/docs/source/api/generators.rst index 818e6e78c..aa46ae120 100644 --- a/docs/source/api/generators.rst +++ b/docs/source/api/generators.rst @@ -8,5 +8,9 @@ generators package :toctree: generators ~xgi.generators.classic - ~xgi.generators.nonuniform - ~xgi.generators.uniform \ No newline at end of file + ~xgi.generators.simple + ~xgi.generators.lattice + ~xgi.generators.random + ~xgi.generators.uniform + ~xgi.generators.simplicial_complexes + \ No newline at end of file diff --git a/docs/source/api/generators/xgi.generators.classic.rst b/docs/source/api/generators/xgi.generators.classic.rst index ec7617b74..2a142f53e 100644 --- a/docs/source/api/generators/xgi.generators.classic.rst +++ b/docs/source/api/generators/xgi.generators.classic.rst @@ -8,9 +8,4 @@ .. rubric:: Functions .. autofunction:: empty_hypergraph - .. autofunction:: empty_simplicial_complex - .. autofunction:: flag_complex - .. autofunction:: flag_complex_d2 - .. autofunction:: star_clique - .. autofunction:: sunflower - .. autofunction:: ring_lattice \ No newline at end of file + .. autofunction:: empty_simplicial_complex \ No newline at end of file diff --git a/docs/source/api/generators/xgi.generators.lattice.rst b/docs/source/api/generators/xgi.generators.lattice.rst new file mode 100644 index 000000000..a503d9a62 --- /dev/null +++ b/docs/source/api/generators/xgi.generators.lattice.rst @@ -0,0 +1,10 @@ +xgi.generators.lattice +====================== + +.. currentmodule:: xgi.generators.lattice + +.. automodule:: xgi.generators.lattice + + .. rubric:: Functions + + .. autofunction:: ring_lattice \ No newline at end of file diff --git a/docs/source/api/generators/xgi.generators.nonuniform.rst b/docs/source/api/generators/xgi.generators.nonuniform.rst deleted file mode 100644 index 17bdeb682..000000000 --- a/docs/source/api/generators/xgi.generators.nonuniform.rst +++ /dev/null @@ -1,15 +0,0 @@ -xgi.generators.nonuniform -========================= - -.. currentmodule:: xgi.generators.nonuniform - -.. automodule:: xgi.generators.nonuniform - - .. rubric:: Functions - - .. autofunction:: chung_lu_hypergraph - .. autofunction:: dcsbm_hypergraph - .. autofunction:: random_flag_complex - .. autofunction:: random_flag_complex_d2 - .. autofunction:: random_hypergraph - .. autofunction:: random_simplicial_complex \ No newline at end of file diff --git a/docs/source/api/generators/xgi.generators.random.rst b/docs/source/api/generators/xgi.generators.random.rst new file mode 100644 index 000000000..4943b6bbc --- /dev/null +++ b/docs/source/api/generators/xgi.generators.random.rst @@ -0,0 +1,13 @@ +xgi.generators.random +===================== + +.. currentmodule:: xgi.generators.random + +.. automodule:: xgi.generators.random + + .. rubric:: Functions + + .. autofunction:: chung_lu_hypergraph + .. autofunction:: dcsbm_hypergraph + .. autofunction:: random_hypergraph + .. autofunction:: watts_strogatz_hypergraph \ No newline at end of file diff --git a/docs/source/api/generators/xgi.generators.simple.rst b/docs/source/api/generators/xgi.generators.simple.rst new file mode 100644 index 000000000..cd0771c6f --- /dev/null +++ b/docs/source/api/generators/xgi.generators.simple.rst @@ -0,0 +1,11 @@ +xgi.generators.simple +===================== + +.. currentmodule:: xgi.generators.simple + +.. automodule:: xgi.generators.simple + + .. rubric:: Functions + + .. autofunction:: star_clique + .. autofunction:: sunflower \ No newline at end of file diff --git a/docs/source/api/generators/xgi.generators.simplicial_complexes.rst b/docs/source/api/generators/xgi.generators.simplicial_complexes.rst new file mode 100644 index 000000000..31236a9e8 --- /dev/null +++ b/docs/source/api/generators/xgi.generators.simplicial_complexes.rst @@ -0,0 +1,14 @@ +xgi.generators.simplicial_complex +================================= + +.. currentmodule:: xgi.generators.simplicial_complex + +.. automodule:: xgi.generators.simplicial_complex + + .. rubric:: Functions + + .. autofunction:: flag_complex + .. autofunction:: flag_complex_d2 + .. autofunction:: random_flag_complex + .. autofunction:: random_flag_complex_d2 + .. autofunction:: random_simplicial_complex \ No newline at end of file diff --git a/docs/source/api/linalg.rst b/docs/source/api/linalg.rst index c3e835ded..1fbfb9d13 100644 --- a/docs/source/api/linalg.rst +++ b/docs/source/api/linalg.rst @@ -7,4 +7,6 @@ linalg package .. autosummary:: :toctree: linalg - ~xgi.linalg.matrix \ No newline at end of file + ~xgi.linalg.hypergraph_matrix + ~xgi.linalg.laplacian_matrix + ~xgi.linalg.hodge_matrix \ No newline at end of file diff --git a/docs/source/api/linalg/xgi.linalg.hodge_matrix.rst b/docs/source/api/linalg/xgi.linalg.hodge_matrix.rst new file mode 100644 index 000000000..34f2c8931 --- /dev/null +++ b/docs/source/api/linalg/xgi.linalg.hodge_matrix.rst @@ -0,0 +1,11 @@ +xgi.linalg.hodge_matrix +======================= + +.. currentmodule:: xgi.linalg.hodge_matrix + +.. automodule:: xgi.linalg.hodge_matrix + + .. rubric:: Functions + + .. autofunction:: boundary_matrix + .. autofunction:: hodge_laplacian \ No newline at end of file diff --git a/docs/source/api/linalg/xgi.linalg.hypergraph_matrix.rst b/docs/source/api/linalg/xgi.linalg.hypergraph_matrix.rst new file mode 100644 index 000000000..d63bb016e --- /dev/null +++ b/docs/source/api/linalg/xgi.linalg.hypergraph_matrix.rst @@ -0,0 +1,14 @@ +xgi.linalg.hypergraph_matrix +============================ + +.. currentmodule:: xgi.linalg.hypergraph_matrix + +.. automodule:: xgi.linalg.hypergraph_matrix + + .. rubric:: Functions + + .. autofunction:: adjacency_matrix + .. autofunction:: clique_motif_matrix + .. autofunction:: degree_matrix + .. autofunction:: incidence_matrix + .. autofunction:: intersection_profile \ No newline at end of file diff --git a/docs/source/api/linalg/xgi.linalg.laplacian_matrix.rst b/docs/source/api/linalg/xgi.linalg.laplacian_matrix.rst new file mode 100644 index 000000000..a0efd3a2c --- /dev/null +++ b/docs/source/api/linalg/xgi.linalg.laplacian_matrix.rst @@ -0,0 +1,11 @@ +xgi.linalg.laplacian_matrix +=========================== + +.. currentmodule:: xgi.linalg.laplacian_matrix + +.. automodule:: xgi.linalg.laplacian_matrix + + .. rubric:: Functions + + .. autofunction:: laplacian + .. autofunction:: normalized_hypergraph_laplacian \ No newline at end of file diff --git a/docs/source/api/linalg/xgi.linalg.matrix.rst b/docs/source/api/linalg/xgi.linalg.matrix.rst deleted file mode 100644 index 4ed805cfd..000000000 --- a/docs/source/api/linalg/xgi.linalg.matrix.rst +++ /dev/null @@ -1,19 +0,0 @@ -xgi.linalg.matrix -================= - -.. currentmodule:: xgi.linalg.matrix - -.. automodule:: xgi.linalg.matrix - - .. rubric:: Functions - - .. autofunction:: adjacency_matrix - .. autofunction:: clique_motif_matrix - .. autofunction:: degree_matrix - .. autofunction:: incidence_matrix - .. autofunction:: intersection_profile - .. autofunction:: laplacian - .. autofunction:: multiorder_laplacian - .. autofunction:: normalized_hypergraph_laplacian - .. autofunction:: boundary_matrix - .. autofunction:: hodge_laplacian \ No newline at end of file diff --git a/tests/generators/test_classic.py b/tests/generators/test_classic.py index 666a9609e..a4294b6bb 100644 --- a/tests/generators/test_classic.py +++ b/tests/generators/test_classic.py @@ -1,8 +1,4 @@ -import networkx as nx -import pytest - import xgi -from xgi.exception import XGIError def test_empty_hypergraph(): @@ -13,110 +9,3 @@ def test_empty_hypergraph(): def test_empty_hypergraph(): SC = xgi.empty_simplicial_complex() assert (SC.num_nodes, SC.num_edges) == (0, 0) - - -def test_star_clique(): - with pytest.raises(ValueError): - H = xgi.star_clique(-1, 7, 3) - with pytest.raises(ValueError): - H = xgi.star_clique(6, -1, 3) - with pytest.raises(ValueError): - H = xgi.star_clique(6, 7, -1) - with pytest.raises(ValueError): - H = xgi.star_clique(6, 7, 7) - - H = xgi.star_clique(6, 7, 3) - assert H.num_nodes == 13 - assert H.num_edges == 97 - assert xgi.max_edge_order(H) == 3 - - -def test_flag_complex(): - edges = [[0, 1], [1, 2], [2, 0], [0, 3]] - G = nx.Graph(edges) - - S = xgi.flag_complex(G) - - simplices_2 = [ - frozenset({0, 1}), - frozenset({0, 2}), - frozenset({0, 3}), - frozenset({1, 2}), - ] - - simplices_3 = simplices_2 + [frozenset({0, 1, 2})] - - assert S.edges.members() == simplices_3 - - S1 = xgi.flag_complex(G, ps=[1], seed=42) - S2 = xgi.flag_complex(G, ps=[0.5], seed=42) - S3 = xgi.flag_complex(G, ps=[0], seed=42) - - assert S1.edges.members() == simplices_3 - assert S2.edges.members() == simplices_2 - assert S3.edges.members() == simplices_2 - - G1 = nx.complete_graph(4) - S4 = xgi.flag_complex(G1) - S5 = xgi.flag_complex(G1, ps=[1]) - assert S4.num_nodes == S5.num_nodes - assert S4.num_edges == S5.num_edges - assert set(S4.edges.members()) == set(S5.edges.members()) - - -def test_flag_complex_d2(): - G = nx.erdos_renyi_graph(15, 0.3, seed=3) - - S = xgi.flag_complex(G, max_order=2) - S2 = xgi.flag_complex_d2(G) - - assert set(S.edges.members()) == set(S2.edges.members()) - - -def test_ring_lattice(): - H = xgi.ring_lattice(5, 2, 2, 0) - assert H.num_nodes == 5 - assert H.num_edges == 5 - assert xgi.unique_edge_sizes(H) == [2] - - H = xgi.ring_lattice(5, 3, 4, 1) - edges = H.edges.members() - for i in range(H.num_edges - 1): - assert len(set(edges[i]).intersection(set(edges[i + 1]))) == 2 # d-l - assert xgi.unique_edge_sizes(H) == [3] - - # k < 2 test - with pytest.warns(Warning): - H = xgi.ring_lattice(5, 2, 1, 0) - assert H.num_nodes == 5 - assert H.num_edges == 0 - - # k % 2 != 0 test - with pytest.warns(Warning): - xgi.ring_lattice(5, 2, 3, 0) - - # k < 0 test - with pytest.raises(XGIError): - xgi.ring_lattice(5, 2, -1, 0) - - -def test_sunflower(): - with pytest.raises(XGIError): - H = xgi.sunflower(3, 4, 2) - - H = xgi.sunflower(3, 1, 5) - - assert H.nodes.memberships(0) == {0, 1, 2} - assert set(H.nodes) == set(range(13)) - assert H.num_edges == 3 - for n in range(1, H.num_nodes): - assert len(H.nodes.memberships(n)) == 1 - - H = xgi.sunflower(4, 3, 6) - for i in range(3): - H.nodes.memberships(i) == {0, 1, 2, 3} - - assert H.num_nodes == 15 - - for i in range(3, 15): - assert len(H.nodes.memberships(i)) == 1 diff --git a/tests/generators/test_lattice.py b/tests/generators/test_lattice.py new file mode 100644 index 000000000..5f865c190 --- /dev/null +++ b/tests/generators/test_lattice.py @@ -0,0 +1,31 @@ +import pytest + +import xgi +from xgi.exception import XGIError + + +def test_ring_lattice(): + H = xgi.ring_lattice(5, 2, 2, 0) + assert H.num_nodes == 5 + assert H.num_edges == 5 + assert xgi.unique_edge_sizes(H) == [2] + + H = xgi.ring_lattice(5, 3, 4, 1) + edges = H.edges.members() + for i in range(H.num_edges - 1): + assert len(set(edges[i]).intersection(set(edges[i + 1]))) == 2 # d-l + assert xgi.unique_edge_sizes(H) == [3] + + # k < 2 test + with pytest.warns(Warning): + H = xgi.ring_lattice(5, 2, 1, 0) + assert H.num_nodes == 5 + assert H.num_edges == 0 + + # k % 2 != 0 test + with pytest.warns(Warning): + xgi.ring_lattice(5, 2, 3, 0) + + # k < 0 test + with pytest.raises(XGIError): + xgi.ring_lattice(5, 2, -1, 0) diff --git a/tests/generators/test_nonuniform.py b/tests/generators/test_nonuniform.py index 803b43534..d2d4787a6 100644 --- a/tests/generators/test_nonuniform.py +++ b/tests/generators/test_nonuniform.py @@ -47,19 +47,27 @@ def test_dcsbm_hypergraph(): def test_random_hypergraph(): # seed - H1 = xgi.random_hypergraph(10, [0.1, 0.001], seed=1) - H2 = xgi.random_hypergraph(10, [0.1, 0.001], seed=2) - H3 = xgi.random_hypergraph(10, [0.1, 0.001], seed=2) + H1 = xgi.random_hypergraph(10, [0.1, 0.01], seed=1) + H2 = xgi.random_hypergraph(10, [0.1, 0.01], seed=2) + H3 = xgi.random_hypergraph(10, [0.1, 0.01], seed=2) assert H1._edge != H2._edge assert H2._edge == H3._edge + assert H1.num_nodes == 10 + assert xgi.unique_edge_sizes(H1) == [2, 3] + # wrong input with pytest.raises(ValueError): H1 = xgi.random_hypergraph(10, [1, 1.1]) with pytest.raises(ValueError): H1 = xgi.random_hypergraph(10, [1, -2]) + # uniform + H4 = xgi.random_hypergraph(10, [0.1], order=2, seed=1) + assert H4.num_nodes == 10 + assert xgi.unique_edge_sizes(H4) == [3] + def test_random_simplicial_complex(): # seed diff --git a/tests/generators/test_simple.py b/tests/generators/test_simple.py new file mode 100644 index 000000000..cc401c889 --- /dev/null +++ b/tests/generators/test_simple.py @@ -0,0 +1,42 @@ +import pytest + +import xgi +from xgi.exception import XGIError + + +def test_star_clique(): + with pytest.raises(ValueError): + H = xgi.star_clique(-1, 7, 3) + with pytest.raises(ValueError): + H = xgi.star_clique(6, -1, 3) + with pytest.raises(ValueError): + H = xgi.star_clique(6, 7, -1) + with pytest.raises(ValueError): + H = xgi.star_clique(6, 7, 7) + + H = xgi.star_clique(6, 7, 3) + assert H.num_nodes == 13 + assert H.num_edges == 97 + assert xgi.max_edge_order(H) == 3 + + +def test_sunflower(): + with pytest.raises(XGIError): + H = xgi.sunflower(3, 4, 2) + + H = xgi.sunflower(3, 1, 5) + + assert H.nodes.memberships(0) == {0, 1, 2} + assert set(H.nodes) == set(range(13)) + assert H.num_edges == 3 + for n in range(1, H.num_nodes): + assert len(H.nodes.memberships(n)) == 1 + + H = xgi.sunflower(4, 3, 6) + for i in range(3): + H.nodes.memberships(i) == {0, 1, 2, 3} + + assert H.num_nodes == 15 + + for i in range(3, 15): + assert len(H.nodes.memberships(i)) == 1 diff --git a/tests/generators/test_simplicial_complexes.py b/tests/generators/test_simplicial_complexes.py new file mode 100644 index 000000000..94f8aff7c --- /dev/null +++ b/tests/generators/test_simplicial_complexes.py @@ -0,0 +1,46 @@ +import networkx as nx + +import xgi +from xgi.exception import XGIError + + +def test_flag_complex(): + edges = [[0, 1], [1, 2], [2, 0], [0, 3]] + G = nx.Graph(edges) + + S = xgi.flag_complex(G) + + simplices_2 = [ + frozenset({0, 1}), + frozenset({0, 2}), + frozenset({0, 3}), + frozenset({1, 2}), + ] + + simplices_3 = simplices_2 + [frozenset({0, 1, 2})] + + assert S.edges.members() == simplices_3 + + S1 = xgi.flag_complex(G, ps=[1], seed=42) + S2 = xgi.flag_complex(G, ps=[0.5], seed=42) + S3 = xgi.flag_complex(G, ps=[0], seed=42) + + assert S1.edges.members() == simplices_3 + assert S2.edges.members() == simplices_2 + assert S3.edges.members() == simplices_2 + + G1 = nx.complete_graph(4) + S4 = xgi.flag_complex(G1) + S5 = xgi.flag_complex(G1, ps=[1]) + assert S4.num_nodes == S5.num_nodes + assert S4.num_edges == S5.num_edges + assert set(S4.edges.members()) == set(S5.edges.members()) + + +def test_flag_complex_d2(): + G = nx.erdos_renyi_graph(15, 0.3, seed=3) + + S = xgi.flag_complex(G, max_order=2) + S2 = xgi.flag_complex_d2(G) + + assert set(S.edges.members()) == set(S2.edges.members()) diff --git a/tests/generators/test_uniform.py b/tests/generators/test_uniform.py index 8554d4c31..1aa1fe005 100644 --- a/tests/generators/test_uniform.py +++ b/tests/generators/test_uniform.py @@ -180,4 +180,4 @@ def test_uniform_erdos_renyi_hypergraph(): m = 2 n = 10 k = 2 - xgi.uniform_erdos_renyi_hypergraph(n, m, k, p_type="test") + xgi.uniform_erdos_renyi_hypergraph(n, m, k, p_type="test") \ No newline at end of file diff --git a/xgi/drawing/draw.py b/xgi/drawing/draw.py index db718835d..df55c36e4 100644 --- a/xgi/drawing/draw.py +++ b/xgi/drawing/draw.py @@ -48,10 +48,12 @@ def draw( Parameters ---- H : Hypergraph or SimplicialComplex. + Hypergraph to draw pos : dict (default=None) If passed, this dictionary of positions node_id:(x,y) is used for placing the 0-simplices. If None (default), use the `barycenter_spring_layout` to compute the positions. ax : matplotlib.pyplot.axes (default=None) + Axis to draw on dyad_color : str, dict, iterable, or EdgeStat (default='black') Color of the dyadic links. If str, use the same color for all edges. If a dict, must contain (edge_id: color_str) pairs. If iterable, assume the colors are @@ -222,6 +224,7 @@ def draw_nodes( H : Hypergraph or SimplicialComplex Higher-order network to plot. ax : matplotlib.pyplot.axes + Axis to draw on pos : dict (default=None) If passed, this dictionary of positions node_id:(x,y) is used for placing the 0-simplices. If None (default), use the `barycenter_spring_layout` to compute the positions. @@ -354,6 +357,7 @@ def draw_hyperedges( ---------- H : Hypergraph ax : matplotlib.pyplot.axes + Axis to draw on pos : dict (default=None) If passed, this dictionary of positions node_id:(x,y) is used for placing the 0-simplices. If None (default), use the `barycenter_spring_layout` to compute the positions. @@ -498,7 +502,9 @@ def draw_simplices( Parameters ---------- SC : SimplicialComplex + Simplicial complex to draw ax : matplotlib.pyplot.axes + Axis to draw on pos : dict (default=None) If passed, this dictionary of positions node_id:(x,y) is used for placing the 0-simplices. If None (default), use the `barycenter_spring_layout` to compute the positions. diff --git a/xgi/dynamics/synchronization.py b/xgi/dynamics/synchronization.py index e6921479d..6c6349794 100644 --- a/xgi/dynamics/synchronization.py +++ b/xgi/dynamics/synchronization.py @@ -5,6 +5,7 @@ import xgi from ..exception import XGIError +from ..linalg.hodge_matrix import boundary_matrix __all__ = [ "simulate_kuramoto", @@ -208,17 +209,15 @@ def simulate_simplicial_kuramoto( ) if index: - B_o, om1_dict, o_dict = xgi.matrix.boundary_matrix(S, order, orientations, True) + B_o, om1_dict, o_dict = boundary_matrix(S, order, orientations, True) else: - B_o = xgi.matrix.boundary_matrix(S, order, orientations, False) + B_o = boundary_matrix(S, order, orientations, False) D_om1 = np.transpose(B_o) if index: - B_op1, __, op1_dict = xgi.matrix.boundary_matrix( - S, order + 1, orientations, True - ) + B_op1, __, op1_dict = boundary_matrix(S, order + 1, orientations, True) else: - B_op1 = xgi.matrix.boundary_matrix(S, order + 1, orientations, False) + B_op1 = boundary_matrix(S, order + 1, orientations, False) D_o = np.transpose(B_op1) # Compute the number of oscillating simplices diff --git a/xgi/generators/__init__.py b/xgi/generators/__init__.py index 69a4f5ba7..9fa1dacac 100644 --- a/xgi/generators/__init__.py +++ b/xgi/generators/__init__.py @@ -1,4 +1,7 @@ -from . import classic, nonuniform, uniform +from . import classic, uniform, random, simple, lattice, simplicial_complexes from .classic import * -from .nonuniform import * +from .random import * from .uniform import * +from .simple import * +from .lattice import * +from .simplicial_complexes import * diff --git a/xgi/generators/classic.py b/xgi/generators/classic.py index 168b97e09..2d2741293 100644 --- a/xgi/generators/classic.py +++ b/xgi/generators/classic.py @@ -5,25 +5,9 @@ """ -import random -from collections import defaultdict -from itertools import combinations -from warnings import warn - -import networkx as nx - -from ..classes.function import subfaces -from ..exception import XGIError -from ..utils.utilities import find_triangles - __all__ = [ "empty_hypergraph", "empty_simplicial_complex", - "star_clique", - "flag_complex", - "flag_complex_d2", - "sunflower", - "ring_lattice", ] @@ -114,282 +98,3 @@ def empty_simplicial_complex(create_using=None, default=None): if default is None: default = xgi.SimplicialComplex return _empty_network(create_using, default) - - -def star_clique(n_star, n_clique, d_max): - """Generate a star-clique structure - - That is a star network and a clique network, - connected by one pairwise edge connecting the centre of the star to the clique. - network, the each clique is promoted to a hyperedge - up to order d_max. - - Parameters - ---------- - n_star : int - Number of legs of the star - n_clique : int - Number of nodes in the clique - d_max : int - Maximum order up to which to promote - cliques to hyperedges - - Returns - ------- - H : Hypergraph - - Examples - -------- - >>> import xgi - >>> H = xgi.star_clique(6, 7, 2) - - Notes - ----- - The total number of nodes is n_star + n_clique. - - """ - - if n_star <= 0: - raise ValueError("n_star must be an integer > 0.") - if n_clique <= 0: - raise ValueError("n_clique must be an integer > 0.") - if d_max < 0: - raise ValueError("d_max must be an integer >= 0.") - elif d_max > n_clique - 1: - raise ValueError("d_max must be <= n_clique - 1.") - - nodes_star = range(n_star) - nodes_clique = range(n_star, n_star + n_clique) - nodes = list(nodes_star) + list(nodes_clique) - - H = empty_hypergraph() - H.add_nodes_from(nodes) - - # add star edges (center of the star is 0-th node) - H.add_edges_from([(nodes_star[0], nodes_star[i]) for i in range(1, n_star)]) - - # connect clique and star by adding last star leg - H.add_edge((nodes_star[0], nodes_clique[0])) - - # add clique hyperedges up to order d_max - H.add_edges_from( - [e for d in range(1, d_max + 1) for e in combinations(nodes_clique, d + 1)] - ) - - return H - - -def flag_complex(G, max_order=2, ps=None, seed=None): - """Generate a flag (or clique) complex from a - NetworkX graph by filling all cliques up to dimension max_order. - - Parameters - ---------- - G : Networkx Graph - - max_order : int - maximal dimension of simplices to add to the output simplicial complex - ps: list of float - List of probabilities (between 0 and 1) to create a - hyperedge from a clique, at each order d. For example, - ps[0] is the probability of promoting any 3-node clique (triangle) to - a 3-hyperedge. - seed: int or None (default) - The seed for the random number generator - - Returns - ------- - S : SimplicialComplex - - Notes - ----- - Computing all cliques quickly becomes heavy for large networks. `flag_complex_d2` - is faster to compute up to order 2. - - See also - -------- - flag_complex_d2 - - """ - # This import needs to happen when this function is called, not when it is - # defined. Otherwise, a circular import error would happen. - from ..classes import SimplicialComplex - - if seed is not None: - random.seed(seed) - - nodes = G.nodes() - edges = G.edges() - - # compute all maximal cliques to fill - max_cliques = list(nx.find_cliques(G)) - - S = SimplicialComplex() - S.add_nodes_from(nodes) - S.add_simplices_from(edges) - if not ps: # promote all cliques - S.add_simplices_from(max_cliques, max_order=max_order) - return S - - if max_order: # compute subfaces of order max_order (allowed max cliques) - max_cliques_to_add = subfaces(max_cliques, order=max_order) - else: - max_cliques_to_add = max_cliques - - # store max cliques per order - cliques_d = defaultdict(list) - for x in max_cliques_to_add: - cliques_d[len(x)].append(x) - - # promote cliques with a given probability - for i, p in enumerate(ps[: max_order - 1]): - d = i + 2 # simplex order - cliques_d_to_add = [el for el in cliques_d[d + 1] if random.random() <= p] - S.add_simplices_from(cliques_d_to_add, max_order=max_order) - - return S - - -def flag_complex_d2(G, p2=None, seed=None): - """Generate a flag (or clique) complex from a - NetworkX graph by filling all cliques up to dimension 2. - - Parameters - ---------- - G : networkx Graph - Graph to consider - p2: float - Probability (between 0 and 1) of filling empty triangles in graph G - seed: int or None (default) - The seed for the random number generator - - Returns - ------- - S : xgi.SimplicialComplex - - Notes - ----- - Computing all cliques quickly becomes heavy for large networks. This - is faster than `flag_complex` to compute up to order 2. - - See also - -------- - flag_complex - """ - # This import needs to happen when this function is called, not when it is - # defined. Otherwise, a circular import error would happen. - from ..classes import SimplicialComplex - - if seed is not None: - random.seed(seed) - - nodes = G.nodes() - edges = G.edges() - - S = SimplicialComplex() - S.add_nodes_from(nodes) - S.add_simplices_from(edges) - - triangles_empty = find_triangles(G) - - if p2 is not None: - triangles = [el for el in triangles_empty if random.random() <= p2] - else: - triangles = triangles_empty - - S.add_simplices_from(triangles) - - return S - - -def ring_lattice(n, d, k, l): - """A ring lattice hypergraph. - - A d-uniform hypergraph on n nodes where each node is part of k edges and the - overlap between consecutive edges is d-l. - - Parameters - ---------- - n : int - Number of nodes - d : int - Edge size - k : int - Number of edges of which a node is a part. Should be a multiple of 2. - l : int - Overlap between edges - - Returns - ------- - Hypergraph - The generated hypergraph - - Raises - ------ - XGIError - If k is negative. - - Notes - ----- - ring_lattice(n, 2, k, 0) is a ring lattice graph where each node has k//2 edges on either - side. - """ - from ..classes import Hypergraph - - if k < 0: - raise XGIError("Invalid k value!") - - if k < 2: - warn("This creates a completely disconnected hypergraph!") - - if k % 2 != 0: - warn("k is not divisible by 2") - - edges = [ - [node] + [(start + l + i) % n for i in range(d - 1)] - for node in range(n) - for start in range(node + 1, node + k // 2 + 1) - ] - H = Hypergraph(edges) - H.add_nodes_from(range(n)) - return H - - -def sunflower(l, c, m): - """Create a sunflower hypergraph. - - This creates an m-uniform hypergraph - according to the sunflower model. - - Parameters - ---------- - l : int - Number of petals - c : int - Size of the core - m : int - Size of each edge - - Raises - ------ - XGIError - If the edge size is smaller than the core. - - Returns - ------- - - """ - from ..classes import Hypergraph - - if m < c: - raise XGIError("m cannot be smaller than c.") - - core_nodes = list(range(c)) - - H = Hypergraph() - start_label = c - while start_label + (m - c) <= c + (m - c) * l: - H.add_edge(core_nodes + [start_label + i for i in range(m - c)]) - start_label = start_label + (m - c) - - return H diff --git a/xgi/generators/lattice.py b/xgi/generators/lattice.py new file mode 100644 index 000000000..e34ff4dfe --- /dev/null +++ b/xgi/generators/lattice.py @@ -0,0 +1,67 @@ +"""Generators for some lattice hypergraphs. + +All the functions in this module return a Hypergraph class (i.e. a simple, undirected +hypergraph). + +""" + +from warnings import warn + +from ..exception import XGIError + +__all__ = [ + "ring_lattice", +] + + +def ring_lattice(n, d, k, l): + """A ring lattice hypergraph. + + A d-uniform hypergraph on n nodes where each node is part of k edges and the + overlap between consecutive edges is d-l. + + Parameters + ---------- + n : int + Number of nodes + d : int + Edge size + k : int + Number of edges of which a node is a part. Should be a multiple of 2. + l : int + Overlap between edges + + Returns + ------- + Hypergraph + The generated hypergraph + + Raises + ------ + XGIError + If k is negative. + + Notes + ----- + ring_lattice(n, 2, k, 0) is a ring lattice graph where each node has k//2 edges on either + side. + """ + from ..classes import Hypergraph + + if k < 0: + raise XGIError("Invalid k value!") + + if k < 2: + warn("This creates a completely disconnected hypergraph!") + + if k % 2 != 0: + warn("k is not divisible by 2") + + edges = [ + [node] + [(start + l + i) % n for i in range(d - 1)] + for node in range(n) + for start in range(node + 1, node + k // 2 + 1) + ] + H = Hypergraph(edges) + H.add_nodes_from(range(n)) + return H diff --git a/xgi/generators/nonuniform.py b/xgi/generators/random.py similarity index 70% rename from xgi/generators/nonuniform.py rename to xgi/generators/random.py index 7b41f4756..e83786c12 100644 --- a/xgi/generators/nonuniform.py +++ b/xgi/generators/random.py @@ -5,24 +5,91 @@ from collections import defaultdict from itertools import combinations -import networkx as nx import numpy as np from scipy.special import comb -from ..classes import SimplicialComplex -from .classic import empty_hypergraph, flag_complex_d2, ring_lattice +from .classic import empty_hypergraph +from .lattice import ring_lattice __all__ = [ + "random_hypergraph", "chung_lu_hypergraph", "dcsbm_hypergraph", - "random_hypergraph", - "random_simplicial_complex", - "random_flag_complex_d2", - "random_flag_complex", "watts_strogatz_hypergraph", ] +def random_hypergraph(N, ps, order=None, seed=None): + """Generates a random hypergraph + + Generate N nodes, and connect any d+1 nodes + by a hyperedge with probability ps[d-1]. + + Parameters + ---------- + N : int + Number of nodes + ps : list of float + List of probabilities (between 0 and 1) to create a + hyperedge at each order d between any d+1 nodes. For example, + ps[0] is the wiring probability of any edge (2 nodes), ps[1] + of any triangles (3 nodes). + order: int of None (default) + If None, ignore. If int, generates a uniform hypergraph with edges + of order `order` (ps must have only one element). + seed : integer or None (default) + Seed for the random number generator. + + Returns + ------- + Hypergraph object + The generated hypergraph + + References + ---------- + Described as 'random hypergraph' by M. Dewar et al. in https://arxiv.org/abs/1703.07686 + + Example + ------- + >>> import xgi + >>> H = xgi.random_hypergraph(50, [0.1, 0.01]) + + """ + if seed is not None: + np.random.seed(seed) + + if order is not None: + if len(ps) != 1: + raise ValueError("ps must contain a single element if order is an int") + + if (np.any(np.array(ps) < 0)) or (np.any(np.array(ps) > 1)): + raise ValueError("All elements of ps must be between 0 and 1 included.") + + nodes = range(N) + hyperedges = [] + + for i, p in enumerate(ps): + + if order is not None: + d = order + else: + d = i + 1 # order, ps[0] is prob of edges (d=1) + + potential_edges = combinations(nodes, d + 1) + n_comb = comb(N, d + 1, exact=True) + mask = np.random.random(size=n_comb) <= p # True if edge to keep + + edges_to_add = [e for e, val in zip(potential_edges, mask) if val] + + hyperedges += edges_to_add + + H = empty_hypergraph() + H.add_nodes_from(nodes) + H.add_edges_from(hyperedges) + + return H + + def chung_lu_hypergraph(k1, k2, seed=None): """A function to generate a Chung-Lu hypergraph @@ -114,7 +181,8 @@ def chung_lu_hypergraph(k1, k2, seed=None): def dcsbm_hypergraph(k1, k2, g1, g2, omega, seed=None): - """A function to generate a DCSBM hypergraph. + """A function to generate a Degree-Corrected Stochastic Block Model + (DCSBM) hypergraph. Parameters ---------- @@ -251,207 +319,6 @@ def dcsbm_hypergraph(k1, k2, g1, g2, omega, seed=None): return H -def random_hypergraph(N, ps, seed=None): - """Generates a random hypergraph - - Generate N nodes, and connect any d+1 nodes - by a hyperedge with probability ps[d-1]. - - Parameters - ---------- - N : int - Number of nodes - ps : list of float - List of probabilities (between 0 and 1) to create a - hyperedge at each order d between any d+1 nodes. For example, - ps[0] is the wiring probability of any edge (2 nodes), ps[1] - of any triangles (3 nodes). - seed : integer or None (default) - Seed for the random number generator. - - Returns - ------- - Hypergraph object - The generated hypergraph - - References - ---------- - Described as 'random hypergraph' by M. Dewar et al. in https://arxiv.org/abs/1703.07686 - - Example - ------- - >>> import xgi - >>> H = xgi.random_hypergraph(50, [0.1, 0.01]) - - """ - if seed is not None: - np.random.seed(seed) - - if (np.any(np.array(ps) < 0)) or (np.any(np.array(ps) > 1)): - raise ValueError("All elements of ps must be between 0 and 1 included.") - - nodes = range(N) - hyperedges = [] - - for i, p in enumerate(ps): - d = i + 1 # order, ps[0] is prob of edges (d=1) - - potential_edges = combinations(nodes, d + 1) - n_comb = comb(N, d + 1, exact=True) - mask = np.random.random(size=n_comb) <= p # True if edge to keep - - edges_to_add = [e for e, val in zip(potential_edges, mask) if val] - - hyperedges += edges_to_add - - H = empty_hypergraph() - H.add_nodes_from(nodes) - H.add_edges_from(hyperedges) - - return H - - -def random_simplicial_complex(N, ps, seed=None): - """Generates a random hypergraph - - Generate N nodes, and connect any d+1 nodes - by a simplex with probability ps[d-1]. For each simplex, - add all its subfaces if they do not already exist. - - Parameters - ---------- - N : int - Number of nodes - ps : list of float - List of probabilities (between 0 and 1) to create a - hyperedge at each order d between any d+1 nodes. For example, - ps[0] is the wiring probability of any edge (2 nodes), ps[1] - of any triangles (3 nodes). - seed : int or None (default) - The seed for the random number generator - - Returns - ------- - Simplicialcomplex object - The generated simplicial complex - - References - ---------- - Described as 'random simplicial complex' in - "Simplicial Models of Social Contagion", Nature Communications 10(1), 2485, - by I. Iacopini, G. Petri, A. Barrat & V. Latora (2019). - https://doi.org/10.1038/s41467-019-10431-6 - - Example - ------- - >>> import xgi - >>> H = xgi.random_simplicial_complex(20, [0.1, 0.01]) - - """ - - if seed is not None: - np.random.seed(seed) - - if (np.any(np.array(ps) < 0)) or (np.any(np.array(ps) > 1)): - raise ValueError("All elements of ps must be between 0 and 1 included.") - - nodes = range(N) - simplices = [] - - for i, p in enumerate(ps): - d = i + 1 # order, ps[0] is prob of edges (d=1) - - potential_simplices = combinations(nodes, d + 1) - n_comb = comb(N, d + 1, exact=True) - mask = np.random.random(size=n_comb) <= p # True if simplex to keep - - simplices_to_add = [e for e, val in zip(potential_simplices, mask) if val] - - simplices += simplices_to_add - - S = SimplicialComplex() - S.add_nodes_from(nodes) - S.add_simplices_from(simplices) - - return S - - -def random_flag_complex_d2(N, p, seed=None): - """Generate a maximal simplicial complex (up to order 2) from a - :math:`G_{N,p}` Erdős-Rényi random graph by filling all empty triangles with 2-simplices. - - Parameters - ---------- - N : int - Number of nodes - p : float - Probabilities (between 0 and 1) to create an edge - between any 2 nodes - seed : int or None (default) - The seed for the random number generator - - Returns - ------- - SimplicialComplex - - Notes - ----- - Computing all cliques quickly becomes heavy for large networks. - """ - if seed is not None: - random.seed(seed) - - if (p < 0) or (p > 1): - raise ValueError("p must be between 0 and 1 included.") - - G = nx.fast_gnp_random_graph(N, p, seed=seed) - - return flag_complex_d2(G) - - -def random_flag_complex(N, p, max_order=2, seed=None): - """Generate a flag (or clique) complex from a - :math:`G_{N,p}` Erdős-Rényi random graph by filling all cliques up to dimension max_order. - - Parameters - ---------- - N : int - Number of nodes - p : float - Probability (between 0 and 1) to create an edge - between any 2 nodes - max_order : int - maximal dimension of simplices to add to the output simplicial complex - seed : int or None (default) - The seed for the random number generator - - Returns - ------- - SimplicialComplex - - Notes - ----- - Computing all cliques quickly becomes heavy for large networks. - """ - - if (p < 0) or (p > 1): - raise ValueError("p must be between 0 and 1 included.") - - G = nx.fast_gnp_random_graph(N, p, seed=seed) - - nodes = G.nodes() - edges = list(G.edges()) - - # compute all triangles to fill - max_cliques = list(nx.find_cliques(G)) - - S = SimplicialComplex() - S.add_nodes_from(nodes) - S.add_simplices_from(max_cliques, max_order=max_order) - - return S - - def watts_strogatz_hypergraph(n, d, k, l, p, seed=None): if seed is not None: np.random.seed(seed) diff --git a/xgi/generators/simple.py b/xgi/generators/simple.py new file mode 100644 index 000000000..35459e26b --- /dev/null +++ b/xgi/generators/simple.py @@ -0,0 +1,118 @@ +"""Generators for some simple hypergraphs. + +All the functions in this module return a Hypergraph class (i.e. a simple, undirected +hypergraph). + +""" +from itertools import combinations + +from ..exception import XGIError +from .classic import empty_hypergraph + +__all__ = [ + "star_clique", + "sunflower", +] + + +def star_clique(n_star, n_clique, d_max): + """Generate a star-clique structure + + That is a star network and a clique network, + connected by one pairwise edge connecting the centre of the star to the clique. + network, the each clique is promoted to a hyperedge + up to order d_max. + + Parameters + ---------- + n_star : int + Number of legs of the star + n_clique : int + Number of nodes in the clique + d_max : int + Maximum order up to which to promote + cliques to hyperedges + + Returns + ------- + H : Hypergraph + + Examples + -------- + >>> import xgi + >>> H = xgi.star_clique(6, 7, 2) + + Notes + ----- + The total number of nodes is n_star + n_clique. + + """ + + if n_star <= 0: + raise ValueError("n_star must be an integer > 0.") + if n_clique <= 0: + raise ValueError("n_clique must be an integer > 0.") + if d_max < 0: + raise ValueError("d_max must be an integer >= 0.") + elif d_max > n_clique - 1: + raise ValueError("d_max must be <= n_clique - 1.") + + nodes_star = range(n_star) + nodes_clique = range(n_star, n_star + n_clique) + nodes = list(nodes_star) + list(nodes_clique) + + H = empty_hypergraph() + H.add_nodes_from(nodes) + + # add star edges (center of the star is 0-th node) + H.add_edges_from([(nodes_star[0], nodes_star[i]) for i in range(1, n_star)]) + + # connect clique and star by adding last star leg + H.add_edge((nodes_star[0], nodes_clique[0])) + + # add clique hyperedges up to order d_max + H.add_edges_from( + [e for d in range(1, d_max + 1) for e in combinations(nodes_clique, d + 1)] + ) + + return H + + +def sunflower(l, c, m): + """Create a sunflower hypergraph. + + This creates an m-uniform hypergraph + according to the sunflower model. + + Parameters + ---------- + l : int + Number of petals + c : int + Size of the core + m : int + Size of each edge + + Raises + ------ + XGIError + If the edge size is smaller than the core. + + Returns + ------- + + """ + from ..classes import Hypergraph + + if m < c: + raise XGIError("m cannot be smaller than c.") + + core_nodes = list(range(c)) + + H = Hypergraph() + start_label = c + while start_label + (m - c) <= c + (m - c) * l: + H.add_edge(core_nodes + [start_label + i for i in range(m - c)]) + start_label = start_label + (m - c) + + return H diff --git a/xgi/generators/simplicial_complexes.py b/xgi/generators/simplicial_complexes.py new file mode 100644 index 000000000..487ba3ff7 --- /dev/null +++ b/xgi/generators/simplicial_complexes.py @@ -0,0 +1,289 @@ +"""Generators for some simplicial complexes. + +All the functions in this module return a SimplicialComplex class. + +""" + +import random +from collections import defaultdict +from itertools import combinations + +import networkx as nx +import numpy as np +from scipy.special import comb + +from ..classes import SimplicialComplex +from ..classes.function import subfaces +from ..utils.utilities import find_triangles + +__all__ = [ + "random_simplicial_complex", + "random_flag_complex_d2", + "random_flag_complex", + "flag_complex", + "flag_complex_d2", +] + + +def random_simplicial_complex(N, ps, seed=None): + """Generates a random hypergraph + + Generate N nodes, and connect any d+1 nodes + by a simplex with probability ps[d-1]. For each simplex, + add all its subfaces if they do not already exist. + + Parameters + ---------- + N : int + Number of nodes + ps : list of float + List of probabilities (between 0 and 1) to create a + hyperedge at each order d between any d+1 nodes. For example, + ps[0] is the wiring probability of any edge (2 nodes), ps[1] + of any triangles (3 nodes). + seed : int or None (default) + The seed for the random number generator + + Returns + ------- + Simplicialcomplex object + The generated simplicial complex + + References + ---------- + Described as 'random simplicial complex' in + "Simplicial Models of Social Contagion", Nature Communications 10(1), 2485, + by I. Iacopini, G. Petri, A. Barrat & V. Latora (2019). + https://doi.org/10.1038/s41467-019-10431-6 + + Example + ------- + >>> import xgi + >>> H = xgi.random_simplicial_complex(20, [0.1, 0.01]) + + """ + + if seed is not None: + np.random.seed(seed) + + if (np.any(np.array(ps) < 0)) or (np.any(np.array(ps) > 1)): + raise ValueError("All elements of ps must be between 0 and 1 included.") + + nodes = range(N) + simplices = [] + + for i, p in enumerate(ps): + d = i + 1 # order, ps[0] is prob of edges (d=1) + + potential_simplices = combinations(nodes, d + 1) + n_comb = comb(N, d + 1, exact=True) + mask = np.random.random(size=n_comb) <= p # True if simplex to keep + + simplices_to_add = [e for e, val in zip(potential_simplices, mask) if val] + + simplices += simplices_to_add + + S = SimplicialComplex() + S.add_nodes_from(nodes) + S.add_simplices_from(simplices) + + return S + + +def flag_complex(G, max_order=2, ps=None, seed=None): + """Generate a flag (or clique) complex from a + NetworkX graph by filling all cliques up to dimension max_order. + + Parameters + ---------- + G : Networkx Graph + + max_order : int + maximal dimension of simplices to add to the output simplicial complex + ps: list of float + List of probabilities (between 0 and 1) to create a + hyperedge from a clique, at each order d. For example, + ps[0] is the probability of promoting any 3-node clique (triangle) to + a 3-hyperedge. + seed: int or None (default) + The seed for the random number generator + + Returns + ------- + S : SimplicialComplex + + Notes + ----- + Computing all cliques quickly becomes heavy for large networks. `flag_complex_d2` + is faster to compute up to order 2. + + See also + -------- + flag_complex_d2 + + """ + # This import needs to happen when this function is called, not when it is + # defined. Otherwise, a circular import error would happen. + from ..classes import SimplicialComplex + + if seed is not None: + random.seed(seed) + + nodes = G.nodes() + edges = G.edges() + + # compute all maximal cliques to fill + max_cliques = list(nx.find_cliques(G)) + + S = SimplicialComplex() + S.add_nodes_from(nodes) + S.add_simplices_from(edges) + if not ps: # promote all cliques + S.add_simplices_from(max_cliques, max_order=max_order) + return S + + if max_order: # compute subfaces of order max_order (allowed max cliques) + max_cliques_to_add = subfaces(max_cliques, order=max_order) + else: + max_cliques_to_add = max_cliques + + # store max cliques per order + cliques_d = defaultdict(list) + for x in max_cliques_to_add: + cliques_d[len(x)].append(x) + + # promote cliques with a given probability + for i, p in enumerate(ps[: max_order - 1]): + d = i + 2 # simplex order + cliques_d_to_add = [el for el in cliques_d[d + 1] if random.random() <= p] + S.add_simplices_from(cliques_d_to_add, max_order=max_order) + + return S + + +def flag_complex_d2(G, p2=None, seed=None): + """Generate a flag (or clique) complex from a + NetworkX graph by filling all cliques up to dimension 2. + + Parameters + ---------- + G : networkx Graph + Graph to consider + p2: float + Probability (between 0 and 1) of filling empty triangles in graph G + seed: int or None (default) + The seed for the random number generator + + Returns + ------- + S : xgi.SimplicialComplex + + Notes + ----- + Computing all cliques quickly becomes heavy for large networks. This + is faster than `flag_complex` to compute up to order 2. + + See also + -------- + flag_complex + """ + # This import needs to happen when this function is called, not when it is + # defined. Otherwise, a circular import error would happen. + from ..classes import SimplicialComplex + + if seed is not None: + random.seed(seed) + + nodes = G.nodes() + edges = G.edges() + + S = SimplicialComplex() + S.add_nodes_from(nodes) + S.add_simplices_from(edges) + + triangles_empty = find_triangles(G) + + if p2 is not None: + triangles = [el for el in triangles_empty if random.random() <= p2] + else: + triangles = triangles_empty + + S.add_simplices_from(triangles) + + return S + + +def random_flag_complex_d2(N, p, seed=None): + """Generate a maximal simplicial complex (up to order 2) from a + :math:`G_{N,p}` Erdős-Rényi random graph by filling all empty triangles with 2-simplices. + + Parameters + ---------- + N : int + Number of nodes + p : float + Probabilities (between 0 and 1) to create an edge + between any 2 nodes + seed : int or None (default) + The seed for the random number generator + + Returns + ------- + SimplicialComplex + + Notes + ----- + Computing all cliques quickly becomes heavy for large networks. + """ + if seed is not None: + random.seed(seed) + + if (p < 0) or (p > 1): + raise ValueError("p must be between 0 and 1 included.") + + G = nx.fast_gnp_random_graph(N, p, seed=seed) + + return flag_complex_d2(G) + + +def random_flag_complex(N, p, max_order=2, seed=None): + """Generate a flag (or clique) complex from a + :math:`G_{N,p}` Erdős-Rényi random graph by filling all cliques up to dimension max_order. + + Parameters + ---------- + N : int + Number of nodes + p : float + Probability (between 0 and 1) to create an edge + between any 2 nodes + max_order : int + maximal dimension of simplices to add to the output simplicial complex + seed : int or None (default) + The seed for the random number generator + + Returns + ------- + SimplicialComplex + + Notes + ----- + Computing all cliques quickly becomes heavy for large networks. + """ + + if (p < 0) or (p > 1): + raise ValueError("p must be between 0 and 1 included.") + + G = nx.fast_gnp_random_graph(N, p, seed=seed) + + nodes = G.nodes() + edges = list(G.edges()) + + # compute all triangles to fill + max_cliques = list(nx.find_cliques(G)) + + S = SimplicialComplex() + S.add_nodes_from(nodes) + S.add_simplices_from(max_cliques, max_order=max_order) + + return S diff --git a/xgi/generators/uniform.py b/xgi/generators/uniform.py index 8dd4090af..85950356a 100644 --- a/xgi/generators/uniform.py +++ b/xgi/generators/uniform.py @@ -372,4 +372,4 @@ def _index_to_edge_partition(index, partition_sizes, m): for r in range(m) ] except: - raise Exception("Invalid parameters") + raise Exception("Invalid parameters") \ No newline at end of file diff --git a/xgi/linalg/__init__.py b/xgi/linalg/__init__.py index 834cf9f03..40c16be7b 100644 --- a/xgi/linalg/__init__.py +++ b/xgi/linalg/__init__.py @@ -1,2 +1,6 @@ -from . import matrix -from .matrix import * +from . import hodge_matrix +from . import hypergraph_matrix +from . import laplacian_matrix +from .hodge_matrix import * +from .hypergraph_matrix import * +from .laplacian_matrix import * diff --git a/xgi/linalg/hodge_matrix.py b/xgi/linalg/hodge_matrix.py new file mode 100644 index 000000000..e0f5b2dfa --- /dev/null +++ b/xgi/linalg/hodge_matrix.py @@ -0,0 +1,199 @@ +"""Hodge theory matrices associated to hypergraphs. + +Note that the order of the rows and columns of the +matrices in this module correspond to the order in +which nodes/edges are added to the hypergraph or +simplicial complex. If the node and edge IDs are +able to be sorted, the following is an example to sort +by the node and edge IDs. + +>>> import xgi +>>> import pandas as pd +>>> H = xgi.Hypergraph([[1, 2, 3, 7], [4], [5, 6, 7]]) +>>> I, nodedict, edgedict = xgi.incidence_matrix(H, sparse=False, index=True) +>>> # Sorting the resulting numpy array: +>>> sortedI = I.copy() +>>> sortedI = sortedI[sorted(nodedict, key=nodedict.get), :] +>>> sortedI = sortedI[:, sorted(edgedict, key=edgedict.get)] +>>> sortedI +array([[1, 0, 0], + [1, 0, 0], + [1, 0, 0], + [0, 1, 0], + [0, 0, 1], + [0, 0, 1], + [1, 0, 1]]) +>>> # Indexing a Pandas dataframe by the node/edge IDs +>>> df = pd.DataFrame(I, index=nodedict.values(), columns=edgedict.values()) + +If the nodes are already sorted, this order can be preserved by adding the nodes +to the hypergraph prior to adding edges. For example, + +>>> import xgi +>>> H = xgi.Hypergraph() +>>> H.add_nodes_from(range(1, 8)) +>>> H.add_edges_from([[1, 2, 3, 7], [4], [5, 6, 7]]) +>>> xgi.incidence_matrix(H, sparse=False) +array([[1, 0, 0], + [1, 0, 0], + [1, 0, 0], + [0, 1, 0], + [0, 0, 1], + [0, 0, 1], + [1, 0, 1]]) + +""" +import numpy as np + +__all__ = [ + "boundary_matrix", + "hodge_laplacian", +] + + +def boundary_matrix(S, order=1, orientations=None, index=False): + """ + A function to generate the boundary matrices of an oriented simplicial complex. + The rows correspond to the (order-1)-simplices and the columns to the (order)-simplices. + + Parameters + ---------- + S: simplicial complex object + The simplicial complex of interest + order: int, default: 1 + Specifies the order of the boundary + matrix to compute + orientations: dict, default: None + Dictionary mapping non-singleton simplices + IDs to their boolean orientation + index: bool, default: False + Specifies whether to output dictionaries + mapping the simplices IDs to indices + + Returns + ------- + B: numpy.ndarray + The boundary matrix of the chosen order, has dimension + (n_simplices of given order - 1, n_simplices of given order) + rowdict: dict + The dictionary mapping indices to + (order-1)-simplices IDs, if index is True + coldict: dict + The dictionary mapping indices to + (order)-simplices IDs, if index is True + + References + ---------- + "Discrete Calculus" + by Leo J. Grady and Jonathan R. Polimeni + https://doi.org/10.1007/978-1-84996-290-2 + + """ + + # Extract the simplices involved + if order == 1: + simplices_d_ids = S.nodes + else: + simplices_d_ids = S.edges.filterby("order", order - 1) + + if order == 0: + simplices_u_ids = S.nodes + else: + simplices_u_ids = S.edges.filterby("order", order) + nd = len(simplices_d_ids) + nu = len(simplices_u_ids) + + simplices_d_dict = dict(zip(simplices_d_ids, range(nd))) + simplices_u_dict = dict(zip(simplices_u_ids, range(nu))) + + if index: + rowdict = {v: k for k, v in simplices_d_dict.items()} + coldict = {v: k for k, v in simplices_u_dict.items()} + + if orientations is None: + orientations = {idd: 0 for idd in S.edges.filterby("order", 1, mode="geq")} + + B = np.zeros((nd, nu)) + if not (nu == 0 or nd == 0): + if order == 1: + for u_simplex_id in simplices_u_ids: + u_simplex = list(S.edges.members(u_simplex_id)) + u_simplex.sort( + key=lambda e: (isinstance(e, str), e) + ) # Sort the simplex's vertices to get a reference orientation + # The key is needed to sort a mixed list of numbers and strings: + # it ensures that node labels which are numbers are put before strings, + # thus giving a list [sorted numbers, sorted strings] + matrix_id = simplices_u_dict[u_simplex_id] + head_idx = u_simplex[1] + tail_idx = u_simplex[0] + B[simplices_d_dict[head_idx], matrix_id] = (-1) ** orientations[ + u_simplex_id + ] + B[simplices_d_dict[tail_idx], matrix_id] = -( + (-1) ** orientations[u_simplex_id] + ) + else: + for u_simplex_id in simplices_u_ids: + u_simplex = list(S.edges.members(u_simplex_id)) + u_simplex.sort( + key=lambda e: (isinstance(e, str), e) + ) # Sort the simplex's vertices to get a reference orientation + # The key is needed to sort a mixed list of numbers and strings: + # it ensures that node labels which are numbers are put before strings, + # thus giving a list [sorted numbers, sorted strings] + matrix_id = simplices_u_dict[u_simplex_id] + u_simplex_subfaces = S._subfaces(u_simplex, all=False) + subfaces_induced_orientation = [ + (orientations[u_simplex_id] + order - i) % 2 + for i in range(order + 1) + ] + for count, subf in enumerate(u_simplex_subfaces): + subface_ID = list(S.edges)[S.edges.members().index(frozenset(subf))] + B[simplices_d_dict[subface_ID], matrix_id] = (-1) ** ( + subfaces_induced_orientation[count] + orientations[subface_ID] + ) + return (B, rowdict, coldict) if index else B + + +def hodge_laplacian(S, order=1, orientations=None, index=False): + """ + A function to compute the Hodge Laplacians of an oriented + simplicial complex. + + Parameters + ---------- + S: simplicial complex object + The simplicial complex of interest + order: int, default: 1 + Specifies the order of the Hodge + Laplacian matrix to be computed + orientations: dict, default: None + Dictionary mapping non-singleton simplices + IDs to their boolean orientation + index: bool, default: False + Specifies whether to output dictionaries + mapping the simplices IDs to indices + + Returns + ------- + L_o: numpy.ndarray + The Hodge Laplacian matrix of the chosen order, has dimension + (n_simplices of given order, n_simplices of given order) + matdict: dict + The dictionary mapping indices to + (order)-simplices IDs, if index is True + + """ + if index: + B_o, __, matdict = boundary_matrix(S, order, orientations, True) + else: + B_o = boundary_matrix(S, order, orientations, False) + D_om1 = np.transpose(B_o) + + B_op1 = boundary_matrix(S, order + 1, orientations, False) + D_o = np.transpose(B_op1) + + L_o = D_om1 @ B_o + B_op1 @ D_o + + return (L_o, matdict) if index else L_o diff --git a/xgi/linalg/hypergraph_matrix.py b/xgi/linalg/hypergraph_matrix.py new file mode 100644 index 000000000..0e1b479a7 --- /dev/null +++ b/xgi/linalg/hypergraph_matrix.py @@ -0,0 +1,289 @@ +"""General matrices associated to hypergraphs. + +Note that the order of the rows and columns of the +matrices in this module correspond to the order in +which nodes/edges are added to the hypergraph or +simplicial complex. If the node and edge IDs are +able to be sorted, the following is an example to sort +by the node and edge IDs. + +>>> import xgi +>>> import pandas as pd +>>> H = xgi.Hypergraph([[1, 2, 3, 7], [4], [5, 6, 7]]) +>>> I, nodedict, edgedict = xgi.incidence_matrix(H, sparse=False, index=True) +>>> # Sorting the resulting numpy array: +>>> sortedI = I.copy() +>>> sortedI = sortedI[sorted(nodedict, key=nodedict.get), :] +>>> sortedI = sortedI[:, sorted(edgedict, key=edgedict.get)] +>>> sortedI +array([[1, 0, 0], + [1, 0, 0], + [1, 0, 0], + [0, 1, 0], + [0, 0, 1], + [0, 0, 1], + [1, 0, 1]]) +>>> # Indexing a Pandas dataframe by the node/edge IDs +>>> df = pd.DataFrame(I, index=nodedict.values(), columns=edgedict.values()) + +If the nodes are already sorted, this order can be preserved by adding the nodes +to the hypergraph prior to adding edges. For example, + +>>> import xgi +>>> H = xgi.Hypergraph() +>>> H.add_nodes_from(range(1, 8)) +>>> H.add_edges_from([[1, 2, 3, 7], [4], [5, 6, 7]]) +>>> xgi.incidence_matrix(H, sparse=False) +array([[1, 0, 0], + [1, 0, 0], + [1, 0, 0], + [0, 1, 0], + [0, 0, 1], + [0, 0, 1], + [1, 0, 1]]) + +""" +from warnings import catch_warnings, warn + +import numpy as np +from scipy.sparse import csr_array + +__all__ = [ + "incidence_matrix", + "adjacency_matrix", + "intersection_profile", + "degree_matrix", + "clique_motif_matrix", +] + + +def incidence_matrix( + H, order=None, sparse=True, index=False, weight=lambda node, edge, H: 1 +): + """ + A function to generate a weighted incidence matrix from a Hypergraph object, + where the rows correspond to nodes and the columns correspond to edges. + + Parameters + ---------- + H: Hypergraph object + The hypergraph of interest + order: int, optional + Order of interactions to use. If None (default), all orders are used. If int, + must be >= 1. + sparse: bool, default: True + Specifies whether the output matrix is a scipy sparse matrix or a numpy matrix + index: bool, default: False + Specifies whether to output dictionaries mapping the node and edge IDs to indices + weight: lambda function, default=lambda function outputting 1 + A function specifying the weight, given a node and edge + + Returns + ------- + I: numpy.ndarray or scipy csr_array + The incidence matrix, has dimension (n_nodes, n_edges) + rowdict: dict + The dictionary mapping indices to node IDs, if index is True + coldict: dict + The dictionary mapping indices to edge IDs, if index is True + + """ + node_ids = H.nodes + edge_ids = H.edges + + if order is not None: + edge_ids = H.edges.filterby("order", order) + if not edge_ids or not node_ids: + if sparse: + I = csr_array((0, 0), dtype=int) + else: + I = np.empty((0, 0), dtype=int) + return (I, {}, {}) if index else I + + num_edges = len(edge_ids) + num_nodes = len(node_ids) + + node_dict = dict(zip(node_ids, range(num_nodes))) + edge_dict = dict(zip(edge_ids, range(num_edges))) + + if index: + rowdict = {v: k for k, v in node_dict.items()} + coldict = {v: k for k, v in edge_dict.items()} + + # Compute the non-zero values, row and column indices for the given order + rows = [] + cols = [] + data = [] + for edge in edge_ids: + members = H._edge[edge] + for node in members: + rows.append(node_dict[node]) + cols.append(edge_dict[edge]) + data.append(weight(node, edge, H)) + + # Create the incidence matrix as a CSR matrix + if sparse: + I = csr_array((data, (rows, cols)), shape=(num_nodes, num_edges), dtype=int) + else: + I = np.zeros((num_nodes, num_edges), dtype=int) + I[rows, cols] = data + + return (I, rowdict, coldict) if index else I + + +def adjacency_matrix(H, order=None, sparse=True, s=1, weighted=False, index=False): + """ + A function to generate an adjacency matrix (N,N) from a Hypergraph object. + + Parameters + ---------- + H: Hypergraph object + The hypergraph of interest + order: int, optional + Order of interactions to use. If None (default), all orders are used. If int, + must be >= 1. + sparse: bool, default: True + Specifies whether the output matrix is a scipy sparse matrix or a numpy matrix + s: int, default: 1 + Specifies the number of overlapping edges to be considered connected. + index: bool, default: False + Specifies whether to output disctionaries mapping the node IDs to indices + + Returns + ------- + if index is True: + return A, rowdict + else: + return A + + Warns + ----- + warn + If there are isolated nodes and the matrix is sparse. + + """ + I, rowdict, coldict = incidence_matrix(H, order=order, sparse=sparse, index=True) + + if I.shape == (0, 0): + if not rowdict: + A = csr_array((0, 0)) if sparse else np.empty((0, 0)) + if not coldict: + shape = (H.num_nodes, H.num_nodes) + A = csr_array(shape, dtype=int) if sparse else np.zeros(shape, dtype=int) + return (A, {}) if index else A + + A = I.dot(I.T) + + if sparse: + with catch_warnings(record=True) as w: + A.setdiag(0) + if w: + warn( + "Forming the adjacency matrix can be expensive when there are isolated nodes!" + ) + else: + np.fill_diagonal(A, 0) + + if not weighted: + A = (A >= s) * 1 + else: + A = (A >= s) * A + + if sparse: + A.eliminate_zeros() + + return (A, rowdict) if index else A + + +def intersection_profile(H, order=None, sparse=True, index=False): + """ + A function to generate an intersection profile from a Hypergraph object. + + Parameters + ---------- + H: Hypergraph object + The hypergraph of interest + order: int, optional + Order of interactions to use. If None (default), all orders are used. If int, + must be >= 1. + sparse: bool, default: True + Specifies whether the output matrix is a scipy sparse matrix or a numpy matrix + index: bool, default: False + Specifies whether to output dictionaries mapping the edge IDs to indices + + Returns + ------- + if index is True: + return P, rowdict, coldict + else: + return P + + """ + I, _, coldict = incidence_matrix(H, order=order, sparse=sparse, index=True) + P = I.T.dot(I) + return (P, coldict) if index else P + + +def degree_matrix(H, order=None, index=False): + """Returns the degree of each node as an array + + Parameters + ---------- + H: Hypergraph object + The hypergraph of interest + order: int, optional + Order of interactions to use. If None (default), all orders are used. If int, + must be >= 1. + index: bool, default: False + Specifies whether to output disctionaries mapping the node and edge IDs to indices + + Returns + ------- + if index is True: + return K, rowdict + else: + return K + + """ + I, rowdict, _ = incidence_matrix(H, order=order, index=True) + + if I.shape == (0, 0): + K = np.zeros(H.num_nodes) + else: + K = np.ravel(np.sum(I, axis=1)) # flatten + + return (K, rowdict) if index else K + + +def clique_motif_matrix(H, sparse=True, index=False): + """ + A function to generate a weighted clique motif matrix + from a Hypergraph object. + + Parameters + ---------- + H: Hypergraph object + The hypergraph of interest + sparse: bool, default: True + Specifies whether the output matrix is a scipy sparse matrix or a numpy matrix + index: bool, default: False + Specifies whether to output dictionaries + mapping the node and edge IDs to indices + + Returns + ------- + if index is True: + return W, rowdict + else: + return W + + References + ---------- + "Higher-order organization of complex networks" + by Austin Benson, David Gleich, and Jure Leskovic + https://doi.org/10.1126/science.aad9029 + + """ + W, rowdict = adjacency_matrix(H, sparse=sparse, weighted=True, index=True) + + return (W, rowdict) if index else W diff --git a/xgi/linalg/laplacian_matrix.py b/xgi/linalg/laplacian_matrix.py new file mode 100644 index 000000000..2f1fa9c73 --- /dev/null +++ b/xgi/linalg/laplacian_matrix.py @@ -0,0 +1,234 @@ +"""Laplacian matrices associated to hypergraphs. + +Note that the order of the rows and columns of the +matrices in this module correspond to the order in +which nodes/edges are added to the hypergraph or +simplicial complex. If the node and edge IDs are +able to be sorted, the following is an example to sort +by the node and edge IDs. + +>>> import xgi +>>> import pandas as pd +>>> H = xgi.Hypergraph([[1, 2, 3, 7], [4], [5, 6, 7]]) +>>> I, nodedict, edgedict = xgi.incidence_matrix(H, sparse=False, index=True) +>>> # Sorting the resulting numpy array: +>>> sortedI = I.copy() +>>> sortedI = sortedI[sorted(nodedict, key=nodedict.get), :] +>>> sortedI = sortedI[:, sorted(edgedict, key=edgedict.get)] +>>> sortedI +array([[1, 0, 0], + [1, 0, 0], + [1, 0, 0], + [0, 1, 0], + [0, 0, 1], + [0, 0, 1], + [1, 0, 1]]) +>>> # Indexing a Pandas dataframe by the node/edge IDs +>>> df = pd.DataFrame(I, index=nodedict.values(), columns=edgedict.values()) + +If the nodes are already sorted, this order can be preserved by adding the nodes +to the hypergraph prior to adding edges. For example, + +>>> import xgi +>>> H = xgi.Hypergraph() +>>> H.add_nodes_from(range(1, 8)) +>>> H.add_edges_from([[1, 2, 3, 7], [4], [5, 6, 7]]) +>>> xgi.incidence_matrix(H, sparse=False) +array([[1, 0, 0], + [1, 0, 0], + [1, 0, 0], + [0, 1, 0], + [0, 0, 1], + [0, 0, 1], + [1, 0, 1]]) + +""" +from warnings import warn + +import numpy as np +from scipy.sparse import csr_array, diags + +from ..exception import XGIError +from .hypergraph_matrix import adjacency_matrix, clique_motif_matrix, degree_matrix + +__all__ = [ + "laplacian", + "multiorder_laplacian", + "normalized_hypergraph_laplacian", +] + + +def laplacian(H, order=1, sparse=False, rescale_per_node=False, index=False): + """Laplacian matrix of order d, see [1]. + + Parameters + ---------- + HG : Hypergraph + Hypergraph + order : int + Order of interactions to consider. If order=1 (default), + returns the usual graph Laplacian + sparse: bool, default: False + Specifies whether the output matrix is a scipy sparse matrix or a numpy matrix + index: bool, default: False + Specifies whether to output disctionaries mapping the node and edge IDs to indices + + Returns + ------- + L_d : numpy array + Array of dim (N, N) + if index is True: + return rowdict + + See also + -------- + multiorder_laplacian + + References + ---------- + .. [1] Lucas, M., Cencetti, G., & Battiston, F. (2020). + Multiorder Laplacian for synchronization in higher-order networks. + Physical Review Research, 2(3), 033410. + + """ + A, row_dict = adjacency_matrix( + H, order=order, sparse=sparse, weighted=True, index=True + ) + + if A.shape == (0, 0): + L = csr_array((0, 0)) if sparse else np.empty((0, 0)) + return (L, {}) if index else L + + if sparse: + K = csr_array(diags(degree_matrix(H, order=order))) + else: + K = np.diag(degree_matrix(H, order=order)) + + L = order * K - A # ravel needed to convert sparse matrix + + if rescale_per_node: + L = L / order + + return (L, row_dict) if index else L + + +def multiorder_laplacian( + H, orders, weights, sparse=False, rescale_per_node=False, index=False +): + """Multiorder Laplacian matrix, see [1]. + + Parameters + ---------- + HG : Hypergraph + Hypergraph + orders : list of int + Orders of interactions to consider. + weights: list of float + Weights associated to each order, i.e coupling strengths gamma_i in [1]. + sparse: bool, default: False + Specifies whether the output matrix is a scipy sparse matrix or a numpy matrix + rescale_per_node: bool, (default=False) + Whether to rescale each Laplacian of order d by d (per node). + index: bool, default: False + Specifies whether to output dictionaries mapping the node and edge IDs to indices + + Returns + ------- + L_multi : numpy array + Array of dim (N, N) + if index is True: + return rowdict + + See also + -------- + laplacian + + References + ---------- + .. [1] Lucas, M., Cencetti, G., & Battiston, F. (2020). + Multiorder Laplacian for synchronization in higher-order networks. + Physical Review Research, 2(3), 033410. + + """ + if len(orders) != len(weights): + raise ValueError("orders and weights must have the same length.") + + Ls = [ + laplacian(H, order=d, sparse=sparse, rescale_per_node=rescale_per_node) + for d in orders + ] + Ks = [degree_matrix(H, order=d) for d in orders] + + if sparse: + L_multi = csr_array((H.num_nodes, H.num_nodes)) + else: + L_multi = np.zeros((H.num_nodes, H.num_nodes)) + + for L, K, w, d in zip(Ls, Ks, weights, orders): + if np.all(K == 0): + # avoid getting nans from dividing by 0 + # manually setting contribution to 0 as it should be + warn( + f"No edges of order {d}. Contribution of that order is zero. Its weight is effectively zero." + ) + else: + L_multi += L * w / np.mean(K) + + rowdict = {i: v for i, v in enumerate(H.nodes)} + + return (L_multi, rowdict) if index else L_multi + + +def normalized_hypergraph_laplacian(H, sparse=True, index=False): + """Compute the normalized Laplacian. + + Parameters + ---------- + H : Hypergraph + Hypergraph + sparse : bool, optional + whether or not the laplacian is sparse, by default True + index : bool, optional + whether to return a dictionary mapping IDs to rows, by default False + + Returns + ------- + array + csr_array if sparse and if not, a numpy ndarray + dict + a dictionary mapping node IDs to rows and columns + if index is True. + + + Raises + ------ + XGIError + If there are isolated nodes. + + References + ---------- + "Learning with Hypergraphs: Clustering, Classification, and Embedding" + by Dengyong Zhou, Jiayuan Huang, Bernhard Schölkopf + Advances in Neural Information Processing Systems (2006) + """ + + from ..algorithms import is_connected + + if H.nodes.isolates(): + raise XGIError( + "Every node must be a member of an edge to avoid divide by zero error!" + ) + + D = degree_matrix(H) + A, rowdict = clique_motif_matrix(H, sparse=sparse, index=True) + + if sparse: + Dinvsqrt = csr_array(diags(np.power(D, -0.5))) + I = csr_array((H.num_nodes, H.num_nodes)) + I.setdiag(1) + else: + Dinvsqrt = np.diag(np.power(D, -0.5)) + I = np.eye(H.num_nodes) + + L = 0.5 * (I - Dinvsqrt @ A @ Dinvsqrt) + return (L, rowdict) if index else L diff --git a/xgi/linalg/matrix.py b/xgi/linalg/matrix.py deleted file mode 100644 index 616444672..000000000 --- a/xgi/linalg/matrix.py +++ /dev/null @@ -1,620 +0,0 @@ -"""Matrices associated to hypergraphs. - -Note that the order of the rows and columns of the -matrices in this module correspond to the order in -which nodes/edges are added to the hypergraph or -simplicial complex. If the node and edge IDs are -able to be sorted, the following is an example to sort -by the node and edge IDs. - ->>> import xgi ->>> import pandas as pd ->>> H = xgi.Hypergraph([[1, 2, 3, 7], [4], [5, 6, 7]]) ->>> I, nodedict, edgedict = xgi.incidence_matrix(H, sparse=False, index=True) ->>> # Sorting the resulting numpy array: ->>> sortedI = I.copy() ->>> sortedI = sortedI[sorted(nodedict, key=nodedict.get), :] ->>> sortedI = sortedI[:, sorted(edgedict, key=edgedict.get)] ->>> sortedI -array([[1, 0, 0], - [1, 0, 0], - [1, 0, 0], - [0, 1, 0], - [0, 0, 1], - [0, 0, 1], - [1, 0, 1]]) ->>> # Indexing a Pandas dataframe by the node/edge IDs ->>> df = pd.DataFrame(I, index=nodedict.values(), columns=edgedict.values()) - -If the nodes are already sorted, this order can be preserved by adding the nodes -to the hypergraph prior to adding edges. For example, - ->>> import xgi ->>> H = xgi.Hypergraph() ->>> H.add_nodes_from(range(1, 8)) ->>> H.add_edges_from([[1, 2, 3, 7], [4], [5, 6, 7]]) ->>> xgi.incidence_matrix(H, sparse=False) -array([[1, 0, 0], - [1, 0, 0], - [1, 0, 0], - [0, 1, 0], - [0, 0, 1], - [0, 0, 1], - [1, 0, 1]]) - -""" -from warnings import catch_warnings, warn - -import numpy as np -from scipy.sparse import csr_array, diags - -from ..exception import XGIError - -__all__ = [ - "incidence_matrix", - "adjacency_matrix", - "intersection_profile", - "degree_matrix", - "laplacian", - "multiorder_laplacian", - "normalized_hypergraph_laplacian", - "clique_motif_matrix", - "boundary_matrix", - "hodge_laplacian", -] - - -def incidence_matrix( - H, order=None, sparse=True, index=False, weight=lambda node, edge, H: 1 -): - """ - A function to generate a weighted incidence matrix from a Hypergraph object, - where the rows correspond to nodes and the columns correspond to edges. - - Parameters - ---------- - H: Hypergraph object - The hypergraph of interest - order: int, optional - Order of interactions to use. If None (default), all orders are used. If int, - must be >= 1. - sparse: bool, default: True - Specifies whether the output matrix is a scipy sparse matrix or a numpy matrix - index: bool, default: False - Specifies whether to output dictionaries mapping the node and edge IDs to indices - weight: lambda function, default=lambda function outputting 1 - A function specifying the weight, given a node and edge - - Returns - ------- - I: numpy.ndarray or scipy csr_array - The incidence matrix, has dimension (n_nodes, n_edges) - rowdict: dict - The dictionary mapping indices to node IDs, if index is True - coldict: dict - The dictionary mapping indices to edge IDs, if index is True - - """ - node_ids = H.nodes - edge_ids = H.edges - - if order is not None: - edge_ids = H.edges.filterby("order", order) - if not edge_ids or not node_ids: - if sparse: - I = csr_array((0, 0), dtype=int) - else: - I = np.empty((0, 0), dtype=int) - return (I, {}, {}) if index else I - - num_edges = len(edge_ids) - num_nodes = len(node_ids) - - node_dict = dict(zip(node_ids, range(num_nodes))) - edge_dict = dict(zip(edge_ids, range(num_edges))) - - if index: - rowdict = {v: k for k, v in node_dict.items()} - coldict = {v: k for k, v in edge_dict.items()} - - # Compute the non-zero values, row and column indices for the given order - rows = [] - cols = [] - data = [] - for edge in edge_ids: - members = H._edge[edge] - for node in members: - rows.append(node_dict[node]) - cols.append(edge_dict[edge]) - data.append(weight(node, edge, H)) - - # Create the incidence matrix as a CSR matrix - if sparse: - I = csr_array((data, (rows, cols)), shape=(num_nodes, num_edges), dtype=int) - else: - I = np.zeros((num_nodes, num_edges), dtype=int) - I[rows, cols] = data - - return (I, rowdict, coldict) if index else I - - -def adjacency_matrix(H, order=None, sparse=True, s=1, weighted=False, index=False): - """ - A function to generate an adjacency matrix (N,N) from a Hypergraph object. - - Parameters - ---------- - H: Hypergraph object - The hypergraph of interest - order: int, optional - Order of interactions to use. If None (default), all orders are used. If int, - must be >= 1. - sparse: bool, default: True - Specifies whether the output matrix is a scipy sparse matrix or a numpy matrix - s: int, default: 1 - Specifies the number of overlapping edges to be considered connected. - index: bool, default: False - Specifies whether to output disctionaries mapping the node IDs to indices - - Returns - ------- - if index is True: - return A, rowdict - else: - return A - - Warns - ----- - warn - If there are isolated nodes and the matrix is sparse. - - """ - I, rowdict, coldict = incidence_matrix(H, order=order, sparse=sparse, index=True) - - if I.shape == (0, 0): - if not rowdict: - A = csr_array((0, 0)) if sparse else np.empty((0, 0)) - if not coldict: - shape = (H.num_nodes, H.num_nodes) - A = csr_array(shape, dtype=int) if sparse else np.zeros(shape, dtype=int) - return (A, {}) if index else A - - A = I.dot(I.T) - - if sparse: - with catch_warnings(record=True) as w: - A.setdiag(0) - if w: - warn( - "Forming the adjacency matrix can be expensive when there are isolated nodes!" - ) - else: - np.fill_diagonal(A, 0) - - if not weighted: - A = (A >= s) * 1 - else: - A = (A >= s) * A - - if sparse: - A.eliminate_zeros() - - return (A, rowdict) if index else A - - -def intersection_profile(H, order=None, sparse=True, index=False): - """ - A function to generate an intersection profile from a Hypergraph object. - - Parameters - ---------- - H: Hypergraph object - The hypergraph of interest - order: int, optional - Order of interactions to use. If None (default), all orders are used. If int, - must be >= 1. - sparse: bool, default: True - Specifies whether the output matrix is a scipy sparse matrix or a numpy matrix - index: bool, default: False - Specifies whether to output dictionaries mapping the edge IDs to indices - - Returns - ------- - if index is True: - return P, rowdict, coldict - else: - return P - - """ - I, _, coldict = incidence_matrix(H, order=order, sparse=sparse, index=True) - P = I.T.dot(I) - return (P, coldict) if index else P - - -def degree_matrix(H, order=None, index=False): - """Returns the degree of each node as an array - - Parameters - ---------- - H: Hypergraph object - The hypergraph of interest - order: int, optional - Order of interactions to use. If None (default), all orders are used. If int, - must be >= 1. - index: bool, default: False - Specifies whether to output disctionaries mapping the node and edge IDs to indices - - Returns - ------- - if index is True: - return K, rowdict - else: - return K - - """ - I, rowdict, _ = incidence_matrix(H, order=order, index=True) - - if I.shape == (0, 0): - K = np.zeros(H.num_nodes) - else: - K = np.ravel(np.sum(I, axis=1)) # flatten - - return (K, rowdict) if index else K - - -def laplacian(H, order=1, sparse=False, rescale_per_node=False, index=False): - """Laplacian matrix of order d, see [1]. - - Parameters - ---------- - HG : Hypergraph - Hypergraph - order : int - Order of interactions to consider. If order=1 (default), - returns the usual graph Laplacian - sparse: bool, default: False - Specifies whether the output matrix is a scipy sparse matrix or a numpy matrix - index: bool, default: False - Specifies whether to output disctionaries mapping the node and edge IDs to indices - - Returns - ------- - L_d : numpy array - Array of dim (N, N) - if index is True: - return rowdict - - See also - -------- - multiorder_laplacian - - References - ---------- - .. [1] Lucas, M., Cencetti, G., & Battiston, F. (2020). - Multiorder Laplacian for synchronization in higher-order networks. - Physical Review Research, 2(3), 033410. - - """ - A, row_dict = adjacency_matrix( - H, order=order, sparse=sparse, weighted=True, index=True - ) - - if A.shape == (0, 0): - L = csr_array((0, 0)) if sparse else np.empty((0, 0)) - return (L, {}) if index else L - - if sparse: - K = csr_array(diags(degree_matrix(H, order=order))) - else: - K = np.diag(degree_matrix(H, order=order)) - - L = order * K - A # ravel needed to convert sparse matrix - - if rescale_per_node: - L = L / order - - return (L, row_dict) if index else L - - -def multiorder_laplacian( - H, orders, weights, sparse=False, rescale_per_node=False, index=False -): - """Multiorder Laplacian matrix, see [1]. - - Parameters - ---------- - HG : Hypergraph - Hypergraph - orders : list of int - Orders of interactions to consider. - weights: list of float - Weights associated to each order, i.e coupling strengths gamma_i in [1]. - sparse: bool, default: False - Specifies whether the output matrix is a scipy sparse matrix or a numpy matrix - rescale_per_node: bool, (default=False) - Whether to rescale each Laplacian of order d by d (per node). - index: bool, default: False - Specifies whether to output dictionaries mapping the node and edge IDs to indices - - Returns - ------- - L_multi : numpy array - Array of dim (N, N) - if index is True: - return rowdict - - See also - -------- - laplacian - - References - ---------- - .. [1] Lucas, M., Cencetti, G., & Battiston, F. (2020). - Multiorder Laplacian for synchronization in higher-order networks. - Physical Review Research, 2(3), 033410. - - """ - if len(orders) != len(weights): - raise ValueError("orders and weights must have the same length.") - - Ls = [ - laplacian(H, order=d, sparse=sparse, rescale_per_node=rescale_per_node) - for d in orders - ] - Ks = [degree_matrix(H, order=d) for d in orders] - - if sparse: - L_multi = csr_array((H.num_nodes, H.num_nodes)) - else: - L_multi = np.zeros((H.num_nodes, H.num_nodes)) - - for L, K, w, d in zip(Ls, Ks, weights, orders): - if np.all(K == 0): - # avoid getting nans from dividing by 0 - # manually setting contribution to 0 as it should be - warn( - f"No edges of order {d}. Contribution of that order is zero. Its weight is effectively zero." - ) - else: - L_multi += L * w / np.mean(K) - - rowdict = {i: v for i, v in enumerate(H.nodes)} - - return (L_multi, rowdict) if index else L_multi - - -def normalized_hypergraph_laplacian(H, sparse=True, index=False): - """Compute the normalized Laplacian. - - Parameters - ---------- - H : Hypergraph - Hypergraph - sparse : bool, optional - whether or not the laplacian is sparse, by default True - index : bool, optional - whether to return a dictionary mapping IDs to rows, by default False - - Returns - ------- - array - csr_array if sparse and if not, a numpy ndarray - dict - a dictionary mapping node IDs to rows and columns - if index is True. - - - Raises - ------ - XGIError - If there are isolated nodes. - - References - ---------- - "Learning with Hypergraphs: Clustering, Classification, and Embedding" - by Dengyong Zhou, Jiayuan Huang, Bernhard Schölkopf - Advances in Neural Information Processing Systems (2006) - """ - - from ..algorithms import is_connected - - if H.nodes.isolates(): - raise XGIError( - "Every node must be a member of an edge to avoid divide by zero error!" - ) - - D = degree_matrix(H) - A, rowdict = clique_motif_matrix(H, sparse=sparse, index=True) - - if sparse: - Dinvsqrt = csr_array(diags(np.power(D, -0.5))) - I = csr_array((H.num_nodes, H.num_nodes)) - I.setdiag(1) - else: - Dinvsqrt = np.diag(np.power(D, -0.5)) - I = np.eye(H.num_nodes) - - L = 0.5 * (I - Dinvsqrt @ A @ Dinvsqrt) - return (L, rowdict) if index else L - - -def clique_motif_matrix(H, sparse=True, index=False): - """ - A function to generate a weighted clique motif matrix - from a Hypergraph object. - - Parameters - ---------- - H: Hypergraph object - The hypergraph of interest - sparse: bool, default: True - Specifies whether the output matrix is a scipy sparse matrix or a numpy matrix - index: bool, default: False - Specifies whether to output dictionaries - mapping the node and edge IDs to indices - - Returns - ------- - if index is True: - return W, rowdict - else: - return W - - References - ---------- - "Higher-order organization of complex networks" - by Austin Benson, David Gleich, and Jure Leskovic - https://doi.org/10.1126/science.aad9029 - - """ - W, rowdict = adjacency_matrix(H, sparse=sparse, weighted=True, index=True) - - return (W, rowdict) if index else W - - -def boundary_matrix(S, order=1, orientations=None, index=False): - """ - A function to generate the boundary matrices of an oriented simplicial complex. - The rows correspond to the (order-1)-simplices and the columns to the (order)-simplices. - - Parameters - ---------- - S: simplicial complex object - The simplicial complex of interest - order: int, default: 1 - Specifies the order of the boundary - matrix to compute - orientations: dict, default: None - Dictionary mapping non-singleton simplices - IDs to their boolean orientation - index: bool, default: False - Specifies whether to output dictionaries - mapping the simplices IDs to indices - - Returns - ------- - B: numpy.ndarray - The boundary matrix of the chosen order, has dimension - (n_simplices of given order - 1, n_simplices of given order) - rowdict: dict - The dictionary mapping indices to - (order-1)-simplices IDs, if index is True - coldict: dict - The dictionary mapping indices to - (order)-simplices IDs, if index is True - - References - ---------- - "Discrete Calculus" - by Leo J. Grady and Jonathan R. Polimeni - https://doi.org/10.1007/978-1-84996-290-2 - - """ - - # Extract the simplices involved - if order == 1: - simplices_d_ids = S.nodes - else: - simplices_d_ids = S.edges.filterby("order", order - 1) - - if order == 0: - simplices_u_ids = S.nodes - else: - simplices_u_ids = S.edges.filterby("order", order) - nd = len(simplices_d_ids) - nu = len(simplices_u_ids) - - simplices_d_dict = dict(zip(simplices_d_ids, range(nd))) - simplices_u_dict = dict(zip(simplices_u_ids, range(nu))) - - if index: - rowdict = {v: k for k, v in simplices_d_dict.items()} - coldict = {v: k for k, v in simplices_u_dict.items()} - - if orientations is None: - orientations = {idd: 0 for idd in S.edges.filterby("order", 1, mode="geq")} - - B = np.zeros((nd, nu)) - if not (nu == 0 or nd == 0): - if order == 1: - for u_simplex_id in simplices_u_ids: - u_simplex = list(S.edges.members(u_simplex_id)) - u_simplex.sort( - key=lambda e: (isinstance(e, str), e) - ) # Sort the simplex's vertices to get a reference orientation - # The key is needed to sort a mixed list of numbers and strings: - # it ensures that node labels which are numbers are put before strings, - # thus giving a list [sorted numbers, sorted strings] - matrix_id = simplices_u_dict[u_simplex_id] - head_idx = u_simplex[1] - tail_idx = u_simplex[0] - B[simplices_d_dict[head_idx], matrix_id] = (-1) ** orientations[ - u_simplex_id - ] - B[simplices_d_dict[tail_idx], matrix_id] = -( - (-1) ** orientations[u_simplex_id] - ) - else: - for u_simplex_id in simplices_u_ids: - u_simplex = list(S.edges.members(u_simplex_id)) - u_simplex.sort( - key=lambda e: (isinstance(e, str), e) - ) # Sort the simplex's vertices to get a reference orientation - # The key is needed to sort a mixed list of numbers and strings: - # it ensures that node labels which are numbers are put before strings, - # thus giving a list [sorted numbers, sorted strings] - matrix_id = simplices_u_dict[u_simplex_id] - u_simplex_subfaces = S._subfaces(u_simplex, all=False) - subfaces_induced_orientation = [ - (orientations[u_simplex_id] + order - i) % 2 - for i in range(order + 1) - ] - for count, subf in enumerate(u_simplex_subfaces): - subface_ID = list(S.edges)[S.edges.members().index(frozenset(subf))] - B[simplices_d_dict[subface_ID], matrix_id] = (-1) ** ( - subfaces_induced_orientation[count] + orientations[subface_ID] - ) - return (B, rowdict, coldict) if index else B - - -def hodge_laplacian(S, order=1, orientations=None, index=False): - """ - A function to compute the Hodge Laplacians of an oriented - simplicial complex. - - Parameters - ---------- - S: simplicial complex object - The simplicial complex of interest - order: int, default: 1 - Specifies the order of the Hodge - Laplacian matrix to be computed - orientations: dict, default: None - Dictionary mapping non-singleton simplices - IDs to their boolean orientation - index: bool, default: False - Specifies whether to output dictionaries - mapping the simplices IDs to indices - - Returns - ------- - L_o: numpy.ndarray - The Hodge Laplacian matrix of the chosen order, has dimension - (n_simplices of given order, n_simplices of given order) - matdict: dict - The dictionary mapping indices to - (order)-simplices IDs, if index is True - - """ - if index: - B_o, __, matdict = boundary_matrix(S, order, orientations, True) - else: - B_o = boundary_matrix(S, order, orientations, False) - D_om1 = np.transpose(B_o) - - B_op1 = boundary_matrix(S, order + 1, orientations, False) - D_o = np.transpose(B_op1) - - L_o = D_om1 @ B_o + B_op1 @ D_o - - return (L_o, matdict) if index else L_o diff --git a/xgi/readwrite/xgi_data.py b/xgi/readwrite/xgi_data.py index d23beb472..a99488817 100644 --- a/xgi/readwrite/xgi_data.py +++ b/xgi/readwrite/xgi_data.py @@ -1,3 +1,4 @@ +"""Load a data set from the xgi-data repository or a local file.""" import json import os from functools import lru_cache